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:
co-authored by
Claude Opus 5
parent
9d009bfcba
commit
c4ca218d97
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user