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:
tomdebone
2026-09-09 21:37:00 +02:00
co-authored by Claude Opus 5
parent bce4c7862f
commit 31880e1005
12 changed files with 2241 additions and 48 deletions
@@ -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.
+132
View File
@@ -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,
+1 -1
View File
@@ -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": {