feat(appview): Notifications, Follower-/Following-Listen, Thread-Route

Bisher erfuhr ein Nutzer nie, dass jemand anderes mit ihm interagiert hat:
Like, Repost, Follow und Reply hinterließen keine Spur, an der der Client
hätte pollen können. Der Tray-/Notification-Pfad im Desktop-Client (Phase 7)
hing damit in der Luft.

Migration 0008:
* notifications(recipient, author, kind, subject_uri, created_at,
  indexed_at, read_at) mit Keyset-Index (recipient, indexed_at DESC, id DESC)
  und Partial-Index auf ungelesene Zeilen für den Badge-Poll.
* Dedupe-Unique-Index über COALESCE(subject_uri, '') — plain NULLs
  kollidieren nicht, sonst gäbe es pro Follow beliebig viele Zeilen.
  Folge: Unlike-Relike erzeugt keine zweite Notification, Toggle-Spam ist
  damit ausgeschlossen.
* Bewusst kein CHECK (recipient <> author): ein Ausrutscher dort würde die
  umgebende Like-Transaktion abbrechen, also das Like wegen eines
  Notification-Bugs verlieren. Gefiltert wird in Rust und im INSERT.

Indexer: record_notification() hängt an upsert_like/-repost (in derselben
Transaktion wie die Counter) sowie upsert_follow/-post. Selbst-Interaktionen
sind still. Empfänger muss uns bekannt sein (profiles- oder posts-Zeile),
sonst würden wir für den gesamten öffentlichen Firehose Zeilen anlegen —
als ein INSERT ... SELECT ... WHERE EXISTS, also ohne TOCTOU-Fenster.
Reply-Notifications tragen die URI der *Antwort* als subject_uri, weil die
Liste den Text zeigt, den der Empfänger noch nicht kennt.

Endpoints: GET /api/notifications, /api/notifications/count,
POST /api/notifications/seen (seenAt als Wasserzeichen),
GET /api/followers, /api/following, GET /api/thread (beide Schreibweisen).
Cursor-Codec, Limit-Clamping und Fehlerform sind die der bestehenden
Endpoints.

