diff --git a/CLAUDE.md b/CLAUDE.md index 183abd6..e5d8413 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,7 +165,7 @@ This is the most intricate part of the codebase and where most current work happ **Chat IDs.** Length is the type discriminator: 32 bytes = DM (SHA-256 over a domain-separated, sorted, deduped member set, so creation is idempotent and order-independent), 16 bytes = group (server-derived: a truncated, domain-separated SHA-256 over the creator's user ID and the request's required `IdempotencyKey`, so a retried `StartChat` names the same group and is answered from the existing record; stamped as a version 8 UUID so every group ID is UUID-shaped, though the ID is opaque and nothing parses it). DM paths must reject 16-byte IDs and vice versa. `CONTACT_DM` uses the bare legacy hash domain; other DM types append their enum number. A chat type of `UNKNOWN` falls back to `CONTACT_DM` for legacy clients. -**Chat storage (`chat/dynamodb`).** Tables: `chats` (metadata), `dm_inbox` (per-user DM feed rows; GSI `by_type_activity` on a composite `feed` key, legacy `by_activity` GSI still maintained), `group_members` (pk chat, sk user; plus one `#meta` item per group holding `member_count`/`version`, CAS-updated in the same transaction as each transition, with bounded retries on contention). `Chat.Members` is populated only for DMs; groups return empty `Members` and a `RosterSummary{MemberCount, Version}`. Version is *state, not a delta*: each real transition bumps it by exactly one and no-ops leave it alone; clients keep the greater version. DM sends fan `last_activity` into each member's inbox row. A store built with users in `excludedFromFeed` (a required argument of `NewInMemory` / `NewInDynamoDB`, nil for none, nil entries ignored, duplicates collapsed; `chat.FeedExclusions` in `chat/feed.go`) creates every DM with those users **excluded from the feed**; the parent passes the team account, so no flow that creates a DM can leave it in. The exclusion is store-internal, not on `Chat`: decided at creation, recorded on the DM's canonical item as `excluded_from_feed` (a binary set of user IDs, absent when empty; a DM between two excluded users excludes both), and each excluded member's `dm_inbox` row carries neither `feed` nor `last_activity`, so it is in neither GSI and records membership alone (kept because DM `IsMember` reads it). `GetDmFeedPage` never lists the chat for them, and `AdvanceLastMessage` skips their row off the canonical item it already reads, so a process built excluding no one still advances such a DM safely (its condition could never hold on that row, so including it would cancel every advance); opening DMs never writes the newest end of one GSI key and no send moves the team's row; **group sends never fan out** — the group feed is assembled at read time with order computed once and a window of chat IDs carried in the paging token (`maxGroupFeedChats = 1000`), re-checking membership per page. A fourth table, `chat_user_state` (pk user, sk chat), holds `chat.ViewerState`: what a chat records about one user independent of membership — today a mute (`muted_until`, epoch seconds, present only while a mute is recorded; an indefinite mute is a store-internal far-future sentinel) and a per-row `version` with the same state-not-delta rule. Rows are sparse, never deleted, and survive leaving the chat. The viewer-state methods (`SetMute`, `ClearMute`, `GetViewerStates`, `GetMutedUsers`, `GetMutedUsersPage`, `GetMutedCount`) are part of `chat.Store` itself, not a separate interface. `GetViewerStates` is one strongly consistent `Query` on the user's partition bounded to the requested key range (a page of chats costs a few RCU, not one per key). The chat-scoped read has **two shapes, chosen by size**: `GetMutedUsers` is a key-range `Query` on the sparse `by_muted` GSI (chat, `muted_until`), billed by the active mutes it returns; `GetMutedUsersPage` ranges the inverted `by_user` GSI (chat, user; every record, full projection so future states need no new index) over an inclusive `[lo, hi]` user-ID key range in the same `user#` order as a group's roster, so a fan-out holding one roster page (`GetGroupMembersPage`, a cursor walk of the `group_members` partition in ascending user-ID order) asks for the mutes between that page's first and last user and never holds either whole. `GetMutedCount` reads a per-chat `#meta` item (pk `chat#`, sk `#meta`; carries neither `chat` nor `muted_until`, which keeps it out of both indexes) holding the number of records with a mute *recorded* — moved in the same transaction as a first mute or a clear, never by a replace or a lapse, so it bounds active mutes from above; the fan-out compares it to the roster size to pick a shape. Both GSIs are eventually consistent. A replaced mute is one conditional `UpdateItem`; a first mute or a clear is a two-item transaction on the `#meta` pattern (version compared, lost race retried from the returned item, `TransactionConflict` backed off). A no-op never creates an item. A fifth table, `chat_activity` (pk chat, sk user; `NewInDynamoDB` / `CreateTables` take its name), holds each group's **activity records** for mention suggestions (`chat.RecentSender` in `chat/model.go`), a fact about the message log, independent of membership: `RecordSend` is an eventually consistent read then one update of `last_sent_at` (epoch ms) and `activity_score`, conditioned on the `last_sent_at` read (a lost race is checked once more against the item the failure returns, and written once more only when its send is the newest by a full interval; a second loss leaves it unrecorded, best effort), throttled to one per `ActivityRecordInterval` (1 min) per user (a throttled send is answered off the read, with no write), never moving backwards, and expiring by TTL `ActivityRetention` (1 year) after it; `GetRecentSenders` is one eventually consistent query on the `by_last_sent_at` LSI, most recent first; `GetLastSentAt` is the eventually consistent point read of one user's record (both at half a strong read's cost; their readers only rank and show people). A second LSI, `by_activity_score`, orders the partition by **activity score** (`chat/activity.go`), a frequency-weighted ordering of recorded sends that **nothing reads yet**: the log-sum-exp of the send times with a 3-day half-life (`ActivityScoreHalfLife`), each send weighted by the quiet before it over `ActivityScoreFullWeightGap` (1 day, at most one, so it counts **days active, not messages**: an hour-long burst barely counts past its first send), stored as epoch ms, so one send scores its time and the decay never moves a stored score (comparing decayed sums at any moment compares the scores); never below the last send, and capped at `ActivityScoreMaxLead` (7 days) past the send that set it, which no steady pattern reaches at these values (sending daily settles 6.8 days ahead, weekly about 1). `NextActivityScore` is the one formula, shared by every store. A record written before scores has no `activity_score`, is missing from the LSI, and reads and scores as its `last_sent_at` (`EffectiveActivityScore`, which also lifts a score trailing `last_sent_at`, as an old writer during rollout can leave) until a backfill sets `activity_score = last_sent_at`. `GetRecentSenders` returns each record's effective score. `messaging.Sender` records each sender's last message of every group send (`recordGroupSenders`, after the broadcast, best effort; DMs and system messages record nothing); `GetMentionSuggestions` reads `GetRecentSenders` (see "Mention suggestions" below). A sixth table, `chat_key_envelopes` (pk user, sk chat, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each member's **key envelope** for a private group (`chat.KeyEnvelope`: scheme, nonce, ciphertext and `wrapped_by`, the user who stored it; see "Private groups"). `SetKeyEnvelope` is one conditional put, refused when the stored envelope is one the user wrapped themself, with the refusal returning the stored item; `GetKeyEnvelope` is a strongly consistent point read. Like viewer state, the methods are on `chat.Store`, write against the IDs alone and know nothing of the roster. The one tie to the roster is a departure: `RemoveGroupMember` takes a required `discardKeyEnvelope` flag and, when set, deletes the leaver's envelope **in the same transaction** as the membership transition and its `#meta` update (an unconditional third item, so it commits iff the departure happens and a no-op leave deletes nothing). A seventh table, `chat_lobbies` (pk user, sk chat; `NewInDynamoDB` / `CreateTables` take its name), holds each private group's **lobby entries** (`chat.LobbyEntry`: `entered_at` epoch nanos; the sparse `by_chat` **GSI** is hashed on the `sk` itself, the chat, and ranged on `entered_at`, so no attribute repeats the chat and the index pages a lobby earliest first, **eventually consistent** as the proto allows) and two `#meta` count items per entry, the lobby's size under `chat#` and the user's lobby count under `user#` (both omit `entered_at`, the index's range key, so they stay out of it). **Keyed by user, decided 2026-10-05**: a user's own entries, and the future listing of every lobby they wait in, are one strongly consistent query with no index; the price is the creator's page, which a chat-keyed table with an LSI could serve strongly consistent (an LSI cannot gather one chat's entries across user partitions), and the creator reconciles against `LobbyUpdate`s instead. `EnterLobby` is one transaction: a conditional put of the entry, both counts incremented under `attribute_not_exists OR count < cap`, and a `ConditionCheck` that the user's `group_members` row is absent or not joined, so the caps (`chat.LobbyLimits`, `DefaultLobbyLimits` 100 per lobby / 100 lobbies per user, user's choice 2026-10-05) hold under concurrent entries with no read and **a member never gains an entry** (closing the race where a retried `EnterLobby` lands after the creator's admission removed the entry); the cancellation reasons tell `ErrAlreadyMember` (judged first) from an existing entry (the no-op) from `ErrLobbyFull` (judged before) `ErrTooManyLobbies`. `LeaveLobby` is the inverse (conditional delete + two decrements; a missing entry is the no-op). `GetLobbyEntries` is a strongly consistent read of the user's partition (point read for one chat, bounded range query otherwise, like `GetViewerStates`). `AdmitFromLobby` is `transitionMembership`'s join carrying `alongside` the entry's **conditional** delete, both decrements and an unconditional put of the key envelope: `transitionMembership` now admits conditional alongside items and reports a failed one as `alongsideConditionFailed{Index}` (judged after the transition's own no-op and the summary CAS), which `AdmitFromLobby` translates to `ErrNotInLobby`; a user who is already a member is the transition's no-op (`changed` false, nothing written, a leftover entry is theirs to clear). An eighth table, `chat_featured_groups` (pk user, sk `pos#`, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each user's **featured groups** (`chat/featured.go`: an ordered list of up to `MaxFeaturedGroups` (10) **public** group IDs, no membership required, nothing recorded against the group; the store reads no record, so existence and the public-only rule are the setter's (`DENIED` for a private group), and since `IsPrivate` is immutable the list needs no filtering on read; `ValidateFeaturedGroups` refuses a DM ID or a repeat rather than collapsing it). `SetFeaturedGroups` replaces the whole list in one transaction: the `#meta` item (`version`, `featured_count`) compare-and-set on version, a put of every position the new list fills (each stamped with the new version) and a delete of every position past it, retried from a fresh read on a lost CAS; an equal list is the no-op. `GetFeaturedGroups` is one **eventually consistent** query of the partition (it may trail a replace by a moment; the replace's own read of the list stays strongly consistent, since a stale one could skip a needed write as a no-op), verified (every position at `#meta`'s version, `featured_count` of them, contiguous) and re-read up to 3× when it straddles a replace, so a reader never sees a mix of two lists. Rows carry the raw `chat` so a `(chat, pk)` GSI for "who features this group" can be added later; `#meta` omits it. **RPCs (`chat/featured.go`) are on the Chat service**, not Profile (`profilepb` cannot import `chatpb`, which imports it). `SetFeaturedGroups` (auth required) validates (`InvalidArgument` for a DM ID, a repeat or too many), reads the groups' records with `Store.GetGroupChatsByID` (eventually consistent, so a group created a moment ago may briefly be `NOT_FOUND`; no membership check; a TODO marks revisiting its consistency if another reader comes to use it), answers `NOT_FOUND` for a missing group before `DENIED` for a private one, writes, and returns the list hydrated from the records it already read. `GetFeaturedGroups` takes a username alone (resolved with `ProfileReader.GetUserIDByUsername`; `NOT_FOUND` when nobody holds it), auth optional and changing nothing, and drops groups with no record. Both project through `featuredGroupsMetadata`: `hydrate` with no viewer, `ReadingDenied` and `listDetail`, so every caller gets the same list (no viewer fields, no messaging state, no cover); it also drops a private group, which should never be there, and logs a warning if it finds one. +**Chat storage (`chat/dynamodb`).** Tables: `chats` (metadata), `dm_inbox` (per-user DM feed rows; GSI `by_type_activity` on a composite `feed` key, legacy `by_activity` GSI still maintained), `group_members` (pk chat, sk user; plus one `#meta` item per group holding `member_count`/`version`, CAS-updated in the same transaction as each transition, with bounded retries on contention). `Chat.Members` is populated only for DMs; groups return empty `Members` and a `RosterSummary{MemberCount, Version}`. Version is *state, not a delta*: each real transition bumps it by exactly one and no-ops leave it alone; clients keep the greater version. DM sends fan `last_activity` into each member's inbox row. A store built with users in `excludedFromFeed` (a required argument of `NewInMemory` / `NewInDynamoDB`, nil for none, nil entries ignored, duplicates collapsed; `chat.FeedExclusions` in `chat/feed.go`) creates every DM with those users **excluded from the feed**; the parent passes the team account, so no flow that creates a DM can leave it in. The exclusion is store-internal, not on `Chat`: decided at creation, recorded on the DM's canonical item as `excluded_from_feed` (a binary set of user IDs, absent when empty; a DM between two excluded users excludes both), and each excluded member's `dm_inbox` row carries neither `feed` nor `last_activity`, so it is in neither GSI and records membership alone (kept because DM `IsMember` reads it). `GetDmFeedPage` never lists the chat for them, and `AdvanceLastMessage` skips their row off the canonical item it already reads, so a process built excluding no one still advances such a DM safely (its condition could never hold on that row, so including it would cancel every advance); opening DMs never writes the newest end of one GSI key and no send moves the team's row; **group sends never fan out** — the group feed is assembled at read time with order computed once and a window of chat IDs carried in the paging token (`maxGroupFeedChats = 1000`), re-checking membership per page. A fourth table, `chat_user_state` (pk user, sk chat), holds `chat.ViewerState`: what a chat records about one user independent of membership — today a mute (`muted_until`, epoch seconds, present only while a mute is recorded; an indefinite mute is a store-internal far-future sentinel) and a per-row `version` with the same state-not-delta rule. Rows are sparse, never deleted, and survive leaving the chat. The viewer-state methods (`SetMute`, `ClearMute`, `GetViewerStates`, `GetMutedUsers`, `GetMutedUsersPage`, `GetMutedCount`) are part of `chat.Store` itself, not a separate interface. `GetViewerStates` is one strongly consistent `Query` on the user's partition bounded to the requested key range (a page of chats costs a few RCU, not one per key). The chat-scoped read has **two shapes, chosen by size**: `GetMutedUsers` is a key-range `Query` on the sparse `by_muted` GSI (chat, `muted_until`), billed by the active mutes it returns; `GetMutedUsersPage` ranges the inverted `by_user` GSI (chat, user; every record, full projection so future states need no new index) over an inclusive `[lo, hi]` user-ID key range in the same `user#` order as a group's roster, so a fan-out holding one roster page (`GetGroupMembersPage`, a cursor walk of the `group_members` partition in ascending user-ID order) asks for the mutes between that page's first and last user and never holds either whole. `GetMutedCount` reads a per-chat `#meta` item (pk `chat#`, sk `#meta`; carries neither `chat` nor `muted_until`, which keeps it out of both indexes) holding the number of records with a mute *recorded* — moved in the same transaction as a first mute or a clear, never by a replace or a lapse, so it bounds active mutes from above; the fan-out compares it to the roster size to pick a shape. Both GSIs are eventually consistent. A replaced mute is one conditional `UpdateItem`; a first mute or a clear is a two-item transaction on the `#meta` pattern (version compared, lost race retried from the returned item, `TransactionConflict` backed off). A no-op never creates an item. A fifth table, `chat_activity` (pk chat, sk user; `NewInDynamoDB` / `CreateTables` take its name), holds each group's **activity records** for mention suggestions (`chat.RecentSender` in `chat/model.go`), a fact about the message log, independent of membership: `RecordSend` is an eventually consistent read then one update of `last_sent_at` (epoch ms) and `activity_score`, conditioned on the `last_sent_at` read (a lost race is checked once more against the item the failure returns, and written once more only when its send is the newest by a full interval; a second loss leaves it unrecorded, best effort), throttled to one per `ActivityRecordInterval` (1 min) per user (a throttled send is answered off the read, with no write), never moving backwards, and expiring by TTL `ActivityRetention` (1 year) after it; `GetRecentSenders` is one eventually consistent query on the `by_last_sent_at` LSI, most recent first; `GetLastSentAt` is the eventually consistent point read of one user's record (both at half a strong read's cost; their readers only rank and show people). A second LSI, `by_activity_score`, orders the partition by **activity score** (`chat/activity.go`), a frequency-weighted ordering of recorded sends, read by `GetActiveSenders` for the chatter sample (mention suggestions stay on recency): the log-sum-exp of the send times with a 3-day half-life (`ActivityScoreHalfLife`), each send weighted by the quiet before it over `ActivityScoreFullWeightGap` (1 day, at most one, so it counts **days active, not messages**: an hour-long burst barely counts past its first send), stored as epoch ms, so one send scores its time and the decay never moves a stored score (comparing decayed sums at any moment compares the scores); never below the last send, and capped at `ActivityScoreMaxLead` (7 days) past the send that set it, which no steady pattern reaches at these values (sending daily settles 6.8 days ahead, weekly about 1). `NextActivityScore` is the one formula, shared by every store. A record written before scores had no `activity_score` and so was missing from the LSI until a one-off backfill (done 2026-10) set `activity_score = last_sent_at`; one that somehow still lacks it reads and scores as its `last_sent_at` (`EffectiveActivityScore`, which also lifts a score trailing `last_sent_at`) but is not found by `GetActiveSenders`. Both reads return each record's effective score. `messaging.Sender` records each sender's last message of every group send (`recordGroupSenders`, after the broadcast, best effort; DMs and system messages record nothing); `GetMentionSuggestions` reads `GetRecentSenders` (see "Mention suggestions" below). A sixth table, `chat_key_envelopes` (pk user, sk chat, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each member's **key envelope** for a private group (`chat.KeyEnvelope`: scheme, nonce, ciphertext and `wrapped_by`, the user who stored it; see "Private groups"). `SetKeyEnvelope` is one conditional put, refused when the stored envelope is one the user wrapped themself, with the refusal returning the stored item; `GetKeyEnvelope` is a strongly consistent point read. Like viewer state, the methods are on `chat.Store`, write against the IDs alone and know nothing of the roster. The one tie to the roster is a departure: `RemoveGroupMember` takes a required `discardKeyEnvelope` flag and, when set, deletes the leaver's envelope **in the same transaction** as the membership transition and its `#meta` update (an unconditional third item, so it commits iff the departure happens and a no-op leave deletes nothing). A seventh table, `chat_lobbies` (pk user, sk chat; `NewInDynamoDB` / `CreateTables` take its name), holds each private group's **lobby entries** (`chat.LobbyEntry`: `entered_at` epoch nanos; the sparse `by_chat` **GSI** is hashed on the `sk` itself, the chat, and ranged on `entered_at`, so no attribute repeats the chat and the index pages a lobby earliest first, **eventually consistent** as the proto allows) and two `#meta` count items per entry, the lobby's size under `chat#` and the user's lobby count under `user#` (both omit `entered_at`, the index's range key, so they stay out of it). **Keyed by user, decided 2026-10-05**: a user's own entries, and the future listing of every lobby they wait in, are one strongly consistent query with no index; the price is the creator's page, which a chat-keyed table with an LSI could serve strongly consistent (an LSI cannot gather one chat's entries across user partitions), and the creator reconciles against `LobbyUpdate`s instead. `EnterLobby` is one transaction: a conditional put of the entry, both counts incremented under `attribute_not_exists OR count < cap`, and a `ConditionCheck` that the user's `group_members` row is absent or not joined, so the caps (`chat.LobbyLimits`, `DefaultLobbyLimits` 100 per lobby / 100 lobbies per user, user's choice 2026-10-05) hold under concurrent entries with no read and **a member never gains an entry** (closing the race where a retried `EnterLobby` lands after the creator's admission removed the entry); the cancellation reasons tell `ErrAlreadyMember` (judged first) from an existing entry (the no-op) from `ErrLobbyFull` (judged before) `ErrTooManyLobbies`. `LeaveLobby` is the inverse (conditional delete + two decrements; a missing entry is the no-op). `GetLobbyEntries` is a strongly consistent read of the user's partition (point read for one chat, bounded range query otherwise, like `GetViewerStates`). `AdmitFromLobby` is `transitionMembership`'s join carrying `alongside` the entry's **conditional** delete, both decrements and an unconditional put of the key envelope: `transitionMembership` now admits conditional alongside items and reports a failed one as `alongsideConditionFailed{Index}` (judged after the transition's own no-op and the summary CAS), which `AdmitFromLobby` translates to `ErrNotInLobby`; a user who is already a member is the transition's no-op (`changed` false, nothing written, a leftover entry is theirs to clear). An eighth table, `chat_featured_groups` (pk user, sk `pos#`, no index; `NewInDynamoDB` / `CreateTables` take its name), holds each user's **featured groups** (`chat/featured.go`: an ordered list of up to `MaxFeaturedGroups` (10) **public** group IDs, no membership required, nothing recorded against the group; the store reads no record, so existence and the public-only rule are the setter's (`DENIED` for a private group), and since `IsPrivate` is immutable the list needs no filtering on read; `ValidateFeaturedGroups` refuses a DM ID or a repeat rather than collapsing it). `SetFeaturedGroups` replaces the whole list in one transaction: the `#meta` item (`version`, `featured_count`) compare-and-set on version, a put of every position the new list fills (each stamped with the new version) and a delete of every position past it, retried from a fresh read on a lost CAS; an equal list is the no-op. `GetFeaturedGroups` is one **eventually consistent** query of the partition (it may trail a replace by a moment; the replace's own read of the list stays strongly consistent, since a stale one could skip a needed write as a no-op), verified (every position at `#meta`'s version, `featured_count` of them, contiguous) and re-read up to 3× when it straddles a replace, so a reader never sees a mix of two lists. Rows carry the raw `chat` so a `(chat, pk)` GSI for "who features this group" can be added later; `#meta` omits it. **RPCs (`chat/featured.go`) are on the Chat service**, not Profile (`profilepb` cannot import `chatpb`, which imports it). `SetFeaturedGroups` (auth required) validates (`InvalidArgument` for a DM ID, a repeat or too many), reads the groups' records with `Store.GetGroupChatsByID` (eventually consistent, so a group created a moment ago may briefly be `NOT_FOUND`; no membership check; a TODO marks revisiting its consistency if another reader comes to use it), answers `NOT_FOUND` for a missing group before `DENIED` for a private one, writes, and returns the list hydrated from the records it already read. `GetFeaturedGroups` takes a username alone (resolved with `ProfileReader.GetUserIDByUsername`; `NOT_FOUND` when nobody holds it), auth optional and changing nothing, and drops groups with no record. Both project through `featuredGroupsMetadata`: `hydrate` with no viewer, `ReadingDenied` and `listDetail`, so every caller gets the same list (no viewer fields, no messaging state, no cover); it also drops a private group, which should never be there, and logs a warning if it finds one. **Tombstones.** A departed group member's row stays with `state=2`, `left_at`, a 1h TTL and the departure's roster version, so re-joins are idempotent updates and delayed duplicates of an undone join can be distinguished from news. Readers trust `state`, never the clock. "Formerly a member" is not durable. @@ -185,7 +185,7 @@ This is the most intricate part of the codebase and where most current work happ **Mention suggestions (`chat/mention.go`).** `GetMentionSuggestions` returns a ranked pool of people to @mention in a group, which the client filters locally: today the group's most recent senders (`GetRecentSenders`), most recent first, **departed users included** (users can't tell them from members). The server decides the size (`mentionSuggestionsSize` 50, reading `mentionSuggestionsSlack` 20 more to absorb filtering); the request carries no limit, and neither size nor ranking is contract. Gate: a DM is `DENIED` before any read, a missing group `NOT_FOUND`, then `Access.CanSpeak` (so a non-creator in a creator-only group is `DENIED`). Dropped after the read: the caller, users the caller blocked (`BlocklistReader.GetBlocked`), users without a username, and users the profile domain no longer knows. A user who blocked the caller **is** still suggested (decided). Each suggestion is a `MentionSuggestion{user_profile, last_sent_at}`; the ranking score is never exposed. -**Chatter sample (`chat/sample.go`).** `SampleChatters` returns a short snapshot of a **public** group's members to show: the creator first while they are a member (`is_creator`, `last_sent_at` unset if they have not sent), then the members who have sent most recently, most recent first (`sampleChattersSize` 20; the proto allows 100 and promises neither size nor order after the creator). It is how a group shows who is in it without listing members who only read; everyone in it is a member as of the read. Gate: a DM is `DENIED` before any read, a missing group `NOT_FOUND`, a private group `DENIED` whoever asks; nothing else. **The sample is public** (decided 2026-10-06): unlike the roster, any caller gets it, member or not, and auth is optional (verified when set, as `GetChat`'s, but changes nothing: the sample is the same for everyone). Read: `GetRecentSenders` over `sampleChattersSenderWindow` (100) senders, candidates = creator then senders (creator deduped; a shown creator outside the window gets their `last_sent_at` from a `GetLastSentAt` point read, made only when the window filled, since otherwise it held every record), membership checked **eventually consistent** (it decides who is shown, never a gate; a TODO on `GetGroupMembersByID` marks revisiting it if a reader needs strong) in chunks of the members still to find plus `sampleChattersCheckBuffer` (5), so the first is 26, via `Store.GetGroupMembersByID` (one group, many users; the transpose of `GetGroupMemberRecords`), stopping at 21 members; the caller is checked like anyone. `has_more` is a 21st member found **or** a full sender window (the server stopped looking). Not filtered: the caller, users the caller blocked, users without a username; a missing profile is `Internal`, as on the roster. No paging, no version: clients may show it as is, or keep it live by moving each new sender to the front, refetching to drop departures. Activity records outlive departures, so a rejoiner is back at their last send. +**Chatter sample (`chat/sample.go`).** `SampleChatters` returns a short snapshot of a **public** group's members to show: the creator first while they are a member (`is_creator`, `last_sent_at` unset if they have not sent), then the **most active** members who have sent, by activity score (days active, see the `chat_activity` store) **blended toward recency** (`sampleRank`: score minus `sampleChattersRecencyWeight` 0.2 of its lead over the last send, so the more recent of two ranks first when their last sends differ by over 4× their scores; a daily regular still ranks above someone who sent once a day later), ties to the more recent sender (`sampleChattersSize` 20; the proto allows 100 and promises neither size nor order after the creator). It is how a group shows who is in it without listing members who only read; everyone in it is a member as of the read. Gate: a DM is `DENIED` before any read, a missing group `NOT_FOUND`, a private group `DENIED` whoever asks; nothing else. **The sample is public** (decided 2026-10-06): unlike the roster, any caller gets it, member or not, and auth is optional (verified when set, as `GetChat`'s, but changes nothing: the sample is the same for everyone). Read: `GetActiveSenders` (the `by_activity_score` LSI, which holds every record since the 2026-10 backfill) over `sampleChattersSenderWindow` (100) senders, re-sorted by that rank (the blend only reorders the window read by raw score), candidates = creator then senders (creator deduped; a shown creator outside the window gets their `last_sent_at` from a `GetLastSentAt` point read, made only when the window filled, since otherwise it held every record), membership checked **eventually consistent** (it decides who is shown, never a gate; a TODO on `GetGroupMembersByID` marks revisiting it if a reader needs strong) in chunks of the members still to find plus `sampleChattersCheckBuffer` (5), so the first is 26, via `Store.GetGroupMembersByID` (one group, many users; the transpose of `GetGroupMemberRecords`), stopping at 21 members; the caller is checked like anyone. `has_more` is a 21st member found **or** a full sender window (the server stopped looking). Not filtered: the caller, users the caller blocked, users without a username; a missing profile is `Internal`, as on the roster. No paging, no version: clients may show it as is, or keep it live by moving each new sender to the front, refetching to drop departures (which drifts toward recency order until the refetch; accepted). Activity records outlive departures, so a rejoiner is back at their last send and score. **Mute (`chat/mute.go`).** `MuteChat` / `UnmuteChat` record the caller's mute on any chat they are a *member* of (membership alone, never the rules; DMs included), gated as `NOT_FOUND` / `DENIED` via the canonical record then `IsMember`. A timed mute must end in the future, checked in the handler rather than by proto validation because `MuteState` also appears in responses. The store's no-op-aware write decides whether anything changed; only a real change publishes `MetadataUpdate.ViewerStateChanged` on the **user topic only** (never the chat topic), and the response carries the same `ViewerState` so the calling device applies it at its version. `LeaveChat` clears the caller's mute **best effort** after the departure lands (`clearMuteOnLeave`, run on every OK leave so a retry repairs a failed clear; a failure is logged, never fails the RPC, and a real clear publishes `ViewerStateChanged` like `UnmuteChat`); the record and its version persist, and a departed member cannot mute or unmute until they rejoin. `hydrate` reads `viewer_state` for every chat on the page for a member (one `GetViewerStates` read), never for a non-member. The projection is **the record exactly, lapsed mute included**: a version names one state, so every carrier of it must agree, and whether a timed mute is still in force is the client's call against its own clock (the server applies `Mute.Active` only in the push fan-out). A member always gets a `viewer_state` (see "Permissions" above); a non-member never does. **Pushes still reach muted recipients on Android, never on iOS**: the fan-out (`messaging/push.go`, see "Push fan-out" below) splits each page's recipients into a `push.ChatRecipients{Unmuted, Muted}`; `ChatMessagePush.Send` sends the unmuted half with the badge resolver and the muted half as a payload cloned at build time with `ChatMetadata.muted = true` and **no badge bump**. `FCMPusher.SendPushesWithBadges` sees that flag and drops every `FCM_APNS` token from the send (`withoutTokenType`), so a muted recipient's iOS devices get nothing at all — iOS cannot suppress an alert APNs already has — while their Android devices get the flagged copy the client keeps quiet. The mute read has two shapes chosen once per message from `GetMutedCount`: at or below `defaultPushMutedWholeSetCap` the active set is read whole (`GetMutedUsers`) and held for every page; above it each page reads `GetMutedUsersPage` over its own `[first, last]` user range and intersects with the page (a muted record of a departed user or non-member is in the range but never a recipient). The mute lookup **fails open** (everyone gets the plain push) where the blocklist lookup fails closed, because a suppressed push would lose the delivery the flag exists to preserve. The FCM shape of the muted batch is still an alert; the client suppresses it. diff --git a/chat/activity.go b/chat/activity.go index ddb3a7b..a87fd46 100644 --- a/chat/activity.go +++ b/chat/activity.go @@ -36,7 +36,7 @@ import ( // ActivityScoreMaxLead bounds it whatever the history; at these values no // steady pattern reaches it. // -// Nothing ranks by it yet. +// A group's chatter sample is ordered by it (see Store.GetActiveSenders). const ( // ActivityScoreHalfLife is how long a recorded send takes to count half // as much toward an activity score. diff --git a/chat/cache/store.go b/chat/cache/store.go index 89462a4..df109d3 100644 --- a/chat/cache/store.go +++ b/chat/cache/store.go @@ -282,6 +282,10 @@ func (c *Cache) GetRecentSenders(ctx context.Context, chatID *commonpb.ChatId, l return c.db.GetRecentSenders(ctx, chatID, limit) } +func (c *Cache) GetActiveSenders(ctx context.Context, chatID *commonpb.ChatId, limit int) ([]chat.RecentSender, error) { + return c.db.GetActiveSenders(ctx, chatID, limit) +} + func (c *Cache) GetLastSentAt(ctx context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (time.Time, bool, error) { return c.db.GetLastSentAt(ctx, chatID, userID) } diff --git a/chat/dynamodb/store.go b/chat/dynamodb/store.go index f0e04d1..8b3f869 100644 --- a/chat/dynamodb/store.go +++ b/chat/dynamodb/store.go @@ -124,13 +124,15 @@ import ( // // lsiByActivityScore orders the same partition by // activity_score, a frequency-weighted ordering that every -// recorded send maintains (see RecordSend) and nothing reads yet. -// The score is epoch-ms-denominated, equal to last_sent_at for a -// user with one recorded send and running ahead of it (possibly -// past now) as their sends accumulate, so a row written before -// scores existed, which is missing from the index, can be given -// activity_score = last_sent_at and compare correctly with the -// rest; until it is, it reads and scores as if it had been. +// recorded send maintains (see RecordSend), read by +// GetActiveSenders. The score is epoch-ms-denominated, equal to +// last_sent_at for a user with one recorded send and running +// ahead of it (possibly past now) as their sends accumulate, so a +// row written before scores existed, which is missing from the +// index, was given activity_score = last_sent_at by a one-off +// backfill and compares correctly with the rest; one that +// somehow still lacks it reads and scores as if it had it, but +// GetActiveSenders does not find it. // // chat_key_envelopes pk = "user#", sk = "chat#" (one item per // (user, private group) the user holds a key envelope for; see @@ -249,8 +251,9 @@ const ( lsiByLastSentAt = "by_last_sent_at" // lsiByActivityScore is the (chat, activity_score) LSI on chat_activity: - // a group's activity records in activity-score order. Nothing reads it - // yet, and a record written before scores existed is missing from it. + // a group's activity records in activity-score order (see + // GetActiveSenders). It is sparse on activity_score, which records + // written before scores existed lacked until backfilled. lsiByActivityScore = "by_activity_score" // gsiLobbyByChat is the (sk, entered_at) index on chat_lobbies: a group's @@ -2845,11 +2848,26 @@ func activityScoreFromItem(item map[string]types.AttributeValue) (time.Time, err // GetRecentSenders queries lsiByLastSentAt descending, eventually // consistent at half the cost of a strong read (the index is local, so a -// strong one is available if a reader ever needs it). It projects -// last_sent_at as its own key and activity_score as an included attribute, -// so nothing is fetched from the table. Every item in the partition is -// an activity record carrying last_sent_at, so the index holds them all. +// strong one is available if a reader ever needs it). Every item in the +// partition is an activity record carrying last_sent_at, so the index holds +// them all. func (s *store) GetRecentSenders(ctx context.Context, chatID *commonpb.ChatId, limit int) ([]chat.RecentSender, error) { + return s.querySenders(ctx, chatID, lsiByLastSentAt, limit) +} + +// GetActiveSenders queries lsiByActivityScore descending, eventually +// consistent like GetRecentSenders. The index is sparse on activity_score, so +// a record written before scores existed and never backfilled is missing +// from it. +func (s *store) GetActiveSenders(ctx context.Context, chatID *commonpb.ChatId, limit int) ([]chat.RecentSender, error) { + return s.querySenders(ctx, chatID, lsiByActivityScore, limit) +} + +// querySenders reads a group's activity records from one of the two LSIs on +// chat_activity, in descending order of its sort key. Each index projects the +// other's sort key, so a record's send time and score both come from the +// index and nothing is fetched from the table. +func (s *store) querySenders(ctx context.Context, chatID *commonpb.ChatId, index string, limit int) ([]chat.RecentSender, error) { if !chat.IsGroupChatID(chatID) { return nil, fmt.Errorf("not a group chat id") } @@ -2859,7 +2877,7 @@ func (s *store) GetRecentSenders(ctx context.Context, chatID *commonpb.ChatId, l for { input := &dynamodb.QueryInput{ TableName: aws.String(s.activityTable), - IndexName: aws.String(lsiByLastSentAt), + IndexName: aws.String(index), KeyConditionExpression: aws.String("#pk = :pk"), ProjectionExpression: aws.String("#sk, #sent, #score"), ExpressionAttributeNames: map[string]string{"#pk": attrPK, "#sk": attrSK, "#sent": attrLastSentAt, "#score": attrActivityScore}, diff --git a/chat/dynamodb/table.go b/chat/dynamodb/table.go index e1c7ad4..3b62ccc 100644 --- a/chat/dynamodb/table.go +++ b/chat/dynamodb/table.go @@ -24,7 +24,7 @@ import ( // when they end (see gsiByMuted) and an inverted GSI of a chat's records by // user (see gsiUserStateByUser); chat_activity is keyed by (pk, sk) = (chat, // user) with two LSIs, one by last_sent_at (lsiByLastSentAt) and one by -// activity_score (lsiByActivityScore, unread today), and TTL on +// activity_score (lsiByActivityScore), and TTL on // expires_at; chat_key_envelopes is keyed by (pk, sk) = (user, chat) with no // index; chat_lobbies is keyed by (pk, sk) = (user, chat) — plus one "#meta" // aggregates item per chat and per user — with a sparse GSI of a chat's diff --git a/chat/memory/store.go b/chat/memory/store.go index dab156b..acd34be 100644 --- a/chat/memory/store.go +++ b/chat/memory/store.go @@ -884,6 +884,36 @@ func (m *memory) GetRecentSenders(_ context.Context, chatID *commonpb.ChatId, li return senders, nil } +func (m *memory) GetActiveSenders(_ context.Context, chatID *commonpb.ChatId, limit int) ([]chat.RecentSender, error) { + if !chat.IsGroupChatID(chatID) { + return nil, fmt.Errorf("not a group chat id") + } + + m.Lock() + defer m.Unlock() + + senders := make([]chat.RecentSender, 0, len(m.activity[string(chatID.Value)])) + for user, record := range m.activity[string(chatID.Value)] { + senders = append(senders, chat.RecentSender{ + UserID: &commonpb.UserId{Value: []byte(user)}, + LastSentAt: record.lastSentAt, + ActivityScore: record.score, + }) + } + // Ties are in no particular order by contract; break them by user so + // this store is at least deterministic. + sort.Slice(senders, func(i, j int) bool { + if !senders[i].ActivityScore.Equal(senders[j].ActivityScore) { + return senders[i].ActivityScore.After(senders[j].ActivityScore) + } + return bytes.Compare(senders[i].UserID.Value, senders[j].UserID.Value) > 0 + }) + if limit > 0 && len(senders) > limit { + senders = senders[:limit] + } + return senders, nil +} + func (m *memory) GetLastSentAt(_ context.Context, chatID *commonpb.ChatId, userID *commonpb.UserId) (time.Time, bool, error) { if !chat.IsGroupChatID(chatID) { return time.Time{}, false, fmt.Errorf("not a group chat id") diff --git a/chat/model.go b/chat/model.go index 7e05e1e..34bbb58 100644 --- a/chat/model.go +++ b/chat/model.go @@ -843,7 +843,8 @@ func (v ViewerState) Clone() ViewerState { // Beside recency, each record carries an activity score, a frequency-weighted // ordering of the same sends (see NextActivityScore), which is why it is an // activity record and not a send time alone. The score is maintained with -// every recorded send; nothing ranks by it yet. +// every recorded send, and orders a group's chatter sample (see +// Store.GetActiveSenders). const ( // ActivityRecordInterval is the least time between two recorded sends by // one user in one group. @@ -858,9 +859,11 @@ const ( ActivityRetention = 365 * 24 * time.Hour ) -// RecentSender is one user's activity record in a group, as recency reads -// it: who, when their latest recorded send was, and the record's activity -// score (see EffectiveActivityScore), both at millisecond precision. +// RecentSender is one user's activity record in a group, as the reads of a +// group's senders return it (see Store.GetRecentSenders and +// Store.GetActiveSenders): who, when their latest recorded send was, and the +// record's activity score (see EffectiveActivityScore), both at millisecond +// precision. type RecentSender struct { UserID *commonpb.UserId LastSentAt time.Time diff --git a/chat/sample.go b/chat/sample.go index 540a24f..cdd9f63 100644 --- a/chat/sample.go +++ b/chat/sample.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "sort" "time" "go.uber.org/zap" @@ -20,19 +21,23 @@ import ( ) // A sample of a public group's chatters: a short list of its members to show, -// the creator first while they are a member, then the members who have sent -// most recently, most recent first. It is how a group shows who is in it -// without listing members who only read: everyone in it is a member as of the -// read, but a member who has not sent recently is not in it. +// the creator first while they are a member, then the most active members who +// have sent, by activity score (see NextActivityScore) leaning toward +// recency (see sampleRank): a member who shows up every day ranks above one +// who sent once a little more recently, but of two with close scores the +// more recent sender comes first. It is how a group +// shows who is in it without listing members who only read: everyone in it +// is a member as of the read, but a member who has not sent recently is not +// in it. // -// The candidates are the group's recent senders, read from its activity -// records (see RecentSender), which name people who have since left as well -// as members. So each candidate's membership is checked (see +// The candidates are the group's most active senders, read from its activity +// records (see Store.GetActiveSenders), which name people who have since left +// as well as members. So each candidate's membership is checked (see // Store.GetGroupMembersByID), in chunks in candidate order, stopping as // soon as one more member than the sample holds is found: that one proves // has_more without being returned. Each chunk is as many candidates as // members still to find, plus sampleChattersCheckBuffer, so a group whose -// recent senders are all still members is answered by one read, and a +// most active senders are all still members is answered by one read, and a // later read checks only about as many as are still missing. The read of // senders is bounded too; when it fills and too few of the senders are // still members, has_more is set, @@ -47,8 +52,8 @@ import ( // (see ActivityRetention) is not a candidate until they send again. // // The creator is a candidate whether or not they have sent, and is shown at -// their own last send whenever they have a record, however many others have -// sent since: one outside the senders read is looked up directly (see +// their own last send whenever they have a record, however many others rank +// above them: one outside the senders read is looked up directly (see // Store.GetLastSentAt), only when they are shown and the read of senders // filled, since otherwise it holds every record the group has. // @@ -69,7 +74,7 @@ const ( // the max_items on SampleChattersResponse.chatters. sampleChattersSize = 20 - // sampleChattersSenderWindow is how many of a group's most recent + // sampleChattersSenderWindow is how many of a group's most active // senders are candidates for the sample. sampleChattersSenderWindow = 100 @@ -77,8 +82,27 @@ const ( // still to find are checked per read, to absorb a few who have left // without another read. sampleChattersCheckBuffer = 5 + + // sampleChattersRecencyWeight is how far a sender's rank leans from + // their activity score toward their last send (see sampleRank): 0 ranks + // by score alone, 1 by recency alone. + sampleChattersRecencyWeight = 0.2 ) +// sampleRank is the key a sender is ranked by in a sample, highest first: +// their activity score less sampleChattersRecencyWeight of its lead over +// their last send. Comparing two senders, the more recent one ranks first +// exactly when the gap between their last sends is more than +// (1 − w) / w times the gap between their scores (4 times at w = 0.2): close +// scores go to the more recent sender, distant ones to the more active. It +// only reorders the senders read, which are the most active by score alone, +// so a sender just outside that read cannot be ranked in by recency; the +// read is five times the sample, so one that would is far down it. +func sampleRank(sender RecentSender) time.Time { + lead := sender.ActivityScore.Sub(sender.LastSentAt) + return sender.ActivityScore.Add(-time.Duration(sampleChattersRecencyWeight * float64(lead))) +} + func (s *Server) SampleChatters(ctx context.Context, req *chatpb.SampleChattersRequest) (*chatpb.SampleChattersResponse, error) { log := s.log.With(zap.String("chat_id", model.ChatIDString(req.ChatId))) if req.Auth != nil { @@ -126,13 +150,21 @@ type sampleCandidate struct { // sampleChatters builds the sample of the public group c, as described above. func (s *Server) sampleChatters(ctx context.Context, c *Chat) ([]*chatpb.SampledChatter, bool, error) { - senders, err := s.chats.GetRecentSenders(ctx, c.ID, sampleChattersSenderWindow) + senders, err := s.chats.GetActiveSenders(ctx, c.ID, sampleChattersSenderWindow) if err != nil { return nil, false, err } + // By rank, and of two with the same rank, the more recent sender first. + sort.SliceStable(senders, func(i, j int) bool { + ri, rj := sampleRank(senders[i]), sampleRank(senders[j]) + if !ri.Equal(rj) { + return ri.After(rj) + } + return senders[i].LastSentAt.After(senders[j].LastSentAt) + }) // The creator first, at their send time if the senders read holds it - // (otherwise looked up below), then every other sender in recency order. + // (otherwise looked up below), then every other sender in rank order. candidates := make([]sampleCandidate, 0, len(senders)+1) if c.CreatorID != nil { creator := sampleCandidate{userID: c.CreatorID, isCreator: true} diff --git a/chat/store.go b/chat/store.go index 14ad1ff..472d1aa 100644 --- a/chat/store.go +++ b/chat/store.go @@ -507,6 +507,17 @@ type Store interface { // chat ID. GetRecentSenders(ctx context.Context, chatID *commonpb.ChatId, limit int) ([]RecentSender, error) + // GetActiveSenders returns the group's activity records most active + // first, by activity score (see NextActivityScore), at most limit of them + // (limit <= 0 means unbounded), from an eventually consistent read, as + // GetRecentSenders. Ties come back in no particular order. A record + // written before scores existed and never since backfilled is not + // returned. Records of users who have since left are included, and a + // record past ActivityRetention may be. A group with no records, or that + // does not exist, is an empty result. It returns an error if chatID is + // not a group chat ID. + GetActiveSenders(ctx context.Context, chatID *commonpb.ChatId, limit int) ([]RecentSender, error) + // GetLastSentAt returns userID's activity record in the group chatID: // when their latest recorded send was, and whether they have a record at // all. It is the point read of what GetRecentSenders ranges over, for a diff --git a/chat/tests/server.go b/chat/tests/server.go index 3539572..b2b6734 100644 --- a/chat/tests/server.go +++ b/chat/tests/server.go @@ -78,6 +78,7 @@ func RunServerTests(t *testing.T, s chat.Store, teardown func()) { testServer_SampleChatters, testServer_SampleChatters_Size, testServer_SampleChatters_Window, + testServer_SampleChatters_Activity, testServer_SampleChatters_Gates, testServer_GetDmChatFeed_Empty, testServer_GetDmChatFeed_OrderAndContent, @@ -1250,6 +1251,46 @@ func testServer_SampleChatters_Window(t *testing.T, s chat.Store) { require.True(t, resp.Chatters[0].LastSentAt.AsTime().Equal(at(0))) } +func testServer_SampleChatters_Activity(t *testing.T, s chat.Store) { + e := newServerEnv(t, s) + + creator := model.MustGenerateUserID() + regular := model.MustGenerateUserID() // sent daily for a week + oneOff := model.MustGenerateUserID() // sent once, a day after the regular + nearby := model.MustGenerateUserID() // sent once, later, at a score just below the regular's + groupID := e.putGroupWithCreator("Regulars", creator, regular, oneOff, nearby) + + day := 24 * time.Hour + var score, last time.Time + for d := range 7 { + sentAt := at(0).Add(time.Duration(d) * day) + e.recordSend(groupID, regular, sentAt) + score, last = chat.NextActivityScore(score, last, sentAt), sentAt + } + e.recordSend(groupID, oneOff, last.Add(day)) + + // Distant scores go to the more active: the regular, about six days + // ahead of their last send, above someone who sent once a day after it. + // Each is shown at their own last send. + resp := e.sampleChatters(e.keys, groupID) + require.Equal(t, chatpb.SampleChattersResponse_OK, resp.Result) + require.Equal(t, [][]byte{creator.Value, regular.Value, oneOff.Value}, sampledUserIDs(resp.Chatters)) + require.True(t, resp.Chatters[1].LastSentAt.AsTime().Equal(last)) + require.True(t, resp.Chatters[2].LastSentAt.AsTime().Equal(last.Add(day))) + + // Close scores go to the more recent: a single send at nine tenths of + // the regular's lead scores just below them, but its last send is so + // much later that it ranks first. + lead := score.Sub(last) + nearbyAt := last.Add(lead * 9 / 10) + e.recordSend(groupID, nearby, nearbyAt) + senders, err := s.GetActiveSenders(e.ctx, groupID, 0) + require.NoError(t, err) + require.Equal(t, regular.Value, senders[0].UserID.Value, "by score alone, the regular leads") + resp = e.sampleChatters(e.keys, groupID) + require.Equal(t, [][]byte{creator.Value, nearby.Value, regular.Value, oneOff.Value}, sampledUserIDs(resp.Chatters)) +} + func testServer_SampleChatters_Gates(t *testing.T, s chat.Store) { e := newServerEnv(t, s) _, strangerKeys := e.addUser() diff --git a/chat/tests/store.go b/chat/tests/store.go index 01cda06..f517322 100644 --- a/chat/tests/store.go +++ b/chat/tests/store.go @@ -90,6 +90,7 @@ func RunStoreTests(t *testing.T, s chat.Store, newStore func(excludedFromFeed [] testStore_Activity_Concurrent, testStore_Activity_Score, testStore_Activity_ScoreConcurrent, + testStore_Activity_ActiveSenders, testStore_KeyEnvelope_SetAndGet, testStore_KeyEnvelope_OwnWrapStands, testStore_KeyEnvelope_DiscardedOnLeave, @@ -2922,6 +2923,54 @@ func testStore_Activity_ScoreConcurrent(t *testing.T, s chat.Store) { } } +func testStore_Activity_ActiveSenders(t *testing.T, s chat.Store) { + ctx := context.Background() + + groupID := chat.MustGenerateGroupChatID() + regular, oneOff, quiet := model.MustGenerateUserID(), model.MustGenerateUserID(), model.MustGenerateUserID() + day := 24 * time.Hour + + senders, err := s.GetActiveSenders(ctx, groupID, 0) + require.NoError(t, err) + require.Empty(t, senders) + + // A regular who sent daily for a week, a user who sent once after them, + // and one who sent once before. + recorded, err := s.RecordSend(ctx, groupID, quiet, at(0)) + require.NoError(t, err) + require.True(t, recorded) + for d := range 7 { + recorded, err := s.RecordSend(ctx, groupID, regular, at(0).Add(time.Duration(d+1)*day)) + require.NoError(t, err) + require.True(t, recorded) + } + recorded, err = s.RecordSend(ctx, groupID, oneOff, at(0).Add(8*day)) + require.NoError(t, err) + require.True(t, recorded) + + // By recency the one-off sender leads; by activity the regular does. + senders, err = s.GetRecentSenders(ctx, groupID, 0) + require.NoError(t, err) + requireRecentSenders(t, senders, oneOff, at(0).Add(8*day), regular, at(0).Add(7*day), quiet, at(0)) + senders, err = s.GetActiveSenders(ctx, groupID, 0) + require.NoError(t, err) + requireRecentSenders(t, senders, regular, at(0).Add(7*day), oneOff, at(0).Add(8*day), quiet, at(0)) + require.True(t, senders[0].ActivityScore.After(senders[1].ActivityScore)) + require.True(t, senders[1].ActivityScore.Equal(at(0).Add(8*day))) + + // A limit keeps the most active. + senders, err = s.GetActiveSenders(ctx, groupID, 2) + require.NoError(t, err) + requireRecentSenders(t, senders, regular, at(0).Add(7*day), oneOff, at(0).Add(8*day)) + + // Records belong to their group; groups only. + senders, err = s.GetActiveSenders(ctx, chat.MustGenerateGroupChatID(), 0) + require.NoError(t, err) + require.Empty(t, senders) + _, err = s.GetActiveSenders(ctx, generateDmChatID(), 0) + require.Error(t, err) +} + // requireRecentSenders asserts senders is exactly the given (user, last sent) // pairs, in order. func requireRecentSenders(t *testing.T, senders []chat.RecentSender, want ...any) {