feat(tauri-app): Notifications-View mit Badge, Follower-Listen, DID-Navigation
Bindet die neuen AppView-Endpoints an: sechs IPC-Commands
(fetch_notifications, notification_count, mark_notifications_seen,
fetch_followers, fetch_following, fetch_thread) plus profile_get_by_did,
dazu die TS-Gegenstücke.
* NotificationsView: Liste mit Icon/Text je Art, Avatar, Vorschau des
subject_text, Cursor-Pagination; Klick auf eine Zeile mit Subject öffnet
den Thread. Beim Öffnen wird mit dem indexed_at der obersten Zeile als
Wasserzeichen quittiert.
* NavRail: Eintrag mit Unread-Badge, gepollt im vorhandenen 5s-Timer und
über den bestehenden Teardown-Pfad abgeräumt; der Poll pausiert, solange
die Liste offen ist.
* ProfileView: Follower- und Following-Zahlen sind jetzt Buttons und öffnen
die jeweilige Liste inline.
Navigiert wird über die DID, nicht über den Handle: für Actors, die weder
profiles noch posts kennen, liefert die AppView einen abgeschnittenen
Platzhalter im handle-Feld ("did:plc:abcd…"), und ein Klick darauf landete
in einem synthetischen Leerprofil. Die echte DID steht im DTO und wird jetzt
explizit durchgereicht — keine Heuristik auf das Platzhalter-Format.
Dabei aufgefallen und mitgefixt: ProfileView lud nur in onMount, obwohl die
Komponente bei Profil-zu-Profil-Navigation gemountet bleibt — Posts und
Zahlen des vorherigen Nutzers wären unter dem neuen Namen stehengeblieben.
Jetzt ein auf (did, handle) gekeyter Effekt.
Das Thread-Overlay hing im else-Zweig der leeren Timeline und wäre aus dem
Notifications-View unsichtbar gewesen; es ist jetzt ein Snippet, das beide
Views rendern.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013HC9HLrUU1LNwkzp8nkDLX
This commit is contained in:
co-authored by
Claude Opus 5
parent
bce4c7862f
commit
31880e1005
@@ -77,6 +77,104 @@ pub struct ThreadView {
|
||||
pub root: Option<PostDto>,
|
||||
}
|
||||
|
||||
/// `GET /api/thread?uri=…` response — the *full* thread shape (the
|
||||
/// whole ancestor chain plus the direct replies), as opposed to the
|
||||
/// narrower `{ post, thread: { parent, root } }` of
|
||||
/// [`ThreadResponse`]. Both come from the same server-side thread
|
||||
/// walk, so they can never disagree about who the parent is.
|
||||
///
|
||||
/// `parents` is ordered root-first, `replies` oldest-first. The
|
||||
/// `viewer_*` flags are only populated when a `viewer_did` was passed;
|
||||
/// `None` means "unknown", not "false".
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadFullResponse {
|
||||
pub post: Option<PostDto>,
|
||||
#[serde(default)]
|
||||
pub parents: Vec<PostDto>,
|
||||
#[serde(default)]
|
||||
pub root: Option<PostDto>,
|
||||
#[serde(default)]
|
||||
pub replies: Vec<PostDto>,
|
||||
#[serde(default)]
|
||||
pub like_count: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub repost_count: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub viewer_liked: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub viewer_reposted: Option<bool>,
|
||||
}
|
||||
|
||||
/// One hydrated row of `GET /api/notifications`.
|
||||
///
|
||||
/// `kind` is `"like" | "repost" | "follow" | "reply"` — kept as a
|
||||
/// `String` rather than an enum so an unknown kind added server-side
|
||||
/// deserialises instead of failing the whole page; the frontend has
|
||||
/// the same fallback.
|
||||
///
|
||||
/// `read_at` is `None` while the notification is unread — that's the
|
||||
/// state the tray badge keys off.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationDto {
|
||||
pub id: i64,
|
||||
pub kind: String,
|
||||
pub author_did: String,
|
||||
pub author_handle: String,
|
||||
#[serde(default)]
|
||||
pub author_display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub author_avatar_cid: Option<String>,
|
||||
/// `null` for `"follow"`; the recipient's own post for
|
||||
/// `"like"`/`"repost"`; the reply itself for `"reply"`.
|
||||
#[serde(default)]
|
||||
pub subject_uri: Option<String>,
|
||||
#[serde(default)]
|
||||
pub subject_text: Option<String>,
|
||||
pub created_at: String,
|
||||
/// The list's sort key, and the value the client echoes back as
|
||||
/// `seen_at` when marking the page read.
|
||||
pub indexed_at: String,
|
||||
#[serde(default)]
|
||||
pub read_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationsResponse {
|
||||
pub notifications: Vec<NotificationDto>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationCountResponse {
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationSeenResponse {
|
||||
pub ok: bool,
|
||||
pub updated: i64,
|
||||
}
|
||||
|
||||
/// A minimal profile card as returned by `GET /api/followers` and
|
||||
/// `GET /api/following`. Deliberately not [`ProfileResponse`] — a page
|
||||
/// of 30 followers would otherwise be 30 post queries server-side.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActorProfileDto {
|
||||
pub did: String,
|
||||
/// Without a leading `@` (the UI renders `@{handle}`).
|
||||
pub handle: String,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub avatar_cid: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ActorListResponse {
|
||||
pub profiles: Vec<ActorProfileDto>,
|
||||
pub cursor: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppViewClient {
|
||||
pub base_url: String,
|
||||
@@ -238,6 +336,196 @@ impl AppViewClient {
|
||||
.await
|
||||
.context("appview: post JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/thread?uri=…&viewer_did=…` — the full thread: the
|
||||
/// whole ancestor chain (root first) plus the direct replies
|
||||
/// (oldest first).
|
||||
///
|
||||
/// We use the query-param spelling rather than
|
||||
/// `/api/thread/<uri>` because the `at://` URI survives a
|
||||
/// query-string round trip without the manual percent-encoding
|
||||
/// [`Self::fetch_post`] needs — `reqwest`'s `.query()` does the
|
||||
/// escaping itself.
|
||||
pub async fn fetch_thread(
|
||||
&self,
|
||||
uri: &str,
|
||||
viewer_did: Option<&str>,
|
||||
) -> Result<ThreadFullResponse> {
|
||||
let mut req = self
|
||||
.client
|
||||
.get(format!("{}/api/thread", self.base_url))
|
||||
.query(&[("uri", uri)]);
|
||||
if let Some(v) = viewer_did {
|
||||
req = req.query(&[("viewer_did", v)]);
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send thread request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: thread returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<ThreadFullResponse>()
|
||||
.await
|
||||
.context("appview: thread JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/notifications?did=&limit=&cursor=` — newest first,
|
||||
/// same opaque-cursor pagination contract as the timeline.
|
||||
pub async fn fetch_notifications(
|
||||
&self,
|
||||
did: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<NotificationsResponse> {
|
||||
let mut req = self
|
||||
.client
|
||||
.get(format!("{}/api/notifications", self.base_url))
|
||||
.query(&[("did", did), ("limit", &limit.to_string())]);
|
||||
if let Some(c) = cursor {
|
||||
req = req.query(&[("cursor", c)]);
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send notifications request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: notifications returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<NotificationsResponse>()
|
||||
.await
|
||||
.context("appview: notifications JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/notifications/count?did=` — unread count for the
|
||||
/// NavRail badge. Cheap enough to poll (partial index on the
|
||||
/// server side).
|
||||
pub async fn notification_count(&self, did: &str) -> Result<NotificationCountResponse> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(format!("{}/api/notifications/count", self.base_url))
|
||||
.query(&[("did", did)])
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send notification-count request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: notification count returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<NotificationCountResponse>()
|
||||
.await
|
||||
.context("appview: notification count JSON parse")
|
||||
}
|
||||
|
||||
/// `POST /api/notifications/seen` — mark everything indexed at or
|
||||
/// before `seen_at` as read. Passing `None` marks *all* currently
|
||||
/// unread rows. Idempotent; a second call reports `updated: 0`.
|
||||
///
|
||||
/// The server accepts both `seenAt` and `seen_at`; we send the
|
||||
/// camelCase spelling because that's what the wire contract
|
||||
/// documents.
|
||||
pub async fn mark_notifications_seen(
|
||||
&self,
|
||||
did: &str,
|
||||
seen_at: Option<&str>,
|
||||
) -> Result<NotificationSeenResponse> {
|
||||
let mut body = serde_json::json!({ "did": did });
|
||||
if let Some(ts) = seen_at {
|
||||
body["seenAt"] = Value::String(ts.to_string());
|
||||
}
|
||||
let resp = self
|
||||
.client
|
||||
.post(format!("{}/api/notifications/seen", self.base_url))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.context("appview: failed to send notifications-seen request")?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"appview: notifications seen returned {}: {}",
|
||||
status,
|
||||
body
|
||||
));
|
||||
}
|
||||
resp
|
||||
.json::<NotificationSeenResponse>()
|
||||
.await
|
||||
.context("appview: notifications seen JSON parse")
|
||||
}
|
||||
|
||||
/// `GET /api/followers?did=&limit=&cursor=`
|
||||
pub async fn fetch_followers(
|
||||
&self,
|
||||
did: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<ActorListResponse> {
|
||||
self.actor_list("followers", did, cursor, limit).await
|
||||
}
|
||||
|
||||
/// `GET /api/following?did=&limit=&cursor=`
|
||||
pub async fn fetch_following(
|
||||
&self,
|
||||
did: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<ActorListResponse> {
|
||||
self.actor_list("following", did, cursor, limit).await
|
||||
}
|
||||
|
||||
/// Shared body of [`Self::fetch_followers`] and
|
||||
/// [`Self::fetch_following`] — the two endpoints have an identical
|
||||
/// request and response shape and differ only in the path segment.
|
||||
async fn actor_list(
|
||||
&self,
|
||||
path: &str,
|
||||
did: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: u32,
|
||||
) -> Result<ActorListResponse> {
|
||||
let mut req = self
|
||||
.client
|
||||
.get(format!("{}/api/{}", self.base_url, path))
|
||||
.query(&[("did", did), ("limit", &limit.to_string())]);
|
||||
if let Some(c) = cursor {
|
||||
req = req.query(&[("cursor", c)]);
|
||||
}
|
||||
let resp = req
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("appview: failed to send {path} request"))?;
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("appview: {} returned {}: {}", path, status, body));
|
||||
}
|
||||
resp
|
||||
.json::<ActorListResponse>()
|
||||
.await
|
||||
.with_context(|| format!("appview: {path} JSON parse"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode every byte of `s` for use as a URL path segment.
|
||||
|
||||
@@ -381,6 +381,29 @@ async fn profile_get(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `profile_get_by_did(did)` — resolve a profile by DID rather than
|
||||
/// by handle (`GET /api/profile?did=…`).
|
||||
///
|
||||
/// This is the *only* correct lookup for an actor that came out of a
|
||||
/// notification or a follower list: the AppView synthesises a
|
||||
/// placeholder `handle` (a truncated DID, see `short_did_bare`) for
|
||||
/// actors it has neither a `profiles` row nor an indexed post for, and
|
||||
/// feeding that placeholder back into the handle lookup matches
|
||||
/// nothing — `resolve_profile` then answers with a synthetic empty
|
||||
/// profile. The DID in the notification / actor DTO is real, so we
|
||||
/// navigate by that instead.
|
||||
#[tauri::command]
|
||||
async fn profile_get_by_did(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
) -> Result<appview_client::ProfileResponse, String> {
|
||||
state
|
||||
.appview
|
||||
.fetch_profile_by_did(&did)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn search(
|
||||
state: tauri::State<'_, AppState>,
|
||||
@@ -407,6 +430,108 @@ async fn post_get(
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `fetch_thread(uri, viewer_did?)` — the full thread around `uri`:
|
||||
/// the whole ancestor chain (root first) plus the direct replies
|
||||
/// (oldest first). `post_get` stays the narrower two-hop shape the
|
||||
/// timeline's inline thread modal uses; this is what a dedicated
|
||||
/// thread view wants.
|
||||
#[tauri::command]
|
||||
async fn fetch_thread(
|
||||
state: tauri::State<'_, AppState>,
|
||||
uri: String,
|
||||
viewer_did: Option<String>,
|
||||
) -> Result<appview_client::ThreadFullResponse, String> {
|
||||
state
|
||||
.appview
|
||||
.fetch_thread(&uri, viewer_did.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `fetch_notifications(did, cursor?, limit?)` — one page of the
|
||||
/// recipient's notifications, newest first. Same limit clamp as
|
||||
/// `timeline_home` (the server clamps too, but doing it here means a
|
||||
/// bad `limit` never costs a round trip).
|
||||
#[tauri::command]
|
||||
async fn fetch_notifications(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::NotificationsResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
state
|
||||
.appview
|
||||
.fetch_notifications(&did, cursor.as_deref(), lim)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `notification_count(did)` — unread count for the NavRail badge.
|
||||
#[tauri::command]
|
||||
async fn notification_count(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
) -> Result<appview_client::NotificationCountResponse, String> {
|
||||
state
|
||||
.appview
|
||||
.notification_count(&did)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `mark_notifications_seen(did, seen_at?)` — mark every notification
|
||||
/// indexed at or before `seen_at` as read. The frontend passes the
|
||||
/// `indexed_at` of the topmost row it actually rendered, so a
|
||||
/// notification that lands mid-scroll is never silently swallowed.
|
||||
/// Omitting `seen_at` marks everything currently unread.
|
||||
#[tauri::command]
|
||||
async fn mark_notifications_seen(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
seen_at: Option<String>,
|
||||
) -> Result<appview_client::NotificationSeenResponse, String> {
|
||||
state
|
||||
.appview
|
||||
.mark_notifications_seen(&did, seen_at.as_deref())
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `fetch_followers(did, cursor?, limit?)` — one page of the actors
|
||||
/// who follow `did`.
|
||||
#[tauri::command]
|
||||
async fn fetch_followers(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::ActorListResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
state
|
||||
.appview
|
||||
.fetch_followers(&did, cursor.as_deref(), lim)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// `fetch_following(did, cursor?, limit?)` — one page of the actors
|
||||
/// `did` follows.
|
||||
#[tauri::command]
|
||||
async fn fetch_following(
|
||||
state: tauri::State<'_, AppState>,
|
||||
did: String,
|
||||
cursor: Option<String>,
|
||||
limit: Option<u32>,
|
||||
) -> Result<appview_client::ActorListResponse, String> {
|
||||
let lim = limit.unwrap_or(30).clamp(1, 100);
|
||||
state
|
||||
.appview
|
||||
.fetch_following(&did, cursor.as_deref(), lim)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn status_pds(state: tauri::State<'_, AppState>) -> Result<serde_json::Value, String> {
|
||||
let sess = state.store.load();
|
||||
@@ -791,8 +916,15 @@ pub fn run() {
|
||||
resolve_handle,
|
||||
timeline_home,
|
||||
profile_get,
|
||||
profile_get_by_did,
|
||||
search,
|
||||
post_get,
|
||||
fetch_thread,
|
||||
fetch_notifications,
|
||||
notification_count,
|
||||
mark_notifications_seen,
|
||||
fetch_followers,
|
||||
fetch_following,
|
||||
like_post,
|
||||
unlike_post,
|
||||
repost_post,
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"https://releases.maarcadetweet.local/{{target}}/{{arch}}/{{current_version}}"
|
||||
],
|
||||
"pubkey": "",
|
||||
"_comment": "Auto-update is disabled for dev. To enable for releases: (1) stand up a release-artifacts server that serves update.json, (2) run `tauri signer generate` and paste the pubkey here, (3) flip active+dialog to true. Capabilities already include `updater:default` so the frontend can request update checks via the plugin once enabled."
|
||||
"_comment": "Auto-update is inert in dev: nothing in the app calls the updater's check(), and `pubkey` is empty. Note that `active` and `dialog` above are Tauri v1 leftovers — the v2 updater plugin ignores unknown keys, so they do NOT switch anything on or off. The full production path (signer keys, pubkey, endpoints, bundle.createUpdaterArtifacts, latest.json format, per-platform build + artifact paths, and the still-missing check() call) is documented in docs/tauri-release.md. Enable it via a release config overlay passed to `tauri build --config`, so this dev config stays as is. Capabilities already include `updater:default` (check/download/install)."
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
Reference in New Issue
Block a user