/api/post/*uri bleibt wire-kompatibel und teilt sich jetzt
load_thread_context() mit /api/thread — mit max_parents = 1, weil es nur
den direkten Parent serialisiert; die volle Ahnenkette wären bis zu 20
sequenzielle Queries für Zeilen, die danach verworfen werden.

Nebenbei ein Darstellungsfehler: der synthetische Platzhalter-Handle für
Actors ohne bekannten Handle trug ein führendes '@', während jeder Consumer
selbst '@{handle}' rendert — im Feed kam '@@did:plc:abcd…' heraus. Der
Platzhalter ist jetzt durchgängig sigil-frei.

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:36:32 +02:00
co-authored by Claude Opus 5
parent 9d009bfcba
commit c4ca218d97
6 changed files with 2519 additions and 123 deletions
+159
View File
@@ -199,6 +199,165 @@ pub struct SearchResponse {
pub q: String,
}
// -- thread -----------------------------------------------------------------
/// `GET /api/thread` response.
///
/// This is the *full* thread shape: the ancestor chain above the post
/// and the direct replies below it. `/api/post/{uri}` keeps its own,
/// narrower `{ post, thread: { parent, root } }` shape for backwards
/// compatibility — both are built from the same
/// `routes::load_thread_context` call, so they can never disagree
/// about what the parent or root is.
///
/// `post` is `None` when the URI isn't in our index. In that case
/// `parents` / `replies` are empty and the counters are omitted, so a
/// client can render "post not found" from a single field check.
#[derive(Debug, Serialize)]
pub struct ThreadFullResponse {
pub post: Option<PostRow>,
/// Ancestor chain ordered **root first, immediate parent last**.
/// Empty for a top-level post, and truncated (from the top) when
/// the chain is longer than the walk limit or when an ancestor
/// isn't in our index — the client should treat a `parents[0]`
/// whose `parent_uri` is non-null as "chain continues above,
/// not loaded".
pub parents: Vec<PostRow>,
/// The thread root as named by the post's own `root_uri`. May be
/// the same row as `parents[0]`, and is `None` for a top-level
/// post (which is its own root).
pub root: Option<PostRow>,
/// Direct replies to `post`, oldest first. Only direct children —
/// the client re-requests `/api/thread` for a nested branch.
pub replies: Vec<PostRow>,
#[serde(skip_serializing_if = "Option::is_none")]
pub like_count: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repost_count: Option<i64>,
/// Same semantics as on `/api/post/{uri}`: `None` means "no
/// viewer_did was supplied, state unknown" — not "not liked".
#[serde(skip_serializing_if = "Option::is_none")]
pub viewer_liked: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewer_reposted: Option<bool>,
}
// -- notifications ----------------------------------------------------------
/// One hydrated row of `GET /api/notifications`.
///
/// The DB row only stores DIDs and a subject URI; the read query joins
/// the `profiles` cache (falling back to the newest non-empty
/// `posts.handle` for authors we've seen post but never had a profile
/// record for) and the `posts` table so the client can render a full
/// notification line without any follow-up fetch.
///
/// `read_at` is serialised even when `None` — unlike the optional
/// fields around it — because "unread" is the state the client's badge
/// keys off, and a *missing* key would be indistinguishable from a
/// field the client forgot to read.
#[derive(Debug, Clone, Serialize)]
pub struct NotificationItem {
/// Row id. Also the tiebreaker inside the opaque cursor.
pub id: i64,
/// `"like"` | `"repost"` | `"follow"` | `"reply"`.
pub kind: String,
pub author_did: String,
/// Best known handle for the author, WITHOUT a leading `@` (the UI
/// renders `@{handle}`). Falls back to a truncated DID, and is
/// never null.
pub author_handle: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub author_display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub author_avatar_cid: Option<String>,
/// The post this notification is about. `null` for `"follow"`. For
/// `"like"`/`"repost"` it's the recipient's own post; for
/// `"reply"` it's the reply itself.
pub subject_uri: Option<String>,
/// Text of `subject_uri`'s post. Omitted when the subject is not
/// (or no longer) in our index, and always for `"follow"`.
#[serde(skip_serializing_if = "Option::is_none")]
pub subject_text: Option<String>,
/// The interaction's own `createdAt` from the AT record.
pub created_at: DateTime<Utc>,
/// When the AppView indexed it. This is the list's sort key, and
/// the value a client should echo back as `seenAt`.
pub indexed_at: DateTime<Utc>,
/// `null` while unread.
pub read_at: Option<DateTime<Utc>>,
}
impl<'r> FromRow<'r, sqlx::postgres::PgRow> for NotificationItem {
fn from_row(row: &'r sqlx::postgres::PgRow) -> sqlx::Result<Self> {
Ok(NotificationItem {
id: row.try_get("id")?,
kind: row.try_get("kind")?,
author_did: row.try_get("author_did")?,
author_handle: row.try_get("author_handle")?,
author_display_name: row.try_get("author_display_name")?,
author_avatar_cid: row.try_get("author_avatar_cid")?,
subject_uri: row.try_get("subject_uri")?,
subject_text: row.try_get("subject_text")?,
created_at: row.try_get("created_at")?,
indexed_at: row.try_get("indexed_at")?,
read_at: row.try_get("read_at")?,
})
}
}
/// `GET /api/notifications` response. `cursor` is `None` at the end of
/// the list — same contract as [`TimelineResponse`].
#[derive(Debug, Serialize)]
pub struct NotificationsResponse {
pub notifications: Vec<NotificationItem>,
pub cursor: Option<String>,
}
/// `GET /api/notifications/count` response.
#[derive(Debug, Serialize)]
pub struct NotificationCountResponse {
/// Number of rows with `read_at IS NULL` for this DID.
pub count: i64,
}
/// `POST /api/notifications/seen` response.
#[derive(Debug, Serialize)]
pub struct NotificationSeenResponse {
pub ok: bool,
/// How many previously-unread rows this call flipped to read.
/// Zero is a normal, successful outcome (nothing was unread).
pub updated: i64,
}
// -- actor lists (followers / following) ------------------------------------
/// A minimal profile card, as returned by `GET /api/followers` and
/// `GET /api/following`.
///
/// Deliberately *not* [`ProfileResponse`]: those endpoints return a
/// list, and shipping each entry's posts + counts would turn one page
/// of 30 followers into 30 post queries. The client renders a row with
/// avatar + name + handle and navigates to `/api/profile` on click.
#[derive(Debug, Clone, Serialize)]
pub struct ActorProfile {
pub did: String,
/// Without a leading `@`; falls back to a truncated DID and is
/// never null.
pub handle: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub avatar_cid: Option<String>,
}
/// `GET /api/followers` / `GET /api/following` response.
#[derive(Debug, Serialize)]
pub struct ActorListResponse {
pub profiles: Vec<ActorProfile>,
pub cursor: Option<String>,
}
// -- Langs newtype ----------------------------------------------------------
/// A list of language tags. Always serialises as `Vec<String>`, never