diff --git a/.agents/skills/chatto-event-sourcing/SKILL.md b/.agents/skills/chatto-event-sourcing/SKILL.md index 5119323fc..5b48b055c 100644 --- a/.agents/skills/chatto-event-sourcing/SKILL.md +++ b/.agents/skills/chatto-event-sourcing/SKILL.md @@ -30,6 +30,13 @@ Authoritative code anchors: ## Core Rules - Durable domain facts go into `EVT`. Do not add durable mirrors in KV or object metadata unless the architecture explicitly calls the state runtime, ephemeral, secret, binary, or cache data. +- Treat every new `EVT` event type as a last resort. It must record an + authoritative fact that changes durable domain state, not implementation + bookkeeping, an invalidation, a work item, or an outbox/recovery trigger. + First exhaust existing domain facts, projections, `RUNTIME_STATE`, transient + live sync, reconciliation, and an explicitly acceptable best-effort effect. + If a new event is still necessary, document why those alternatives cannot + preserve the required domain behavior. - Domain state interactions should go through a Service for that domain. Avoid direct JetStream, KV, object-store, or projection manipulation from unrelated code. - `ChattoCore` is a facade and wiring point. Prefer moving domain-specific write/readiness logic into a focused Service. - Projections are process-local read models rebuilt from `EVT`. They are not locks, coordination points, or sources of durable truth. @@ -61,15 +68,17 @@ For a hot, high-fanout `RUNTIME_STATE` or `MEMORY_CACHE` read path: Answer these questions before editing: 1. Is this a durable domain fact, runtime state, transient live sync, binary/object data, a secret, or a cache? -2. Which Service owns the domain? If none exists, should this change introduce one? -3. Which aggregate owns the event subject? -4. What invariant does the OCC filter protect? -5. Which projections must consume the event? -6. Which projections must be current before the mutation returns? -7. Does the write need to publish transient `LiveEvent`s, or will `EVT` republish through `live.evt.>` be enough? -8. What happens with multiple replicas racing the same write? -9. What happens on forward deploy, mixed-version rolling deploy, and rollback? -10. Which focused tests lock down the subject, replay, OCC, projection, and delivery behavior? +2. If this proposes a new `EVT` event, what durable domain state changes, and + why can no existing fact or non-EVT mechanism represent the requirement? +3. Which Service owns the domain? If none exists, should this change introduce one? +4. Which aggregate owns the event subject? +5. What invariant does the OCC filter protect? +6. Which projections must consume the event? +7. Which projections must be current before the mutation returns? +8. Does the write need to publish transient `LiveEvent`s, or will `EVT` republish through `live.evt.>` be enough? +9. What happens with multiple replicas racing the same write? +10. What happens on forward deploy, mixed-version rolling deploy, and rollback? +11. Which focused tests lock down the subject, replay, OCC, projection, and delivery behavior? ## Choosing An Aggregate Subject @@ -188,6 +197,11 @@ Check: Treat the interval between an EVT commit and completion of any KMS, LiveKit, object-store, webhook, or other external side effect as a crash boundary. +- Recovery requirements alone do not justify a new event type. Recover work + from the existing domain-changing fact whenever possible. If no such fact + exists, decide explicitly whether the effect belongs in `RUNTIME_STATE`, can + be reconciled from current authority, or may remain best-effort before + proposing another `EVT` fact. - External side effects required by a committed fact must be retryable from durable state after process restart and lease-holder turnover. - A process-local retry queue may reduce recovery latency, but must not be the diff --git a/apps/docs-website/astro.config.mjs b/apps/docs-website/astro.config.mjs index 9c2d34c2a..6cb7e0842 100644 --- a/apps/docs-website/astro.config.mjs +++ b/apps/docs-website/astro.config.mjs @@ -134,7 +134,6 @@ export default defineConfig({ "reference/connectrpc-api/messages", "reference/connectrpc-api/message-search", "reference/connectrpc-api/account", - "reference/connectrpc-api/notification-preferences", "reference/connectrpc-api/notifications", "reference/connectrpc-api/push-notifications", "reference/connectrpc-api/roles", diff --git a/apps/docs-website/src/content/docs/guides/operations/notifications-web-push.mdx b/apps/docs-website/src/content/docs/guides/operations/notifications-web-push.mdx index 6fdc6ce74..e50fd76e6 100644 --- a/apps/docs-website/src/content/docs/guides/operations/notifications-web-push.mdx +++ b/apps/docs-website/src/content/docs/guides/operations/notifications-web-push.mdx @@ -18,22 +18,33 @@ Chatto creates persistent notifications for attention-worthy events: | Role, `@all`, and `@here` mentions | Notify matching recipients, with confirmation for broad sends. | | Replies to your message | Notify you. | | Followed thread replies | Notify followers. | -| Rooms set to all messages | Notify for every root message in that room. | +| Followed room activity | Notify according to the followed-room policy. | +| Reactions to your messages | Add a non-interruptive notification by default. | -Notifications are stored for the user, survive browser restarts, sync across tabs and devices, and expire after 90 days. Dismissing a notification clears it everywhere for that user. +Notifications are stored for the user, survive browser restarts, sync across tabs and devices, and expire after 90 days. Read and Delete changes synchronize everywhere for that user. -## Notification Levels +## Notification Centre -Members can choose notification levels per room: +The notification centre shows one chronological list: -| Level | Effect | -| ----- | ------ | -| Default | Inherit the parent/default behavior. | -| Muted | Suppress notifications and unread sidebar state for that room. | -| Normal | Notify for mentions, DMs, and thread replies. | -| All messages | Notify for mentions, DMs, thread replies, and every root message. | +- Unread and read notifications use the normal treatment until you mark or delete them. +- Read notifications stay in the same chronological list without unread emphasis. The trash action permanently dismisses a notification without requiring you to open it. +- Delete permanently removes a notification from the list. -Muted rooms suppress mentions too. Treat mute as "leave this room out of my attention surface." +Related activity is grouped by DM, room, thread, or reacted-to message. Opening a group goes to its newest unread event, or its newest event when everything in the group is read. Reading a room or thread marks covered notifications read. All notifications expire 90 days after their source activity. + +## Notification Policy + +Members control each notification cause independently at server level and, where useful, per room: + +| Intensity | Effect | +| --------- | ------ | +| Inherit | Use the next broader server or product default. | +| Off | Do not create a notification for this cause. | +| Badge | Add it to the notification list and badge counts without sound or Web Push. | +| Alert | Add it to the notification list and allow sound and Web Push. | + +Direct messages, direct mentions, replies, role mentions, `@here`, and `@all` alert by default. Followed threads and reactions use Badge by default; followed rooms are Off until enabled. A message that matches several causes creates one notification using the strongest effective intensity. Notification policy affects future activity and does not hide ordinary unread room state. ## Do Not Disturb @@ -85,11 +96,11 @@ Use a `mailto:` or HTTPS contact URI for `vapid_subject`. Keep the private key s ## Device Behavior -Each browser or installed PWA creates its own subscription. A user can have multiple subscribed devices, and each device receives the same push notification. +Each browser or installed PWA creates its own subscription. Chatto attempts every current device for each push. Once any device accepts a notification, Chatto does not retry the complete device set merely because another endpoint failed; this avoids repeatedly alerting healthy devices. When a user reopens Chatto, the app refreshes the server's stored subscription for that browser. Expired subscriptions are cleaned up automatically when push providers report them as gone. -Chatto sends regular notifications in a declarative-compatible Web Push format, with browser-managed display and app-badge updates where supported. User-visible pushes request high-urgency delivery so mobile push services can wake sleeping devices promptly; silent cross-device dismissal updates remain normal urgency. Its service worker preserves notification display and click compatibility and updates a closed app's badge after cross-device dismissals. Operators do not need to change VAPID keys or push configuration for this behavior. +Chatto sends regular notifications in a declarative-compatible Web Push format, with browser-managed display and app-badge updates where supported. User-visible pushes request high-urgency delivery so mobile push services can wake sleeping devices promptly. Its service worker preserves notification display and click compatibility for browsers that do not handle the declarative payload directly. Read and delete actions synchronize inside Chatto; they do not attempt to retract an OS notification that a browser already displayed. Operators do not need to change VAPID keys or push configuration for this behavior. On iOS and iPadOS, Web Push works only for supported Home Screen web apps. Safari tabs that are not installed as an app may not receive native push. diff --git a/apps/docs-website/src/content/docs/reference/connectrpc-api/index.mdx b/apps/docs-website/src/content/docs/reference/connectrpc-api/index.mdx index c4af44b7a..8d98179b6 100644 --- a/apps/docs-website/src/content/docs/reference/connectrpc-api/index.mdx +++ b/apps/docs-website/src/content/docs/reference/connectrpc-api/index.mdx @@ -223,8 +223,7 @@ Use the service pages below for request and response fields. Shared messages and - [MessageService](/reference/connectrpc-api/messages/) - Message creation, editing, deletion, composer link-preview, reaction, and attachment RPCs. - [MessageSearchService](/reference/connectrpc-api/message-search/) - Authorized full-text message search and provider availability RPCs. - [MyAccountService](/reference/connectrpc-api/account/) - Self-service account, profile, avatar, presence, status, external identity, and settings RPCs for the authenticated user. -- [NotificationPreferencesService](/reference/connectrpc-api/notification-preferences/) - Server and room notification preference RPCs. -- [NotificationService](/reference/connectrpc-api/notifications/) - Notification listing, counts, checks, and dismissal RPCs. +- [NotificationService](/reference/connectrpc-api/notifications/) - Grouped notification listing, read, deletion, and per-cause policy RPCs. - [PushNotificationService](/reference/connectrpc-api/push-notifications/) - Web Push subscription RPCs. - [RoleService](/reference/connectrpc-api/roles/) - Authenticated role catalog read RPCs. - [RoomDirectoryService](/reference/connectrpc-api/room-directory/) - Room navigation, room group, and room viewer-state RPCs. diff --git a/apps/docs-website/src/content/docs/reference/connectrpc-api/notification-preferences.mdx b/apps/docs-website/src/content/docs/reference/connectrpc-api/notification-preferences.mdx deleted file mode 100644 index cf517caad..000000000 --- a/apps/docs-website/src/content/docs/reference/connectrpc-api/notification-preferences.mdx +++ /dev/null @@ -1,138 +0,0 @@ ---- -title: NotificationPreferencesService -description: Server and room notification preference RPCs. -editUrl: false ---- - -{/* Generated from proto/chatto/{auth,discovery,api,admin,realtime}/v1/*.proto. Do not edit directly. */} - -Chatto exposes this service below `/api/connect`. - - - -Shared message and enum definitions are documented in [Shared Types And Enums](/reference/connectrpc-api/types/). - - - - - -## NotificationPreferencesService - -Reads and updates per-room notification preferences for the current user. - - - -### GetServerNotificationPreference - -Returns the current user's explicit and effective server notification level. - -```http -POST /api/connect/chatto.api.v1.NotificationPreferencesService/GetServerNotificationPreference -``` - - - -#### Input: GetServerNotificationPreferenceRequest - -Request for the current user's server-level notification preference. - - - - -#### Result: GetNotificationPreferenceResponse - -Current notification preference. - -| Field | Type | Description | -| --- | --- | --- | -| `preference` | [`NotificationPreference`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationPreference) | Current stored and effective notification preference. | - - - - -### UpdateServerNotificationPreference - -Updates the current user's explicit server notification level and returns the -resulting stored and effective levels. - -```http -POST /api/connect/chatto.api.v1.NotificationPreferencesService/UpdateServerNotificationPreference -``` - - - -#### Input: UpdateServerNotificationPreferenceRequest - -Request to update the current user's server-level notification level. - -| Field | Type | Description | -| --- | --- | --- | -| `level` | [`NotificationLevel`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationLevel) | Required. New explicit notification level. Use NOTIFICATION_LEVEL_DEFAULT to return the server to default behavior. | - - - - -#### Result: UpdateNotificationPreferenceResponse - -Updated notification preference. - -| Field | Type | Description | -| --- | --- | --- | -| `preference` | [`NotificationPreference`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationPreference) | Stored and effective notification preference after the update. | - - - - -### GetRoomNotificationPreference - -Returns the current user's explicit and effective notification level for a -room. Use this before rendering room notification controls so the UI can -distinguish inherited defaults from an explicit room override. Returns -NOT_FOUND when the room does not exist and PERMISSION_DENIED when the room -exists but is inaccessible to the caller. - -```http -POST /api/connect/chatto.api.v1.NotificationPreferencesService/GetRoomNotificationPreference -``` - - - -#### Input: GetRoomNotificationPreferenceRequest - -Request for the current user's notification preference in one room. - -| Field | Type | Description | -| --- | --- | --- | -| `room_id` | `string` | Required. Room whose notification preference should be loaded for the current user. | - - -#### Result: [GetNotificationPreferenceResponse](#chatto-api-v1-GetNotificationPreferenceResponse) - -Uses the same result shape documented above. - - - -### UpdateRoomNotificationPreference - -Updates the current user's explicit notification level for a room and returns -the resulting stored and effective levels. - -```http -POST /api/connect/chatto.api.v1.NotificationPreferencesService/UpdateRoomNotificationPreference -``` - - - -#### Input: UpdateRoomNotificationPreferenceRequest - -Request to update the current user's notification level in one room. - -| Field | Type | Description | -| --- | --- | --- | -| `room_id` | `string` | Required. Room whose notification level should be changed for the current user. | -| `level` | [`NotificationLevel`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationLevel) | Required. New explicit notification level. Use NOTIFICATION_LEVEL_DEFAULT to return the room to inherited/default behavior. | - - -#### Result: [UpdateNotificationPreferenceResponse](#chatto-api-v1-UpdateNotificationPreferenceResponse) - -Uses the same result shape documented above. diff --git a/apps/docs-website/src/content/docs/reference/connectrpc-api/notifications.mdx b/apps/docs-website/src/content/docs/reference/connectrpc-api/notifications.mdx index f82d47a6e..abbe93778 100644 --- a/apps/docs-website/src/content/docs/reference/connectrpc-api/notifications.mdx +++ b/apps/docs-website/src/content/docs/reference/connectrpc-api/notifications.mdx @@ -1,6 +1,6 @@ --- title: NotificationService -description: Notification listing, counts, checks, and dismissal RPCs. +description: Grouped notification listing, read, deletion, and per-cause policy RPCs. editUrl: false --- @@ -18,252 +18,237 @@ Shared message and enum definitions are documented in [Shared Types And Enums](/ ## NotificationService -Reads and dismisses pending notifications for the authenticated viewer. +Reads, marks read, and deletes notifications for the authenticated viewer. - + -### ListNotifications +### ListNotificationGroups -Lists the authenticated viewer's pending notifications. +Lists Notifications 2.0 groups. ```http -POST /api/connect/chatto.api.v1.NotificationService/ListNotifications +POST /api/connect/chatto.api.v1.NotificationService/ListNotificationGroups ``` - + -#### Input: ListNotificationsRequest +#### Input: ListNotificationGroupsRequest -Request for the authenticated viewer's pending notifications. +Request for one page of grouped notification occurrences. | Field | Type | Description | | --- | --- | --- | | `page` | [`PageRequest`](/reference/connectrpc-api/types/#chatto-api-v1-PageRequest) | Page request. Defaults to 50 results when absent or limit is zero. | - + -#### Result: ListNotificationsResponse +#### Result: ListNotificationGroupsResponse -Pending notification page. +One page of derived notification groups. | Field | Type | Description | | --- | --- | --- | -| `notifications` | repeated [`NotificationItem`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationItem) | Page notifications, newest first. | +| `groups` | repeated [`NotificationGroup`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationGroup) | Groups newest activity first. | | `page` | [`PageInfo`](/reference/connectrpc-api/types/#chatto-api-v1-PageInfo) | Page metadata. | +| `unread_group_count` | `int32` | Total unread group count. | +| `next_expiry_at` | `google.protobuf.Timestamp` | Earliest expiry in the complete list, including groups outside this page. Clients refresh authoritative notification state at this boundary. | +| `room_unread_group_counts` | repeated [`NotificationRoomUnreadGroupCount`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationRoomUnreadGroupCount) | Complete unread-group counts by target room. This is independent of the bounded group page so room indicators remain authoritative. | - + -### GetNotification +### MarkNotificationRead -Gets one pending notification. Returns NOT_FOUND when the notification is -unknown or has been dismissed. +Marks one occurrence read. Repeating the call is safe. ```http -POST /api/connect/chatto.api.v1.NotificationService/GetNotification +POST /api/connect/chatto.api.v1.NotificationService/MarkNotificationRead ``` - + -#### Input: GetNotificationRequest +#### Input: MarkNotificationReadRequest -Request one pending notification for the authenticated viewer. +Request to mark one notification occurrence read. | Field | Type | Description | | --- | --- | --- | -| `notification_id` | `string` | Required notification ID. | +| `notification_id` | `string` | Required stable occurrence ID. | - + -#### Result: GetNotificationResponse +#### Result: MarkNotificationReadResponse -Pending notification response. +Notification occurrence after marking it read. | Field | Type | Description | | --- | --- | --- | -| `notification` | [`NotificationItem`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationItem) | Requested notification. | +| `notification` | [`NotificationOccurrence`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationOccurrence) | Updated occurrence. | - + -### BatchGetNotifications +### DeleteNotificationOccurrence -Gets pending notifications by ID. +Permanently deletes one occurrence while retaining its anti-recreation +tombstone through the original expiry. Repeating the call is safe. ```http -POST /api/connect/chatto.api.v1.NotificationService/BatchGetNotifications +POST /api/connect/chatto.api.v1.NotificationService/DeleteNotificationOccurrence ``` - + -#### Input: BatchGetNotificationsRequest +#### Input: DeleteNotificationOccurrenceRequest -Request pending notifications for a set of stable notification IDs. +Request permanent deletion of one notification occurrence. | Field | Type | Description | | --- | --- | --- | -| `notification_ids` | `repeated string` | Required notification IDs. Unknown or dismissed IDs are omitted from the response. | +| `notification_id` | `string` | Required stable occurrence ID. | - + -#### Result: BatchGetNotificationsResponse +#### Result: DeleteNotificationOccurrenceResponse -Batch pending notification response. +Result of deleting one notification occurrence. | Field | Type | Description | | --- | --- | --- | -| `notifications` | repeated [`NotificationItem`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationItem) | Found notifications. The server preserves first-seen request order and de-duplicates repeated IDs. | +| `deleted` | `bool` | True when this call replaced a visible occurrence with a deletion tombstone. False when the occurrence was already absent or deleted. | - + -### ListRoomNotifications +### DeleteNotificationGroup -Lists pending notifications for one room. Non-members receive an empty page. +Permanently deletes occurrences currently belonging to one derived group. +Group membership is captured when the request is handled. Callers must not +retry this mutation automatically because later activity may reuse the +same derived group ID. ```http -POST /api/connect/chatto.api.v1.NotificationService/ListRoomNotifications +POST /api/connect/chatto.api.v1.NotificationService/DeleteNotificationGroup ``` - + -#### Input: ListRoomNotificationsRequest +#### Input: DeleteNotificationGroupRequest -Request for pending notifications scoped to one room. +Request permanent deletion of one derived notification group. | Field | Type | Description | | --- | --- | --- | -| `room_id` | `string` | Required. Room whose notifications should be listed. | -| `page` | [`PageRequest`](/reference/connectrpc-api/types/#chatto-api-v1-PageRequest) | Page request. Defaults to 50 results when absent or limit is zero. | - - - - -#### Result: ListRoomNotificationsResponse - -Pending notification page scoped to one room. - -| Field | Type | Description | -| --- | --- | --- | -| `notifications` | repeated [`NotificationItem`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationItem) | Page notifications, newest first. | -| `page` | [`PageInfo`](/reference/connectrpc-api/types/#chatto-api-v1-PageInfo) | Page metadata. | - - - +| `group_id` | `string` | Required stable group ID. | -### ListRoomNotificationCounts -Lists pending notification counts grouped by room as a finite snapshot. - -```http -POST /api/connect/chatto.api.v1.NotificationService/ListRoomNotificationCounts -``` - - - -#### Input: ListRoomNotificationCountsRequest - -Request for pending notification counts grouped by room. + +#### Result: DeleteNotificationGroupResponse - - -#### Result: ListRoomNotificationCountsResponse - -Finite snapshot of pending notification counts grouped by room. +Result of deleting one derived notification group. | Field | Type | Description | | --- | --- | --- | -| `room_counts` | repeated [`RoomNotificationCount`](/reference/connectrpc-api/types/#chatto-api-v1-RoomNotificationCount) | Counts for rooms with at least one pending notification. | +| `deleted_count` | `int32` | Number of visible occurrences replaced by deletion tombstones. | - + -### HasNotifications +### DeleteAllNotificationOccurrences -Checks whether the authenticated viewer has any pending notifications. +Permanently deletes every occurrence current at the server's authoritative +mutation boundary. Callers must not retry this mutation automatically +because later activity may arrive after that boundary. ```http -POST /api/connect/chatto.api.v1.NotificationService/HasNotifications +POST /api/connect/chatto.api.v1.NotificationService/DeleteAllNotificationOccurrences ``` - + -#### Input: HasNotificationsRequest +#### Input: DeleteAllNotificationOccurrencesRequest -Request for a lightweight pending notification check. +Request permanent deletion of every current notification occurrence. - + -#### Result: HasNotificationsResponse +#### Result: DeleteAllNotificationOccurrencesResponse -Lightweight pending notification check. +Result of deleting every current notification occurrence. | Field | Type | Description | | --- | --- | --- | -| `has_notifications` | `bool` | True when the authenticated viewer has at least one pending notification. | +| `deleted_count` | `int32` | Number of current occurrences replaced by deletion tombstones. | - + -### DismissNotification +### GetNotificationPolicy -Dismisses one pending notification. Already-dismissed notifications are -treated as idempotent success. +Gets every supported cause and its effective inherited delivery intensity. ```http -POST /api/connect/chatto.api.v1.NotificationService/DismissNotification +POST /api/connect/chatto.api.v1.NotificationService/GetNotificationPolicy ``` - + -#### Input: DismissNotificationRequest +#### Input: GetNotificationPolicyRequest -Request to dismiss one pending notification. +Request the authenticated viewer's notification policy. | Field | Type | Description | | --- | --- | --- | -| `notification_id` | `string` | Required. Notification to dismiss. | +| `room_id` | `optional string` | Empty returns server-scoped preferences. A room ID returns the inherited effective policy for that room and requires current membership. | - + -#### Result: DismissNotificationResponse +#### Result: GetNotificationPolicyResponse -Result of dismissing one pending notification. +Complete supported notification policy for one scope. | Field | Type | Description | | --- | --- | --- | -| `dismissed` | `bool` | True when the notification is no longer pending. | +| `room_id` | `optional string` | Room scope when requested; absent for server scope. | +| `preferences` | repeated [`NotificationPolicyPreference`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationPolicyPreference) | One row for every supported notification cause. | - + -### DismissAllNotifications +### SetNotificationPolicyPreference -Dismisses all pending notifications. +Sets or clears one server- or room-scoped cause override. ```http -POST /api/connect/chatto.api.v1.NotificationService/DismissAllNotifications +POST /api/connect/chatto.api.v1.NotificationService/SetNotificationPolicyPreference ``` - + + +#### Input: SetNotificationPolicyPreferenceRequest -#### Input: DismissAllNotificationsRequest +Set or clear one notification preference override. -Request to dismiss all pending notifications. +| Field | Type | Description | +| --- | --- | --- | +| `room_id` | `optional string` | Room scope to change; absent changes the server scope. | +| `reason` | [`NotificationReason`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationReason) | Required notification cause. | +| `intensity` | [`NotificationDeliveryIntensity`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationDeliveryIntensity) | Unspecified clears the selected server or room override. | - + -#### Result: DismissAllNotificationsResponse +#### Result: SetNotificationPolicyPreferenceResponse -Result of dismissing all pending notifications. +Complete supported notification policy after one preference change. | Field | Type | Description | | --- | --- | --- | -| `dismissed_count` | `int32` | Number of notifications dismissed. | +| `room_id` | `optional string` | Room scope when changed; absent for server scope. | +| `preferences` | repeated [`NotificationPolicyPreference`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationPolicyPreference) | One row for every supported notification cause. | diff --git a/apps/docs-website/src/content/docs/reference/connectrpc-api/realtime.mdx b/apps/docs-website/src/content/docs/reference/connectrpc-api/realtime.mdx index 35729d26a..2787d29db 100644 --- a/apps/docs-website/src/content/docs/reference/connectrpc-api/realtime.mdx +++ b/apps/docs-website/src/content/docs/reference/connectrpc-api/realtime.mdx @@ -104,8 +104,6 @@ delivered only as RealtimeProjectionEvent operations. | `actor_id` | `optional string` | User or system actor that caused the event, when known. | | `event.user_typing` | [`RealtimeTypingEvent`](#chatto-realtime-v1-RealtimeTypingEvent) | A user is typing in a room or thread. | | `event.presence_changed` | [`RealtimePresenceChangedEvent`](#chatto-realtime-v1-RealtimePresenceChangedEvent) | A user's live presence changed. | -| `event.mention_notification` | [`RealtimeMentionNotificationEvent`](#chatto-realtime-v1-RealtimeMentionNotificationEvent) | The current user was mentioned in a room. | -| `event.new_direct_message_notification` | [`RealtimeNewDirectMessageNotificationEvent`](#chatto-realtime-v1-RealtimeNewDirectMessageNotificationEvent) | The current user received a new direct message. | | `event.session_terminated` | [`RealtimeSessionTerminatedEvent`](#chatto-realtime-v1-RealtimeSessionTerminatedEvent) | The current user's session was terminated. | @@ -136,42 +134,6 @@ non-fatal `too_many_retained_rooms` error. | --- | --- | --- | | `room_id` | `string` | Joined room to materialise. Repeating a retained room is idempotent. | - - -### RealtimeMentionNotificationEvent - -Mention attention signal for the connected user. - -Inline names are display hints. Hydrate referenced rooms through -`RoomDirectoryService.BatchGetRooms` and users through -`UserService.BatchGetUsers` when local caches are missing or stale. - -| Field | Type | Description | -| --- | --- | --- | -| `room_id` | `string` | Room where the mention occurred. | -| `actor_user_id` | `string` | Author user ID. | -| `room_name` | `optional string` | Display name of the room where the mention occurred, when hydrated for the caller. | -| `actor_display_name` | `optional string` | Display name of the author who mentioned the connected user, when hydrated. | - - - -### RealtimeNewDirectMessageNotificationEvent - -New-DM attention signal for the connected user. - -Inline names and avatar URLs are display hints. Hydrate the DM room through -`RoomDirectoryService.GetRoom` or `RoomDirectoryService.BatchGetRooms`, and -the sender through `UserService.GetUser` or `UserService.BatchGetUsers` when -local caches are missing or stale. - -| Field | Type | Description | -| --- | --- | --- | -| `room_id` | `string` | DM room ID. | -| `sender_id` | `string` | Sender user ID. | -| `sender_display_name` | `optional string` | Display name of the sender, when hydrated. | -| `sender_avatar_url` | `optional string` | Avatar URL of the sender, when one is available. | -| `conversation_name` | `optional string` | Display name for the DM conversation from the connected user's perspective, when hydrated. | - ### RealtimePing @@ -259,9 +221,8 @@ Finite current notification state emitted on bootstrap and every resume. | Field | Type | Description | | --- | --- | --- | -| `page` | `chatto.api.v1.ListNotificationsResponse` | Newest pending notifications and total pending count. | -| `room_counts` | `repeated chatto.api.v1.RoomNotificationCount` | Complete current counts for rooms with pending notifications. | | `change` | optional [`RealtimeProjectionNotificationChange`](#chatto-realtime-v1-RealtimeProjectionNotificationChange) | Live transition that caused this replacement, when one exists. Bootstrap, replay reconciliation, and compacted reset replacements omit this field. | +| `groups` | `chatto.api.v1.ListNotificationGroupsResponse` | Authoritative Notifications 2.0 groups and unread group count. | @@ -345,7 +306,6 @@ Canonical lightweight room state retained by a server-scoped projection. | --- | --- | --- | | `room` | `chatto.api.v1.RoomWithViewerState` | Public room metadata and viewer-specific state. | | `member_user_ids` | `repeated string` | Current room membership expressed as references into the projection's server-wide user directory. DM participants are always present. Channel membership is empty in a cold room summary and complete after that room's timeline has been materialised. | -| `viewer_notification_count` | `uint32` | Current pending notifications targeting this room for the viewer. | | `has_message_history` | `optional bool` | Whether this DM room has ever received a root message. False means the durable room exists only so its initiator can compose the first message. Once true, message deletion does not reset it. Absent for channel rooms and servers that predate this field. | @@ -571,7 +531,8 @@ Kind of live notification transition. | --- | --- | --- | | `REALTIME_PROJECTION_NOTIFICATION_ACTION_UNSPECIFIED` | `0` | No value description provided. | | `REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED` | `1` | No value description provided. | -| `REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED` | `2` | No value description provided. | +| `REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED` | `3` | No value description provided. | +| `REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED` | `4` | No value description provided. | diff --git a/apps/docs-website/src/content/docs/reference/connectrpc-api/types.mdx b/apps/docs-website/src/content/docs/reference/connectrpc-api/types.mdx index 257c12a9c..83cb8ed4b 100644 --- a/apps/docs-website/src/content/docs/reference/connectrpc-api/types.mdx +++ b/apps/docs-website/src/content/docs/reference/connectrpc-api/types.mdx @@ -126,17 +126,6 @@ Public user fields. | `presence_status` | [`PresenceStatus`](#chatto-api-v1-PresenceStatus) | Current live presence status. | | `custom_status` | [`CustomUserStatus`](#chatto-api-v1-CustomUserStatus) | Custom profile status, when set. | - - -### NotificationPreference - -Stored and effective notification preference. - -| Field | Type | Description | -| --- | --- | --- | -| `level` | [`NotificationLevel`](#chatto-api-v1-NotificationLevel) | Explicit level stored for the current user. | -| `effective_level` | [`NotificationLevel`](#chatto-api-v1-NotificationLevel) | Level after applying defaults and inheritance. | - ### CapabilityGrant @@ -174,17 +163,6 @@ Effective decision for one permission key. | `permission` | `string` | Stable permission key, such as "server.manage". | | `granted` | `bool` | Whether the permission is currently granted. | - - -### RoomNotificationPreference - -Room notification preference for one joined room. - -| Field | Type | Description | -| --- | --- | --- | -| `room_id` | `string` | Room whose preference is represented. | -| `preference` | [`NotificationPreference`](#chatto-api-v1-NotificationPreference) | Stored and effective notification preference. | - ### ServerViewerPermissions @@ -710,79 +688,90 @@ Lightweight room reference for cross-resource rows. | `kind` | [`RoomKind`](#chatto-api-v1-RoomKind) | Room kind. | | `name` | `string` | Room name. Direct-message rooms may have an empty name because clients derive their display label from participants. | - + -### DirectMessageNotification +### NotificationGroup -Direct-message notification payload. +A presentation group derived from related notification occurrences. | Field | Type | Description | | --- | --- | --- | -| `event_id` | `string` | Message event ID. | -| `room` | [`RoomSummary`](#chatto-api-v1-RoomSummary) | DM room where the message was posted. | +| `id` | `string` | Stable ID derived from the viewer and grouping target. | +| `occurrences` | repeated [`NotificationOccurrence`](#chatto-api-v1-NotificationOccurrence) | Bounded newest-occurrence preview. It also includes the open target when that target falls outside the newest preview window. | +| `open_target` | [`NotificationTarget`](#chatto-api-v1-NotificationTarget) | Target to open: newest unread, or newest when all are read. | +| `unread` | `bool` | True when at least one member occurrence is unread. | +| `occurrence_count` | `int32` | Total number of occurrences in this group, including those not in the bounded preview. | +| `latest_at` | `google.protobuf.Timestamp` | Time of the newest occurrence. | +| `strongest_intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Strongest intensity among member occurrences. | +| `reasons` | repeated [`NotificationReason`](#chatto-api-v1-NotificationReason) | Distinct causes represented by member occurrences. | +| `next_expiry_at` | `google.protobuf.Timestamp` | Earliest member expiry. Clients refresh the group at this boundary. | +| `open_notification_id` | `string` | Occurrence ID corresponding to open_target, including when several occurrences share the same message target. | - + -### MentionNotification +### NotificationOccurrence -Mention notification payload. +One exact Notifications 2.0 source occurrence. | Field | Type | Description | | --- | --- | --- | -| `room` | [`RoomSummary`](#chatto-api-v1-RoomSummary) | Room where the mention occurred. | -| `event_id` | `string` | Message event ID. | -| `thread_root_event_id` | `optional string` | Thread root event ID when the mention happened inside a thread. | +| `id` | `string` | Stable occurrence ID. | +| `source_event_id` | `string` | Durable source event from which this occurrence was derived. | +| `created_at` | `google.protobuf.Timestamp` | Time of the source activity. | +| `actor` | [`User`](#chatto-api-v1-User) | User who caused the source activity, when still visible. | +| `target` | [`NotificationTarget`](#chatto-api-v1-NotificationTarget) | Exact current destination for navigation. | +| `reasons` | repeated [`NotificationReasonMatch`](#chatto-api-v1-NotificationReasonMatch) | Every cause that matched when the source activity occurred. | +| `strongest_intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Strongest evaluated intensity across all matching causes. | +| `unread` | `bool` | Whether this occurrence still contributes unread attention. | +| `expires_at` | `google.protobuf.Timestamp` | Absolute expiry, 90 days after the source activity. | - + -### NotificationItem +### NotificationPolicyPreference -One pending notification for the authenticated viewer. +Explicit and effective delivery policy for one notification cause. | Field | Type | Description | | --- | --- | --- | -| `id` | `string` | Stable notification ID. | -| `created_at` | `google.protobuf.Timestamp` | Creation time. | -| `actor` | [`User`](#chatto-api-v1-User) | User who triggered the notification, when still resolvable. | -| `kind.direct_message` | [`DirectMessageNotification`](#chatto-api-v1-DirectMessageNotification) | Direct-message notification. | -| `kind.mention` | [`MentionNotification`](#chatto-api-v1-MentionNotification) | Mention notification. | -| `kind.reply` | [`ReplyNotification`](#chatto-api-v1-ReplyNotification) | Reply notification. | -| `kind.room_message` | [`RoomMessageNotification`](#chatto-api-v1-RoomMessageNotification) | All-messages room notification. | +| `reason` | [`NotificationReason`](#chatto-api-v1-NotificationReason) | Notification cause controlled by this row. | +| `server_intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Explicit server override, or unspecified when inherited from product defaults. | +| `room_intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Explicit room override, or unspecified when inherited from server scope. | +| `effective_intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Effective intensity after applying product, server, and room inheritance. | - + -### ReplyNotification +### NotificationReasonMatch -Reply notification payload. +One cause that matched an occurrence and its evaluated delivery intensity. | Field | Type | Description | | --- | --- | --- | -| `room` | [`RoomSummary`](#chatto-api-v1-RoomSummary) | Room where the reply occurred. | -| `event_id` | `string` | Reply event ID. | -| `in_reply_to_id` | `string` | Event ID of the message being replied to. | -| `thread_root_event_id` | `optional string` | Thread root event ID when the reply happened inside a thread. | +| `reason` | [`NotificationReason`](#chatto-api-v1-NotificationReason) | Cause that matched the viewer. | +| `intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Effective intensity when the source activity occurred. | - + -### RoomMessageNotification +### NotificationRoomUnreadGroupCount -All-messages room notification payload. +Unread notification groups currently targeting one room. | Field | Type | Description | | --- | --- | --- | -| `room` | [`RoomSummary`](#chatto-api-v1-RoomSummary) | Room where the message was posted. | -| `event_id` | `string` | Message event ID. | +| `room_id` | `string` | Target room ID. | +| `unread_group_count` | `int32` | Number of unread groups targeting this room. | - + -### RoomNotificationCount +### NotificationTarget -Pending notification count for one room. +Exact visible destination of one notification occurrence. | Field | Type | Description | | --- | --- | --- | -| `room_id` | `string` | Room ID. | -| `total_count` | `int32` | Pending notification count for the authenticated viewer in this room. | +| `room` | [`RoomSummary`](#chatto-api-v1-RoomSummary) | Room containing the source activity. | +| `event_id` | `string` | Exact source or reacted-to message event to reveal. | +| `thread_root_event_id` | `optional string` | Thread root when the target is inside a thread. | +| `parent_event_id` | `optional string` | Direct reply target when the occurrence was caused by a reply. | @@ -1484,20 +1473,6 @@ the server's live presence record expire. | `PRESENCE_STATUS_DO_NOT_DISTURB` | `3` | The user does not want notifications while this live status is active. | | `PRESENCE_STATUS_OFFLINE` | `4` | The user has no active live presence record. | - - -### NotificationLevel - -Notification delivery level for a room. - -| Name | Number | Description | -| --- | --- | --- | -| `NOTIFICATION_LEVEL_UNSPECIFIED` | `0` | The level was not specified. | -| `NOTIFICATION_LEVEL_DEFAULT` | `1` | Use the inherited default for the room. | -| `NOTIFICATION_LEVEL_MUTED` | `2` | Do not notify for this room. | -| `NOTIFICATION_LEVEL_NORMAL` | `3` | Notify according to the normal room rules. | -| `NOTIFICATION_LEVEL_ALL_MESSAGES` | `4` | Notify for every message in the room. | - ### TimeFormat @@ -1577,6 +1552,39 @@ Kind of room represented by the public API. | `ROOM_KIND_CHANNEL` | `1` | A regular channel governed by server and room permissions. | | `ROOM_KIND_DM` | `2` | A direct-message conversation between members. | + + +### NotificationDeliveryIntensity + +Delivery strength for one notification cause. + +| Name | Number | Description | +| --- | --- | --- | +| `NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED` | `0` | In preference writes, unspecified clears the override (Inherit). | +| `NOTIFICATION_DELIVERY_INTENSITY_OFF` | `1` | Matching activity does not create a notification occurrence. | +| `NOTIFICATION_DELIVERY_INTENSITY_BADGE` | `2` | Matching activity appears in the notification list without interruptive delivery. | +| `NOTIFICATION_DELIVERY_INTENSITY_ALERT` | `3` | Matching activity appears in the notification list and may trigger sound or push. | + + + +### NotificationReason + +Why source activity matched the authenticated viewer's notification policy. + +| Name | Number | Description | +| --- | --- | --- | +| `NOTIFICATION_REASON_UNSPECIFIED` | `0` | No cause was specified. This value is not valid in preference writes. | +| `NOTIFICATION_REASON_DIRECT_MESSAGE` | `1` | A message was posted in a direct-message conversation. | +| `NOTIFICATION_REASON_DIRECT_MENTION` | `2` | The viewer's username was mentioned directly. | +| `NOTIFICATION_REASON_REPLY` | `3` | Activity replied directly to the viewer's message. | +| `NOTIFICATION_REASON_ROLE_MENTION` | `4` | A role held by the viewer was mentioned. | +| `NOTIFICATION_REASON_HERE` | `5` | An `@here` mention included the viewer. | +| `NOTIFICATION_REASON_ALL` | `6` | An `@all` mention included the viewer. | +| `NOTIFICATION_REASON_FOLLOWED_THREAD` | `7` | New activity appeared in a thread followed by the viewer. | +| `NOTIFICATION_REASON_FOLLOWED_ROOM` | `8` | New activity appeared in a room followed by the viewer. | +| `NOTIFICATION_REASON_REACTION` | `9` | Someone reacted to the viewer's message. | +| `NOTIFICATION_REASON_ROOM_INVITATION` | `10` | The viewer was invited to a room. | + ### RoomDirectoryScope diff --git a/apps/docs-website/src/content/docs/reference/connectrpc-api/viewer.mdx b/apps/docs-website/src/content/docs/reference/connectrpc-api/viewer.mdx index 4a3669d9b..c6dfc1843 100644 --- a/apps/docs-website/src/content/docs/reference/connectrpc-api/viewer.mdx +++ b/apps/docs-website/src/content/docs/reference/connectrpc-api/viewer.mdx @@ -48,7 +48,5 @@ Authenticated viewer snapshot needed by application shells. | --- | --- | --- | | `user` | [`ViewerUser`](/reference/connectrpc-api/types/#chatto-api-v1-ViewerUser) | Current authenticated user. | | `capabilities` | [`ViewerCapabilities`](/reference/connectrpc-api/types/#chatto-api-v1-ViewerCapabilities) | Permission-derived capabilities. | -| `server_notification_preference` | [`NotificationPreference`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationPreference) | Server-wide notification preference. | -| `room_notification_preferences` | repeated [`RoomNotificationPreference`](/reference/connectrpc-api/types/#chatto-api-v1-RoomNotificationPreference) | Notification preferences for rooms the user participates in. | | `viewer_permissions` | [`ServerViewerPermissions`](/reference/connectrpc-api/types/#chatto-api-v1-ServerViewerPermissions) | Effective server/channel permission decisions for the authenticated user. | | `viewer_state` | [`ServerViewerState`](/reference/connectrpc-api/types/#chatto-api-v1-ServerViewerState) | Non-permission server state for the authenticated user. | diff --git a/apps/docs-website/src/content/docs/releases/0-5-0.mdx b/apps/docs-website/src/content/docs/releases/0-5-0.mdx index 187ee9209..c693ae18d 100644 --- a/apps/docs-website/src/content/docs/releases/0-5-0.mdx +++ b/apps/docs-website/src/content/docs/releases/0-5-0.mdx @@ -38,6 +38,12 @@ import ReleaseHero from "../../../components/release-notes/ReleaseHero.astro"; menu. Attachment sends now show per-file progress and preserve failed uploads for retry. + + Notifications now stay in one chronological list after you read them. Delete an item whenever + you no longer want to retain it; per-cause controls distinguish mentions, + replies, followed conversations, direct messages, and reactions. + + Room managers can pin useful channel messages in a shared sidebar. A subtle dot shows when a newer pin has not been viewed. @@ -99,6 +105,12 @@ import ReleaseHero from "../../../components/release-notes/ReleaseHero.astro"; ## Upgrade Notes +- Notifications 2.0 replaces the previous notification service and stored records. Existing + pending notifications and notification-level preferences are not migrated, so every account + starts with an empty notification list and the new per-cause defaults. Integrations must + regenerate their clients and move to the new group, occurrence, and policy methods on + `NotificationService`; the legacy list/get/dismiss RPCs and realtime fields no longer work. + Upgrade all replicas and bundled clients together. - Realtime protocol 2 is required by both the 0.5 client and server. Upgrade every server replica and the bundled frontend together; a 0.5 client treats 0.4 servers as incompatible, and a 0.5 server no longer accepts protocol 1. Integrations that use `/api/realtime` must @@ -156,8 +168,8 @@ import ReleaseHero from "../../../components/release-notes/ReleaseHero.astro"; each room while you navigate. - Installed-app badges show exact direct-message counts without presenting unrelated channel notifications as phantom DMs. -- App badges stay synchronized more reliably across regular pushes, dismissals, visible - windows, and supported Safari versions. +- App badges stay synchronized with the authoritative unread notification groups across + regular pushes, visible windows, and supported Safari versions. ### Administration and interface polish diff --git a/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx b/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx index b40582852..2cbe41cc3 100644 --- a/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx +++ b/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx @@ -14,143 +14,6 @@ Endpoint paths use the Connect convention: Use this reference when building clients against Chatto's public ConnectRPC API. - - -## NotificationPreferencesService - -Reads and updates per-room notification preferences for the current user. - - - -### GetServerNotificationPreference - -Returns the current user's explicit and effective server notification level. - -```http -POST /api/connect/chatto.api.v1.NotificationPreferencesService/GetServerNotificationPreference -``` - - - -#### Input: GetServerNotificationPreferenceRequest - -Request for the current user's server-level notification preference. - - - - -#### Result: GetNotificationPreferenceResponse - -Current notification preference. - -| Field | Type | Description | -| --- | --- | --- | -| `preference` | [`NotificationPreference`](#chatto-api-v1-NotificationPreference) | Current stored and effective notification preference. | - - - - -### UpdateServerNotificationPreference - -Updates the current user's explicit server notification level and returns the -resulting stored and effective levels. - -```http -POST /api/connect/chatto.api.v1.NotificationPreferencesService/UpdateServerNotificationPreference -``` - - - -#### Input: UpdateServerNotificationPreferenceRequest - -Request to update the current user's server-level notification level. - -| Field | Type | Description | -| --- | --- | --- | -| `level` | [`NotificationLevel`](#chatto-api-v1-NotificationLevel) | Required. New explicit notification level. Use NOTIFICATION_LEVEL_DEFAULT to return the server to default behavior. | - - - - -#### Result: UpdateNotificationPreferenceResponse - -Updated notification preference. - -| Field | Type | Description | -| --- | --- | --- | -| `preference` | [`NotificationPreference`](#chatto-api-v1-NotificationPreference) | Stored and effective notification preference after the update. | - - - - -### GetRoomNotificationPreference - -Returns the current user's explicit and effective notification level for a -room. Use this before rendering room notification controls so the UI can -distinguish inherited defaults from an explicit room override. Returns -NOT_FOUND when the room does not exist and PERMISSION_DENIED when the room -exists but is inaccessible to the caller. - -```http -POST /api/connect/chatto.api.v1.NotificationPreferencesService/GetRoomNotificationPreference -``` - - - -#### Input: GetRoomNotificationPreferenceRequest - -Request for the current user's notification preference in one room. - -| Field | Type | Description | -| --- | --- | --- | -| `room_id` | `string` | Required. Room whose notification preference should be loaded for the current user. | - - - - -#### Result: GetNotificationPreferenceResponse - -Current notification preference. - -| Field | Type | Description | -| --- | --- | --- | -| `preference` | [`NotificationPreference`](#chatto-api-v1-NotificationPreference) | Current stored and effective notification preference. | - - - - -### UpdateRoomNotificationPreference - -Updates the current user's explicit notification level for a room and returns -the resulting stored and effective levels. - -```http -POST /api/connect/chatto.api.v1.NotificationPreferencesService/UpdateRoomNotificationPreference -``` - - - -#### Input: UpdateRoomNotificationPreferenceRequest - -Request to update the current user's notification level in one room. - -| Field | Type | Description | -| --- | --- | --- | -| `room_id` | `string` | Required. Room whose notification level should be changed for the current user. | -| `level` | [`NotificationLevel`](#chatto-api-v1-NotificationLevel) | Required. New explicit notification level. Use NOTIFICATION_LEVEL_DEFAULT to return the room to inherited/default behavior. | - - - - -#### Result: UpdateNotificationPreferenceResponse - -Updated notification preference. - -| Field | Type | Description | -| --- | --- | --- | -| `preference` | [`NotificationPreference`](#chatto-api-v1-NotificationPreference) | Stored and effective notification preference after the update. | - - ## ViewerService @@ -185,8 +48,6 @@ Authenticated viewer snapshot needed by application shells. | --- | --- | --- | | `user` | [`ViewerUser`](#chatto-api-v1-ViewerUser) | Current authenticated user. | | `capabilities` | [`ViewerCapabilities`](#chatto-api-v1-ViewerCapabilities) | Permission-derived capabilities. | -| `server_notification_preference` | [`NotificationPreference`](#chatto-api-v1-NotificationPreference) | Server-wide notification preference. | -| `room_notification_preferences` | repeated [`RoomNotificationPreference`](#chatto-api-v1-RoomNotificationPreference) | Notification preferences for rooms the user participates in. | | `viewer_permissions` | [`ServerViewerPermissions`](#chatto-api-v1-ServerViewerPermissions) | Effective server/channel permission decisions for the authenticated user. | | `viewer_state` | [`ServerViewerState`](#chatto-api-v1-ServerViewerState) | Non-permission server state for the authenticated user. | @@ -2299,255 +2160,240 @@ Result of removing a room ban. ## NotificationService -Reads and dismisses pending notifications for the authenticated viewer. +Reads, marks read, and deletes notifications for the authenticated viewer. - + -### ListNotifications +### ListNotificationGroups -Lists the authenticated viewer's pending notifications. +Lists Notifications 2.0 groups. ```http -POST /api/connect/chatto.api.v1.NotificationService/ListNotifications +POST /api/connect/chatto.api.v1.NotificationService/ListNotificationGroups ``` - + -#### Input: ListNotificationsRequest +#### Input: ListNotificationGroupsRequest -Request for the authenticated viewer's pending notifications. +Request for one page of grouped notification occurrences. | Field | Type | Description | | --- | --- | --- | | `page` | [`PageRequest`](#chatto-api-v1-PageRequest) | Page request. Defaults to 50 results when absent or limit is zero. | - + -#### Result: ListNotificationsResponse +#### Result: ListNotificationGroupsResponse -Pending notification page. +One page of derived notification groups. | Field | Type | Description | | --- | --- | --- | -| `notifications` | repeated [`NotificationItem`](#chatto-api-v1-NotificationItem) | Page notifications, newest first. | +| `groups` | repeated [`NotificationGroup`](#chatto-api-v1-NotificationGroup) | Groups newest activity first. | | `page` | [`PageInfo`](#chatto-api-v1-PageInfo) | Page metadata. | +| `unread_group_count` | `int32` | Total unread group count. | +| `next_expiry_at` | `google.protobuf.Timestamp` | Earliest expiry in the complete list, including groups outside this page. Clients refresh authoritative notification state at this boundary. | +| `room_unread_group_counts` | repeated [`NotificationRoomUnreadGroupCount`](#chatto-api-v1-NotificationRoomUnreadGroupCount) | Complete unread-group counts by target room. This is independent of the bounded group page so room indicators remain authoritative. | - + -### GetNotification +### MarkNotificationRead -Gets one pending notification. Returns NOT_FOUND when the notification is -unknown or has been dismissed. +Marks one occurrence read. Repeating the call is safe. ```http -POST /api/connect/chatto.api.v1.NotificationService/GetNotification +POST /api/connect/chatto.api.v1.NotificationService/MarkNotificationRead ``` - + -#### Input: GetNotificationRequest +#### Input: MarkNotificationReadRequest -Request one pending notification for the authenticated viewer. +Request to mark one notification occurrence read. | Field | Type | Description | | --- | --- | --- | -| `notification_id` | `string` | Required notification ID. | +| `notification_id` | `string` | Required stable occurrence ID. | - + -#### Result: GetNotificationResponse +#### Result: MarkNotificationReadResponse -Pending notification response. +Notification occurrence after marking it read. | Field | Type | Description | | --- | --- | --- | -| `notification` | [`NotificationItem`](#chatto-api-v1-NotificationItem) | Requested notification. | +| `notification` | [`NotificationOccurrence`](#chatto-api-v1-NotificationOccurrence) | Updated occurrence. | - + -### BatchGetNotifications +### DeleteNotificationOccurrence -Gets pending notifications by ID. +Permanently deletes one occurrence while retaining its anti-recreation +tombstone through the original expiry. Repeating the call is safe. ```http -POST /api/connect/chatto.api.v1.NotificationService/BatchGetNotifications +POST /api/connect/chatto.api.v1.NotificationService/DeleteNotificationOccurrence ``` - + -#### Input: BatchGetNotificationsRequest +#### Input: DeleteNotificationOccurrenceRequest -Request pending notifications for a set of stable notification IDs. +Request permanent deletion of one notification occurrence. | Field | Type | Description | | --- | --- | --- | -| `notification_ids` | `repeated string` | Required notification IDs. Unknown or dismissed IDs are omitted from the response. | +| `notification_id` | `string` | Required stable occurrence ID. | - + -#### Result: BatchGetNotificationsResponse +#### Result: DeleteNotificationOccurrenceResponse -Batch pending notification response. +Result of deleting one notification occurrence. | Field | Type | Description | | --- | --- | --- | -| `notifications` | repeated [`NotificationItem`](#chatto-api-v1-NotificationItem) | Found notifications. The server preserves first-seen request order and de-duplicates repeated IDs. | +| `deleted` | `bool` | True when this call replaced a visible occurrence with a deletion tombstone. False when the occurrence was already absent or deleted. | - + -### ListRoomNotifications +### DeleteNotificationGroup -Lists pending notifications for one room. Non-members receive an empty page. +Permanently deletes occurrences currently belonging to one derived group. +Group membership is captured when the request is handled. Callers must not +retry this mutation automatically because later activity may reuse the +same derived group ID. ```http -POST /api/connect/chatto.api.v1.NotificationService/ListRoomNotifications +POST /api/connect/chatto.api.v1.NotificationService/DeleteNotificationGroup ``` - + -#### Input: ListRoomNotificationsRequest +#### Input: DeleteNotificationGroupRequest -Request for pending notifications scoped to one room. +Request permanent deletion of one derived notification group. | Field | Type | Description | | --- | --- | --- | -| `room_id` | `string` | Required. Room whose notifications should be listed. | -| `page` | [`PageRequest`](#chatto-api-v1-PageRequest) | Page request. Defaults to 50 results when absent or limit is zero. | +| `group_id` | `string` | Required stable group ID. | - + -#### Result: ListRoomNotificationsResponse +#### Result: DeleteNotificationGroupResponse -Pending notification page scoped to one room. +Result of deleting one derived notification group. | Field | Type | Description | | --- | --- | --- | -| `notifications` | repeated [`NotificationItem`](#chatto-api-v1-NotificationItem) | Page notifications, newest first. | -| `page` | [`PageInfo`](#chatto-api-v1-PageInfo) | Page metadata. | +| `deleted_count` | `int32` | Number of visible occurrences replaced by deletion tombstones. | - + -### ListRoomNotificationCounts +### DeleteAllNotificationOccurrences -Lists pending notification counts grouped by room as a finite snapshot. +Permanently deletes every occurrence current at the server's authoritative +mutation boundary. Callers must not retry this mutation automatically +because later activity may arrive after that boundary. ```http -POST /api/connect/chatto.api.v1.NotificationService/ListRoomNotificationCounts +POST /api/connect/chatto.api.v1.NotificationService/DeleteAllNotificationOccurrences ``` - + -#### Input: ListRoomNotificationCountsRequest +#### Input: DeleteAllNotificationOccurrencesRequest -Request for pending notification counts grouped by room. +Request permanent deletion of every current notification occurrence. - + -#### Result: ListRoomNotificationCountsResponse +#### Result: DeleteAllNotificationOccurrencesResponse -Finite snapshot of pending notification counts grouped by room. +Result of deleting every current notification occurrence. | Field | Type | Description | | --- | --- | --- | -| `room_counts` | repeated [`RoomNotificationCount`](#chatto-api-v1-RoomNotificationCount) | Counts for rooms with at least one pending notification. | +| `deleted_count` | `int32` | Number of current occurrences replaced by deletion tombstones. | - + -### HasNotifications +### GetNotificationPolicy -Checks whether the authenticated viewer has any pending notifications. +Gets every supported cause and its effective inherited delivery intensity. ```http -POST /api/connect/chatto.api.v1.NotificationService/HasNotifications +POST /api/connect/chatto.api.v1.NotificationService/GetNotificationPolicy ``` - - -#### Input: HasNotificationsRequest + -Request for a lightweight pending notification check. +#### Input: GetNotificationPolicyRequest - - - -#### Result: HasNotificationsResponse - -Lightweight pending notification check. +Request the authenticated viewer's notification policy. | Field | Type | Description | | --- | --- | --- | -| `has_notifications` | `bool` | True when the authenticated viewer has at least one pending notification. | +| `room_id` | `optional string` | Empty returns server-scoped preferences. A room ID returns the inherited effective policy for that room and requires current membership. | - + -### DismissNotification +#### Result: GetNotificationPolicyResponse -Dismisses one pending notification. Already-dismissed notifications are -treated as idempotent success. - -```http -POST /api/connect/chatto.api.v1.NotificationService/DismissNotification -``` - - - -#### Input: DismissNotificationRequest - -Request to dismiss one pending notification. +Complete supported notification policy for one scope. | Field | Type | Description | | --- | --- | --- | -| `notification_id` | `string` | Required. Notification to dismiss. | +| `room_id` | `optional string` | Room scope when requested; absent for server scope. | +| `preferences` | repeated [`NotificationPolicyPreference`](#chatto-api-v1-NotificationPolicyPreference) | One row for every supported notification cause. | - + -#### Result: DismissNotificationResponse - -Result of dismissing one pending notification. - -| Field | Type | Description | -| --- | --- | --- | -| `dismissed` | `bool` | True when the notification is no longer pending. | +### SetNotificationPolicyPreference - - - -### DismissAllNotifications - -Dismisses all pending notifications. +Sets or clears one server- or room-scoped cause override. ```http -POST /api/connect/chatto.api.v1.NotificationService/DismissAllNotifications +POST /api/connect/chatto.api.v1.NotificationService/SetNotificationPolicyPreference ``` - + + +#### Input: SetNotificationPolicyPreferenceRequest -#### Input: DismissAllNotificationsRequest +Set or clear one notification preference override. -Request to dismiss all pending notifications. +| Field | Type | Description | +| --- | --- | --- | +| `room_id` | `optional string` | Room scope to change; absent changes the server scope. | +| `reason` | [`NotificationReason`](#chatto-api-v1-NotificationReason) | Required notification cause. | +| `intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Unspecified clears the selected server or room override. | - + -#### Result: DismissAllNotificationsResponse +#### Result: SetNotificationPolicyPreferenceResponse -Result of dismissing all pending notifications. +Complete supported notification policy after one preference change. | Field | Type | Description | | --- | --- | --- | -| `dismissed_count` | `int32` | Number of notifications dismissed. | +| `room_id` | `optional string` | Room scope when changed; absent for server scope. | +| `preferences` | repeated [`NotificationPolicyPreference`](#chatto-api-v1-NotificationPolicyPreference) | One row for every supported notification cause. | @@ -3622,19 +3468,6 @@ Public user fields. - - -### NotificationPreference - -Stored and effective notification preference. - -| Field | Type | Description | -| --- | --- | --- | -| `level` | [`NotificationLevel`](#chatto-api-v1-NotificationLevel) | Explicit level stored for the current user. | -| `effective_level` | [`NotificationLevel`](#chatto-api-v1-NotificationLevel) | Level after applying defaults and inheritance. | - - - ### CapabilityGrant @@ -3676,19 +3509,6 @@ Effective decision for one permission key. - - -### RoomNotificationPreference - -Room notification preference for one joined room. - -| Field | Type | Description | -| --- | --- | --- | -| `room_id` | `string` | Room whose preference is represented. | -| `preference` | [`NotificationPreference`](#chatto-api-v1-NotificationPreference) | Stored and effective notification preference. | - - - ### ServerViewerPermissions @@ -4284,89 +4104,100 @@ Lightweight room reference for cross-resource rows. - + -### DirectMessageNotification +### NotificationGroup -Direct-message notification payload. +A presentation group derived from related notification occurrences. | Field | Type | Description | | --- | --- | --- | -| `event_id` | `string` | Message event ID. | -| `room` | [`RoomSummary`](#chatto-api-v1-RoomSummary) | DM room where the message was posted. | +| `id` | `string` | Stable ID derived from the viewer and grouping target. | +| `occurrences` | repeated [`NotificationOccurrence`](#chatto-api-v1-NotificationOccurrence) | Bounded newest-occurrence preview. It also includes the open target when that target falls outside the newest preview window. | +| `open_target` | [`NotificationTarget`](#chatto-api-v1-NotificationTarget) | Target to open: newest unread, or newest when all are read. | +| `unread` | `bool` | True when at least one member occurrence is unread. | +| `occurrence_count` | `int32` | Total number of occurrences in this group, including those not in the bounded preview. | +| `latest_at` | `google.protobuf.Timestamp` | Time of the newest occurrence. | +| `strongest_intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Strongest intensity among member occurrences. | +| `reasons` | repeated [`NotificationReason`](#chatto-api-v1-NotificationReason) | Distinct causes represented by member occurrences. | +| `next_expiry_at` | `google.protobuf.Timestamp` | Earliest member expiry. Clients refresh the group at this boundary. | +| `open_notification_id` | `string` | Occurrence ID corresponding to open_target, including when several occurrences share the same message target. | - + -### MentionNotification +### NotificationOccurrence -Mention notification payload. +One exact Notifications 2.0 source occurrence. | Field | Type | Description | | --- | --- | --- | -| `room` | [`RoomSummary`](#chatto-api-v1-RoomSummary) | Room where the mention occurred. | -| `event_id` | `string` | Message event ID. | -| `thread_root_event_id` | `optional string` | Thread root event ID when the mention happened inside a thread. | +| `id` | `string` | Stable occurrence ID. | +| `source_event_id` | `string` | Durable source event from which this occurrence was derived. | +| `created_at` | `google.protobuf.Timestamp` | Time of the source activity. | +| `actor` | [`User`](#chatto-api-v1-User) | User who caused the source activity, when still visible. | +| `target` | [`NotificationTarget`](#chatto-api-v1-NotificationTarget) | Exact current destination for navigation. | +| `reasons` | repeated [`NotificationReasonMatch`](#chatto-api-v1-NotificationReasonMatch) | Every cause that matched when the source activity occurred. | +| `strongest_intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Strongest evaluated intensity across all matching causes. | +| `unread` | `bool` | Whether this occurrence still contributes unread attention. | +| `expires_at` | `google.protobuf.Timestamp` | Absolute expiry, 90 days after the source activity. | - + -### NotificationItem +### NotificationPolicyPreference -One pending notification for the authenticated viewer. +Explicit and effective delivery policy for one notification cause. | Field | Type | Description | | --- | --- | --- | -| `id` | `string` | Stable notification ID. | -| `created_at` | `google.protobuf.Timestamp` | Creation time. | -| `actor` | [`User`](#chatto-api-v1-User) | User who triggered the notification, when still resolvable. | -| `kind.direct_message` | [`DirectMessageNotification`](#chatto-api-v1-DirectMessageNotification) | Direct-message notification. | -| `kind.mention` | [`MentionNotification`](#chatto-api-v1-MentionNotification) | Mention notification. | -| `kind.reply` | [`ReplyNotification`](#chatto-api-v1-ReplyNotification) | Reply notification. | -| `kind.room_message` | [`RoomMessageNotification`](#chatto-api-v1-RoomMessageNotification) | All-messages room notification. | +| `reason` | [`NotificationReason`](#chatto-api-v1-NotificationReason) | Notification cause controlled by this row. | +| `server_intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Explicit server override, or unspecified when inherited from product defaults. | +| `room_intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Explicit room override, or unspecified when inherited from server scope. | +| `effective_intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Effective intensity after applying product, server, and room inheritance. | - + -### ReplyNotification +### NotificationReasonMatch -Reply notification payload. +One cause that matched an occurrence and its evaluated delivery intensity. | Field | Type | Description | | --- | --- | --- | -| `room` | [`RoomSummary`](#chatto-api-v1-RoomSummary) | Room where the reply occurred. | -| `event_id` | `string` | Reply event ID. | -| `in_reply_to_id` | `string` | Event ID of the message being replied to. | -| `thread_root_event_id` | `optional string` | Thread root event ID when the reply happened inside a thread. | +| `reason` | [`NotificationReason`](#chatto-api-v1-NotificationReason) | Cause that matched the viewer. | +| `intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Effective intensity when the source activity occurred. | - + -### RoomMessageNotification +### NotificationRoomUnreadGroupCount -All-messages room notification payload. +Unread notification groups currently targeting one room. | Field | Type | Description | | --- | --- | --- | -| `room` | [`RoomSummary`](#chatto-api-v1-RoomSummary) | Room where the message was posted. | -| `event_id` | `string` | Message event ID. | +| `room_id` | `string` | Target room ID. | +| `unread_group_count` | `int32` | Number of unread groups targeting this room. | - + -### RoomNotificationCount +### NotificationTarget -Pending notification count for one room. +Exact visible destination of one notification occurrence. | Field | Type | Description | | --- | --- | --- | -| `room_id` | `string` | Room ID. | -| `total_count` | `int32` | Pending notification count for the authenticated viewer in this room. | +| `room` | [`RoomSummary`](#chatto-api-v1-RoomSummary) | Room containing the source activity. | +| `event_id` | `string` | Exact source or reacted-to message event to reveal. | +| `thread_root_event_id` | `optional string` | Thread root when the target is inside a thread. | +| `parent_event_id` | `optional string` | Direct reply target when the occurrence was caused by a reply. | @@ -4612,21 +4443,6 @@ the server's live presence record expire. | `PRESENCE_STATUS_OFFLINE` | `4` | The user has no active live presence record. | - - -### NotificationLevel - -Notification delivery level for a room. - -| Name | Number | Description | -| --- | --- | --- | -| `NOTIFICATION_LEVEL_UNSPECIFIED` | `0` | The level was not specified. | -| `NOTIFICATION_LEVEL_DEFAULT` | `1` | Use the inherited default for the room. | -| `NOTIFICATION_LEVEL_MUTED` | `2` | Do not notify for this room. | -| `NOTIFICATION_LEVEL_NORMAL` | `3` | Notify according to the normal room rules. | -| `NOTIFICATION_LEVEL_ALL_MESSAGES` | `4` | Notify for every message in the room. | - - ### TimeFormat @@ -4712,6 +4528,41 @@ Kind of room represented by the public API. | `ROOM_KIND_DM` | `2` | A direct-message conversation between members. | + + +### NotificationDeliveryIntensity + +Delivery strength for one notification cause. + +| Name | Number | Description | +| --- | --- | --- | +| `NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED` | `0` | In preference writes, unspecified clears the override (Inherit). | +| `NOTIFICATION_DELIVERY_INTENSITY_OFF` | `1` | Matching activity does not create a notification occurrence. | +| `NOTIFICATION_DELIVERY_INTENSITY_BADGE` | `2` | Matching activity appears in the notification list without interruptive delivery. | +| `NOTIFICATION_DELIVERY_INTENSITY_ALERT` | `3` | Matching activity appears in the notification list and may trigger sound or push. | + + + + +### NotificationReason + +Why source activity matched the authenticated viewer's notification policy. + +| Name | Number | Description | +| --- | --- | --- | +| `NOTIFICATION_REASON_UNSPECIFIED` | `0` | No cause was specified. This value is not valid in preference writes. | +| `NOTIFICATION_REASON_DIRECT_MESSAGE` | `1` | A message was posted in a direct-message conversation. | +| `NOTIFICATION_REASON_DIRECT_MENTION` | `2` | The viewer's username was mentioned directly. | +| `NOTIFICATION_REASON_REPLY` | `3` | Activity replied directly to the viewer's message. | +| `NOTIFICATION_REASON_ROLE_MENTION` | `4` | A role held by the viewer was mentioned. | +| `NOTIFICATION_REASON_HERE` | `5` | An `@here` mention included the viewer. | +| `NOTIFICATION_REASON_ALL` | `6` | An `@all` mention included the viewer. | +| `NOTIFICATION_REASON_FOLLOWED_THREAD` | `7` | New activity appeared in a thread followed by the viewer. | +| `NOTIFICATION_REASON_FOLLOWED_ROOM` | `8` | New activity appeared in a room followed by the viewer. | +| `NOTIFICATION_REASON_REACTION` | `9` | Someone reacted to the viewer's message. | +| `NOTIFICATION_REASON_ROOM_INVITATION` | `10` | The viewer was invited to a room. | + + ### RoomDirectoryScope diff --git a/apps/docs-website/src/generated/connectrpc-api/realtime.raw.mdx b/apps/docs-website/src/generated/connectrpc-api/realtime.raw.mdx index 0f9702f5c..ec662bd6d 100644 --- a/apps/docs-website/src/generated/connectrpc-api/realtime.raw.mdx +++ b/apps/docs-website/src/generated/connectrpc-api/realtime.raw.mdx @@ -113,8 +113,6 @@ delivered only as RealtimeProjectionEvent operations. | `actor_id` | `optional string` | User or system actor that caused the event, when known. | | `event.user_typing` | [`RealtimeTypingEvent`](#chatto-realtime-v1-RealtimeTypingEvent) | A user is typing in a room or thread. | | `event.presence_changed` | [`RealtimePresenceChangedEvent`](#chatto-realtime-v1-RealtimePresenceChangedEvent) | A user's live presence changed. | -| `event.mention_notification` | [`RealtimeMentionNotificationEvent`](#chatto-realtime-v1-RealtimeMentionNotificationEvent) | The current user was mentioned in a room. | -| `event.new_direct_message_notification` | [`RealtimeNewDirectMessageNotificationEvent`](#chatto-realtime-v1-RealtimeNewDirectMessageNotificationEvent) | The current user received a new direct message. | | `event.session_terminated` | [`RealtimeSessionTerminatedEvent`](#chatto-realtime-v1-RealtimeSessionTerminatedEvent) | The current user's session was terminated. | @@ -151,46 +149,6 @@ non-fatal `too_many_retained_rooms` error. - - -### RealtimeMentionNotificationEvent - -Mention attention signal for the connected user. - -Inline names are display hints. Hydrate referenced rooms through -`RoomDirectoryService.BatchGetRooms` and users through -`UserService.BatchGetUsers` when local caches are missing or stale. - -| Field | Type | Description | -| --- | --- | --- | -| `room_id` | `string` | Room where the mention occurred. | -| `actor_user_id` | `string` | Author user ID. | -| `room_name` | `optional string` | Display name of the room where the mention occurred, when hydrated for the caller. | -| `actor_display_name` | `optional string` | Display name of the author who mentioned the connected user, when hydrated. | - - - - - -### RealtimeNewDirectMessageNotificationEvent - -New-DM attention signal for the connected user. - -Inline names and avatar URLs are display hints. Hydrate the DM room through -`RoomDirectoryService.GetRoom` or `RoomDirectoryService.BatchGetRooms`, and -the sender through `UserService.GetUser` or `UserService.BatchGetUsers` when -local caches are missing or stale. - -| Field | Type | Description | -| --- | --- | --- | -| `room_id` | `string` | DM room ID. | -| `sender_id` | `string` | Sender user ID. | -| `sender_display_name` | `optional string` | Display name of the sender, when hydrated. | -| `sender_avatar_url` | `optional string` | Avatar URL of the sender, when one is available. | -| `conversation_name` | `optional string` | Display name for the DM conversation from the connected user's perspective, when hydrated. | - - - ### RealtimePing @@ -290,9 +248,8 @@ Finite current notification state emitted on bootstrap and every resume. | Field | Type | Description | | --- | --- | --- | -| `page` | `chatto.api.v1.ListNotificationsResponse` | Newest pending notifications and total pending count. | -| `room_counts` | `repeated chatto.api.v1.RoomNotificationCount` | Complete current counts for rooms with pending notifications. | | `change` | optional [`RealtimeProjectionNotificationChange`](#chatto-realtime-v1-RealtimeProjectionNotificationChange) | Live transition that caused this replacement, when one exists. Bootstrap, replay reconciliation, and compacted reset replacements omit this field. | +| `groups` | `chatto.api.v1.ListNotificationGroupsResponse` | Authoritative Notifications 2.0 groups and unread group count. | @@ -388,7 +345,6 @@ Canonical lightweight room state retained by a server-scoped projection. | --- | --- | --- | | `room` | `chatto.api.v1.RoomWithViewerState` | Public room metadata and viewer-specific state. | | `member_user_ids` | `repeated string` | Current room membership expressed as references into the projection's server-wide user directory. DM participants are always present. Channel membership is empty in a cold room summary and complete after that room's timeline has been materialised. | -| `viewer_notification_count` | `uint32` | Current pending notifications targeting this room for the viewer. | | `has_message_history` | `optional bool` | Whether this DM room has ever received a root message. False means the durable room exists only so its initiator can compose the first message. Once true, message deletion does not reset it. Absent for channel rooms and servers that predate this field. | @@ -647,7 +603,8 @@ Kind of live notification transition. | --- | --- | --- | | `REALTIME_PROJECTION_NOTIFICATION_ACTION_UNSPECIFIED` | `0` | No value description provided. | | `REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED` | `1` | No value description provided. | -| `REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED` | `2` | No value description provided. | +| `REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED` | `3` | No value description provided. | +| `REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED` | `4` | No value description provided. | diff --git a/apps/frontend/e2e/cross-server-dots.test.ts b/apps/frontend/e2e/cross-server-dots.test.ts index 6bddaf1f5..92745922b 100644 --- a/apps/frontend/e2e/cross-server-dots.test.ts +++ b/apps/frontend/e2e/cross-server-dots.test.ts @@ -310,9 +310,9 @@ test.describe('Cross-instance dots', () => { const spaceIcon = page.locator('.server-gutter [data-testid="server-icon"]').first(); const spaceBadge = spaceIcon.locator('..').getByTestId('server-notification-badge'); await expect(spaceBadge).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); - // The reply both mentions User A and replies to their message, so the - // count badge reflects both pending notification records. - await expect(spaceBadge).toHaveText('2'); + // The reply both mentions User A and replies to their message, but one + // source occurrence merges all matching reasons into one group count. + await expect(spaceBadge).toHaveText('1'); // Click the badge. The mention is on a thread message, so clicking should // land in #general with the thread pane open and the reply highlighted. diff --git a/apps/frontend/e2e/fixtures/connectHelpers.ts b/apps/frontend/e2e/fixtures/connectHelpers.ts index f912ed9bc..5a3110514 100644 --- a/apps/frontend/e2e/fixtures/connectHelpers.ts +++ b/apps/frontend/e2e/fixtures/connectHelpers.ts @@ -5,14 +5,10 @@ type ConnectClient = Page | APIRequestContext; const DEFAULT_POLL_TIMEOUT = process.env.CI ? 10_000 : 3_000; export type E2EPermissionDecision = - | 'PERMISSION_DECISION_ALLOW' - | 'PERMISSION_DECISION_DENY' - | 'PERMISSION_DECISION_NONE'; + 'PERMISSION_DECISION_ALLOW' | 'PERMISSION_DECISION_DENY' | 'PERMISSION_DECISION_NONE'; export type E2EPermissionScopeKind = - | 'PERMISSION_SCOPE_KIND_SERVER' - | 'PERMISSION_SCOPE_KIND_GROUP' - | 'PERMISSION_SCOPE_KIND_ROOM'; + 'PERMISSION_SCOPE_KIND_SERVER' | 'PERMISSION_SCOPE_KIND_GROUP' | 'PERMISSION_SCOPE_KIND_ROOM'; export interface E2EPermissionDecisionUpdateResponse { decision?: { @@ -49,18 +45,34 @@ export interface E2EServerRole { permissionDenials: string[]; } -export type E2ENotificationLevel = 'DEFAULT' | 'MUTED' | 'NORMAL' | 'ALL_MESSAGES'; - -export interface E2ENotificationPreference { - level: E2ENotificationLevel; - effectiveLevel: E2ENotificationLevel; -} - -interface NotificationPreferenceResponse { - preference?: { - level?: unknown; - effectiveLevel?: unknown; - }; +export type E2ENotificationReason = + | 'DIRECT_MESSAGE' + | 'DIRECT_MENTION' + | 'REPLY' + | 'ROLE_MENTION' + | 'HERE' + | 'ALL' + | 'FOLLOWED_THREAD' + | 'FOLLOWED_ROOM' + | 'REACTION' + | 'ROOM_INVITATION'; + +export type E2ENotificationIntensity = 'UNSPECIFIED' | 'OFF' | 'BADGE' | 'ALERT'; + +export interface E2ENotificationPolicyPreference { + reason: E2ENotificationReason; + serverIntensity: E2ENotificationIntensity; + roomIntensity: E2ENotificationIntensity; + effectiveIntensity: E2ENotificationIntensity; +} + +interface NotificationPolicyResponse { + preferences?: Array<{ + reason?: unknown; + serverIntensity?: unknown; + roomIntensity?: unknown; + effectiveIntensity?: unknown; + }>; } interface ListRoomsResponse { @@ -94,18 +106,24 @@ interface GetUserResponse { user?: { profile?: { user?: { id?: string } } }; } -const notificationLevelToProtoName: Record = { - DEFAULT: 'NOTIFICATION_LEVEL_DEFAULT', - MUTED: 'NOTIFICATION_LEVEL_MUTED', - NORMAL: 'NOTIFICATION_LEVEL_NORMAL', - ALL_MESSAGES: 'NOTIFICATION_LEVEL_ALL_MESSAGES' +const notificationReasonByNumber: Record = { + 1: 'DIRECT_MESSAGE', + 2: 'DIRECT_MENTION', + 3: 'REPLY', + 4: 'ROLE_MENTION', + 5: 'HERE', + 6: 'ALL', + 7: 'FOLLOWED_THREAD', + 8: 'FOLLOWED_ROOM', + 9: 'REACTION', + 10: 'ROOM_INVITATION' }; -const notificationLevelByNumber: Record = { - 1: 'DEFAULT', - 2: 'MUTED', - 3: 'NORMAL', - 4: 'ALL_MESSAGES' +const notificationIntensityByNumber: Record = { + 0: 'UNSPECIFIED', + 1: 'OFF', + 2: 'BADGE', + 3: 'ALERT' }; export async function connectPost( @@ -371,80 +389,75 @@ async function postMessageWithConnectInput(page: Page, input: ConnectRequest): P return eventId; } -export async function getServerNotificationPreference( - page: Page -): Promise { - const data = await connectPost( - page, - 'chatto.api.v1.NotificationPreferencesService/GetServerNotificationPreference' - ); - return normalizeNotificationPreference(data); -} - -export async function updateServerNotificationPreference( +export async function getNotificationPolicy( page: Page, - level: E2ENotificationLevel -): Promise { - const data = await connectPost( + roomId?: string +): Promise { + const data = await connectPost( page, - 'chatto.api.v1.NotificationPreferencesService/UpdateServerNotificationPreference', - { level: notificationLevelToProtoName[level] } + 'chatto.api.v1.NotificationService/GetNotificationPolicy', + roomId ? { roomId } : {} ); - return normalizeNotificationPreference(data); + return normalizeNotificationPolicy(data); } -export async function getRoomNotificationPreference( +export async function setNotificationPolicyPreference( page: Page, - roomId: string -): Promise { - const data = await connectPost( + reason: E2ENotificationReason, + intensity: E2ENotificationIntensity, + roomId?: string +): Promise { + const data = await connectPost( page, - 'chatto.api.v1.NotificationPreferencesService/GetRoomNotificationPreference', - { roomId } + 'chatto.api.v1.NotificationService/SetNotificationPolicyPreference', + { + ...(roomId ? { roomId } : {}), + reason: `NOTIFICATION_REASON_${reason}`, + intensity: `NOTIFICATION_DELIVERY_INTENSITY_${intensity}` + } ); - return normalizeNotificationPreference(data); + return normalizeNotificationPolicy(data); } -export async function updateRoomNotificationPreference( - page: Page, - roomId: string, - level: E2ENotificationLevel -): Promise { - const data = await connectPost( - page, - 'chatto.api.v1.NotificationPreferencesService/UpdateRoomNotificationPreference', - { roomId, level: notificationLevelToProtoName[level] } - ); - return normalizeNotificationPreference(data); +function normalizeNotificationPolicy( + data: NotificationPolicyResponse +): E2ENotificationPolicyPreference[] { + return (data.preferences ?? []).map((preference) => ({ + reason: normalizeNotificationReason(preference.reason), + serverIntensity: normalizeNotificationIntensity(preference.serverIntensity), + roomIntensity: normalizeNotificationIntensity(preference.roomIntensity), + effectiveIntensity: normalizeNotificationIntensity(preference.effectiveIntensity) + })); } -function normalizeNotificationPreference( - data: NotificationPreferenceResponse -): E2ENotificationPreference { - if (!data.preference) { - throw new Error('Notification preference response did not include preference metadata'); +function normalizeNotificationReason(value: unknown): E2ENotificationReason { + if (typeof value === 'number' && Number.isInteger(value)) { + const reason = notificationReasonByNumber[value]; + if (reason) return reason; } - return { - level: normalizeNotificationLevel(data.preference.level), - effectiveLevel: normalizeNotificationLevel(data.preference.effectiveLevel) - }; + if (typeof value === 'string') { + const compact = value.replace(/^NOTIFICATION_REASON_/, '') as E2ENotificationReason; + if (Object.values(notificationReasonByNumber).includes(compact)) return compact; + } + + throw new Error(`Unexpected notification reason: ${String(value)}`); } -function normalizeNotificationLevel(value: unknown): E2ENotificationLevel { +function normalizeNotificationIntensity(value: unknown): E2ENotificationIntensity { + if (value === undefined || value === null) return 'UNSPECIFIED'; if (typeof value === 'number' && Number.isInteger(value)) { - const level = notificationLevelByNumber[value]; - if (level) return level; + const intensity = notificationIntensityByNumber[value]; + if (intensity) return intensity; } if (typeof value === 'string') { - const compact = value.replace(/^NOTIFICATION_LEVEL_/, ''); - if (isNotificationLevel(compact)) return compact; + const compact = value.replace( + /^NOTIFICATION_DELIVERY_INTENSITY_/, + '' + ) as E2ENotificationIntensity; + if (Object.values(notificationIntensityByNumber).includes(compact)) return compact; } - throw new Error(`Unexpected notification level: ${String(value)}`); -} - -function isNotificationLevel(value: string): value is E2ENotificationLevel { - return value === 'DEFAULT' || value === 'MUTED' || value === 'NORMAL' || value === 'ALL_MESSAGES'; + throw new Error(`Unexpected notification intensity: ${String(value)}`); } diff --git a/apps/frontend/e2e/notification-level.test.ts b/apps/frontend/e2e/notification-level.test.ts deleted file mode 100644 index 54c639121..000000000 --- a/apps/frontend/e2e/notification-level.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { expect } from '@playwright/test'; -import { NotificationLevel } from '@chatto/api-types/api/v1/notification_preferences_pb'; -import { createAndLoginTestUser } from './fixtures/testUser'; -import { - getRoomIdByNameViaConnect, - getRoomNotificationPreference, - getServerNotificationPreference, - updateRoomNotificationPreference, - updateServerNotificationPreference -} from './fixtures/connectHelpers'; -import { test } from './setup'; -import { TIMEOUTS } from './constants'; -import * as routes from './routes'; - -test.describe('Notification Level - Notifications Settings', () => { - test('notifications settings page renders with server-level and room sections', async ({ - page, - chatPage - }) => { - await createAndLoginTestUser(page); - await chatPage.goto(); - - // Navigate to notification settings page - await page.goto(routes.settingsNotifications); - - // Verify page heading - await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible(); - - // Verify server notification level section - await expect(page.getByText('Server Notification Level')).toBeVisible(); - - // Verify the three server-level option labels are visible - await expect(page.getByText('No notifications or unread markers')).toBeVisible(); - await expect( - page.getByText('Unread markers + mentions, DMs, and thread replies') - ).toBeVisible(); - await expect(page.getByText('Normal + notification for every new message')).toBeVisible(); - - // Verify room overrides section is visible - await expect(page.getByText('Room Overrides')).toBeVisible(); - - // The general room should be listed in the room overrides (use testid) - await expect(page.getByTestId('room-notification-general')).toBeVisible(); - }); - - test('can set server notification level via UI', async ({ page, chatPage }) => { - await createAndLoginTestUser(page); - await chatPage.goto(); - - // Navigate to notification settings - await page.goto(routes.settingsNotifications); - - // Normal should be selected by default. - const normalButton = page.locator('button', { hasText: 'Normal' }).filter({ - hasText: 'Unread markers' - }); - await expect(normalButton).toHaveClass(/choice-row-selected/); - - // Click Muted button - const mutedButton = page.locator('button', { hasText: 'Muted' }).filter({ - hasText: 'No notifications' - }); - await mutedButton.click(); - - // Wait for success toast - await expect(page.getByText('Server notification level updated')).toBeVisible({ - timeout: TIMEOUTS.UI_STANDARD - }); - - // Verify Muted is now selected. - await expect(mutedButton).toHaveClass(/choice-row-selected/); - - // Reload and verify persistence - await page.reload(); - await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible(); - const mutedButtonReloaded = page.locator('button', { hasText: 'Muted' }).filter({ - hasText: 'No notifications' - }); - await expect(mutedButtonReloaded).toHaveClass(/choice-row-selected/); - }); - - test('can set room notification level via UI', async ({ page, chatPage }) => { - await createAndLoginTestUser(page); - await chatPage.goto(); - - // Navigate to notification settings - await page.goto(routes.settingsNotifications); - - // Find the room override row for "general" and change its select - const generalRow = page.getByTestId('room-notification-general'); - const select = generalRow.locator('select'); - - // Default should be selected initially - await expect(select).toHaveValue(String(NotificationLevel.DEFAULT)); - - // Change to MUTED - await select.selectOption(String(NotificationLevel.MUTED)); - - // Wait for success toast - await expect(page.getByText('Room notification level updated')).toBeVisible({ - timeout: TIMEOUTS.UI_STANDARD - }); - - // Verify it persists after reload - await page.reload(); - await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible(); - const generalRowAfterReload = page.getByTestId('room-notification-general'); - await expect(generalRowAfterReload.locator('select')).toHaveValue( - String(NotificationLevel.MUTED) - ); - }); - - test('notification levels are available from settings sidebar', async ({ page, chatPage }) => { - await createAndLoginTestUser(page); - await chatPage.goto(); - - await page.goto(routes.settings); - - const notificationsLink = page - .locator('nav') - .getByRole('link', { name: 'Notifications', exact: true }); - await expect(notificationsLink).toBeVisible(); - await notificationsLink.click(); - - await page.waitForURL(routes.settingsNotifications); - await expect(page.getByText('Server Notification Level')).toBeVisible(); - }); -}); - -test.describe('Notification Level - Server-Side Enforcement', () => { - test('setting notification level persists via Connect roundtrip', async ({ page, chatPage }) => { - await createAndLoginTestUser(page); - await chatPage.goto(); - - // Set server level to MUTED via API - await updateServerNotificationPreference(page, 'MUTED'); - - // Query it back - const preference = await getServerNotificationPreference(page); - - expect(preference.level).toBe('MUTED'); - expect(preference.effectiveLevel).toBe('MUTED'); - }); - - test('room inherits server notification level when set to DEFAULT', async ({ - page, - chatPage - }) => { - await createAndLoginTestUser(page); - await chatPage.goto(); - const roomId = await getRoomIdByNameViaConnect(page, 'general'); - - // Set server level to MUTED - await updateServerNotificationPreference(page, 'MUTED'); - - // Room (with DEFAULT) should inherit MUTED from server - const preference = await getRoomNotificationPreference(page, roomId); - - expect(preference.level).toBe('DEFAULT'); - expect(preference.effectiveLevel).toBe('MUTED'); - }); - - test('room level overrides server level', async ({ page, chatPage }) => { - await createAndLoginTestUser(page); - await chatPage.goto(); - const roomId = await getRoomIdByNameViaConnect(page, 'general'); - - // Set server level to MUTED - await updateServerNotificationPreference(page, 'MUTED'); - - // Set room level to ALL_MESSAGES (overrides server MUTED) - await updateRoomNotificationPreference(page, roomId, 'ALL_MESSAGES'); - - // Room should show ALL_MESSAGES as effective level - const preference = await getRoomNotificationPreference(page, roomId); - - expect(preference.level).toBe('ALL_MESSAGES'); - expect(preference.effectiveLevel).toBe('ALL_MESSAGES'); - }); -}); diff --git a/apps/frontend/e2e/notification-policy.test.ts b/apps/frontend/e2e/notification-policy.test.ts new file mode 100644 index 000000000..70c74c114 --- /dev/null +++ b/apps/frontend/e2e/notification-policy.test.ts @@ -0,0 +1,59 @@ +import { expect } from '@playwright/test'; +import { NotificationDeliveryIntensity } from '@chatto/api-types/api/v1/notifications_pb'; +import { createAndLoginTestUser } from './fixtures/testUser'; +import { + getNotificationPolicy, + getRoomIdByNameViaConnect, + setNotificationPolicyPreference +} from './fixtures/connectHelpers'; +import { test } from './setup'; +import * as routes from './routes'; + +test.describe('Notification policy', () => { + test('renders every supported cause and persists a server override', async ({ + page, + chatPage + }) => { + await createAndLoginTestUser(page); + await chatPage.goto(); + await page.goto(routes.settingsNotifications); + + await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible(); + await expect(page.getByText('Notification policy')).toBeVisible(); + + const directMessages = page.getByRole('combobox', { name: 'Direct messages' }); + await expect(directMessages).toBeVisible(); + await expect(page.getByRole('combobox', { name: 'Room invitations' })).toHaveCount(0); + await directMessages.selectOption(String(NotificationDeliveryIntensity.OFF)); + await expect(directMessages).toHaveValue(String(NotificationDeliveryIntensity.OFF)); + + await page.reload(); + await expect(page.getByRole('combobox', { name: 'Direct messages' })).toHaveValue( + String(NotificationDeliveryIntensity.OFF) + ); + }); + + test('inherits server policy and supports a room override through Connect', async ({ + page, + chatPage + }) => { + await createAndLoginTestUser(page); + await chatPage.goto(); + const roomId = await getRoomIdByNameViaConnect(page, 'general'); + + await setNotificationPolicyPreference(page, 'FOLLOWED_ROOM', 'BADGE'); + let roomPolicy = await getNotificationPolicy(page, roomId); + expect(roomPolicy.find(({ reason }) => reason === 'FOLLOWED_ROOM')).toMatchObject({ + serverIntensity: 'BADGE', + roomIntensity: 'UNSPECIFIED', + effectiveIntensity: 'BADGE' + }); + + roomPolicy = await setNotificationPolicyPreference(page, 'FOLLOWED_ROOM', 'ALERT', roomId); + expect(roomPolicy.find(({ reason }) => reason === 'FOLLOWED_ROOM')).toMatchObject({ + serverIntensity: 'BADGE', + roomIntensity: 'ALERT', + effectiveIntensity: 'ALERT' + }); + }); +}); diff --git a/apps/frontend/e2e/notifications.test.ts b/apps/frontend/e2e/notifications.test.ts index cb3aab04f..fa3a89141 100644 --- a/apps/frontend/e2e/notifications.test.ts +++ b/apps/frontend/e2e/notifications.test.ts @@ -1,5 +1,4 @@ import { expect } from '@playwright/test'; -import { NotificationLevel } from '@chatto/api-types/api/v1/notification_preferences_pb'; import { test } from './setup'; import { ChatPage, NotificationsPage } from './pages'; import { createAndLoginTestUser, loginAsAdmin, loginTestUser } from './fixtures/testUser'; @@ -14,6 +13,10 @@ import { } from './fixtures/serverUser'; import * as routes from './routes'; import { POLLING_INTERVALS, TIMEOUTS } from './constants'; +import { + getRoomIdByNameViaConnect, + setNotificationPolicyPreference +} from './fixtures/connectHelpers'; test.describe('Mention Notifications', () => { // Note: Toast notifications for mentions were removed - the bell icon with notification badge @@ -157,14 +160,8 @@ test.describe('All Messages Notifications', () => { await page.goto(routes.settingsNotifications); await expect(page.getByRole('heading', { name: 'Notifications' })).toBeVisible(); - const generalNotificationRow = page.getByTestId('room-notification-general'); - await expect(generalNotificationRow).toBeVisible(); - await generalNotificationRow - .locator('select') - .selectOption(String(NotificationLevel.ALL_MESSAGES)); - await expect(page.getByText('Room notification level updated')).toBeVisible({ - timeout: TIMEOUTS.UI_STANDARD - }); + const generalRoomId = await getRoomIdByNameViaConnect(page, 'general'); + await setNotificationPolicyPreference(page, 'FOLLOWED_ROOM', 'ALERT', generalRoomId); await chatPage.goto(); await chatPage.enterRoom('announcements'); @@ -186,7 +183,7 @@ test.describe('All Messages Notifications', () => { await expect(notificationBadge).toHaveText('1'); await notificationsPage.goto(); - const notification = notificationsPage.getNotificationBySummary('posted a message'); + const notification = notificationsPage.getNotificationBySummary('posted a new message.'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); }); }); @@ -237,7 +234,7 @@ test.describe('Thread Reply Notifications (Cascading Indicators)', () => { const threadNotificationDot = threadLink.getByTestId('thread-notification-dot'); await expect(threadNotificationDot).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); - // 4. Open the thread - notification should be dismissed + // 4. Open the thread - notification should be marked read await message.openThread(); await roomPage.expectThreadPaneVisible(); @@ -304,7 +301,7 @@ test.describe('Notification Bell & Page', () => { await notificationsPage.goto(); await notificationsPage.expectEmptyState(); - await notificationsPage.expectClearAllNotVisible(); + await notificationsPage.expectUnreadEmpty(); }); }); @@ -329,14 +326,14 @@ test.describe('Notification Page Display', () => { await notificationsPage.goto(); // Verify notification appears with correct content - const notification = notificationsPage.getNotificationBySummary('mentioned you'); + const notification = notificationsPage.getNotificationBySummary('mentioned you.'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); // Verify location is shown (room and server name) await notificationsPage.expectNotificationWithLocation(notification, 'general', serverName); - // Verify Clear all button is visible - await notificationsPage.expectClearAllVisible(); + // Verify the notification row and its dismissal action are visible. + await notificationsPage.expectUnreadNotEmpty(); }); test('reply notification shows summary, location, and time', async ({ @@ -370,7 +367,9 @@ test.describe('Notification Page Display', () => { await notificationsPage.goto(); // Verify notification appears with correct content - const notification = notificationsPage.getNotificationBySummary('replied to your message'); + const notification = notificationsPage.getNotificationBySummary( + 'replied in a thread you follow.' + ); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); // Verify location is shown (room and server name) @@ -393,7 +392,7 @@ test.describe('Notification Page Display', () => { await roomPage.sendMessage(rootMessage); // User A creates the additional room (room.create is not granted to everyone by default) const secondRoomName = await chatPage.createRoom(); - // Navigate AWAY from all rooms so notifications won't be auto-dismissed + // Navigate AWAY from all rooms so notifications won't be marked read await page.goto(routes.settings); // User B: Create multiple notifications (mention in the second room + reply in general) @@ -421,8 +420,8 @@ test.describe('Notification Page Display', () => { }); }); -test.describe('Notification Dismissal', () => { - test('dismiss single notification via X button', async ({ +test.describe('Notification dismissal', () => { + test('dismisses one notification via the trash button', async ({ page, chatPage, notificationsPage, @@ -437,19 +436,19 @@ test.describe('Notification Dismissal', () => { // User B: Mention User A await postMentionFromServerUser(browser!, serverURL, userA.login, 'dismiss test'); - // User A: Navigate to notifications and dismiss + // User A: Navigate to notifications and dismiss the group. await notificationsPage.goto(); - const notification = notificationsPage.getNotificationBySummary('mentioned you'); + const notification = notificationsPage.getNotificationBySummary('mentioned you.'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); - await notificationsPage.dismissNotification(notification); + await notificationsPage.dismiss(notification); - // Verify notification is gone and empty state shows - await expect(notification).not.toBeVisible(); + // The notification is permanently removed from the list. + await notificationsPage.expectUnreadEmpty(); await notificationsPage.expectEmptyState(); }); - test('dismiss all notifications via Clear all button', async ({ + test('dismisses all notifications via their trash buttons', async ({ page, chatPage, roomPage, @@ -461,11 +460,11 @@ test.describe('Notification Dismissal', () => { const userA = await createAndLoginTestUser(page); await chatPage.goto(); await chatPage.enterRoom('general'); - const rootMessage = `Clear all test ${Date.now()}`; + const rootMessage = `Dismiss all test ${Date.now()}`; await roomPage.sendMessage(rootMessage); // User A creates the additional room (room.create is not granted to everyone by default) const secondRoomName = await chatPage.createRoom(); - // Navigate AWAY from all rooms so notifications won't be auto-dismissed + // Navigate AWAY from all rooms so notifications won't be marked read await page.goto(routes.settings); // User B: Create multiple notifications (mention in second room + reply in general) @@ -478,23 +477,23 @@ test.describe('Notification Dismissal', () => { await chatPage.enterRoom(secondRoomName); // Create mention in the second room (User A is not in any room) - await roomPage.sendMessage(`@${userA.login} clear all test 1`); + await roomPage.sendMessage(`@${userA.login} dismiss all test 1`); await chatPage.enterRoom('general'); const message2 = roomPage.getMessage(rootMessage); await message2.openThread(); await roomPage.expectThreadPaneVisible(); - await roomPage.postThreadReply('Clear all test 2'); + await roomPage.postThreadReply('Dismiss all test 2'); }); - // User A: Dismiss all + // User A: Dismiss every notification group. // Use longer timeout to allow real-time events to propagate await notificationsPage.goto(); await notificationsPage.expectNotificationCount(2, TIMEOUTS.COMPLEX_OPERATION); await notificationsPage.dismissAll(); - // Verify all gone + // Dismissed groups are no longer visible. + await notificationsPage.expectUnreadEmpty(); await notificationsPage.expectEmptyState(); - await notificationsPage.expectClearAllNotVisible(); }); test('bell indicator clears after dismissing all notifications', async ({ @@ -515,7 +514,7 @@ test.describe('Notification Dismissal', () => { // User A: Verify bell has indicator await notificationsPage.expectBellIndicatorVisible(); - // Dismiss all + // Dismiss all notification groups. await notificationsPage.goto(); await notificationsPage.dismissAll(); @@ -543,7 +542,7 @@ test.describe('Navigation from Notifications', () => { // User A: Click notification await notificationsPage.goto(); - const notification = notificationsPage.getNotificationBySummary('mentioned you'); + const notification = notificationsPage.getNotificationBySummary('mentioned you.'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); await notificationsPage.clickNotification(notification); @@ -574,7 +573,9 @@ test.describe('Navigation from Notifications', () => { // User A: Click notification await notificationsPage.goto(); - const notification = notificationsPage.getNotificationBySummary('replied to your message'); + const notification = notificationsPage.getNotificationBySummary( + 'replied in a thread you follow.' + ); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); await notificationsPage.clickNotification(notification); @@ -587,7 +588,7 @@ test.describe('Navigation from Notifications', () => { await expect(roomPage.threadPane.getByText(replyText)).toBeInViewport(); }); - test('clicking notification dismisses it', async ({ + test('opening a notification marks it read but keeps it in the list', async ({ page, chatPage, notificationsPage, @@ -600,18 +601,20 @@ test.describe('Navigation from Notifications', () => { await chatPage.enterRoom('announcements'); // User B: Mention User A in general room (User B can't post in announcements due to RBAC) - await postMentionFromServerUser(browser!, serverURL, userA.login, 'dismiss on click test'); + await postMentionFromServerUser(browser!, serverURL, userA.login, 'read on click test'); // User A: Click notification await notificationsPage.goto(); - const notification = notificationsPage.getNotificationBySummary('mentioned you'); + const notification = notificationsPage.getNotificationBySummary('mentioned you.'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); await notificationsPage.clickNotification(notification); await page.waitForURL(routes.patterns.anyRoomWithQuery); - // Go back to notifications - should be empty + // Opening handles the unread attention state without dismissing the item. await notificationsPage.gotoDirectly(); - await notificationsPage.expectEmptyState(); + const readNotification = notificationsPage.getNotificationBySummary('mentioned you.'); + await expect(readNotification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); + await expect(readNotification.getByLabel('Unread')).not.toBeVisible(); }); }); @@ -646,7 +649,7 @@ test.describe('Cross-Tab Sync', () => { // Navigate to notifications page in second tab - should see the notification await notificationsPage1b.goto(); - await notificationsPage1b.expectNotificationWithSummary('mentioned you'); + await notificationsPage1b.expectNotificationWithSummary('mentioned you.'); }); }); @@ -673,11 +676,12 @@ test.describe('Cross-Tab Sync', () => { await notificationsPage.expectBellIndicatorVisible(); await notificationsPage1b.expectBellIndicatorVisible(); - // User A: Dismiss notification in first tab + // User A: Dismiss the notification in the first tab. await notificationsPage.goto(); - const notification = notificationsPage.getNotificationBySummary('mentioned you'); + const notification = notificationsPage.getNotificationBySummary('mentioned you.'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); - await notificationsPage.dismissNotification(notification); + await notificationsPage.dismiss(notification); + await notificationsPage.expectUnreadEmpty(); await notificationsPage.expectEmptyState(); // Second tab: Bell indicator should also be gone @@ -685,13 +689,14 @@ test.describe('Cross-Tab Sync', () => { await page1b.goto(routes.space()); await notificationsPage1b.expectBellIndicatorNotVisible(); - // Second tab: Notifications page should also be empty + // Second tab: the handled notification remains in the combined list. await notificationsPage1b.goto(); + await notificationsPage1b.expectUnreadEmpty(); await notificationsPage1b.expectEmptyState(); }); }); - test('notification dismissed by entering room syncs to other tabs', async ({ + test('notification marked read by entering room syncs to other tabs', async ({ page, chatPage, browser, @@ -710,24 +715,27 @@ test.describe('Cross-Tab Sync', () => { browser!, serverURL, userA.login, - 'room entry dismiss sync test' + 'room entry read sync test' ); // User A (tab 2): Go to notifications page and verify notification exists await notificationsPage1b.gotoDirectly(); - const notification1b = notificationsPage1b.getNotificationBySummary('mentioned you'); + const notification1b = notificationsPage1b.getNotificationBySummary('mentioned you.'); await expect(notification1b).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); - // User A (tab 1): Enter the general room (auto-dismisses mention) + // User A (tab 1): Enter the general room (marks the mention read) await chatPage.enterRoom('general'); - // User A (tab 2): Notification should disappear from the list - await expect(notification1b).not.toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); - await notificationsPage1b.expectEmptyState(); + // User A (tab 2): The list keeps the notification, but its unread + // attention state converges without a refresh. + await expect(notification1b).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); + await expect(notification1b.getByLabel('Unread')).not.toBeVisible({ + timeout: TIMEOUTS.REALTIME_EVENT + }); }); }); - test('reply notification is dismissed by opening the thread', async ({ + test('reply notification is marked read by opening the thread', async ({ page, chatPage, roomPage, @@ -753,7 +761,7 @@ test.describe('Cross-Tab Sync', () => { // User A: Verify bell indicator await notificationsPage.expectBellIndicatorVisible(); - // User A: Open the thread (auto-dismisses reply notification) + // User A: Open the thread (marks the reply notification read) await chatPage.enterRoom('general'); await message.openThread(); await roomPage.expectThreadPaneVisible(); @@ -772,7 +780,7 @@ test.describe('Cross-Tab Sync', () => { // Verifies the cross-device fix: dismissing a mention notification on // Tab 1 not only removes the notification on Tab 2, but also clears the // room-level mention indicator (notification badge in the room list). The reload - // step proves the server-side pending notification was cleared — not just the + // step proves the server-side occurrence was marked read — not just the // local frontend state — by hitting the room notification count API on a // fresh load. @@ -813,15 +821,16 @@ test.describe('Cross-Tab Sync', () => { // room. Pre-fix this would only sync the bell across tabs; the // room-level badge would linger on Tab 2 and re-appear after reload. await notificationsPage.goto(); - const notification = notificationsPage.getNotificationBySummary('mentioned you'); + const notification = notificationsPage.getNotificationBySummary('mentioned you.'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); - await notificationsPage.dismissNotification(notification); + await notificationsPage.dismiss(notification); + await notificationsPage.expectUnreadEmpty(); await notificationsPage.expectEmptyState(); - // Tab 2: the badge disappears via live notification dismissal sync. + // Tab 2: the badge disappears via live notification sync. await expect(generalMentionBadge1b).not.toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); - // Tab 2: hard reload. If the badge reappears, the pending notification wasn't + // Tab 2: hard reload. If the badge reappears, the occurrence wasn't marked read // cleared server-side — local state covered for it transiently. The // badge staying gone proves Room.viewerNotifications.totalCount returns 0. await page1b.reload(); @@ -858,12 +867,12 @@ test.describe('Real-time Notification Updates', () => { await postMentionFromServerUser(browser!, serverURL, userA.login, 'real-time test'); // User A: Notification should appear without refresh - const notification = notificationsPage.getNotificationBySummary('mentioned you'); + const notification = notificationsPage.getNotificationBySummary('mentioned you.'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); - // Empty state should be gone, Clear all should be visible + // The empty state should be gone and the notification row should be visible. await expect(notificationsPage.emptyState).not.toBeVisible(); - await notificationsPage.expectClearAllVisible(); + await notificationsPage.expectUnreadNotEmpty(); }); test('notification count updates in real-time', async ({ @@ -943,7 +952,7 @@ test.describe('Page Title Notification Count', () => { // User A creates the additional room (room.create is not granted to everyone by default) const secondRoomName = await chatPage.createRoom(); - // Navigate away so notifications won't be auto-dismissed + // Navigate away so notifications won't be marked read await page.goto(routes.settings); // Verify no count prefix @@ -992,7 +1001,7 @@ test.describe('Page Title Notification Count', () => { // User A: Verify title has count await expect(page).toHaveTitle(/^\(1\) /, { timeout: TIMEOUTS.REALTIME_EVENT }); - // Dismiss all notifications + // Dismiss all notifications. await notificationsPage.goto(); await notificationsPage.dismissAll(); @@ -1000,7 +1009,7 @@ test.describe('Page Title Notification Count', () => { await expect(page).toHaveTitle(/^(?!\(\d+\)).*$/); }); - test('page title count decrements when notification is dismissed', async ({ + test('page title count decrements when a notification is dismissed', async ({ page, chatPage, roomPage, @@ -1017,7 +1026,7 @@ test.describe('Page Title Notification Count', () => { // User A creates the additional room (room.create is not granted to everyone by default) const secondRoomName = await chatPage.createRoom(); - // Navigate away so notifications won't be auto-dismissed + // Navigate away so notifications won't be marked read await page.goto(routes.settings); // User B: Create two notifications (mention in second room + reply in general) @@ -1041,18 +1050,20 @@ test.describe('Page Title Notification Count', () => { // User A: Verify title shows (2) await expect(page).toHaveTitle(/^\(2\) /, { timeout: TIMEOUTS.REALTIME_EVENT }); - // Dismiss one notification + // Dismiss one notification. await notificationsPage.goto(); - const mentionNotification = notificationsPage.getNotificationBySummary('mentioned you'); + const mentionNotification = notificationsPage.getNotificationBySummary('mentioned you.'); await expect(mentionNotification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); - await notificationsPage.dismissNotification(mentionNotification); + await notificationsPage.dismiss(mentionNotification); // Title should now show (1) await expect(page).toHaveTitle(/^\(1\) /); - // Dismiss the remaining notification - const replyNotification = notificationsPage.getNotificationBySummary('replied to your message'); - await notificationsPage.dismissNotification(replyNotification); + // Dismiss the remaining notification. + const replyNotification = notificationsPage.getNotificationBySummary( + 'replied in a thread you follow.' + ); + await notificationsPage.dismiss(replyNotification); // Title should have no count prefix await expect(page).toHaveTitle(/^(?!\(\d+\)).*$/); @@ -1060,7 +1071,7 @@ test.describe('Page Title Notification Count', () => { }); test.describe('Clickable Notification Badges', () => { - test('clicking notification badge on room name navigates to message and dismisses', async ({ + test('clicking notification badge on room name navigates to message and marks it read', async ({ page, chatPage, browser, @@ -1098,7 +1109,7 @@ test.describe('Clickable Notification Badges', () => { await page.waitForURL(routes.patterns.anyRoom); await expect(page.getByRole('heading', { name: '# general' })).toBeVisible(); - // Verify notification badge is gone (notification was dismissed). + // Verify notification badge is gone (notification was marked read). await expect(roomNotificationBadge).not.toBeVisible({ timeout: TIMEOUTS.UI_STANDARD }); }); @@ -1191,7 +1202,7 @@ test.describe('Room Reply Notifications', () => { // Verify notification page shows reply notification with correct content await notificationsPage.goto(); - const notification = notificationsPage.getNotificationBySummary('replied to your message'); + const notification = notificationsPage.getNotificationBySummary('replied to your message.'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); await notificationsPage.expectNotificationWithLocation(notification, 'general', serverName); }); @@ -1217,7 +1228,7 @@ test.describe('Room Reply Notifications', () => { // User A: Click the reply notification await notificationsPage.goto(); - const notification = notificationsPage.getNotificationBySummary('replied to your message'); + const notification = notificationsPage.getNotificationBySummary('replied to your message.'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); await notificationsPage.clickNotification(notification); @@ -1227,7 +1238,7 @@ test.describe('Room Reply Notifications', () => { await expect(page.getByRole('heading', { name: '# general' })).toBeVisible(); }); - test('room reply notification dismissed on room entry', async ({ + test('room reply notification is marked read on room entry', async ({ page, chatPage, roomPage, @@ -1239,7 +1250,7 @@ test.describe('Room Reply Notifications', () => { await createAndLoginTestUser(page); await chatPage.goto(); await chatPage.enterRoom('general'); - const rootMessage = `Room reply dismiss test ${Date.now()}`; + const rootMessage = `Room reply read test ${Date.now()}`; await roomPage.sendMessage(rootMessage); await chatPage.enterRoom('announcements'); @@ -1254,7 +1265,7 @@ test.describe('Room Reply Notifications', () => { // User A: Verify bell indicator appears await notificationsPage.expectBellIndicatorVisible(); - // User A: Enter the general room (should auto-dismiss the reply notification) + // User A: Enter the general room (should mark the reply notification read) await chatPage.enterRoom('general'); // Bell indicator should be gone diff --git a/apps/frontend/e2e/pages/NotificationsPage.ts b/apps/frontend/e2e/pages/NotificationsPage.ts index a32e55703..983592143 100644 --- a/apps/frontend/e2e/pages/NotificationsPage.ts +++ b/apps/frontend/e2e/pages/NotificationsPage.ts @@ -4,7 +4,7 @@ import * as routes from '../routes'; /** * Page object for the notifications page and bell icon. - * Handles notification bell, notification list, and dismiss actions. + * Handles the notification bell, persistent notification list, and dismissal. */ export class NotificationsPage { constructor(readonly page: Page) {} @@ -28,11 +28,6 @@ export class NotificationsPage { return this.page.getByRole('heading', { name: 'Notifications' }); } - /** The "Clear all" button */ - get clearAllButton(): Locator { - return this.page.getByRole('button', { name: 'Clear all' }); - } - /** The empty state message */ get emptyState(): Locator { return this.page.getByText("You're all caught up!"); @@ -40,7 +35,19 @@ export class NotificationsPage { /** Get all notification items on the page */ get notificationItems(): Locator { - return this.page.locator('[data-testid="notification-item"]'); + return this.page.locator('[data-testid="notification-group"]'); + } + + /** Notification groups that still need attention. */ + get unreadItems(): Locator { + return this.page.locator( + '[data-testid="notification-group"][data-notification-state="unread"]' + ); + } + + /** Notification groups that have already been read. */ + get readItems(): Locator { + return this.page.locator('[data-testid="notification-group"][data-notification-state="read"]'); } /** @@ -74,17 +81,17 @@ export class NotificationsPage { } /** - * Get a notification item by its summary text. + * Get a notification item by text in its localised activity summary. */ getNotificationBySummary(summaryText: string): Locator { return this.notificationItems.filter({ hasText: summaryText }); } /** - * Get the dismiss button (X) for a specific notification. + * Get the Delete button for a specific notification group. */ - getDismissButton(notification: Locator): Locator { - return notification.locator('button[title="Dismiss"]'); + getDeleteButton(notification: Locator): Locator { + return notification.locator('button[title="Delete"]'); } /** @@ -95,17 +102,24 @@ export class NotificationsPage { } /** - * Dismiss a specific notification. + * Permanently dismiss a specific notification group. */ - async dismissNotification(notification: Locator): Promise { - await this.getDismissButton(notification).click(); + async dismiss(notification: Locator): Promise { + await this.getDeleteButton(notification).click(); } /** - * Dismiss all notifications. + * Permanently dismiss every currently visible notification group. */ async dismissAll(): Promise { - await this.clearAllButton.click(); + await expect(this.notificationItems.first()).toBeVisible({ + timeout: TIMEOUTS.REALTIME_EVENT + }); + while ((await this.notificationItems.count()) > 0) { + const count = await this.notificationItems.count(); + await this.getDeleteButton(this.notificationItems.first()).click(); + await expect(this.notificationItems).toHaveCount(count - 1); + } } // --- Assertions --- @@ -132,7 +146,7 @@ export class NotificationsPage { } /** - * Assert that a notification with specific summary text exists. + * Assert that a notification with specific localised summary text exists. */ async expectNotificationWithSummary(summaryText: string): Promise { await expect(this.getNotificationBySummary(summaryText)).toBeVisible(); @@ -146,8 +160,8 @@ export class NotificationsPage { roomName: string, serverName: string ): Promise { - const locationText = `#${roomName} in ${serverName}`; - await expect(notification.getByText(locationText)).toBeVisible(); + void serverName; + await expect(notification.getByText(`#${roomName}`, { exact: false })).toBeVisible(); } /** @@ -159,17 +173,18 @@ export class NotificationsPage { await expect(this.notificationItems).toHaveCount(count, { timeout: timeout ?? 5000 }); } - /** - * Assert that Clear all button is visible (only when there are notifications). - */ - async expectClearAllVisible(): Promise { - await expect(this.clearAllButton).toBeVisible(); + /** Assert that the list contains at least one unread notification group. */ + async expectUnreadNotEmpty(): Promise { + await expect(this.unreadItems.first()).toBeVisible(); } - /** - * Assert that Clear all button is NOT visible (empty state). - */ - async expectClearAllNotVisible(): Promise { - await expect(this.clearAllButton).not.toBeVisible(); + /** Assert that the list contains no unread notification groups. */ + async expectUnreadEmpty(): Promise { + await expect(this.unreadItems).toHaveCount(0); + } + + /** Assert that the list contains at least one read notification group. */ + async expectReadNotEmpty(): Promise { + await expect(this.readItems.first()).toBeVisible(); } } diff --git a/apps/frontend/e2e/realtime-protobuf.test.ts b/apps/frontend/e2e/realtime-protobuf.test.ts index 4fdae0ac5..6a82133ac 100644 --- a/apps/frontend/e2e/realtime-protobuf.test.ts +++ b/apps/frontend/e2e/realtime-protobuf.test.ts @@ -13,6 +13,7 @@ import { RealtimeServerFrame, RealtimeSubscribeEvents } from '@chatto/api-types/realtime/v1/realtime_pb'; +import { NotificationReason } from '@chatto/api-types/api/v1/notifications_pb'; class RealtimeProtobufClient { readonly #socket: WebSocket; @@ -216,7 +217,7 @@ test.describe('protobuf realtime stream', () => { } }); - test('delivers mention and DM display payloads over /api/realtime', async ({ + test('delivers mention and DM occurrence display payloads over /api/realtime', async ({ page, browser, serverURL @@ -233,18 +234,37 @@ test.describe('protobuf realtime stream', () => { await roomPage.sendMessage(`@${viewer.login} protobuf mention ${Date.now()}`); }); - const mentionEvent = await realtime.waitForEvent( - (event) => event.event.case === 'mentionNotification' + const mentionFrame = await realtime.waitForFrame((frame) => + frame.frame.case === 'projectionEvent' + ? frame.frame.value.operations.some((operation) => + operation.operation.case === 'notificationsReplace' + ? operation.operation.value.groups?.groups.some((group) => + group.reasons.includes(NotificationReason.DIRECT_MENTION) + ) + : false + ) + : false ); - expect(mentionEvent.event.case).toBe('mentionNotification'); - expect(mentionEvent.event.value).toEqual( - expect.objectContaining({ - actorDisplayName: mentionActorDisplayName, - roomName: 'general' - }) + expect(mentionFrame.frame.case).toBe('projectionEvent'); + if (mentionFrame.frame.case !== 'projectionEvent') { + throw new Error('expected mention projection event'); + } + const mentionReplacement = mentionFrame.frame.value.operations + .map((operation) => + operation.operation.case === 'notificationsReplace' ? operation.operation.value : null + ) + .find((replacement) => replacement?.groups?.groups.length); + const mentionGroup = mentionReplacement?.groups?.groups.find((group) => + group.reasons.includes(NotificationReason.DIRECT_MENTION) ); - expect(mentionEvent.event.value.actorUserId).toBeTruthy(); - expect(mentionEvent.event.value.roomId).toBeTruthy(); + const mention = + mentionGroup?.occurrences.find( + (occurrence) => occurrence.id === mentionGroup.openNotificationId + ) ?? mentionGroup?.occurrences[0]; + expect(mention?.actor?.displayName).toBe(mentionActorDisplayName); + expect(mention?.actor?.id).toBeTruthy(); + expect(mention?.target?.room?.name).toBe('general'); + expect(mention?.target?.room?.id).toBeTruthy(); let dmSenderDisplayName = ''; await withServerUser(browser!, serverURL, async ({ user, page: senderPage }) => { @@ -254,18 +274,35 @@ test.describe('protobuf realtime stream', () => { await roomPage.sendMessage(`protobuf dm ${Date.now()}`); }); - const dmEvent = await realtime.waitForEvent( - (event) => event.event.case === 'newDirectMessageNotification' + const dmFrame = await realtime.waitForFrame((frame) => + frame.frame.case === 'projectionEvent' + ? frame.frame.value.operations.some((operation) => + operation.operation.case === 'notificationsReplace' + ? operation.operation.value.groups?.groups.some((group) => + group.reasons.includes(NotificationReason.DIRECT_MESSAGE) + ) + : false + ) + : false ); - expect(dmEvent.event.case).toBe('newDirectMessageNotification'); - expect(dmEvent.event.value).toEqual( - expect.objectContaining({ - senderDisplayName: dmSenderDisplayName, - conversationName: dmSenderDisplayName - }) + expect(dmFrame.frame.case).toBe('projectionEvent'); + if (dmFrame.frame.case !== 'projectionEvent') { + throw new Error('expected direct-message projection event'); + } + const dmReplacement = dmFrame.frame.value.operations + .map((operation) => + operation.operation.case === 'notificationsReplace' ? operation.operation.value : null + ) + .find((replacement) => replacement?.groups?.groups.length); + const dmGroup = dmReplacement?.groups?.groups.find((group) => + group.reasons.includes(NotificationReason.DIRECT_MESSAGE) ); - expect(dmEvent.event.value.senderId).toBeTruthy(); - expect(dmEvent.event.value.roomId).toBeTruthy(); + const dm = + dmGroup?.occurrences.find((occurrence) => occurrence.id === dmGroup.openNotificationId) ?? + dmGroup?.occurrences[0]; + expect(dm?.actor?.displayName).toBe(dmSenderDisplayName); + expect(dm?.actor?.id).toBeTruthy(); + expect(dm?.target?.room?.id).toBeTruthy(); } finally { realtime.close(); } diff --git a/apps/frontend/messages/ar/chat.json b/apps/frontend/messages/ar/chat.json index 2698c42ff..f44febb94 100644 --- a/apps/frontend/messages/ar/chat.json +++ b/apps/frontend/messages/ar/chat.json @@ -9,7 +9,18 @@ "time_now": "الآن", "time_minutes": "{count}m منذ", "time_hours": "{count}h منذ", - "time_days": "{count}d منذ" + "time_days": "{count}d منذ", + "unread": "غير مقروء", + "activity": "نشاط جديد", + "summary": { + "direct_message": "أرسل إليك {actor} رسالة مباشرة.", + "reaction": "تفاعل {actor} مع رسالتك.", + "reply": "ردّ {actor} على رسالتك.", + "mention": "أشار إليك {actor}.", + "followed_thread": "ردّ {actor} في سلسلة تتابعها.", + "new_message": "نشر {actor} رسالة جديدة.", + "activity": "أرسل إليك {actor} إشعارًا." + } }, "sign_out": { "title": "تسجيل الخروج", diff --git a/apps/frontend/messages/ar/settings.json b/apps/frontend/messages/ar/settings.json index b9808c343..5e8d093bd 100644 --- a/apps/frontend/messages/ar/settings.json +++ b/apps/frontend/messages/ar/settings.json @@ -195,35 +195,6 @@ "enable": "تمكين", "dismiss": "لا، شكرًا" }, - "levels": { - "loading": "جارٍ التحميل...", - "load_failed": "فشل تحميل تفضيلات الإشعارات", - "server_updated": "تم تحديث مستوى إشعارات الخادم", - "room_updated": "تم تحديث مستوى إشعارات الغرفة", - "update_failed": "فشل التحديث", - "server_title": "مستوى إشعارات الخادم", - "server_description": "يتحكم في كيفية تلقي الإشعارات لجميع الغرف في هذا الخادم. ويمكن للغرف الفردية تجاوز هذا الإعداد.", - "room_title": "تجاوزات الغرفة", - "room_description": "تجاوز الإعداد على مستوى الخادم للغرف الفردية. الغرف التي تم ضبطها على \"افتراضي\" ترث إعداد الخادم ({level}).", - "room_level_label": "مستوى الإشعارات لـ #{room}", - "effective": "فعال: {level}", - "default": { - "label": "الافتراضي", - "description": "استخدم الإعداد الافتراضي الموروث" - }, - "muted": { - "label": "مكتوم", - "description": "لا توجد إشعارات أو علامات غير مقروءة" - }, - "normal": { - "label": "عادي", - "description": "علامات غير مقروءة + إشارات ورسائل مباشرة وردود على سلسلة الرسائل" - }, - "all_messages": { - "label": "جميع الرسائل", - "description": "عادي + إشعار لكل رسالة جديدة" - } - }, "sound": { "title": "صوت الإشعار", "shape_title": "شكل الصوت", @@ -273,6 +244,32 @@ "dubstep": "هبوط دوبستيب", "circus": "سيرك" } + }, + "policy": { + "title": "سياسة الإشعارات", + "description": "اختر مستوى التنبيه لكل نوع من النشاط. يستخدم التوارث إعداد الخادم الافتراضي.", + "load_failed": "تعذّر تحميل سياسة الإشعارات", + "save_failed": "تعذّر حفظ سياسة الإشعارات", + "effective": "الفعلي: {intensity}", + "intensity": { + "inherit": "توريث", + "off": "إيقاف", + "badge": "شارة", + "alert": "تنبيه" + }, + "reason": { + "direct_message": "الرسائل المباشرة", + "direct_mention": "الإشارات المباشرة", + "reply": "الردود على رسائلك", + "role_mention": "إشارات الأدوار", + "here": "إشارات @here", + "all": "إشارات @all", + "followed_thread": "سلاسل النقاش المتابَعة", + "followed_room": "الغرف المتابَعة", + "reaction": "التفاعلات مع رسائلك", + "activity": "النشاط", + "room_invitation": "دعوات الغرف" + } } }, "account": { diff --git a/apps/frontend/messages/cs-CZ/chat.json b/apps/frontend/messages/cs-CZ/chat.json index 372b78710..3d1b62e38 100644 --- a/apps/frontend/messages/cs-CZ/chat.json +++ b/apps/frontend/messages/cs-CZ/chat.json @@ -9,7 +9,18 @@ "time_now": "Právě teď", "time_minutes": "před {count}m", "time_hours": "před {count}h", - "time_days": "Před {count}d" + "time_days": "Před {count}d", + "unread": "Nepřečtené", + "activity": "Nová aktivita", + "summary": { + "direct_message": "Od uživatele {actor} máte novou přímou zprávu.", + "reaction": "Uživatel {actor} reagoval na vaši zprávu.", + "reply": "Uživatel {actor} odpověděl na vaši zprávu.", + "mention": "Uživatel {actor} vás zmínil.", + "followed_thread": "Uživatel {actor} odpověděl ve vlákně, které sledujete.", + "new_message": "Uživatel {actor} zveřejnil novou zprávu.", + "activity": "Uživatel {actor} vám poslal oznámení." + } }, "sign_out": { "title": "Odhlásit se", diff --git a/apps/frontend/messages/cs-CZ/settings.json b/apps/frontend/messages/cs-CZ/settings.json index 01bd04b16..905dfecf1 100644 --- a/apps/frontend/messages/cs-CZ/settings.json +++ b/apps/frontend/messages/cs-CZ/settings.json @@ -195,35 +195,6 @@ "enable": "Povolit", "dismiss": "Ne, děkuji" }, - "levels": { - "loading": "Načítání...", - "load_failed": "Předvolby oznámení se nepodařilo načíst", - "server_updated": "Úroveň oznámení serveru aktualizována", - "room_updated": "Úroveň oznámení místnosti byla aktualizována", - "update_failed": "Aktualizace se nezdařila", - "server_title": "Úroveň oznámení serveru", - "server_description": "Řídí, jak budete dostávat oznámení pro všechny místnosti na tomto serveru. Jednotlivé místnosti mohou toto nastavení přepsat.", - "room_title": "Přepisy místností", - "room_description": "Přepište nastavení na úrovni serveru pro jednotlivé místnosti. Místnosti nastavené na „Výchozí“ zdědí nastavení serveru ({level}).", - "room_level_label": "Úroveň oznámení pro #{room}", - "effective": "Platné: {level}", - "default": { - "label": "Výchozí", - "description": "Použijte zděděné výchozí nastavení" - }, - "muted": { - "label": "Ztlumeno", - "description": "Žádná oznámení ani nepřečtené značky" - }, - "normal": { - "label": "Normální", - "description": "Nepřečtené značky + zmínky, DM a odpovědi vláken" - }, - "all_messages": { - "label": "Všechny zprávy", - "description": "Normální + upozornění na každou novou zprávu" - } - }, "sound": { "title": "Zvuk upozornění", "shape_title": "Zvukový tvar", @@ -273,6 +244,32 @@ "dubstep": "Dubstep Drop", "circus": "Cirkus" } + }, + "policy": { + "title": "Pravidla oznámení", + "description": "Zvolte, jak silně má každý druh aktivity upoutat vaši pozornost. Zdědit použije výchozí nastavení serveru.", + "load_failed": "Pravidla oznámení se nepodařilo načíst", + "save_failed": "Pravidla oznámení se nepodařilo uložit", + "effective": "Výsledné: {intensity}", + "intensity": { + "inherit": "Zdědit", + "off": "Vypnuto", + "badge": "Odznak", + "alert": "Upozornění" + }, + "reason": { + "direct_message": "Přímé zprávy", + "direct_mention": "Přímé zmínky", + "reply": "Odpovědi na vaše zprávy", + "role_mention": "Zmínky rolí", + "here": "Zmínky @here", + "all": "Zmínky @all", + "followed_thread": "Sledovaná vlákna", + "followed_room": "Sledované místnosti", + "reaction": "Reakce na vaše zprávy", + "activity": "Aktivita", + "room_invitation": "Pozvánky do místností" + } } }, "account": { diff --git a/apps/frontend/messages/de-DE/chat.json b/apps/frontend/messages/de-DE/chat.json index 018e07620..941a37e39 100644 --- a/apps/frontend/messages/de-DE/chat.json +++ b/apps/frontend/messages/de-DE/chat.json @@ -9,7 +9,18 @@ "time_now": "Gerade eben", "time_minutes": "vor {count} Min.", "time_hours": "vor {count} Std.", - "time_days": "vor {count} Tg." + "time_days": "vor {count} Tg.", + "unread": "Ungelesen", + "activity": "Neue Aktivität", + "summary": { + "direct_message": "{actor} hat dir eine Direktnachricht gesendet.", + "reaction": "{actor} hat auf deine Nachricht reagiert.", + "reply": "{actor} hat auf deine Nachricht geantwortet.", + "mention": "{actor} hat dich erwähnt.", + "followed_thread": "{actor} hat in einem Thread geantwortet, dem du folgst.", + "new_message": "{actor} hat eine neue Nachricht gepostet.", + "activity": "{actor} hat dir eine Benachrichtigung gesendet." + } }, "sign_out": { "title": "Abmelden", diff --git a/apps/frontend/messages/de-DE/settings.json b/apps/frontend/messages/de-DE/settings.json index cf71e708a..35c6c99cd 100644 --- a/apps/frontend/messages/de-DE/settings.json +++ b/apps/frontend/messages/de-DE/settings.json @@ -195,35 +195,6 @@ "enable": "Aktivieren", "dismiss": "Nein danke" }, - "levels": { - "loading": "Wird geladen...", - "load_failed": "Benachrichtigungseinstellungen konnten nicht geladen werden", - "server_updated": "Benachrichtigungsstufe für den Server aktualisiert", - "room_updated": "Benachrichtigungsstufe für den Raum aktualisiert", - "update_failed": "Aktualisierung fehlgeschlagen", - "server_title": "Benachrichtigungsstufe für den Server", - "server_description": "Steuert, wie du Benachrichtigungen für alle Räume auf diesem Server erhältst. Einzelne Räume können diese Einstellung überschreiben.", - "room_title": "Raum-Ausnahmen", - "room_description": "Überschreibe die serverweite Einstellung für einzelne Räume. Räume mit \"Standard\" übernehmen die Server-Einstellung ({level}).", - "room_level_label": "Benachrichtigungsstufe für #{room}", - "effective": "Wirksam: {level}", - "default": { - "label": "Standard", - "description": "Den geerbten Standard verwenden" - }, - "muted": { - "label": "Stumm", - "description": "Keine Benachrichtigungen oder Ungelesen-Markierungen" - }, - "normal": { - "label": "Normal", - "description": "Ungelesen-Markierungen + Erwähnungen, DMs und Thread-Antworten" - }, - "all_messages": { - "label": "Alle Nachrichten", - "description": "Normal + Benachrichtigung für jede neue Nachricht" - } - }, "sound": { "title": "Benachrichtigungston", "shape_title": "Klangform", @@ -273,6 +244,32 @@ "dubstep": "Dubstep-Drop", "circus": "Zirkus" } + }, + "policy": { + "title": "Benachrichtigungsrichtlinie", + "description": "Lege fest, wie stark jede Art von Aktivität deine Aufmerksamkeit erhalten soll. Vererben verwendet die Servervorgabe.", + "load_failed": "Benachrichtigungsrichtlinie konnte nicht geladen werden", + "save_failed": "Benachrichtigungsrichtlinie konnte nicht gespeichert werden", + "effective": "Wirksam: {intensity}", + "intensity": { + "inherit": "Vererben", + "off": "Aus", + "badge": "Abzeichen", + "alert": "Hinweis" + }, + "reason": { + "direct_message": "Direktnachrichten", + "direct_mention": "Direkte Erwähnungen", + "reply": "Antworten auf deine Nachrichten", + "role_mention": "Rollenerwähnungen", + "here": "@here-Erwähnungen", + "all": "@all-Erwähnungen", + "followed_thread": "Verfolgte Threads", + "followed_room": "Verfolgte Räume", + "reaction": "Reaktionen auf deine Nachrichten", + "activity": "Aktivität", + "room_invitation": "Raumeinladungen" + } } }, "account": { diff --git a/apps/frontend/messages/en-GB/chat.json b/apps/frontend/messages/en-GB/chat.json index 99b984073..752b02e24 100644 --- a/apps/frontend/messages/en-GB/chat.json +++ b/apps/frontend/messages/en-GB/chat.json @@ -3,13 +3,24 @@ "notifications": { "title": "Notifications", "subtitle": "Here's what's new", - "clear_all": "Clear all", + "clear_all": "Dismiss all", "empty_title": "No notifications", "empty_body": "You're all caught up!", "time_now": "Just now", "time_minutes": "{count}m ago", "time_hours": "{count}h ago", - "time_days": "{count}d ago" + "time_days": "{count}d ago", + "unread": "Unread", + "activity": "New activity", + "summary": { + "direct_message": "{actor} has sent you a direct message.", + "reaction": "{actor} reacted to your message.", + "reply": "{actor} replied to your message.", + "mention": "{actor} mentioned you.", + "followed_thread": "{actor} replied in a thread you follow.", + "new_message": "{actor} posted a new message.", + "activity": "{actor} sent you a notification." + } }, "sign_out": { "title": "Sign Out", diff --git a/apps/frontend/messages/en-GB/settings.json b/apps/frontend/messages/en-GB/settings.json index f8089cbcf..4d3782c82 100644 --- a/apps/frontend/messages/en-GB/settings.json +++ b/apps/frontend/messages/en-GB/settings.json @@ -195,35 +195,6 @@ "enable": "Enable", "dismiss": "No thanks" }, - "levels": { - "loading": "Loading...", - "load_failed": "Failed to load notification preferences", - "server_updated": "Server notification level updated", - "room_updated": "Room notification level updated", - "update_failed": "Failed to update", - "server_title": "Server Notification Level", - "server_description": "Controls how you receive notifications for all rooms in this server. Individual rooms can override this setting.", - "room_title": "Room Overrides", - "room_description": "Override the server-level setting for individual rooms. Rooms set to \"Default\" inherit the server setting ({level}).", - "room_level_label": "Notification level for #{room}", - "effective": "Effective: {level}", - "default": { - "label": "Default", - "description": "Use the inherited default" - }, - "muted": { - "label": "Muted", - "description": "No notifications or unread markers" - }, - "normal": { - "label": "Normal", - "description": "Unread markers + mentions, DMs, and thread replies" - }, - "all_messages": { - "label": "All Messages", - "description": "Normal + notification for every new message" - } - }, "sound": { "title": "Notification Sound", "shape_title": "Sound Shape", @@ -273,6 +244,32 @@ "dubstep": "Dubstep Drop", "circus": "Circus" } + }, + "policy": { + "title": "Notification policy", + "description": "Choose how strongly each kind of activity should get your attention. Inherit uses the server default.", + "load_failed": "Failed to load notification policy", + "save_failed": "Failed to save notification policy", + "effective": "Effective: {intensity}", + "intensity": { + "inherit": "Inherit", + "off": "Off", + "badge": "Badge", + "alert": "Alert" + }, + "reason": { + "direct_message": "Direct messages", + "direct_mention": "Direct mentions", + "reply": "Replies to your messages", + "role_mention": "Role mentions", + "here": "@here mentions", + "all": "@all mentions", + "followed_thread": "Followed threads", + "followed_room": "Followed rooms", + "reaction": "Reactions to your messages", + "activity": "Activity", + "room_invitation": "Room invitations" + } } }, "account": { diff --git a/apps/frontend/messages/eo/chat.json b/apps/frontend/messages/eo/chat.json index e10bd0818..3206e1277 100644 --- a/apps/frontend/messages/eo/chat.json +++ b/apps/frontend/messages/eo/chat.json @@ -9,7 +9,18 @@ "time_now": "Ĝuste nun", "time_minutes": "{count}m antaŭ", "time_hours": "{count}h antaŭ", - "time_days": "{count}d antaŭ" + "time_days": "{count}d antaŭ", + "unread": "Nelegite", + "activity": "Nova aktiveco", + "summary": { + "direct_message": "{actor} sendis al vi rektan mesaĝon.", + "reaction": "{actor} reagis al via mesaĝo.", + "reply": "{actor} respondis al via mesaĝo.", + "mention": "{actor} menciis vin.", + "followed_thread": "{actor} respondis en fadeno, kiun vi sekvas.", + "new_message": "{actor} afiŝis novan mesaĝon.", + "activity": "{actor} sendis al vi sciigon." + } }, "sign_out": { "title": "Eliru", diff --git a/apps/frontend/messages/eo/settings.json b/apps/frontend/messages/eo/settings.json index 0de5195ee..cca0020a0 100644 --- a/apps/frontend/messages/eo/settings.json +++ b/apps/frontend/messages/eo/settings.json @@ -195,35 +195,6 @@ "enable": "Ebligu", "dismiss": "Ne dankon" }, - "levels": { - "loading": "Ŝarĝante...", - "load_failed": "Malsukcesis ŝargi sciigajn preferojn", - "server_updated": "Servila sciiga nivelo ĝisdatigita", - "room_updated": "Ĉambra sciiga nivelo ĝisdatigita", - "update_failed": "Malsukcesis ĝisdatigi", - "server_title": "Servila Sciiga Nivelo", - "server_description": "Kontrolas kiel vi ricevas sciigojn por ĉiuj ĉambroj en ĉi tiu servilo. Individuaj ĉambroj povas anstataŭi ĉi tiun agordon.", - "room_title": "Ĉambro Anstataŭigoj", - "room_description": "Anstataŭigi la servilnivelan agordon por individuaj ĉambroj. Ĉambroj fiksitaj al \"Defaŭlta\" heredas la servilan agordon ({level}).", - "room_level_label": "Sciiga nivelo por #{room}", - "effective": "Efika: {level}", - "default": { - "label": "Defaŭlte", - "description": "Uzu la hereditan defaŭlton" - }, - "muted": { - "label": "Silentita", - "description": "Neniuj sciigoj aŭ nelegitaj markiloj" - }, - "normal": { - "label": "Normala", - "description": "Nelegitaj markiloj + mencioj, DM-oj kaj fadenaj respondoj" - }, - "all_messages": { - "label": "Ĉiuj Mesaĝoj", - "description": "Normala + sciigo por ĉiu nova mesaĝo" - } - }, "sound": { "title": "Sciiga Sono", "shape_title": "Sonformo", @@ -273,6 +244,32 @@ "dubstep": "Dubstep Drop", "circus": "Cirko" } + }, + "policy": { + "title": "Sciiga politiko", + "description": "Elektu kiom forte ĉiu speco de aktiveco atentigu vin. Heredi uzas la servilan aprioron.", + "load_failed": "Ne eblis ŝargi la sciigan politikon", + "save_failed": "Ne eblis konservi la sciigan politikon", + "effective": "Efektiva: {intensity}", + "intensity": { + "inherit": "Heredi", + "off": "Malŝaltita", + "badge": "Insigno", + "alert": "Alarmo" + }, + "reason": { + "direct_message": "Rektaj mesaĝoj", + "direct_mention": "Rektaj mencioj", + "reply": "Respondoj al viaj mesaĝoj", + "role_mention": "Rolmencioj", + "here": "@here-mencioj", + "all": "@all-mencioj", + "followed_thread": "Sekvataj fadenoj", + "followed_room": "Sekvataj ĉambroj", + "reaction": "Reagoj al viaj mesaĝoj", + "activity": "Aktiveco", + "room_invitation": "Invitoj al ĉambroj" + } } }, "account": { diff --git a/apps/frontend/messages/es-419/chat.json b/apps/frontend/messages/es-419/chat.json index cef9be177..39a8814d5 100644 --- a/apps/frontend/messages/es-419/chat.json +++ b/apps/frontend/messages/es-419/chat.json @@ -9,7 +9,18 @@ "time_now": "Justo ahora", "time_minutes": "Hace {count}m", "time_hours": "Hace {count}h", - "time_days": "Hace {count}d" + "time_days": "Hace {count}d", + "unread": "No leído", + "activity": "Actividad nueva", + "summary": { + "direct_message": "{actor} te envió un mensaje directo.", + "reaction": "{actor} reaccionó a tu mensaje.", + "reply": "{actor} respondió a tu mensaje.", + "mention": "{actor} te mencionó.", + "followed_thread": "{actor} respondió en un hilo que sigues.", + "new_message": "{actor} publicó un mensaje nuevo.", + "activity": "{actor} te envió una notificación." + } }, "sign_out": { "title": "Cerrar sesión", diff --git a/apps/frontend/messages/es-419/settings.json b/apps/frontend/messages/es-419/settings.json index e4e4b8ad2..841c7e196 100644 --- a/apps/frontend/messages/es-419/settings.json +++ b/apps/frontend/messages/es-419/settings.json @@ -195,35 +195,6 @@ "enable": "Habilitar", "dismiss": "No, gracias" }, - "levels": { - "loading": "Cargando...", - "load_failed": "No se pudo cargar las preferencias de notificación", - "server_updated": "Nivel de notificación del servidor actualizado", - "room_updated": "Nivel de notificación de sala actualizado", - "update_failed": "No se pudo actualizar", - "server_title": "Nivel de notificación del servidor", - "server_description": "Controla cómo recibes notificaciones para todas las salas de este servidor. Las habitaciones individuales pueden anular esta configuración.", - "room_title": "Anulación de salas", - "room_description": "Anula la configuración a nivel de servidor para salas individuales. Las habitaciones configuradas como \"Predeterminadas\" heredan la configuración del servidor ({level}).", - "room_level_label": "Nivel de notificación para #{room}", - "effective": "Vigente: {level}", - "default": { - "label": "Predeterminado", - "description": "Usar el valor predeterminado heredado" - }, - "muted": { - "label": "Silenciado", - "description": "No hay notificaciones ni marcadores de no leídos" - }, - "normal": { - "label": "Normal", - "description": "Marcadores no leídos + menciones, DM y respuestas a hilos" - }, - "all_messages": { - "label": "Todos los mensajes", - "description": "Normal + notificación por cada mensaje nuevo" - } - }, "sound": { "title": "Sonido de notificación", "shape_title": "Forma del sonido", @@ -273,6 +244,32 @@ "dubstep": "Lanzamiento de Dubstep", "circus": "Circo" } + }, + "policy": { + "title": "Política de notificaciones", + "description": "Elige cuánta atención debe recibir cada tipo de actividad. Heredar usa el valor predeterminado del servidor.", + "load_failed": "No se pudo cargar la política de notificaciones", + "save_failed": "No se pudo guardar la política de notificaciones", + "effective": "Efectivo: {intensity}", + "intensity": { + "inherit": "Heredar", + "off": "Desactivado", + "badge": "Indicador", + "alert": "Alerta" + }, + "reason": { + "direct_message": "Mensajes directos", + "direct_mention": "Menciones directas", + "reply": "Respuestas a tus mensajes", + "role_mention": "Menciones de roles", + "here": "Menciones @here", + "all": "Menciones @all", + "followed_thread": "Hilos seguidos", + "followed_room": "Salas seguidas", + "reaction": "Reacciones a tus mensajes", + "activity": "Actividad", + "room_invitation": "Invitaciones a salas" + } } }, "account": { diff --git a/apps/frontend/messages/es-ES/chat.json b/apps/frontend/messages/es-ES/chat.json index cef9be177..dcd05964c 100644 --- a/apps/frontend/messages/es-ES/chat.json +++ b/apps/frontend/messages/es-ES/chat.json @@ -9,7 +9,18 @@ "time_now": "Justo ahora", "time_minutes": "Hace {count}m", "time_hours": "Hace {count}h", - "time_days": "Hace {count}d" + "time_days": "Hace {count}d", + "unread": "No leído", + "activity": "Actividad nueva", + "summary": { + "direct_message": "{actor} te ha enviado un mensaje directo.", + "reaction": "{actor} ha reaccionado a tu mensaje.", + "reply": "{actor} ha respondido a tu mensaje.", + "mention": "{actor} te ha mencionado.", + "followed_thread": "{actor} ha respondido en un hilo que sigues.", + "new_message": "{actor} ha publicado un mensaje nuevo.", + "activity": "{actor} te ha enviado una notificación." + } }, "sign_out": { "title": "Cerrar sesión", diff --git a/apps/frontend/messages/es-ES/settings.json b/apps/frontend/messages/es-ES/settings.json index aac550218..c066d6122 100644 --- a/apps/frontend/messages/es-ES/settings.json +++ b/apps/frontend/messages/es-ES/settings.json @@ -195,35 +195,6 @@ "enable": "Habilitar", "dismiss": "No, gracias" }, - "levels": { - "loading": "Cargando...", - "load_failed": "No se pudo cargar las preferencias de notificación", - "server_updated": "Nivel de notificación del servidor actualizado", - "room_updated": "Nivel de notificación de sala actualizado", - "update_failed": "No se pudo actualizar", - "server_title": "Nivel de notificación del servidor", - "server_description": "Controla cómo recibes notificaciones para todas las salas de este servidor. Las habitaciones individuales pueden anular esta configuración.", - "room_title": "Anulación de salas", - "room_description": "Anula la configuración a nivel de servidor para salas individuales. Las habitaciones configuradas como \"Predeterminadas\" heredan la configuración del servidor ({level}).", - "room_level_label": "Nivel de notificación para #{room}", - "effective": "Vigente: {level}", - "default": { - "label": "Predeterminado", - "description": "Usar el valor predeterminado heredado" - }, - "muted": { - "label": "Silenciado", - "description": "No hay notificaciones ni marcadores de no leídos" - }, - "normal": { - "label": "Normal", - "description": "Marcadores no leídos + menciones, DM y respuestas a hilos" - }, - "all_messages": { - "label": "Todos los mensajes", - "description": "Normal + notificación por cada mensaje nuevo" - } - }, "sound": { "title": "Sonido de notificación", "shape_title": "Forma del sonido", @@ -273,6 +244,32 @@ "dubstep": "Lanzamiento de Dubstep", "circus": "Circo" } + }, + "policy": { + "title": "Política de notificaciones", + "description": "Elige cuánta atención debe recibir cada tipo de actividad. Heredar usa el valor predeterminado del servidor.", + "load_failed": "No se pudo cargar la política de notificaciones", + "save_failed": "No se pudo guardar la política de notificaciones", + "effective": "Efectivo: {intensity}", + "intensity": { + "inherit": "Heredar", + "off": "Desactivado", + "badge": "Indicador", + "alert": "Alerta" + }, + "reason": { + "direct_message": "Mensajes directos", + "direct_mention": "Menciones directas", + "reply": "Respuestas a tus mensajes", + "role_mention": "Menciones de roles", + "here": "Menciones @here", + "all": "Menciones @all", + "followed_thread": "Hilos seguidos", + "followed_room": "Salas seguidas", + "reaction": "Reacciones a tus mensajes", + "activity": "Actividad", + "room_invitation": "Invitaciones a salas" + } } }, "account": { diff --git a/apps/frontend/messages/et-EE/chat.json b/apps/frontend/messages/et-EE/chat.json index 296aef19d..54548f4bd 100644 --- a/apps/frontend/messages/et-EE/chat.json +++ b/apps/frontend/messages/et-EE/chat.json @@ -9,7 +9,18 @@ "time_now": "Just praegu", "time_minutes": "{count}min tagasi", "time_hours": "{count}h tagasi", - "time_days": "{count}p tagasi" + "time_days": "{count}p tagasi", + "unread": "Lugemata", + "activity": "Uus tegevus", + "summary": { + "direct_message": "{actor} saatis sulle otsesõnumi.", + "reaction": "{actor} reageeris sinu sõnumile.", + "reply": "{actor} vastas sinu sõnumile.", + "mention": "{actor} mainis sind.", + "followed_thread": "{actor} vastas lõimes, mida jälgid.", + "new_message": "{actor} postitas uue sõnumi.", + "activity": "{actor} saatis sulle teavituse." + } }, "sign_out": { "title": "Logi välja", diff --git a/apps/frontend/messages/et-EE/settings.json b/apps/frontend/messages/et-EE/settings.json index 4a9dc861e..4f83771f6 100644 --- a/apps/frontend/messages/et-EE/settings.json +++ b/apps/frontend/messages/et-EE/settings.json @@ -195,35 +195,6 @@ "enable": "Luba", "dismiss": "Ei aitäh" }, - "levels": { - "loading": "Laadimine...", - "load_failed": "Teavituse eelistuste laadimine ebaõnnestus", - "server_updated": "Serveri teavituste taset värskendati", - "room_updated": "Ruumi teavituste taset värskendati", - "update_failed": "Värskendamine ebaõnnestus", - "server_title": "Serveri teavituse tase", - "server_description": "Määrab, kuidas saate selle serveri kõigi ruumide kohta märguandeid. Üksikud ruumid võivad selle sätte tühistada.", - "room_title": "Ruumi alistamised", - "room_description": "Alistage üksikute ruumide serveritaseme säte. Vaikimisi seatud ruumid pärivad serveri seaded ({level}).", - "room_level_label": "#{room} teavitustase", - "effective": "Kehtib: {level}", - "default": { - "label": "Vaikimisi", - "description": "Kasutage päritud vaikeseadet" - }, - "muted": { - "label": "Vaigistatud", - "description": "Märguandeid ega lugemata markereid pole" - }, - "normal": { - "label": "Tavaline", - "description": "Lugemata markerid + mainimised, DM-id ja lõime vastused" - }, - "all_messages": { - "label": "Kõik sõnumid", - "description": "Tavaline + märguanne iga uue sõnumi kohta" - } - }, "sound": { "title": "Teavitusheli", "shape_title": "Heli kuju", @@ -273,6 +244,32 @@ "dubstep": "Dubstep Drop", "circus": "Tsirkus" } + }, + "policy": { + "title": "Teavituste reeglid", + "description": "Vali, kui tugevalt iga tegevuse liik sinu tähelepanu köidab. Pärimine kasutab serveri vaikeväärtust.", + "load_failed": "Teavituste reeglite laadimine ebaõnnestus", + "save_failed": "Teavituste reeglite salvestamine ebaõnnestus", + "effective": "Kehtiv: {intensity}", + "intensity": { + "inherit": "Päri", + "off": "Väljas", + "badge": "Märk", + "alert": "Teavitus" + }, + "reason": { + "direct_message": "Otsesõnumid", + "direct_mention": "Otsesed mainimised", + "reply": "Vastused sinu sõnumitele", + "role_mention": "Rollimainimised", + "here": "@here mainimised", + "all": "@all mainimised", + "followed_thread": "Jälgitavad lõimed", + "followed_room": "Jälgitavad ruumid", + "reaction": "Reaktsioonid sinu sõnumitele", + "activity": "Tegevus", + "room_invitation": "Ruumide kutsed" + } } }, "account": { diff --git a/apps/frontend/messages/fr-CA/chat.json b/apps/frontend/messages/fr-CA/chat.json index 9ad355cb0..b9e88bff0 100644 --- a/apps/frontend/messages/fr-CA/chat.json +++ b/apps/frontend/messages/fr-CA/chat.json @@ -9,7 +9,18 @@ "time_now": "Tout à l'heure", "time_minutes": "Y a {count}m", "time_hours": "Y a {count}h", - "time_days": "Y a {count}d" + "time_days": "Y a {count}d", + "unread": "Non lu", + "activity": "Nouvelle activité", + "summary": { + "direct_message": "{actor} vous a envoyé un message direct.", + "reaction": "{actor} a réagi à votre message.", + "reply": "{actor} a répondu à votre message.", + "mention": "{actor} vous a mentionné.", + "followed_thread": "{actor} a répondu dans un fil que vous suivez.", + "new_message": "{actor} a publié un nouveau message.", + "activity": "{actor} vous a envoyé une notification." + } }, "sign_out": { "title": "Se déconnecter", diff --git a/apps/frontend/messages/fr-CA/settings.json b/apps/frontend/messages/fr-CA/settings.json index 22b865666..a9651f0e4 100644 --- a/apps/frontend/messages/fr-CA/settings.json +++ b/apps/frontend/messages/fr-CA/settings.json @@ -195,35 +195,6 @@ "enable": "Activer", "dismiss": "Non merci" }, - "levels": { - "loading": "Chargement...", - "load_failed": "Échec du chargement des préférences de notification", - "server_updated": "Niveau de notification du serveur mis à jour", - "room_updated": "Niveau de notification de la salle mis à jour", - "update_failed": "Échec de la mise à jour", - "server_title": "Niveau de notification du serveur", - "server_description": "Contrôle la façon dont vous recevez des notifications pour toutes les salles de ce serveur. Les pièces individuelles peuvent remplacer ce paramètre.", - "room_title": "Remplacements de salle", - "room_description": "Remplacez le paramètre au niveau du serveur pour les salles individuelles. Les salles définies sur « Par défaut » héritent du paramètre du serveur ({level}).", - "room_level_label": "Niveau de notification pour #{room}", - "effective": "En vigueur : {level}", - "default": { - "label": "Par défaut", - "description": "Utiliser la valeur par défaut héritée" - }, - "muted": { - "label": "Muet", - "description": "Aucune notification ni marqueur non lu" - }, - "normal": { - "label": "Normal", - "description": "Marqueurs non lus + mentions, messages directs et réponses au fil de discussion" - }, - "all_messages": { - "label": "Tous les messages", - "description": "Normal + notification pour chaque nouveau message" - } - }, "sound": { "title": "Son de notification", "shape_title": "Forme sonore", @@ -273,6 +244,32 @@ "dubstep": "Sortie Dubstep", "circus": "Cirque" } + }, + "policy": { + "title": "Politique de notifications", + "description": "Choisissez le niveau d'attention accordé à chaque type d'activité. Hériter utilise la valeur par défaut du serveur.", + "load_failed": "Impossible de charger la politique de notifications", + "save_failed": "Impossible d'enregistrer la politique de notifications", + "effective": "Effectif : {intensity}", + "intensity": { + "inherit": "Hériter", + "off": "Désactivé", + "badge": "Badge", + "alert": "Alerte" + }, + "reason": { + "direct_message": "Messages directs", + "direct_mention": "Mentions directes", + "reply": "Réponses à vos messages", + "role_mention": "Mentions de rôles", + "here": "Mentions @here", + "all": "Mentions @all", + "followed_thread": "Fils suivis", + "followed_room": "Salons suivis", + "reaction": "Réactions à vos messages", + "activity": "Activité", + "room_invitation": "Invitations à des salons" + } } }, "account": { diff --git a/apps/frontend/messages/fr-FR/chat.json b/apps/frontend/messages/fr-FR/chat.json index 54776e37e..2dd3df280 100644 --- a/apps/frontend/messages/fr-FR/chat.json +++ b/apps/frontend/messages/fr-FR/chat.json @@ -9,7 +9,18 @@ "time_now": "Tout à l'heure", "time_minutes": "Il y a {count}m", "time_hours": "Il y a {count}h", - "time_days": "Il y a {count}d" + "time_days": "Il y a {count}d", + "unread": "Non lu", + "activity": "Nouvelle activité", + "summary": { + "direct_message": "{actor} vous a envoyé un message direct.", + "reaction": "{actor} a réagi à votre message.", + "reply": "{actor} a répondu à votre message.", + "mention": "{actor} vous a mentionné.", + "followed_thread": "{actor} a répondu dans un fil que vous suivez.", + "new_message": "{actor} a publié un nouveau message.", + "activity": "{actor} vous a envoyé une notification." + } }, "sign_out": { "title": "Se déconnecter", diff --git a/apps/frontend/messages/fr-FR/settings.json b/apps/frontend/messages/fr-FR/settings.json index fb0cd855e..0fa4ff0b2 100644 --- a/apps/frontend/messages/fr-FR/settings.json +++ b/apps/frontend/messages/fr-FR/settings.json @@ -195,35 +195,6 @@ "enable": "Activer", "dismiss": "Non merci" }, - "levels": { - "loading": "Chargement...", - "load_failed": "Échec du chargement des préférences de notification", - "server_updated": "Niveau de notification du serveur mis à jour", - "room_updated": "Niveau de notification de la salle mis à jour", - "update_failed": "Échec de la mise à jour", - "server_title": "Niveau de notification du serveur", - "server_description": "Contrôle la façon dont vous recevez des notifications pour toutes les salles de ce serveur. Les pièces individuelles peuvent remplacer ce paramètre.", - "room_title": "Remplacements de salle", - "room_description": "Remplacez le paramètre au niveau du serveur pour les salles individuelles. Les salles définies sur « Par défaut » héritent du paramètre du serveur ({level}).", - "room_level_label": "Niveau de notification pour #{room}", - "effective": "En vigueur : {level}", - "default": { - "label": "Par défaut", - "description": "Utiliser la valeur par défaut héritée" - }, - "muted": { - "label": "Muet", - "description": "Aucune notification ni marqueur non lu" - }, - "normal": { - "label": "Normale", - "description": "Marqueurs non lus + mentions, messages directs et réponses au fil de discussion" - }, - "all_messages": { - "label": "Tous les messages", - "description": "Normal + notification pour chaque nouveau message" - } - }, "sound": { "title": "Son de notification", "shape_title": "Forme sonore", @@ -273,6 +244,32 @@ "dubstep": "Sortie Dubstep", "circus": "Cirque" } + }, + "policy": { + "title": "Politique de notifications", + "description": "Choisissez le niveau d'attention accordé à chaque type d'activité. Hériter utilise la valeur par défaut du serveur.", + "load_failed": "Impossible de charger la politique de notifications", + "save_failed": "Impossible d'enregistrer la politique de notifications", + "effective": "Effectif : {intensity}", + "intensity": { + "inherit": "Hériter", + "off": "Désactivé", + "badge": "Badge", + "alert": "Alerte" + }, + "reason": { + "direct_message": "Messages directs", + "direct_mention": "Mentions directes", + "reply": "Réponses à vos messages", + "role_mention": "Mentions de rôles", + "here": "Mentions @here", + "all": "Mentions @all", + "followed_thread": "Fils suivis", + "followed_room": "Salons suivis", + "reaction": "Réactions à vos messages", + "activity": "Activité", + "room_invitation": "Invitations à des salons" + } } }, "account": { diff --git a/apps/frontend/messages/he-IL/chat.json b/apps/frontend/messages/he-IL/chat.json index 5fb54dc0a..33983a22c 100644 --- a/apps/frontend/messages/he-IL/chat.json +++ b/apps/frontend/messages/he-IL/chat.json @@ -9,7 +9,18 @@ "time_now": "ממש עכשיו", "time_minutes": "{count}m לפני", "time_hours": "{count}h לפני", - "time_days": "{count}d לפני" + "time_days": "{count}d לפני", + "unread": "לא נקרא", + "activity": "פעילות חדשה", + "summary": { + "direct_message": "קיבלת הודעה ישירה מאת {actor}.", + "reaction": "נוספה תגובה מאת {actor} להודעה שלך.", + "reply": "נוספה תשובה מאת {actor} להודעה שלך.", + "mention": "קיבלת אזכור מאת {actor}.", + "followed_thread": "נוספה תשובה מאת {actor} בשרשור שבמעקב שלך.", + "new_message": "נוספה הודעה חדשה מאת {actor}.", + "activity": "קיבלת התראה מאת {actor}." + } }, "sign_out": { "title": "צא", diff --git a/apps/frontend/messages/he-IL/settings.json b/apps/frontend/messages/he-IL/settings.json index e3bfde82c..e43306b4e 100644 --- a/apps/frontend/messages/he-IL/settings.json +++ b/apps/frontend/messages/he-IL/settings.json @@ -195,35 +195,6 @@ "enable": "הפעל", "dismiss": "לא תודה" }, - "levels": { - "loading": "טוען...", - "load_failed": "נכשל בטעינת העדפות ההתראות", - "server_updated": "רמת ההתראה של השרת עודכנה", - "room_updated": "רמת ההתראה בחדר עודכנה", - "update_failed": "נכשל בעדכון", - "server_title": "רמת הודעות שרת", - "server_description": "שולט כיצד אתה מקבל התראות עבור כל החדרים בשרת זה. חדרים בודדים יכולים לעקוף הגדרה זו.", - "room_title": "עקיפות לחדרים", - "room_description": "עוקף את ההגדרה ברמת השרת עבור חדרים בודדים. חדרים המוגדרים ל\"ברירת מחדל\" יורשים את הגדרת השרת ({level}).", - "room_level_label": "רמת התראה עבור #{room}", - "effective": "יעיל: {level}", - "default": { - "label": "ברירת מחדל", - "description": "השתמש בברירת המחדל שהורשתה" - }, - "muted": { - "label": "מושתק", - "description": "אין התראות או סמנים שלא נקראו" - }, - "normal": { - "label": "רגיל", - "description": "סמנים שלא נקראו + אזכורים, הודעות DM ותשובות שרשור" - }, - "all_messages": { - "label": "כל ההודעות", - "description": "רגיל + התראה לכל הודעה חדשה" - } - }, "sound": { "title": "צליל התראה", "shape_title": "צורת צליל", @@ -273,6 +244,32 @@ "dubstep": "Dubstep Drop", "circus": "קרקס" } + }, + "policy": { + "title": "מדיניות התראות", + "description": "בחרו באיזו עוצמה כל סוג פעילות ימשוך את תשומת לבכם. ירושה משתמשת בברירת המחדל של השרת.", + "load_failed": "טעינת מדיניות ההתראות נכשלה", + "save_failed": "שמירת מדיניות ההתראות נכשלה", + "effective": "בתוקף: {intensity}", + "intensity": { + "inherit": "ירושה", + "off": "כבוי", + "badge": "תג", + "alert": "התראה" + }, + "reason": { + "direct_message": "הודעות ישירות", + "direct_mention": "אזכורים ישירים", + "reply": "תגובות להודעות שלך", + "role_mention": "אזכורי תפקידים", + "here": "אזכורי @here", + "all": "אזכורי @all", + "followed_thread": "שרשורים במעקב", + "followed_room": "חדרים במעקב", + "reaction": "תגובות להודעות שלך", + "activity": "פעילות", + "room_invitation": "הזמנות לחדרים" + } } }, "account": { diff --git a/apps/frontend/messages/it-IT/chat.json b/apps/frontend/messages/it-IT/chat.json index 9ba60a7b2..40708d170 100644 --- a/apps/frontend/messages/it-IT/chat.json +++ b/apps/frontend/messages/it-IT/chat.json @@ -9,7 +9,18 @@ "time_now": "Proprio adesso", "time_minutes": "{count}m fa", "time_hours": "{count} ore fa", - "time_days": "{count}d fa" + "time_days": "{count}d fa", + "unread": "Non letta", + "activity": "Nuova attività", + "summary": { + "direct_message": "{actor} ti ha inviato un messaggio diretto.", + "reaction": "{actor} ha reagito al tuo messaggio.", + "reply": "{actor} ha risposto al tuo messaggio.", + "mention": "{actor} ti ha menzionato.", + "followed_thread": "{actor} ha risposto in una discussione che segui.", + "new_message": "{actor} ha pubblicato un nuovo messaggio.", + "activity": "{actor} ti ha inviato una notifica." + } }, "sign_out": { "title": "Esci", diff --git a/apps/frontend/messages/it-IT/settings.json b/apps/frontend/messages/it-IT/settings.json index f8177fb29..3f762995a 100644 --- a/apps/frontend/messages/it-IT/settings.json +++ b/apps/frontend/messages/it-IT/settings.json @@ -195,35 +195,6 @@ "enable": "Abilita", "dismiss": "No grazie" }, - "levels": { - "loading": "Caricamento...", - "load_failed": "Impossibile caricare le preferenze di notifica", - "server_updated": "Livello di notifica del server aggiornato", - "room_updated": "Livello di notifica della stanza aggiornato", - "update_failed": "Impossibile aggiornare", - "server_title": "Livello di notifica del server", - "server_description": "Controlla il modo in cui ricevi le notifiche per tutte le stanze in questo server. Le singole stanze possono ignorare questa impostazione.", - "room_title": "Sostituzioni della stanza", - "room_description": "Sostituisci l'impostazione a livello di server per le singole stanze. Le stanze impostate su \"Predefinite\" ereditano l'impostazione del server ({level}).", - "room_level_label": "Livello di notifica per #{room}", - "effective": "Efficace: {level}", - "default": { - "label": "Predefinito", - "description": "Utilizza l'impostazione predefinita ereditata" - }, - "muted": { - "label": "Disattivato", - "description": "Nessuna notifica o indicatore di non letto" - }, - "normal": { - "label": "Normale", - "description": "Indicatori non letti + menzioni, DM e risposte ai thread" - }, - "all_messages": { - "label": "Tutti i messaggi", - "description": "Normale + notifica per ogni nuovo messaggio" - } - }, "sound": { "title": "Suono di notifica", "shape_title": "Forma del suono", @@ -273,6 +244,32 @@ "dubstep": "Goccia dubstep", "circus": "Circo" } + }, + "policy": { + "title": "Regole di notifica", + "description": "Scegli quanta attenzione deve ricevere ogni tipo di attività. Eredita usa il valore predefinito del server.", + "load_failed": "Impossibile caricare le regole di notifica", + "save_failed": "Impossibile salvare le regole di notifica", + "effective": "Effettivo: {intensity}", + "intensity": { + "inherit": "Eredita", + "off": "Disattivato", + "badge": "Indicatore", + "alert": "Avviso" + }, + "reason": { + "direct_message": "Messaggi diretti", + "direct_mention": "Menzioni dirette", + "reply": "Risposte ai tuoi messaggi", + "role_mention": "Menzioni di ruoli", + "here": "Menzioni @here", + "all": "Menzioni @all", + "followed_thread": "Discussioni seguite", + "followed_room": "Stanze seguite", + "reaction": "Reazioni ai tuoi messaggi", + "activity": "Attività", + "room_invitation": "Inviti alle stanze" + } } }, "account": { diff --git a/apps/frontend/messages/ja-JP/chat.json b/apps/frontend/messages/ja-JP/chat.json index 7007ec772..605ebcb30 100644 --- a/apps/frontend/messages/ja-JP/chat.json +++ b/apps/frontend/messages/ja-JP/chat.json @@ -9,7 +9,18 @@ "time_now": "ただいま", "time_minutes": "{count}分前", "time_hours": "{count}時間前", - "time_days": "{count}d 前" + "time_days": "{count}d 前", + "unread": "未読", + "activity": "新しいアクティビティ", + "summary": { + "direct_message": "{actor}さんからダイレクトメッセージが届きました。", + "reaction": "{actor}さんがあなたのメッセージにリアクションしました。", + "reply": "{actor}さんがあなたのメッセージに返信しました。", + "mention": "{actor}さんがあなたをメンションしました。", + "followed_thread": "{actor}さんがフォロー中のスレッドに返信しました。", + "new_message": "{actor}さんが新しいメッセージを投稿しました。", + "activity": "{actor}さんから通知が届きました。" + } }, "sign_out": { "title": "サインアウト", diff --git a/apps/frontend/messages/ja-JP/settings.json b/apps/frontend/messages/ja-JP/settings.json index d7ecb9075..2de843f59 100644 --- a/apps/frontend/messages/ja-JP/settings.json +++ b/apps/frontend/messages/ja-JP/settings.json @@ -195,35 +195,6 @@ "enable": "有効にする", "dismiss": "いいえ、ありがとう" }, - "levels": { - "loading": "読み込み中...", - "load_failed": "通知設定の読み込みに失敗しました", - "server_updated": "サーバー通知レベルが更新されました", - "room_updated": "ルーム通知レベルが更新されました", - "update_failed": "更新に失敗しました", - "server_title": "サーバー通知レベル", - "server_description": "このサーバー内のすべてのルームの通知を受信する方法を制御します。個々のルームはこの設定をオーバーライドできます。", - "room_title": "ルームのオーバーライド", - "room_description": "個々のルームのサーバーレベルの設定を上書きします。 「デフォルト」に設定されたルームはサーバー設定({level})を継承します。", - "room_level_label": "#{room} の通知レベル", - "effective": "有効: {level}", - "default": { - "label": "デフォルト", - "description": "継承されたデフォルトを使用する" - }, - "muted": { - "label": "ミュートされました", - "description": "通知または未読マーカーがありません" - }, - "normal": { - "label": "通常", - "description": "未読マーカー + メンション、DM、スレッド返信" - }, - "all_messages": { - "label": "すべてのメッセージ", - "description": "通常 + 新しいメッセージごとに通知" - } - }, "sound": { "title": "通知音", "shape_title": "音の形", @@ -273,6 +244,32 @@ "dubstep": "ダブステップドロップ", "circus": "サーカス" } + }, + "policy": { + "title": "通知ポリシー", + "description": "アクティビティの種類ごとに通知の強さを選択します。「継承」はサーバーの既定値を使用します。", + "load_failed": "通知ポリシーを読み込めませんでした", + "save_failed": "通知ポリシーを保存できませんでした", + "effective": "適用値:{intensity}", + "intensity": { + "inherit": "継承", + "off": "オフ", + "badge": "バッジ", + "alert": "アラート" + }, + "reason": { + "direct_message": "ダイレクトメッセージ", + "direct_mention": "直接メンション", + "reply": "自分のメッセージへの返信", + "role_mention": "ロールメンション", + "here": "@here メンション", + "all": "@all メンション", + "followed_thread": "フォロー中のスレッド", + "followed_room": "フォロー中のルーム", + "reaction": "自分のメッセージへのリアクション", + "activity": "アクティビティ", + "room_invitation": "ルームへの招待" + } } }, "account": { diff --git a/apps/frontend/messages/lv-LV/chat.json b/apps/frontend/messages/lv-LV/chat.json index 34bf84e26..60e26006f 100644 --- a/apps/frontend/messages/lv-LV/chat.json +++ b/apps/frontend/messages/lv-LV/chat.json @@ -9,7 +9,18 @@ "time_now": "Tikai tagad", "time_minutes": "Pirms {count}min", "time_hours": "Pirms {count}h", - "time_days": "Pirms {count}d" + "time_days": "Pirms {count}d", + "unread": "Nelasīts", + "activity": "Jauna aktivitāte", + "summary": { + "direct_message": "{actor} nosūtīja jums tiešu ziņu.", + "reaction": "{actor} reaģēja uz jūsu ziņu.", + "reply": "{actor} atbildēja uz jūsu ziņu.", + "mention": "{actor} pieminēja jūs.", + "followed_thread": "{actor} atbildēja pavedienā, kuram sekojat.", + "new_message": "{actor} publicēja jaunu ziņu.", + "activity": "{actor} nosūtīja jums paziņojumu." + } }, "sign_out": { "title": "Izrakstīties", diff --git a/apps/frontend/messages/lv-LV/settings.json b/apps/frontend/messages/lv-LV/settings.json index d31e1a135..9971766b9 100644 --- a/apps/frontend/messages/lv-LV/settings.json +++ b/apps/frontend/messages/lv-LV/settings.json @@ -195,35 +195,6 @@ "enable": "Iespējot", "dismiss": "Nē, paldies" }, - "levels": { - "loading": "Notiek ielāde...", - "load_failed": "Neizdevās ielādēt paziņojumu preferences", - "server_updated": "Servera paziņojumu līmenis ir atjaunināts", - "room_updated": "Telpas paziņojumu līmenis ir atjaunināts", - "update_failed": "Neizdevās atjaunināt", - "server_title": "Servera paziņojumu līmenis", - "server_description": "Kontrolē, kā jūs saņemat paziņojumus par visām šī servera telpām. Atsevišķas telpas var ignorēt šo iestatījumu.", - "room_title": "Telpas ignorēšana", - "room_description": "Ignorēt servera līmeņa iestatījumu atsevišķām telpām. Telpas, kurām iestatīta vērtība “Noklusējums”, pārmanto servera iestatījumu ({level}).", - "room_level_label": "Paziņojumu līmenis: #{room}", - "effective": "Spēkā: {level}", - "default": { - "label": "Noklusējums", - "description": "Izmantojiet mantoto noklusējuma iestatījumu" - }, - "muted": { - "label": "Izslēgts", - "description": "Nav paziņojumu vai nelasītu marķieru" - }, - "normal": { - "label": "Normāls", - "description": "Nelasīti marķieri + pieminējumi, DM un pavedienu atbildes" - }, - "all_messages": { - "label": "Visas ziņas", - "description": "Normāls + paziņojums par katru jaunu ziņojumu" - } - }, "sound": { "title": "Paziņojuma skaņa", "shape_title": "Skaņas forma", @@ -273,6 +244,32 @@ "dubstep": "Dubstep Drop", "circus": "Cirks" } + }, + "policy": { + "title": "Paziņojumu noteikumi", + "description": "Izvēlieties, cik izteikti katram aktivitātes veidam jāpiesaista jūsu uzmanība. Mantošana izmanto servera noklusējumu.", + "load_failed": "Neizdevās ielādēt paziņojumu noteikumus", + "save_failed": "Neizdevās saglabāt paziņojumu noteikumus", + "effective": "Spēkā: {intensity}", + "intensity": { + "inherit": "Mantot", + "off": "Izslēgts", + "badge": "Žetons", + "alert": "Brīdinājums" + }, + "reason": { + "direct_message": "Tiešie ziņojumi", + "direct_mention": "Tiešie pieminējumi", + "reply": "Atbildes uz jūsu ziņojumiem", + "role_mention": "Lomu pieminējumi", + "here": "@here pieminējumi", + "all": "@all pieminējumi", + "followed_thread": "Sekotie pavedieni", + "followed_room": "Sekotās telpas", + "reaction": "Reakcijas uz jūsu ziņojumiem", + "activity": "Aktivitāte", + "room_invitation": "Uzaicinājumi uz telpām" + } } }, "account": { diff --git a/apps/frontend/messages/nb-NO/chat.json b/apps/frontend/messages/nb-NO/chat.json index 7edd065d2..9d4e594e2 100644 --- a/apps/frontend/messages/nb-NO/chat.json +++ b/apps/frontend/messages/nb-NO/chat.json @@ -9,7 +9,18 @@ "time_now": "Akkurat nå", "time_minutes": "{count}m siden", "time_hours": "{count}h siden", - "time_days": "{count}d siden" + "time_days": "{count}d siden", + "unread": "Ulest", + "activity": "Ny aktivitet", + "summary": { + "direct_message": "{actor} har sendt deg en direktemelding.", + "reaction": "{actor} reagerte på meldingen din.", + "reply": "{actor} svarte på meldingen din.", + "mention": "{actor} nevnte deg.", + "followed_thread": "{actor} svarte i en tråd du følger.", + "new_message": "{actor} la ut en ny melding.", + "activity": "{actor} sendte deg et varsel." + } }, "sign_out": { "title": "Logg av", diff --git a/apps/frontend/messages/nb-NO/settings.json b/apps/frontend/messages/nb-NO/settings.json index f480bcfd6..2777d31fe 100644 --- a/apps/frontend/messages/nb-NO/settings.json +++ b/apps/frontend/messages/nb-NO/settings.json @@ -195,35 +195,6 @@ "enable": "Aktiver", "dismiss": "Nei takk" }, - "levels": { - "loading": "Laster ...", - "load_failed": "Kunne ikke laste inn varslingspreferanser", - "server_updated": "Servervarslingsnivå oppdatert", - "room_updated": "Romvarslingsnivå oppdatert", - "update_failed": "Kunne ikke oppdatere", - "server_title": "Servervarslingsnivå", - "server_description": "Styrer hvordan du mottar varsler for alle rom på denne serveren. Individuelle rom kan overstyre denne innstillingen.", - "room_title": "Romoverstyringer", - "room_description": "Overstyr innstillingen på servernivå for individuelle rom. Rom satt til \"Standard\" arver serverinnstillingen ({level}).", - "room_level_label": "Varslingsnivå for #{room}", - "effective": "Gjelder: {level}", - "default": { - "label": "Standard", - "description": "Bruk den nedarvede standarden" - }, - "muted": { - "label": "Dempet", - "description": "Ingen varsler eller uleste markører" - }, - "normal": { - "label": "Normal", - "description": "Uleste markører + omtaler, DM-er og trådsvar" - }, - "all_messages": { - "label": "Alle meldinger", - "description": "Normal + varsling for hver ny melding" - } - }, "sound": { "title": "Varslingslyd", "shape_title": "Lydform", @@ -273,6 +244,32 @@ "dubstep": "Dubstep Drop", "circus": "Sirkus" } + }, + "policy": { + "title": "Varslingsregler", + "description": "Velg hvor mye oppmerksomhet hver aktivitetstype skal få. Arv bruker serverens standard.", + "load_failed": "Kunne ikke laste varslingsreglene", + "save_failed": "Kunne ikke lagre varslingsreglene", + "effective": "Gjeldende: {intensity}", + "intensity": { + "inherit": "Arv", + "off": "Av", + "badge": "Merke", + "alert": "Varsel" + }, + "reason": { + "direct_message": "Direktemeldinger", + "direct_mention": "Direkte omtaler", + "reply": "Svar på meldingene dine", + "role_mention": "Rolleomtaler", + "here": "@here-omtaler", + "all": "@all-omtaler", + "followed_thread": "Fulgte tråder", + "followed_room": "Fulgte rom", + "reaction": "Reaksjoner på meldingene dine", + "activity": "Aktivitet", + "room_invitation": "Invitasjoner til rom" + } } }, "account": { diff --git a/apps/frontend/messages/nl-BE/chat.json b/apps/frontend/messages/nl-BE/chat.json index 32c38d100..8a707afd0 100644 --- a/apps/frontend/messages/nl-BE/chat.json +++ b/apps/frontend/messages/nl-BE/chat.json @@ -9,7 +9,18 @@ "time_now": "Zojuist", "time_minutes": "{count}m geleden", "time_hours": "{count}h geleden", - "time_days": "{count}d geleden" + "time_days": "{count}d geleden", + "unread": "Ongelezen", + "activity": "Nieuwe activiteit", + "summary": { + "direct_message": "{actor} heeft je een privébericht gestuurd.", + "reaction": "{actor} heeft op je bericht gereageerd.", + "reply": "{actor} heeft op je bericht geantwoord.", + "mention": "{actor} heeft je vermeld.", + "followed_thread": "{actor} heeft geantwoord in een gesprek dat je volgt.", + "new_message": "{actor} heeft een nieuw bericht geplaatst.", + "activity": "{actor} heeft je een melding gestuurd." + } }, "sign_out": { "title": "Uitloggen", diff --git a/apps/frontend/messages/nl-BE/settings.json b/apps/frontend/messages/nl-BE/settings.json index 9fb15122b..573dae11e 100644 --- a/apps/frontend/messages/nl-BE/settings.json +++ b/apps/frontend/messages/nl-BE/settings.json @@ -195,35 +195,6 @@ "enable": "Inschakelen", "dismiss": "Nee bedankt" }, - "levels": { - "loading": "Laden...", - "load_failed": "Kan meldingsvoorkeuren niet laden", - "server_updated": "Servermeldingsniveau bijgewerkt", - "room_updated": "Meldingsniveau voor ruimte bijgewerkt", - "update_failed": "Updaten is mislukt", - "server_title": "Servermeldingsniveau", - "server_description": "Bepaalt hoe u meldingen ontvangt voor alle kamers op deze server. Individuele kamers kunnen deze instelling overschrijven.", - "room_title": "Kameroverschrijvingen", - "room_description": "Negeer de instelling op serverniveau voor individuele kamers. Kamers die zijn ingesteld op \"Standaard\" nemen de serverinstelling over ({level}).", - "room_level_label": "Meldingsniveau voor #{room}", - "effective": "Van kracht: {level}", - "default": { - "label": "Standaard", - "description": "Gebruik de overgenomen standaard" - }, - "muted": { - "label": "Gedempt", - "description": "Geen meldingen of ongelezen markeringen" - }, - "normal": { - "label": "Normaal", - "description": "Ongelezen markeringen + vermeldingen, DM's en threadantwoorden" - }, - "all_messages": { - "label": "Alle berichten", - "description": "Normaal + notificatie voor elk nieuw bericht" - } - }, "sound": { "title": "Meldingsgeluid", "shape_title": "Geluidsvorm", @@ -273,6 +244,32 @@ "dubstep": "Dubstep-drop", "circus": "Circus" } + }, + "policy": { + "title": "Meldingsbeleid", + "description": "Kies hoeveel aandacht elk type activiteit krijgt. Overnemen gebruikt de serverstandaard.", + "load_failed": "Meldingsbeleid kon niet worden geladen", + "save_failed": "Meldingsbeleid kon niet worden opgeslagen", + "effective": "Effectief: {intensity}", + "intensity": { + "inherit": "Overnemen", + "off": "Uit", + "badge": "Badge", + "alert": "Melding" + }, + "reason": { + "direct_message": "Directe berichten", + "direct_mention": "Directe vermeldingen", + "reply": "Antwoorden op je berichten", + "role_mention": "Rolvermeldingen", + "here": "@here-vermeldingen", + "all": "@all-vermeldingen", + "followed_thread": "Gevolgde threads", + "followed_room": "Gevolgde ruimtes", + "reaction": "Reacties op je berichten", + "activity": "Activiteit", + "room_invitation": "Uitnodigingen voor ruimtes" + } } }, "account": { diff --git a/apps/frontend/messages/nl-NL/chat.json b/apps/frontend/messages/nl-NL/chat.json index 32c38d100..ef5d77f92 100644 --- a/apps/frontend/messages/nl-NL/chat.json +++ b/apps/frontend/messages/nl-NL/chat.json @@ -9,7 +9,18 @@ "time_now": "Zojuist", "time_minutes": "{count}m geleden", "time_hours": "{count}h geleden", - "time_days": "{count}d geleden" + "time_days": "{count}d geleden", + "unread": "Ongelezen", + "activity": "Nieuwe activiteit", + "summary": { + "direct_message": "{actor} heeft je een direct bericht gestuurd.", + "reaction": "{actor} heeft op je bericht gereageerd.", + "reply": "{actor} heeft op je bericht geantwoord.", + "mention": "{actor} heeft je genoemd.", + "followed_thread": "{actor} heeft geantwoord in een gesprek dat je volgt.", + "new_message": "{actor} heeft een nieuw bericht geplaatst.", + "activity": "{actor} heeft je een melding gestuurd." + } }, "sign_out": { "title": "Uitloggen", diff --git a/apps/frontend/messages/nl-NL/settings.json b/apps/frontend/messages/nl-NL/settings.json index 9fb15122b..573dae11e 100644 --- a/apps/frontend/messages/nl-NL/settings.json +++ b/apps/frontend/messages/nl-NL/settings.json @@ -195,35 +195,6 @@ "enable": "Inschakelen", "dismiss": "Nee bedankt" }, - "levels": { - "loading": "Laden...", - "load_failed": "Kan meldingsvoorkeuren niet laden", - "server_updated": "Servermeldingsniveau bijgewerkt", - "room_updated": "Meldingsniveau voor ruimte bijgewerkt", - "update_failed": "Updaten is mislukt", - "server_title": "Servermeldingsniveau", - "server_description": "Bepaalt hoe u meldingen ontvangt voor alle kamers op deze server. Individuele kamers kunnen deze instelling overschrijven.", - "room_title": "Kameroverschrijvingen", - "room_description": "Negeer de instelling op serverniveau voor individuele kamers. Kamers die zijn ingesteld op \"Standaard\" nemen de serverinstelling over ({level}).", - "room_level_label": "Meldingsniveau voor #{room}", - "effective": "Van kracht: {level}", - "default": { - "label": "Standaard", - "description": "Gebruik de overgenomen standaard" - }, - "muted": { - "label": "Gedempt", - "description": "Geen meldingen of ongelezen markeringen" - }, - "normal": { - "label": "Normaal", - "description": "Ongelezen markeringen + vermeldingen, DM's en threadantwoorden" - }, - "all_messages": { - "label": "Alle berichten", - "description": "Normaal + notificatie voor elk nieuw bericht" - } - }, "sound": { "title": "Meldingsgeluid", "shape_title": "Geluidsvorm", @@ -273,6 +244,32 @@ "dubstep": "Dubstep-drop", "circus": "Circus" } + }, + "policy": { + "title": "Meldingsbeleid", + "description": "Kies hoeveel aandacht elk type activiteit krijgt. Overnemen gebruikt de serverstandaard.", + "load_failed": "Meldingsbeleid kon niet worden geladen", + "save_failed": "Meldingsbeleid kon niet worden opgeslagen", + "effective": "Effectief: {intensity}", + "intensity": { + "inherit": "Overnemen", + "off": "Uit", + "badge": "Badge", + "alert": "Melding" + }, + "reason": { + "direct_message": "Directe berichten", + "direct_mention": "Directe vermeldingen", + "reply": "Antwoorden op je berichten", + "role_mention": "Rolvermeldingen", + "here": "@here-vermeldingen", + "all": "@all-vermeldingen", + "followed_thread": "Gevolgde threads", + "followed_room": "Gevolgde ruimtes", + "reaction": "Reacties op je berichten", + "activity": "Activiteit", + "room_invitation": "Uitnodigingen voor ruimtes" + } } }, "account": { diff --git a/apps/frontend/messages/pl-PL/chat.json b/apps/frontend/messages/pl-PL/chat.json index c875eccb9..14a066331 100644 --- a/apps/frontend/messages/pl-PL/chat.json +++ b/apps/frontend/messages/pl-PL/chat.json @@ -9,7 +9,18 @@ "time_now": "Właśnie teraz", "time_minutes": "{count}m temu", "time_hours": "{count}h temu", - "time_days": "{count}d temu" + "time_days": "{count}d temu", + "unread": "Nieprzeczytane", + "activity": "Nowa aktywność", + "summary": { + "direct_message": "Masz nową wiadomość bezpośrednią od {actor}.", + "reaction": "Osoba {actor} zareagowała na Twoją wiadomość.", + "reply": "Osoba {actor} odpowiedziała na Twoją wiadomość.", + "mention": "Osoba {actor} wspomniała o Tobie.", + "followed_thread": "Osoba {actor} odpowiedziała w obserwowanym przez Ciebie wątku.", + "new_message": "Osoba {actor} opublikowała nową wiadomość.", + "activity": "Masz nowe powiadomienie od {actor}." + } }, "sign_out": { "title": "Wyloguj się", diff --git a/apps/frontend/messages/pl-PL/settings.json b/apps/frontend/messages/pl-PL/settings.json index cbb21cb56..b1f43af5d 100644 --- a/apps/frontend/messages/pl-PL/settings.json +++ b/apps/frontend/messages/pl-PL/settings.json @@ -195,35 +195,6 @@ "enable": "Włącz", "dismiss": "Nie, dziękuję" }, - "levels": { - "loading": "Ładowanie...", - "load_failed": "Nie udało się załadować preferencji powiadomień", - "server_updated": "Zaktualizowano poziom powiadamiania serwera", - "room_updated": "Zaktualizowano poziom powiadamiania o pokoju", - "update_failed": "Nie udało się zaktualizować", - "server_title": "Poziom powiadamiania serwera", - "server_description": "Kontroluje sposób otrzymywania powiadomień dla wszystkich pokoi na tym serwerze. Poszczególne pokoje mogą zastąpić to ustawienie.", - "room_title": "Zastąpienia pokoju", - "room_description": "Zastąp ustawienia na poziomie serwera dla poszczególnych pokoi. Pokoje ustawione na „Domyślne” dziedziczą ustawienia serwera ({level}).", - "room_level_label": "Poziom powiadomień dla #{room}", - "effective": "Obowiązuje: {level}", - "default": { - "label": "Domyślnie", - "description": "Użyj odziedziczonej wartości domyślnej" - }, - "muted": { - "label": "Wyciszony", - "description": "Brak powiadomień i nieprzeczytanych znaczników" - }, - "normal": { - "label": "Normalny", - "description": "Nieprzeczytane znaczniki + wzmianki, DM i odpowiedzi w wątkach" - }, - "all_messages": { - "label": "Wszystkie wiadomości", - "description": "Normalny + powiadomienie o każdej nowej wiadomości" - } - }, "sound": { "title": "Dźwięk powiadomienia", "shape_title": "Kształt dźwięku", @@ -273,6 +244,32 @@ "dubstep": "Dubstepowy spadek", "circus": "Cyrk" } + }, + "policy": { + "title": "Zasady powiadomień", + "description": "Wybierz, jak mocno każdy rodzaj aktywności ma zwracać Twoją uwagę. Dziedziczenie używa ustawienia serwera.", + "load_failed": "Nie udało się wczytać zasad powiadomień", + "save_failed": "Nie udało się zapisać zasad powiadomień", + "effective": "Obowiązuje: {intensity}", + "intensity": { + "inherit": "Dziedzicz", + "off": "Wyłączone", + "badge": "Znacznik", + "alert": "Alert" + }, + "reason": { + "direct_message": "Wiadomości bezpośrednie", + "direct_mention": "Bezpośrednie wzmianki", + "reply": "Odpowiedzi na Twoje wiadomości", + "role_mention": "Wzmianki o rolach", + "here": "Wzmianki @here", + "all": "Wzmianki @all", + "followed_thread": "Obserwowane wątki", + "followed_room": "Obserwowane pokoje", + "reaction": "Reakcje na Twoje wiadomości", + "activity": "Aktywność", + "room_invitation": "Zaproszenia do pokoi" + } } }, "account": { diff --git a/apps/frontend/messages/pt-BR/chat.json b/apps/frontend/messages/pt-BR/chat.json index 349eb20a7..79796155c 100644 --- a/apps/frontend/messages/pt-BR/chat.json +++ b/apps/frontend/messages/pt-BR/chat.json @@ -9,7 +9,18 @@ "time_now": "Agora mesmo", "time_minutes": "{count}m atrás", "time_hours": "{count}h atrás", - "time_days": "{count}d atrás" + "time_days": "{count}d atrás", + "unread": "Não lido", + "activity": "Nova atividade", + "summary": { + "direct_message": "{actor} enviou uma mensagem direta para você.", + "reaction": "{actor} reagiu à sua mensagem.", + "reply": "{actor} respondeu à sua mensagem.", + "mention": "{actor} mencionou você.", + "followed_thread": "{actor} respondeu em uma conversa que você segue.", + "new_message": "{actor} publicou uma nova mensagem.", + "activity": "{actor} enviou uma notificação para você." + } }, "sign_out": { "title": "Sair", diff --git a/apps/frontend/messages/pt-BR/settings.json b/apps/frontend/messages/pt-BR/settings.json index 007884e0e..7950d0e03 100644 --- a/apps/frontend/messages/pt-BR/settings.json +++ b/apps/frontend/messages/pt-BR/settings.json @@ -195,35 +195,6 @@ "enable": "Ativar", "dismiss": "Não, obrigado" }, - "levels": { - "loading": "Carregando...", - "load_failed": "Falha ao carregar preferências de notificação", - "server_updated": "Nível de notificação do servidor atualizado", - "room_updated": "Nível de notificação da sala atualizado", - "update_failed": "Falha ao atualizar", - "server_title": "Nível de notificação do servidor", - "server_description": "Controla como você recebe notificações de todas as salas deste servidor. Salas individuais podem substituir essa configuração.", - "room_title": "Substituições de salas", - "room_description": "Substitua a configuração de nível de servidor para salas individuais. As salas definidas como \"Padrão\" herdam a configuração do servidor ({level}).", - "room_level_label": "Nível de notificação para #{room}", - "effective": "Em vigor: {level}", - "default": { - "label": "Padrão", - "description": "Use o padrão herdado" - }, - "muted": { - "label": "Silenciado", - "description": "Nenhuma notificação ou marcadores não lidos" - }, - "normal": { - "label": "Normal", - "description": "Marcadores não lidos + menções, mensagens diretas e respostas ao tópico" - }, - "all_messages": { - "label": "Todas as mensagens", - "description": "Normal + notificação para cada nova mensagem" - } - }, "sound": { "title": "Som de notificação", "shape_title": "Forma sonora", @@ -273,6 +244,32 @@ "dubstep": "Queda de Dubstep", "circus": "Circo" } + }, + "policy": { + "title": "Política de notificações", + "description": "Escolha quanta atenção cada tipo de atividade deve receber. Herdar usa o padrão do servidor.", + "load_failed": "Não foi possível carregar a política de notificações", + "save_failed": "Não foi possível salvar a política de notificações", + "effective": "Efetivo: {intensity}", + "intensity": { + "inherit": "Herdar", + "off": "Desativado", + "badge": "Indicador", + "alert": "Alerta" + }, + "reason": { + "direct_message": "Mensagens diretas", + "direct_mention": "Menções diretas", + "reply": "Respostas às suas mensagens", + "role_mention": "Menções a cargos", + "here": "Menções @here", + "all": "Menções @all", + "followed_thread": "Tópicos seguidos", + "followed_room": "Salas seguidas", + "reaction": "Reações às suas mensagens", + "activity": "Atividade", + "room_invitation": "Convites para salas" + } } }, "account": { diff --git a/apps/frontend/messages/pt-PT/chat.json b/apps/frontend/messages/pt-PT/chat.json index 75bf163b9..73d308a04 100644 --- a/apps/frontend/messages/pt-PT/chat.json +++ b/apps/frontend/messages/pt-PT/chat.json @@ -9,7 +9,18 @@ "time_now": "Neste momento", "time_minutes": "{count}m atrás", "time_hours": "{count}h atrás", - "time_days": "{count}d atrás" + "time_days": "{count}d atrás", + "unread": "Não lido", + "activity": "Nova atividade", + "summary": { + "direct_message": "{actor} enviou-lhe uma mensagem direta.", + "reaction": "{actor} reagiu à sua mensagem.", + "reply": "{actor} respondeu à sua mensagem.", + "mention": "{actor} mencionou-o.", + "followed_thread": "{actor} respondeu num tópico que segue.", + "new_message": "{actor} publicou uma nova mensagem.", + "activity": "{actor} enviou-lhe uma notificação." + } }, "sign_out": { "title": "Sair", diff --git a/apps/frontend/messages/pt-PT/settings.json b/apps/frontend/messages/pt-PT/settings.json index f2c398d23..2e6e7d124 100644 --- a/apps/frontend/messages/pt-PT/settings.json +++ b/apps/frontend/messages/pt-PT/settings.json @@ -195,35 +195,6 @@ "enable": "Ativar", "dismiss": "Não, obrigado" }, - "levels": { - "loading": "Carregando...", - "load_failed": "Falha ao carregar as preferências de notificação", - "server_updated": "Nível de notificação do servidor atualizado", - "room_updated": "Nível de notificação da sala atualizado", - "update_failed": "Falha ao atualizar", - "server_title": "Nível de notificação do servidor", - "server_description": "Controla a forma como recebe notificações de todas as salas deste servidor. Salas individuais podem substituir esta configuração.", - "room_title": "Substituições de salas", - "room_description": "Substitua a definição de nível de servidor para salas individuais. As salas definidas como \"Padrão\" herdam a configuração do servidor ({level}).", - "room_level_label": "Nível de notificação para #{room}", - "effective": "Em vigor: {level}", - "default": { - "label": "Padrão", - "description": "Utilizar o padrão herdado" - }, - "muted": { - "label": "Silenciado", - "description": "Sem notificação ou marcadores não lidos" - }, - "normal": { - "label": "Normal", - "description": "Marcadores não lidos + menções, mensagens diretas e respostas ao tópico" - }, - "all_messages": { - "label": "Todas as mensagens", - "description": "Normal + notificação para cada nova mensagem" - } - }, "sound": { "title": "Som de notificação", "shape_title": "Forma sonora", @@ -273,6 +244,32 @@ "dubstep": "Queda de Dubstep", "circus": "Circo" } + }, + "policy": { + "title": "Política de notificações", + "description": "Escolha quanta atenção deve receber cada tipo de atividade. Herdar usa a predefinição do servidor.", + "load_failed": "Não foi possível carregar a política de notificações", + "save_failed": "Não foi possível guardar a política de notificações", + "effective": "Efetivo: {intensity}", + "intensity": { + "inherit": "Herdar", + "off": "Desativado", + "badge": "Indicador", + "alert": "Alerta" + }, + "reason": { + "direct_message": "Mensagens diretas", + "direct_mention": "Menções diretas", + "reply": "Respostas às suas mensagens", + "role_mention": "Menções a cargos", + "here": "Menções @here", + "all": "Menções @all", + "followed_thread": "Tópicos seguidos", + "followed_room": "Salas seguidas", + "reaction": "Reações às suas mensagens", + "activity": "Atividade", + "room_invitation": "Convites para salas" + } } }, "account": { diff --git a/apps/frontend/messages/ru-RU/chat.json b/apps/frontend/messages/ru-RU/chat.json index 6ec35e587..b79a425f9 100644 --- a/apps/frontend/messages/ru-RU/chat.json +++ b/apps/frontend/messages/ru-RU/chat.json @@ -9,7 +9,18 @@ "time_now": "только что", "time_minutes": "{count} мин назад", "time_hours": "{count}ч назад", - "time_days": "{count}дн назад" + "time_days": "{count}дн назад", + "unread": "Непрочитанное", + "activity": "Новое событие", + "summary": { + "direct_message": "От пользователя {actor} пришло личное сообщение.", + "reaction": "Пользователь {actor} отреагировал на ваше сообщение.", + "reply": "Пользователь {actor} ответил на ваше сообщение.", + "mention": "Пользователь {actor} упомянул вас.", + "followed_thread": "Пользователь {actor} ответил в отслеживаемой вами теме.", + "new_message": "Пользователь {actor} опубликовал новое сообщение.", + "activity": "От пользователя {actor} пришло уведомление." + } }, "sign_out": { "title": "Выйти", diff --git a/apps/frontend/messages/ru-RU/settings.json b/apps/frontend/messages/ru-RU/settings.json index fd27ed981..ee23d57e4 100644 --- a/apps/frontend/messages/ru-RU/settings.json +++ b/apps/frontend/messages/ru-RU/settings.json @@ -195,35 +195,6 @@ "enable": "Включить", "dismiss": "Нет, спасибо" }, - "levels": { - "loading": "Загрузка...", - "load_failed": "Не удалось загрузить настройки уведомлений.", - "server_updated": "Уровень уведомлений сервера обновлен.", - "room_updated": "Уровень уведомлений для переговорных комнат обновлен.", - "update_failed": "Не удалось обновить", - "server_title": "Уровень уведомления сервера", - "server_description": "Управляет получением уведомлений для всех комнат на этом сервере. Отдельные комнаты могут переопределить эту настройку.", - "room_title": "Переопределения комнаты", - "room_description": "Переопределить настройку уровня сервера для отдельных комнат. Комнаты, для которых установлено значение «По умолчанию», наследуют настройки сервера ({level}).", - "room_level_label": "Уровень уведомления для #{room}", - "effective": "Действует: {level}", - "default": { - "label": "По умолчанию", - "description": "Использовать унаследованное значение по умолчанию" - }, - "muted": { - "label": "Без звука", - "description": "Нет уведомлений или непрочитанных маркеров" - }, - "normal": { - "label": "Нормальный", - "description": "Непрочитанные маркеры + упоминания, личные сообщения и ответы в цепочках." - }, - "all_messages": { - "label": "Все сообщения", - "description": "Обычный + уведомление о каждом новом сообщении" - } - }, "sound": { "title": "Звук уведомления", "shape_title": "Форма звука", @@ -273,6 +244,32 @@ "dubstep": "Дабстеп Дроп", "circus": "Цирк" } + }, + "policy": { + "title": "Правила уведомлений", + "description": "Выберите, насколько активно каждый вид событий должен привлекать ваше внимание. Наследование использует настройку сервера.", + "load_failed": "Не удалось загрузить правила уведомлений", + "save_failed": "Не удалось сохранить правила уведомлений", + "effective": "Действует: {intensity}", + "intensity": { + "inherit": "Наследовать", + "off": "Выключено", + "badge": "Значок", + "alert": "Оповещение" + }, + "reason": { + "direct_message": "Личные сообщения", + "direct_mention": "Прямые упоминания", + "reply": "Ответы на ваши сообщения", + "role_mention": "Упоминания ролей", + "here": "Упоминания @here", + "all": "Упоминания @all", + "followed_thread": "Отслеживаемые ветки", + "followed_room": "Отслеживаемые комнаты", + "reaction": "Реакции на ваши сообщения", + "activity": "Активность", + "room_invitation": "Приглашения в комнаты" + } } }, "account": { diff --git a/apps/frontend/messages/sv-SE/chat.json b/apps/frontend/messages/sv-SE/chat.json index 9c99b78b6..6a8893190 100644 --- a/apps/frontend/messages/sv-SE/chat.json +++ b/apps/frontend/messages/sv-SE/chat.json @@ -9,7 +9,18 @@ "time_now": "Just nu", "time_minutes": "{count}m sedan", "time_hours": "{count}h sedan", - "time_days": "{count}d sedan" + "time_days": "{count}d sedan", + "unread": "Oläst", + "activity": "Ny aktivitet", + "summary": { + "direct_message": "{actor} har skickat ett direktmeddelande till dig.", + "reaction": "{actor} reagerade på ditt meddelande.", + "reply": "{actor} svarade på ditt meddelande.", + "mention": "{actor} nämnde dig.", + "followed_thread": "{actor} svarade i en tråd som du följer.", + "new_message": "{actor} publicerade ett nytt meddelande.", + "activity": "{actor} skickade en avisering till dig." + } }, "sign_out": { "title": "Logga ut", diff --git a/apps/frontend/messages/sv-SE/settings.json b/apps/frontend/messages/sv-SE/settings.json index a97a9e6cd..50e76e703 100644 --- a/apps/frontend/messages/sv-SE/settings.json +++ b/apps/frontend/messages/sv-SE/settings.json @@ -195,35 +195,6 @@ "enable": "Aktivera", "dismiss": "Nej tack" }, - "levels": { - "loading": "Laddar...", - "load_failed": "Det gick inte att läsa in aviseringsinställningar", - "server_updated": "Serveraviseringsnivå uppdaterad", - "room_updated": "Rumsaviseringsnivå uppdaterad", - "update_failed": "Det gick inte att uppdatera", - "server_title": "Servermeddelandenivå", - "server_description": "Styr hur du får aviseringar för alla rum på den här servern. Enskilda rum kan åsidosätta denna inställning.", - "room_title": "Åsidosättande av rum", - "room_description": "Åsidosätt inställningen på servernivå för enskilda rum. Rum som är inställda på \"Standard\" ärver serverinställningen ({level}).", - "room_level_label": "Aviseringsnivå för #{room}", - "effective": "Gäller: {level}", - "default": { - "label": "Standard", - "description": "Använd den ärvda standardinställningen" - }, - "muted": { - "label": "Avstängd", - "description": "Inga meddelanden eller olästa markörer" - }, - "normal": { - "label": "Normal", - "description": "Olästa markörer + omnämnanden, DM:n och trådsvar" - }, - "all_messages": { - "label": "Alla meddelanden", - "description": "Normal + avisering för varje nytt meddelande" - } - }, "sound": { "title": "Aviseringsljud", "shape_title": "Ljudform", @@ -273,6 +244,32 @@ "dubstep": "Dubstep Drop", "circus": "Cirkus" } + }, + "policy": { + "title": "Aviseringspolicy", + "description": "Välj hur mycket uppmärksamhet varje typ av aktivitet ska få. Ärv använder serverns standard.", + "load_failed": "Det gick inte att läsa in aviseringspolicyn", + "save_failed": "Det gick inte att spara aviseringspolicyn", + "effective": "Gäller: {intensity}", + "intensity": { + "inherit": "Ärv", + "off": "Av", + "badge": "Märke", + "alert": "Avisering" + }, + "reason": { + "direct_message": "Direktmeddelanden", + "direct_mention": "Direkta omnämnanden", + "reply": "Svar på dina meddelanden", + "role_mention": "Rollomnämnanden", + "here": "@here-omnämnanden", + "all": "@all-omnämnanden", + "followed_thread": "Följda trådar", + "followed_room": "Följda rum", + "reaction": "Reaktioner på dina meddelanden", + "activity": "Aktivitet", + "room_invitation": "Inbjudningar till rum" + } } }, "account": { diff --git a/apps/frontend/messages/tr-TR/chat.json b/apps/frontend/messages/tr-TR/chat.json index 8dee45ff7..1fdc81c44 100644 --- a/apps/frontend/messages/tr-TR/chat.json +++ b/apps/frontend/messages/tr-TR/chat.json @@ -9,7 +9,18 @@ "time_now": "Az önce", "time_minutes": "{count}m önce", "time_hours": "{count}sa önce", - "time_days": "{count} gün önce" + "time_days": "{count} gün önce", + "unread": "Okunmadı", + "activity": "Yeni etkinlik", + "summary": { + "direct_message": "{actor} sana doğrudan mesaj gönderdi.", + "reaction": "{actor} mesajına tepki verdi.", + "reply": "{actor} mesajına yanıt verdi.", + "mention": "{actor} senden bahsetti.", + "followed_thread": "{actor} takip ettiğin bir konuya yanıt verdi.", + "new_message": "{actor} yeni bir mesaj yayınladı.", + "activity": "{actor} sana bir bildirim gönderdi." + } }, "sign_out": { "title": "Oturumu Kapat", diff --git a/apps/frontend/messages/tr-TR/settings.json b/apps/frontend/messages/tr-TR/settings.json index d15b07b2a..db05e7e24 100644 --- a/apps/frontend/messages/tr-TR/settings.json +++ b/apps/frontend/messages/tr-TR/settings.json @@ -195,35 +195,6 @@ "enable": "Etkinleştir", "dismiss": "Hayır teşekkürler" }, - "levels": { - "loading": "Yükleniyor...", - "load_failed": "Bildirim tercihleri yüklenemedi", - "server_updated": "Sunucu bildirim düzeyi güncellendi", - "room_updated": "Oda bildirim düzeyi güncellendi", - "update_failed": "Güncelleme başarısız oldu", - "server_title": "Sunucu Bildirim Düzeyi", - "server_description": "Bu sunucudaki tüm odalara ilişkin bildirimleri nasıl alacağınızı kontrol eder. Bireysel odalar bu ayarı geçersiz kılabilir.", - "room_title": "Oda Geçersiz Kılmaları", - "room_description": "Bireysel odalar için sunucu düzeyindeki ayarı geçersiz kılın. \"Varsayılan\" olarak ayarlanan odalar sunucu ayarını ({level}) devralır.", - "room_level_label": "#{room} için bildirim düzeyi", - "effective": "Geçerli: {level}", - "default": { - "label": "Varsayılan", - "description": "Devralınan varsayılanı kullan" - }, - "muted": { - "label": "Sessize alındı", - "description": "Bildirim veya okunmamış işaretçi yok" - }, - "normal": { - "label": "Normal", - "description": "Okunmamış işaretler + bahsetmeler, DM'ler ve mesaj dizisi yanıtları" - }, - "all_messages": { - "label": "Tüm Mesajlar", - "description": "Her yeni mesaj için normal + bildirim" - } - }, "sound": { "title": "Bildirim Sesi", "shape_title": "Ses Şekli", @@ -273,6 +244,32 @@ "dubstep": "Dubstep Bırak", "circus": "Sirk" } + }, + "policy": { + "title": "Bildirim politikası", + "description": "Her etkinlik türünün dikkatinizi ne ölçüde çekmesi gerektiğini seçin. Devral, sunucu varsayılanını kullanır.", + "load_failed": "Bildirim politikası yüklenemedi", + "save_failed": "Bildirim politikası kaydedilemedi", + "effective": "Geçerli: {intensity}", + "intensity": { + "inherit": "Devral", + "off": "Kapalı", + "badge": "Rozet", + "alert": "Uyarı" + }, + "reason": { + "direct_message": "Doğrudan mesajlar", + "direct_mention": "Doğrudan bahsetmeler", + "reply": "Mesajlarınıza verilen yanıtlar", + "role_mention": "Rol bahsetmeleri", + "here": "@here bahsetmeleri", + "all": "@all bahsetmeleri", + "followed_thread": "Takip edilen ileti dizileri", + "followed_room": "Takip edilen odalar", + "reaction": "Mesajlarınıza verilen tepkiler", + "activity": "Etkinlik", + "room_invitation": "Oda davetleri" + } } }, "account": { diff --git a/apps/frontend/messages/uk-UA/chat.json b/apps/frontend/messages/uk-UA/chat.json index 058ef0d74..58be7a574 100644 --- a/apps/frontend/messages/uk-UA/chat.json +++ b/apps/frontend/messages/uk-UA/chat.json @@ -9,7 +9,18 @@ "time_now": "Щойно", "time_minutes": "{count}м тому", "time_hours": "{count}h тому", - "time_days": "{count}d тому" + "time_days": "{count}d тому", + "unread": "Непрочитане", + "activity": "Нова активність", + "summary": { + "direct_message": "Від користувача {actor} надійшло особисте повідомлення.", + "reaction": "Користувач {actor} відреагував на ваше повідомлення.", + "reply": "Користувач {actor} відповів на ваше повідомлення.", + "mention": "Користувач {actor} згадав вас.", + "followed_thread": "Користувач {actor} відповів у темі, за якою ви стежите.", + "new_message": "Користувач {actor} опублікував нове повідомлення.", + "activity": "Від користувача {actor} надійшло сповіщення." + } }, "sign_out": { "title": "Вийти", diff --git a/apps/frontend/messages/uk-UA/settings.json b/apps/frontend/messages/uk-UA/settings.json index fb7c4569c..a78eb875e 100644 --- a/apps/frontend/messages/uk-UA/settings.json +++ b/apps/frontend/messages/uk-UA/settings.json @@ -195,35 +195,6 @@ "enable": "Увімкнути", "dismiss": "Ні, дякую" }, - "levels": { - "loading": "Завантаження...", - "load_failed": "Не вдалося завантажити параметри сповіщень", - "server_updated": "Оновлено рівень сповіщень сервера", - "room_updated": "Оновлено рівень сповіщень кімнати", - "update_failed": "Не вдалося оновити", - "server_title": "Рівень сповіщення сервера", - "server_description": "Контролює, як ви отримуєте сповіщення для всіх кімнат на цьому сервері. Окремі кімнати можуть замінити це налаштування.", - "room_title": "Перевизначення кімнати", - "room_description": "Перевизначте налаштування на рівні сервера для окремих кімнат. Кімнати зі значенням \"За замовчуванням\" успадковують налаштування сервера ({level}).", - "room_level_label": "Рівень сповіщення для #{room}", - "effective": "Чинний: {level}", - "default": { - "label": "За умовчанням", - "description": "Використовувати успадковане значення за умовчанням" - }, - "muted": { - "label": "Вимкнено", - "description": "Немає сповіщень або непрочитаних маркерів" - }, - "normal": { - "label": "Нормально", - "description": "Непрочитані маркери + згадки, DM і відповіді в ланцюжках" - }, - "all_messages": { - "label": "Усі повідомлення", - "description": "Звичайний + сповіщення про кожне нове повідомлення" - } - }, "sound": { "title": "Звук сповіщення", "shape_title": "Форма звуку", @@ -273,6 +244,32 @@ "dubstep": "Dubstep Drop", "circus": "Цирк" } + }, + "policy": { + "title": "Правила сповіщень", + "description": "Виберіть, наскільки активно кожен вид подій має привертати вашу увагу. Успадкування використовує налаштування сервера.", + "load_failed": "Не вдалося завантажити правила сповіщень", + "save_failed": "Не вдалося зберегти правила сповіщень", + "effective": "Діє: {intensity}", + "intensity": { + "inherit": "Успадкувати", + "off": "Вимкнено", + "badge": "Значок", + "alert": "Сповіщення" + }, + "reason": { + "direct_message": "Приватні повідомлення", + "direct_mention": "Прямі згадки", + "reply": "Відповіді на ваші повідомлення", + "role_mention": "Згадки ролей", + "here": "Згадки @here", + "all": "Згадки @all", + "followed_thread": "Відстежувані гілки", + "followed_room": "Відстежувані кімнати", + "reaction": "Реакції на ваші повідомлення", + "activity": "Активність", + "room_invitation": "Запрошення до кімнат" + } } }, "account": { diff --git a/apps/frontend/messages/zh-CN/chat.json b/apps/frontend/messages/zh-CN/chat.json index 1a1751d0f..7bd9006d0 100644 --- a/apps/frontend/messages/zh-CN/chat.json +++ b/apps/frontend/messages/zh-CN/chat.json @@ -9,7 +9,18 @@ "time_now": "刚刚", "time_minutes": "{count} 分钟前", "time_hours": "{count} 小时前", - "time_days": "{count} 天前" + "time_days": "{count} 天前", + "unread": "未读", + "activity": "新动态", + "summary": { + "direct_message": "{actor} 给你发了一条私信。", + "reaction": "{actor} 对你的消息作出了回应。", + "reply": "{actor} 回复了你的消息。", + "mention": "{actor} 提到了你。", + "followed_thread": "{actor} 回复了你关注的主题。", + "new_message": "{actor} 发布了一条新消息。", + "activity": "{actor} 向你发送了一条通知。" + } }, "sign_out": { "title": "退出登录", diff --git a/apps/frontend/messages/zh-CN/settings.json b/apps/frontend/messages/zh-CN/settings.json index e1b9e1cb6..70d4d4aed 100644 --- a/apps/frontend/messages/zh-CN/settings.json +++ b/apps/frontend/messages/zh-CN/settings.json @@ -195,35 +195,6 @@ "enable": "启用", "dismiss": "不用了" }, - "levels": { - "loading": "正在加载...", - "load_failed": "通知偏好设置加载失败", - "server_updated": "服务器通知级别已更新", - "room_updated": "聊天室通知级别已更新", - "update_failed": "更新失败", - "server_title": "服务器通知级别", - "server_description": "控制此服务器中所有聊天室的通知接收方式。各聊天室可单独覆盖此设置。", - "room_title": "聊天室单独设置", - "room_description": "为各聊天室单独覆盖服务器级设置。设为“默认”的聊天室将继承服务器设置({level})。", - "room_level_label": "#{room} 的通知级别", - "effective": "当前生效:{level}", - "default": { - "label": "默认", - "description": "使用继承的默认设置" - }, - "muted": { - "label": "静音", - "description": "不显示通知或未读标记" - }, - "normal": { - "label": "普通", - "description": "显示未读标记,并通知提及、私信和话题串回复" - }, - "all_messages": { - "label": "所有消息", - "description": "普通通知,并对每条新消息发送通知" - } - }, "sound": { "title": "通知提示音", "shape_title": "声音效果", @@ -273,6 +244,32 @@ "dubstep": "回响贝斯骤降", "circus": "马戏团" } + }, + "policy": { + "title": "通知策略", + "description": "选择各类动态提醒你的程度。继承将使用服务器默认值。", + "load_failed": "无法加载通知策略", + "save_failed": "无法保存通知策略", + "effective": "当前生效:{intensity}", + "intensity": { + "inherit": "继承", + "off": "关闭", + "badge": "角标", + "alert": "提醒" + }, + "reason": { + "direct_message": "私信", + "direct_mention": "直接提及", + "reply": "对你消息的回复", + "role_mention": "身份组提及", + "here": "@here 提及", + "all": "@all 提及", + "followed_thread": "已关注的讨论串", + "followed_room": "已关注的房间", + "reaction": "对你消息的回应", + "activity": "动态", + "room_invitation": "房间邀请" + } } }, "account": { diff --git a/apps/frontend/messages/zh-TW/chat.json b/apps/frontend/messages/zh-TW/chat.json index afd04d95c..294d72070 100644 --- a/apps/frontend/messages/zh-TW/chat.json +++ b/apps/frontend/messages/zh-TW/chat.json @@ -9,7 +9,18 @@ "time_now": "剛剛", "time_minutes": "{count} 分鐘前", "time_hours": "{count} 小時前", - "time_days": "{count} 天前" + "time_days": "{count} 天前", + "unread": "未讀", + "activity": "新動態", + "summary": { + "direct_message": "{actor} 傳送了一則私人訊息給你。", + "reaction": "{actor} 對你的訊息做出了回應。", + "reply": "{actor} 回覆了你的訊息。", + "mention": "{actor} 提及了你。", + "followed_thread": "{actor} 回覆了你追蹤的討論串。", + "new_message": "{actor} 發布了一則新訊息。", + "activity": "{actor} 傳送了一則通知給你。" + } }, "sign_out": { "title": "登出", diff --git a/apps/frontend/messages/zh-TW/settings.json b/apps/frontend/messages/zh-TW/settings.json index a7e826ad3..b27eb6ef0 100644 --- a/apps/frontend/messages/zh-TW/settings.json +++ b/apps/frontend/messages/zh-TW/settings.json @@ -195,35 +195,6 @@ "enable": "啟用", "dismiss": "不用了" }, - "levels": { - "loading": "正在載入...", - "load_failed": "無法載入通知偏好設定", - "server_updated": "伺服器通知層級已更新", - "room_updated": "聊天室通知層級已更新", - "update_failed": "無法更新", - "server_title": "伺服器通知層級", - "server_description": "控制您接收此伺服器所有聊天室通知的方式。各聊天室可覆寫此設定。", - "room_title": "聊天室覆寫設定", - "room_description": "為個別聊天室覆寫伺服器層級的設定。設為「預設」的聊天室會沿用伺服器設定({level})。", - "room_level_label": "#{room} 的通知層級", - "effective": "實際套用:{level}", - "default": { - "label": "預設", - "description": "使用沿用的預設值" - }, - "muted": { - "label": "靜音", - "description": "不顯示通知或未讀標記" - }, - "normal": { - "label": "一般", - "description": "未讀標記,以及提及、私訊和討論串回覆通知" - }, - "all_messages": { - "label": "所有訊息", - "description": "一般通知,另加每則新訊息通知" - } - }, "sound": { "title": "通知音效", "shape_title": "音效效果", @@ -273,6 +244,32 @@ "dubstep": "迴響貝斯落拍", "circus": "馬戲團" } + }, + "policy": { + "title": "通知政策", + "description": "選擇各類動態提醒你的程度。繼承將使用伺服器預設值。", + "load_failed": "無法載入通知政策", + "save_failed": "無法儲存通知政策", + "effective": "目前生效:{intensity}", + "intensity": { + "inherit": "繼承", + "off": "關閉", + "badge": "徽章", + "alert": "提醒" + }, + "reason": { + "direct_message": "私訊", + "direct_mention": "直接提及", + "reply": "對你訊息的回覆", + "role_mention": "身分組提及", + "here": "@here 提及", + "all": "@all 提及", + "followed_thread": "已關注的討論串", + "followed_room": "已關注的房間", + "reaction": "對你訊息的回應", + "activity": "動態", + "room_invitation": "房間邀請" + } } }, "account": { diff --git a/apps/frontend/src/lib/RoomList.svelte b/apps/frontend/src/lib/RoomList.svelte index d08bd6b06..b4d529986 100644 --- a/apps/frontend/src/lib/RoomList.svelte +++ b/apps/frontend/src/lib/RoomList.svelte @@ -57,7 +57,6 @@ rooms are organized into collapsible sections. Otherwise, rooms display alphabet const activeServerBaseURL = $derived(activeServer?.url ?? null); const stores = $derived(serverScope.store); const notificationStore = $derived(stores.notifications); - const notificationLevelStore = $derived(stores.notificationLevels); const activeCallRooms = $derived(stores.activeCallRooms); const appUi = getAppUiState(); @@ -251,9 +250,13 @@ rooms are organized into collapsible sections. Otherwise, rooms display alphabet const target = notificationTarget(notification); prepareUiForNotificationTarget(appUi, activeServerId, target); if (target.eventId && target.roomId) { - stores.pendingHighlights.set(target.roomId, target.threadRootId, target.eventId); + stores.pendingHighlights.set( + target.roomId, + target.threadRootId, + target.eventId, + notification.id + ); } - void notificationStore.dismiss(notification.id); const path = notificationStore.getCleanPath(activeServerId, notification); // eslint-disable-next-line svelte/no-navigation-without-resolve -- getCleanPath() returns a resolved app path @@ -316,8 +319,7 @@ rooms are organized into collapsible sections. Otherwise, rooms display alphabet {@const hasActiveCall = activeCallRooms.has(room.id)} {@const hasUnread = roomUnreadStore.roomIsUnread(room.id)} {@const isJoined = room.viewerIsMember} - {@const isMuted = !isDM && notificationLevelStore.isRoomMuted(room.id)} - {@const showUnread = hasUnread && (isDM || (isJoined && !isMuted))} + {@const showUnread = hasUnread && (isDM || isJoined)} {@const showActiveCall = hasActiveCall && (isDM || isJoined)} {@const presentation = isDM ? dmPresentation(room) : null} ({ totalCount: 0, notification: null }), - dismiss: vi.fn(), + markRead: vi.fn(), getCleanPath: vi.fn().mockReturnValue('/chat/-/room') }, - notificationLevels: { - isRoomMuted: vi.fn().mockReturnValue(false) - }, roomUnread: { roomIsUnread: vi.fn((roomId: string) => mocks.unreadRoomIds.has(roomId)), setRoomUnread: vi.fn((roomId: string, unread: boolean) => { @@ -154,7 +151,8 @@ function notification(id: string, roomId: string, isDM = false) { createdAt: '2026-06-18T10:00:00Z', actor: null, summary: 'new direct message', - room: { id: roomId } + room: { id: roomId }, + eventId: 'event-1' }; } @@ -894,7 +892,7 @@ describe('RoomList', () => { notification: roomNotification }); mocks.store.notifications.getCleanPath.mockReturnValue('/chat/-/channel-1/thread-1'); - mocks.store.notifications.dismiss.mockResolvedValue(true); + mocks.store.notifications.markRead.mockResolvedValue(true); const { container } = render(RoomList); @@ -909,13 +907,14 @@ describe('RoomList', () => { expect(mocks.store.pendingHighlights.set).toHaveBeenCalledWith( 'channel-1', 'thread-1', - 'event-1' + 'event-1', + 'mention-1' ); expect(mocks.appUi.disableRoomCallWideFor).toHaveBeenCalledWith('origin', 'channel-1'); expect(mocks.appUi.disableRoomCallWideFor.mock.invocationCallOrder[0]).toBeLessThan( mocks.goto.mock.invocationCallOrder[0] ); - expect(mocks.store.notifications.dismiss).toHaveBeenCalledWith('mention-1'); + expect(mocks.store.notifications.markRead).not.toHaveBeenCalled(); expect(mocks.store.notifications.getCleanPath).toHaveBeenCalledWith( 'origin', roomNotification @@ -933,7 +932,7 @@ describe('RoomList', () => { notification: dmNotification }); mocks.store.notifications.getCleanPath.mockReturnValue('/chat/-/dm-with-participants'); - mocks.store.notifications.dismiss.mockResolvedValue(true); + mocks.store.notifications.markRead.mockResolvedValue(true); const { container } = render(RoomList); @@ -953,7 +952,13 @@ describe('RoomList', () => { expect(mocks.appUi.disableRoomCallWideFor.mock.invocationCallOrder[0]).toBeLessThan( mocks.goto.mock.invocationCallOrder[0] ); - expect(mocks.store.notifications.dismiss).toHaveBeenCalledWith('dm-1'); + expect(mocks.store.pendingHighlights.set).toHaveBeenCalledWith( + 'dm-with-participants', + null, + 'event-1', + 'dm-1' + ); + expect(mocks.store.notifications.markRead).not.toHaveBeenCalled(); expect(mocks.goto).toHaveBeenCalledWith('/chat/-/dm-with-participants'); }); }); @@ -977,7 +982,7 @@ describe('RoomList', () => { isDM: false }); expect(mocks.goto).not.toHaveBeenCalled(); - expect(mocks.store.notifications.dismiss).not.toHaveBeenCalled(); + expect(mocks.store.notifications.markRead).not.toHaveBeenCalled(); }); }); }); diff --git a/apps/frontend/src/lib/ServerSidebarEntry.svelte b/apps/frontend/src/lib/ServerSidebarEntry.svelte index dc788035f..7ae4f9235 100644 --- a/apps/frontend/src/lib/ServerSidebarEntry.svelte +++ b/apps/frontend/src/lib/ServerSidebarEntry.svelte @@ -148,12 +148,16 @@ const target = notificationTarget(notification); prepareUiForNotificationTarget(appUi, serverId, target); if (target.eventId && target.roomId) { - stores.pendingHighlights.set(target.roomId, target.threadRootId, target.eventId); + stores.pendingHighlights.set( + target.roomId, + target.threadRootId, + target.eventId, + notification.id + ); } - void notificationStore.dismiss(notification.id); const path = notificationStore.getCleanPath(serverId, notification); - // eslint-disable-next-line svelte/no-navigation-without-resolve -- path from getCleanPath() is already resolved + // eslint-disable-next-line svelte/no-navigation-without-resolve -- getCleanPath() returns a resolved app path await goto(path); } diff --git a/apps/frontend/src/lib/ServerSidebarEntry.svelte.spec.ts b/apps/frontend/src/lib/ServerSidebarEntry.svelte.spec.ts index fff6eaa01..c6246659c 100644 --- a/apps/frontend/src/lib/ServerSidebarEntry.svelte.spec.ts +++ b/apps/frontend/src/lib/ServerSidebarEntry.svelte.spec.ts @@ -1,5 +1,4 @@ import { PresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; -import { NotificationLevel } from '@chatto/api-types/api/v1/notification_preferences_pb'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render } from 'vitest-browser-svelte'; @@ -46,7 +45,7 @@ const { mocks } = vi.hoisted(() => { unreadNotificationCount: 0, getNonDMNotification: vi.fn().mockReturnValue(null), getDMNotification: vi.fn().mockReturnValue(null), - dismiss: vi.fn(), + markRead: vi.fn(), getCleanPath: vi.fn().mockReturnValue('/chat/remote.example.com/room-1') }, roomUnread: { @@ -60,12 +59,6 @@ const { mocks } = vi.hoisted(() => { setRoomUnread: vi.fn(), getFirstUnreadRoomId: vi.fn().mockReturnValue(null) }, - notificationLevels: { - setServerPreference: vi.fn(), - setRoomPreference: vi.fn(), - isRoomMuted: vi.fn().mockReturnValue(false), - isServerMuted: vi.fn().mockReturnValue(false) - }, pendingHighlights: { set: vi.fn() }, serverInfo: { name: 'Chatto', @@ -195,11 +188,6 @@ function viewerState(overrides: Record = {}) { canAdminManageRoles: false, canAdminViewSystem: false, canAdminViewAudit: false, - serverNotificationPreference: { - level: NotificationLevel.DEFAULT, - effectiveLevel: NotificationLevel.NORMAL - }, - roomNotificationPreferences: [], ...overrides }; } @@ -240,7 +228,7 @@ describe('ServerSidebarEntry', () => { mocks.store.notifications.unreadNotificationCount = 0; mocks.store.notifications.getNonDMNotification.mockReturnValue(null); mocks.store.notifications.getDMNotification.mockReturnValue(null); - mocks.store.notifications.dismiss.mockClear(); + mocks.store.notifications.markRead.mockClear(); mocks.store.notifications.getCleanPath.mockReturnValue('/chat/remote.example.com/room-1'); mocks.store.roomUnread.clear.mockClear(); mocks.store.roomUnread.captureSnapshotRevision.mockClear(); @@ -250,8 +238,6 @@ describe('ServerSidebarEntry', () => { mocks.store.roomUnread.resolveUnknownUnread.mockClear(); mocks.store.roomUnread.setServerHasUnread.mockClear(); mocks.store.roomUnread.setRoomUnread.mockClear(); - mocks.store.notificationLevels.setServerPreference.mockClear(); - mocks.store.notificationLevels.setRoomPreference.mockClear(); mocks.store.setPermissions.mockClear(); mocks.store.serverIndicator.mockReturnValue(null); mocks.store.projection.viewer = {}; @@ -597,9 +583,10 @@ describe('ServerSidebarEntry', () => { expect(mocks.store.pendingHighlights.set).toHaveBeenCalledWith( 'room-1', 'thread-1', - 'event-1' + 'event-1', + 'mention-1' ); - expect(mocks.store.notifications.dismiss).toHaveBeenCalledWith('mention-1'); + expect(mocks.store.notifications.markRead).not.toHaveBeenCalled(); expect(mocks.goto).toHaveBeenCalledWith('/chat/remote.example.com/room-1/thread-1'); }); }); diff --git a/apps/frontend/src/lib/api-client-tests/enumDefaults.spec.ts b/apps/frontend/src/lib/api-client-tests/enumDefaults.spec.ts index c893c8959..4b5630929 100644 --- a/apps/frontend/src/lib/api-client-tests/enumDefaults.spec.ts +++ b/apps/frontend/src/lib/api-client-tests/enumDefaults.spec.ts @@ -1,36 +1,27 @@ import { describe, expect, it } from 'vitest'; import { ImageFitMode } from '@chatto/api-types/api/v1/common_pb'; -import { NotificationLevel } from '@chatto/api-types/api/v1/notification_preferences_pb'; import { PresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; import { RoomKind } from '@chatto/api-types/api/v1/rooms_pb'; import { imageFitModeOrCover, - notificationLevelOrDefault, presenceStatusOrOffline, roomKindOrChannel } from '$lib/api-client/enumDefaults'; describe('protobuf enum defaults', () => { it('preserves supported values', () => { - expect(notificationLevelOrDefault(NotificationLevel.ALL_MESSAGES)).toBe( - NotificationLevel.ALL_MESSAGES - ); expect(presenceStatusOrOffline(PresenceStatus.AWAY)).toBe(PresenceStatus.AWAY); expect(roomKindOrChannel(RoomKind.DM)).toBe(RoomKind.DM); expect(imageFitModeOrCover(ImageFitMode.CONTAIN)).toBe(ImageFitMode.CONTAIN); }); it('uses safe defaults for unspecified values', () => { - expect(notificationLevelOrDefault(NotificationLevel.UNSPECIFIED)).toBe( - NotificationLevel.DEFAULT - ); expect(presenceStatusOrOffline(PresenceStatus.UNSPECIFIED)).toBe(PresenceStatus.OFFLINE); expect(roomKindOrChannel(RoomKind.UNSPECIFIED)).toBe(RoomKind.CHANNEL); expect(imageFitModeOrCover(ImageFitMode.UNSPECIFIED)).toBe(ImageFitMode.COVER); }); it('uses safe defaults for unknown future values', () => { - expect(notificationLevelOrDefault(99 as NotificationLevel)).toBe(NotificationLevel.DEFAULT); expect(presenceStatusOrOffline(99 as PresenceStatus)).toBe(PresenceStatus.OFFLINE); expect(roomKindOrChannel(99 as RoomKind)).toBe(RoomKind.CHANNEL); expect(imageFitModeOrCover(99 as ImageFitMode)).toBe(ImageFitMode.COVER); diff --git a/apps/frontend/src/lib/api-client-tests/notificationPreferences.spec.ts b/apps/frontend/src/lib/api-client-tests/notificationPreferences.spec.ts deleted file mode 100644 index 153cf50b7..000000000 --- a/apps/frontend/src/lib/api-client-tests/notificationPreferences.spec.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { Code, ConnectError } from '@connectrpc/connect'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { configureApiClientHooks } from '$lib/api-client/hooks'; -import { NotificationLevel } from '@chatto/api-types/api/v1/notification_preferences_pb'; -import { - getServerNotificationPreference, - updateRoomNotificationPreference, - updateServerNotificationPreference -} from '$lib/api-client/notificationPreferences'; - -const mocks = vi.hoisted(() => ({ - createClient: vi.fn(), - createConnectTransport: vi.fn(), - handleAuthenticationRequired: vi.fn(), - getServerNotificationPreference: vi.fn(), - updateServerNotificationPreference: vi.fn(), - updateRoomNotificationPreference: vi.fn() -})); - -vi.mock('@connectrpc/connect', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createClient: mocks.createClient - }; -}); - -vi.mock('@connectrpc/connect-web', () => ({ - createConnectTransport: mocks.createConnectTransport -})); - -describe('notificationPreferences API', () => { - beforeEach(() => { - mocks.createClient.mockReset(); - mocks.createConnectTransport.mockReset(); - mocks.handleAuthenticationRequired.mockReset(); - - configureApiClientHooks({ onAuthenticationRequired: mocks.handleAuthenticationRequired }); - mocks.getServerNotificationPreference.mockReset(); - mocks.updateServerNotificationPreference.mockReset(); - mocks.updateRoomNotificationPreference.mockReset(); - mocks.createConnectTransport.mockReturnValue({ kind: 'transport' }); - mocks.createClient.mockReturnValue({ - getServerNotificationPreference: mocks.getServerNotificationPreference, - updateServerNotificationPreference: mocks.updateServerNotificationPreference, - updateRoomNotificationPreference: mocks.updateRoomNotificationPreference - }); - }); - - it('gets and sets server notification preferences with bearer auth', async () => { - mocks.getServerNotificationPreference.mockResolvedValue({ - preference: { - level: NotificationLevel.NORMAL, - effectiveLevel: NotificationLevel.NORMAL - } - }); - mocks.updateServerNotificationPreference.mockResolvedValue({ - preference: { - level: NotificationLevel.ALL_MESSAGES, - effectiveLevel: NotificationLevel.ALL_MESSAGES - } - }); - - const config = { - serverId: 'remote', - baseUrl: 'https://remote.example.test/api/connect', - bearerToken: 'remote-token' - }; - const signal = new AbortController().signal; - - await expect(getServerNotificationPreference(config, { signal })).resolves.toEqual({ - level: NotificationLevel.NORMAL, - effectiveLevel: NotificationLevel.NORMAL - }); - await expect( - updateServerNotificationPreference(config, NotificationLevel.ALL_MESSAGES) - ).resolves.toEqual({ - level: NotificationLevel.ALL_MESSAGES, - effectiveLevel: NotificationLevel.ALL_MESSAGES - }); - - expect(mocks.createConnectTransport).toHaveBeenCalledWith({ - baseUrl: 'https://remote.example.test/api/connect', - useBinaryFormat: true - }); - expect(mocks.getServerNotificationPreference).toHaveBeenCalledWith( - {}, - { - headers: { Authorization: 'Bearer remote-token' }, - signal - } - ); - expect(mocks.updateServerNotificationPreference).toHaveBeenCalledWith( - { level: NotificationLevel.ALL_MESSAGES }, - { - headers: { Authorization: 'Bearer remote-token' } - } - ); - }); - - it('sets room notification levels with bearer auth', async () => { - mocks.updateRoomNotificationPreference.mockResolvedValue({ - preference: { - level: NotificationLevel.MUTED, - effectiveLevel: NotificationLevel.MUTED - } - }); - - const response = await updateRoomNotificationPreference( - { - serverId: 'remote', - baseUrl: 'https://remote.example.test/api/connect', - bearerToken: 'remote-token' - }, - 'room-1', - NotificationLevel.MUTED - ); - - expect(mocks.createConnectTransport).toHaveBeenCalledWith({ - baseUrl: 'https://remote.example.test/api/connect', - useBinaryFormat: true - }); - expect(mocks.updateRoomNotificationPreference).toHaveBeenCalledWith( - { - roomId: 'room-1', - level: NotificationLevel.MUTED - }, - { - headers: { Authorization: 'Bearer remote-token' } - } - ); - expect(response).toEqual({ - level: NotificationLevel.MUTED, - effectiveLevel: NotificationLevel.MUTED - }); - }); - - it('rejects notification preference responses without shared preference metadata', async () => { - mocks.getServerNotificationPreference.mockResolvedValue({}); - - await expect( - getServerNotificationPreference({ - serverId: 'remote', - baseUrl: 'https://remote.example.test/api/connect', - bearerToken: 'remote-token' - }) - ).rejects.toThrow('notification preference response did not include preference metadata'); - }); - - it('marks the server authentication stale on unauthenticated Connect errors', async () => { - const err = new ConnectError('authentication required', Code.Unauthenticated); - mocks.updateRoomNotificationPreference.mockRejectedValue(err); - - await expect( - updateRoomNotificationPreference( - { - serverId: 'remote', - baseUrl: 'https://remote.example.test/api/connect', - bearerToken: 'expired-token' - }, - 'room-1', - NotificationLevel.MUTED - ) - ).rejects.toBe(err); - - expect(mocks.handleAuthenticationRequired).toHaveBeenCalledWith('remote'); - }); - - it('does not clear authentication for authorization failures', async () => { - const err = new ConnectError('permission denied', Code.PermissionDenied); - mocks.updateRoomNotificationPreference.mockRejectedValue(err); - - await expect( - updateRoomNotificationPreference( - { - serverId: 'remote', - baseUrl: 'https://remote.example.test/api/connect', - bearerToken: 'remote-token' - }, - 'room-1', - NotificationLevel.MUTED - ) - ).rejects.toBe(err); - - expect(mocks.handleAuthenticationRequired).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/frontend/src/lib/api-client-tests/notifications.spec.ts b/apps/frontend/src/lib/api-client-tests/notifications.spec.ts index 21ed7dc72..0fbb88358 100644 --- a/apps/frontend/src/lib/api-client-tests/notifications.spec.ts +++ b/apps/frontend/src/lib/api-client-tests/notifications.spec.ts @@ -1,161 +1,129 @@ -import { PresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; -import { Timestamp } from '@bufbuild/protobuf'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { PresenceStatus as APIPresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; +import { describe, expect, it } from 'vitest'; +import { NotificationOccurrence } from '@chatto/api-types/api/v1/notifications_pb'; -import { createNotificationAPI, NotificationItemKind } from '$lib/api-client/notifications'; +import { + notificationOccurrence, + occurrenceAsNotificationItem, + NotificationDeliveryIntensity, + NotificationItemKind, + NotificationReason +} from '$lib/api-client/notifications'; -const mocks = vi.hoisted(() => ({ - createClient: vi.fn(), - createConnectTransport: vi.fn(), - listNotifications: vi.fn(), - listRoomNotifications: vi.fn(), - listRoomNotificationCounts: vi.fn(), - dismissNotification: vi.fn(), - dismissAllNotifications: vi.fn() -})); - -vi.mock('@connectrpc/connect', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createClient: mocks.createClient - }; -}); - -vi.mock('@connectrpc/connect-web', () => ({ - createConnectTransport: mocks.createConnectTransport -})); +describe('notification occurrence presentation mapping', () => { + it('keeps followed-thread targets as reply notifications', () => { + const occurrence = notificationOccurrence( + new NotificationOccurrence({ + id: 'thread-notification', + sourceEventId: 'reply-1', + actor: { id: 'u1', displayName: 'Alice' }, + target: { + room: { id: 'room-1', name: 'general' }, + eventId: 'reply-1', + threadRootEventId: 'root-1' + }, + reasons: [ + { + reason: NotificationReason.FOLLOWED_THREAD, + intensity: NotificationDeliveryIntensity.BADGE + } + ], + unread: true + }) + ); -describe('createNotificationAPI', () => { - beforeEach(() => { - mocks.createClient.mockReset(); - mocks.createConnectTransport.mockReset(); - mocks.listNotifications.mockReset(); - mocks.listRoomNotifications.mockReset(); - mocks.listRoomNotificationCounts.mockReset(); - mocks.dismissNotification.mockReset(); - mocks.dismissAllNotifications.mockReset(); - mocks.createConnectTransport.mockReturnValue({ kind: 'transport' }); - mocks.createClient.mockReturnValue({ - listNotifications: mocks.listNotifications, - listRoomNotifications: mocks.listRoomNotifications, - listRoomNotificationCounts: mocks.listRoomNotificationCounts, - dismissNotification: mocks.dismissNotification, - dismissAllNotifications: mocks.dismissAllNotifications + expect(occurrence.reasons).toEqual([NotificationReason.FOLLOWED_THREAD]); + expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ + kind: NotificationItemKind.Reply, + replyEventId: 'reply-1', + replyInThread: 'root-1' }); }); - it('maps notification pages and sends bearer auth', async () => { - mocks.listNotifications.mockResolvedValue({ - page: { totalCount: 2n, hasMore: true }, - notifications: [ - { - id: 'n1', - createdAt: Timestamp.fromDate(new Date('2026-06-01T12:00:00Z')), - actor: { - id: 'u1', - login: 'alice', - displayName: 'Alice', - deleted: false, - avatarUrl: 'https://cdn/avatar.webp', - presenceStatus: APIPresenceStatus.OFFLINE + it('keeps a direct mention ahead of ambient followed-thread activity', () => { + const occurrence = notificationOccurrence( + new NotificationOccurrence({ + id: 'thread-mention', + sourceEventId: 'reply-2', + actor: { id: 'u1', displayName: 'Alice' }, + target: { + room: { id: 'room-1', name: 'general' }, + eventId: 'reply-2', + threadRootEventId: 'root-1' + }, + reasons: [ + { + reason: NotificationReason.DIRECT_MENTION, + intensity: NotificationDeliveryIntensity.ALERT }, - kind: { - case: 'mention', - value: { - room: { id: 'room-1', name: 'general' }, - eventId: 'event-1', - threadRootEventId: 'thread-1' - } + { + reason: NotificationReason.FOLLOWED_THREAD, + intensity: NotificationDeliveryIntensity.BADGE } - } - ] - }); + ], + unread: true + }) + ); - const api = createNotificationAPI({ - baseUrl: 'https://remote.example.com/api/connect', - bearerToken: 'token' + expect(occurrence.reasons).toEqual([ + NotificationReason.DIRECT_MENTION, + NotificationReason.FOLLOWED_THREAD + ]); + expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ + kind: NotificationItemKind.Mention, + mentionEventId: 'reply-2', + mentionInThread: 'root-1' }); - const page = await api.listNotifications(50); + }); - expect(mocks.createConnectTransport).toHaveBeenCalledWith({ - baseUrl: 'https://remote.example.com/api/connect', - useBinaryFormat: true - }); - expect(mocks.listNotifications).toHaveBeenCalledWith( - { page: { limit: 50, offset: 0 } }, - { headers: { Authorization: 'Bearer token' } } + it('describes followed-room occurrences as messages', () => { + const occurrence = notificationOccurrence( + new NotificationOccurrence({ + id: 'room-notification', + sourceEventId: 'message-1', + actor: { id: 'u1', displayName: 'Alice' }, + target: { room: { id: 'room-1', name: 'general' }, eventId: 'message-1' }, + reasons: [ + { + reason: NotificationReason.FOLLOWED_ROOM, + intensity: NotificationDeliveryIntensity.ALERT + } + ], + unread: true + }) ); - expect(page).toEqual({ - totalCount: 2, - hasMore: true, - items: [ - { - kind: NotificationItemKind.Mention, - id: 'n1', - createdAt: '2026-06-01T12:00:00.000Z', - actor: { - id: 'u1', - login: 'alice', - displayName: 'Alice', - deleted: false, - avatarUrl: 'https://cdn/avatar.webp', - presenceStatus: PresenceStatus.OFFLINE, - customStatus: null - }, - summary: 'Alice mentioned you', - mentionRoom: { id: 'room-1', name: 'general' }, - mentionEventId: 'event-1', - mentionInThread: 'thread-1' - } - ] + + expect(occurrence.reasons).toEqual([NotificationReason.FOLLOWED_ROOM]); + expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ + kind: NotificationItemKind.RoomMessage, + roomMsgEventId: 'message-1' }); }); - it('maps room notification reads and dismiss mutations without auth headers', async () => { - mocks.listRoomNotifications.mockResolvedValue({ - page: { totalCount: 1n, hasMore: false }, - notifications: [ - { - id: 'n2', - kind: { - case: 'directMessage', - value: { room: { id: 'dm-1', name: 'Alice' }, eventId: 'event-2' } + it('preserves a threaded reaction target in the flattened room-message shape', () => { + const occurrence = notificationOccurrence( + new NotificationOccurrence({ + id: 'reaction-notification', + sourceEventId: 'reaction-1', + actor: { id: 'u1', displayName: 'Alice' }, + target: { + room: { id: 'room-1', name: 'general' }, + eventId: 'message-1', + threadRootEventId: 'thread-root-1' + }, + reasons: [ + { + reason: NotificationReason.REACTION, + intensity: NotificationDeliveryIntensity.BADGE } - } - ] - }); - mocks.listRoomNotificationCounts.mockResolvedValue({ - roomCounts: [ - { roomId: 'room-1', totalCount: 2 }, - { roomId: 'dm-1', totalCount: 1 } - ] - }); - mocks.dismissNotification.mockResolvedValue({ dismissed: true }); - mocks.dismissAllNotifications.mockResolvedValue({ dismissedCount: 3 }); - - const api = createNotificationAPI({ baseUrl: '/api/connect', bearerToken: null }); + ], + unread: true + }) + ); - await expect(api.listRoomNotifications('dm-1')).resolves.toMatchObject({ - totalCount: 1, - items: [ - { - kind: NotificationItemKind.DirectMessage, - room: { id: 'dm-1' } - } - ] - }); - await expect(api.listRoomNotificationCounts()).resolves.toEqual({ - 'room-1': 2, - 'dm-1': 1 + expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ + kind: NotificationItemKind.RoomMessage, + roomMsgEventId: 'message-1', + roomMsgThreadRootId: 'thread-root-1' }); - await expect(api.dismissNotification('n2')).resolves.toBe(true); - await expect(api.dismissAllNotifications()).resolves.toBe(3); - - expect(mocks.listRoomNotifications).toHaveBeenCalledWith( - { roomId: 'dm-1', page: { limit: 1, offset: 0 } }, - { headers: undefined } - ); }); }); diff --git a/apps/frontend/src/lib/api-client-tests/viewer.spec.ts b/apps/frontend/src/lib/api-client-tests/viewer.spec.ts index 5592cedba..6751183c9 100644 --- a/apps/frontend/src/lib/api-client-tests/viewer.spec.ts +++ b/apps/frontend/src/lib/api-client-tests/viewer.spec.ts @@ -1,8 +1,6 @@ import { PresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; -import { NotificationLevel } from '@chatto/api-types/api/v1/notification_preferences_pb'; import { Timestamp } from '@bufbuild/protobuf'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { NotificationLevel as APINotificationLevel } from '@chatto/api-types/api/v1/notification_preferences_pb'; import { PresenceStatus as APIPresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; import { TimeFormat } from '@chatto/api-types/api/v1/viewer_pb'; @@ -58,8 +56,7 @@ describe('getCurrentUserViaConnect', () => { timezone: 'Europe/Berlin', timeFormat: TimeFormat.TIME_FORMAT_24_HOUR } - }, - roomNotificationPreferences: [] + } }); const user = await getCurrentUserViaConnect({ @@ -108,8 +105,7 @@ describe('getCurrentUserViaConnect', () => { }, hasVerifiedEmail: false, settings: { timeFormat: TimeFormat.TIME_FORMAT_UNSPECIFIED } - }, - roomNotificationPreferences: [] + } }); const user = await getCurrentUserViaConnect({ @@ -126,7 +122,7 @@ describe('getCurrentUserViaConnect', () => { expect(user.lastLoginChange).toBeNull(); }); - it('loads viewer capabilities and notification preferences', async () => { + it('loads viewer capabilities', async () => { mocks.getViewer.mockResolvedValue({ user: { profile: { @@ -151,27 +147,7 @@ describe('getCurrentUserViaConnect', () => { { capability: 'user.manage-permissions', granted: true }, { capability: 'user.invite', granted: true } ] - }, - serverNotificationPreference: { - level: APINotificationLevel.ALL_MESSAGES, - effectiveLevel: APINotificationLevel.ALL_MESSAGES - }, - roomNotificationPreferences: [ - { - roomId: 'room-1', - preference: { - level: APINotificationLevel.MUTED, - effectiveLevel: APINotificationLevel.MUTED - } - }, - { - roomId: 'room-2', - preference: { - level: APINotificationLevel.DEFAULT, - effectiveLevel: APINotificationLevel.NORMAL - } - } - ] + } }); const signal = new AbortController().signal; @@ -200,46 +176,8 @@ describe('getCurrentUserViaConnect', () => { canAdminViewSystem: true, canAdminViewAudit: true, canManageUserPermissions: true, - canManageInvites: true, - serverNotificationPreference: { - level: NotificationLevel.ALL_MESSAGES, - effectiveLevel: NotificationLevel.ALL_MESSAGES - }, - roomNotificationPreferences: [ - { - roomId: 'room-1', - level: NotificationLevel.MUTED, - effectiveLevel: NotificationLevel.MUTED - }, - { - roomId: 'room-2', - level: NotificationLevel.DEFAULT, - effectiveLevel: NotificationLevel.NORMAL - } - ] + canManageInvites: true }) ); }); - - it('rejects room notification preference rows without shared preference metadata', async () => { - mocks.getViewer.mockResolvedValue({ - user: { - profile: { - id: 'U3', - login: 'carol', - displayName: 'Carol', - presenceStatus: APIPresenceStatus.ONLINE - }, - hasVerifiedEmail: true - }, - roomNotificationPreferences: [{ roomId: 'room-1' }] - }); - - await expect( - getViewerStateViaConnect({ - baseUrl: '/api/connect', - bearerToken: 'token' - }) - ).rejects.toThrow('room notification preference response did not include preference metadata'); - }); }); diff --git a/apps/frontend/src/lib/api-client/enumDefaults.ts b/apps/frontend/src/lib/api-client/enumDefaults.ts index 5b90a4568..2e37e40aa 100644 --- a/apps/frontend/src/lib/api-client/enumDefaults.ts +++ b/apps/frontend/src/lib/api-client/enumDefaults.ts @@ -1,24 +1,7 @@ import { ImageFitMode } from '@chatto/api-types/api/v1/common_pb'; -import { NotificationLevel } from '@chatto/api-types/api/v1/notification_preferences_pb'; import { PresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; import { RoomKind } from '@chatto/api-types/api/v1/rooms_pb'; -/** Preserve the frontend's default when a notification level is absent or unknown. */ -export function notificationLevelOrDefault( - level: NotificationLevel | undefined -): NotificationLevel { - switch (level) { - case NotificationLevel.MUTED: - case NotificationLevel.NORMAL: - case NotificationLevel.ALL_MESSAGES: - case NotificationLevel.DEFAULT: - return level; - case NotificationLevel.UNSPECIFIED: - default: - return NotificationLevel.DEFAULT; - } -} - /** Treat absent or unknown presence values as offline instead of implying availability. */ export function presenceStatusOrOffline(status: PresenceStatus): PresenceStatus { switch (status) { diff --git a/apps/frontend/src/lib/api-client/notificationPreferences.ts b/apps/frontend/src/lib/api-client/notificationPreferences.ts deleted file mode 100644 index 7662229dd..000000000 --- a/apps/frontend/src/lib/api-client/notificationPreferences.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { - authHeaders, - createChattoClient, - handleAuthError, - type ConnectAPIConfig as BaseConnectAPIConfig -} from './connect.js'; -import { NotificationPreferencesService } from '@chatto/api-types/api/v1/notification_preferences_connect'; -import { - NotificationLevel, - type NotificationPreference as APINotificationPreference -} from '@chatto/api-types/api/v1/notification_preferences_pb'; - -export type ConnectAPIConfig = BaseConnectAPIConfig & { - serverId: string; -}; - -export type NotificationPreference = { - level: NotificationLevel; - effectiveLevel: NotificationLevel; -}; - -export async function getServerNotificationPreference( - config: ConnectAPIConfig, - options: { signal?: AbortSignal } = {} -): Promise { - const client = createNotificationPreferencesClient(config); - let response; - try { - response = await client.getServerNotificationPreference( - {}, - { - headers: connectHeaders(config), - ...(options.signal ? { signal: options.signal } : {}) - } - ); - } catch (err) { - handleAuthError(config, err); - } - return notificationPreference(response.preference); -} - -export async function updateServerNotificationPreference( - config: ConnectAPIConfig, - level: NotificationLevel -): Promise { - const client = createNotificationPreferencesClient(config); - let response; - try { - response = await client.updateServerNotificationPreference( - { level }, - { - headers: connectHeaders(config) - } - ); - } catch (err) { - handleAuthError(config, err); - } - return notificationPreference(response.preference); -} - -export async function updateRoomNotificationPreference( - config: ConnectAPIConfig, - roomId: string, - level: NotificationLevel -): Promise { - const client = createNotificationPreferencesClient(config); - let response; - try { - response = await client.updateRoomNotificationPreference( - { - roomId, - level - }, - { - headers: connectHeaders(config) - } - ); - } catch (err) { - handleAuthError(config, err); - } - return notificationPreference(response.preference); -} - -function createNotificationPreferencesClient(config: ConnectAPIConfig) { - return createChattoClient(NotificationPreferencesService, config); -} - -function connectHeaders(config: ConnectAPIConfig) { - return authHeaders(config); -} - -function notificationPreference( - preference: APINotificationPreference | undefined -): NotificationPreference { - if (!preference) { - throw new Error('notification preference response did not include preference metadata'); - } - return { - level: preference.level, - effectiveLevel: preference.effectiveLevel - }; -} diff --git a/apps/frontend/src/lib/api-client/notifications.ts b/apps/frontend/src/lib/api-client/notifications.ts index eb9293700..56d3edcb9 100644 --- a/apps/frontend/src/lib/api-client/notifications.ts +++ b/apps/frontend/src/lib/api-client/notifications.ts @@ -2,9 +2,13 @@ import { PresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; import { authHeaders, createChattoClient } from './connect.js'; import { NotificationService } from '@chatto/api-types/api/v1/notifications_connect'; import type { - ListRoomNotificationsResponse, - ListNotificationsResponse, - NotificationItem as APINotificationItem + ListNotificationGroupsResponse, + NotificationGroup as APINotificationGroup, + NotificationOccurrence as APINotificationOccurrence +} from '@chatto/api-types/api/v1/notifications_pb'; +import { + NotificationDeliveryIntensity, + NotificationReason } from '@chatto/api-types/api/v1/notifications_pb'; import type { User as APIUser } from '@chatto/api-types/api/v1/users_pb'; import { presenceStatusOrOffline } from './enumDefaults.js'; @@ -44,6 +48,7 @@ export type DirectMessageNotificationItem = { actor?: NotificationActor | null; summary: string; room: { id: string }; + eventId?: string | null; }; export type MentionNotificationItem = { @@ -77,6 +82,7 @@ export type RoomMessageNotificationItem = { summary: string; roomMsgRoom: { id: string; name: string } | null; roomMsgEventId: string; + roomMsgThreadRootId?: string | null; }; export type NotificationItem = @@ -85,134 +91,240 @@ export type NotificationItem = | ReplyNotificationItem | RoomMessageNotificationItem; -export type NotificationPage = { - items: NotificationItem[]; +export type NotificationOccurrenceItem = { + id: string; + sourceEventId: string; + createdAt: string; + actor: NotificationActor | null; + room: { id: string; name: string } | null; + eventId: string; + threadRootId: string | null; + parentEventId: string | null; + reasons: NotificationReason[]; + reasonMatches: Array<{ + reason: NotificationReason; + intensity: NotificationDeliveryIntensity; + }>; + unread: boolean; + expiresAt?: string; +}; + +export type NotificationGroupItem = { + id: string; + occurrences: NotificationOccurrenceItem[]; + openTarget: NotificationOccurrenceItem | null; + unread: boolean; + occurrenceCount: number; + latestAt: string; + reasons: NotificationReason[]; + nextExpiryAt?: string | null; +}; + +export type NotificationGroupPage = { + groups: NotificationGroupItem[]; + unreadGroupCount: number; + roomUnreadGroupCounts: Record; totalCount: number; hasMore: boolean; + nextExpiryAt?: string | null; +}; + +export { NotificationDeliveryIntensity, NotificationReason }; +export type NotificationPolicyItem = { + reason: NotificationReason; + serverIntensity: NotificationDeliveryIntensity; + roomIntensity: NotificationDeliveryIntensity; + effectiveIntensity: NotificationDeliveryIntensity; }; export function createNotificationAPI(config: NotificationAPIConfig) { const client = createChattoClient(NotificationService, config); const headers = () => authHeaders(config); - const listRoomNotificationCounts = async (): Promise> => { - const response = await client.listRoomNotificationCounts({}, { headers: headers() }); - return Object.fromEntries( - response.roomCounts.map((count) => [count.roomId, count.totalCount] as const) - ); - }; return { - async listNotifications(limit = 50, offset = 0): Promise { - return mapNotificationPage( - await client.listNotifications({ page: { limit, offset } }, { headers: headers() }) + async listNotificationGroups(limit = 50, offset = 0): Promise { + return mapNotificationGroupPage( + await client.listNotificationGroups({ page: { limit, offset } }, { headers: headers() }) + ); + }, + + async markNotificationRead(notificationId: string): Promise { + const response = await client.markNotificationRead( + { notificationId }, + { headers: headers() } ); + if (!response.notification) throw new Error('Read notification was not returned'); + return notificationOccurrence(response.notification); }, - async listRoomNotifications(roomId: string, limit = 1, offset = 0): Promise { - return mapNotificationPage( - await client.listRoomNotifications( - { roomId, page: { limit, offset } }, - { headers: headers() } - ) + async deleteNotificationOccurrence(notificationId: string): Promise { + const response = await client.deleteNotificationOccurrence( + { notificationId }, + { headers: headers() } ); + return response.deleted; }, - async listRoomNotificationCounts(): Promise> { - return listRoomNotificationCounts(); + async deleteNotificationGroup(groupId: string): Promise { + return Number( + (await client.deleteNotificationGroup({ groupId }, { headers: headers() })).deletedCount + ); + }, + + async deleteAllNotificationOccurrences(): Promise { + return Number( + (await client.deleteAllNotificationOccurrences({}, { headers: headers() })).deletedCount + ); }, - async dismissNotification(notificationId: string): Promise { - return (await client.dismissNotification({ notificationId }, { headers: headers() })) - .dismissed; + async getNotificationPolicy(roomId?: string): Promise { + const response = await client.getNotificationPolicy({ roomId }, { headers: headers() }); + return response.preferences.map((preference) => ({ + reason: preference.reason, + serverIntensity: preference.serverIntensity, + roomIntensity: preference.roomIntensity, + effectiveIntensity: preference.effectiveIntensity + })); }, - async dismissAllNotifications(): Promise { - return (await client.dismissAllNotifications({}, { headers: headers() })).dismissedCount; + async setNotificationPolicyPreference( + reason: NotificationReason, + intensity: NotificationDeliveryIntensity, + roomId?: string + ): Promise { + const response = await client.setNotificationPolicyPreference( + { reason, intensity, roomId }, + { headers: headers() } + ); + return response.preferences.map((preference) => ({ + reason: preference.reason, + serverIntensity: preference.serverIntensity, + roomIntensity: preference.roomIntensity, + effectiveIntensity: preference.effectiveIntensity + })); } }; } export type NotificationAPI = ReturnType; -export function mapNotificationPage( - response: ListNotificationsResponse | ListRoomNotificationsResponse -): NotificationPage { +export function mapNotificationGroupPage( + response: ListNotificationGroupsResponse +): NotificationGroupPage { return { - items: response.notifications.flatMap((item) => { - const mapped = notificationItem(item); - return mapped ? [mapped] : []; - }), + groups: response.groups.map(notificationGroup), + unreadGroupCount: Number(response.unreadGroupCount), + roomUnreadGroupCounts: Object.fromEntries( + response.roomUnreadGroupCounts.map((count) => [count.roomId, Number(count.unreadGroupCount)]) + ), totalCount: Number(response.page?.totalCount ?? 0), - hasMore: response.page?.hasMore ?? false + hasMore: response.page?.hasMore ?? false, + nextExpiryAt: response.nextExpiryAt?.toDate().toISOString() ?? null + }; +} + +function notificationGroup(group: APINotificationGroup): NotificationGroupItem { + const occurrences = group.occurrences.map(notificationOccurrence); + const targetEventId = group.openTarget?.eventId; + return { + id: group.id, + occurrences, + openTarget: + occurrences.find((occurrence) => occurrence.id === group.openNotificationId) ?? + occurrences.find((occurrence) => occurrence.eventId === targetEventId) ?? + occurrences[0] ?? + null, + unread: group.unread, + occurrenceCount: Number(group.occurrenceCount), + latestAt: group.latestAt?.toDate().toISOString() ?? new Date(0).toISOString(), + reasons: [...group.reasons], + nextExpiryAt: group.nextExpiryAt?.toDate().toISOString() ?? null }; } -function notificationItem(item: APINotificationItem): NotificationItem | null { +export function notificationOccurrence( + item: APINotificationOccurrence +): NotificationOccurrenceItem { const actor = notificationActor(item.actor); - const base = { + const reasonMatches = item.reasons.map((match) => ({ + reason: match.reason, + intensity: match.intensity + })); + const reasons = reasonMatches.map((match) => match.reason); + return { id: item.id, + sourceEventId: item.sourceEventId, createdAt: item.createdAt?.toDate().toISOString() ?? new Date(0).toISOString(), - actor + actor, + room: item.target?.room ? { id: item.target.room.id, name: item.target.room.name } : null, + eventId: item.target?.eventId ?? '', + threadRootId: item.target?.threadRootEventId ?? null, + parentEventId: item.target?.parentEventId ?? null, + reasons, + reasonMatches, + unread: item.unread, + expiresAt: item.expiresAt?.toDate().toISOString() ?? new Date(0).toISOString() }; - - switch (item.kind.case) { - case 'directMessage': - return { - kind: NotificationItemKind.DirectMessage, - ...base, - summary: notificationSummary(actor, NotificationItemKind.DirectMessage), - room: { id: item.kind.value.room?.id ?? '' } - }; - case 'mention': - return { - kind: NotificationItemKind.Mention, - ...base, - summary: notificationSummary(actor, NotificationItemKind.Mention), - mentionRoom: item.kind.value.room - ? { id: item.kind.value.room.id, name: item.kind.value.room.name } - : null, - mentionEventId: item.kind.value.eventId, - mentionInThread: item.kind.value.threadRootEventId ?? null - }; - case 'reply': - return { - kind: NotificationItemKind.Reply, - ...base, - summary: notificationSummary(actor, NotificationItemKind.Reply), - replyRoom: item.kind.value.room - ? { id: item.kind.value.room.id, name: item.kind.value.room.name } - : null, - replyEventId: item.kind.value.eventId, - inReplyToId: item.kind.value.inReplyToId, - replyInThread: item.kind.value.threadRootEventId ?? null - }; - case 'roomMessage': - return { - kind: NotificationItemKind.RoomMessage, - ...base, - summary: notificationSummary(actor, NotificationItemKind.RoomMessage), - roomMsgRoom: item.kind.value.room - ? { id: item.kind.value.room.id, name: item.kind.value.room.name } - : null, - roomMsgEventId: item.kind.value.eventId - }; - default: - return null; - } } -function notificationSummary(actor: NotificationActor | null, kind: NotificationItemKind): string { - const actorName = actor?.displayName || null; - switch (kind) { - case NotificationItemKind.DirectMessage: - return actorName ? `${actorName} sent you a message` : 'New message'; - case NotificationItemKind.Mention: - return actorName ? `${actorName} mentioned you` : 'You were mentioned'; - case NotificationItemKind.Reply: - return actorName ? `${actorName} replied to your message` : 'New reply to your message'; - case NotificationItemKind.RoomMessage: - return actorName ? `${actorName} posted a message` : 'New message'; +export function occurrenceAsNotificationItem(item: NotificationOccurrenceItem): NotificationItem { + const base = { + id: item.id, + createdAt: item.createdAt, + actor: item.actor, + // Compact sidebar presentation consumers require this field, while the + // notification centre renders structured reasons through the active locale. + summary: '' + }; + if (item.reasons.includes(NotificationReason.DIRECT_MESSAGE)) { + return { + kind: NotificationItemKind.DirectMessage, + ...base, + room: { id: item.room?.id ?? '' }, + eventId: item.eventId + }; + } + if (item.reasons.includes(NotificationReason.REPLY)) { + return { + kind: NotificationItemKind.Reply, + ...base, + replyRoom: item.room, + replyEventId: item.eventId, + inReplyToId: item.parentEventId ?? '', + replyInThread: item.threadRootId + }; } + if ( + item.reasons.includes(NotificationReason.DIRECT_MENTION) || + item.reasons.includes(NotificationReason.ROLE_MENTION) || + item.reasons.includes(NotificationReason.HERE) || + item.reasons.includes(NotificationReason.ALL) + ) { + return { + kind: NotificationItemKind.Mention, + ...base, + mentionRoom: item.room, + mentionEventId: item.eventId, + mentionInThread: item.threadRootId + }; + } + if (item.reasons.includes(NotificationReason.FOLLOWED_THREAD)) { + return { + kind: NotificationItemKind.Reply, + ...base, + replyRoom: item.room, + replyEventId: item.eventId, + inReplyToId: item.parentEventId ?? '', + replyInThread: item.threadRootId + }; + } + return { + kind: NotificationItemKind.RoomMessage, + ...base, + roomMsgRoom: item.room, + roomMsgEventId: item.eventId, + roomMsgThreadRootId: item.threadRootId + }; } function notificationActor(actor: APIUser | undefined): NotificationActor | null { diff --git a/apps/frontend/src/lib/api-client/viewer.ts b/apps/frontend/src/lib/api-client/viewer.ts index a32b51df3..ccabbb983 100644 --- a/apps/frontend/src/lib/api-client/viewer.ts +++ b/apps/frontend/src/lib/api-client/viewer.ts @@ -1,9 +1,8 @@ import { PresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; -import { NotificationLevel } from '@chatto/api-types/api/v1/notification_preferences_pb'; import { authHeaders, createChattoClient } from './connect.js'; import { ViewerService } from '@chatto/api-types/api/v1/viewer_connect'; import { TimeFormat, type GetViewerResponse } from '@chatto/api-types/api/v1/viewer_pb'; -import { notificationLevelOrDefault, presenceStatusOrOffline } from './enumDefaults.js'; +import { presenceStatusOrOffline } from './enumDefaults.js'; import { timeFormatOrAuto } from './timeFormat.js'; export type ViewerAPIConfig = { @@ -48,19 +47,8 @@ export type ViewerCapabilities = { canManageInvites: boolean; }; -export type NotificationPreference = { - level: NotificationLevel; - effectiveLevel: NotificationLevel; -}; - -export type RoomNotificationPreference = NotificationPreference & { - roomId: string; -}; - export type ViewerState = ViewerCapabilities & { user: CurrentUser; - serverNotificationPreference: NotificationPreference; - roomNotificationPreferences: RoomNotificationPreference[]; viewerPermissions: Record; viewerHasUnreadRooms: boolean; }; @@ -142,16 +130,7 @@ export function viewerResponseToState(response: GetViewerResponse): ViewerState canManageUserPermissions: can(capabilityKeys.manageUserPermissions), canManageInvites: can(capabilityKeys.manageInvites), viewerPermissions, - viewerHasUnreadRooms: response.viewerState?.hasUnreadRooms ?? false, - serverNotificationPreference: { - level: notificationLevelOrDefault(response.serverNotificationPreference?.level), - effectiveLevel: notificationLevelOrDefault( - response.serverNotificationPreference?.effectiveLevel - ) - }, - roomNotificationPreferences: response.roomNotificationPreferences.map( - roomNotificationPreference - ) + viewerHasUnreadRooms: response.viewerState?.hasUnreadRooms ?? false }; } @@ -170,20 +149,3 @@ function mapCapabilityGrants( export async function getCurrentUserViaConnect(config: ViewerAPIConfig): Promise { return (await getViewerStateViaConnect(config)).user; } - -function roomNotificationPreference(pref: { - roomId: string; - preference?: { - level: NotificationLevel; - effectiveLevel: NotificationLevel; - }; -}): RoomNotificationPreference { - if (!pref.preference) { - throw new Error('room notification preference response did not include preference metadata'); - } - return { - roomId: pref.roomId, - level: notificationLevelOrDefault(pref.preference.level), - effectiveLevel: notificationLevelOrDefault(pref.preference.effectiveLevel) - }; -} diff --git a/apps/frontend/src/lib/components/NotificationSync.svelte b/apps/frontend/src/lib/components/NotificationSync.svelte index cf85bd8a7..3fa23ca11 100644 --- a/apps/frontend/src/lib/components/NotificationSync.svelte +++ b/apps/frontend/src/lib/components/NotificationSync.svelte @@ -7,7 +7,7 @@ and installed-app badge updates. **Responsibilities:** - Listens for live notification transitions attached to authoritative projection replacements - Plays the user's selected sound for non-silent creations -- Shows an exact DM count or a flag for other/incompletely loaded pending notifications +- Reconciles the installed-app badge from authoritative unread group counts Include this component once in the application root so signed-out pages also clear stale badges. --> @@ -21,7 +21,6 @@ Include this component once in the application root so signed-out pages also cle updateAppBadge, type AppBadgeIntent } from '$lib/notifications/appBadge'; - import { NotificationItemKind } from '$lib/api-client/notifications'; import type { ProjectionHandler } from '$lib/eventBus.svelte'; import { RealtimeProjectionNotificationAction } from '@chatto/api-types/realtime/v1/realtime_pb'; @@ -60,32 +59,16 @@ Include this component once in the application root so signed-out pages also cle }); function appBadgeIntent(): AppBadgeIntent | null { - let dmCount = 0; - let hasNotification = false; - let allStoresLoaded = true; - let hasCompleteNotificationPages = true; + let unreadGroupCount = 0; for (const instance of serverRegistry.servers) { const stores = serverRegistry.getStore(instance.id); if (!stores.isAuthenticated) continue; - - const notifications = stores.notifications.notifications; - const notificationTotal = stores.notifications.unreadNotificationCount; - dmCount += notifications.filter( - (notification) => notification.kind === NotificationItemKind.DirectMessage - ).length; - if (notificationTotal > 0 || notifications.length > 0) hasNotification = true; - if (!stores.notifications.hasLoaded) { - allStoresLoaded = false; - hasCompleteNotificationPages = false; - } else if (notificationTotal !== notifications.length) { - hasCompleteNotificationPages = false; - } + if (!stores.notifications.hasLoaded) return null; + unreadGroupCount += stores.notifications.unreadNotificationCount; } - if (dmCount > 0 && hasCompleteNotificationPages) return { kind: 'count', count: dmCount }; - if (hasNotification) return { kind: 'flag' }; - if (!allStoresLoaded) return null; + if (unreadGroupCount > 0) return { kind: 'count', count: unreadGroupCount }; return { kind: 'clear' }; } @@ -103,4 +86,28 @@ Include this component once in the application root so signed-out pages also cle $effect(() => { return listenForAppBadgeRefresh(syncAppBadge); }); + + // KV expiry may not produce a watcher mutation. Refresh each authoritative + // Inbox at its next server-provided expiry boundary so long-lived tabs do + // not retain stale groups or badge counts. + $effect(() => { + const timers: ReturnType[] = []; + for (const instance of serverRegistry.servers) { + const stores = serverRegistry.getStore(instance.id); + if (!stores.isAuthenticated || !stores.notifications.nextExpiryAt) continue; + const boundary = new Date(stores.notifications.nextExpiryAt).getTime() + 50; + const schedule = () => { + const remaining = boundary - Date.now(); + if (remaining <= 0) { + void stores.notifications.fetch(); + return; + } + timers.push(setTimeout(schedule, Math.min(remaining, 2_147_483_647))); + }; + schedule(); + } + return () => { + for (const timer of timers) clearTimeout(timer); + }; + }); diff --git a/apps/frontend/src/lib/components/NotificationSync.svelte.spec.ts b/apps/frontend/src/lib/components/NotificationSync.svelte.spec.ts index 125205c51..d8da44244 100644 --- a/apps/frontend/src/lib/components/NotificationSync.svelte.spec.ts +++ b/apps/frontend/src/lib/components/NotificationSync.svelte.spec.ts @@ -18,9 +18,12 @@ const { mocks } = vi.hoisted(() => { isAuthenticated: true, notifications: { notifications: [] as Array<{ kind: string }>, + groups: [] as Array<{ unread: boolean; reasons: number[] }>, count: 0, unreadNotificationCount: 0, - hasLoaded: true + hasLoaded: true, + nextExpiryAt: null as string | null, + fetch: vi.fn(async () => {}) } }); const stores = { @@ -104,9 +107,7 @@ async function renderAndWaitForSubscription() { const authenticatedServerCount = mocks.servers.filter( ({ id }) => mocks.stores[id as keyof typeof mocks.stores].isAuthenticated ).length; - await vi.waitFor(() => - expect(mocks.bus.projectionHandlers.size).toBe(authenticatedServerCount) - ); + await vi.waitFor(() => expect(mocks.bus.projectionHandlers.size).toBe(authenticatedServerCount)); await vi.waitFor(() => expect(mocks.badgeRefreshHandlers.size).toBe(1)); } @@ -120,9 +121,12 @@ describe('NotificationSync', () => { for (const store of Object.values(mocks.stores)) { store.isAuthenticated = true; store.notifications.notifications = []; + store.notifications.groups = []; store.notifications.count = 0; store.notifications.unreadNotificationCount = 0; store.notifications.hasLoaded = true; + store.notifications.nextExpiryAt = null; + store.notifications.fetch.mockClear(); } }); @@ -154,13 +158,13 @@ describe('NotificationSync', () => { expect(mocks.playNotificationSound).not.toHaveBeenCalled(); }); - it('does not play a sound for reconciliation or dismissal replacements', async () => { + it('does not play a sound for reconciliation or occurrence updates', async () => { await renderAndWaitForSubscription(); dispatch(); dispatch( new RealtimeProjectionNotificationChange({ - action: RealtimeProjectionNotificationAction.DISMISSED, + action: RealtimeProjectionNotificationAction.UPDATED, notificationId: 'n1' }) ); @@ -168,11 +172,14 @@ describe('NotificationSync', () => { expect(mocks.playNotificationSound).not.toHaveBeenCalled(); }); - it('uses an exact numeric badge for loaded DM notifications', async () => { - mocks.stores.origin.notifications.notifications = [ - { kind: 'directMessage' }, - { kind: 'directMessage' } - ]; + it('refreshes authoritative notification state at the next expiry boundary', async () => { + mocks.stores.origin.notifications.nextExpiryAt = new Date().toISOString(); + await renderAndWaitForSubscription(); + + await vi.waitFor(() => expect(mocks.stores.origin.notifications.fetch).toHaveBeenCalledOnce()); + }); + + it('uses the exact unread-group count for the installed-app badge', async () => { mocks.stores.origin.notifications.unreadNotificationCount = 2; await renderAndWaitForSubscription(); @@ -182,62 +189,49 @@ describe('NotificationSync', () => { ); }); - it('uses a flag for non-DM notifications', async () => { - mocks.stores.origin.notifications.notifications = [{ kind: 'mention' }]; + it('uses a numeric badge for every notification cause', async () => { mocks.stores.origin.notifications.unreadNotificationCount = 1; await renderAndWaitForSubscription(); await vi.waitFor(() => - expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'flag' }) + expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'count', count: 1 }) ); }); - it('counts only DMs when a complete page also contains other notifications', async () => { - mocks.stores.origin.notifications.notifications = [ - { kind: 'mention' }, - { kind: 'directMessage' }, - { kind: 'reply' } - ]; + it('uses the server aggregate independently of the bounded group page', async () => { mocks.stores.origin.notifications.unreadNotificationCount = 3; await renderAndWaitForSubscription(); await vi.waitFor(() => - expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'count', count: 1 }) + expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'count', count: 3 }) ); }); - it('aggregates exact DM counts across authenticated servers', async () => { + it('aggregates exact unread-group counts across authenticated servers', async () => { mocks.servers.push({ id: 'remote' }); - mocks.stores.origin.notifications.notifications = [{ kind: 'directMessage' }]; mocks.stores.origin.notifications.unreadNotificationCount = 1; - mocks.stores.remote.notifications.notifications = [ - { kind: 'directMessage' }, - { kind: 'mention' } - ]; mocks.stores.remote.notifications.unreadNotificationCount = 2; await renderAndWaitForSubscription(); await vi.waitFor(() => - expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'count', count: 2 }) + expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'count', count: 3 }) ); }); - it('uses a flag when a notification page is truncated', async () => { - mocks.stores.origin.notifications.notifications = [{ kind: 'directMessage' }]; + it('keeps an exact numeric badge when the group page is truncated', async () => { mocks.stores.origin.notifications.unreadNotificationCount = 3; await renderAndWaitForSubscription(); await vi.waitFor(() => - expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'flag' }) + expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'count', count: 3 }) ); }); it('reasserts the unchanged aggregate badge after a regular push', async () => { - mocks.stores.origin.notifications.notifications = [{ kind: 'directMessage' }]; mocks.stores.origin.notifications.unreadNotificationCount = 1; await renderAndWaitForSubscription(); await vi.waitFor(() => @@ -252,28 +246,30 @@ describe('NotificationSync', () => { ); }); - it('clears a legacy app badge once empty notification stores have loaded', async () => { + it('clears an existing app badge once empty notification stores have loaded', async () => { await renderAndWaitForSubscription(); - await vi.waitFor(() => - expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'clear' }) - ); + await vi.waitFor(() => expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'clear' })); + }); + + it('clears the app badge when Inbox contains only read notifications', async () => { + mocks.stores.origin.notifications.notifications = [{ kind: 'directMessage' }]; + + await renderAndWaitForSubscription(); + + await vi.waitFor(() => expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'clear' })); }); it('owns a zero badge while signed out and reasserts it after a push', async () => { mocks.stores.origin.isAuthenticated = false; render(NotificationSync); await vi.waitFor(() => expect(mocks.badgeRefreshHandlers.size).toBe(1)); - await vi.waitFor(() => - expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'clear' }) - ); + await vi.waitFor(() => expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'clear' })); mocks.updateAppBadge.mockClear(); for (const refresh of mocks.badgeRefreshHandlers) refresh(); - await vi.waitFor(() => - expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'clear' }) - ); + await vi.waitFor(() => expect(mocks.updateAppBadge).toHaveBeenCalledWith({ kind: 'clear' })); }); it('does not clear the app badge before notifications have loaded', async () => { diff --git a/apps/frontend/src/lib/components/settings/NotificationLevelSettings.svelte b/apps/frontend/src/lib/components/settings/NotificationLevelSettings.svelte deleted file mode 100644 index d972adce5..000000000 --- a/apps/frontend/src/lib/components/settings/NotificationLevelSettings.svelte +++ /dev/null @@ -1,422 +0,0 @@ - - - - - -{#if loading} -
{m('settings.notifications.levels.loading')}
-{:else if error} -
- -
-{:else} - -

- {m('settings.notifications.levels.server_description')} -

- -
- {#each serverLevelOptions as option (option.value)} - {@const isSelected = serverLevel === option.value} - handleServerLevelChange(option.value)} - /> - {/each} -
-
- - {#if rooms.length > 0} - -

- {m('settings.notifications.levels.room_description', { - level: levelLabel(serverEffectiveLevel) - })} -

- -
- {#each rooms as room (room.id)} - {@const isSaving = savingRoomId === room.id} -
-
-
- # - {room.name} -
- {#if room.level !== NotificationLevel.DEFAULT} -
- {m('settings.notifications.levels.effective', { - level: levelLabel(room.effectiveLevel) - })} -
- {/if} -
- -
- {/each} -
-
- {/if} -{/if} diff --git a/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte b/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte new file mode 100644 index 000000000..318152f0e --- /dev/null +++ b/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte @@ -0,0 +1,184 @@ + + + +

{m('settings.notifications.policy.description')}

+ + {#if error}{error}{/if} + {#if loading} +

{m('common.loading')}

+ {:else} +
+ {#each reasons as reason (reason)} + {@const preference = preferences.find((candidate) => candidate.reason === reason)} + + {/each} +
+ {/if} +
diff --git a/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte.spec.ts b/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte.spec.ts new file mode 100644 index 000000000..b1568d283 --- /dev/null +++ b/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte.spec.ts @@ -0,0 +1,117 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { loadLocaleMessages } from '$lib/i18n/messages'; +import { setReactiveLocale } from '$lib/i18n/state.svelte'; +import { NotificationDeliveryIntensity, NotificationReason } from '$lib/api-client/notifications'; + +const { mocks } = vi.hoisted(() => ({ + mocks: { + notifications: { + getPolicy: vi.fn(), + setPolicyPreference: vi.fn() + } + } +})); + +vi.mock('$lib/state/server/scope.svelte', () => ({ + useServerScope: () => ({ + store: { + notifications: mocks.notifications, + serverInfo: { name: 'Test Server' }, + navigation: { + rooms: [{ id: 'room-1', name: 'general', viewerIsMember: true }] + } + } + }) +})); + +import NotificationPolicySettings from './NotificationPolicySettings.svelte'; + +describe('NotificationPolicySettings', () => { + beforeEach(async () => { + vi.clearAllMocks(); + await loadLocaleMessages('en-GB'); + setReactiveLocale('en-GB'); + mocks.notifications.getPolicy.mockResolvedValue([ + { + reason: NotificationReason.DIRECT_MESSAGE, + serverIntensity: NotificationDeliveryIntensity.ALERT, + roomIntensity: NotificationDeliveryIntensity.UNSPECIFIED, + effectiveIntensity: NotificationDeliveryIntensity.ALERT + } + ]); + mocks.notifications.setPolicyPreference.mockRejectedValue(new Error('save rejected')); + }); + + it('shows only implemented causes and restores a rejected selection', async () => { + const { container } = render(NotificationPolicySettings); + const select = await vi.waitFor(() => { + const element = container.querySelector( + 'select[aria-label="Direct messages"]' + ) as HTMLSelectElement | null; + expect(element).not.toBeNull(); + return element!; + }); + + expect(container.querySelector('select[aria-label="Room invitations"]')).toBeNull(); + expect(select.value).toBe(String(NotificationDeliveryIntensity.ALERT)); + select.value = String(NotificationDeliveryIntensity.OFF); + select.dispatchEvent(new Event('change', { bubbles: true })); + + await vi.waitFor(() => { + expect(mocks.notifications.setPolicyPreference).toHaveBeenCalledWith( + NotificationReason.DIRECT_MESSAGE, + NotificationDeliveryIntensity.OFF, + undefined + ); + expect(select.value).toBe(String(NotificationDeliveryIntensity.ALERT)); + expect(container.textContent).toContain('save rejected'); + }); + }); + + it('loads, changes, and clears policy at room scope', async () => { + mocks.notifications.getPolicy.mockImplementation((roomId?: string) => + Promise.resolve([ + { + reason: NotificationReason.DIRECT_MESSAGE, + serverIntensity: NotificationDeliveryIntensity.ALERT, + roomIntensity: roomId + ? NotificationDeliveryIntensity.BADGE + : NotificationDeliveryIntensity.UNSPECIFIED, + effectiveIntensity: roomId + ? NotificationDeliveryIntensity.BADGE + : NotificationDeliveryIntensity.ALERT + } + ]) + ); + mocks.notifications.setPolicyPreference.mockResolvedValue([]); + const { container } = render(NotificationPolicySettings); + const scope = await vi.waitFor(() => { + const element = container.querySelector( + 'select[aria-label="Notification policy"]' + ) as HTMLSelectElement | null; + expect(element).not.toBeNull(); + return element!; + }); + scope.value = 'room-1'; + scope.dispatchEvent(new Event('change', { bubbles: true })); + + await vi.waitFor(() => { + expect(mocks.notifications.getPolicy).toHaveBeenCalledWith('room-1'); + }); + const directMessages = container.querySelector( + 'select[aria-label="Direct messages"]' + ) as HTMLSelectElement; + expect(directMessages.value).toBe(String(NotificationDeliveryIntensity.BADGE)); + directMessages.value = String(NotificationDeliveryIntensity.UNSPECIFIED); + directMessages.dispatchEvent(new Event('change', { bubbles: true })); + + await vi.waitFor(() => { + expect(mocks.notifications.setPolicyPreference).toHaveBeenCalledWith( + NotificationReason.DIRECT_MESSAGE, + NotificationDeliveryIntensity.UNSPECIFIED, + 'room-1' + ); + }); + }); +}); diff --git a/apps/frontend/src/lib/hooks/usePageTitle.svelte.spec.ts b/apps/frontend/src/lib/hooks/usePageTitle.svelte.spec.ts index df8c1fe3c..d3c9cc3b1 100644 --- a/apps/frontend/src/lib/hooks/usePageTitle.svelte.spec.ts +++ b/apps/frontend/src/lib/hooks/usePageTitle.svelte.spec.ts @@ -10,7 +10,7 @@ const mocks = vi.hoisted(() => ({ { isAuthenticated: boolean; serverInfo: { name: string }; - notifications: { count: number }; + notifications: { unreadNotificationCount: number }; } >() })); @@ -33,11 +33,11 @@ vi.mock('$lib/state/server/registry.svelte', () => ({ import { usePageTitle } from './usePageTitle.svelte'; -function store(name: string, count = 0, isAuthenticated = true) { +function store(name: string, unreadNotificationCount = 0, isAuthenticated = true) { return { isAuthenticated, serverInfo: { name }, - notifications: { count } + notifications: { unreadNotificationCount } }; } @@ -107,7 +107,7 @@ describe('usePageTitle', () => { cleanup(); }); - it('prefixes authenticated notification counts across servers', () => { + it('prefixes authenticated unread notification counts across servers', () => { setServers([ { id: 'origin', name: 'Chatto Test', count: 2, origin: true }, { id: 'remote', name: 'Remote', count: 3 }, diff --git a/apps/frontend/src/lib/hooks/usePageTitle.svelte.ts b/apps/frontend/src/lib/hooks/usePageTitle.svelte.ts index 11646f81d..acfe0f3ab 100644 --- a/apps/frontend/src/lib/hooks/usePageTitle.svelte.ts +++ b/apps/frontend/src/lib/hooks/usePageTitle.svelte.ts @@ -19,7 +19,7 @@ export function usePageTitle(): () => string { const totalCount = serverRegistry.servers.reduce((sum, instance) => { const store = serverRegistry.getStore(instance.id); if (!store.isAuthenticated) return sum; - return sum + store.notifications.count; + return sum + store.notifications.unreadNotificationCount; }, 0); return totalCount > 0 ? `(${totalCount}) ${base}` : base; diff --git a/apps/frontend/src/lib/query/settings.ts b/apps/frontend/src/lib/query/settings.ts index 81527daab..f6575aa43 100644 --- a/apps/frontend/src/lib/query/settings.ts +++ b/apps/frontend/src/lib/query/settings.ts @@ -10,8 +10,5 @@ export const settingsQueryKeys = { root: settingsRoot, externalIdentities(serverId: string, connection: SettingsQueryConnection) { return [...settingsRoot(serverId, connection), 'external-identities'] as const; - }, - notificationPreferences(serverId: string, connection: SettingsQueryConnection) { - return [...settingsRoot(serverId, connection), 'notification-preferences'] as const; } }; diff --git a/apps/frontend/src/lib/realtimeEventMapper.spec.ts b/apps/frontend/src/lib/realtimeEventMapper.spec.ts deleted file mode 100644 index e231eff0e..000000000 --- a/apps/frontend/src/lib/realtimeEventMapper.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Timestamp } from '@bufbuild/protobuf'; -import { describe, expect, it } from 'vitest'; - -import { realtimeEventToEventEnvelope } from '$lib/realtimeEventMapper'; -import { TransientEventKind } from '$lib/realtimeEvents'; -import { - RealtimeEventEnvelope, - RealtimeMentionNotificationEvent, - RealtimeNewDirectMessageNotificationEvent -} from '@chatto/api-types/realtime/v1/realtime_pb'; - -describe('realtimeEventToEventEnvelope', () => { - it('preserves mention notification display data', () => { - const event = realtimeEventToEventEnvelope( - new RealtimeEventEnvelope({ - id: 'evt-mention', - createdAt: Timestamp.now(), - actorId: 'user-1', - event: { - case: 'mentionNotification', - value: new RealtimeMentionNotificationEvent({ - roomId: 'room-1', - actorUserId: 'user-1', - roomName: 'General', - actorDisplayName: 'Ada Lovelace' - }) - } - }) - ) as unknown as { - event: { - kind: string; - roomName: string; - actorUserId: string; - actorDisplayName: string; - }; - }; - - expect(event.event.kind).toBe(TransientEventKind.MentionNotification); - expect(event.event.roomName).toBe('General'); - expect(event.event.actorUserId).toBe('user-1'); - expect(event.event.actorDisplayName).toBe('Ada Lovelace'); - }); - - it('preserves DM notification display data', () => { - const event = realtimeEventToEventEnvelope( - new RealtimeEventEnvelope({ - id: 'evt-dm', - createdAt: Timestamp.now(), - actorId: 'user-2', - event: { - case: 'newDirectMessageNotification', - value: new RealtimeNewDirectMessageNotificationEvent({ - roomId: 'dm-1', - senderId: 'user-2', - senderDisplayName: 'Grace Hopper', - senderAvatarUrl: '/assets/avatar.png', - conversationName: 'Grace Hopper' - }) - } - }) - ) as unknown as { - event: { - kind: string; - conversationName: string; - senderId: string; - senderDisplayName: string; - senderAvatarUrl: string; - }; - }; - - expect(event.event.kind).toBe(TransientEventKind.NewDirectMessageNotification); - expect(event.event.conversationName).toBe('Grace Hopper'); - expect(event.event.senderId).toBe('user-2'); - expect(event.event.senderDisplayName).toBe('Grace Hopper'); - expect(event.event.senderAvatarUrl).toBe('/assets/avatar.png'); - }); -}); diff --git a/apps/frontend/src/lib/realtimeEventMapper.ts b/apps/frontend/src/lib/realtimeEventMapper.ts index ba30ad5fe..6b1c103ec 100644 --- a/apps/frontend/src/lib/realtimeEventMapper.ts +++ b/apps/frontend/src/lib/realtimeEventMapper.ts @@ -1,9 +1,6 @@ import { RealtimeEventEnvelope } from '@chatto/api-types/realtime/v1/realtime_pb'; import { presenceStatusOrOffline } from '$lib/api-client/enumDefaults'; -import { - TransientEventKind, - type TransientEventEnvelope -} from '$lib/realtimeEvents'; +import { TransientEventKind, type TransientEventEnvelope } from '$lib/realtimeEvents'; function timestampToISO(value: { toDate(): Date } | undefined): string { return value?.toDate().toISOString() ?? new Date().toISOString(); @@ -39,35 +36,6 @@ export function realtimeEventToEventEnvelope( status: presenceStatusOrOffline(frame.event.value.status) } }; - case 'mentionNotification': { - const value = frame.event.value; - return { - ...base, - actorId: value.actorUserId || base.actorId, - event: { - kind: TransientEventKind.MentionNotification, - roomId: value.roomId, - actorUserId: value.actorUserId, - actorDisplayName: value.actorDisplayName ?? 'Unknown user', - roomName: value.roomName ?? '' - } - }; - } - case 'newDirectMessageNotification': { - const value = frame.event.value; - return { - ...base, - actorId: value.senderId || base.actorId, - event: { - kind: TransientEventKind.NewDirectMessageNotification, - roomId: value.roomId, - senderId: value.senderId, - senderDisplayName: value.senderDisplayName ?? 'Unknown user', - senderAvatarUrl: value.senderAvatarUrl ?? '', - conversationName: value.conversationName ?? '' - } - }; - } case 'sessionTerminated': return { ...base, diff --git a/apps/frontend/src/lib/realtimeEvents.ts b/apps/frontend/src/lib/realtimeEvents.ts index 55326109a..d8103e9ec 100644 --- a/apps/frontend/src/lib/realtimeEvents.ts +++ b/apps/frontend/src/lib/realtimeEvents.ts @@ -4,8 +4,6 @@ import type { PresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; * Transient realtime signals delivered outside durable room timelines. */ export const TransientEventKind = { - MentionNotification: 'mentionNotification', - NewDirectMessageNotification: 'newDirectMessageNotification', PresenceChanged: 'presenceChanged', SessionTerminated: 'sessionTerminated', UserTyping: 'userTyping' @@ -14,21 +12,6 @@ export const TransientEventKind = { export type TransientEventKind = (typeof TransientEventKind)[keyof typeof TransientEventKind]; export type TransientEventPayload = - | { - kind: typeof TransientEventKind.MentionNotification; - roomId: string; - actorUserId: string; - actorDisplayName: string; - roomName: string; - } - | { - kind: typeof TransientEventKind.NewDirectMessageNotification; - roomId: string; - senderId: string; - senderDisplayName: string; - senderAvatarUrl: string; - conversationName: string; - } | { kind: typeof TransientEventKind.PresenceChanged; status: PresenceStatus } | { kind: typeof TransientEventKind.SessionTerminated; reason: string } | { diff --git a/apps/frontend/src/lib/state/server/eventBus.svelte.spec.ts b/apps/frontend/src/lib/state/server/eventBus.svelte.spec.ts index 9453650b1..55566d98a 100644 --- a/apps/frontend/src/lib/state/server/eventBus.svelte.spec.ts +++ b/apps/frontend/src/lib/state/server/eventBus.svelte.spec.ts @@ -9,7 +9,6 @@ import { RealtimeCaughtUp, RealtimeError, RealtimeHeartbeat, - RealtimeMentionNotificationEvent, RealtimeServerFrame, RealtimeServerHello, RealtimeTypingEvent, @@ -189,26 +188,6 @@ function heartbeatFrame(): RealtimeServerFrame { }); } -function mentionNotificationFrame(): RealtimeServerFrame { - return serverFrame({ - case: 'event', - value: new RealtimeEventEnvelope({ - id: 'evt-mention', - createdAt: Timestamp.now(), - actorId: 'user-1', - event: { - case: 'mentionNotification', - value: new RealtimeMentionNotificationEvent({ - roomId: 'room-1', - actorUserId: 'user-1', - actorDisplayName: 'Ada Lovelace', - roomName: 'General' - }) - } - }) - }); -} - async function startAndSubscribe(fake = new FakeServerConnection()): Promise<{ fake: FakeServerConnection; socket: FakeRealtimeSocket; @@ -665,29 +644,6 @@ describe('eventBusManager realtime transport', () => { expect(sockets).toHaveLength(2); }); - it('preserves transient event display data in dispatched envelopes', async () => { - const { socket } = await startAndSubscribe(); - const handler = vi.fn(); - eventBusManager.getBus(TEST_SERVER)!.handlers.add(handler); - - await socket.receive(mentionNotificationFrame()); - - const dispatched = handler.mock.calls[0]?.[0]; - expect(dispatched).toEqual( - expect.objectContaining({ - event: expect.objectContaining({ - kind: TransientEventKind.MentionNotification - }) - }) - ); - expect(dispatched.event).toEqual( - expect.objectContaining({ - actorDisplayName: 'Ada Lovelace', - roomName: 'General' - }) - ); - }); - it('isolates handler errors so one throwing handler does not stop the others', async () => { const { socket } = await startAndSubscribe(); const ranBefore = vi.fn(); diff --git a/apps/frontend/src/lib/state/server/notificationLevel.svelte.ts b/apps/frontend/src/lib/state/server/notificationLevel.svelte.ts deleted file mode 100644 index eed995caa..000000000 --- a/apps/frontend/src/lib/state/server/notificationLevel.svelte.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { NotificationLevel } from '@chatto/api-types/api/v1/notification_preferences_pb'; -/** - * Server-level and per-room notification level preferences. - * - * Tracks the viewer's notification level for the server and individual rooms. - * Used by the room list to suppress unread indicators for muted rooms, and - * by the preferences page to display and edit settings. - * - * Post-PR(b) the API surface no longer carries `spaceId`, so the previous - * "per-space" dimension collapses to a single server-level preference. - */ - -import { SvelteMap } from 'svelte/reactivity'; - -export class NotificationLevelStore { - /** Server-level preference. */ - private server = $state<{ level: NotificationLevel; effectiveLevel: NotificationLevel }>({ - level: NotificationLevel.DEFAULT, - effectiveLevel: NotificationLevel.NORMAL - }); - - /** Room-level preferences: roomId -> { level, effectiveLevel } */ - private roomLevels = new SvelteMap< - string, - { level: NotificationLevel; effectiveLevel: NotificationLevel } - >(); - - /** - * Set the viewer's server-level notification preference. - */ - setServerPreference(level: NotificationLevel, effectiveLevel: NotificationLevel): void { - this.server = { level, effectiveLevel }; - } - - /** - * Set the viewer's notification preference for a room. - */ - setRoomPreference( - roomId: string, - level: NotificationLevel, - effectiveLevel: NotificationLevel - ): void { - this.roomLevels.set(roomId, { level, effectiveLevel }); - } - - /** - * Get the viewer's server-level notification preference. - * Returns DEFAULT/NORMAL if not set. - */ - getServerPreference(): { level: NotificationLevel; effectiveLevel: NotificationLevel } { - return this.server; - } - - /** - * Get the viewer's notification preference for a room. - * Returns DEFAULT with the server's effective level if not set. - */ - getRoomPreference(roomId: string): { - level: NotificationLevel; - effectiveLevel: NotificationLevel; - } { - const roomPref = this.roomLevels.get(roomId); - if (roomPref) return roomPref; - return { - level: NotificationLevel.DEFAULT, - effectiveLevel: this.server.effectiveLevel - }; - } - - /** - * Get the effective notification level for a room. - * Resolves: room-level -> server-level -> NORMAL. - */ - getEffectiveLevel(roomId: string): NotificationLevel { - return this.getRoomPreference(roomId).effectiveLevel; - } - - /** - * Check if a room is muted (no notifications, no unread markers). - */ - isRoomMuted(roomId: string): boolean { - return this.getEffectiveLevel(roomId) === NotificationLevel.MUTED; - } - - /** - * Check if the server is fully muted (server-level muted, no room overrides). - */ - isServerMuted(): boolean { - return this.server.effectiveLevel === NotificationLevel.MUTED; - } - - /** - * Clear all preferences. Called on logout. - */ - clear(): void { - this.server = { - level: NotificationLevel.DEFAULT, - effectiveLevel: NotificationLevel.NORMAL - }; - this.roomLevels.clear(); - } -} diff --git a/apps/frontend/src/lib/state/server/notifications.spec.ts b/apps/frontend/src/lib/state/server/notifications.spec.ts index 57cb6dbae..2d387867a 100644 --- a/apps/frontend/src/lib/state/server/notifications.spec.ts +++ b/apps/frontend/src/lib/state/server/notifications.spec.ts @@ -6,19 +6,28 @@ import { } from './notifications.svelte'; import { NotificationItemKind, + NotificationDeliveryIntensity, + NotificationReason, type NotificationAPI, - type NotificationPage + type NotificationGroupPage } from '$lib/api-client/notifications'; type MockNotificationAPI = NotificationAPI & { - listNotifications: ReturnType; - listRoomNotifications: ReturnType; - listRoomNotificationCounts: ReturnType; - dismissNotification: ReturnType; - dismissAllNotifications: ReturnType; + listNotificationGroups: ReturnType; + markNotificationRead: ReturnType; + deleteNotificationGroup: ReturnType; + deleteAllNotificationOccurrences: ReturnType; + getNotificationPolicy: ReturnType; + setNotificationPolicyPreference: ReturnType; }; -function page(items: NotificationItem[], totalCount = items.length): NotificationPage { +type FlatNotificationPage = { + items: NotificationItem[]; + totalCount: number; + hasMore: boolean; +}; + +function page(items: NotificationItem[], totalCount = items.length): FlatNotificationPage { return { items, totalCount, @@ -26,44 +35,73 @@ function page(items: NotificationItem[], totalCount = items.length): Notificatio }; } +function groupPage(source: FlatNotificationPage): NotificationGroupPage { + const groups = source.items.map((item) => { + const target = notificationTarget(item); + const occurrence = { + id: item.id, + sourceEventId: item.id, + createdAt: item.createdAt, + actor: item.actor ?? null, + room: target.roomId ? { id: target.roomId, name: target.roomName ?? '' } : null, + eventId: target.eventId ?? '', + threadRootId: target.threadRootId, + parentEventId: null, + reasons: [NotificationReason.DIRECT_MENTION], + reasonMatches: [ + { + reason: NotificationReason.DIRECT_MENTION, + intensity: NotificationDeliveryIntensity.ALERT + } + ], + unread: true + }; + return { + id: `group-${item.id}`, + occurrences: [occurrence], + openTarget: occurrence, + unread: true, + occurrenceCount: 1, + latestAt: item.createdAt, + reasons: [NotificationReason.DIRECT_MENTION] + }; + }); + return { + groups, + unreadGroupCount: source.totalCount, + roomUnreadGroupCounts: {}, + totalCount: source.totalCount, + hasMore: source.hasMore + }; +} + function deferred() { let resolve!: (value: T | PromiseLike) => void; - const promise = new Promise((res) => { + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { resolve = res; + reject = rej; }); - return { promise, resolve }; + return { promise, resolve, reject }; } function makeAPI( options: { - notifications?: NotificationPage; - roomNotifications?: NotificationPage; + notifications?: FlatNotificationPage; notificationsError?: Error; - roomNotificationsError?: Error; - dismissNotification?: (notificationId: string) => Promise | boolean; - dismissAllNotifications?: () => Promise | number; } = {} ): MockNotificationAPI { return { - listNotifications: vi.fn().mockImplementation(async () => { + listNotificationGroups: vi.fn().mockImplementation(async () => { if (options.notificationsError) throw options.notificationsError; - return options.notifications ?? page([]); + return groupPage(options.notifications ?? page([])); }), - listRoomNotifications: vi.fn().mockImplementation(async () => { - if (options.roomNotificationsError) throw options.roomNotificationsError; - return options.roomNotifications ?? page([]); - }), - listRoomNotificationCounts: vi.fn().mockResolvedValue({}), - dismissNotification: vi - .fn() - .mockImplementation(async (notificationId: string) => - options.dismissNotification ? options.dismissNotification(notificationId) : true - ), - dismissAllNotifications: vi - .fn() - .mockImplementation(async () => - options.dismissAllNotifications ? options.dismissAllNotifications() : 0 - ) + markNotificationRead: vi.fn().mockResolvedValue(undefined), + deleteNotificationOccurrence: vi.fn().mockResolvedValue(false), + deleteNotificationGroup: vi.fn().mockResolvedValue(0), + deleteAllNotificationOccurrences: vi.fn().mockResolvedValue(0), + getNotificationPolicy: vi.fn().mockResolvedValue([]), + setNotificationPolicyPreference: vi.fn().mockResolvedValue([]) }; } @@ -94,7 +132,7 @@ describe('NotificationStore', () => { it('invalidates projection state during reset', () => { const store = new NotificationStore(makeAPI()); - store.replaceProjection(page([mention('n1')], 3)); + store.replaceGroupProjection(groupPage(page([mention('n1')], 3))); store.resetProjectionState(); @@ -106,22 +144,28 @@ describe('NotificationStore', () => { it('scrubs deleted notification actors and actor-derived summaries', () => { const store = new NotificationStore(makeAPI()); - store.replaceProjection(page([mention('n1')])); + store.replaceGroupProjection(groupPage(page([mention('n1')]))); store.scrubUser('a'); expect(store.notifications[0]?.actor).toBeNull(); expect(store.notifications[0]?.summary).not.toContain('Tester'); + expect(store.groups[0]?.occurrences[0]?.actor).toBeNull(); + expect(store.groups[0]?.openTarget?.actor).toBeNull(); }); it('clears room notification payloads at an authorization boundary', () => { - const other = { ...mention('n2'), mentionRoom: { id: 'r2', name: 'other' } } as NotificationItem; + const other = { + ...mention('n2'), + mentionRoom: { id: 'r2', name: 'other' } + } as NotificationItem; const store = new NotificationStore(makeAPI()); - store.replaceProjection(page([mention('n1'), other], 2)); + store.replaceGroupProjection(groupPage(page([mention('n1'), other], 2))); store.clearRoom('r1'); expect(store.notifications.map(({ id }) => id)).toEqual(['n2']); + expect(store.groups.map(({ id }) => id)).toEqual(['group-n2']); expect(store.unreadNotificationCount).toBe(1); }); @@ -135,58 +179,74 @@ describe('NotificationStore', () => { expect(store.hasLoaded).toBe(true); }); + it('keeps read groups without exposing them as unread room indicators', () => { + const store = new NotificationStore(makeAPI()); + const response = groupPage(page([mention('n1')])); + response.groups[0]!.unread = false; + response.groups[0]!.occurrences[0]!.unread = false; + response.unreadGroupCount = 0; + + store.replaceGroupProjection(response); + + expect(store.groups).toHaveLength(1); + expect(store.notifications).toEqual([]); + expect(store.unreadNotificationCount).toBe(0); + }); + it('discards an older full-list response that arrives after a newer response', async () => { - const older = deferred(); - const newer = deferred(); + const older = deferred(); + const newer = deferred(); const api = makeAPI(); - api.listNotifications.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise); + api.listNotificationGroups + .mockReturnValueOnce(older.promise) + .mockReturnValueOnce(newer.promise); const store = new NotificationStore(api); const olderFetch = store.fetch(); const newerFetch = store.fetch(); - newer.resolve(page([mention('newer')])); + newer.resolve(groupPage(page([mention('newer')]))); await newerFetch; - older.resolve(page([mention('older')])); + older.resolve(groupPage(page([mention('older')]))); await olderFetch; expect(store.notifications.map((notification) => notification.id)).toEqual(['newer']); }); - it('does not let an in-flight fetch restore an optimistically dismissed notification', async () => { - const response = deferred(); + it('does not let an in-flight fetch restore an optimistically read notification', async () => { + const response = deferred(); const api = makeAPI(); - api.listNotifications.mockReturnValueOnce(response.promise); + api.listNotificationGroups.mockReturnValueOnce(response.promise); const store = new NotificationStore(api); store.notifications = [mention('dismiss-me')]; store.unreadNotificationCount = 1; const fetch = store.fetch(); - await store.dismiss('dismiss-me'); - response.resolve(page([mention('dismiss-me')])); + await store.markRead('dismiss-me'); + response.resolve(groupPage(page([mention('dismiss-me')]))); await fetch; - expect(api.listNotifications).toHaveBeenCalledTimes(2); + expect(api.listNotificationGroups).toHaveBeenCalledTimes(2); expect(store.notifications).toEqual([]); expect(store.unreadNotificationCount).toBe(0); }); it('fetchRoomNotification returns the newest room-scoped notification and caches it', async () => { const roomMention = mention('room-mention'); - const store = new NotificationStore(makeAPI({ roomNotifications: page([roomMention], 4) })); + const store = new NotificationStore(makeAPI({ notifications: page([roomMention]) })); const result = await store.fetchRoomNotification('r1'); - expect(result).toEqual({ + expect(result).toMatchObject({ ok: true, - totalCount: 4, - notification: roomMention + totalCount: 1, + notification: { id: roomMention.id } }); expect(store.notifications.map((n) => n.id)).toEqual(['room-mention']); }); it('fetchRoomNotification reports an empty room-scoped notification result', async () => { - const store = new NotificationStore(makeAPI({ roomNotifications: page([], 0) })); + const store = new NotificationStore(makeAPI({ notifications: page([], 0) })); const result = await store.fetchRoomNotification('r1'); @@ -200,7 +260,7 @@ describe('NotificationStore', () => { it('resolveRoomNotification uses the cached room notification before querying', async () => { const cached = mention('cached'); - const api = makeAPI({ roomNotifications: page([mention('remote')], 1) }); + const api = makeAPI({ notifications: page([mention('remote')], 1) }); const store = new NotificationStore(api); store.notifications = [cached]; @@ -211,7 +271,90 @@ describe('NotificationStore', () => { totalCount: null, notification: cached }); - expect(api.listRoomNotifications).not.toHaveBeenCalled(); + expect(api.listNotificationGroups).not.toHaveBeenCalled(); + }); + + it('returns one page for automatic UI pagination', async () => { + const first = groupPage(page([mention('first')], 2)); + first.hasMore = true; + const api = makeAPI(); + api.listNotificationGroups.mockResolvedValueOnce(first); + const store = new NotificationStore(api); + + const result = await store.fetchPage(50); + + expect(result.groups.map(({ id }) => id)).toEqual(['group-first']); + expect(result.hasMore).toBe(true); + expect(api.listNotificationGroups).toHaveBeenCalledWith(50, 50); + }); + + it('leaves post-mutation list reconciliation to the realtime replacement', async () => { + const api = makeAPI(); + const store = new NotificationStore(api); + + await store.deleteGroup('group-1'); + await store.markOccurrenceRead('notification-1'); + + expect(api.deleteNotificationGroup).toHaveBeenCalledWith('group-1'); + expect(api.markNotificationRead).toHaveBeenCalledWith('notification-1'); + expect(api.listNotificationGroups).not.toHaveBeenCalled(); + }); + + it('deletes a group optimistically and restores it when the server rejects the mutation', async () => { + const mutation = deferred(); + const api = makeAPI(); + api.deleteNotificationGroup.mockReturnValueOnce(mutation.promise); + const store = new NotificationStore(api); + store.replaceGroupProjection(groupPage(page([mention('n1')]))); + + const deletion = store.deleteGroup('group-n1'); + + expect(store.groups).toEqual([]); + expect(store.notifications).toEqual([]); + expect(store.unreadNotificationCount).toBe(0); + + mutation.reject(new Error('offline')); + await expect(deletion).rejects.toThrow('offline'); + expect(store.groups.map(({ id }) => id)).toEqual(['group-n1']); + expect(store.notifications.map(({ id }) => id)).toEqual(['n1']); + expect(store.unreadNotificationCount).toBe(1); + }); + + it('does not issue a replacement list request while deleting a group', async () => { + const list = deferred(); + const mutation = deferred(); + const api = makeAPI(); + api.listNotificationGroups.mockReturnValueOnce(list.promise); + api.deleteNotificationGroup.mockReturnValueOnce(mutation.promise); + const store = new NotificationStore(api); + store.replaceGroupProjection(groupPage(page([mention('n1')]))); + + const fetch = store.fetch(); + const deletion = store.deleteGroup('group-n1'); + list.resolve(groupPage(page([mention('n1')]))); + mutation.resolve(1); + await Promise.all([fetch, deletion]); + + expect(api.listNotificationGroups).toHaveBeenCalledTimes(1); + expect(store.groups).toEqual([]); + }); + + it('deletes all occurrences optimistically', async () => { + const mutation = deferred(); + const api = makeAPI(); + api.deleteAllNotificationOccurrences.mockReturnValueOnce(mutation.promise); + const store = new NotificationStore(api); + store.replaceGroupProjection(groupPage(page([mention('n1'), mention('n2')], 2))); + + const deletion = store.deleteAllOccurrences(); + + expect(store.groups).toEqual([]); + expect(store.notifications).toEqual([]); + expect(store.unreadNotificationCount).toBe(0); + + mutation.resolve(2); + await deletion; + expect(api.deleteAllNotificationOccurrences).toHaveBeenCalledTimes(1); }); it('normalizes the room, thread, and event used by push payloads', () => { @@ -261,7 +404,8 @@ describe('NotificationStore', () => { }, summary: 'posted a message', roomMsgRoom: { id: 'room-news', name: 'news' }, - roomMsgEventId: 'room-event' + roomMsgEventId: 'room-event', + roomMsgThreadRootId: 'thread-root' } as unknown as NotificationItem; expect(notificationTarget(threadMention)).toMatchObject({ @@ -278,7 +422,7 @@ describe('NotificationStore', () => { expect(notificationTarget(roomMessage)).toMatchObject({ roomId: 'room-news', eventId: 'room-event', - threadRootId: null + threadRootId: 'thread-root' }); }); diff --git a/apps/frontend/src/lib/state/server/notifications.svelte.ts b/apps/frontend/src/lib/state/server/notifications.svelte.ts index 1de4949ce..56bfc339b 100644 --- a/apps/frontend/src/lib/state/server/notifications.svelte.ts +++ b/apps/frontend/src/lib/state/server/notifications.svelte.ts @@ -3,9 +3,15 @@ import { resolve } from '$app/paths'; import { serverIdToSegment } from '$lib/navigation'; import { NotificationItemKind, + NotificationDeliveryIntensity, + occurrenceAsNotificationItem, type DirectMessageNotificationItem, type MentionNotificationItem, type NotificationAPI, + type NotificationGroupItem, + type NotificationGroupPage, + type NotificationPolicyItem, + type NotificationReason, type NotificationItem, type ReplyNotificationItem, type RoomMessageNotificationItem @@ -71,7 +77,7 @@ export function notificationTarget(n: NotificationItem): NotificationTarget { isDM: true, roomId: n.room.id, roomName: null, - eventId: null, + eventId: n.eventId ?? null, threadRootId: null }; } @@ -99,7 +105,7 @@ export function notificationTarget(n: NotificationItem): NotificationTarget { roomId: n.roomMsgRoom?.id ?? null, roomName: n.roomMsgRoom?.name ?? null, eventId: n.roomMsgEventId ?? null, - threadRootId: null + threadRootId: n.roomMsgThreadRootId ?? null }; } return { @@ -117,10 +123,13 @@ export function notificationTarget(n: NotificationItem): NotificationTarget { */ export class NotificationStore { #api: NotificationAPI; - #locallyDismissedNotificationIds = new SvelteSet(); #fetchGeneration = 0; notifications = $state([]); + groups = $state.raw([]); unreadNotificationCount = $state(0); + nextExpiryAt = $state(null); + /** Advances only for realtime invalidations, including changes made in another session. */ + viewInvalidationVersion = $state(0); loading = $state(false); hasLoaded = $state(false); error = $state(null); @@ -137,27 +146,35 @@ export class NotificationStore { this.unreadNotificationCount = Math.max(0, count); } - /** Replace the finite current page carried by the realtime projection. */ - replaceProjection(page: { items: NotificationItem[]; totalCount: number }): void { + /** Replace notification state from the realtime projection. */ + replaceGroupProjection(page: NotificationGroupPage): void { this.#fetchGeneration++; - const notifications = page.items.filter( - (notification) => !this.#locallyDismissedNotificationIds.has(notification.id) - ); - this.notifications = notifications; - this.unreadNotificationCount = Math.max( - 0, - page.totalCount - (page.items.length - notifications.length) - ); + this.groups = page.groups; + this.notifications = page.groups + .flatMap((group) => + group.occurrences.filter((occurrence) => occurrence.unread && group.unread) + ) + .map(occurrenceAsNotificationItem) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 50); + this.unreadNotificationCount = page.unreadGroupCount; + this.nextExpiryAt = page.nextExpiryAt ?? null; this.loading = false; this.hasLoaded = true; this.error = null; } + invalidateViews(): void { + this.viewInvalidationVersion++; + } + /** Invalidate projection-owned state while a compacted reset hydrates. */ resetProjectionState(): void { this.#fetchGeneration++; this.notifications = []; + this.groups = []; this.unreadNotificationCount = 0; + this.nextExpiryAt = null; this.loading = true; // The empty reset boundary is already authoritative. Keep this true so // badge synchronisation clears stale native notification counts even when @@ -179,17 +196,55 @@ export class NotificationStore { }; }); if (changed) this.notifications = notifications; + + let groupsChanged = false; + const groups = this.groups.map((group) => { + let groupChanged = false; + const occurrences = group.occurrences.map((occurrence) => { + if (occurrence.actor?.id !== userId) return occurrence; + groupChanged = true; + groupsChanged = true; + return { + ...occurrence, + actor: null + }; + }); + if (!groupChanged) return group; + return { + ...group, + occurrences, + openTarget: + occurrences.find((occurrence) => occurrence.id === group.openTarget?.id) ?? + occurrences[0] ?? + null + }; + }); + if (groupsChanged) this.groups = groups; } /** Drop notification payloads for a room at an authorization boundary. */ clearRoom(roomId: string): void { + const originalGroups = this.groups; + const groups = originalGroups.filter( + (group) => !group.occurrences.some((occurrence) => occurrence.room?.id === roomId) + ); + const removedUnreadGroups = originalGroups.filter( + (group) => + group.unread && group.occurrences.some((occurrence) => occurrence.room?.id === roomId) + ).length; + if (groups.length !== originalGroups.length) this.groups = groups; + const notifications = this.notifications.filter( (notification) => notificationTarget(notification).roomId !== roomId ); const removed = this.notifications.length - notifications.length; - if (removed === 0) return; - this.notifications = notifications; - this.unreadNotificationCount = Math.max(0, this.unreadNotificationCount - removed); + if (removed > 0) this.notifications = notifications; + if (removedUnreadGroups > 0) { + this.unreadNotificationCount = Math.max( + 0, + this.unreadNotificationCount - removedUnreadGroups + ); + } } /** @@ -199,20 +254,17 @@ export class NotificationStore { get threadsWithNotifications(): SvelteSet { const threadIds = new SvelteSet(); for (const n of this.notifications) { - if (isReplyNotification(n) && n.replyInThread) { - threadIds.add(n.replyInThread); - } + const threadRootId = notificationTarget(n).threadRootId; + if (threadRootId) threadIds.add(threadRootId); } return threadIds; } /** - * Check if a specific thread has pending notifications. + * Check if a specific thread has unread notification occurrences. */ hasThreadNotification(threadRootId: string): boolean { - return this.notifications.some( - (n) => isReplyNotification(n) && n.replyInThread === threadRootId - ); + return this.notifications.some((n) => notificationTarget(n).threadRootId === threadRootId); } /** @@ -264,7 +316,7 @@ export class NotificationStore { } /** - * Check if a specific DM conversation has pending notifications. + * Check if a specific DM conversation has unread notification occurrences. * Counterpart to {@link hasRoomNotification}, which excludes DMs. */ hasDMRoomNotification(roomId: string): boolean { @@ -302,16 +354,10 @@ export class NotificationStore { this.error = null; try { - const page = await this.#api.listNotifications(50); + const page = await this.#api.listNotificationGroups(50); if (generation !== this.#fetchGeneration) return; - const notifications = page.items.filter( - (notification) => !this.#locallyDismissedNotificationIds.has(notification.id) - ); - const locallyDismissedPageItems = page.items.length - notifications.length; - this.notifications = notifications; - this.unreadNotificationCount = Math.max(0, page.totalCount - locallyDismissedPageItems); - this.hasLoaded = true; + this.replaceGroupProjection(page); } catch (e) { if (generation !== this.#fetchGeneration) return; this.error = e instanceof Error ? e.message : 'Failed to fetch notifications'; @@ -323,24 +369,130 @@ export class NotificationStore { } } + async fetchPage(offset = 0): Promise { + const page = await this.#api.listNotificationGroups(50, offset); + if (offset === 0) this.replaceGroupProjection(page); + return page; + } + + async markOccurrenceRead(notificationId: string): Promise { + await this.#api.markNotificationRead(notificationId); + } + + /** Delete one group optimistically, restoring it if the server rejects the mutation. */ + async deleteGroup(groupId: string): Promise { + const originalGroups = this.groups; + const originalNotifications = this.notifications; + const originalCount = this.unreadNotificationCount; + const originalNextExpiryAt = this.nextExpiryAt; + const removed = this.groups.find((group) => group.id === groupId); + // Supersede stale list work without starting a second list request. The + // worker's realtime replacement performs authoritative reconciliation. + this.#fetchGeneration++; + this.loading = false; + const mutationGeneration = this.#fetchGeneration; + if (removed) { + const removedOccurrenceIds = new SvelteSet( + removed.occurrences.map((occurrence) => occurrence.id) + ); + this.groups = this.groups.filter((group) => group.id !== groupId); + this.notifications = this.notifications.filter( + (notification) => !removedOccurrenceIds.has(notification.id) + ); + if (removed.unread) { + this.unreadNotificationCount = Math.max(0, this.unreadNotificationCount - 1); + } + this.nextExpiryAt = earliestNotificationGroupExpiry(this.groups); + } + + try { + await this.#api.deleteNotificationGroup(groupId); + } catch (error) { + if (this.#fetchGeneration === mutationGeneration) { + this.groups = originalGroups; + this.notifications = originalNotifications; + this.unreadNotificationCount = originalCount; + this.nextExpiryAt = originalNextExpiryAt; + } + throw error; + } + } + + /** Delete every current occurrence optimistically for this server. */ + async deleteAllOccurrences(): Promise { + const originalGroups = this.groups; + const originalNotifications = this.notifications; + const originalCount = this.unreadNotificationCount; + const originalNextExpiryAt = this.nextExpiryAt; + this.#fetchGeneration++; + this.loading = false; + const mutationGeneration = this.#fetchGeneration; + this.groups = []; + this.notifications = []; + this.unreadNotificationCount = 0; + this.nextExpiryAt = null; + + try { + await this.#api.deleteAllNotificationOccurrences(); + } catch (error) { + if (this.#fetchGeneration === mutationGeneration) { + this.groups = originalGroups; + this.notifications = originalNotifications; + this.unreadNotificationCount = originalCount; + this.nextExpiryAt = originalNextExpiryAt; + } + throw error; + } + } + + getPolicy(roomId?: string): Promise { + return this.#api.getNotificationPolicy(roomId); + } + + setPolicyPreference( + reason: NotificationReason, + intensity: NotificationDeliveryIntensity, + roomId?: string + ): Promise { + return this.#api.setNotificationPolicyPreference(reason, intensity, roomId); + } + /** - * Fetch the newest pending notification for a single room. + * Fetch the newest unread notification occurrence for a single room. * * Room sidebar badge clicks need the same scoped source as room notification * counts when the global cached page is empty, stale, or does not include * this room's notification. */ - async fetchRoomNotification(roomId: string): Promise { + async fetchRoomNotification( + roomId: string, + options: RoomNotificationResolveOptions = {} + ): Promise { try { - const page = await this.#api.listRoomNotifications(roomId, 1); - const notification = page.items[0] ?? null; + let offset = 0; + let totalCount = 0; + let notification: NotificationItem | null = null; + let hasMore = false; + do { + const page = await this.#api.listNotificationGroups(50, offset); + const matches = page.groups + .flatMap((group) => group.occurrences) + .filter((occurrence) => occurrence.unread && occurrence.room?.id === roomId) + .map(occurrenceAsNotificationItem) + .filter((item) => (options.isDM ? isDMNotification(item) : !isDMNotification(item))); + totalCount += matches.length; + if (!notification && matches.length > 0) notification = matches[0]!; + hasMore = page.hasMore; + if (!hasMore || page.groups.length === 0) break; + offset += page.groups.length; + } while (hasMore); if (notification) { this.#upsertNotification(notification); } return { ok: true, - totalCount: page.totalCount, + totalCount, notification }; } catch (e) { @@ -358,66 +510,47 @@ export class NotificationStore { if (cached) { return { ok: true, totalCount: null, notification: cached }; } - return this.fetchRoomNotification(roomId); + return this.fetchRoomNotification(roomId, options); } - /** - * Dismiss a single notification. Optimistic: removes locally first, rolls - * back on failure. The notification indicator disappears the moment the user clicks. - */ - async dismiss(notificationId: string): Promise { + /** Mark one occurrence read optimistically, then reconcile authoritative groups. */ + async markRead(notificationId: string): Promise { const removed = this.notifications.find((n) => n.id === notificationId); if (!removed) return false; - this.#invalidateFetch(); + // Supersede any in-flight list read without scheduling its generic retry; + // this mutation performs one authoritative refresh after the write. + this.#fetchGeneration++; + this.loading = false; + const originalGroups = this.groups; + const originalCount = this.unreadNotificationCount; this.notifications = this.notifications.filter((n) => n.id !== notificationId); - this.unreadNotificationCount = Math.max(0, this.unreadNotificationCount - 1); - this.#markLocalDismissal(notificationId); + let resolvedUnreadGroups = 0; + this.groups = this.groups.map((group) => { + const occurrences = group.occurrences.map((occurrence) => + occurrence.id === notificationId ? { ...occurrence, unread: false } : occurrence + ); + const unread = occurrences.some((occurrence) => occurrence.unread); + if (group.unread && !unread) resolvedUnreadGroups++; + return { + ...group, + occurrences, + openTarget: occurrences.find((occurrence) => occurrence.unread) ?? occurrences[0] ?? null, + unread + }; + }); + this.unreadNotificationCount = Math.max(0, this.unreadNotificationCount - resolvedUnreadGroups); try { - if (!(await this.#api.dismissNotification(notificationId))) { - this.#locallyDismissedNotificationIds.delete(notificationId); - this.#restoreNotification(removed); - this.unreadNotificationCount += 1; - return false; - } + await this.#api.markNotificationRead(notificationId); + await this.fetch(); return true; } catch (e) { - console.error('Failed to dismiss notification:', e); - this.#locallyDismissedNotificationIds.delete(notificationId); + console.error('Failed to mark notification read:', e); + this.groups = originalGroups; this.#restoreNotification(removed); - this.unreadNotificationCount += 1; - return false; - } - } - - /** - * Dismiss all notifications. Optimistic: clears locally first, rolls back - * on failure. - */ - async dismissAll(): Promise { - const original = this.notifications; - const originalCount = this.unreadNotificationCount; - if (original.length === 0 && originalCount === 0) return 0; - - this.#invalidateFetch(); - this.notifications = []; - this.unreadNotificationCount = 0; - for (const notification of original) { - this.#markLocalDismissal(notification.id); - } - - try { - return await this.#api.dismissAllNotifications(); - } catch (e) { - console.error('Failed to dismiss all notifications:', e); - for (const notification of original) { - this.#locallyDismissedNotificationIds.delete(notification.id); - } - this.notifications = original; this.unreadNotificationCount = originalCount; - await this.fetch(); - return 0; + return false; } } @@ -455,17 +588,6 @@ export class NotificationStore { } } - #markLocalDismissal(notificationId: string): void { - this.#locallyDismissedNotificationIds.add(notificationId); - const timeout = setTimeout( - () => this.#locallyDismissedNotificationIds.delete(notificationId), - 30_000 - ); - if (typeof timeout === 'object' && timeout !== null && 'unref' in timeout) { - (timeout as { unref: () => void }).unref(); - } - } - /** * Get location string for a notification (e.g., "#general in My Server"). * Returns null for DM notifications and any notification missing names. @@ -511,7 +633,6 @@ export class NotificationStore { roomId: t.roomId }); } - } function redactedNotificationSummary(kind: NotificationItemKind): string { @@ -526,3 +647,12 @@ function redactedNotificationSummary(kind: NotificationItemKind): string { return 'New message'; } } + +function earliestNotificationGroupExpiry(groups: NotificationGroupItem[]): string | null { + return ( + groups + .map((group) => group.nextExpiryAt) + .filter((expiry): expiry is string => Boolean(expiry)) + .sort()[0] ?? null + ); +} diff --git a/apps/frontend/src/lib/state/server/pendingHighlight.svelte.ts b/apps/frontend/src/lib/state/server/pendingHighlight.svelte.ts index 05cb06f22..77f743058 100644 --- a/apps/frontend/src/lib/state/server/pendingHighlight.svelte.ts +++ b/apps/frontend/src/lib/state/server/pendingHighlight.svelte.ts @@ -16,21 +16,29 @@ import { SvelteMap } from 'svelte/reactivity'; * permalinks; Room.svelte checks both, with this store taking precedence. */ export class PendingHighlightStore { - private highlights = new SvelteMap(); + private highlights = new SvelteMap(); - set(roomId: string, threadRootId: string | null, eventId: string): void { - this.highlights.set(this.#key(roomId, threadRootId), eventId); + set( + roomId: string, + threadRootId: string | null, + eventId: string, + notificationId: string | null = null + ): void { + this.highlights.set(this.#key(roomId, threadRootId), { eventId, notificationId }); } /** * Remove and return the pending highlight for a destination, if any. */ - consume(roomId: string, threadRootId: string | null): string | null { + consume( + roomId: string, + threadRootId: string | null + ): { eventId: string; notificationId: string | null } | null { const k = this.#key(roomId, threadRootId); - const eventId = this.highlights.get(k); - if (eventId === undefined) return null; + const highlight = this.highlights.get(k); + if (highlight === undefined) return null; this.highlights.delete(k); - return eventId; + return highlight; } clear(): void { diff --git a/apps/frontend/src/lib/state/server/projection.svelte.spec.ts b/apps/frontend/src/lib/state/server/projection.svelte.spec.ts index 687c41311..14c6e9928 100644 --- a/apps/frontend/src/lib/state/server/projection.svelte.spec.ts +++ b/apps/frontend/src/lib/state/server/projection.svelte.spec.ts @@ -20,13 +20,15 @@ import { RoomTimelineEvent, RoomTimelinePage } from '@chatto/api-types/api/v1/room_timeline_pb'; -import { Room } from '@chatto/api-types/api/v1/rooms_pb'; +import { Room, RoomSummary } from '@chatto/api-types/api/v1/rooms_pb'; import { User } from '@chatto/api-types/api/v1/users_pb'; import { ActiveCall, CallParticipant } from '@chatto/api-types/api/v1/voice_calls_pb'; import { - ListNotificationsResponse, - NotificationItem, - RoomNotificationCount + ListNotificationGroupsResponse, + NotificationGroup, + NotificationOccurrence, + NotificationRoomUnreadGroupCount, + NotificationTarget } from '@chatto/api-types/api/v1/notifications_pb'; import { RealtimeProjectionEvent, @@ -73,6 +75,33 @@ function timelineEvent(id: string, at: string): RoomTimelineEvent { }); } +function notificationGroups(roomId: string, count: number): ListNotificationGroupsResponse { + return new ListNotificationGroupsResponse({ + groups: Array.from( + { length: count }, + (_, index) => + new NotificationGroup({ + id: `G${index}`, + unread: true, + occurrences: [ + new NotificationOccurrence({ + id: `N${index}`, + actor: new User({ id: 'U1', displayName: 'Ada' }), + unread: true, + target: new NotificationTarget({ + room: new RoomSummary({ id: roomId }) + }) + }) + ] + }) + ), + unreadGroupCount: count, + roomUnreadGroupCounts: [ + new NotificationRoomUnreadGroupCount({ roomId, unreadGroupCount: count }) + ] + }); +} + describe('ServerProjectionStore', () => { it('reconciles followed-thread state and clears entries absent from the replacement', () => { const store = new ServerProjectionStore(); @@ -187,8 +216,7 @@ describe('ServerProjectionStore', () => { room: new Room({ id: 'R1' }), viewerState: new RoomViewerState({ isMember: true }) }), - memberUserIds: ['U1'], - viewerNotificationCount: 3 + memberUserIds: ['U1'] }); const group = new RoomGroup({ id: 'G1', name: 'General' }); @@ -212,15 +240,7 @@ describe('ServerProjectionStore', () => { operation({ case: 'notificationsReplace', value: new RealtimeProjectionNotificationsReplace({ - page: new ListNotificationsResponse({ - notifications: [ - new NotificationItem({ - id: 'N1', - actor: new User({ id: 'U1', displayName: 'Ada' }) - }) - ] - }), - roomCounts: [new RoomNotificationCount({ roomId: 'R1', totalCount: 3 })] + groups: notificationGroups('R1', 3) }) }), operation({ @@ -260,7 +280,7 @@ describe('ServerProjectionStore', () => { expect(store.roomGroups).toEqual([group]); expect(store.rooms.get('R1')?.room?.viewerState?.isMember).toBe(false); expect(store.rooms.get('R1')?.memberUserIds).toEqual(['U1']); - expect(store.rooms.get('R1')?.viewerNotificationCount).toBe(3); + expect(store.notificationGroups?.unreadGroupCount).toBe(3); store.apply( event( @@ -277,7 +297,7 @@ describe('ServerProjectionStore', () => { expect(store.users.has('U1')).toBe(false); expect(store.rooms.get('R1')?.memberUserIds).toEqual([]); expect(store.timelines.get('R1')?.includes?.users.U1).toBeUndefined(); - expect(store.notifications?.notifications[0]?.actor).toBeUndefined(); + expect(store.notificationGroups?.groups[0]?.occurrences[0]?.actor).toBeUndefined(); expect(store.activeCalls[0]?.participants).toEqual([]); expect(store.roomGroups).toEqual([]); }); @@ -438,15 +458,14 @@ describe('ServerProjectionStore', () => { expect(store.timelines.size).toBe(0); }); - it('bounds retained room timelines and replaces current notification counts', () => { + it('bounds retained room timelines and replaces current notification groups', () => { const store = new ServerProjectionStore(); store.apply( event( operation({ case: 'roomUpsert', value: new RealtimeProjectionRoom({ - room: new RoomWithViewerState({ room: new Room({ id: 'R1' }) }), - viewerNotificationCount: 9 + room: new RoomWithViewerState({ room: new Room({ id: 'R1' }) }) }) }), ...Array.from({ length: 55 }, (_, index) => @@ -465,8 +484,7 @@ describe('ServerProjectionStore', () => { operation({ case: 'notificationsReplace', value: new RealtimeProjectionNotificationsReplace({ - page: new ListNotificationsResponse(), - roomCounts: [new RoomNotificationCount({ roomId: 'R1', totalCount: 2 })] + groups: notificationGroups('R1', 2) }) }) ) @@ -476,8 +494,7 @@ describe('ServerProjectionStore', () => { expect(store.timelines.get('R1')?.events[0]?.id).toBe('M5'); expect(store.timelines.get('R1')?.startCursor).toBe('cursor-5'); expect(store.timelines.get('R1')?.endCursor).toBe('cursor-54'); - expect(store.rooms.get('R1')?.viewerNotificationCount).toBe(2); - expect(store.notifications).not.toBeNull(); + expect(store.notificationGroups?.unreadGroupCount).toBe(2); store.apply( event( @@ -489,7 +506,7 @@ describe('ServerProjectionStore', () => { }) ) ); - expect(store.rooms.get('R1')?.viewerNotificationCount).toBe(2); + expect(store.notificationGroups?.unreadGroupCount).toBe(2); }); it('retains root-message room activity order across viewer-state replacements', () => { diff --git a/apps/frontend/src/lib/state/server/projection.svelte.ts b/apps/frontend/src/lib/state/server/projection.svelte.ts index 28e6ba1ce..c3c73f23e 100644 --- a/apps/frontend/src/lib/state/server/projection.svelte.ts +++ b/apps/frontend/src/lib/state/server/projection.svelte.ts @@ -6,7 +6,7 @@ import { PresenceStatus } from '@chatto/api-types/api/v1/presence_pb'; import { RoomWithViewerState, type RoomGroup } from '@chatto/api-types/api/v1/room_directory_pb'; import type { ServerPublicProfile } from '@chatto/api-types/api/v1/server_pb'; import type { GetViewerResponse } from '@chatto/api-types/api/v1/viewer_pb'; -import type { ListNotificationsResponse } from '@chatto/api-types/api/v1/notifications_pb'; +import type { ListNotificationGroupsResponse } from '@chatto/api-types/api/v1/notifications_pb'; import type { ActiveCall } from '@chatto/api-types/api/v1/voice_calls_pb'; import { RealtimeProjectionRoom } from '@chatto/api-types/realtime/v1/realtime_pb'; import type { @@ -22,7 +22,7 @@ export class ServerProjectionStore { users = new SvelteMap(); rooms = new SvelteMap(); roomGroups = $state.raw([]); - notifications = $state.raw(null); + notificationGroups = $state.raw(null); activeCalls = $state.raw([]); /** Complete current followed-thread viewer state, keyed by room and root ID. */ threadViewerStates = new SvelteMap(); @@ -92,7 +92,8 @@ export class ServerProjectionStore { this.timelines.delete(roomId); this.timelineEventCursors.delete(roomId); this.removeActiveCallRoom(roomId); - } else if (room.room?.viewerState?.isMember === true) this.revokedRoomIds.delete(roomId); + } else if (room.room?.viewerState?.isMember === true) + this.revokedRoomIds.delete(roomId); } break; } @@ -131,21 +132,7 @@ export class ServerProjectionStore { break; case 'notificationsReplace': { const replacement = operation.operation.value; - this.notifications = replacement.page ?? null; - const counts = Object.fromEntries( - replacement.roomCounts.map((count) => [count.roomId, count.totalCount]) - ); - for (const [roomId, current] of this.rooms) { - this.rooms.set( - roomId, - new RealtimeProjectionRoom({ - room: current.room, - memberUserIds: [...current.memberUserIds], - viewerNotificationCount: Math.max(0, counts[roomId] ?? 0), - hasMessageHistory: current.hasMessageHistory - }) - ); - } + this.notificationGroups = replacement.groups ?? null; break; } case 'roomViewerStateReplace': { @@ -160,7 +147,6 @@ export class ServerProjectionStore { viewerState: replacement.viewerState }), memberUserIds: [...current.memberUserIds], - viewerNotificationCount: current.viewerNotificationCount, hasMessageHistory: current.hasMessageHistory }) ); @@ -252,7 +238,6 @@ export class ServerProjectionStore { new RealtimeProjectionRoom({ room: room.room, memberUserIds: [], - viewerNotificationCount: room.viewerNotificationCount, hasMessageHistory: room.hasMessageHistory }) ); @@ -265,7 +250,7 @@ export class ServerProjectionStore { this.users.clear(); this.rooms.clear(); this.roomGroups = []; - this.notifications = null; + this.notificationGroups = null; this.activeCalls = []; this.threadViewerStates.clear(); this.timelines.clear(); @@ -288,7 +273,6 @@ export class ServerProjectionStore { new RealtimeProjectionRoom({ room: room.room, memberUserIds: room.memberUserIds.filter((candidate) => candidate !== userId), - viewerNotificationCount: room.viewerNotificationCount, hasMessageHistory: room.hasMessageHistory }) ); @@ -301,15 +285,17 @@ export class ServerProjectionStore { this.timelines.set(roomId, next); } - if (this.notifications) { + if (this.notificationGroups) { let changed = false; - const next = this.notifications.clone(); - for (const notification of next.notifications) { - if (notification.actor?.id !== userId) continue; - notification.actor = undefined; - changed = true; + const next = this.notificationGroups.clone(); + for (const group of next.groups) { + for (const occurrence of group.occurrences) { + if (occurrence.actor?.id !== userId) continue; + occurrence.actor = undefined; + changed = true; + } } - if (changed) this.notifications = next; + if (changed) this.notificationGroups = next; } let callsChanged = false; @@ -401,7 +387,6 @@ export class ServerProjectionStore { const room = new RealtimeProjectionRoom({ room: current.room, memberUserIds: [...current.memberUserIds], - viewerNotificationCount: current.viewerNotificationCount, hasMessageHistory: true }); if (this.rooms.keys().next().value === roomId) { diff --git a/apps/frontend/src/lib/state/server/rooms.svelte.spec.ts b/apps/frontend/src/lib/state/server/rooms.svelte.spec.ts index e2ad83b3f..6c21ae7c9 100644 --- a/apps/frontend/src/lib/state/server/rooms.svelte.spec.ts +++ b/apps/frontend/src/lib/state/server/rooms.svelte.spec.ts @@ -8,9 +8,17 @@ import { RoomWithViewerState } from '@chatto/api-types/api/v1/room_directory_pb'; import { Room, RoomKind } from '@chatto/api-types/api/v1/rooms_pb'; +import { RoomSummary } from '@chatto/api-types/api/v1/rooms_pb'; import { User } from '@chatto/api-types/api/v1/users_pb'; import { GetViewerResponse, ViewerUser } from '@chatto/api-types/api/v1/viewer_pb'; import { RealtimeProjectionRoom } from '@chatto/api-types/realtime/v1/realtime_pb'; +import { + ListNotificationGroupsResponse, + NotificationGroup, + NotificationOccurrence, + NotificationRoomUnreadGroupCount, + NotificationTarget +} from '@chatto/api-types/api/v1/notifications_pb'; import { ServerProjectionStore } from './projection.svelte'; import { RealtimeProjectionSyncState } from './realtimeSync.svelte'; import { NavigationStore } from './rooms.svelte'; @@ -28,13 +36,11 @@ function projectedRoom( { kind = RoomKind.CHANNEL, member = true, - count = 0, memberUserIds = [], hasMessageHistory }: { kind?: RoomKind; member?: boolean; - count?: number; memberUserIds?: string[]; hasMessageHistory?: boolean; } = {} @@ -51,7 +57,6 @@ function projectedRoom( }) }), memberUserIds, - viewerNotificationCount: count, hasMessageHistory }); } @@ -72,11 +77,30 @@ describe('NavigationStore', () => { 'dm', projectedRoom('dm', { kind: RoomKind.DM, - count: 3, memberUserIds: ['U2'], hasMessageHistory: true }) ); + projection.notificationGroups = new ListNotificationGroupsResponse({ + groups: Array.from( + { length: 3 }, + (_, index) => + new NotificationGroup({ + id: `G${index}`, + unread: true, + occurrences: [ + new NotificationOccurrence({ + id: `N${index}`, + unread: true, + target: new NotificationTarget({ room: new RoomSummary({ id: 'dm' }) }) + }) + ] + }) + ), + roomUnreadGroupCounts: [ + new NotificationRoomUnreadGroupCount({ roomId: 'dm', unreadGroupCount: 3 }) + ] + }); projection.rooms.set('managed', projectedRoom('managed')); const { navigation } = navigationFor(projection); diff --git a/apps/frontend/src/lib/state/server/rooms.svelte.ts b/apps/frontend/src/lib/state/server/rooms.svelte.ts index b23336618..c9d091f1c 100644 --- a/apps/frontend/src/lib/state/server/rooms.svelte.ts +++ b/apps/frontend/src/lib/state/server/rooms.svelte.ts @@ -91,6 +91,11 @@ export class NavigationStore { const member = this.projection.users.get(userId); return member ? [avatarUserFromDirectoryMember(mapDirectoryMember(member))] : []; }); + const viewerNotificationCount = Number( + this.projection.notificationGroups?.roomUnreadGroupCounts.find( + (count) => count.roomId === room.id + )?.unreadGroupCount ?? 0 + ); return [ { id: room.id, @@ -101,7 +106,7 @@ export class NavigationStore { viewerIsMember: room.isMember, viewerCanJoinRoom: room.canJoinRoom, viewerCanManageRoom: room.canManageRoom, - viewerNotificationCount: entry.viewerNotificationCount, + viewerNotificationCount, hasMessageHistory: room.kind === RoomKind.DM ? (entry.hasMessageHistory ?? null) : null, members } diff --git a/apps/frontend/src/lib/state/server/store.svelte.spec.ts b/apps/frontend/src/lib/state/server/store.svelte.spec.ts index d00e85c47..8babf0355 100644 --- a/apps/frontend/src/lib/state/server/store.svelte.spec.ts +++ b/apps/frontend/src/lib/state/server/store.svelte.spec.ts @@ -78,13 +78,6 @@ const { soundMocks, apiMocks, cacheMocks } = vi.hoisted(() => ({ joinCall: vi.fn(() => Promise.resolve(true)), getCallToken: vi.fn(() => Promise.resolve(null)), leaveCall: vi.fn(() => Promise.resolve(true)), - listRoomNotificationCounts: vi.fn(() => Promise.resolve({})), - listNotifications: vi.fn(() => - Promise.resolve({ - items: [], - unreadCount: 0 - }) - ), getAuthenticatedServerState: vi.fn<() => Promise>(() => Promise.resolve({ name: 'Store Event Test', @@ -148,12 +141,7 @@ const { soundMocks, apiMocks, cacheMocks } = vi.hoisted(() => ({ canAdminManageRoles: false, canAdminViewSystem: false, canAdminViewAudit: false, - canManageUserPermissions: false, - serverNotificationPreference: { - level: 'DEFAULT', - effectiveLevel: 'NORMAL' - }, - roomNotificationPreferences: [] + canManageUserPermissions: false }) ), getCurrentUserViaConnect: vi.fn(() => @@ -219,26 +207,28 @@ vi.mock('$lib/api-client/voiceCalls', () => ({ })) })); -vi.mock('$lib/api-client/notifications', () => ({ - NotificationItemKind: { - DirectMessage: 'directMessage', - Mention: 'mention', - Reply: 'reply', - RoomMessage: 'roomMessage' - }, - mapNotificationPage: vi.fn((response) => ({ - items: [], - totalCount: Number(response.page?.totalCount ?? 0), - hasMore: response.page?.hasMore ?? false - })), - createNotificationAPI: vi.fn(() => ({ - listNotifications: apiMocks.listNotifications, - listRoomNotifications: vi.fn(), - listRoomNotificationCounts: apiMocks.listRoomNotificationCounts, - dismissNotification: vi.fn(), - dismissAllNotifications: vi.fn() - })) -})); +vi.mock('$lib/api-client/notifications', async (importActual) => { + const actual = await importActual(); + return { + ...actual, + createNotificationAPI: vi.fn(() => ({ + listNotificationGroups: vi.fn(() => + Promise.resolve({ + groups: [], + totalCount: 0, + hasMore: false, + unreadGroupCount: 0, + roomUnreadGroupCounts: {} + }) + ), + markNotificationRead: vi.fn(), + deleteNotificationOccurrence: vi.fn(), + deleteNotificationGroup: vi.fn(), + getNotificationPolicy: vi.fn(() => Promise.resolve([])), + setNotificationPolicyPreference: vi.fn(() => Promise.resolve([])) + })) + }; +}); vi.mock('$lib/api-client/roles', () => ({ createRoleAPI: vi.fn(() => ({ @@ -472,11 +462,6 @@ beforeEach(() => { apiMocks.joinCall.mockResolvedValue(true); apiMocks.getCallToken.mockResolvedValue(null); apiMocks.leaveCall.mockResolvedValue(true); - apiMocks.listRoomNotificationCounts.mockResolvedValue({}); - apiMocks.listNotifications.mockResolvedValue({ - items: [], - unreadCount: 0 - }); apiMocks.getAuthenticatedServerState.mockResolvedValue({ name: 'Store Event Test', version: 'test', @@ -537,12 +522,7 @@ beforeEach(() => { canAdminManageRoles: false, canAdminViewSystem: false, canAdminViewAudit: false, - canManageUserPermissions: false, - serverNotificationPreference: { - level: 'DEFAULT', - effectiveLevel: 'NORMAL' - }, - roomNotificationPreferences: [] + canManageUserPermissions: false }); apiMocks.getCurrentUserViaConnect.mockResolvedValue({ id: 'U1', @@ -686,23 +666,17 @@ describe('ServerStateStore live server updates', () => { flushSync(); const bus = eventBusManager.getBus(registered.id)!; - store.notifications.replaceProjection({ - items: [ - { - kind: 'mention', - id: 'N1', - createdAt: '2026-01-01T00:00:00Z', - summary: 'Alice mentioned you', - mentionRoom: { id: 'R1', name: 'general' }, - mentionEventId: 'M1' - } as never - ], - totalCount: 1 + store.notifications.replaceGroupProjection({ + groups: [], + totalCount: 0, + hasMore: false, + unreadGroupCount: 1, + roomUnreadGroupCounts: {}, + nextExpiryAt: null }); store.activeCallRooms.replaceProjection([ new ActiveCall({ room: new Room({ id: 'R1' }), callId: 'call-1' }) ]); - store.notificationLevels.setServerPreference('MUTED' as never, 'MUTED' as never); store.roomUnread.setRoomUnread('R1', true); store.setPermissions({ canViewAdmin: true } as never); store.serverInfo.applyProjectionState( @@ -742,7 +716,6 @@ describe('ServerStateStore live server updates', () => { expect(store.notifications.unreadNotificationCount).toBe(0); expect(store.notifications.hasLoaded).toBe(true); expect(store.activeCallRooms.has('R1')).toBe(false); - expect(store.notificationLevels.isServerMuted()).toBe(false); expect(store.roomUnread.hasAnyUnread).toBe(false); expect(store.permissions.loaded).toBe(false); expect(store.permissions.canViewAdmin).toBe(false); @@ -833,11 +806,7 @@ describe('ServerStateStore live server updates', () => { } } as never ]; - store.projection.viewer = { - user: { id: 'U1' }, - serverNotificationPreference: { level: 'DEFAULT', effectiveLevel: 'NORMAL' }, - roomNotificationPreferences: [] - } as never; + store.projection.viewer = { user: { id: 'U1' } } as never; store.projection.users.set( 'U2', new DirectoryMember({ user: new User({ id: 'U2', displayName: 'Deleted Person' }) }) diff --git a/apps/frontend/src/lib/state/server/store.svelte.ts b/apps/frontend/src/lib/state/server/store.svelte.ts index 95fcc38bd..24ec73bd0 100644 --- a/apps/frontend/src/lib/state/server/store.svelte.ts +++ b/apps/frontend/src/lib/state/server/store.svelte.ts @@ -9,7 +9,6 @@ import type { PublicServerInfo } from '$lib/api-client/server'; import type { ServerPermissions, ViewerData } from './permissions'; import { NotificationStore } from './notifications.svelte'; import { RoomUnreadStore } from './roomUnread.svelte'; -import { NotificationLevelStore } from './notificationLevel.svelte'; import { PendingHighlightStore } from './pendingHighlight.svelte'; import { VoiceCallState } from './voiceCall.svelte'; import { ActiveCallRoomsState } from './activeCallRooms.svelte'; @@ -37,14 +36,14 @@ import type { RoomMember } from '$lib/state/room'; import type { RealtimeProjectionEvent } from '@chatto/api-types/realtime/v1/realtime_pb'; import { mapDirectoryRoom, RoomKind } from '$lib/api-client/roomDirectory'; import { mapDirectoryMember } from '$lib/api-client/memberDirectory'; -import { viewerResponseToState, type ViewerState } from '$lib/api-client/viewer'; +import { viewerResponseToState } from '$lib/api-client/viewer'; import { notifyUserSummaries } from '$lib/api-client/hooks'; import { clearUserSummaryCache, removeUserSummaryCacheEntry } from '$lib/state/userSummaries.svelte'; import { avatarUserFromDirectoryMember } from './rooms.svelte'; -import { mapNotificationPage } from '$lib/api-client/notifications'; +import { mapNotificationGroupPage } from '$lib/api-client/notifications'; import { RealtimeProjectionSyncState } from './realtimeSync.svelte'; import type { ActiveCall } from '@chatto/api-types/api/v1/voice_calls_pb'; import { MessageSearchStore } from './messageSearch.svelte'; @@ -69,7 +68,7 @@ import { /** * What kind of indicator a server (or the DM area) should display. * - 'notification' = warning badge, has a pending mention/reply/room-message - * - 'unread' = grey dot, has unread rooms but no pending notification + * - 'unread' = grey dot, has unread rooms but no unread notification occurrence * - null = no indicator */ export type ServerIndicator = 'notification' | 'unread' | null; @@ -96,7 +95,6 @@ export class ServerStateStore { readonly serverInfo: ServerInfoState; readonly notifications: NotificationStore; readonly roomUnread: RoomUnreadStore; - readonly notificationLevels: NotificationLevelStore; readonly pendingHighlights: PendingHighlightStore; readonly voiceCall: VoiceCallState; readonly activeCallRooms: ActiveCallRoomsState; @@ -171,7 +169,6 @@ export class ServerStateStore { this.serverInfo = new ServerInfoState(registration.url, publicServerInfoLoader); this.notifications = new NotificationStore(notificationAPI); this.roomUnread = new RoomUnreadStore(() => this.projection); - this.notificationLevels = new NotificationLevelStore(); const roomCommandAPI = serverConnection.getAPI(createRoomCommandAPI); this.pendingHighlights = new PendingHighlightStore(); this.voiceCall = new VoiceCallState(voiceCallAPI); @@ -406,7 +403,6 @@ export class ServerStateStore { this.currentUser.user = viewer.user; this.currentUser.loading = false; this.setPermissions(viewer); - this.applyViewerPreferences(viewer); this.roomUnread.acknowledgeViewerProjection(); break; } @@ -547,8 +543,9 @@ export class ServerStateStore { } case 'notificationsReplace': { const replacement = operation.operation.value; - if (replacement.page) { - this.notifications.replaceProjection(mapNotificationPage(replacement.page)); + if (replacement.groups) { + this.notifications.replaceGroupProjection(mapNotificationGroupPage(replacement.groups)); + this.notifications.invalidateViews(); } break; } @@ -673,20 +670,6 @@ export class ServerStateStore { }; } - private applyViewerPreferences(viewer: ViewerState): void { - this.notificationLevels.setServerPreference( - viewer.serverNotificationPreference.level, - viewer.serverNotificationPreference.effectiveLevel - ); - for (const preference of viewer.roomNotificationPreferences) { - this.notificationLevels.setRoomPreference( - preference.roomId, - preference.level, - preference.effectiveLevel - ); - } - } - /** Clear every mirror whose authority was invalidated by a reset frame. */ private resetProjectionMirrors(): void { removeRegisteredAdminQueries(this.serverId); @@ -701,7 +684,6 @@ export class ServerStateStore { } this.roomDirectory.resetOptimisticState(); this.notifications.resetProjectionState(); - this.notificationLevels.clear(); this.roomUnread.clear(); this.pendingHighlights.clear(); this.activeCallRooms.clear(); @@ -910,7 +892,6 @@ export class ServerStateStore { this.#threadMessages = Object.create(null); this.#threadMessageRefCounts = Object.create(null); this.roomUnread.clear(); - this.notificationLevels.clear(); this.pendingHighlights.clear(); this.activeCallRooms.clear(); this.messageSearch.reset(); diff --git a/apps/frontend/src/lib/ui/form/Button.stories.svelte b/apps/frontend/src/lib/ui/form/Button.stories.svelte index cb7f2214a..d0b7c1537 100644 --- a/apps/frontend/src/lib/ui/form/Button.stories.svelte +++ b/apps/frontend/src/lib/ui/form/Button.stories.svelte @@ -139,3 +139,25 @@ + + +
+ + +
+
diff --git a/apps/frontend/src/lib/ui/form/Button.svelte b/apps/frontend/src/lib/ui/form/Button.svelte index b19a1f6dd..2542d9069 100644 --- a/apps/frontend/src/lib/ui/form/Button.svelte +++ b/apps/frontend/src/lib/ui/form/Button.svelte @@ -12,6 +12,8 @@ loadingText, href, onclick, + label, + title, children }: { type?: 'button' | 'submit' | 'reset'; @@ -31,6 +33,10 @@ /** When provided, renders as an
link instead of a {/if} {/snippet}
- {#if loading && allNotifications.length === 0} + {#if loading && groups.length === 0}
{m('common.loading')}
- {:else if allNotifications.length === 0} + {:else if pageError && groups.length === 0} + + + + {:else if visibleGroups.length === 0} {m('chat.notifications.empty_body')} {:else} -
- {#each allNotifications as item (item.notification.id)} - {@const actor = item.notification.actor ?? null} - {@const location = serverRegistry - .getStore(item.serverId) - .notifications.getLocationString(item.notification, item.serverName)} -
handleClick(item)} - onkeydown={(e) => e.key === 'Enter' && handleClick(item)} - > - {#if actor} - - {/if} - -
-

{item.notification.summary}

-

- {item.serverHostname} - {#if location} - - {location} - {/if} - - {formatTime(item.notification.createdAt, item.timeFormatSettings)} -

-
- - -
+
+ {#each dateSections as section (section.key)} +
+

+ {section.label} +

+ {#each section.items as item (rowKey(item))} + {@const occurrence = item.group.openTarget} + {@const actor = occurrence?.actor ?? null} + {@const mutationPending = dismissingAll || pendingMutationKeys.has(mutationKey(item))} +
+ +
+ +
+
+ {/each} +
{/each} + {#if pageError} + + {:else if hasMore} +
+ {#if loadingMore}{m('common.loading')}{/if} +
+ {/if}
{/if}
diff --git a/apps/frontend/src/routes/chat/notifications/notifications.page.svelte.spec.ts b/apps/frontend/src/routes/chat/notifications/notifications.page.svelte.spec.ts index d756419f3..9db3871f5 100644 --- a/apps/frontend/src/routes/chat/notifications/notifications.page.svelte.spec.ts +++ b/apps/frontend/src/routes/chat/notifications/notifications.page.svelte.spec.ts @@ -1,27 +1,30 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { q } from '$lib/test-utils'; import { loadLocaleMessages } from '$lib/i18n/messages'; import { setReactiveLocale } from '$lib/i18n/state.svelte'; +import { NotificationReason, type NotificationOccurrenceItem } from '$lib/api-client/notifications'; import { TimeFormat } from '@chatto/api-types/api/v1/viewer_pb'; +import { getToasts, toast } from '$lib/ui/toast'; const { mocks } = vi.hoisted(() => ({ mocks: { goto: vi.fn(), servers: [{ id: 'origin', url: 'https://chat.example.test' }], stores: new Map(), - appUi: { - disableRoomCallWideFor: vi.fn() - }, - notification: { + appUi: { disableRoomCallWideFor: vi.fn() }, + occurrence: { id: 'mention-1', - kind: 'mention', + sourceEventId: 'source-1', createdAt: new Date().toISOString(), actor: null, - summary: 'Mentioned you in a message', - mentionRoom: { id: 'room-1', name: 'general' }, - mentionEventId: 'event-1', - mentionInThread: 'thread-1' + room: { id: 'room-1', name: 'general' }, + eventId: 'event-1', + threadRootId: 'thread-1', + parentEventId: null, + reasons: [2], + reasonMatches: [{ reason: 2, intensity: 3 }], + unread: true }, store: { isAuthenticated: true, @@ -33,21 +36,14 @@ const { mocks } = vi.hoisted(() => ({ } | null } }, - serverInfo: { - name: 'Test Server' - }, notifications: { - notifications: [] as unknown[], - unreadNotificationCount: 1, - fetch: vi.fn().mockResolvedValue(undefined), - dismiss: vi.fn().mockResolvedValue(true), - dismissAll: vi.fn().mockResolvedValue(0), - getCleanPath: vi.fn().mockReturnValue('/chat/-/room-1/thread-1'), - getLocationString: vi.fn().mockReturnValue('#general in Test Server') + viewInvalidationVersion: 0, + fetchPage: vi.fn(), + markOccurrenceRead: vi.fn().mockResolvedValue(undefined), + deleteGroup: vi.fn().mockResolvedValue(undefined), + deleteAllOccurrences: vi.fn().mockResolvedValue(undefined) }, - pendingHighlights: { - set: vi.fn() - } + pendingHighlights: { set: vi.fn() } } } })); @@ -61,7 +57,9 @@ vi.mock('$app/navigation', () => ({ vi.mock('$lib/state/server/registry.svelte', () => ({ serverRegistry: { servers: mocks.servers, - getStore: vi.fn((serverId: string) => mocks.stores.get(serverId)) + getStore: vi.fn((serverId: string) => mocks.stores.get(serverId)), + isOriginServer: vi.fn((serverId: string) => serverId === 'origin'), + getServer: vi.fn((serverId: string) => mocks.servers.find((server) => server.id === serverId)) } })); @@ -69,18 +67,57 @@ vi.mock('$lib/state/appUi.svelte', () => ({ getAppUiState: () => mocks.appUi })); +vi.mock('$lib/state/presenceCache.svelte', () => ({ + getPresenceCache: () => ({ + get: (_scope: { serverId: string; userId: string }, fallback: number) => fallback + }) +})); + +vi.mock('$lib/state/userProfiles.svelte', () => ({ + getLiveDisplayName: (_userId: string, fallback: string) => fallback, + getLiveAvatarUrl: (_userId: string, fallback: string | null) => fallback, + getLiveCustomStatus: (_userId: string, fallback: unknown) => fallback +})); + import NotificationsPage from './+page.svelte'; +function group( + id = 'group-1', + occurrence: NotificationOccurrenceItem = mocks.occurrence as NotificationOccurrenceItem, + unread = occurrence.unread +) { + return { + id, + occurrences: [occurrence], + openTarget: occurrence, + unread, + occurrenceCount: 1, + latestAt: occurrence.createdAt, + reasons: occurrence.reasons + }; +} + +function page(groups = [group()], hasMore = false) { + return { + groups, + unreadGroupCount: groups.filter((candidate) => candidate.unread).length, + roomUnreadGroupCounts: {}, + totalCount: groups.length, + hasMore + }; +} + describe('notifications page', () => { beforeEach(async () => { vi.clearAllMocks(); + toast.clear(); await loadLocaleMessages('en-GB'); setReactiveLocale('en-GB'); - mocks.store.notifications.notifications = [mocks.notification]; - mocks.store.notifications.fetch.mockResolvedValue(undefined); - mocks.store.notifications.dismiss.mockResolvedValue(true); - mocks.store.notifications.getCleanPath.mockReturnValue('/chat/-/room-1/thread-1'); - mocks.store.notifications.getLocationString.mockReturnValue('#general in Test Server'); + mocks.store.notifications.viewInvalidationVersion = 0; + mocks.store.notifications.fetchPage.mockResolvedValue(page()); + mocks.store.notifications.deleteGroup.mockResolvedValue(undefined); + mocks.store.notifications.deleteAllOccurrences.mockResolvedValue(undefined); + mocks.store.currentUser.user.settings = null; mocks.servers.splice(0, mocks.servers.length, { id: 'origin', url: 'https://chat.example.test' @@ -89,65 +126,264 @@ describe('notifications page', () => { mocks.stores.set('origin', mocks.store); }); - it('reveals the target room before navigating from a notification row', async () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('queues an unread occurrence to be marked read after its target is displayed', async () => { const { container } = render(NotificationsPage); + const item = await vi.waitFor(() => { + const element = q(container, '[data-testid="notification-group"] > button'); + expect(element).not.toBeNull(); + return element as HTMLButtonElement; + }); - const item = q(container, '[data-testid="notification-item"]') as HTMLElement; - await expect.element(item).toBeInTheDocument(); item.click(); await vi.waitFor(() => { expect(mocks.appUi.disableRoomCallWideFor).toHaveBeenCalledWith('origin', 'room-1'); - expect(mocks.appUi.disableRoomCallWideFor.mock.invocationCallOrder[0]).toBeLessThan( - mocks.goto.mock.invocationCallOrder[0] - ); expect(mocks.store.pendingHighlights.set).toHaveBeenCalledWith( 'room-1', 'thread-1', - 'event-1' + 'event-1', + 'mention-1' ); - expect(mocks.store.notifications.dismiss).toHaveBeenCalledWith('mention-1'); expect(mocks.goto).toHaveBeenCalledWith('/chat/-/room-1/thread-1'); }); + expect(mocks.store.notifications.markOccurrenceRead).not.toHaveBeenCalled(); + }); + + it('uses the notification orange and exposes only the delete action', async () => { + const { container } = render(NotificationsPage); + const row = await vi.waitFor(() => { + const element = q(container, '[data-testid="notification-group"]'); + expect(element).not.toBeNull(); + return element as HTMLElement; + }); + const rowTarget = q(row, ':scope > button') as HTMLButtonElement; + const deleteButton = q(row, 'button[aria-label="Delete"]') as HTMLButtonElement; + + expect(row.classList.contains('cursor-pointer')).toBe(true); + expect(row.classList.contains('bg-attention/5')).toBe(true); + expect(q(row, '.bg-attention')).not.toBeNull(); + expect(rowTarget.classList.contains('cursor-pointer')).toBe(true); + expect(deleteButton.classList.contains('btn-danger-secondary')).toBe(true); + expect(row.querySelectorAll('button')).toHaveLength(2); + expect(row.textContent).not.toContain('Move to inbox'); + expect(row.textContent).not.toContain('Mark done'); + }); + + it('renders read and unread notifications in one list', async () => { + const readOccurrence = { + ...mocks.occurrence, + id: 'read-1', + unread: false, + createdAt: new Date(Date.now() - 60_000).toISOString() + }; + mocks.store.notifications.fetchPage.mockResolvedValue( + page([group('unread', mocks.occurrence, true), group('read', readOccurrence, false)]) + ); + + const { container } = render(NotificationsPage); + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-testid="notification-group"]')).toHaveLength(2); + }); + + expect(mocks.store.notifications.fetchPage).toHaveBeenCalledTimes(1); + const readRow = q(container, '[data-notification-state="read"]') as HTMLElement; + expect(readRow.classList.contains('bg-attention/5')).toBe(false); + expect(q(readRow, '.bg-attention')).toBeNull(); }); - it('formats old notifications with their source server viewer settings', async () => { - const createdAt = '2025-04-27T00:30:00Z'; + it('renders a full-sentence summary and omits the single-occurrence counter', async () => { + const actor = { + id: 'alice', + login: 'alice', + displayName: 'Alice', + deleted: false, + avatarUrl: null, + presenceStatus: 1, + customStatus: null + }; + const occurrence = { + ...mocks.occurrence, + actor, + reasons: [NotificationReason.FOLLOWED_THREAD], + reasonMatches: [{ reason: NotificationReason.FOLLOWED_THREAD, intensity: 2 }] + }; + mocks.store.notifications.fetchPage.mockResolvedValue(page([group('followed', occurrence)])); + + const { container } = render(NotificationsPage); + const row = await vi.waitFor(() => { + const element = q(container, '[data-testid="notification-group"]'); + expect(element).not.toBeNull(); + return element as HTMLElement; + }); + + expect(row.textContent).toContain('Alice replied in a thread you follow.'); + expect(row.textContent).not.toContain('Followed threads'); + expect(row.textContent).not.toMatch(/·\s*1\s*·/); + }); + + it('preserves a healthy server result when another server fails', async () => { + const remoteStore = { + ...mocks.store, + notifications: { + ...mocks.store.notifications, + fetchPage: vi.fn().mockRejectedValue(new Error('remote offline')) + } + }; + mocks.servers.push({ id: 'remote', url: 'https://remote.example.test' }); + mocks.stores.set('remote', remoteStore); + + const { container } = render(NotificationsPage); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-testid="notification-group"]')).toHaveLength(1); + expect(container.querySelector('[role="alert"]')).not.toBeNull(); + }); + expect(container.textContent).toContain('Network error. Please try again.'); + expect(container.textContent).toContain('chat.example.test'); + }); + + it('removes a dismissed row immediately and restores it when the request fails', async () => { + let rejectMutation: ((reason?: unknown) => void) | undefined; + mocks.store.notifications.deleteGroup.mockImplementation( + () => + new Promise((_, reject) => { + rejectMutation = reject; + }) + ); + const { container } = render(NotificationsPage); + const deleteButton = await vi.waitFor(() => { + const element = q(container, 'button[aria-label="Delete"]'); + expect(element).not.toBeNull(); + return element as HTMLButtonElement; + }); + deleteButton.click(); + await vi.waitFor(() => { + expect(container.querySelector('[data-testid="notification-group"]')).toBeNull(); + }); + + rejectMutation?.(new Error('offline')); + await vi.waitFor(() => { + expect(getToasts().at(-1)?.message).toBe('Network error. Please try again.'); + expect(container.querySelector('[data-testid="notification-group"]')).not.toBeNull(); + }); + }); + + it('groups rows by date in the viewer timezone', async () => { + const now = new Date(); + const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1_000); + const older = new Date(now); + older.setUTCMonth(older.getUTCMonth() - 2); + const occurrences = [ + { ...mocks.occurrence, id: 'today', createdAt: now.toISOString() }, + { ...mocks.occurrence, id: 'yesterday', createdAt: yesterday.toISOString() }, + { ...mocks.occurrence, id: 'older', createdAt: older.toISOString() } + ]; mocks.store.currentUser.user.settings = { - timezone: 'UTC', + timezone: 'Pacific/Auckland', timeFormat: TimeFormat.TIME_FORMAT_24_HOUR }; - mocks.store.notifications.notifications = [{ ...mocks.notification, createdAt }]; + mocks.store.notifications.fetchPage.mockResolvedValue( + page(occurrences.map((occurrence) => group(`group-${occurrence.id}`, occurrence))) + ); + + const { container } = render(NotificationsPage); + const headings = await vi.waitFor(() => { + const elements = container.querySelectorAll('[data-testid="notification-date-heading"]'); + expect(elements.length).toBeGreaterThanOrEqual(3); + return [...elements].map((heading) => heading.textContent?.trim()); + }); + + expect(headings).toContain('Today'); + expect(headings).toContain('Yesterday'); + expect(headings.some((heading) => heading?.match(/\w+ \d{4}/))).toBe(true); + }); + it('dismisses all notifications optimistically with one request per server', async () => { + let resolveOrigin: (() => void) | undefined; + let resolveRemote: (() => void) | undefined; + mocks.store.notifications.deleteAllOccurrences.mockImplementation( + () => new Promise((resolve) => (resolveOrigin = resolve)) + ); const remoteStore = { ...mocks.store, - currentUser: { - user: { - settings: { - timezone: 'Pacific/Honolulu', - timeFormat: TimeFormat.TIME_FORMAT_12_HOUR - } - } - }, - serverInfo: { name: 'Remote Server' }, notifications: { ...mocks.store.notifications, - notifications: [ - { - ...mocks.notification, - id: 'mention-remote', - createdAt, - summary: 'Remote mention' - } - ] + fetchPage: vi.fn().mockResolvedValue( + page([ + group('remote-group', { + ...mocks.occurrence, + id: 'remote-notification' + }) + ]) + ), + deleteAllOccurrences: vi.fn(() => new Promise((resolve) => (resolveRemote = resolve))) } }; mocks.servers.push({ id: 'remote', url: 'https://remote.example.test' }); mocks.stores.set('remote', remoteStore); const { container } = render(NotificationsPage); + const dismissAll = await vi.waitFor(() => { + expect(container.querySelectorAll('[data-testid="notification-group"]')).toHaveLength(2); + const button = q(container, 'button[aria-label="Dismiss all"]'); + expect(button).not.toBeNull(); + return button as HTMLButtonElement; + }); + + dismissAll.click(); + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-testid="notification-group"]')).toHaveLength(0); + }); + expect(mocks.store.notifications.deleteAllOccurrences).toHaveBeenCalledTimes(1); + expect(remoteStore.notifications.deleteAllOccurrences).toHaveBeenCalledTimes(1); + + resolveOrigin?.(); + resolveRemote?.(); + }); - await expect.element(container).toHaveTextContent('27 Apr 2025'); - await expect.element(container).toHaveTextContent('26 Apr 2025'); + it('loads the next page from each server independently', async () => { + let intersectionCallback: IntersectionObserverCallback | undefined; + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + root = null; + rootMargin = ''; + thresholds = []; + } + ); + mocks.store.notifications.fetchPage.mockImplementation((offset = 0) => { + const occurrence = { + ...mocks.occurrence, + id: `notification-${offset}`, + createdAt: `2026-08-11T12:0${offset}:00Z` + }; + return Promise.resolve(page([group(`group-${offset}`, occurrence)], offset === 0)); + }); + + const { container } = render(NotificationsPage); + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-testid="notification-group"]')).toHaveLength(1); + }); + intersectionCallback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + await vi.waitFor(() => { + expect(mocks.store.notifications.fetchPage).toHaveBeenCalledWith(1); + expect(container.querySelectorAll('[data-testid="notification-group"]')).toHaveLength(2); + }); }); }); diff --git a/apps/frontend/src/service-worker.spec.ts b/apps/frontend/src/service-worker.spec.ts index eebec4046..b1975bccc 100644 --- a/apps/frontend/src/service-worker.spec.ts +++ b/apps/frontend/src/service-worker.spec.ts @@ -292,87 +292,4 @@ describe('service worker notifications', () => { consoleError.mockRestore(); } }); - - it('closes matching native notifications and updates the badge when the app is closed', async () => { - const worker = await importServiceWorker(); - const staleNotification = { close: vi.fn() }; - worker.registration.getNotifications.mockResolvedValueOnce([staleNotification]); - - await worker.dispatch('push', { - data: { - json: () => ({ - action: 'dismiss', - tag: 'notification-1', - app_badge: '2' - }) - } - }); - - expect(staleNotification.close).toHaveBeenCalledOnce(); - expect(worker.registration.getNotifications).toHaveBeenCalledOnce(); - expect(worker.clients.matchAll).toHaveBeenCalledWith({ - type: 'window', - includeUncontrolled: true - }); - expect(worker.setAppBadge).toHaveBeenCalledWith(2); - expect(worker.clearAppBadge).not.toHaveBeenCalled(); - }); - - it('leaves dismiss badge updates to an open app client', async () => { - const worker = await importServiceWorker(); - worker.clients.matchAll.mockResolvedValueOnce([ - { id: 'open-app', visibilityState: 'visible', postMessage: vi.fn() } - ]); - - await worker.dispatch('push', { - data: { - json: () => ({ - action: 'dismiss', - tag: 'notification-1', - app_badge: 1 - }) - } - }); - - expect(worker.setAppBadge).not.toHaveBeenCalled(); - expect(worker.clearAppBadge).not.toHaveBeenCalled(); - }); - - it('updates a dismiss badge when the only app client is hidden', async () => { - const worker = await importServiceWorker(); - worker.clients.matchAll.mockResolvedValueOnce([ - { id: 'background-app', visibilityState: 'hidden', postMessage: vi.fn() } - ]); - - await worker.dispatch('push', { - data: { - json: () => ({ - action: 'dismiss', - tag: 'notification-1', - app_badge: 1 - }) - } - }); - - expect(worker.setAppBadge).toHaveBeenCalledWith(1); - expect(worker.clearAppBadge).not.toHaveBeenCalled(); - }); - - it('ignores dismiss badge updates without a valid authoritative count', async () => { - const worker = await importServiceWorker(); - - await worker.dispatch('push', { - data: { - json: () => ({ - action: 'dismiss', - tag: 'notification-1', - app_badge: '-1' - }) - } - }); - - expect(worker.clients.matchAll).not.toHaveBeenCalled(); - expect(worker.setAppBadge).not.toHaveBeenCalled(); - expect(worker.clearAppBadge).not.toHaveBeenCalled(); - }); }); diff --git a/apps/frontend/src/service-worker.ts b/apps/frontend/src/service-worker.ts index 0f36736af..3eb9158d0 100644 --- a/apps/frontend/src/service-worker.ts +++ b/apps/frontend/src/service-worker.ts @@ -16,12 +16,6 @@ import { declare const self: ServiceWorkerGlobalScope; -type ServiceWorkerAppBadgeNavigator = WorkerNavigator & { - setAppBadge?: (contents?: number) => Promise; -}; - -const badgeNavigator = navigator as ServiceWorkerAppBadgeNavigator; - const RETIRED_SHELL_CACHE_PREFIX = 'chatto-shell-'; const RETIRED_BADGE_CACHE_NAMES = new Set(['chatto-badge-state-v1', 'chatto-badge-state-v2']); @@ -65,8 +59,6 @@ interface PushPayload { notificationId?: string; url?: string; app_badge?: string | number; - // "dismiss" action is used to close notifications on other devices - action?: 'dismiss'; } interface DeclarativePushPayload extends PushPayload { @@ -153,29 +145,6 @@ function stringProperty(record: object, key: string): string | undefined { return typeof value === 'string' ? value : undefined; } -/** - * Apply the server's remaining origin count unless a visible page can provide - * the authoritative aggregate multi-server badge. Hidden clients may be - * suspended and cannot safely claim foreground ownership. - */ -async function updateClosedAppBadgeAfterDismiss(appBadge: unknown): Promise { - const count = parseAppBadgeCount(appBadge); - if (count === undefined || !badgeNavigator.setAppBadge) return; - - let windowClients: readonly WindowClient[]; - try { - windowClients = (await self.clients.matchAll({ - type: 'window', - includeUncontrolled: true - })) as WindowClient[]; - } catch { - return; - } - if (windowClients.some((client) => client.visibilityState === 'visible')) return; - - await badgeNavigator.setAppBadge(count).catch(() => {}); -} - /** Ask visible pages to restore their authoritative aggregate after a regular push. */ async function refreshVisibleAppBadges(): Promise { let windowClients: readonly WindowClient[]; @@ -195,14 +164,9 @@ async function refreshVisibleAppBadges(): Promise { } } -function parseAppBadgeCount(value: unknown): number | undefined { - const count = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value; - return typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : undefined; -} - /** * Handle incoming push events. - * Parse the payload and display a native notification, or dismiss existing ones. + * Parse the payload and display a native notification. */ self.addEventListener('push', (event) => { const declarativeNotification = (event as PushEventWithDeclarativeNotification).notification; @@ -221,18 +185,6 @@ self.addEventListener('push', (event) => { return; } - // Handle dismiss action - close matching notifications on this device - if (payload.action === 'dismiss' && payload.tag) { - event.waitUntil( - (async () => { - const notifications = await self.registration.getNotifications({ tag: payload.tag }); - notifications.forEach((n) => n.close()); - await updateClosedAppBadgeAfterDismiss(payload.app_badge); - })() - ); - return; - } - const notification = normalizePushNotification(payload); event.waitUntil( diff --git a/cli/cmd/run.go b/cli/cmd/run.go index 5f6407e32..9c1fb0880 100644 --- a/cli/cmd/run.go +++ b/cli/cmd/run.go @@ -413,7 +413,10 @@ func setupPushNotifications(chattoCore *core.ChattoCore, cfg config.ChattoConfig if len(subscriptions) == 0 { return errors.New("no push subscriptions registered") } - subscriptions = filterOwnedPushSubscriptions(ctx, chattoCore, userID, subscriptions, logger) + subscriptions, err = filterOwnedPushSubscriptions(ctx, chattoCore, userID, subscriptions) + if err != nil { + return fmt.Errorf("revalidate push endpoint ownership: %w", err) + } if len(subscriptions) == 0 { return errors.New("no current push subscriptions registered") } @@ -447,152 +450,99 @@ func setupPushNotifications(chattoCore *core.ChattoCore, cfg config.ChattoConfig return errors.New("push provider did not accept the test notification") } - // Set the callback that will be invoked when notifications are created - chattoCore.OnNotificationCreated = func(ctx context.Context, notification *corev1.Notification) { - // Get user's push subscriptions - subscriptions, err := chattoCore.GetUserPushSubscriptions(ctx, notification.RecipientId) + chattoCore.OnNotificationOccurrenceCreated = func(ctx context.Context, occurrence *corev1.NotificationOccurrence) error { + visibleOccurrences, err := chattoCore.NotificationOccurrences().VisibleOccurrences(ctx, occurrence.GetRecipientId(), []*corev1.NotificationOccurrence{occurrence}) if err != nil { - logger.Warn("Failed to get push subscriptions", - "user_id", notification.RecipientId, - "error", err) - return + return fmt.Errorf("revalidate notification target visibility: %w", err) + } + if len(visibleOccurrences) == 0 { + _, _ = chattoCore.NotificationOccurrences().Delete(ctx, occurrence.GetRecipientId(), occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return nil + } + if occurrence.GetTarget() == nil { + return nil + } + subscriptions, err := chattoCore.GetUserPushSubscriptions(ctx, occurrence.GetRecipientId()) + if err != nil { + return fmt.Errorf("get push subscriptions: %w", err) } - if len(subscriptions) == 0 { - return + return nil } - - // Get actor's display name for the notification actorName := "Someone" - if notification.ActorId != "" { - actor, err := chattoCore.GetUser(ctx, notification.ActorId) - if err == nil && actor != nil { + if occurrence.GetActorId() != "" { + if actor, actorErr := chattoCore.GetUser(ctx, occurrence.GetActorId()); actorErr == nil && actor != nil { actorName = actor.DisplayName if actorName == "" { actorName = actor.Login } } } - - // Build payload context with message preview and room name - payloadCtx := fetchPayloadContext(ctx, chattoCore, notification, logger) - - // Build and send push notification - payload := push.BuildPayloadFromNotification(notification, actorName, cfg.Webserver.URL, payloadCtx) - if count, err := chattoCore.GetNotificationCount(ctx, notification.RecipientId); err == nil { + payload := push.BuildPayloadFromOccurrence(occurrence, actorName, cfg.Webserver.URL, fetchOccurrencePayloadContext(ctx, chattoCore, occurrence, logger)) + if count, countErr := chattoCore.NotificationOccurrences().UnreadGroupCount(ctx, occurrence.GetRecipientId()); countErr == nil { payload.AppBadge = strconv.Itoa(count) - } else { - logger.Warn("Failed to get notification count for push app badge", - "user_id", notification.RecipientId, - "error", err) } - // Creation and dismissal callbacks run asynchronously. A dismissal can - // overtake a slow creation callback, so fail closed if the notification is - // no longer pending immediately before delivery. - pending, err := chattoCore.GetNotification(ctx, notification.RecipientId, notification.Id) - if err != nil { - logger.Warn("Failed to revalidate notification before push delivery", - "user_id", notification.RecipientId, - "notification_id", notification.Id, - "error", err) - return - } - if pending == nil { - logger.Debug("Skipped stale push for dismissed notification", - "user_id", notification.RecipientId, - "notification_id", notification.Id) - return + // Revalidate after hydration so a concurrent delete or visibility purge + // cannot overtake a slow alert preparation. + claimCurrent, err := chattoCore.NotificationOccurrences().AlertClaimCurrent(ctx, occurrence) + if err != nil || !claimCurrent { + return err } - - subscriptions = filterOwnedPushSubscriptions(ctx, chattoCore, notification.RecipientId, subscriptions, logger) - if len(subscriptions) == 0 { - return + visibleOccurrences, err = chattoCore.NotificationOccurrences().VisibleOccurrences(ctx, occurrence.GetRecipientId(), []*corev1.NotificationOccurrence{occurrence}) + if err != nil { + return fmt.Errorf("revalidate notification target visibility before delivery: %w", err) } - results := sender.SendToMany(ctx, subscriptions, payload) - - // Process results - clean up expired subscriptions - for _, result := range results { - if result.Gone { - // Subscription is no longer valid, delete it - if err := chattoCore.DeletePushSubscription(ctx, notification.RecipientId, result.Endpoint); err != nil { - logger.Warn("Failed to delete expired push subscription", - "endpoint_id", push.EndpointLogID(result.Endpoint), - "error", err) - } else { - logger.Debug("Deleted expired push subscription", - "endpoint_id", push.EndpointLogID(result.Endpoint)) - } - } else if result.Error != nil { - logger.Warn("Failed to send push notification", - "endpoint_id", push.EndpointLogID(result.Endpoint), - "error", result.Error) - } else if result.Success { - logger.Debug("Push notification sent", - "user_id", notification.RecipientId, - "notification_id", notification.Id) - } + if len(visibleOccurrences) == 0 { + _, _ = chattoCore.NotificationOccurrences().Delete(ctx, occurrence.GetRecipientId(), occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return nil } - } - - // Set the callback that will be invoked when notifications are dismissed - chattoCore.OnNotificationDismissed = func(ctx context.Context, userID string, notification *corev1.Notification) { - // Get user's push subscriptions - subscriptions, err := chattoCore.GetUserPushSubscriptions(ctx, userID) + subscriptions, err = filterOwnedPushSubscriptions(ctx, chattoCore, occurrence.GetRecipientId(), subscriptions) if err != nil { - logger.Warn("Failed to get push subscriptions for dismiss", - "user_id", userID, - "error", err) - return + return fmt.Errorf("revalidate push endpoint ownership: %w", err) } - if len(subscriptions) == 0 { - return - } - - // Get the notification tag for dismissal - tag := push.NotificationTag(notification) - if tag == "" { - return + return nil } - - // Send dismiss push to all devices - payload := &push.Payload{ - Action: "dismiss", - Tag: tag, + renewed, renewedClaim, err := chattoCore.NotificationOccurrences().RenewAlertClaim(ctx, occurrence) + if err != nil || !renewedClaim { + return err } - if count, err := chattoCore.GetNotificationCount(ctx, userID); err == nil { - payload.AppBadge = strconv.Itoa(count) - } else { - logger.Warn("Failed to get notification count for dismiss app badge", - "user_id", userID, - "error", err) + // CompleteAlertClaim fences on this exact renewed timestamp. + occurrence.AlertClaimedUntil = renewed.GetAlertClaimedUntil() + status, err := chattoCore.GetUserPresence(ctx, occurrence.GetRecipientId()) + if err != nil { + return fmt.Errorf("revalidate notification presence before delivery: %w", err) } - subscriptions = filterOwnedPushSubscriptions(ctx, chattoCore, userID, subscriptions, logger) - if len(subscriptions) == 0 { - return + if status == core.PresenceStatusDoNotDisturb { + _, err := chattoCore.NotificationOccurrences().SilenceAlertClaim(ctx, occurrence) + return err } results := sender.SendToMany(ctx, subscriptions, payload) - - // Process results - clean up expired subscriptions + var sendErr error + accepted := false for _, result := range results { if result.Gone { - if err := chattoCore.DeletePushSubscription(ctx, userID, result.Endpoint); err != nil { - logger.Warn("Failed to delete expired push subscription", - "endpoint_id", push.EndpointLogID(result.Endpoint), - "error", err) - } - } else if result.Error != nil { - logger.Debug("Failed to send dismiss push", - "endpoint_id", push.EndpointLogID(result.Endpoint), - "error", result.Error) - } else if result.Success { - logger.Debug("Dismiss push sent", - "user_id", userID, - "tag", tag) + _ = chattoCore.DeletePushSubscription(ctx, occurrence.GetRecipientId(), result.Endpoint) + continue + } + if result.Success { + accepted = true + continue + } + if result.Error != nil { + sendErr = result.Error } } + // Delivery is occurrence-scoped: once any current device accepts the + // alert, complete the claim. Retrying the whole occurrence for another + // failing endpoint would duplicate alerts on every successful device. + if accepted { + return nil + } + return sendErr } + } func filterOwnedPushSubscriptions( @@ -600,48 +550,29 @@ func filterOwnedPushSubscriptions( chattoCore *core.ChattoCore, userID string, subscriptions []*corev1.PushSubscription, - logger *log.Logger, -) []*corev1.PushSubscription { +) ([]*corev1.PushSubscription, error) { owned := make([]*corev1.PushSubscription, 0, len(subscriptions)) for _, subscription := range subscriptions { isOwned, err := chattoCore.PushSubscriptionCurrentForUser(ctx, userID, subscription) if err != nil { - logger.Warn("Failed to revalidate push endpoint ownership", - "user_id", userID, - "endpoint_id", push.EndpointLogID(subscription.Endpoint), - "error", err) - continue + return nil, err } if isOwned { owned = append(owned, subscription) } } - return owned + return owned, nil } -// fetchPayloadContext builds the payload context with message preview and room name. -// This is best-effort - if fetching fails, returns nil and the notification will have a generic body. -func fetchPayloadContext(ctx context.Context, chattoCore *core.ChattoCore, notification *corev1.Notification, logger *log.Logger) *push.PayloadContext { - var roomID, eventID string - var kind core.RoomKind - - switch n := notification.Notification.(type) { - case *corev1.Notification_DmMessage: - kind = core.KindDM - roomID = n.DmMessage.RoomId - eventID = n.DmMessage.EventId - case *corev1.Notification_Mention: - roomID = n.Mention.RoomId - eventID = n.Mention.EventId - case *corev1.Notification_Reply: - roomID = n.Reply.RoomId - eventID = n.Reply.EventId - case *corev1.Notification_RoomMessage: - roomID = n.RoomMessage.RoomId - eventID = n.RoomMessage.EventId - default: +// fetchOccurrencePayloadContext builds a best-effort message preview and room +// name for an occurrence-backed push payload. +func fetchOccurrencePayloadContext(ctx context.Context, chattoCore *core.ChattoCore, occurrence *corev1.NotificationOccurrence, logger *log.Logger) *push.PayloadContext { + target := occurrence.GetTarget() + if target == nil { return nil } + roomID := target.GetRoomId() + eventID := target.GetEventId() if eventID == "" { return nil @@ -649,16 +580,11 @@ func fetchPayloadContext(ctx context.Context, chattoCore *core.ChattoCore, notif payloadCtx := &push.PayloadContext{} - if kind == "" { - // Mention and reply notifications no longer carry a kind on the - // wire — recover from the room record (mostly channels in practice). - var err error - kind, err = chattoCore.FindRoomKind(ctx, roomID) - if err != nil { - logger.Debug("Failed to resolve room kind for push notification preview", - "room_id", roomID, "error", err) - return nil - } + kind, err := chattoCore.FindRoomKind(ctx, roomID) + if err != nil { + logger.Debug("Failed to resolve room kind for push notification preview", + "room_id", roomID, "error", err) + return nil } // Fetch the message to get its body @@ -685,9 +611,7 @@ func fetchPayloadContext(ctx context.Context, chattoCore *core.ChattoCore, notif } } - // For notifications shown as channel activity, also fetch the room name. - switch notification.Notification.(type) { - case *corev1.Notification_Mention, *corev1.Notification_Reply, *corev1.Notification_RoomMessage: + if kind != core.KindDM { room, err := chattoCore.GetRoom(ctx, kind, roomID) if err != nil { logger.Debug("Failed to fetch room for push notification", diff --git a/cli/internal/connectapi/account_server_services_test.go b/cli/internal/connectapi/account_server_services_test.go index e80172339..4f8580e1c 100644 --- a/cli/internal/connectapi/account_server_services_test.go +++ b/cli/internal/connectapi/account_server_services_test.go @@ -46,13 +46,6 @@ func TestViewerServiceGetViewerReturnsSelfScopedState(t *testing.T) { if err := env.core.SetPresence(env.ctx, env.viewer.Id, core.PresenceStatusAway); err != nil { t.Fatalf("SetPresence: %v", err) } - if err := env.core.SetSpaceNotificationLevel(env.ctx, env.viewer.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED); err != nil { - t.Fatalf("SetSpaceNotificationLevel: %v", err) - } - room := env.createJoinedRoom("viewer-prefs") - if err := env.core.SetRoomNotificationLevel(env.ctx, env.viewer.Id, room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES); err != nil { - t.Fatalf("SetRoomNotificationLevel: %v", err) - } if err := env.core.GrantServerPermission(env.ctx, core.SystemActorID, core.RoleEveryone, core.PermRoleAssign); err != nil { t.Fatalf("GrantServerPermission role.assign: %v", err) } @@ -78,21 +71,6 @@ func TestViewerServiceGetViewerReturnsSelfScopedState(t *testing.T) { if settings := user.GetSettings(); settings.GetTimezone() != tz || settings.GetTimeFormat() != apiv1.TimeFormat_TIME_FORMAT_24_HOUR { t.Fatalf("settings = %+v, want timezone %q and 24-hour format", settings, tz) } - if pref := resp.Msg.GetServerNotificationPreference(); pref.GetLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED || pref.GetEffectiveLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Fatalf("server notification preference = %+v, want muted/muted", pref) - } - foundRoomPref := false - for _, pref := range resp.Msg.GetRoomNotificationPreferences() { - if pref.GetRoomId() == room.Id { - foundRoomPref = true - if pref.GetPreference().GetLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES || pref.GetPreference().GetEffectiveLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES { - t.Fatalf("room notification preference = %+v, want all/all", pref) - } - } - } - if !foundRoomPref { - t.Fatalf("room notification preferences did not include %s: %+v", room.Id, resp.Msg.GetRoomNotificationPreferences()) - } if caps := resp.Msg.GetCapabilities(); !apiCapabilityGranted(caps.GetGrants(), viewerCapabilityAssignRoles) || apiCapabilityGranted(caps.GetGrants(), viewerCapabilityAdminManageUsers) { t.Fatalf("viewer capabilities = %+v, want role.assign true and account management false", caps.GetGrants()) } diff --git a/cli/internal/connectapi/api.go b/cli/internal/connectapi/api.go index 994aed249..db483d817 100644 --- a/cli/internal/connectapi/api.go +++ b/cli/internal/connectapi/api.go @@ -135,7 +135,6 @@ func (a *API) Handlers() []Handler { messagePath, messageHandler := apiv1connect.NewMessageServiceHandler(&messageService{api: a}, options...) messageSearchPath, messageSearchHandler := apiv1connect.NewMessageSearchServiceHandler(&messageSearchService{api: a}, options...) notificationPath, notificationHandler := apiv1connect.NewNotificationServiceHandler(¬ificationService{api: a}, options...) - prefsPath, prefsHandler := apiv1connect.NewNotificationPreferencesServiceHandler(¬ificationPreferencesService{api: a}, options...) pushPath, pushHandler := apiv1connect.NewPushNotificationServiceHandler(&pushNotificationService{api: a}, options...) adminRolePath, adminRoleHandler := adminv1connect.NewAdminRoleServiceHandler(&roleService{api: a}, options...) rolePath, roleHandler := apiv1connect.NewRoleServiceHandler(&publicRoleService{api: a}, options...) @@ -163,7 +162,6 @@ func (a *API) Handlers() []Handler { {ServicePath: userPath, Handler: userHandler, AuthPolicy: AuthPolicyAuthenticatedUser}, {ServicePath: viewerPath, Handler: viewerHandler, AuthPolicy: AuthPolicyAuthenticatedUser}, {ServicePath: permissionPath, Handler: permissionHandler, AuthPolicy: AuthPolicyAuthenticatedUser}, - {ServicePath: prefsPath, Handler: prefsHandler, AuthPolicy: AuthPolicyAuthenticatedUser}, {ServicePath: pushPath, Handler: pushHandler, AuthPolicy: AuthPolicyAuthenticatedUser}, {ServicePath: adminRolePath, Handler: adminRoleHandler, AuthPolicy: AuthPolicyAuthenticatedUser}, {ServicePath: rolePath, Handler: roleHandler, AuthPolicy: AuthPolicyAuthenticatedUser}, diff --git a/cli/internal/connectapi/api_contract_test.go b/cli/internal/connectapi/api_contract_test.go index 4308210ae..266858e31 100644 --- a/cli/internal/connectapi/api_contract_test.go +++ b/cli/internal/connectapi/api_contract_test.go @@ -24,7 +24,6 @@ import ( apiv1 "hmans.de/chatto/internal/pb/chatto/api/v1" "hmans.de/chatto/internal/pb/chatto/api/v1/apiv1connect" "hmans.de/chatto/internal/pb/chatto/auth/v1/authv1connect" - corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" "hmans.de/chatto/internal/pb/chatto/discovery/v1/discoveryv1connect" ) @@ -57,7 +56,6 @@ func TestAPIHandlers(t *testing.T) { "/" + apiv1connect.MessageServiceName + "/", "/" + apiv1connect.MessageSearchServiceName + "/", "/" + apiv1connect.NotificationServiceName + "/", - "/" + apiv1connect.NotificationPreferencesServiceName + "/", "/" + adminv1connect.AdminPermissionServiceName + "/", "/" + apiv1connect.PushNotificationServiceName + "/", "/" + adminv1connect.AdminRoleServiceName + "/", @@ -88,34 +86,33 @@ func TestAPIHandlerAuthPolicies(t *testing.T) { } want := map[string]AuthPolicy{ - "/" + apiv1connect.MyAccountServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.AssetServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.AssetUploadServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + adminv1connect.AdminServerServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + authv1connect.ExternalIdentityAuthServiceName + "/": AuthPolicyPublic, - "/" + adminv1connect.AdminDiagnosticsServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + adminv1connect.AdminEventLogServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + adminv1connect.AdminInviteLinkServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + adminv1connect.AdminRoomLayoutServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + adminv1connect.AdminUserServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + grpcreflect.ReflectV1AlphaServiceName + "/": AuthPolicyPublic, - "/" + grpcreflect.ReflectV1ServiceName + "/": AuthPolicyPublic, - "/" + apiv1connect.MessageServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.MessageSearchServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.NotificationServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.NotificationPreferencesServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + adminv1connect.AdminPermissionServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.PushNotificationServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + adminv1connect.AdminRoleServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.RoleServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.RoomDirectoryServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.RoomServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + discoveryv1connect.ServerDiscoveryServiceName + "/": AuthPolicyPublic, - "/" + apiv1connect.ServerServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.ThreadServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.UserServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.ViewerServiceName + "/": AuthPolicyAuthenticatedUser, - "/" + apiv1connect.VoiceCallServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.MyAccountServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.AssetServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.AssetUploadServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + adminv1connect.AdminServerServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + authv1connect.ExternalIdentityAuthServiceName + "/": AuthPolicyPublic, + "/" + adminv1connect.AdminDiagnosticsServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + adminv1connect.AdminEventLogServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + adminv1connect.AdminInviteLinkServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + adminv1connect.AdminRoomLayoutServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + adminv1connect.AdminUserServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + grpcreflect.ReflectV1AlphaServiceName + "/": AuthPolicyPublic, + "/" + grpcreflect.ReflectV1ServiceName + "/": AuthPolicyPublic, + "/" + apiv1connect.MessageServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.MessageSearchServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.NotificationServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + adminv1connect.AdminPermissionServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.PushNotificationServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + adminv1connect.AdminRoleServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.RoleServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.RoomDirectoryServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.RoomServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + discoveryv1connect.ServerDiscoveryServiceName + "/": AuthPolicyPublic, + "/" + apiv1connect.ServerServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.ThreadServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.UserServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.ViewerServiceName + "/": AuthPolicyAuthenticatedUser, + "/" + apiv1connect.VoiceCallServiceName + "/": AuthPolicyAuthenticatedUser, } if len(got) != len(want) { t.Fatalf("auth policy count = %d, want %d (%v)", len(got), len(want), got) @@ -286,7 +283,6 @@ func TestBatchGetResourceRequestsValidateThroughConnectHandlers(t *testing.T) { messages := apiv1connect.NewMessageServiceClient(ts.Client(), ts.URL) users := apiv1connect.NewUserServiceClient(ts.Client(), ts.URL) rooms := apiv1connect.NewRoomServiceClient(ts.Client(), ts.URL) - notifications := apiv1connect.NewNotificationServiceClient(ts.Client(), ts.URL) voice := apiv1connect.NewVoiceCallServiceClient(ts.Client(), ts.URL) adminMembers := adminv1connect.NewAdminUserServiceClient(ts.Client(), ts.URL) adminServer := adminv1connect.NewAdminServerServiceClient(ts.Client(), ts.URL) @@ -362,23 +358,6 @@ func TestBatchGetResourceRequestsValidateThroughConnectHandlers(t *testing.T) { t.Fatalf("too-many BatchGetRoomMembers code = %v, want invalid_argument", connect.CodeOf(err)) } - if _, err := notifications.GetNotification(context.Background(), connect.NewRequest(&apiv1.GetNotificationRequest{})); connect.CodeOf(err) != connect.CodeInvalidArgument { - t.Fatalf("empty GetNotification code = %v, want invalid_argument", connect.CodeOf(err)) - } - if _, err := notifications.BatchGetNotifications(context.Background(), connect.NewRequest(&apiv1.BatchGetNotificationsRequest{})); connect.CodeOf(err) != connect.CodeInvalidArgument { - t.Fatalf("empty BatchGetNotifications code = %v, want invalid_argument", connect.CodeOf(err)) - } - if _, err := notifications.BatchGetNotifications(context.Background(), connect.NewRequest(&apiv1.BatchGetNotificationsRequest{NotificationIds: []string{""}})); connect.CodeOf(err) != connect.CodeInvalidArgument { - t.Fatalf("empty-id BatchGetNotifications code = %v, want invalid_argument", connect.CodeOf(err)) - } - tooManyNotificationIDs := make([]string, 101) - for i := range tooManyNotificationIDs { - tooManyNotificationIDs[i] = fmt.Sprintf("notification-%d", i) - } - if _, err := notifications.BatchGetNotifications(context.Background(), connect.NewRequest(&apiv1.BatchGetNotificationsRequest{NotificationIds: tooManyNotificationIDs})); connect.CodeOf(err) != connect.CodeInvalidArgument { - t.Fatalf("too-many BatchGetNotifications code = %v, want invalid_argument", connect.CodeOf(err)) - } - if _, err := messages.BatchGetMessages(context.Background(), connect.NewRequest(&apiv1.BatchGetMessagesRequest{})); connect.CodeOf(err) != connect.CodeInvalidArgument { t.Fatalf("empty BatchGetMessages code = %v, want invalid_argument", connect.CodeOf(err)) } @@ -445,51 +424,6 @@ func TestBatchGetResourceRequestsValidateThroughConnectHandlers(t *testing.T) { } } -func TestNotificationLevelMapping(t *testing.T) { - valid := []struct { - name string - api apiv1.NotificationLevel - core corev1.NotificationLevel - }{ - {"default clears core override", apiv1.NotificationLevel_NOTIFICATION_LEVEL_DEFAULT, corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED}, - {"muted", apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED}, - {"normal", apiv1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL, corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL}, - {"all messages", apiv1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES, corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES}, - } - - for _, tt := range valid { - t.Run(tt.name, func(t *testing.T) { - got, err := apiNotificationLevelToCore(tt.api) - if err != nil { - t.Fatalf("apiNotificationLevelToCore(%v) returned error: %v", tt.api, err) - } - if got != tt.core { - t.Fatalf("apiNotificationLevelToCore(%v) = %v, want %v", tt.api, got, tt.core) - } - }) - } - - invalid := []struct { - name string - api apiv1.NotificationLevel - }{ - {"unspecified is not user intent", apiv1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED}, - {"unknown enum", apiv1.NotificationLevel(99)}, - } - for _, tt := range invalid { - t.Run(tt.name, func(t *testing.T) { - _, err := apiNotificationLevelToCore(tt.api) - if got := connect.CodeOf(err); got != connect.CodeInvalidArgument { - t.Fatalf("apiNotificationLevelToCore(%v) error code = %v, want %v", tt.api, got, connect.CodeInvalidArgument) - } - }) - } - - if got := coreNotificationLevelToAPI(corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED); got != apiv1.NotificationLevel_NOTIFICATION_LEVEL_DEFAULT { - t.Fatalf("core unspecified maps to %v, want DEFAULT", got) - } -} - func TestConnectErrorMapping(t *testing.T) { tests := []struct { name string diff --git a/cli/internal/connectapi/api_test_helpers_test.go b/cli/internal/connectapi/api_test_helpers_test.go index 1d8ffc3d8..d530e92b9 100644 --- a/cli/internal/connectapi/api_test_helpers_test.go +++ b/cli/internal/connectapi/api_test_helpers_test.go @@ -115,7 +115,6 @@ type connectAPITestEnv struct { messages *messageService notifications *notificationService permissions *permissionService - prefs *notificationPreferencesService push *pushNotificationService publicRoles *publicRoleService roles *roleService @@ -169,7 +168,6 @@ func newConnectAPITestEnv(t *testing.T) *connectAPITestEnv { messages: &messageService{api: api}, notifications: ¬ificationService{api: api}, permissions: &permissionService{api: api}, - prefs: ¬ificationPreferencesService{api: api}, push: &pushNotificationService{api: api}, publicRoles: &publicRoleService{api: api}, roles: &roleService{api: api}, diff --git a/cli/internal/connectapi/notification_assembler.go b/cli/internal/connectapi/notification_assembler.go deleted file mode 100644 index 84d4fdfdc..000000000 --- a/cli/internal/connectapi/notification_assembler.go +++ /dev/null @@ -1,148 +0,0 @@ -package connectapi - -import ( - "context" - "errors" - "fmt" - - "hmans.de/chatto/internal/core" - "hmans.de/chatto/internal/parallel" - apiv1 "hmans.de/chatto/internal/pb/chatto/api/v1" - corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" -) - -type notificationAssembler struct { - api *API -} - -func newNotificationAssembler(api *API) *notificationAssembler { - return ¬ificationAssembler{api: api} -} - -func (a *notificationAssembler) pageFromList(ctx context.Context, notifications []*corev1.Notification, pageRequest *apiv1.PageRequest) (*apiv1.ListNotificationsResponse, error) { - limitVal, offsetVal := apiPagination(pageRequest, defaultNotificationLimit, maxNotificationLimit) - page, totalCount, hasMore := paginateNotifications(notifications, limitVal, offsetVal) - actorIDs := make([]string, 0, len(page)) - for _, notification := range page { - if actorID := notification.GetActorId(); actorID != "" { - actorIDs = append(actorIDs, actorID) - } - } - presences, err := a.api.core.GetUserPresences(ctx, actorIDs) - if err != nil { - return nil, err - } - hydrated, err := parallel.MapNonNil(ctx, maxConnectAPIHydrationConcurrency, page, func(ctx context.Context, _ int, notification *corev1.Notification) (*apiv1.NotificationItem, error) { - return a.itemWithPresence(ctx, notification, presences[notification.GetActorId()]) - }) - if err != nil { - return nil, err - } - return &apiv1.ListNotificationsResponse{ - Notifications: hydrated, - Page: apiPageInfo(totalCount, hasMore), - }, nil -} - -func (a *notificationAssembler) item(ctx context.Context, notification *corev1.Notification) (*apiv1.NotificationItem, error) { - if notification == nil { - return nil, nil - } - presence, err := a.api.core.GetUserPresence(ctx, notification.GetActorId()) - if err != nil { - return nil, err - } - return a.itemWithPresence(ctx, notification, presence) -} - -func (a *notificationAssembler) itemWithPresence(ctx context.Context, notification *corev1.Notification, presence string) (*apiv1.NotificationItem, error) { - if notification == nil { - return nil, nil - } - - actor, err := a.actor(ctx, notification.GetActorId(), presence) - if err != nil { - return nil, err - } - room, err := a.room(ctx, notificationTargetRoomID(notification)) - if err != nil { - return nil, err - } - item := &apiv1.NotificationItem{ - Id: notification.GetId(), - CreatedAt: notification.GetCreatedAt(), - Actor: actor, - } - - switch payload := notification.GetNotification().(type) { - case *corev1.Notification_DmMessage: - item.Kind = &apiv1.NotificationItem_DirectMessage{ - DirectMessage: &apiv1.DirectMessageNotification{ - EventId: payload.DmMessage.GetEventId(), - Room: room, - }, - } - case *corev1.Notification_Mention: - mention := &apiv1.MentionNotification{ - Room: room, - EventId: payload.Mention.GetEventId(), - } - if threadID := payload.Mention.GetInThread(); threadID != "" { - mention.ThreadRootEventId = &threadID - } - item.Kind = &apiv1.NotificationItem_Mention{Mention: mention} - case *corev1.Notification_Reply: - reply := &apiv1.ReplyNotification{ - Room: room, - EventId: payload.Reply.GetEventId(), - InReplyToId: payload.Reply.GetInReplyToId(), - } - if threadID := payload.Reply.GetInThread(); threadID != "" { - reply.ThreadRootEventId = &threadID - } - item.Kind = &apiv1.NotificationItem_Reply{Reply: reply} - case *corev1.Notification_RoomMessage: - item.Kind = &apiv1.NotificationItem_RoomMessage{ - RoomMessage: &apiv1.RoomMessageNotification{ - Room: room, - EventId: payload.RoomMessage.GetEventId(), - }, - } - default: - return nil, fmt.Errorf("unknown notification type %T", notification.GetNotification()) - } - - return item, nil -} - -func (a *notificationAssembler) actor(ctx context.Context, userID, presence string) (*apiv1.User, error) { - if userID == "" { - return nil, nil - } - user, err := a.api.core.GetUser(ctx, userID) - if err != nil { - if errors.Is(err, core.ErrNotFound) { - return nil, nil - } - return nil, err - } - actor, err := userSummaryWithPresence(ctx, a.api, user, nil, presence) - if err != nil { - return nil, err - } - return actor, nil -} - -func (a *notificationAssembler) room(ctx context.Context, roomID string) (*apiv1.RoomSummary, error) { - if roomID == "" { - return nil, nil - } - room, err := a.api.core.FindRoomByID(ctx, roomID) - if err != nil { - if errors.Is(err, core.ErrNotFound) { - return &apiv1.RoomSummary{Id: roomID}, nil - } - return nil, err - } - return apiRoomSummary(room), nil -} diff --git a/cli/internal/connectapi/notification_occurrence_assembler.go b/cli/internal/connectapi/notification_occurrence_assembler.go new file mode 100644 index 000000000..8debca112 --- /dev/null +++ b/cli/internal/connectapi/notification_occurrence_assembler.go @@ -0,0 +1,190 @@ +package connectapi + +import ( + "context" + "errors" + "sort" + + "hmans.de/chatto/internal/core" + "hmans.de/chatto/internal/parallel" + apiv1 "hmans.de/chatto/internal/pb/chatto/api/v1" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +const notificationGroupOccurrencePreviewLimit = 20 + +type notificationAssembler struct { + api *API +} + +func newNotificationAssembler(api *API) *notificationAssembler { + return ¬ificationAssembler{api: api} +} + +func (a *notificationAssembler) actor(ctx context.Context, userID, presence string) (*apiv1.User, error) { + if userID == "" { + return nil, nil + } + user, err := a.api.core.GetUser(ctx, userID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + return nil, nil + } + return nil, err + } + return userSummaryWithPresence(ctx, a.api, user, nil, presence) +} + +func (a *notificationAssembler) room(ctx context.Context, roomID string) (*apiv1.RoomSummary, error) { + if roomID == "" { + return nil, nil + } + room, err := a.api.core.FindRoomByID(ctx, roomID) + if err != nil { + if errors.Is(err, core.ErrNotFound) { + return &apiv1.RoomSummary{Id: roomID}, nil + } + return nil, err + } + return apiRoomSummary(room), nil +} + +func (a *notificationAssembler) occurrence(ctx context.Context, occurrence *corev1.NotificationOccurrence) (*apiv1.NotificationOccurrence, error) { + if occurrence == nil { + return nil, nil + } + presence, err := a.api.core.GetUserPresence(ctx, occurrence.GetActorId()) + if err != nil { + return nil, err + } + return a.occurrenceWithPresence(ctx, occurrence, presence) +} + +func (a *notificationAssembler) occurrenceWithPresence(ctx context.Context, occurrence *corev1.NotificationOccurrence, presence string) (*apiv1.NotificationOccurrence, error) { + if occurrence == nil { + return nil, nil + } + actor, err := a.actor(ctx, occurrence.GetActorId(), presence) + if err != nil { + return nil, err + } + room, err := a.room(ctx, occurrence.GetTarget().GetRoomId()) + if err != nil { + return nil, err + } + target := &apiv1.NotificationTarget{Room: room, EventId: occurrence.GetTarget().GetEventId()} + if value := occurrence.GetTarget().GetThreadRootEventId(); value != "" { + target.ThreadRootEventId = &value + } + if value := occurrence.GetTarget().GetParentEventId(); value != "" { + target.ParentEventId = &value + } + reasons := make([]*apiv1.NotificationReasonMatch, 0, len(occurrence.GetReasons())) + for _, match := range occurrence.GetReasons() { + reasons = append(reasons, &apiv1.NotificationReasonMatch{ + Reason: apiv1.NotificationReason(match.GetReason()), + Intensity: apiv1.NotificationDeliveryIntensity(match.GetIntensity()), + }) + } + return &apiv1.NotificationOccurrence{ + Id: occurrence.GetId(), + SourceEventId: occurrence.GetSourceEventId(), + CreatedAt: occurrence.GetSourceCreatedAt(), + Actor: actor, + Target: target, + Reasons: reasons, + StrongestIntensity: apiv1.NotificationDeliveryIntensity(occurrence.GetStrongestIntensity()), + Unread: occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD, + ExpiresAt: occurrence.GetExpiresAt(), + }, nil +} + +func (a *notificationAssembler) groups(ctx context.Context, groups []core.NotificationOccurrenceGroup) ([]*apiv1.NotificationGroup, error) { + actorIDs := make([]string, 0) + for _, group := range groups { + for _, occurrence := range group.Occurrences { + if actorID := occurrence.GetActorId(); actorID != "" { + actorIDs = append(actorIDs, actorID) + } + } + } + presences, err := a.api.core.GetUserPresences(ctx, actorIDs) + if err != nil { + return nil, err + } + return parallel.MapNonNil(ctx, maxConnectAPIHydrationConcurrency, groups, func(ctx context.Context, _ int, group core.NotificationOccurrenceGroup) (*apiv1.NotificationGroup, error) { + return a.groupWithPresences(ctx, group, presences) + }) +} + +func (a *notificationAssembler) groupWithPresences(ctx context.Context, group core.NotificationOccurrenceGroup, presences map[string]string) (*apiv1.NotificationGroup, error) { + if len(group.Occurrences) == 0 { + return nil, nil + } + unread := false + strongest := corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED + reasonSet := make(map[corev1.NotificationReason]struct{}) + var openOccurrence *corev1.NotificationOccurrence + var nextExpiry *corev1.NotificationOccurrence + for _, occurrence := range group.Occurrences { + if occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { + unread = true + if openOccurrence == nil { + openOccurrence = occurrence + } + } + if nextExpiry == nil || occurrence.GetExpiresAt().AsTime().Before(nextExpiry.GetExpiresAt().AsTime()) { + nextExpiry = occurrence + } + if occurrence.GetStrongestIntensity() > strongest { + strongest = occurrence.GetStrongestIntensity() + } + for _, match := range occurrence.GetReasons() { + reasonSet[match.GetReason()] = struct{}{} + } + } + if openOccurrence == nil { + openOccurrence = group.Occurrences[0] + } + previewCount := min(len(group.Occurrences), notificationGroupOccurrencePreviewLimit) + preview := append([]*corev1.NotificationOccurrence(nil), group.Occurrences[:previewCount]...) + openInPreview := false + for _, occurrence := range preview { + if occurrence.GetId() == openOccurrence.GetId() { + openInPreview = true + break + } + } + if !openInPreview { + preview[len(preview)-1] = openOccurrence + } + items, err := parallel.MapNonNil(ctx, maxConnectAPIHydrationConcurrency, preview, func(ctx context.Context, _ int, occurrence *corev1.NotificationOccurrence) (*apiv1.NotificationOccurrence, error) { + return a.occurrenceWithPresence(ctx, occurrence, presences[occurrence.GetActorId()]) + }) + if err != nil { + return nil, err + } + openPreviewIndex := 0 + for index, occurrence := range preview { + if occurrence.GetId() == openOccurrence.GetId() { + openPreviewIndex = index + } + } + reasons := make([]apiv1.NotificationReason, 0, len(reasonSet)) + for reason := range reasonSet { + reasons = append(reasons, apiv1.NotificationReason(reason)) + } + sort.Slice(reasons, func(i, j int) bool { return reasons[i] < reasons[j] }) + return &apiv1.NotificationGroup{ + Id: group.ID, + Occurrences: items, + OpenTarget: items[openPreviewIndex].GetTarget(), + Unread: unread, + OccurrenceCount: int32(len(group.Occurrences)), + LatestAt: items[0].GetCreatedAt(), + StrongestIntensity: apiv1.NotificationDeliveryIntensity(strongest), + Reasons: reasons, + NextExpiryAt: nextExpiry.GetExpiresAt(), + OpenNotificationId: openOccurrence.GetId(), + }, nil +} diff --git a/cli/internal/connectapi/notification_occurrences.go b/cli/internal/connectapi/notification_occurrences.go new file mode 100644 index 000000000..fde45dcb5 --- /dev/null +++ b/cli/internal/connectapi/notification_occurrences.go @@ -0,0 +1,346 @@ +package connectapi + +import ( + "context" + "sort" + + "connectrpc.com/connect" + "google.golang.org/protobuf/types/known/timestamppb" + "hmans.de/chatto/internal/core" + apiv1 "hmans.de/chatto/internal/pb/chatto/api/v1" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +const ( + defaultNotificationLimit = 50 + maxNotificationLimit = 100 +) + +type notificationService struct { + api *API +} + +func (s *notificationService) waitForCurrentOccurrences(ctx context.Context) error { + if err := s.api.core.NotificationOccurrences().WaitCurrent(ctx); err != nil { + return connectError(err) + } + return nil +} + +func (s *notificationService) ListNotificationGroups(ctx context.Context, req *connect.Request[apiv1.ListNotificationGroupsRequest]) (*connect.Response[apiv1.ListNotificationGroupsResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + if err := s.waitForCurrentOccurrences(ctx); err != nil { + return nil, err + } + groups, err := s.api.core.NotificationOccurrences().Groups(ctx, caller.UserID) + if err != nil { + return nil, connectError(err) + } + limit, offset := apiPagination(req.Msg.GetPage(), defaultNotificationLimit, maxNotificationLimit) + page, total, hasMore, err := s.visibleNotificationPage(ctx, caller.UserID, groups, limit, offset) + if err != nil { + return nil, connectError(err) + } + assembler := newNotificationAssembler(s.api) + hydrated, err := assembler.groups(ctx, page) + if err != nil { + return nil, connectError(err) + } + // Visibility filtering may have tombstoned stale rows. Re-read the + // local occurrence index so summary counts reflect those writes without + // performing another projection fence or exhaustive target validation. + groups, err = s.api.core.NotificationOccurrences().Groups(ctx, caller.UserID) + if err != nil { + return nil, connectError(err) + } + unreadGroupCount, nextExpiryAt, roomCounts := notificationSummary(groups) + return connect.NewResponse(&apiv1.ListNotificationGroupsResponse{ + Groups: hydrated, + Page: apiPageInfo(total, hasMore), + UnreadGroupCount: unreadGroupCount, + NextExpiryAt: nextExpiryAt, + RoomUnreadGroupCounts: roomCounts, + }), nil +} + +// visibleNotificationPage validates only the visible prefix needed for an +// offset page. It grows by one page when stale rows are filtered, instead of +// fencing and revalidating the entire 90-day list for every request. +func (s *notificationService) visibleNotificationPage(ctx context.Context, userID string, groups []core.NotificationOccurrenceGroup, limit, offset int) ([]core.NotificationOccurrenceGroup, int, bool, error) { + if offset >= len(groups) { + return []core.NotificationOccurrenceGroup{}, len(groups), false, nil + } + targetCount := offset + limit + 1 + scanEnd := min(len(groups), targetCount) + scanStart := 0 + visible := make([]core.NotificationOccurrenceGroup, 0, targetCount) + for { + batch, err := s.visibleNotificationGroups(ctx, userID, groups[scanStart:scanEnd]) + if err != nil { + return nil, 0, false, err + } + visible = append(visible, batch...) + if len(visible) >= targetCount || scanEnd == len(groups) { + break + } + scanStart = scanEnd + scanEnd = min(len(groups), scanEnd+max(limit, defaultNotificationLimit)) + } + total := len(groups) - (scanEnd - len(visible)) + if offset >= len(visible) { + return []core.NotificationOccurrenceGroup{}, total, scanEnd < len(groups), nil + } + end := min(len(visible), offset+limit) + hasMore := len(visible) > end || scanEnd < len(groups) + return visible[offset:end], total, hasMore, nil +} + +func notificationSummary(groups []core.NotificationOccurrenceGroup) (int32, *timestamppb.Timestamp, []*apiv1.NotificationRoomUnreadGroupCount) { + unreadGroupCount := int32(0) + roomCounts := make(map[string]int32) + var nextExpiryAt *timestamppb.Timestamp + for _, group := range groups { + groupUnread := false + roomID := "" + for _, occurrence := range group.Occurrences { + if roomID == "" { + roomID = occurrence.GetTarget().GetRoomId() + } + if nextExpiryAt == nil || occurrence.GetExpiresAt().AsTime().Before(nextExpiryAt.AsTime()) { + nextExpiryAt = occurrence.GetExpiresAt() + } + groupUnread = groupUnread || occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD + } + if groupUnread { + unreadGroupCount++ + if roomID != "" { + roomCounts[roomID]++ + } + } + } + roomIDs := make([]string, 0, len(roomCounts)) + for roomID := range roomCounts { + roomIDs = append(roomIDs, roomID) + } + sort.Strings(roomIDs) + result := make([]*apiv1.NotificationRoomUnreadGroupCount, 0, len(roomIDs)) + for _, roomID := range roomIDs { + result = append(result, &apiv1.NotificationRoomUnreadGroupCount{ + RoomId: roomID, + UnreadGroupCount: roomCounts[roomID], + }) + } + return unreadGroupCount, nextExpiryAt, result +} + +func (s *notificationService) visibleNotificationGroups(ctx context.Context, userID string, groups []core.NotificationOccurrenceGroup) ([]core.NotificationOccurrenceGroup, error) { + occurrences := make([]*corev1.NotificationOccurrence, 0) + for _, group := range groups { + occurrences = append(occurrences, group.Occurrences...) + } + allowedOccurrences, err := s.api.core.NotificationOccurrences().VisibleOccurrences(ctx, userID, occurrences) + if err != nil { + return nil, err + } + allowedIDs := make(map[string]struct{}, len(allowedOccurrences)) + for _, occurrence := range allowedOccurrences { + allowedIDs[occurrence.GetId()] = struct{}{} + } + visible := make([]core.NotificationOccurrenceGroup, 0, len(groups)) + for _, group := range groups { + visibleOccurrences := make([]*corev1.NotificationOccurrence, 0, len(group.Occurrences)) + for _, occurrence := range group.Occurrences { + if _, allowed := allowedIDs[occurrence.GetId()]; !allowed { + if _, err := s.api.core.NotificationOccurrences().Delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST); err != nil { + return nil, err + } + continue + } + visibleOccurrences = append(visibleOccurrences, occurrence) + } + if len(visibleOccurrences) == 0 { + continue + } + group.Occurrences = visibleOccurrences + visible = append(visible, group) + } + return visible, nil +} + +func (s *notificationService) notificationOccurrenceVisible(ctx context.Context, userID string, occurrence *corev1.NotificationOccurrence) (bool, error) { + visible, err := s.api.core.NotificationOccurrences().VisibleOccurrences(ctx, userID, []*corev1.NotificationOccurrence{occurrence}) + return len(visible) == 1, err +} + +func (s *notificationService) MarkNotificationRead(ctx context.Context, req *connect.Request[apiv1.MarkNotificationReadRequest]) (*connect.Response[apiv1.MarkNotificationReadResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + if err := s.waitForCurrentOccurrences(ctx); err != nil { + return nil, err + } + existing, err := s.api.core.NotificationOccurrences().Get(ctx, caller.UserID, req.Msg.GetNotificationId()) + if err != nil { + return nil, connectError(err) + } + visible, err := s.notificationOccurrenceVisible(ctx, caller.UserID, existing) + if err != nil { + return nil, connectError(err) + } + if !visible { + _, _ = s.api.core.NotificationOccurrences().Delete(ctx, caller.UserID, existing.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return nil, connectError(core.ErrNotFound) + } + occurrence, err := s.api.core.NotificationOccurrences().MarkRead(ctx, caller.UserID, req.Msg.GetNotificationId()) + if err != nil { + return nil, connectError(err) + } + item, err := newNotificationAssembler(s.api).occurrence(ctx, occurrence) + if err != nil { + return nil, connectError(err) + } + return connect.NewResponse(&apiv1.MarkNotificationReadResponse{Notification: item}), nil +} + +func (s *notificationService) DeleteNotificationOccurrence(ctx context.Context, req *connect.Request[apiv1.DeleteNotificationOccurrenceRequest]) (*connect.Response[apiv1.DeleteNotificationOccurrenceResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + if err := s.waitForCurrentOccurrences(ctx); err != nil { + return nil, err + } + deleted, err := s.api.core.NotificationOccurrences().Delete( + ctx, + caller.UserID, + req.Msg.GetNotificationId(), + corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_DELETED, + ) + if err != nil { + return nil, connectError(err) + } + return connect.NewResponse(&apiv1.DeleteNotificationOccurrenceResponse{Deleted: deleted}), nil +} + +func (s *notificationService) requireVisibleNotificationGroup(ctx context.Context, userID, groupID string) error { + groups, err := s.api.core.NotificationOccurrences().Groups(ctx, userID) + if err != nil { + return err + } + for _, group := range groups { + if group.ID != groupID || len(group.Occurrences) == 0 { + continue + } + visible, err := s.api.core.NotificationOccurrences().VisibleOccurrences(ctx, userID, group.Occurrences) + if err != nil { + return err + } + visibleIDs := make(map[string]struct{}, len(visible)) + for _, occurrence := range visible { + visibleIDs[occurrence.GetId()] = struct{}{} + } + for _, occurrence := range group.Occurrences { + if _, allowed := visibleIDs[occurrence.GetId()]; !allowed { + if _, err := s.api.core.NotificationOccurrences().Delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST); err != nil { + return err + } + } + } + if len(visible) > 0 { + return nil + } + return core.ErrNotFound + } + return core.ErrNotFound +} + +func (s *notificationService) DeleteNotificationGroup(ctx context.Context, req *connect.Request[apiv1.DeleteNotificationGroupRequest]) (*connect.Response[apiv1.DeleteNotificationGroupResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + if err := s.waitForCurrentOccurrences(ctx); err != nil { + return nil, err + } + if err := s.requireVisibleNotificationGroup(ctx, caller.UserID, req.Msg.GetGroupId()); err != nil { + return nil, connectError(err) + } + count, err := s.api.core.NotificationOccurrences().DeleteGroup(ctx, caller.UserID, req.Msg.GetGroupId()) + if err != nil { + return nil, connectError(err) + } + return connect.NewResponse(&apiv1.DeleteNotificationGroupResponse{DeletedCount: int32(count)}), nil +} + +func (s *notificationService) DeleteAllNotificationOccurrences(ctx context.Context, _ *connect.Request[apiv1.DeleteAllNotificationOccurrencesRequest]) (*connect.Response[apiv1.DeleteAllNotificationOccurrencesResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + if err := s.waitForCurrentOccurrences(ctx); err != nil { + return nil, err + } + count, err := s.api.core.NotificationOccurrences().DeleteAll(ctx, caller.UserID) + if err != nil { + return nil, connectError(err) + } + return connect.NewResponse(&apiv1.DeleteAllNotificationOccurrencesResponse{DeletedCount: int32(count)}), nil +} + +func apiNotificationPolicy(roomID string, policy []core.NotificationPolicyPreference) (*apiv1.GetNotificationPolicyResponse, *apiv1.SetNotificationPolicyPreferenceResponse) { + preferences := make([]*apiv1.NotificationPolicyPreference, 0, len(policy)) + for _, preference := range policy { + preferences = append(preferences, &apiv1.NotificationPolicyPreference{ + Reason: apiv1.NotificationReason(preference.Reason), + ServerIntensity: apiv1.NotificationDeliveryIntensity(preference.ServerIntensity), + RoomIntensity: apiv1.NotificationDeliveryIntensity(preference.RoomIntensity), + EffectiveIntensity: apiv1.NotificationDeliveryIntensity(preference.Effective), + }) + } + get := &apiv1.GetNotificationPolicyResponse{Preferences: preferences} + set := &apiv1.SetNotificationPolicyPreferenceResponse{Preferences: preferences} + if roomID != "" { + get.RoomId = &roomID + set.RoomId = &roomID + } + return get, set +} + +func (s *notificationService) GetNotificationPolicy(ctx context.Context, req *connect.Request[apiv1.GetNotificationPolicyRequest]) (*connect.Response[apiv1.GetNotificationPolicyResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + roomID := req.Msg.GetRoomId() + policy, err := s.api.core.NotificationPolicy().GetNotificationPolicy(ctx, caller.UserID, roomID) + if err != nil { + return nil, connectError(err) + } + response, _ := apiNotificationPolicy(roomID, policy) + return connect.NewResponse(response), nil +} + +func (s *notificationService) SetNotificationPolicyPreference(ctx context.Context, req *connect.Request[apiv1.SetNotificationPolicyPreferenceRequest]) (*connect.Response[apiv1.SetNotificationPolicyPreferenceResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + roomID := req.Msg.GetRoomId() + reason := corev1.NotificationReason(req.Msg.GetReason()) + intensity := corev1.NotificationDeliveryIntensity(req.Msg.GetIntensity()) + var policy []core.NotificationPolicyPreference + if roomID == "" { + policy, err = s.api.core.NotificationPolicy().SetServerNotificationIntensity(ctx, caller.UserID, reason, intensity) + } else { + policy, err = s.api.core.NotificationPolicy().SetRoomNotificationIntensity(ctx, caller.UserID, roomID, reason, intensity) + } + if err != nil { + return nil, connectError(err) + } + _, response := apiNotificationPolicy(roomID, policy) + return connect.NewResponse(response), nil +} diff --git a/cli/internal/connectapi/notification_preferences.go b/cli/internal/connectapi/notification_preferences.go deleted file mode 100644 index 5b8250ee2..000000000 --- a/cli/internal/connectapi/notification_preferences.go +++ /dev/null @@ -1,130 +0,0 @@ -package connectapi - -import ( - "context" - "strings" - - "connectrpc.com/connect" - apiv1 "hmans.de/chatto/internal/pb/chatto/api/v1" - corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" -) - -type notificationPreferencesService struct { - api *API -} - -func (s *notificationPreferencesService) GetServerNotificationPreference(ctx context.Context, _ *connect.Request[apiv1.GetServerNotificationPreferenceRequest]) (*connect.Response[apiv1.GetNotificationPreferenceResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - - level, err := s.api.core.GetSpaceNotificationLevel(ctx, caller.UserID) - if err != nil { - return nil, connectError(err) - } - effectiveLevel := level - if effectiveLevel == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - effectiveLevel = corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL - } - return connect.NewResponse(&apiv1.GetNotificationPreferenceResponse{ - Preference: apiNotificationPreference(level, effectiveLevel), - }), nil -} - -func (s *notificationPreferencesService) UpdateServerNotificationPreference(ctx context.Context, req *connect.Request[apiv1.UpdateServerNotificationPreferenceRequest]) (*connect.Response[apiv1.UpdateNotificationPreferenceResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - level, err := apiNotificationLevelToCore(req.Msg.Level) - if err != nil { - return nil, err - } - - if err := s.api.core.SetSpaceNotificationLevel(ctx, caller.UserID, level); err != nil { - return nil, connectError(err) - } - effectiveLevel := level - if effectiveLevel == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - effectiveLevel = corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL - } - return connect.NewResponse(&apiv1.UpdateNotificationPreferenceResponse{ - Preference: apiNotificationPreference(level, effectiveLevel), - }), nil -} - -func (s *notificationPreferencesService) GetRoomNotificationPreference(ctx context.Context, req *connect.Request[apiv1.GetRoomNotificationPreferenceRequest]) (*connect.Response[apiv1.GetNotificationPreferenceResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - if strings.TrimSpace(req.Msg.RoomId) == "" { - return nil, invalidArgument("room_id is required") - } - - pref, err := s.api.core.NotificationPreferences().GetRoomNotificationPreference(ctx, caller.UserID, req.Msg.RoomId) - if err != nil { - return nil, connectError(err) - } - return connect.NewResponse(&apiv1.GetNotificationPreferenceResponse{ - Preference: apiNotificationPreference(pref.Level, pref.EffectiveLevel), - }), nil -} - -func (s *notificationPreferencesService) UpdateRoomNotificationPreference(ctx context.Context, req *connect.Request[apiv1.UpdateRoomNotificationPreferenceRequest]) (*connect.Response[apiv1.UpdateNotificationPreferenceResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - if strings.TrimSpace(req.Msg.RoomId) == "" { - return nil, invalidArgument("room_id is required") - } - level, err := apiNotificationLevelToCore(req.Msg.Level) - if err != nil { - return nil, err - } - - pref, err := s.api.core.NotificationPreferences().SetRoomNotificationLevel(ctx, caller.UserID, req.Msg.RoomId, level) - if err != nil { - return nil, connectError(err) - } - return connect.NewResponse(&apiv1.UpdateNotificationPreferenceResponse{ - Preference: apiNotificationPreference(pref.Level, pref.EffectiveLevel), - }), nil -} - -func apiNotificationPreference(level, effectiveLevel corev1.NotificationLevel) *apiv1.NotificationPreference { - return &apiv1.NotificationPreference{ - Level: coreNotificationLevelToAPI(level), - EffectiveLevel: coreNotificationLevelToAPI(effectiveLevel), - } -} - -func apiNotificationLevelToCore(level apiv1.NotificationLevel) (corev1.NotificationLevel, error) { - switch level { - case apiv1.NotificationLevel_NOTIFICATION_LEVEL_DEFAULT: - return corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED, nil - case apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED: - return corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, nil - case apiv1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL: - return corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL, nil - case apiv1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES: - return corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES, nil - default: - return corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED, invalidArgument("notification level must be DEFAULT, MUTED, NORMAL, or ALL_MESSAGES") - } -} - -func coreNotificationLevelToAPI(level corev1.NotificationLevel) apiv1.NotificationLevel { - switch level { - case corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED: - return apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED - case corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL: - return apiv1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL - case corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES: - return apiv1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES - default: - return apiv1.NotificationLevel_NOTIFICATION_LEVEL_DEFAULT - } -} diff --git a/cli/internal/connectapi/notifications.go b/cli/internal/connectapi/notifications.go deleted file mode 100644 index b03e11396..000000000 --- a/cli/internal/connectapi/notifications.go +++ /dev/null @@ -1,221 +0,0 @@ -package connectapi - -import ( - "context" - - "connectrpc.com/connect" - "hmans.de/chatto/internal/core" - apiv1 "hmans.de/chatto/internal/pb/chatto/api/v1" - corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" -) - -const ( - defaultNotificationLimit = 50 - maxNotificationLimit = 100 -) - -type notificationService struct { - api *API -} - -func (s *notificationService) ListNotifications(ctx context.Context, req *connect.Request[apiv1.ListNotificationsRequest]) (*connect.Response[apiv1.ListNotificationsResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - return s.notificationPage(ctx, caller.UserID, req.Msg.GetPage(), nil) -} - -func (s *notificationService) GetNotification(ctx context.Context, req *connect.Request[apiv1.GetNotificationRequest]) (*connect.Response[apiv1.GetNotificationResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - - notification, err := s.api.core.GetNotification(ctx, caller.UserID, req.Msg.GetNotificationId()) - if err != nil { - return nil, connectError(err) - } - if notification == nil { - return nil, connectError(core.ErrNotFound) - } - assembler := newNotificationAssembler(s.api) - item, err := assembler.item(ctx, notification) - if err != nil { - return nil, connectError(err) - } - return connect.NewResponse(&apiv1.GetNotificationResponse{ - Notification: item, - }), nil -} - -func (s *notificationService) BatchGetNotifications(ctx context.Context, req *connect.Request[apiv1.BatchGetNotificationsRequest]) (*connect.Response[apiv1.BatchGetNotificationsResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - - assembler := newNotificationAssembler(s.api) - seen := make(map[string]struct{}, len(req.Msg.GetNotificationIds())) - notifications := make([]*apiv1.NotificationItem, 0, len(req.Msg.GetNotificationIds())) - for _, notificationID := range req.Msg.GetNotificationIds() { - if _, ok := seen[notificationID]; ok { - continue - } - seen[notificationID] = struct{}{} - - notification, err := s.api.core.GetNotification(ctx, caller.UserID, notificationID) - if err != nil { - return nil, connectError(err) - } - if notification == nil { - continue - } - item, err := assembler.item(ctx, notification) - if err != nil { - return nil, connectError(err) - } - notifications = append(notifications, item) - } - return connect.NewResponse(&apiv1.BatchGetNotificationsResponse{ - Notifications: notifications, - }), nil -} - -func (s *notificationService) ListRoomNotifications(ctx context.Context, req *connect.Request[apiv1.ListRoomNotificationsRequest]) (*connect.Response[apiv1.ListRoomNotificationsResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - notifications, err := s.api.core.GetRoomNotificationsForMember(ctx, caller.UserID, req.Msg.GetRoomId()) - if err != nil { - return nil, connectError(err) - } - page, err := newNotificationAssembler(s.api).pageFromList(ctx, notifications, req.Msg.GetPage()) - if err != nil { - return nil, connectError(err) - } - return connect.NewResponse(&apiv1.ListRoomNotificationsResponse{ - Notifications: page.GetNotifications(), - Page: page.GetPage(), - }), nil -} - -func (s *notificationService) HasNotifications(ctx context.Context, _ *connect.Request[apiv1.HasNotificationsRequest]) (*connect.Response[apiv1.HasNotificationsResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - has, err := s.api.core.HasUnreadNotifications(ctx, caller.UserID) - if err != nil { - return nil, connectError(err) - } - return connect.NewResponse(&apiv1.HasNotificationsResponse{HasNotifications: has}), nil -} - -func (s *notificationService) ListRoomNotificationCounts(ctx context.Context, _ *connect.Request[apiv1.ListRoomNotificationCountsRequest]) (*connect.Response[apiv1.ListRoomNotificationCountsResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - notifications, err := s.api.core.GetNotifications(ctx, caller.UserID) - if err != nil { - return nil, connectError(err) - } - countsByRoom := make(map[string]int32) - for _, notification := range notifications { - roomID := notificationTargetRoomID(notification) - if roomID == "" { - continue - } - countsByRoom[roomID]++ - } - roomCounts := make([]*apiv1.RoomNotificationCount, 0, len(countsByRoom)) - for roomID, count := range countsByRoom { - roomCounts = append(roomCounts, &apiv1.RoomNotificationCount{ - RoomId: roomID, - TotalCount: count, - }) - } - return connect.NewResponse(&apiv1.ListRoomNotificationCountsResponse{RoomCounts: roomCounts}), nil -} - -func (s *notificationService) DismissNotification(ctx context.Context, req *connect.Request[apiv1.DismissNotificationRequest]) (*connect.Response[apiv1.DismissNotificationResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - if req.Msg.NotificationId == "" { - return nil, invalidArgument("notification_id is required") - } - _, err = s.api.core.DismissNotification(ctx, caller.UserID, req.Msg.NotificationId) - if err != nil { - return nil, connectError(err) - } - return connect.NewResponse(&apiv1.DismissNotificationResponse{Dismissed: true}), nil -} - -func (s *notificationService) DismissAllNotifications(ctx context.Context, _ *connect.Request[apiv1.DismissAllNotificationsRequest]) (*connect.Response[apiv1.DismissAllNotificationsResponse], error) { - caller, err := requireCaller(ctx) - if err != nil { - return nil, err - } - count, err := s.api.core.DismissAllNotifications(ctx, caller.UserID) - if err != nil { - return nil, connectError(err) - } - return connect.NewResponse(&apiv1.DismissAllNotificationsResponse{DismissedCount: int32(count)}), nil -} - -func (s *notificationService) notificationPage(ctx context.Context, userID string, pageRequest *apiv1.PageRequest, matches func(*corev1.Notification) bool) (*connect.Response[apiv1.ListNotificationsResponse], error) { - notifications, err := s.api.core.GetNotifications(ctx, userID) - if err != nil { - return nil, connectError(err) - } - - filtered := notifications - if matches != nil { - filtered = make([]*corev1.Notification, 0, len(notifications)) - for _, notification := range notifications { - if matches(notification) { - filtered = append(filtered, notification) - } - } - } - - page, err := newNotificationAssembler(s.api).pageFromList(ctx, filtered, pageRequest) - if err != nil { - return nil, connectError(err) - } - return connect.NewResponse(page), nil -} - -func notificationTargetRoomID(notification *corev1.Notification) string { - if notification == nil { - return "" - } - switch payload := notification.GetNotification().(type) { - case *corev1.Notification_DmMessage: - return payload.DmMessage.GetRoomId() - case *corev1.Notification_Mention: - return payload.Mention.GetRoomId() - case *corev1.Notification_Reply: - return payload.Reply.GetRoomId() - case *corev1.Notification_RoomMessage: - return payload.RoomMessage.GetRoomId() - default: - return "" - } -} - -func paginateNotifications(notifications []*corev1.Notification, limit, offset int) ([]*corev1.Notification, int, bool) { - total := len(notifications) - if offset >= total { - return []*corev1.Notification{}, total, false - } - end := offset + limit - if end > total { - end = total - } - return notifications[offset:end], total, end < total -} diff --git a/cli/internal/connectapi/realtime_projection.go b/cli/internal/connectapi/realtime_projection.go index a0c1e2b76..d21f52678 100644 --- a/cli/internal/connectapi/realtime_projection.go +++ b/cli/internal/connectapi/realtime_projection.go @@ -47,10 +47,9 @@ type RealtimeProjectionServerState struct { // carries every public directory user exactly once; timelines are hydrated // independently when first viewed. type RealtimeProjectionRoom struct { - Room *apiv1.RoomWithViewerState - MemberUserIDs []string - ViewerNotificationCount uint32 - HasMessageHistory *bool + Room *apiv1.RoomWithViewerState + MemberUserIDs []string + HasMessageHistory *bool } // RealtimeProjectionRoomTimeline identifies one compacted recent room window. @@ -60,11 +59,10 @@ type RealtimeProjectionRoomTimeline struct { EventCursors map[string]string } -// RealtimeProjectionNotifications is the finite current notification page and -// complete per-room counts reconciled on bootstrap and every socket resume. +// RealtimeProjectionNotifications is the finite current Notifications 2.0 +// group page reconciled on bootstrap and every socket resume. type RealtimeProjectionNotifications struct { - Page *apiv1.ListNotificationsResponse - RoomCounts []*apiv1.RoomNotificationCount + Groups *apiv1.ListNotificationGroupsResponse } // RealtimeProjectionRoomViewerState is one latest-value room read/permission @@ -208,15 +206,11 @@ func (a *API) BuildRealtimeProjectionSnapshot(ctx context.Context, userID string if err != nil { return nil, fmt.Errorf("assemble realtime active calls: %w", err) } - notificationCounts := make(map[string]uint32, len(notifications.RoomCounts)) - for _, count := range notifications.RoomCounts { - notificationCounts[count.GetRoomId()] = uint32(max(count.GetTotalCount(), 0)) - } apiRooms := make([]*RealtimeProjectionRoom, 0, len(rooms)) memberRooms := make(map[string]*core.DirectoryRoom, len(rooms)) for _, room := range rooms { _, includeMembership := retainedRoomIDs[room.Room.GetId()] - apiRoom, err := a.realtimeProjectionRoom(ctx, userID, room, notificationCounts[room.Room.GetId()], includeMembership) + apiRoom, err := a.realtimeProjectionRoom(ctx, userID, room, includeMembership) if err != nil { return nil, fmt.Errorf("assemble realtime room %q: %w", room.Room.GetId(), err) } @@ -380,14 +374,10 @@ func (a *API) buildRealtimeProjectionRoom(ctx context.Context, userID, roomID st if err != nil { return nil, err } - counts, err := a.realtimeProjectionNotificationCounts(ctx, userID) - if err != nil { - return nil, err - } - return a.realtimeProjectionRoom(ctx, userID, room, counts[roomID], includeChannelMembership) + return a.realtimeProjectionRoom(ctx, userID, room, includeChannelMembership) } -func (a *API) realtimeProjectionRoom(ctx context.Context, userID string, room *core.DirectoryRoom, notificationCount uint32, includeChannelMembership bool) (*RealtimeProjectionRoom, error) { +func (a *API) realtimeProjectionRoom(ctx context.Context, userID string, room *core.DirectoryRoom, includeChannelMembership bool) (*RealtimeProjectionRoom, error) { if room == nil || room.Room == nil { return nil, core.ErrNotFound } @@ -403,10 +393,10 @@ func (a *API) realtimeProjectionRoom(ctx context.Context, userID string, room *c // the viewer joins. Their member list is not authorized at that point and // is not needed until the room becomes a joined-room projection. if !room.ViewerState.IsMember { - return &RealtimeProjectionRoom{Room: apiRoomWithViewerState(room), ViewerNotificationCount: notificationCount, HasMessageHistory: hasMessageHistory}, nil + return &RealtimeProjectionRoom{Room: apiRoomWithViewerState(room), HasMessageHistory: hasMessageHistory}, nil } if room.Room.GetKind() != corev1.RoomKind_ROOM_KIND_DM && !includeChannelMembership { - return &RealtimeProjectionRoom{Room: apiRoomWithViewerState(room), ViewerNotificationCount: notificationCount, HasMessageHistory: hasMessageHistory}, nil + return &RealtimeProjectionRoom{Room: apiRoomWithViewerState(room), HasMessageHistory: hasMessageHistory}, nil } members, err := a.core.ListRoomMemberReferencesForList(ctx, userID, room.Room.GetId()) if err != nil { @@ -418,21 +408,7 @@ func (a *API) realtimeProjectionRoom(ctx context.Context, userID string, room *c memberIDs = append(memberIDs, member.GetId()) } } - return &RealtimeProjectionRoom{Room: apiRoomWithViewerState(room), MemberUserIDs: memberIDs, ViewerNotificationCount: notificationCount, HasMessageHistory: hasMessageHistory}, nil -} - -func (a *API) realtimeProjectionNotificationCounts(ctx context.Context, userID string) (map[string]uint32, error) { - notifications, err := a.core.GetNotifications(ctx, userID) - if err != nil { - return nil, err - } - counts := make(map[string]uint32) - for _, notification := range notifications { - if roomID := notificationTargetRoomID(notification); roomID != "" { - counts[roomID]++ - } - } - return counts, nil + return &RealtimeProjectionRoom{Room: apiRoomWithViewerState(room), MemberUserIDs: memberIDs, HasMessageHistory: hasMessageHistory}, nil } // BuildRealtimeProjectionRoomViewerState returns current viewer-specific room @@ -446,29 +422,42 @@ func (a *API) BuildRealtimeProjectionRoomViewerState(ctx context.Context, userID return apiRoom.GetViewerState(), nil } -// BuildRealtimeProjectionNotifications returns the viewer's newest pending -// notification page plus complete room counts. It is intentionally emitted on -// every resume because RUNTIME_STATE notification mutations have no EVT cursor. +// BuildRealtimeProjectionNotifications returns the viewer's authoritative +// Notifications 2.0 groups. It is emitted on every resume because +// RUNTIME_STATE occurrence mutations have no EVT cursor. func (a *API) BuildRealtimeProjectionNotifications(ctx context.Context, userID string) (*RealtimeProjectionNotifications, error) { - notifications, err := a.core.GetNotifications(ctx, userID) + if err := a.core.NotificationOccurrences().WaitCurrent(ctx); err != nil { + return nil, err + } + groups, err := a.core.NotificationOccurrences().Groups(ctx, userID) if err != nil { return nil, err } - page, err := newNotificationAssembler(a).pageFromList(ctx, notifications, &apiv1.PageRequest{Limit: defaultNotificationLimit}) + page, total, hasMore, err := (¬ificationService{api: a}).visibleNotificationPage(ctx, userID, groups, defaultNotificationLimit, 0) if err != nil { return nil, err } - counts := make(map[string]int32) - for _, notification := range notifications { - if roomID := notificationTargetRoomID(notification); roomID != "" { - counts[roomID]++ - } + assembler := newNotificationAssembler(a) + // The page validation may have purged stale occurrences. Refresh the local + // index-backed groups before deriving aggregate counts. + groups, err = a.core.NotificationOccurrences().Groups(ctx, userID) + if err != nil { + return nil, err } - roomCounts := make([]*apiv1.RoomNotificationCount, 0, len(counts)) - for roomID, count := range counts { - roomCounts = append(roomCounts, &apiv1.RoomNotificationCount{RoomId: roomID, TotalCount: count}) + unreadGroups, nextExpiryAt, roomCounts := notificationSummary(groups) + hydratedGroups, err := assembler.groups(ctx, page) + if err != nil { + return nil, err } - return &RealtimeProjectionNotifications{Page: page, RoomCounts: roomCounts}, nil + return &RealtimeProjectionNotifications{ + Groups: &apiv1.ListNotificationGroupsResponse{ + Groups: hydratedGroups, + Page: apiPageInfo(total, hasMore), + UnreadGroupCount: unreadGroups, + NextExpiryAt: nextExpiryAt, + RoomUnreadGroupCounts: roomCounts, + }, + }, nil } // BuildRealtimeProjectionUser returns the current public directory row. PII is diff --git a/cli/internal/connectapi/reflection.go b/cli/internal/connectapi/reflection.go index 091f5e552..26b5a9b2e 100644 --- a/cli/internal/connectapi/reflection.go +++ b/cli/internal/connectapi/reflection.go @@ -33,7 +33,6 @@ var publicReflectionServiceNames = []string{ adminv1connect.AdminServerServiceName, apiv1connect.MessageServiceName, apiv1connect.MessageSearchServiceName, - apiv1connect.NotificationPreferencesServiceName, apiv1connect.NotificationServiceName, apiv1connect.PushNotificationServiceName, apiv1connect.RoleServiceName, diff --git a/cli/internal/connectapi/room_services_test.go b/cli/internal/connectapi/room_services_test.go index ced3cd455..cacd4eb32 100644 --- a/cli/internal/connectapi/room_services_test.go +++ b/cli/internal/connectapi/room_services_test.go @@ -2,6 +2,7 @@ package connectapi import ( "context" + "errors" "fmt" "strings" "testing" @@ -12,6 +13,7 @@ import ( "hmans.de/chatto/internal/config" "hmans.de/chatto/internal/core" + "hmans.de/chatto/internal/core/subjects" adminv1 "hmans.de/chatto/internal/pb/chatto/admin/v1" apiv1 "hmans.de/chatto/internal/pb/chatto/api/v1" corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" @@ -704,16 +706,18 @@ func TestConnectServicesRejectDMOutsiders(t *testing.T) { })) checkInaccessible("UnfollowThread", err) - _, err = env.prefs.GetRoomNotificationPreference(ctx, connect.NewRequest(&apiv1.GetRoomNotificationPreferenceRequest{ - RoomId: dm.Id, + roomID := dm.Id + _, err = env.notifications.GetNotificationPolicy(ctx, connect.NewRequest(&apiv1.GetNotificationPolicyRequest{ + RoomId: &roomID, })) - checkInaccessible("GetRoomNotificationPreference", err) + checkInaccessible("GetNotificationPolicy", err) - _, err = env.prefs.UpdateRoomNotificationPreference(ctx, connect.NewRequest(&apiv1.UpdateRoomNotificationPreferenceRequest{ - RoomId: dm.Id, - Level: apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, + _, err = env.notifications.SetNotificationPolicyPreference(ctx, connect.NewRequest(&apiv1.SetNotificationPolicyPreferenceRequest{ + RoomId: &roomID, + Reason: apiv1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE, + Intensity: apiv1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF, })) - checkInaccessible("UpdateRoomNotificationPreference", err) + checkInaccessible("SetNotificationPolicyPreference", err) } func TestRoomDirectoryServiceListRoomsVisibilityAndDMs(t *testing.T) { @@ -1472,188 +1476,382 @@ func TestMyAccountServiceSetAndDeleteCustomStatus(t *testing.T) { } } -func TestNotificationServiceListsAndDismissesNotifications(t *testing.T) { +func TestNotificationServiceOccurrenceLifecycle(t *testing.T) { env := newConnectAPITestEnv(t) ctx := withCaller(env.ctx, env.viewer) - room := env.createJoinedRoom("notification-connect-room") - actor, err := env.core.CreateUser(env.ctx, core.SystemActorID, "notification-actor", "Notification Actor", "password") + actor, err := env.core.CreateUser(env.ctx, core.SystemActorID, "notification-v2-actor", "Notification 2 Actor", "password") if err != nil { t.Fatalf("CreateUser actor: %v", err) } - if err := env.core.SetPresence(env.ctx, actor.Id, core.PresenceStatusAway); err != nil { - t.Fatalf("SetPresence actor: %v", err) - } - - mention, err := env.core.CreateNotification(env.ctx, env.viewer.Id, actor.Id, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{ - RoomId: room.Id, - EventId: "mention-event", - InThread: "thread-root", - }, - }, - }) + dm, _, err := env.core.FindOrCreateDM(env.ctx, env.viewer.Id, []string{actor.Id}) if err != nil { - t.Fatalf("CreateNotification mention: %v", err) + t.Fatalf("FindOrCreateDM: %v", err) } - dm, err := env.core.CreateNotification(env.ctx, env.viewer.Id, actor.Id, &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{ - RoomId: "dm-room", - EventId: "dm-event", - }, - }, - }) + posted, err := env.core.PostMessage(env.ctx, core.KindDM, dm.Id, actor.Id, "hello from v2", nil, "", "", nil, false) if err != nil { - t.Fatalf("CreateNotification dm: %v", err) + t.Fatalf("PostMessage: %v", err) } - if _, err := env.notifications.GetNotification(env.ctx, connect.NewRequest(&apiv1.GetNotificationRequest{NotificationId: mention.Id})); connect.CodeOf(err) != connect.CodeUnauthenticated { - t.Fatalf("unauthenticated GetNotification code = %v, want unauthenticated", connect.CodeOf(err)) + list, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{})) + if err != nil { + t.Fatalf("ListNotificationGroups: %v", err) } - if _, err := env.notifications.BatchGetNotifications(env.ctx, connect.NewRequest(&apiv1.BatchGetNotificationsRequest{NotificationIds: []string{mention.Id}})); connect.CodeOf(err) != connect.CodeUnauthenticated { - t.Fatalf("unauthenticated BatchGetNotifications code = %v, want unauthenticated", connect.CodeOf(err)) + if len(list.Msg.GetGroups()) != 1 || list.Msg.GetUnreadGroupCount() != 1 { + t.Fatalf("groups = %+v, unread = %d, want one unread group", list.Msg.GetGroups(), list.Msg.GetUnreadGroupCount()) } - - listResp, err := env.notifications.ListNotifications(ctx, connect.NewRequest(&apiv1.ListNotificationsRequest{Page: &apiv1.PageRequest{Limit: 1}})) - if err != nil { - t.Fatalf("ListNotifications: %v", err) + if counts := list.Msg.GetRoomUnreadGroupCounts(); len(counts) != 1 || counts[0].GetRoomId() != dm.Id || counts[0].GetUnreadGroupCount() != 1 { + t.Fatalf("room unread-group counts = %+v, want one group for %s", counts, dm.Id) + } + group := list.Msg.GetGroups()[0] + occurrence := group.GetOccurrences()[0] + if occurrence.GetTarget().GetRoom().GetId() != dm.Id || occurrence.GetTarget().GetEventId() != posted.Id || + !occurrence.GetUnread() { + t.Fatalf("occurrence = %+v, want exact unread DM target", occurrence) } - if listResp.Msg.GetPage().GetTotalCount() != 2 || !listResp.Msg.GetPage().GetHasMore() || len(listResp.Msg.GetNotifications()) != 1 { - t.Fatalf("ListNotifications page = %+v, want total 2, has_more true, one item", listResp.Msg) + + if _, err := env.notifications.MarkNotificationRead(ctx, connect.NewRequest(&apiv1.MarkNotificationReadRequest{ + NotificationId: occurrence.GetId(), + })); err != nil { + t.Fatalf("MarkNotificationRead: %v", err) } - item := listResp.Msg.GetNotifications()[0] - if item.GetActor().GetDisplayName() != "Notification Actor" || item.GetActor().GetPresenceStatus() != apiv1.PresenceStatus_PRESENCE_STATUS_AWAY { - t.Fatalf("notification actor = %+v, want hydrated actor", item.GetActor()) + readList, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{})) + if err != nil || len(readList.Msg.GetGroups()) != 1 || readList.Msg.GetGroups()[0].GetUnread() || readList.Msg.GetUnreadGroupCount() != 0 { + t.Fatalf("ListNotificationGroups after read = %+v, %v, want one read group", readList, err) } - getResp, err := env.notifications.GetNotification(ctx, connect.NewRequest(&apiv1.GetNotificationRequest{NotificationId: mention.Id})) - if err != nil { - t.Fatalf("GetNotification: %v", err) + if _, err := env.notifications.DeleteNotificationGroup(ctx, connect.NewRequest(&apiv1.DeleteNotificationGroupRequest{ + GroupId: group.GetId(), + })); err != nil { + t.Fatalf("DeleteNotificationGroup: %v", err) } - if got := getResp.Msg.GetNotification(); got.GetId() != mention.Id || got.GetMention().GetEventId() != "mention-event" || got.GetMention().GetThreadRootEventId() != "thread-root" { - t.Fatalf("GetNotification item = %+v, want mention", got) + afterDelete, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{})) + if err != nil || len(afterDelete.Msg.GetGroups()) != 0 { + t.Fatalf("list after delete = %+v, %v, want empty", afterDelete, err) } - if _, err := env.notifications.GetNotification(ctx, connect.NewRequest(&apiv1.GetNotificationRequest{NotificationId: "missing-notification"})); connect.CodeOf(err) != connect.CodeNotFound { - t.Fatalf("missing GetNotification code = %v, want not_found", connect.CodeOf(err)) + if _, err := env.core.PostMessage(env.ctx, core.KindDM, dm.Id, actor.Id, "dismiss all of this", nil, "", "", nil, false); err != nil { + t.Fatalf("PostMessage before DeleteAllNotificationOccurrences: %v", err) } - - batchResp, err := env.notifications.BatchGetNotifications(ctx, connect.NewRequest(&apiv1.BatchGetNotificationsRequest{ - NotificationIds: []string{mention.Id, "missing-notification", dm.Id, mention.Id}, - })) - if err != nil { - t.Fatalf("BatchGetNotifications: %v", err) + deleteAll, err := env.notifications.DeleteAllNotificationOccurrences(ctx, connect.NewRequest(&apiv1.DeleteAllNotificationOccurrencesRequest{})) + if err != nil || deleteAll.Msg.GetDeletedCount() != 1 { + t.Fatalf("DeleteAllNotificationOccurrences = (%+v, %v), want one", deleteAll, err) } - gotBatch := batchResp.Msg.GetNotifications() - if len(gotBatch) != 2 || gotBatch[0].GetId() != mention.Id || gotBatch[1].GetId() != dm.Id { - t.Fatalf("BatchGetNotifications items = %+v, want mention,dm", gotBatch) + afterDeleteAll, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{})) + if err != nil || len(afterDeleteAll.Msg.GetGroups()) != 0 { + t.Fatalf("list after delete all = %+v, %v, want empty", afterDeleteAll, err) } - roomResp, err := env.notifications.ListRoomNotifications(ctx, connect.NewRequest(&apiv1.ListRoomNotificationsRequest{RoomId: room.Id})) + policy, err := env.notifications.SetNotificationPolicyPreference(ctx, connect.NewRequest(&apiv1.SetNotificationPolicyPreferenceRequest{ + Reason: apiv1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: apiv1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + })) if err != nil { - t.Fatalf("ListRoomNotifications: %v", err) + t.Fatalf("SetNotificationPolicyPreference: %v", err) } - if roomResp.Msg.GetPage().GetTotalCount() != 1 || len(roomResp.Msg.GetNotifications()) != 1 { - t.Fatalf("ListRoomNotifications page = %+v, want one room notification", roomResp.Msg) + found := false + for _, preference := range policy.Msg.GetPreferences() { + if preference.GetReason() == apiv1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION { + found = preference.GetServerIntensity() == apiv1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE && + preference.GetEffectiveIntensity() == apiv1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE + } } - mentionItem := roomResp.Msg.GetNotifications()[0] - if mentionItem.GetMention().GetRoom().GetId() != room.Id || mentionItem.GetMention().GetThreadRootEventId() != "thread-root" { - t.Fatalf("mention payload = %+v, want room/thread payload", mentionItem.GetMention()) + if !found { + t.Fatalf("notification policy = %+v, want direct mention Badge", policy.Msg.GetPreferences()) } +} - outsider, err := env.core.CreateUser(env.ctx, core.SystemActorID, "notification-outsider", "Notification Outsider", "password") +func TestNotificationServiceDeleteOccurrenceIsIdempotent(t *testing.T) { + env := newConnectAPITestEnv(t) + ctx := withCaller(env.ctx, env.viewer) + actor, err := env.core.CreateUser(env.ctx, core.SystemActorID, "notification-delete-actor", "Notification Delete Actor", "password") if err != nil { - t.Fatalf("CreateUser outsider: %v", err) + t.Fatalf("CreateUser actor: %v", err) } - outsiderResp, err := env.notifications.ListRoomNotifications(withCaller(env.ctx, outsider), connect.NewRequest(&apiv1.ListRoomNotificationsRequest{RoomId: room.Id})) + dm, _, err := env.core.FindOrCreateDM(env.ctx, env.viewer.Id, []string{actor.Id}) if err != nil { - t.Fatalf("ListRoomNotifications outsider: %v", err) + t.Fatalf("FindOrCreateDM: %v", err) + } + if _, err := env.core.PostMessage(env.ctx, core.KindDM, dm.Id, actor.Id, "delete exactly this", nil, "", "", nil, false); err != nil { + t.Fatalf("PostMessage: %v", err) + } + inbox, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{})) + if err != nil || len(inbox.Msg.GetGroups()) != 1 || len(inbox.Msg.GetGroups()[0].GetOccurrences()) != 1 { + t.Fatalf("ListNotificationGroups = (%+v, %v), want one occurrence", inbox, err) } - if outsiderResp.Msg.GetPage().GetTotalCount() != 0 || len(outsiderResp.Msg.GetNotifications()) != 0 { - t.Fatalf("outsider room notifications = %+v, want empty page", outsiderResp.Msg) + notificationID := inbox.Msg.GetGroups()[0].GetOccurrences()[0].GetId() + first, err := env.notifications.DeleteNotificationOccurrence(ctx, connect.NewRequest(&apiv1.DeleteNotificationOccurrenceRequest{ + NotificationId: notificationID, + })) + if err != nil || !first.Msg.GetDeleted() { + t.Fatalf("first DeleteNotificationOccurrence = (%+v, %v), want deleted", first, err) + } + second, err := env.notifications.DeleteNotificationOccurrence(ctx, connect.NewRequest(&apiv1.DeleteNotificationOccurrenceRequest{ + NotificationId: notificationID, + })) + if err != nil || second.Msg.GetDeleted() { + t.Fatalf("second DeleteNotificationOccurrence = (%+v, %v), want idempotent deleted=false", second, err) + } + after, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{})) + if err != nil || len(after.Msg.GetGroups()) != 0 { + t.Fatalf("ListNotificationGroups after delete = (%+v, %v), want empty", after, err) } +} - hasResp, err := env.notifications.HasNotifications(ctx, connect.NewRequest(&apiv1.HasNotificationsRequest{})) +func TestNotificationServiceRejectsRetractedTargetsBeforeCleanup(t *testing.T) { + env := newConnectAPITestEnv(t) + ctx := withCaller(env.ctx, env.viewer) + actor, err := env.core.CreateUser(env.ctx, core.SystemActorID, "notification-stale-actor", "Notification Stale Actor", "password") if err != nil { - t.Fatalf("HasNotifications: %v", err) + t.Fatalf("CreateUser actor: %v", err) + } + dm, _, err := env.core.FindOrCreateDM(env.ctx, env.viewer.Id, []string{actor.Id}) + if err != nil { + t.Fatalf("FindOrCreateDM: %v", err) } - if !hasResp.Msg.GetHasNotifications() { - t.Fatal("HasNotifications = false, want true") + posted, err := env.core.PostMessage(env.ctx, core.KindDM, dm.Id, actor.Id, "soon retracted", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) } - countsResp, err := env.notifications.ListRoomNotificationCounts(ctx, connect.NewRequest(&apiv1.ListRoomNotificationCountsRequest{})) + sequence, err := env.core.GetEventSequence(env.ctx, core.KindDM, dm.Id, posted.Id) if err != nil { - t.Fatalf("ListRoomNotificationCounts: %v", err) + t.Fatalf("GetEventSequence: %v", err) } - counts := make(map[string]int32) - for _, count := range countsResp.Msg.GetRoomCounts() { - counts[count.GetRoomId()] = count.GetTotalCount() + if err := env.core.DeleteMessage(env.ctx, actor.Id, core.KindDM, dm.Id, posted.Id); err != nil { + t.Fatalf("DeleteMessage: %v", err) } - if counts[room.Id] != 1 || counts["dm-room"] != 1 { - t.Fatalf("ListRoomNotificationCounts = %+v, want counts for channel and DM rooms", counts) + createStale := func(sourceID string) *corev1.NotificationOccurrence { + t.Helper() + occurrence, created, err := env.core.NotificationOccurrences().Create(env.ctx, core.CreateNotificationOccurrenceInput{ + RecipientID: env.viewer.Id, + SourceEventID: sourceID, + SourceCreated: posted.GetCreatedAt().AsTime(), + SourceStreamSequence: sequence, + ActorID: actor.Id, + Target: &corev1.NotificationTarget{RoomId: dm.Id, EventId: posted.Id}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }) + if err != nil || !created { + t.Fatalf("Create stale occurrence = (%v, %v, %v), want created", occurrence, created, err) + } + return occurrence } - dismissResp, err := env.notifications.DismissNotification(ctx, connect.NewRequest(&apiv1.DismissNotificationRequest{NotificationId: mention.Id})) - if err != nil { - t.Fatalf("DismissNotification: %v", err) + createStale("stale-list-" + posted.Id) + inbox, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{})) + if err != nil || len(inbox.Msg.GetGroups()) != 0 { + t.Fatalf("ListNotificationGroups with retracted target = (%+v, %v), want empty", inbox, err) } - if !dismissResp.Msg.GetDismissed() { - t.Fatal("DismissNotification dismissed = false, want true") + + staleUpdate := createStale("stale-update-" + posted.Id) + _, err = env.notifications.MarkNotificationRead(ctx, connect.NewRequest(&apiv1.MarkNotificationReadRequest{ + NotificationId: staleUpdate.GetId(), + })) + if connect.CodeOf(err) != connect.CodeNotFound { + t.Fatalf("MarkNotificationRead retracted target code = %v, want not found", connect.CodeOf(err)) } - dismissAgainResp, err := env.notifications.DismissNotification(ctx, connect.NewRequest(&apiv1.DismissNotificationRequest{NotificationId: mention.Id})) +} + +func TestNotificationServiceVisibilityFilteringFillsOffsetPages(t *testing.T) { + env := newConnectAPITestEnv(t) + ctx := withCaller(env.ctx, env.viewer) + actor, err := env.core.CreateUser(env.ctx, core.SystemActorID, "notification-page-filter-actor", "Notification Page Filter Actor", "password") if err != nil { - t.Fatalf("DismissNotification again: %v", err) + t.Fatalf("CreateUser actor: %v", err) } - if !dismissAgainResp.Msg.GetDismissed() { - t.Fatal("DismissNotification again dismissed = false, want idempotent true") + baseTime := time.Now().UTC().Add(-time.Minute) + created := make([]*corev1.NotificationOccurrence, 0, 3) + for index := 0; index < 3; index++ { + room, err := env.core.CreateRoom(env.ctx, core.SystemActorID, core.KindChannel, "", fmt.Sprintf("notification-page-filter-%d", index), "") + if err != nil { + t.Fatalf("CreateRoom %d: %v", index, err) + } + for _, userID := range []string{env.viewer.Id, actor.Id} { + if _, err := env.core.JoinRoom(env.ctx, userID, core.KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %d for %s: %v", index, userID, err) + } + } + posted, err := env.core.PostMessage(env.ctx, core.KindChannel, room.Id, actor.Id, fmt.Sprintf("page target %d", index), nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage %d: %v", index, err) + } + sequence, err := env.core.GetEventSequence(env.ctx, core.KindChannel, room.Id, posted.Id) + if err != nil { + t.Fatalf("GetEventSequence %d: %v", index, err) + } + if index == 0 { + if err := env.core.DeleteMessage(env.ctx, actor.Id, core.KindChannel, room.Id, posted.Id); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + } + occurrence, wasCreated, err := env.core.NotificationOccurrences().Create(env.ctx, core.CreateNotificationOccurrenceInput{ + RecipientID: env.viewer.Id, + SourceEventID: fmt.Sprintf("page-filter-source-%d", index), + SourceCreated: baseTime.Add(-time.Duration(index) * time.Second), + SourceStreamSequence: sequence, + ActorID: actor.Id, + Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: posted.Id}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }) + if err != nil || !wasCreated { + t.Fatalf("Create occurrence %d = (%v, %v, %v), want created", index, occurrence, wasCreated, err) + } + created = append(created, occurrence) } - if _, err := env.notifications.GetNotification(ctx, connect.NewRequest(&apiv1.GetNotificationRequest{NotificationId: mention.Id})); connect.CodeOf(err) != connect.CodeNotFound { - t.Fatalf("dismissed GetNotification code = %v, want not_found", connect.CodeOf(err)) + + first, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{ + Page: &apiv1.PageRequest{Limit: 1}, + })) + if err != nil || len(first.Msg.GetGroups()) != 1 || !first.Msg.GetPage().GetHasMore() || first.Msg.GetPage().GetTotalCount() != 2 { + t.Fatalf("first filtered page = (%+v, %v), want one of two with more", first, err) } - remainingBatchResp, err := env.notifications.BatchGetNotifications(ctx, connect.NewRequest(&apiv1.BatchGetNotificationsRequest{ - NotificationIds: []string{mention.Id, dm.Id}, + second, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{ + Page: &apiv1.PageRequest{Limit: 1, Offset: 1}, })) + if err != nil || len(second.Msg.GetGroups()) != 1 || second.Msg.GetPage().GetHasMore() { + t.Fatalf("second filtered page = (%+v, %v), want final row", second, err) + } + if first.Msg.GetGroups()[0].GetId() == second.Msg.GetGroups()[0].GetId() { + t.Fatalf("filtered pages repeated group %s", first.Msg.GetGroups()[0].GetId()) + } + if _, err := env.core.NotificationOccurrences().Get(env.ctx, env.viewer.Id, created[0].GetId()); !errors.Is(err, core.ErrNotFound) { + t.Fatalf("stale occurrence Get error = %v, want not found after visibility purge", err) + } +} + +func TestNotificationServiceSummaryExcludesImplicitMembershipLossOutsidePage(t *testing.T) { + env := newConnectAPITestEnv(t) + ctx := withCaller(env.ctx, env.viewer) + actor, err := env.core.CreateUser(env.ctx, core.SystemActorID, "notification-implicit-actor", "Notification Implicit Actor", "password") + if err != nil { + t.Fatalf("CreateUser actor: %v", err) + } + baseTime := time.Now().UTC().Add(-time.Minute) + createOccurrence := func(room *corev1.Room, sourceID string, sourceCreated time.Time) *corev1.NotificationOccurrence { + t.Helper() + posted, err := env.core.PostMessage(env.ctx, core.KindChannel, room.Id, actor.Id, sourceID, nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage %s: %v", sourceID, err) + } + sequence, err := env.core.GetEventSequence(env.ctx, core.KindChannel, room.Id, posted.Id) + if err != nil { + t.Fatalf("GetEventSequence %s: %v", sourceID, err) + } + occurrence, created, err := env.core.NotificationOccurrences().Create(env.ctx, core.CreateNotificationOccurrenceInput{ + RecipientID: env.viewer.Id, + SourceEventID: sourceID, + SourceCreated: sourceCreated, + SourceStreamSequence: sequence, + ActorID: actor.Id, + Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: posted.Id}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }) + if err != nil || !created { + t.Fatalf("Create occurrence %s = (%+v, %v, %v), want created", sourceID, occurrence, created, err) + } + return occurrence + } + + implicitRoom, err := env.core.CreateRoom(env.ctx, actor.Id, core.KindChannel, "", "notification-implicit-old", "") if err != nil { - t.Fatalf("BatchGetNotifications after dismiss: %v", err) + t.Fatalf("CreateRoom implicit: %v", err) + } + if _, err := env.core.SetRoomUniversal(env.ctx, actor.Id, core.KindChannel, implicitRoom.Id, true); err != nil { + t.Fatalf("SetRoomUniversal true: %v", err) + } + stale := createOccurrence(implicitRoom, "implicit-old-source", baseTime) + + for index := 0; index < 3; index++ { + room, err := env.core.CreateRoom(env.ctx, actor.Id, core.KindChannel, "", fmt.Sprintf("notification-explicit-new-%d", index), "") + if err != nil { + t.Fatalf("CreateRoom explicit %d: %v", index, err) + } + if _, err := env.core.JoinRoom(env.ctx, env.viewer.Id, core.KindChannel, env.viewer.Id, room.Id); err != nil { + t.Fatalf("JoinRoom explicit %d: %v", index, err) + } + createOccurrence(room, fmt.Sprintf("explicit-new-source-%d", index), baseTime.Add(time.Duration(index+1)*time.Second)) } - if got := remainingBatchResp.Msg.GetNotifications(); len(got) != 1 || got[0].GetId() != dm.Id { - t.Fatalf("BatchGetNotifications after dismiss items = %+v, want dm only", got) + if _, err := env.core.SetRoomUniversal(env.ctx, actor.Id, core.KindChannel, implicitRoom.Id, false); err != nil { + t.Fatalf("SetRoomUniversal false: %v", err) } - dismissAllResp, err := env.notifications.DismissAllNotifications(ctx, connect.NewRequest(&apiv1.DismissAllNotificationsRequest{})) + response, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{ + Page: &apiv1.PageRequest{Limit: 1}, + })) if err != nil { - t.Fatalf("DismissAllNotifications: %v", err) + t.Fatalf("ListNotificationGroups: %v", err) + } + if len(response.Msg.GetGroups()) != 1 || response.Msg.GetPage().GetTotalCount() != 3 || + response.Msg.GetUnreadGroupCount() != 3 || len(response.Msg.GetRoomUnreadGroupCounts()) != 3 { + t.Fatalf("summary after implicit membership loss = %+v, want three visible groups", response.Msg) } - if dismissAllResp.Msg.GetDismissedCount() != 1 { - t.Fatalf("DismissAllNotifications count = %d, want 1", dismissAllResp.Msg.GetDismissedCount()) + if _, err := env.core.NotificationOccurrences().Get(env.ctx, env.viewer.Id, stale.GetId()); !errors.Is(err, core.ErrNotFound) { + t.Fatalf("stale off-page occurrence Get error = %v, want not found", err) } } -func TestNotificationPreferencesServiceServerLevelPreference(t *testing.T) { +func TestNotificationServiceBoundsGroupPreview(t *testing.T) { env := newConnectAPITestEnv(t) ctx := withCaller(env.ctx, env.viewer) - - if _, err := env.prefs.UpdateServerNotificationPreference(env.ctx, connect.NewRequest(&apiv1.UpdateServerNotificationPreferenceRequest{ - Level: apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, - })); connect.CodeOf(err) != connect.CodeUnauthenticated { - t.Fatalf("unauthenticated UpdateServerNotificationPreference code = %v, want unauthenticated", connect.CodeOf(err)) + actor, err := env.core.CreateUser(env.ctx, core.SystemActorID, "notification-v2-page-actor", "Notification Page Actor", "password") + if err != nil { + t.Fatalf("CreateUser actor: %v", err) } - - setResp, err := env.prefs.UpdateServerNotificationPreference(ctx, connect.NewRequest(&apiv1.UpdateServerNotificationPreferenceRequest{ - Level: apiv1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES, - })) + dm, _, err := env.core.FindOrCreateDM(env.ctx, env.viewer.Id, []string{actor.Id}) if err != nil { - t.Fatalf("UpdateServerNotificationPreference: %v", err) + t.Fatalf("FindOrCreateDM: %v", err) } - if setResp.Msg.GetPreference().GetLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES || setResp.Msg.GetPreference().GetEffectiveLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES { - t.Fatalf("UpdateServerNotificationPreference response = %+v, want all/all", setResp.Msg) + for index := 0; index < notificationGroupOccurrencePreviewLimit+5; index++ { + if _, err := env.core.PostMessage(env.ctx, core.KindDM, dm.Id, actor.Id, fmt.Sprintf("message %d", index), nil, "", "", nil, false); err != nil { + t.Fatalf("PostMessage %d: %v", index, err) + } } - getResp, err := env.prefs.GetServerNotificationPreference(ctx, connect.NewRequest(&apiv1.GetServerNotificationPreferenceRequest{})) + inbox, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{})) + if err != nil || len(inbox.Msg.GetGroups()) != 1 { + t.Fatalf("ListNotificationGroups = %+v, %v, want one group", inbox, err) + } + group := inbox.Msg.GetGroups()[0] + if got := len(group.GetOccurrences()); got != notificationGroupOccurrencePreviewLimit { + t.Fatalf("group preview size = %d, want %d", got, notificationGroupOccurrencePreviewLimit) + } + if group.GetOccurrenceCount() != int32(notificationGroupOccurrencePreviewLimit+5) || group.GetOpenNotificationId() == "" || group.GetNextExpiryAt() == nil || inbox.Msg.GetNextExpiryAt() == nil { + t.Fatalf("bounded group metadata = %+v", group) + } + projection, err := env.api.BuildRealtimeProjectionNotifications(env.ctx, env.viewer.Id) + if err != nil || projection.Groups.GetNextExpiryAt() == nil { + t.Fatalf("realtime expiry boundary = %+v, %v", projection, err) + } + + live, err := env.nc.SubscribeSync(subjects.LiveSyncUserEvent(env.viewer.Id, "notification_v2")) if err != nil { - t.Fatalf("GetServerNotificationPreference: %v", err) + t.Fatalf("subscribe to notification invalidations: %v", err) + } + defer live.Unsubscribe() + if err := env.nc.Flush(); err != nil { + t.Fatalf("flush notification subscription: %v", err) + } + updated, err := env.notifications.MarkNotificationRead(ctx, connect.NewRequest(&apiv1.MarkNotificationReadRequest{ + NotificationId: group.GetOpenNotificationId(), + })) + if err != nil || updated.Msg.GetNotification().GetUnread() { + t.Fatalf("MarkNotificationRead = %+v, %v", updated, err) + } + if _, err := live.NextMsg(2 * time.Second); err != nil { + t.Fatalf("wait for coalesced notification invalidation: %v", err) } - if getResp.Msg.GetPreference().GetLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES || getResp.Msg.GetPreference().GetEffectiveLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES { - t.Fatalf("GetServerNotificationPreference response = %+v, want all/all", getResp.Msg) + if _, err := live.NextMsg(200 * time.Millisecond); err == nil { + t.Fatal("mark read published more than one notification invalidation") } } diff --git a/cli/internal/connectapi/server_cache_test.go b/cli/internal/connectapi/server_cache_test.go index 893f3ffa5..b528840a8 100644 --- a/cli/internal/connectapi/server_cache_test.go +++ b/cli/internal/connectapi/server_cache_test.go @@ -42,10 +42,10 @@ func TestPublicRPCIdempotencyLevels(t *testing.T) { want: descriptorpb.MethodOptions_IDEMPOTENT, }, { - name: "dismiss notification is idempotent", + name: "update notification occurrence is idempotent", file: apiv1.File_chatto_api_v1_notifications_proto, service: "NotificationService", - method: "DismissNotification", + method: "MarkNotificationRead", want: descriptorpb.MethodOptions_IDEMPOTENT, }, { diff --git a/cli/internal/connectapi/timeline_thread_services_test.go b/cli/internal/connectapi/timeline_thread_services_test.go index 00efcec0d..91342c7b9 100644 --- a/cli/internal/connectapi/timeline_thread_services_test.go +++ b/cli/internal/connectapi/timeline_thread_services_test.go @@ -596,40 +596,12 @@ func TestRoomAndThreadServicesMarkRoomAsReadAnchorsAndDoesNotRegress(t *testing. } e2 := env.post(room.Id, env.viewer.Id, "two", "") e3 := env.post(room.Id, env.viewer.Id, "three", "") - roomMention, err := env.core.CreateNotification(env.ctx, reader.Id, env.viewer.Id, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{RoomId: room.Id, EventId: e1.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification room mention: %v", err) - } - roomReply, err := env.core.CreateNotification(env.ctx, reader.Id, env.viewer.Id, &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{RoomId: room.Id, EventId: e2.Id, InReplyToId: e1.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification room reply: %v", err) - } - futureRoomNotification, err := env.core.CreateNotification(env.ctx, reader.Id, env.viewer.Id, &corev1.Notification{ - Notification: &corev1.Notification_RoomMessage{ - RoomMessage: &corev1.RoomMessageNotification{RoomId: room.Id, EventId: e3.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification future room message: %v", err) - } + roomMention := createReadTestOccurrence(t, env, reader.Id, env.viewer.Id, room.Id, e1, "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) + roomReply := createReadTestOccurrence(t, env, reader.Id, env.viewer.Id, room.Id, e2, "", corev1.NotificationReason_NOTIFICATION_REASON_REPLY) + futureRoomNotification := createReadTestOccurrence(t, env, reader.Id, env.viewer.Id, room.Id, e3, "", corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM) threadRoot := env.post(room.Id, env.viewer.Id, "thread root", "") threadReply := env.post(room.Id, env.viewer.Id, "thread reply", threadRoot.Id) - threadNotification, err := env.core.CreateNotification(env.ctx, reader.Id, env.viewer.Id, &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{RoomId: room.Id, EventId: threadReply.Id, InReplyToId: threadRoot.Id, InThread: threadRoot.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification thread reply: %v", err) - } + threadNotification := createReadTestOccurrence(t, env, reader.Id, env.viewer.Id, room.Id, threadReply, threadRoot.Id, corev1.NotificationReason_NOTIFICATION_REASON_REPLY) ctx := withCaller(env.ctx, reader) resp, err := env.rooms.MarkRoomAsRead(ctx, connect.NewRequest(&apiv1.MarkRoomAsReadRequest{ @@ -645,11 +617,10 @@ func TestRoomAndThreadServicesMarkRoomAsReadAnchorsAndDoesNotRegress(t *testing. if got, err := env.core.GetLastReadEventID(env.ctx, core.KindChannel, reader.Id, room.Id); err != nil || got != e2.Id { t.Fatalf("marker after e2 = %q, %v; want %s", got, err, e2.Id) } - assertAPINotifications(t, env, ctx, + assertAPINotificationStates(t, env, ctx, []string{futureRoomNotification.Id, threadNotification.Id}, []string{roomMention.Id, roomReply.Id}, ) - assertRoomNotificationCount(t, env, ctx, room.Id, 2) if _, err := env.rooms.MarkRoomAsRead(ctx, connect.NewRequest(&apiv1.MarkRoomAsReadRequest{ RoomId: room.Id, @@ -660,7 +631,7 @@ func TestRoomAndThreadServicesMarkRoomAsReadAnchorsAndDoesNotRegress(t *testing. if got, err := env.core.GetLastReadEventID(env.ctx, core.KindChannel, reader.Id, room.Id); err != nil || got != e2.Id { t.Fatalf("marker after stale e1 = %q, %v; want %s", got, err, e2.Id) } - assertAPINotifications(t, env, ctx, + assertAPINotificationStates(t, env, ctx, []string{futureRoomNotification.Id, threadNotification.Id}, []string{roomMention.Id, roomReply.Id}, ) @@ -694,11 +665,10 @@ func TestRoomAndThreadServicesMarkRoomAsReadAnchorsAndDoesNotRegress(t *testing. if got, err := env.core.GetLastReadEventID(env.ctx, core.KindChannel, reader.Id, room.Id); err != nil || got != threadRoot.Id { t.Fatalf("marker after omitted anchor = %q, %v; want %s", got, err, threadRoot.Id) } - assertAPINotifications(t, env, ctx, + assertAPINotificationStates(t, env, ctx, []string{threadNotification.Id}, []string{futureRoomNotification.Id, roomMention.Id, roomReply.Id}, ) - assertRoomNotificationCount(t, env, ctx, room.Id, 1) } func TestRoomAndThreadServicesMarkRoomAsReadRejectsMissingAnchorWithoutLazyMarker(t *testing.T) { @@ -765,49 +735,14 @@ func TestRoomAndThreadServicesMarkThreadAsReadAnchorsAndDoesNotRegress(t *testin root := env.post(room.Id, env.viewer.Id, "root", "") reply1 := env.post(room.Id, env.viewer.Id, "reply one", root.Id) reply2 := env.post(room.Id, env.viewer.Id, "reply two", root.Id) - threadReplyNotification, err := env.core.CreateNotification(env.ctx, reader.Id, env.viewer.Id, &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{RoomId: room.Id, EventId: reply1.Id, InReplyToId: root.Id, InThread: root.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification thread reply: %v", err) - } - threadMentionNotification, err := env.core.CreateNotification(env.ctx, reader.Id, env.viewer.Id, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{RoomId: room.Id, EventId: reply2.Id, InThread: root.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification thread mention: %v", err) - } + threadReplyNotification := createReadTestOccurrence(t, env, reader.Id, env.viewer.Id, room.Id, reply1, root.Id, corev1.NotificationReason_NOTIFICATION_REASON_REPLY) + threadMentionNotification := createReadTestOccurrence(t, env, reader.Id, env.viewer.Id, room.Id, reply2, root.Id, corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) reply3 := env.post(room.Id, env.viewer.Id, "reply three", root.Id) - futureThreadNotification, err := env.core.CreateNotification(env.ctx, reader.Id, env.viewer.Id, &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{RoomId: room.Id, EventId: reply3.Id, InReplyToId: root.Id, InThread: root.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification future thread reply: %v", err) - } + futureThreadNotification := createReadTestOccurrence(t, env, reader.Id, env.viewer.Id, room.Id, reply3, root.Id, corev1.NotificationReason_NOTIFICATION_REASON_REPLY) otherRoot := env.post(room.Id, env.viewer.Id, "other root", "") otherReply := env.post(room.Id, env.viewer.Id, "other reply", otherRoot.Id) - otherThreadNotification, err := env.core.CreateNotification(env.ctx, reader.Id, env.viewer.Id, &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{RoomId: room.Id, EventId: otherReply.Id, InReplyToId: otherRoot.Id, InThread: otherRoot.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification other thread reply: %v", err) - } - roomNotification, err := env.core.CreateNotification(env.ctx, reader.Id, env.viewer.Id, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{RoomId: room.Id, EventId: root.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification room mention: %v", err) - } + otherThreadNotification := createReadTestOccurrence(t, env, reader.Id, env.viewer.Id, room.Id, otherReply, otherRoot.Id, corev1.NotificationReason_NOTIFICATION_REASON_REPLY) + roomNotification := createReadTestOccurrence(t, env, reader.Id, env.viewer.Id, room.Id, root, "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) ctx := withCaller(env.ctx, reader) resp, err := env.threads.MarkThreadAsRead(ctx, connect.NewRequest(&apiv1.MarkThreadAsReadRequest{ @@ -825,11 +760,10 @@ func TestRoomAndThreadServicesMarkThreadAsReadAnchorsAndDoesNotRegress(t *testin if err != nil { t.Fatalf("GetThreadLastOpened after reply2: %v", err) } - assertAPINotifications(t, env, ctx, + assertAPINotificationStates(t, env, ctx, []string{futureThreadNotification.Id, otherThreadNotification.Id, roomNotification.Id}, []string{threadReplyNotification.Id, threadMentionNotification.Id}, ) - assertRoomNotificationCount(t, env, ctx, room.Id, 3) resp, err = env.threads.MarkThreadAsRead(ctx, connect.NewRequest(&apiv1.MarkThreadAsReadRequest{ RoomId: room.Id, @@ -849,7 +783,7 @@ func TestRoomAndThreadServicesMarkThreadAsReadAnchorsAndDoesNotRegress(t *testin if !markerAfter.Equal(marker2) { t.Fatalf("thread marker regressed from %v to %v", marker2, markerAfter) } - assertAPINotifications(t, env, ctx, + assertAPINotificationStates(t, env, ctx, []string{futureThreadNotification.Id, otherThreadNotification.Id, roomNotification.Id}, []string{threadReplyNotification.Id, threadMentionNotification.Id}, ) @@ -885,45 +819,59 @@ func TestRoomAndThreadServicesMarkThreadAsReadAnchorsAndDoesNotRegress(t *testin } } -func assertAPINotifications(t *testing.T, env *connectAPITestEnv, ctx context.Context, present []string, absent []string) { +func createReadTestOccurrence(t *testing.T, env *connectAPITestEnv, recipientID, actorID, roomID string, event *corev1.Event, threadRootID string, reason corev1.NotificationReason) *corev1.NotificationOccurrence { t.Helper() - resp, err := env.notifications.ListNotifications(ctx, connect.NewRequest(&apiv1.ListNotificationsRequest{ - Page: &apiv1.PageRequest{Limit: 100}, - })) + sequence, err := env.core.GetEventSequence(env.ctx, core.KindChannel, roomID, event.GetId()) if err != nil { - t.Fatalf("ListNotifications: %v", err) - } - ids := map[string]bool{} - for _, notification := range resp.Msg.GetNotifications() { - ids[notification.GetId()] = true - } - for _, id := range present { - if !ids[id] { - t.Fatalf("notification %s missing from API list; got %v", id, ids) - } - } - for _, id := range absent { - if ids[id] { - t.Fatalf("notification %s still present in API list; got %v", id, ids) - } + t.Fatalf("GetEventSequence: %v", err) + } + target := &corev1.NotificationTarget{RoomId: roomID, EventId: event.GetId()} + if threadRootID != "" { + target.ThreadRootEventId = &threadRootID + } + occurrence, _, err := env.core.NotificationOccurrences().Create(env.ctx, core.CreateNotificationOccurrenceInput{ + RecipientID: recipientID, + SourceEventID: event.GetId(), + SourceCreated: event.GetCreatedAt().AsTime(), + SourceStreamSequence: sequence, + ActorID: actorID, + Target: target, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: reason, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + InitialState: corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD, + SkipReadLookup: true, + }) + if err != nil { + t.Fatalf("Create notification occurrence: %v", err) } + return occurrence } -func assertRoomNotificationCount(t *testing.T, env *connectAPITestEnv, ctx context.Context, roomID string, want int32) { +func assertAPINotificationStates(t *testing.T, env *connectAPITestEnv, ctx context.Context, unreadIDs []string, readIDs []string) { t.Helper() - resp, err := env.notifications.ListRoomNotificationCounts(ctx, connect.NewRequest(&apiv1.ListRoomNotificationCountsRequest{})) + resp, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{ + Page: &apiv1.PageRequest{Limit: 100}, + })) if err != nil { - t.Fatalf("ListRoomNotificationCounts: %v", err) + t.Fatalf("ListNotificationGroups: %v", err) } - got := int32(0) - for _, count := range resp.Msg.GetRoomCounts() { - if count.GetRoomId() == roomID { - got = count.GetTotalCount() - break + states := map[string]bool{} + for _, group := range resp.Msg.GetGroups() { + for _, notification := range group.GetOccurrences() { + states[notification.GetId()] = notification.GetUnread() } } - if got != want { - t.Fatalf("room notification count for %s = %d, want %d", roomID, got, want) + for _, id := range unreadIDs { + if !states[id] { + t.Fatalf("notification %s state = %v, want unread; got %v", id, states[id], states) + } + } + for _, id := range readIDs { + if states[id] { + t.Fatalf("notification %s state = %v, want read; got %v", id, states[id], states) + } } } diff --git a/cli/internal/connectapi/viewer.go b/cli/internal/connectapi/viewer.go index 981ba7fd6..9b346c035 100644 --- a/cli/internal/connectapi/viewer.go +++ b/cli/internal/connectapi/viewer.go @@ -53,8 +53,6 @@ func (a *API) buildViewer(ctx context.Context, userID string) (*apiv1.GetViewerR var ( responseUser *apiv1.ViewerUser capabilities *apiv1.ViewerCapabilities - serverPreference *apiv1.NotificationPreference - roomPreferences []*apiv1.RoomNotificationPreference viewerPermissions *apiv1.ServerViewerPermissions viewerState *apiv1.ServerViewerState ) @@ -69,16 +67,6 @@ func (a *API) buildViewer(ctx context.Context, userID string) (*apiv1.GetViewerR capabilities, err = viewerCapabilities(groupCtx, a, userID) return err }) - group.Go(func() error { - var err error - serverPreference, err = serverNotificationPreference(groupCtx, a, userID) - return err - }) - group.Go(func() error { - var err error - roomPreferences, err = roomNotificationPreferences(groupCtx, a, userID) - return err - }) group.Go(func() error { var err error viewerPermissions, viewerState, err = a.serverViewerState(groupCtx, userID) @@ -89,12 +77,10 @@ func (a *API) buildViewer(ctx context.Context, userID string) (*apiv1.GetViewerR } return &apiv1.GetViewerResponse{ - User: responseUser, - Capabilities: capabilities, - ServerNotificationPreference: serverPreference, - RoomNotificationPreferences: roomPreferences, - ViewerPermissions: viewerPermissions, - ViewerState: viewerState, + User: responseUser, + Capabilities: capabilities, + ViewerPermissions: viewerPermissions, + ViewerState: viewerState, }, nil } @@ -249,33 +235,6 @@ func viewerCapabilities(ctx context.Context, api *API, userID string) (*apiv1.Vi }, nil } -func serverNotificationPreference(ctx context.Context, api *API, userID string) (*apiv1.NotificationPreference, error) { - level, err := api.core.GetSpaceNotificationLevel(ctx, userID) - if err != nil { - return nil, connectError(err) - } - effectiveLevel := level - if effectiveLevel == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - effectiveLevel = corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL - } - return apiNotificationPreference(level, effectiveLevel), nil -} - -func roomNotificationPreferences(ctx context.Context, api *API, userID string) ([]*apiv1.RoomNotificationPreference, error) { - prefs, err := api.core.GetAllRoomNotificationPreferences(ctx, userID) - if err != nil { - return nil, connectError(err) - } - result := make([]*apiv1.RoomNotificationPreference, 0, len(prefs)) - for _, pref := range prefs { - result = append(result, &apiv1.RoomNotificationPreference{ - RoomId: pref.RoomID, - Preference: apiNotificationPreference(pref.Level, pref.EffectiveLevel), - }) - } - return result, nil -} - func coreUserSettingsToAPI(settings *corev1.ServerUserPreferences) *apiv1.UserSettings { response := &apiv1.UserSettings{TimeFormat: apiv1.TimeFormat_TIME_FORMAT_AUTO} if settings == nil { diff --git a/cli/internal/core/config_projection.go b/cli/internal/core/config_projection.go index 817e98fcc..3f47ff78b 100644 --- a/cli/internal/core/config_projection.go +++ b/cli/internal/core/config_projection.go @@ -29,10 +29,10 @@ type serverConfigState struct { } type userConfigState struct { - timezone *string - timeFormat *corev1.TimeFormat - serverLevel *corev1.NotificationLevel - roomLevelByRoom map[string]corev1.NotificationLevel + timezone *string + timeFormat *corev1.TimeFormat + serverIntensityByReason map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity + roomIntensityByRoomAndCause map[string]map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity } func NewConfigProjection() *ConfigProjection { @@ -86,22 +86,35 @@ func (p *ConfigProjection) Apply(event *corev1.Event, _ uint64) error { u.timeFormat = &tf case *corev1.Event_UserTimeFormatCleared: p.ensureUserLocked(e.UserTimeFormatCleared.GetUserId()).timeFormat = nil - case *corev1.Event_UserServerNotificationLevelSet: - u := p.ensureUserLocked(e.UserServerNotificationLevelSet.GetUserId()) - level := e.UserServerNotificationLevelSet.GetLevel() - u.serverLevel = &level - case *corev1.Event_UserServerNotificationLevelCleared: - p.ensureUserLocked(e.UserServerNotificationLevelCleared.GetUserId()).serverLevel = nil - case *corev1.Event_UserRoomNotificationLevelSet: - u := p.ensureUserLocked(e.UserRoomNotificationLevelSet.GetUserId()) - if u.roomLevelByRoom == nil { - u.roomLevelByRoom = make(map[string]corev1.NotificationLevel) + case *corev1.Event_UserNotificationPreferenceChanged: + preference := e.UserNotificationPreferenceChanged + u := p.ensureUserLocked(preference.GetUserId()) + if preference.RoomId == nil { + if preference.GetIntensity() == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED { + delete(u.serverIntensityByReason, preference.GetReason()) + break + } + if u.serverIntensityByReason == nil { + u.serverIntensityByReason = make(map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity) + } + u.serverIntensityByReason[preference.GetReason()] = preference.GetIntensity() + break } - u.roomLevelByRoom[e.UserRoomNotificationLevelSet.GetRoomId()] = e.UserRoomNotificationLevelSet.GetLevel() - case *corev1.Event_UserRoomNotificationLevelCleared: - if u := p.users[e.UserRoomNotificationLevelCleared.GetUserId()]; u != nil { - delete(u.roomLevelByRoom, e.UserRoomNotificationLevelCleared.GetRoomId()) + roomID := preference.GetRoomId() + if preference.GetIntensity() == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED { + delete(u.roomIntensityByRoomAndCause[roomID], preference.GetReason()) + if len(u.roomIntensityByRoomAndCause[roomID]) == 0 { + delete(u.roomIntensityByRoomAndCause, roomID) + } + break } + if u.roomIntensityByRoomAndCause == nil { + u.roomIntensityByRoomAndCause = make(map[string]map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity) + } + if u.roomIntensityByRoomAndCause[roomID] == nil { + u.roomIntensityByRoomAndCause[roomID] = make(map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity) + } + u.roomIntensityByRoomAndCause[roomID][preference.GetReason()] = preference.GetIntensity() case *corev1.Event_UserServerPreferencesChanged: p.applyLegacyUserPreferencesLocked(e.UserServerPreferencesChanged) case *corev1.Event_UserAccountDeleted: diff --git a/cli/internal/core/config_projection_snapshot.go b/cli/internal/core/config_projection_snapshot.go index 0b394f793..2f2ea349a 100644 --- a/cli/internal/core/config_projection_snapshot.go +++ b/cli/internal/core/config_projection_snapshot.go @@ -2,6 +2,7 @@ package core import ( "fmt" + "sort" "google.golang.org/protobuf/proto" @@ -31,12 +32,21 @@ func (p *ConfigProjection) Snapshot() ([]byte, error) { value := *user.timeFormat row.TimeFormat = &value } - if user.serverLevel != nil { - value := *user.serverLevel - row.ServerNotificationLevel = &value + for _, reason := range sortedNotificationReasons(user.serverIntensityByReason) { + row.ServerNotificationPreferences = append(row.ServerNotificationPreferences, &corev1.NotificationPreferenceSnapshot{ + Reason: reason, + Intensity: user.serverIntensityByReason[reason], + }) } - for _, roomID := range sortedMapKeys(user.roomLevelByRoom) { - row.RoomNotificationLevels = append(row.RoomNotificationLevels, &corev1.RoomNotificationLevelSnapshot{RoomId: roomID, Level: user.roomLevelByRoom[roomID]}) + for _, roomID := range sortedMapKeys(user.roomIntensityByRoomAndCause) { + room := &corev1.RoomNotificationPreferenceSnapshot{RoomId: roomID} + for _, reason := range sortedNotificationReasons(user.roomIntensityByRoomAndCause[roomID]) { + room.Preferences = append(room.Preferences, &corev1.NotificationPreferenceSnapshot{ + Reason: reason, + Intensity: user.roomIntensityByRoomAndCause[roomID][reason], + }) + } + row.RoomNotificationPreferences = append(row.RoomNotificationPreferences, room) } snapshot.Users = append(snapshot.Users, row) } @@ -63,7 +73,10 @@ func (p *ConfigProjection) Restore(data []byte) error { if _, duplicate := users[row.GetUserId()]; duplicate { return fmt.Errorf("config snapshot repeats user %q", row.GetUserId()) } - user := &userConfigState{roomLevelByRoom: make(map[string]corev1.NotificationLevel)} + user := &userConfigState{ + serverIntensityByReason: make(map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity), + roomIntensityByRoomAndCause: make(map[string]map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity), + } if row.Timezone != nil { value := row.GetTimezone() user.timezone = &value @@ -72,18 +85,27 @@ func (p *ConfigProjection) Restore(data []byte) error { value := row.GetTimeFormat() user.timeFormat = &value } - if row.ServerNotificationLevel != nil { - value := row.GetServerNotificationLevel() - user.serverLevel = &value + for _, preference := range row.GetServerNotificationPreferences() { + if _, duplicate := user.serverIntensityByReason[preference.GetReason()]; duplicate { + return fmt.Errorf("config snapshot repeats server notification preference") + } + user.serverIntensityByReason[preference.GetReason()] = preference.GetIntensity() } - for _, level := range row.GetRoomNotificationLevels() { - if level.GetRoomId() == "" { - return fmt.Errorf("config snapshot has empty notification room ID") + for _, room := range row.GetRoomNotificationPreferences() { + if room.GetRoomId() == "" { + return fmt.Errorf("config snapshot has empty notification preference room ID") + } + if _, duplicate := user.roomIntensityByRoomAndCause[room.GetRoomId()]; duplicate { + return fmt.Errorf("config snapshot repeats room notification preferences") } - if _, duplicate := user.roomLevelByRoom[level.GetRoomId()]; duplicate { - return fmt.Errorf("config snapshot repeats room notification level") + preferences := make(map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity) + for _, preference := range room.GetPreferences() { + if _, duplicate := preferences[preference.GetReason()]; duplicate { + return fmt.Errorf("config snapshot repeats room notification cause") + } + preferences[preference.GetReason()] = preference.GetIntensity() } - user.roomLevelByRoom[level.GetRoomId()] = level.GetLevel() + user.roomIntensityByRoomAndCause[room.GetRoomId()] = preferences } users[row.GetUserId()] = user } @@ -92,3 +114,12 @@ func (p *ConfigProjection) Restore(data []byte) error { p.Unlock() return nil } + +func sortedNotificationReasons(values map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity) []corev1.NotificationReason { + keys := make([]corev1.NotificationReason, 0, len(values)) + for reason := range values { + keys = append(keys, reason) + } + sort.Slice(keys, func(i, j int) bool { return keys[i] < keys[j] }) + return keys +} diff --git a/cli/internal/core/core.go b/cli/internal/core/core.go index fe02863a0..9b893fbf4 100644 --- a/cli/internal/core/core.go +++ b/cli/internal/core/core.go @@ -39,9 +39,11 @@ type ChattoCore struct { roomDirectoryReads *RoomDirectoryReadModel messageModel *MessageModel messageSearchReads *MessageSearchReadModel - notificationPrefs *NotificationPreferencesModel + notificationPolicy *NotificationPolicyModel roomTimelineReads *RoomTimelineReadModel readStateModel *ReadStateModel + notificationOccurrences *NotificationOccurrenceModel + notificationMaterializer *NotificationMaterializer threadFollows *ThreadFollowModel reactionModel *ReactionModel userModel *UserModel @@ -74,15 +76,10 @@ type ChattoCore struct { // independently; the main process does not hand work to a local callback. VideoUploadsEnabled bool - // OnNotificationCreated is called when a notification is created. - // Used by the push notification system to send Web Push notifications. - // Set this after ChattoCore is created. - OnNotificationCreated func(ctx context.Context, notification *corev1.Notification) - - // OnNotificationDismissed is called when a notification is dismissed. - // Used by the push notification system to dismiss notifications on other devices. - // Set this after ChattoCore is created. - OnNotificationDismissed func(ctx context.Context, userID string, notification *corev1.Notification) + // OnNotificationOccurrenceCreated delivers a claimed Alert occurrence. The + // claim is retried after a lease timeout when the callback returns an error + // or the delivering replica stops before recording completion. + OnNotificationOccurrenceCreated func(ctx context.Context, occurrence *corev1.NotificationOccurrence) error // OnPushTestRequested sends a test notification to a user's push subscriptions. OnPushTestRequested func(ctx context.Context, userID string) error @@ -165,6 +162,12 @@ func (c *ChattoCore) Run(ctx context.Context) error { if err := c.readStateModel.WaitReady(gctx); err != nil { return fmt.Errorf("wait for read state index: %w", err) } + if err := c.notificationOccurrences.WaitReady(gctx); err != nil { + return fmt.Errorf("wait for notification occurrence index: %w", err) + } + if err := c.notificationMaterializer.WaitReady(gctx); err != nil { + return fmt.Errorf("wait for notification materializer: %w", err) + } c.secureDeleteObsoleteProjectedMessageBodyEvents(gctx) // Apply config-designated owners to already-verified users on every // boot. Changing owners.emails requires a process restart, so this @@ -190,6 +193,8 @@ func (c *ChattoCore) Run(ctx context.Context) error { }) g.Go(func() error { return c.readStateModel.Run(gctx) }) + g.Go(func() error { return c.notificationOccurrences.Run(gctx) }) + g.Go(func() error { return c.notificationMaterializer.Run(gctx) }) g.Go(func() error { return c.presenceModel.Run(gctx) }) g.Go(func() error { return c.myEventsModel.Run(gctx) }) g.Go(func() error { return c.callModel.Run(gctx) }) diff --git a/cli/internal/core/core_services.go b/cli/internal/core/core_services.go index f543de294..8f27a7179 100644 --- a/cli/internal/core/core_services.go +++ b/cli/internal/core/core_services.go @@ -105,7 +105,7 @@ func initializeCoreServices( core.roomDirectoryReads = &RoomDirectoryReadModel{core: core} core.messageModel = &MessageModel{core: core} core.messageSearchReads = &MessageSearchReadModel{core: core} - core.notificationPrefs = &NotificationPreferencesModel{core: core} + core.notificationPolicy = &NotificationPolicyModel{core: core} core.roomTimelineReads = &RoomTimelineReadModel{ core: core, rooms: core.roomModel, @@ -114,6 +114,12 @@ func initializeCoreServices( core: core, index: NewReadStateIndex(infra.storage.runtimeStateKV, logger.WithPrefix("core.ReadStateIndex")), } + core.notificationOccurrences = NewNotificationOccurrenceModel( + core, + infra.storage.runtimeStateKV, + logger.WithPrefix("core.NotificationOccurrences"), + ) + core.notificationMaterializer = NewNotificationMaterializer(core, projections.notificationVisibility) core.threadFollows = &ThreadFollowModel{core: core} core.reactionModel = &ReactionModel{core: core, mutations: core.EventPublisher} core.keyShredding, err = newUserKeyShreddingModel(ctx, core, logger.WithPrefix("core.UserKeyShredding")) @@ -124,6 +130,9 @@ func initializeCoreServices( if err := core.seedDefaultRBAC(ctx); err != nil { return fmt.Errorf("failed to seed default RBAC: %w", err) } + if err := core.notificationMaterializer.Initialize(ctx); err != nil { + return fmt.Errorf("failed to initialize notification materializer: %w", err) + } core.permissionResolver = NewPermissionResolver(core) core.linkPreviewCache = linkpreview.NewCache(infra.storage.runtimeStateKV) diff --git a/cli/internal/core/core_test.go b/cli/internal/core/core_test.go index 2fac7f590..1e7495d8d 100644 --- a/cli/internal/core/core_test.go +++ b/cli/internal/core/core_test.go @@ -114,8 +114,8 @@ func TestNewChattoCoreInitializesOperationModels(t *testing.T) { if core.AssetUploads() != core.assetUploadModel { t.Fatal("AssetUploads() did not return the eagerly wired model") } - if core.NotificationPreferences() == nil { - t.Fatal("NotificationPreferences() = nil") + if core.NotificationPolicy() == nil { + t.Fatal("NotificationPolicy() = nil") } if core.RoomTimelineReads() == nil { t.Fatal("RoomTimelineReads() = nil") diff --git a/cli/internal/core/dm.go b/cli/internal/core/dm.go index 5c79cfd4a..92e591e4d 100644 --- a/cli/internal/core/dm.go +++ b/cli/internal/core/dm.go @@ -10,7 +10,6 @@ import ( "github.com/nats-io/nats.go/jetstream" - "hmans.de/chatto/internal/core/subjects" "hmans.de/chatto/internal/evtstream" corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" "hmans.de/chatto/pkg/events" @@ -242,79 +241,3 @@ func ensureInList(list []string, id string) []string { } return append(list, id) } - -// notifyDMParticipants sends notifications to all DM participants except the sender. -// This creates persistent notifications (for bell icon) and publishes live events. -// This is best-effort - failures are logged but don't affect message posting. -func (c *ChattoCore) notifyDMParticipants(ctx context.Context, roomID, senderID, eventID string) { - participants, err := c.GetRoomMembersList(ctx, KindDM, roomID) - if err != nil { - c.logger.Warn("Failed to get DM participants for notification", - "room_id", roomID, - "error", err) - return - } - - for _, participant := range participants { - participantID := participant.UserId - // Don't notify the sender - if participantID == senderID { - continue - } - - // Skip if user has muted this DM room - level, err := c.GetEffectiveNotificationLevel(ctx, participantID, roomID) - if err != nil { - c.logger.Warn("Failed to get notification level for DM participant, continuing", - "user_id", participantID, "error", err) - } else if level == corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - continue - } - // Create persistent notification (for bell icon and notification center) - // This also publishes NotificationCreatedEvent for real-time updates - created, createErr := c.CreateNotification(ctx, participantID, senderID, &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{ - RoomId: roomID, - EventId: eventID, - }, - }, - }) - if createErr != nil { - c.logger.Warn("Failed to create DM notification", - "participant_id", participantID, - "sender_id", senderID, - "room_id", roomID, - "error", createErr) - continue - } - if created == nil { - continue - } - if c.suppressesNotificationAlertsForPresence(ctx, participantID) { - continue - } - - // Publish live DM notification event for unread indicator real-time update - event := newLiveEvent(senderID, &corev1.LiveEvent{ - Event: &corev1.LiveEvent_NewDirectMessageNotification{ - NewDirectMessageNotification: &corev1.NewDirectMessageNotificationEvent{ - RoomId: roomID, - SenderId: senderID, - }, - }, - }) - - subject := subjects.LiveSyncUserEvent(participantID, "dm_message") - if err := c.publishLiveEvent(ctx, subject, event); err != nil { - c.logger.Warn("Failed to publish DM live event", - "participant_id", participantID, - "error", err) - } - - c.logger.Debug("Created DM notification", - "participant_id", participantID, - "sender_id", senderID, - "room_id", roomID) - } -} diff --git a/cli/internal/core/dm_test.go b/cli/internal/core/dm_test.go index 88e24dbe1..7bb7a25b0 100644 --- a/cli/internal/core/dm_test.go +++ b/cli/internal/core/dm_test.go @@ -796,9 +796,9 @@ func TestDMNotifications(t *testing.T) { } t.Run("DM message triggers notification to other participants", func(t *testing.T) { - // Subscribe to user2's notification subject + // Subscribe to the occurrence invalidation subject. notificationReceived := make(chan bool, 1) - sub, err := nc.Subscribe(subjects.LiveSyncUserEvent(user2.Id, "dm_message"), func(msg *nats.Msg) { + sub, err := nc.Subscribe(subjects.LiveSyncUserEvent(user2.Id, "notification_v2"), func(msg *nats.Msg) { notificationReceived <- true }) if err != nil { @@ -819,27 +819,23 @@ func TestDMNotifications(t *testing.T) { case <-time.After(2 * time.Second): t.Error("Expected to receive DM notification for user2") } + occurrences := testNotificationOccurrences(t, core, user2.Id) + if len(occurrences) != 1 || !testOccurrenceHasReason(occurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE) { + t.Fatalf("DM occurrences = %+v, want one direct-message occurrence", occurrences) + } }) t.Run("DM message creates silent notification for do not disturb participants", func(t *testing.T) { if err := core.SetPresence(ctx, user2.Id, PresenceStatusDoNotDisturb); err != nil { t.Fatalf("SetPresence DND: %v", err) } - before, err := core.GetNotifications(ctx, user2.Id) - if err != nil { - t.Fatalf("GetNotifications before DND DM: %v", err) - } + before := testNotificationOccurrences(t, core, user2.Id) - sub, err := nc.SubscribeSync(subjects.LiveSyncUserEvent(user2.Id, "notification_created")) + sub, err := nc.SubscribeSync(subjects.LiveSyncUserEvent(user2.Id, "notification_v2")) if err != nil { t.Fatalf("Failed to subscribe: %v", err) } defer sub.Unsubscribe() - dmSub, err := nc.SubscribeSync(subjects.LiveSyncUserEvent(user2.Id, "dm_message")) - if err != nil { - t.Fatalf("Failed to subscribe to dm_message: %v", err) - } - defer dmSub.Unsubscribe() if err := nc.Flush(); err != nil { t.Fatalf("Flush subscription: %v", err) } @@ -851,27 +847,20 @@ func TestDMNotifications(t *testing.T) { msg, err := sub.NextMsg(2 * time.Second) if err != nil { - t.Fatalf("waiting for DND notification_created live event: %v", err) + t.Fatalf("waiting for DND notification occurrence change: %v", err) } var live corev1.LiveEvent if err := proto.Unmarshal(msg.Data, &live); err != nil { t.Fatalf("unmarshal live event: %v", err) } - event := live.GetNotificationCreated() + event := live.GetNotificationOccurrenceChanged() if event == nil { - t.Fatalf("expected NotificationCreatedEvent, got %T", live.Event) + t.Fatalf("expected NotificationOccurrenceChangedEvent, got %T", live.Event) } - if !event.Silent { - t.Fatal("NotificationCreatedEvent.Silent = false, want true") - } - if _, err := dmSub.NextMsg(200 * time.Millisecond); err == nil { - t.Fatal("expected no legacy live DM notification while DND") - } - - after, err := core.GetNotifications(ctx, user2.Id) - if err != nil { - t.Fatalf("GetNotifications after DND DM: %v", err) + if event.Alert { + t.Fatal("NotificationOccurrenceChangedEvent.Alert = true during DND, want false") } + after := testNotificationOccurrences(t, core, user2.Id) if len(after) != len(before)+1 { t.Fatalf("notifications after DND DM = %d, want %d", len(after), len(before)+1) } diff --git a/cli/internal/core/event_envelope.go b/cli/internal/core/event_envelope.go index efae1ab32..a28ddc535 100644 --- a/cli/internal/core/event_envelope.go +++ b/cli/internal/core/event_envelope.go @@ -143,17 +143,3 @@ func EventPresenceChanged(event EventEnvelope) *corev1.PresenceChangedEvent { } return event.LiveEvent().GetPresenceChanged() } - -func EventMentionNotification(event EventEnvelope) *corev1.MentionNotificationEvent { - if event == nil || event.LiveEvent() == nil { - return nil - } - return event.LiveEvent().GetMentionNotification() -} - -func EventNotificationCreated(event EventEnvelope) *corev1.NotificationCreatedEvent { - if event == nil || event.LiveEvent() == nil { - return nil - } - return event.LiveEvent().GetNotificationCreated() -} diff --git a/cli/internal/core/mentions.go b/cli/internal/core/mentions.go index 227aa5749..0c9ff71d5 100644 --- a/cli/internal/core/mentions.go +++ b/cli/internal/core/mentions.go @@ -2,6 +2,8 @@ package core import ( "context" + "errors" + "fmt" "strings" "github.com/yuin/goldmark" @@ -9,7 +11,6 @@ import ( "github.com/yuin/goldmark/parser" "github.com/yuin/goldmark/text" "github.com/yuin/goldmark/util" - "hmans.de/chatto/internal/core/subjects" corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" ) @@ -225,8 +226,18 @@ func (c *ChattoCore) ResolveMentions(ctx context.Context, usernames []string) ([ return userIDs, nil } -// ResolveRoomMentions resolves @handles in a message to concrete room-member -// user IDs. Handles share one namespace across users, roles, and virtual +// RoomMentionResolution retains both the concrete recipients and why each +// recipient matched. The reason provenance is embedded in the durable message +// source fact so @here presence and overlapping handles are not re-evaluated +// later by notification materialization. +type RoomMentionResolution struct { + RecipientIDs []string + ReasonsByUser map[string][]corev1.NotificationReason +} + +// ResolveRoomMentionReasons resolves @handles in a message to concrete +// room-member user IDs and distinct Notifications 2.0 causes. Handles share +// one namespace across users, roles, and virtual // room-scoped broadcasts: // - @all: every current room member // - @here: current room members whose presence is not OFFLINE @@ -234,9 +245,10 @@ func (c *ChattoCore) ResolveMentions(ctx context.Context, usernames []string) ([ // - @user: that user, if they are a current room member // // Invalid handles are silently ignored, matching existing @user behavior. -func (c *ChattoCore) ResolveRoomMentions(ctx context.Context, kind RoomKind, roomID string, handles []string) ([]string, error) { +func (c *ChattoCore) ResolveRoomMentionReasons(ctx context.Context, kind RoomKind, roomID string, handles []string) (*RoomMentionResolution, error) { + result := &RoomMentionResolution{ReasonsByUser: make(map[string][]corev1.NotificationReason)} if len(handles) == 0 { - return nil, nil + return result, nil } members, err := c.GetRoomMembersList(ctx, kind, roomID) @@ -250,24 +262,27 @@ func (c *ChattoCore) ResolveRoomMentions(ctx context.Context, kind RoomKind, roo } } - seen := make(map[string]struct{}) - userIDs := make([]string, 0, len(handles)) - add := func(userID string) { + seen := make(map[string]map[corev1.NotificationReason]struct{}) + add := func(userID string, reason corev1.NotificationReason) { if userID == "" { return } if _, ok := roomMembers[userID]; !ok { return } - if _, ok := seen[userID]; ok { + if seen[userID] == nil { + seen[userID] = make(map[corev1.NotificationReason]struct{}) + result.RecipientIDs = append(result.RecipientIDs, userID) + } + if _, duplicate := seen[userID][reason]; duplicate { return } - seen[userID] = struct{}{} - userIDs = append(userIDs, userID) + seen[userID][reason] = struct{}{} + result.ReasonsByUser[userID] = append(result.ReasonsByUser[userID], reason) } - addMembers := func(candidates []string) { + addMembers := func(candidates []string, reason corev1.NotificationReason) { for _, userID := range candidates { - add(userID) + add(userID, reason) } } @@ -277,7 +292,7 @@ func (c *ChattoCore) ResolveRoomMentions(ctx context.Context, kind RoomKind, roo case MentionHandleAll: for _, member := range members { if member != nil { - add(member.UserId) + add(member.UserId, corev1.NotificationReason_NOTIFICATION_REASON_ALL) } } continue @@ -288,14 +303,10 @@ func (c *ChattoCore) ResolveRoomMentions(ctx context.Context, kind RoomKind, roo } status, err := c.GetUserPresence(ctx, member.UserId) if err != nil { - c.logger.Warn("Failed to get presence for @here mention", - "user_id", member.UserId, - "room_id", roomID, - "error", err) - continue + return nil, fmt.Errorf("resolve @here presence: %w", err) } if status != PresenceStatusOffline { - add(member.UserId) + add(member.UserId, corev1.NotificationReason_NOTIFICATION_REASON_HERE) } } continue @@ -311,202 +322,54 @@ func (c *ChattoCore) ResolveRoomMentions(ctx context.Context, kind RoomKind, roo } roleUsers, err := c.GetRoleUsers(ctx, normalized) if err != nil { - if err != ErrRoleNotFound { - c.logger.Warn("Failed to resolve role mention", - "role", normalized, - "room_id", roomID, - "error", err) + if errors.Is(err, ErrRoleNotFound) { + continue } - continue + return nil, fmt.Errorf("resolve role mention: %w", err) } - addMembers(roleUsers) + addMembers(roleUsers, corev1.NotificationReason_NOTIFICATION_REASON_ROLE_MENTION) continue } user, err := c.GetUserByLogin(ctx, handle) if err != nil { - continue + if errors.Is(err, ErrNotFound) { + continue + } + return nil, fmt.Errorf("resolve user mention: %w", err) } - add(user.Id) + add(user.Id, corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) } - return userIDs, nil + return result, nil } -// ResolveDirectRoomMentions resolves only direct @user handles to room-member -// user IDs. Role and virtual broadcast handles are intentionally ignored. -func (c *ChattoCore) ResolveDirectRoomMentions(ctx context.Context, kind RoomKind, roomID string, handles []string) ([]string, error) { - if len(handles) == 0 { - return nil, nil - } - - members, err := c.GetRoomMembersList(ctx, kind, roomID) +// ResolveRoomMentions is the compatibility view used by message rendering and +// legacy callers that only need the concrete recipient list. +func (c *ChattoCore) ResolveRoomMentions(ctx context.Context, kind RoomKind, roomID string, handles []string) ([]string, error) { + resolved, err := c.ResolveRoomMentionReasons(ctx, kind, roomID, handles) if err != nil { return nil, err } - roomMembers := make(map[string]struct{}, len(members)) - for _, member := range members { - if member != nil && member.UserId != "" { - roomMembers[member.UserId] = struct{}{} - } - } - - seen := make(map[string]struct{}) - userIDs := make([]string, 0, len(handles)) - for _, handle := range handles { - normalized := strings.ToLower(handle) - if IsVirtualMentionHandle(normalized) || normalized == RoleEveryone { - continue - } - if _, ok := c.rbacModel.role(normalized); ok { - continue - } - - user, err := c.GetUserByLogin(ctx, handle) - if err != nil { - continue - } - if _, ok := roomMembers[user.Id]; !ok { - continue - } - if _, ok := seen[user.Id]; ok { - continue - } - seen[user.Id] = struct{}{} - userIDs = append(userIDs, user.Id) - } - - return userIDs, nil + return resolved.RecipientIDs, nil } -func (c *ChattoCore) mentionNotificationRecipientCount(ctx context.Context, roomID, authorID string, mentionedUserIDs []string) (int, error) { - count := 0 - for _, mentionedUserID := range mentionedUserIDs { - if mentionedUserID == "" || mentionedUserID == authorID { - continue - } - level, err := c.GetEffectiveNotificationLevel(ctx, mentionedUserID, roomID) - if err != nil { - return 0, err - } - if level == corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - continue - } - count++ - } - return count, nil -} - -// MentionNotificationRecipientCountForBody returns how many people would -// receive a notification from the @mentions in body after room membership, -// author/self, dedupe, and room-mute filtering are applied. -func (c *ChattoCore) MentionNotificationRecipientCountForBody(ctx context.Context, kind RoomKind, roomID, authorID, body string) (int, error) { - handles := ExtractMentionUsernames(body) - mentionedUserIDs, err := c.ResolveRoomMentions(ctx, kind, roomID, handles) +// ResolveDirectRoomMentions resolves only direct @user handles to room-member +// user IDs. Role and virtual broadcast handles are intentionally ignored. +func (c *ChattoCore) ResolveDirectRoomMentions(ctx context.Context, kind RoomKind, roomID string, handles []string) ([]string, error) { + resolved, err := c.ResolveRoomMentionReasons(ctx, kind, roomID, handles) if err != nil { - return 0, err - } - return c.mentionNotificationRecipientCount(ctx, roomID, authorID, mentionedUserIDs) -} - -// notifyMentionedUsers creates persistent notifications for all mentioned users. -// This is best-effort - failures are logged but don't affect message posting. -// -// inThread is the thread root event ID when the mention is on a message inside -// a thread, or empty string for room-level messages. The frontend uses this to -// route notification clicks directly into the thread pane. -func (c *ChattoCore) notifyMentionedUsers(ctx context.Context, kind RoomKind, roomID, authorID, eventID, inThread string, mentionedUserIDs, directMentionedUserIDs []string) []string { - directMentioned := make(map[string]struct{}, len(directMentionedUserIDs)) - for _, userID := range directMentionedUserIDs { - if userID != "" { - directMentioned[userID] = struct{}{} - } + return nil, err } - - var newlyAutoFollowed []string - for _, mentionedUserID := range mentionedUserIDs { - // Don't notify the author if they mentioned themselves - if mentionedUserID == authorID { - continue - } - - // Skip if user has muted this room - level, err := c.GetEffectiveNotificationLevel(ctx, mentionedUserID, roomID) - if err != nil { - c.logger.Warn("Failed to get notification level for mention check, continuing", - "user_id", mentionedUserID, "error", err) - } else if level == corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - continue - } - // Create persistent notification (for bell icon and notification center) - // This also publishes NotificationCreatedEvent for real-time updates - created, createErr := c.CreateNotification(ctx, mentionedUserID, authorID, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{ - RoomId: roomID, - EventId: eventID, - InThread: inThread, - }, - }, - }) - if createErr != nil { - c.logger.Warn("Failed to create mention notification", - "mentioned_user_id", mentionedUserID, - "author_id", authorID, - "kind", kind, - "room_id", roomID, - "error", createErr) - continue - } - if created == nil { - continue - } - if inThread != "" { - if _, ok := directMentioned[mentionedUserID]; ok { - followed, err := c.FollowThreadIfNeverSet(ctx, kind, mentionedUserID, roomID, inThread, corev1.ThreadFollowSource_THREAD_FOLLOW_SOURCE_DIRECT_MENTION) - if err != nil { - c.logger.Warn("Failed to auto-follow thread for mentioned user", - "mentioned_user_id", mentionedUserID, - "kind", kind, - "room_id", roomID, - "thread_root_event_id", inThread, - "error", err) - } else if followed { - newlyAutoFollowed = append(newlyAutoFollowed, mentionedUserID) - c.logger.Debug("Auto-followed thread for mentioned user", - "mentioned_user_id", mentionedUserID, - "kind", kind, - "room_id", roomID, - "thread_root_event_id", inThread) - } + userIDs := make([]string, 0, len(resolved.RecipientIDs)) + for _, userID := range resolved.RecipientIDs { + for _, reason := range resolved.ReasonsByUser[userID] { + if reason == corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION { + userIDs = append(userIDs, userID) + break } } - if c.suppressesNotificationAlertsForPresence(ctx, mentionedUserID) { - continue - } - - // Publish live mention event for room-level indicator real-time update. - // Clients hydrate space, room, and user names from projected reads. - mentionEvent := newLiveEvent(authorID, &corev1.LiveEvent{ - Event: &corev1.LiveEvent_MentionNotification{ - MentionNotification: &corev1.MentionNotificationEvent{ - RoomId: roomID, - MentionedByUserId: authorID, - }, - }, - }) - subject := subjects.LiveSyncUserEvent(mentionedUserID, "mentioned") - if err := c.publishLiveEvent(ctx, subject, mentionEvent); err != nil { - c.logger.Warn("Failed to publish mention live event", - "mentioned_user_id", mentionedUserID, - "error", err) - } - - c.logger.Debug("Created mention notification", - "mentioned_user_id", mentionedUserID, - "author_id", authorID, - "kind", kind, - "room_id", roomID) } - return newlyAutoFollowed + + return userIDs, nil } diff --git a/cli/internal/core/mentions_test.go b/cli/internal/core/mentions_test.go index f1d2752c2..27e68eed3 100644 --- a/cli/internal/core/mentions_test.go +++ b/cli/internal/core/mentions_test.go @@ -547,27 +547,19 @@ func TestChattoCore_MentionCreatesNotificationWithoutMentionStatus(t *testing.T) t.Fatalf("PostMessage: %v", err) } - notifications, err := core.GetNotifications(ctx, mentioned.Id) - if err != nil { - t.Fatalf("GetNotifications: %v", err) - } - if len(notifications) != 1 || notifications[0].GetMention() == nil { + notifications := testNotificationOccurrences(t, core, mentioned.Id) + if len(notifications) != 1 || !testOccurrenceHasReason(notifications[0], corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) { t.Fatalf("expected one mention notification, got %#v", notifications) } if err := core.SetPresence(ctx, mentioned.Id, PresenceStatusDoNotDisturb); err != nil { t.Fatalf("SetPresence DND: %v", err) } - sub, err := nc.SubscribeSync(subjects.LiveSyncUserEvent(mentioned.Id, "notification_created")) + sub, err := nc.SubscribeSync(subjects.LiveSyncUserEvent(mentioned.Id, "notification_v2")) if err != nil { - t.Fatalf("SubscribeSync notification_created: %v", err) + t.Fatalf("SubscribeSync notification_v2: %v", err) } defer sub.Unsubscribe() - mentionSub, err := nc.SubscribeSync(subjects.LiveSyncUserEvent(mentioned.Id, "mentioned")) - if err != nil { - t.Fatalf("SubscribeSync mentioned: %v", err) - } - defer mentionSub.Unsubscribe() if err := nc.Flush(); err != nil { t.Fatalf("Flush subscription: %v", err) } @@ -577,26 +569,20 @@ func TestChattoCore_MentionCreatesNotificationWithoutMentionStatus(t *testing.T) } msg, err := sub.NextMsg(2 * time.Second) if err != nil { - t.Fatalf("waiting for DND notification_created live event: %v", err) + t.Fatalf("waiting for DND notification occurrence change: %v", err) } var live corev1.LiveEvent if err := proto.Unmarshal(msg.Data, &live); err != nil { t.Fatalf("unmarshal live event: %v", err) } - event := live.GetNotificationCreated() + event := live.GetNotificationOccurrenceChanged() if event == nil { - t.Fatalf("expected NotificationCreatedEvent, got %T", live.Event) + t.Fatalf("expected NotificationOccurrenceChangedEvent, got %T", live.Event) } - if !event.Silent { - t.Fatal("NotificationCreatedEvent.Silent = false, want true") - } - if _, err := mentionSub.NextMsg(200 * time.Millisecond); err == nil { - t.Fatal("expected no legacy live mention notification while DND") - } - notifications, err = core.GetNotifications(ctx, mentioned.Id) - if err != nil { - t.Fatalf("GetNotifications after DND: %v", err) + if event.Alert { + t.Fatal("NotificationOccurrenceChangedEvent.Alert = true during DND, want false") } + notifications = testNotificationOccurrences(t, core, mentioned.Id) if len(notifications) != 2 { t.Fatalf("notifications after DND mention = %d, want 2", len(notifications)) } @@ -635,10 +621,7 @@ func TestChattoCore_MentionInsideMarkdownCodeDoesNotNotify(t *testing.T) { t.Fatalf("mentioned_user_ids = %v, want none", got) } - notifications, err := core.GetNotifications(ctx, mentioned.Id) - if err != nil { - t.Fatalf("GetNotifications: %v", err) - } + notifications := testNotificationOccurrences(t, core, mentioned.Id) if len(notifications) != 0 { t.Fatalf("expected no mention notification, got %#v", notifications) } @@ -675,11 +658,8 @@ func TestChattoCore_MentionImmediatelyAfterMarkdownCodeNotifies(t *testing.T) { } requireUserIDs(t, event.GetMessagePosted().GetMentionedUserIds(), mentioned.Id) - notifications, err := core.GetNotifications(ctx, mentioned.Id) - if err != nil { - t.Fatalf("GetNotifications: %v", err) - } - if len(notifications) != 1 || notifications[0].GetMention() == nil { + notifications := testNotificationOccurrences(t, core, mentioned.Id) + if len(notifications) != 1 || !testOccurrenceHasReason(notifications[0], corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) { t.Fatalf("expected one mention notification, got %#v", notifications) } } @@ -719,10 +699,7 @@ func TestChattoCore_MentionSplitByMarkdownFormattingDoesNotNotify(t *testing.T) } } - notifications, err := core.GetNotifications(ctx, alice.Id) - if err != nil { - t.Fatalf("GetNotifications: %v", err) - } + notifications := testNotificationOccurrences(t, core, alice.Id) if len(notifications) != 0 { t.Fatalf("expected no mention notification, got %#v", notifications) } diff --git a/cli/internal/core/messages.go b/cli/internal/core/messages.go index 8685e84d7..d8f501b58 100644 --- a/cli/internal/core/messages.go +++ b/cli/internal/core/messages.go @@ -20,9 +20,10 @@ const ( ) type postMessageOptions struct { - videoProcessingAssetIDs map[string]struct{} - createThread bool - commitAuthorize func(context.Context, string) error + videoProcessingAssetIDs map[string]struct{} + createThread bool + commitAuthorize func(context.Context, string) error + notificationAttemptPrepared func(context.Context) error } type editMessageOptions struct { @@ -78,6 +79,16 @@ func withPostMessageCommitAuthorization(authorize func(context.Context, string) } } +// withPostMessageNotificationAttemptPrepared installs a package-private test +// hook after notification work is prepared but before the guarded append. It +// lets concurrency tests deterministically force an OCC retry at that exact +// boundary without exposing another production API. +func withPostMessageNotificationAttemptPrepared(hook func(context.Context) error) PostMessageOption { + return func(options *postMessageOptions) { + options.notificationAttemptPrepared = hook + } +} + // WithMessageChannelEcho reconciles whether a thread reply should have a // visible echo in the channel timeline after the edit is saved. func WithMessageChannelEcho(enabled bool) EditMessageOption { @@ -355,6 +366,7 @@ func (c *ChattoCore) appendBodyAndMessage( bodyEvent, messageEvent *corev1.Event, processingEvents []*corev1.Event, authorize func(context.Context) error, + prepareNotificationWork func(context.Context) error, ) (uint64, error) { bodySubject := agg.SubjectFor(bodyEvent) messageSubject := agg.SubjectFor(messageEvent) @@ -365,6 +377,11 @@ func (c *ChattoCore) appendBodyAndMessage( if err != nil { return 0, err } + if prepareNotificationWork != nil { + if err := prepareNotificationWork(ctx); err != nil { + return 0, err + } + } entries := []evtstream.BatchEntry{ { Subject: bodySubject, @@ -414,7 +431,7 @@ func (c *ChattoCore) appendBodyAndMessage( return 0, fmt.Errorf("append message body batch after %d attempts: %w", maxThreadCreateAppendAttempts, lastErr) } -func (c *ChattoCore) appendRootMessageWithThread(ctx context.Context, agg evtstream.Aggregate, bodyEvent, messageEvent, threadCreatedEvent, threadFollowedEvent *corev1.Event, processingEvents []*corev1.Event, authorize func(context.Context) error) (uint64, error) { +func (c *ChattoCore) appendRootMessageWithThread(ctx context.Context, agg evtstream.Aggregate, bodyEvent, messageEvent, threadCreatedEvent, threadFollowedEvent *corev1.Event, processingEvents []*corev1.Event, authorize func(context.Context) error, prepareNotificationWork func(context.Context) error) (uint64, error) { messageSubject := agg.SubjectFor(messageEvent) bodySubject := agg.SubjectFor(bodyEvent) var lastErr error @@ -424,6 +441,11 @@ func (c *ChattoCore) appendRootMessageWithThread(ctx context.Context, agg evtstr if err != nil { return 0, err } + if prepareNotificationWork != nil { + if err := prepareNotificationWork(ctx); err != nil { + return 0, err + } + } entries := []evtstream.BatchEntry{ { @@ -681,14 +703,15 @@ func (c *ChattoCore) appendMessageWithOptionalThreadCreated( threadRootEventID string, processingEvents []*corev1.Event, authorize func(context.Context) error, + prepareNotificationWork func(context.Context) error, ) (uint64, error) { if threadCreatedEvent == nil || threadRootEventID == "" || c.roomModel.threadExists(threadRootEventID) { - return c.appendBodyAndMessage(ctx, agg, bodyEvent, messageEvent, processingEvents, authorize) + return c.appendBodyAndMessage(ctx, agg, bodyEvent, messageEvent, processingEvents, authorize, prepareNotificationWork) } if exists, err := c.threadCreatedExistsInStream(ctx, agg, threadRootEventID); err != nil { return 0, fmt.Errorf("check existing thread creation: %w", err) } else if exists { - return c.appendBodyAndMessage(ctx, agg, bodyEvent, messageEvent, processingEvents, authorize) + return c.appendBodyAndMessage(ctx, agg, bodyEvent, messageEvent, processingEvents, authorize, prepareNotificationWork) } threadCreatedSubject := agg.Subject(evtstream.EventThreadCreated) @@ -701,6 +724,11 @@ func (c *ChattoCore) appendMessageWithOptionalThreadCreated( if err != nil { return 0, err } + if prepareNotificationWork != nil { + if err := prepareNotificationWork(ctx); err != nil { + return 0, err + } + } entries := []evtstream.BatchEntry{ { Subject: threadCreatedSubject, @@ -755,12 +783,12 @@ func (c *ChattoCore) appendMessageWithOptionalThreadCreated( } } if c.roomModel.threadExists(threadRootEventID) { - return c.appendBodyAndMessage(ctx, agg, bodyEvent, messageEvent, processingEvents, authorize) + return c.appendBodyAndMessage(ctx, agg, bodyEvent, messageEvent, processingEvents, authorize, prepareNotificationWork) } if exists, err := c.threadCreatedExistsInStream(ctx, agg, threadRootEventID); err != nil { return 0, fmt.Errorf("check existing thread creation after conflict: %w", err) } else if exists { - return c.appendBodyAndMessage(ctx, agg, bodyEvent, messageEvent, processingEvents, authorize) + return c.appendBodyAndMessage(ctx, agg, bodyEvent, messageEvent, processingEvents, authorize, prepareNotificationWork) } } @@ -915,28 +943,11 @@ func (c *ChattoCore) PostMessage(ctx context.Context, kind RoomKind, room_id, us now := time.Now() - // Extract and resolve @mentions from message body - var mentionedUserIDs []string - var directMentionedUserIDs []string + // Mention tokens are stable request input, but their recipient expansion is + // mutable room state and is therefore resolved inside every OCC attempt. + var mentionUsernames []string if hasBody { - usernames := ExtractMentionUsernames(body) - if len(usernames) > 0 { - resolved, err := c.ResolveRoomMentions(ctx, kind, room_id, usernames) - if err != nil { - c.logger.Warn("Failed to resolve mentions", "error", err) - // Continue without mentions - don't fail the message - } else { - mentionedUserIDs = resolved - } - if inThread != "" { - directResolved, err := c.ResolveDirectRoomMentions(ctx, kind, room_id, usernames) - if err != nil { - c.logger.Warn("Failed to resolve direct mentions", "error", err) - } else { - directMentionedUserIDs = directResolved - } - } - } + mentionUsernames = ExtractMentionUsernames(body) } eventID := NewEventID() @@ -967,13 +978,51 @@ func (c *ChattoCore) PostMessage(ctx context.Context, kind RoomKind, room_id, us CreatedAt: timestamppb.New(now), Event: &corev1.Event_MessagePosted{ MessagePosted: &corev1.MessagePostedEvent{ - RoomId: room_id, - InReplyTo: inReplyTo, - InThread: inThread, - MentionedUserIds: mentionedUserIDs, + RoomId: room_id, + InReplyTo: inReplyTo, + InThread: inThread, }, }, }) + notificationTarget := &corev1.NotificationTarget{RoomId: room_id, EventId: eventID} + if inThread != "" { + notificationTarget.ThreadRootEventId = &inThread + } + if inReplyTo != "" { + notificationTarget.ParentEventId = &inReplyTo + } + var notificationDecisions []notificationRecipientDecision + prepareNotificationWork := func(attemptCtx context.Context) error { + var mentionResolution *RoomMentionResolution + if len(mentionUsernames) > 0 { + resolved, err := c.ResolveRoomMentionReasons(attemptCtx, kind, room_id, mentionUsernames) + if err != nil { + return fmt.Errorf("resolve notification mention recipients: %w", err) + } + mentionResolution = resolved + event.GetMessagePosted().MentionedUserIds = append([]string(nil), resolved.RecipientIDs...) + } else { + event.GetMessagePosted().MentionedUserIds = nil + } + if err := c.waitForCurrentNotificationPolicy(attemptCtx); err != nil { + return err + } + nextNotificationDecisions, err := c.buildMessageNotificationDecisions(attemptCtx, kind, room_id, user_id, inThread, inReplyTo, mentionResolution) + if err != nil { + return fmt.Errorf("evaluate notification decisions: %w", err) + } + notificationWork := newNotificationOccurrenceWork(event, notificationTarget, nextNotificationDecisions) + if err := c.notificationMaterializer.StoreWork(attemptCtx, event, notificationWork); err != nil { + return fmt.Errorf("prepare notification work: %w", err) + } + if options.notificationAttemptPrepared != nil { + if err := options.notificationAttemptPrepared(attemptCtx); err != nil { + return err + } + } + notificationDecisions = nextNotificationDecisions + return nil + } var threadCreatedEvent *corev1.Event if inThread != "" && !c.roomModel.threadExists(inThread) { threadCreatedEvent = newEvent(user_id, &corev1.Event{ @@ -1038,9 +1087,9 @@ func (c *ChattoCore) PostMessage(ctx context.Context, kind RoomKind, room_id, us } var sequenceID uint64 if options.createThread { - sequenceID, err = c.appendRootMessageWithThread(ctx, agg, bodyEventEvent, event, threadCreatedEvent, rootThreadFollowedEvent, processingEvents, commitAuthorize) + sequenceID, err = c.appendRootMessageWithThread(ctx, agg, bodyEventEvent, event, threadCreatedEvent, rootThreadFollowedEvent, processingEvents, commitAuthorize, prepareNotificationWork) } else { - sequenceID, err = c.appendMessageWithOptionalThreadCreated(ctx, agg, bodyEventEvent, event, threadCreatedEvent, inThread, processingEvents, commitAuthorize) + sequenceID, err = c.appendMessageWithOptionalThreadCreated(ctx, agg, bodyEventEvent, event, threadCreatedEvent, inThread, processingEvents, commitAuthorize, prepareNotificationWork) } if err != nil { return nil, fmt.Errorf("failed to publish message event: %w", err) @@ -1120,60 +1169,23 @@ func (c *ChattoCore) PostMessage(ctx context.Context, kind RoomKind, room_id, us } } - // Notify mentioned users (best-effort, don't fail the message if this fails) - var newlyAutoFollowedMentionedUserIDs []string - if len(mentionedUserIDs) > 0 { - newlyAutoFollowedMentionedUserIDs = c.notifyMentionedUsers(ctx, kind, room_id, user_id, event.Id, inThread, mentionedUserIDs, directMentionedUserIDs) - } - - // Notify the author of the message being replied to (best-effort). - // Fires for both room-level replies and in-thread replies with inReplyTo set. - // Runs before notifyThreadFollowers so the more specific inReplyTo notification - // takes priority (thread participants dedup against this). - var replyNotifiedUserID string - if inReplyTo != "" { - replyNotifiedUserID = c.notifyInReplyToAuthor(ctx, kind, room_id, user_id, event.Id, inReplyTo, inThread, mentionedUserIDs) - } - - // Notify all thread participants (best-effort). - // Newly auto-followed mention recipients should not also get the ambient - // followed-thread notification for the same message. Existing followers - // still receive it, matching the existing server badge count behavior. + // A delivered direct mention follows its thread unless the recipient has + // explicitly opted out. This subscription side effect remains best-effort + // and is distinct from occurrence materialization. if inThread != "" { - skipIDs := append([]string(nil), newlyAutoFollowedMentionedUserIDs...) - if replyNotifiedUserID != "" { - skipIDs = append(skipIDs, replyNotifiedUserID) - } - c.notifyThreadFollowers(ctx, kind, room_id, user_id, event.Id, inThread, skipIDs) - } - - // Notify DM participants for every new message (best-effort) - if kind == KindDM { - c.notifyDMParticipants(ctx, room_id, user_id, event.Id) - } - - // Notify room members who have ALL_MESSAGES notification level (root messages only). - // Build a set of already-notified users to avoid duplicate notifications. - if inThread == "" { - alreadyNotified := make(map[string]bool) - alreadyNotified[user_id] = true // Author - for _, uid := range mentionedUserIDs { - alreadyNotified[uid] = true - } - // Include in-reply-to author to avoid duplicate notification - if replyNotifiedUserID != "" { - alreadyNotified[replyNotifiedUserID] = true - } - // Include DM participants to avoid duplicate notifications - // (they were already notified by notifyDMParticipants above) - if kind == KindDM { - if participants, err := c.GetRoomMembersList(ctx, KindDM, room_id); err == nil { - for _, participant := range participants { - alreadyNotified[participant.UserId] = true - } + for _, mentionedUserID := range directMentionRecipients(notificationDecisions) { + if _, err := c.FollowThreadIfNeverSet(ctx, kind, mentionedUserID, room_id, inThread, corev1.ThreadFollowSource_THREAD_FOLLOW_SOURCE_DIRECT_MENTION); err != nil { + c.logger.Warn("Failed to auto-follow thread for directly mentioned user", + "mentioned_user_id", mentionedUserID, + "room_id", room_id, + "thread_root_event_id", inThread, + "error", err) } } - c.notifyAllMessageSubscribers(ctx, kind, room_id, user_id, event.Id, alreadyNotified) + } + if err := c.notificationMaterializer.WaitThrough(ctx, sequenceID); err != nil { + c.logger.Warn("Notification materialization did not reach the committed message before the request completed", + "room_id", room_id, "event_id", event.Id, "error", err) } // Publish echo event to the message subject if "also send to channel" was requested. @@ -1185,14 +1197,7 @@ func (c *ChattoCore) PostMessage(ctx context.Context, kind RoomKind, room_id, us if err != nil { c.logger.Warn("Failed to publish thread reply echo", "error", err, "thread_reply_event_id", event.Id) } else if created { - // Notify room members with ALL_MESSAGES notification level (best-effort). - // Build already-notified set: author + mentioned users (already notified above for original reply). - echoAlreadyNotified := make(map[string]bool) - echoAlreadyNotified[user_id] = true - for _, uid := range mentionedUserIDs { - echoAlreadyNotified[uid] = true - } - c.notifyAllMessageSubscribers(ctx, kind, room_id, user_id, echoID, echoAlreadyNotified) + c.logger.Debug("Created channel echo for thread reply", "echo_event_id", echoID, "thread_reply_event_id", event.Id) } } @@ -1214,58 +1219,6 @@ func validateMessageAttachmentAssetIDs(assetIDs []string) error { return nil } -// notifyAllMessageSubscribers creates notifications for room members who have the -// ALL_MESSAGES notification level. Only called for root messages (not thread replies). -// Skips users who were already notified (mentions, thread replies, DM notifications). -// This is best-effort - failures are logged but don't affect message posting. -func (c *ChattoCore) notifyAllMessageSubscribers(ctx context.Context, kind RoomKind, roomID, authorID, eventID string, alreadyNotified map[string]bool) { - members, err := c.GetRoomMembersList(ctx, kind, roomID) - if err != nil { - c.logger.Warn("Failed to get room members for all-message notifications", - "kind", kind, "room_id", roomID, "error", err) - return - } - - notifiedCount := 0 - for _, member := range members { - memberID := member.UserId - if alreadyNotified[memberID] { - continue - } - - level, err := c.GetEffectiveNotificationLevel(ctx, memberID, roomID) - if err != nil { - c.logger.Warn("Failed to get notification level for all-message check", - "user_id", memberID, "error", err) - continue - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES { - continue - } - - created, err := c.CreateNotification(ctx, memberID, authorID, &corev1.Notification{ - Notification: &corev1.Notification_RoomMessage{ - RoomMessage: &corev1.RoomMessageNotification{ - RoomId: roomID, - EventId: eventID, - }, - }, - }) - if err != nil { - c.logger.Warn("Failed to create all-message notification", - "recipient_id", memberID, "author_id", authorID, - "kind", kind, "room_id", roomID, "error", err) - } else if created != nil { - notifiedCount++ - } - } - - if notifiedCount > 0 { - c.logger.Debug("Created all-message notifications", - "kind", kind, "room_id", roomID, "count", notifiedCount) - } -} - type messageMutationAuthorization struct { authorOnly bool enforceEditWindow bool @@ -1481,7 +1434,6 @@ func (c *ChattoCore) EditMessage(ctx context.Context, actorID string, kind RoomK channelEchoCreationTargetID := "" channelEchoRetractionTargetID := "" channelEchoExistedBefore := false - var channelEchoPost *corev1.MessagePostedEvent if options.channelEcho != nil { echoTargetEvent := originalEntry.Event echoTargetPost := origPost @@ -1505,7 +1457,6 @@ func (c *ChattoCore) EditMessage(ctx context.Context, actorID string, kind RoomK if time.Since(echoTargetEvent.GetCreatedAt().AsTime()) > MessageEditWindow { return ErrEditWindowExpired } - channelEchoPost = echoTargetPost _, channelEchoExistedBefore = c.roomModel.channelEchoEventID(echoTargetEvent.GetId()) if *options.channelEcho { channelEchoCreationTargetID = echoTargetEvent.GetId() @@ -1581,16 +1532,8 @@ func (c *ChattoCore) EditMessage(ctx context.Context, actorID string, kind RoomK } c.logger.Debug("Message edited", "kind", kind, "room_id", roomID, "event_id", eventID, "actor_id", actorID) - if options.channelEcho != nil { - if *options.channelEcho && !channelEchoExistedBefore { - if createdChannelEchoID != "" { - alreadyNotified := map[string]bool{actorID: true} - for _, uid := range channelEchoPost.GetMentionedUserIds() { - alreadyNotified[uid] = true - } - c.notifyAllMessageSubscribers(ctx, kind, roomID, actorID, createdChannelEchoID, alreadyNotified) - } - } + if options.channelEcho != nil && *options.channelEcho && !channelEchoExistedBefore && createdChannelEchoID != "" { + c.logger.Debug("Created channel echo while editing thread reply", "echo_event_id", createdChannelEchoID, "thread_reply_event_id", eventID) } return nil } @@ -1643,6 +1586,10 @@ func (c *ChattoCore) publishMessageRetract( if err := c.roomModel.waitForTimeline(ctx, events.SubjectPosition(entries[lastIndex].Subject, seqs[lastIndex])); err != nil { return err } + if err := c.notificationMaterializer.WaitThrough(ctx, seqs[lastIndex]); err != nil { + c.logger.Warn("Notification cleanup did not reach the message retraction before the request completed", + "room_id", roomID, "event_id", eventID, "error", err) + } return nil } if !errors.Is(err, events.ErrConflict) { diff --git a/cli/internal/core/nats_recovery.go b/cli/internal/core/nats_recovery.go index b16f961d4..fb3ceabee 100644 --- a/cli/internal/core/nats_recovery.go +++ b/cli/internal/core/nats_recovery.go @@ -195,6 +195,9 @@ func (c *ChattoCore) verifyNATSRecovery(ctx context.Context) error { if err := c.readStateModel.Resync(ctx); err != nil { return fmt.Errorf("read state recovery: %w", err) } + if err := c.notificationOccurrences.Resync(ctx); err != nil { + return fmt.Errorf("notification occurrence recovery: %w", err) + } if err := c.presenceModel.Resync(ctx); err != nil { return fmt.Errorf("presence recovery: %w", err) } diff --git a/cli/internal/core/notification_common.go b/cli/internal/core/notification_common.go new file mode 100644 index 000000000..195f03587 --- /dev/null +++ b/cli/internal/core/notification_common.go @@ -0,0 +1,18 @@ +package core + +import ( + "context" + "time" +) + +const notificationTTL = 90 * 24 * time.Hour + +func (c *ChattoCore) suppressesNotificationAlertsForPresence(ctx context.Context, userID string) bool { + status, err := c.GetUserPresence(ctx, userID) + if err != nil { + c.logger.Warn("Failed to get presence for notification suppression", + "user_id", userID, "error", err) + return false + } + return status == PresenceStatusDoNotDisturb +} diff --git a/cli/internal/core/notification_decisions.go b/cli/internal/core/notification_decisions.go new file mode 100644 index 000000000..d76229bb1 --- /dev/null +++ b/cli/internal/core/notification_decisions.go @@ -0,0 +1,225 @@ +package core + +import ( + "context" + "sort" + + "google.golang.org/protobuf/proto" + + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +// notificationRecipientDecision is the source-time policy result used to +// prepare an exact occurrence. It is deliberately process-local: the prepared +// NotificationOccurrence, not another domain event, is the temporary durable +// work record. +type notificationRecipientDecision struct { + recipientID string + reasons []*corev1.NotificationReasonMatch +} + +// buildMessageNotificationDecisions evaluates every matching cause once and +// returns one deterministic decision per recipient for the source message. +func (c *ChattoCore) buildMessageNotificationDecisions( + ctx context.Context, + kind RoomKind, + roomID, authorID, inThread, inReplyTo string, + mentions *RoomMentionResolution, +) ([]notificationRecipientDecision, error) { + reasonsByRecipient := make(map[string]map[corev1.NotificationReason]struct{}) + add := func(userID string, reason corev1.NotificationReason) { + if userID == "" || userID == authorID { + return + } + if reasonsByRecipient[userID] == nil { + reasonsByRecipient[userID] = make(map[corev1.NotificationReason]struct{}) + } + reasonsByRecipient[userID][reason] = struct{}{} + } + + if mentions != nil { + for userID, reasons := range mentions.ReasonsByUser { + for _, reason := range reasons { + add(userID, reason) + } + } + } + + if kind == KindDM { + members, err := c.GetRoomMembersList(ctx, kind, roomID) + if err != nil { + return nil, err + } + for _, member := range members { + if member != nil { + add(member.GetUserId(), corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE) + } + } + } else if inThread == "" { + // Joining a channel establishes its ambient room subscription. The + // product default is Off, so this creates attention only when the user + // explicitly raises FOLLOWED_ROOM at server or room scope. + members, err := c.GetRoomMembersList(ctx, kind, roomID) + if err != nil { + return nil, err + } + for _, member := range members { + if member != nil { + add(member.GetUserId(), corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM) + } + } + } + + if inReplyTo != "" { + original, err := c.GetRoomEventByEventID(ctx, kind, roomID, inReplyTo) + if err != nil { + return nil, err + } + if original != nil { + add(original.GetActorId(), corev1.NotificationReason_NOTIFICATION_REASON_REPLY) + } + } + + if inThread != "" { + followers, err := c.GetThreadFollowers(ctx, kind, roomID, inThread) + if err != nil { + return nil, err + } + for _, followerID := range followers { + add(followerID, corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD) + } + + // The root author is automatically subscribed by the first reply. That + // follow event is appended after the message, so include the cause while + // evaluating the source instead of depending on later state. + metadata, err := c.GetThreadMetadata(ctx, kind, roomID, inThread) + if err != nil { + return nil, err + } + if metadata.ReplyCount == 0 { + root, err := c.GetRoomEventByEventID(ctx, kind, roomID, inThread) + if err != nil { + return nil, err + } + if root != nil && c.roomModel.threadFollowState(root.GetActorId(), roomID, inThread) == ThreadFollowStateNone { + add(root.GetActorId(), corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD) + } + } + } + + recipientIDs := make([]string, 0, len(reasonsByRecipient)) + for userID := range reasonsByRecipient { + recipientIDs = append(recipientIDs, userID) + } + sort.Strings(recipientIDs) + + decisions := make([]notificationRecipientDecision, 0, len(recipientIDs)) + for _, userID := range recipientIDs { + reasons := make([]corev1.NotificationReason, 0, len(reasonsByRecipient[userID])) + for reason := range reasonsByRecipient[userID] { + reasons = append(reasons, reason) + } + sort.Slice(reasons, func(i, j int) bool { return reasons[i] < reasons[j] }) + matches := make([]*corev1.NotificationReasonMatch, 0, len(reasons)) + strongest := corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED + for _, reason := range reasons { + intensity := c.GetEffectiveNotificationIntensity(userID, roomID, reason) + matches = append(matches, &corev1.NotificationReasonMatch{Reason: reason, Intensity: intensity}) + if intensity > strongest { + strongest = intensity + } + } + if strongest <= corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF { + continue + } + decisions = append(decisions, notificationRecipientDecision{recipientID: userID, reasons: matches}) + } + return decisions, nil +} + +func directMentionRecipients(decisions []notificationRecipientDecision) []string { + result := make([]string, 0) + for _, decision := range decisions { + for _, match := range decision.reasons { + if match.GetReason() == corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION && + match.GetIntensity() > corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF { + result = append(result, decision.recipientID) + break + } + } + } + return result +} + +// newNotificationOccurrenceWork prepares exact occurrence values for temporary +// RUNTIME_STATE work keys. Source-time policy is therefore never re-evaluated +// by the asynchronous materializer. +func newNotificationOccurrenceWork( + source *corev1.Event, + target *corev1.NotificationTarget, + decisions []notificationRecipientDecision, +) []*corev1.NotificationOccurrence { + if source == nil || source.GetCreatedAt() == nil || target == nil || len(decisions) == 0 { + return nil + } + work := make([]*corev1.NotificationOccurrence, 0, len(decisions)) + for _, decision := range decisions { + work = append(work, &corev1.NotificationOccurrence{ + RecipientId: decision.recipientID, + SourceEventId: source.GetId(), + SourceCreatedAt: source.GetCreatedAt(), + ActorId: source.GetActorId(), + Target: proto.Clone(target).(*corev1.NotificationTarget), + Reasons: cloneNotificationReasons(decision.reasons), + EvaluatedAt: source.GetCreatedAt(), + }) + } + return work +} + +func (c *ChattoCore) buildReactionNotificationWork(source, target *corev1.Event, roomID, messageEventID string) []*corev1.NotificationOccurrence { + if source == nil || target == nil || target.GetActorId() == "" || target.GetActorId() == source.GetActorId() { + return nil + } + intensity := c.GetEffectiveNotificationIntensity(target.GetActorId(), roomID, corev1.NotificationReason_NOTIFICATION_REASON_REACTION) + if intensity <= corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF { + return nil + } + notificationTarget := &corev1.NotificationTarget{RoomId: roomID, EventId: messageEventID} + if threadRootEventID := target.GetMessagePosted().GetInThread(); threadRootEventID != "" { + notificationTarget.ThreadRootEventId = &threadRootEventID + } + work := newNotificationOccurrenceWork(source, notificationTarget, []notificationRecipientDecision{{ + recipientID: target.GetActorId(), + reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_REACTION, + Intensity: intensity, + }}, + }}) + for _, occurrence := range work { + occurrence.ReactionEmoji = source.GetReactionAdded().GetEmoji() + } + return work +} + +func newNotificationRevocationWork(trigger *corev1.Event, recipientID, sourceEventID string, reason corev1.NotificationRemovalReason) []*corev1.NotificationOccurrence { + if trigger == nil || recipientID == "" || sourceEventID == "" { + return nil + } + return []*corev1.NotificationOccurrence{{ + RecipientId: recipientID, + SourceEventId: sourceEventID, + SourceCreatedAt: trigger.GetCreatedAt(), + RemovalReason: reason, + }} +} + +func cloneNotificationReasons(reasons []*corev1.NotificationReasonMatch) []*corev1.NotificationReasonMatch { + cloned := make([]*corev1.NotificationReasonMatch, 0, len(reasons)) + for _, reason := range reasons { + if reason != nil { + cloned = append(cloned, proto.Clone(reason).(*corev1.NotificationReasonMatch)) + } + } + return cloned +} diff --git a/cli/internal/core/notification_level.go b/cli/internal/core/notification_level.go deleted file mode 100644 index efc25298b..000000000 --- a/cli/internal/core/notification_level.go +++ /dev/null @@ -1,396 +0,0 @@ -package core - -import ( - "context" - "fmt" - "sort" - - "hmans.de/chatto/internal/core/subjects" - "hmans.de/chatto/internal/evtstream" - corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" -) - -// ============================================================================ -// Notification Level Operations -// -// Notification levels control how a user receives notifications for the server -// or for a specific room. They are stored as semantic user config events on the -// user's config aggregate. -// -// Inheritance: room-level → server-level → NORMAL (system default). -// -// Transitional layering note: the low-level ChattoCore helpers in this file -// predate the ConnectRPC API and mostly perform projected reads, config writes, -// effective-level resolution, and live-event publishing. New user-facing -// transports should enter through NotificationPreferencesModel instead, so -// operation authZ and response shaping do not drift across public transports. -// ============================================================================ - -// GetSpaceNotificationLevel returns the user's server-wide notification level. -// Returns NOTIFICATION_LEVEL_UNSPECIFIED if no preference is set. -// Authorization: Caller must verify access before calling this helper. -func (c *ChattoCore) GetSpaceNotificationLevel(_ context.Context, userID string) (corev1.NotificationLevel, error) { - if c.configModel == nil { - return corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED, nil - } - return c.configModel.notificationServerLevel(userID), nil -} - -// SetSpaceNotificationLevel sets the user's server-wide notification level. -// Pass NOTIFICATION_LEVEL_UNSPECIFIED to clear the override. -// Authorization: Caller must verify access before calling this helper. -func (c *ChattoCore) SetSpaceNotificationLevel(ctx context.Context, userID string, level corev1.NotificationLevel) error { - if c.configModel == nil { - return fmt.Errorf("config model not configured") - } - - changed := false - if err := c.configModel.updateSubject(ctx, userID, func(_ evtstream.Aggregate, _ string, _ uint64) ([]*corev1.Event, error) { - current := c.configModel.notificationServerLevel(userID) - if current == level || (current == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED && level == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED) { - changed = false - return nil, nil - } - changed = true - if level == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - return []*corev1.Event{newEvent(userID, &corev1.Event{Event: &corev1.Event_UserServerNotificationLevelCleared{ - UserServerNotificationLevelCleared: &corev1.UserServerNotificationLevelClearedEvent{UserId: userID}, - }})}, nil - } - return []*corev1.Event{newEvent(userID, &corev1.Event{Event: &corev1.Event_UserServerNotificationLevelSet{ - UserServerNotificationLevelSet: &corev1.UserServerNotificationLevelSetEvent{UserId: userID, Level: level}, - }})}, nil - }); err != nil { - return fmt.Errorf("failed to set server notification level: %w", err) - } - if !changed { - return nil - } - - c.logger.Info("Set server notification level", "user_id", userID, "level", level) - - effectiveLevel := level - if effectiveLevel == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - effectiveLevel = corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL - } - c.publishNotificationLevelChangedEvent(ctx, userID, "", level, effectiveLevel) - - return nil -} - -// GetRoomNotificationLevel returns the user's notification level for a room. -// Returns NOTIFICATION_LEVEL_UNSPECIFIED if no preference is set. -// Authorization: Caller must verify access before calling this helper. -func (c *ChattoCore) GetRoomNotificationLevel(_ context.Context, userID, roomID string) (corev1.NotificationLevel, error) { - if c.configModel == nil { - return corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED, nil - } - return c.configModel.notificationRoomLevel(userID, roomID), nil -} - -// SetRoomNotificationLevel sets the user's notification level for a room and -// publishes the live invalidation event. Pass NOTIFICATION_LEVEL_UNSPECIFIED to -// clear the override. -// -// This is intentionally a lower-level write helper. It does not verify room -// membership; callers that serve user requests should use -// NotificationPreferencesModel.SetRoomNotificationLevel instead. -func (c *ChattoCore) SetRoomNotificationLevel(ctx context.Context, userID, roomID string, level corev1.NotificationLevel) error { - if c.configModel == nil { - return fmt.Errorf("config model not configured") - } - - changed := false - if err := c.configModel.updateSubject(ctx, userID, func(_ evtstream.Aggregate, _ string, _ uint64) ([]*corev1.Event, error) { - current := c.configModel.notificationRoomLevel(userID, roomID) - if current == level || (current == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED && level == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED) { - changed = false - return nil, nil - } - changed = true - if level == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - return []*corev1.Event{newEvent(userID, &corev1.Event{Event: &corev1.Event_UserRoomNotificationLevelCleared{ - UserRoomNotificationLevelCleared: &corev1.UserRoomNotificationLevelClearedEvent{UserId: userID, RoomId: roomID}, - }})}, nil - } - return []*corev1.Event{newEvent(userID, &corev1.Event{Event: &corev1.Event_UserRoomNotificationLevelSet{ - UserRoomNotificationLevelSet: &corev1.UserRoomNotificationLevelSetEvent{UserId: userID, RoomId: roomID, Level: level}, - }})}, nil - }); err != nil { - return fmt.Errorf("failed to set room notification level: %w", err) - } - if !changed { - return nil - } - - c.logger.Info("Set room notification level", "room_id", roomID, "user_id", userID, "level", level) - - effectiveLevel, err := c.resolveEffectiveNotificationLevel(ctx, userID, level) - if err != nil { - c.logger.Warn("Failed to resolve effective notification level", "error", err) - effectiveLevel = level - } - c.publishNotificationLevelChangedEvent(ctx, userID, roomID, level, effectiveLevel) - - return nil -} - -// GetEffectiveNotificationLevel resolves the effective notification level for a -// user in a room. Resolution order: room-level → server-level → NORMAL. -// Authorization: Caller must verify access. -func (c *ChattoCore) GetEffectiveNotificationLevel(ctx context.Context, userID, roomID string) (corev1.NotificationLevel, error) { - roomLevel, err := c.GetRoomNotificationLevel(ctx, userID, roomID) - if err != nil { - return corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL, fmt.Errorf("failed to get room notification level: %w", err) - } - if roomLevel != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - return roomLevel, nil - } - - serverLevel, err := c.GetSpaceNotificationLevel(ctx, userID) - if err != nil { - return corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL, fmt.Errorf("failed to get server notification level: %w", err) - } - if serverLevel != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - return serverLevel, nil - } - - return corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL, nil -} - -// resolveEffectiveNotificationLevel resolves the effective notification level -// when the room-level value is known. -func (c *ChattoCore) resolveEffectiveNotificationLevel(ctx context.Context, userID string, roomLevel corev1.NotificationLevel) (corev1.NotificationLevel, error) { - if roomLevel != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - return roomLevel, nil - } - - serverLevel, err := c.GetSpaceNotificationLevel(ctx, userID) - if err != nil { - return corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL, err - } - if serverLevel != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - return serverLevel, nil - } - - return corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL, nil -} - -func (cm *ConfigModel) notificationServerLevel(userID string) corev1.NotificationLevel { - if cm == nil || cm.config.Projection() == nil { - return corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED - } - cm.config.Projection().RLock() - defer cm.config.Projection().RUnlock() - u := cm.config.Projection().users[userID] - if u == nil || u.serverLevel == nil { - return corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED - } - return *u.serverLevel -} - -func (cm *ConfigModel) notificationRoomLevel(userID, roomID string) corev1.NotificationLevel { - if cm == nil || cm.config.Projection() == nil { - return corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED - } - cm.config.Projection().RLock() - defer cm.config.Projection().RUnlock() - u := cm.config.Projection().users[userID] - if u == nil || u.roomLevelByRoom == nil { - return corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED - } - if level, ok := u.roomLevelByRoom[roomID]; ok { - return level - } - return corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED -} - -func (cm *ConfigModel) notificationRoomIDs(userID string) []string { - if cm == nil || cm.config.Projection() == nil { - return nil - } - cm.config.Projection().RLock() - defer cm.config.Projection().RUnlock() - u := cm.config.Projection().users[userID] - if u == nil || len(u.roomLevelByRoom) == 0 { - return nil - } - ids := make([]string, 0, len(u.roomLevelByRoom)) - for roomID := range u.roomLevelByRoom { - ids = append(ids, roomID) - } - sort.Strings(ids) - return ids -} - -// RoomNotificationPreference holds a resolved notification preference for a -// single room. -type RoomNotificationPreference struct { - SpaceID string - RoomID string - Level corev1.NotificationLevel - EffectiveLevel corev1.NotificationLevel -} - -// NotificationPreferences returns the operation-level model for notification -// preference reads and writes. Transports should use this model rather than -// calling the lower-level ChattoCore helpers directly so authorization and -// response semantics stay shared. NewChattoCore initializes this eagerly so -// concurrent request handlers do not race on first use. -func (c *ChattoCore) NotificationPreferences() *NotificationPreferencesModel { - return c.notificationPrefs -} - -// NotificationPreferencesModel owns user-facing notification preference -// operations. It is intentionally thin for now: the low-level config -// reads/writes already lived on ChattoCore, while membership authZ and response -// shaping now live here. This model centralizes that operation policy for -// ConnectRPC and future public transports. -type NotificationPreferencesModel struct { - core *ChattoCore -} - -// SetRoomNotificationLevel sets the authenticated actor's notification -// preference for a channel room and returns the resolved preference after the -// write. Authorization: actor must be a member of the room. -func (s *NotificationPreferencesModel) SetRoomNotificationLevel(ctx context.Context, actorID, roomID string, level corev1.NotificationLevel) (*RoomNotificationPreference, error) { - if err := s.requireAuthenticatedActor(actorID); err != nil { - return nil, err - } - if err := s.requireChannelRoomMember(ctx, actorID, roomID); err != nil { - return nil, err - } - if err := s.core.SetRoomNotificationLevel(ctx, actorID, roomID, level); err != nil { - return nil, err - } - return s.GetRoomNotificationPreference(ctx, actorID, roomID) -} - -// GetRoomNotificationPreference returns the authenticated actor's explicit and -// effective preference for a channel room. Authorization: actor must be a -// member of the room. -func (s *NotificationPreferencesModel) GetRoomNotificationPreference(ctx context.Context, actorID, roomID string) (*RoomNotificationPreference, error) { - if err := s.requireAuthenticatedActor(actorID); err != nil { - return nil, err - } - if err := s.requireChannelRoomMember(ctx, actorID, roomID); err != nil { - return nil, err - } - level, err := s.core.GetRoomNotificationLevel(ctx, actorID, roomID) - if err != nil { - return nil, err - } - effectiveLevel, err := s.core.GetEffectiveNotificationLevel(ctx, actorID, roomID) - if err != nil { - return nil, err - } - return &RoomNotificationPreference{ - RoomID: roomID, - Level: level, - EffectiveLevel: effectiveLevel, - }, nil -} - -func (s *NotificationPreferencesModel) requireAuthenticatedActor(actorID string) error { - if actorID == "" { - return ErrNotAuthenticated - } - return nil -} - -func (s *NotificationPreferencesModel) requireChannelRoomMember(ctx context.Context, actorID, roomID string) error { - isMember, err := s.core.RoomMembershipExists(ctx, KindChannel, actorID, roomID) - if err != nil { - return err - } - if !isMember { - return ErrPermissionDenied - } - return nil -} - -// GetAllRoomNotificationPreferences returns notification preferences for all -// rooms the user has joined. For each room, both the explicit level and the -// effective level are returned. -// -// Authorization: Caller must verify self-only access. -func (c *ChattoCore) GetAllRoomNotificationPreferences(ctx context.Context, userID string) ([]RoomNotificationPreference, error) { - memberships, err := c.GetAllUserRoomMemberships(ctx, userID) - if err != nil { - return nil, fmt.Errorf("failed to get room memberships: %w", err) - } - if len(memberships) == 0 { - return nil, nil - } - - serverLevel, err := c.GetSpaceNotificationLevel(ctx, userID) - if err != nil { - return nil, fmt.Errorf("failed to get server notification level: %w", err) - } - - result := make([]RoomNotificationPreference, 0, len(memberships)) - for _, m := range memberships { - roomLevel, err := c.GetRoomNotificationLevel(ctx, userID, m.RoomId) - if err != nil { - return nil, fmt.Errorf("failed to get room preference for room %s: %w", m.RoomId, err) - } - - effectiveLevel := roomLevel - if effectiveLevel == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - effectiveLevel = serverLevel - } - if effectiveLevel == corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - effectiveLevel = corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL - } - - result = append(result, RoomNotificationPreference{ - RoomID: m.RoomId, - Level: roomLevel, - EffectiveLevel: effectiveLevel, - }) - } - - return result, nil -} - -// deleteUserNotificationLevels removes all notification level preferences for a -// user. Called during account deletion. Best-effort. -func (c *ChattoCore) deleteUserNotificationLevels(ctx context.Context, userID string) error { - if c.configModel == nil { - return nil - } - return c.configModel.updateSubject(ctx, userID, func(_ evtstream.Aggregate, _ string, _ uint64) ([]*corev1.Event, error) { - var evs []*corev1.Event - if c.configModel.notificationServerLevel(userID) != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - evs = append(evs, newEvent(SystemActorID, &corev1.Event{Event: &corev1.Event_UserServerNotificationLevelCleared{ - UserServerNotificationLevelCleared: &corev1.UserServerNotificationLevelClearedEvent{UserId: userID}, - }})) - } - for _, roomID := range c.configModel.notificationRoomIDs(userID) { - evs = append(evs, newEvent(SystemActorID, &corev1.Event{Event: &corev1.Event_UserRoomNotificationLevelCleared{ - UserRoomNotificationLevelCleared: &corev1.UserRoomNotificationLevelClearedEvent{UserId: userID, RoomId: roomID}, - }})) - } - return evs, nil - }) -} - -// publishNotificationLevelChangedEvent publishes a live event when a -// notification level changes. User-scoped: only delivered to the user who -// changed their preference. -func (c *ChattoCore) publishNotificationLevelChangedEvent(ctx context.Context, userID, roomID string, level, effectiveLevel corev1.NotificationLevel) { - event := newLiveEvent(userID, &corev1.LiveEvent{ - Event: &corev1.LiveEvent_NotificationLevelChanged{ - NotificationLevelChanged: &corev1.NotificationLevelChangedEvent{ - RoomId: roomID, - Level: level, - EffectiveLevel: effectiveLevel, - }, - }, - }) - - subject := subjects.LiveSyncUserEvent(userID, "notification_level_changed") - if err := c.publishLiveEvent(ctx, subject, event); err != nil { - c.logger.Warn("Failed to publish notification level changed event", "error", err, "user_id", userID) - } -} diff --git a/cli/internal/core/notification_level_test.go b/cli/internal/core/notification_level_test.go deleted file mode 100644 index 24edcc5c6..000000000 --- a/cli/internal/core/notification_level_test.go +++ /dev/null @@ -1,502 +0,0 @@ -package core - -import ( - "errors" - "testing" - - "google.golang.org/protobuf/proto" - - corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" -) - -func TestPrefsProtoRoundTrip(t *testing.T) { - levels := []corev1.NotificationLevel{ - corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED, - corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, - corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL, - corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES, - } - - t.Run("UserPreferences", func(t *testing.T) { - for _, level := range levels { - t.Run(level.String(), func(t *testing.T) { - prefs := &corev1.UserPreferences{NotificationLevel: level} - data, err := proto.Marshal(prefs) - if err != nil { - t.Fatalf("Marshal failed: %v", err) - } - got := &corev1.UserPreferences{} - if err := proto.Unmarshal(data, got); err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - if got.NotificationLevel != level { - t.Errorf("Round trip failed: %v -> %v", level, got.NotificationLevel) - } - }) - } - }) - - t.Run("RoomUserPreferences", func(t *testing.T) { - for _, level := range levels { - t.Run(level.String(), func(t *testing.T) { - prefs := &corev1.RoomUserPreferences{NotificationLevel: level} - data, err := proto.Marshal(prefs) - if err != nil { - t.Fatalf("Marshal failed: %v", err) - } - got := &corev1.RoomUserPreferences{} - if err := proto.Unmarshal(data, got); err != nil { - t.Fatalf("Unmarshal failed: %v", err) - } - if got.NotificationLevel != level { - t.Errorf("Round trip failed: %v -> %v", level, got.NotificationLevel) - } - }) - } - }) -} - -// ============================================================================ -// Integration Tests: Space-Level -// ============================================================================ - -func TestChattoCore_GetSpaceNotificationLevel_NoPreference(t *testing.T) { - core, _ := setupTestCore(t) - ctx := testContext(t) - - // Create space (needed for config bucket) - - level, err := core.GetSpaceNotificationLevel(ctx, "test-user") - if err != nil { - t.Fatalf("GetSpaceNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - t.Errorf("Expected DEFAULT for no preference, got %v", level) - } -} - -func TestChattoCore_SetSpaceNotificationLevel(t *testing.T) { - core, _ := setupTestCore(t) - ctx := testContext(t) - - tests := []struct { - name string - level corev1.NotificationLevel - expected corev1.NotificationLevel - }{ - {"set muted", corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED}, - {"set normal", corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL, corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL}, - {"set all_messages", corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES, corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES}, - {"set default (clears)", corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED, corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := core.SetSpaceNotificationLevel(ctx, "test-user", tt.level) - if err != nil { - t.Fatalf("SetSpaceNotificationLevel failed: %v", err) - } - - got, err := core.GetSpaceNotificationLevel(ctx, "test-user") - if err != nil { - t.Fatalf("GetSpaceNotificationLevel failed: %v", err) - } - if got != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, got) - } - }) - } -} - -func TestChattoCore_SetSpaceNotificationLevel_DefaultDeletesKey(t *testing.T) { - core, _ := setupTestCore(t) - ctx := testContext(t) - - // Set to MUTED first - err := core.SetSpaceNotificationLevel(ctx, "test-user", corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED) - if err != nil { - t.Fatalf("SetSpaceNotificationLevel failed: %v", err) - } - - // Verify it's MUTED - level, err := core.GetSpaceNotificationLevel(ctx, "test-user") - if err != nil { - t.Fatalf("GetSpaceNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Fatalf("Expected MUTED, got %v", level) - } - - // Set to DEFAULT (should delete the key) - err = core.SetSpaceNotificationLevel(ctx, "test-user", corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED) - if err != nil { - t.Fatalf("SetSpaceNotificationLevel (DEFAULT) failed: %v", err) - } - - // Verify it returns DEFAULT (key was deleted) - level, err = core.GetSpaceNotificationLevel(ctx, "test-user") - if err != nil { - t.Fatalf("GetSpaceNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - t.Errorf("Expected DEFAULT after clearing, got %v", level) - } -} - -// ============================================================================ -// Integration Tests: Room-Level -// ============================================================================ - -func TestChattoCore_GetRoomNotificationLevel_NoPreference(t *testing.T) { - core, _ := setupTestCore(t) - ctx := testContext(t) - - level, err := core.GetRoomNotificationLevel(ctx, "test-user", "room123") - if err != nil { - t.Fatalf("GetRoomNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - t.Errorf("Expected DEFAULT for no preference, got %v", level) - } -} - -func TestChattoCore_SetRoomNotificationLevel(t *testing.T) { - core, _ := setupTestCore(t) - ctx := testContext(t) - - room, err := core.CreateRoom(ctx, "test-user", KindChannel, "", "General", "") - if err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - tests := []struct { - name string - level corev1.NotificationLevel - expected corev1.NotificationLevel - }{ - {"set muted", corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED}, - {"set all_messages", corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES, corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES}, - {"set default (clears)", corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED, corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := core.SetRoomNotificationLevel(ctx, "test-user", room.Id, tt.level) - if err != nil { - t.Fatalf("SetRoomNotificationLevel failed: %v", err) - } - - got, err := core.GetRoomNotificationLevel(ctx, "test-user", room.Id) - if err != nil { - t.Fatalf("GetRoomNotificationLevel failed: %v", err) - } - if got != tt.expected { - t.Errorf("Expected %v, got %v", tt.expected, got) - } - }) - } -} - -func TestNotificationPreferencesModel_SetRoomNotificationLevelAuthorization(t *testing.T) { - core, _ := setupTestCore(t) - ctx := testContext(t) - - member, err := core.CreateUser(ctx, SystemActorID, "notification-member", "Notification Member", "password") - if err != nil { - t.Fatalf("CreateUser(member): %v", err) - } - other, err := core.CreateUser(ctx, SystemActorID, "notification-other", "Notification Other", "password") - if err != nil { - t.Fatalf("CreateUser(other): %v", err) - } - room, err := core.CreateRoom(ctx, member.Id, KindChannel, "", "notification-service-room", "") - if err != nil { - t.Fatalf("CreateRoom: %v", err) - } - if _, err := core.JoinRoom(ctx, member.Id, KindChannel, member.Id, room.Id); err != nil { - t.Fatalf("JoinRoom(member): %v", err) - } - - service := core.NotificationPreferences() - - if _, err := service.SetRoomNotificationLevel(ctx, "", room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED); !errors.Is(err, ErrNotAuthenticated) { - t.Fatalf("SetRoomNotificationLevel unauthenticated err = %v, want ErrNotAuthenticated", err) - } - - if _, err := service.SetRoomNotificationLevel(ctx, other.Id, room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED); !errors.Is(err, ErrPermissionDenied) { - t.Fatalf("SetRoomNotificationLevel non-member err = %v, want ErrPermissionDenied", err) - } - - pref, err := service.SetRoomNotificationLevel(ctx, member.Id, room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED) - if err != nil { - t.Fatalf("SetRoomNotificationLevel(member): %v", err) - } - if pref.RoomID != room.Id { - t.Fatalf("RoomID = %q, want %q", pref.RoomID, room.Id) - } - if pref.Level != corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Fatalf("Level = %v, want muted", pref.Level) - } - if pref.EffectiveLevel != corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Fatalf("EffectiveLevel = %v, want muted", pref.EffectiveLevel) - } -} - -// ============================================================================ -// Integration Tests: Effective Level (Inheritance) -// ============================================================================ - -func TestChattoCore_GetEffectiveNotificationLevel_Inheritance(t *testing.T) { - core, _ := setupTestCore(t) - ctx := testContext(t) - - room, err := core.CreateRoom(ctx, "test-user", KindChannel, "", "General", "") - if err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - // No preferences set: should return NORMAL (system default) - t.Run("no preferences returns NORMAL", func(t *testing.T) { - level, err := core.GetEffectiveNotificationLevel(ctx, "test-user", room.Id) - if err != nil { - t.Fatalf("GetEffectiveNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL { - t.Errorf("Expected NORMAL, got %v", level) - } - }) - - // Set space-level to MUTED: room should inherit - t.Run("room inherits from space", func(t *testing.T) { - err := core.SetSpaceNotificationLevel(ctx, "test-user", corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED) - if err != nil { - t.Fatalf("SetSpaceNotificationLevel failed: %v", err) - } - - level, err := core.GetEffectiveNotificationLevel(ctx, "test-user", room.Id) - if err != nil { - t.Fatalf("GetEffectiveNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Errorf("Expected MUTED (inherited from space), got %v", level) - } - }) - - // Set room-level to ALL_MESSAGES: should override space-level - t.Run("room overrides space", func(t *testing.T) { - err := core.SetRoomNotificationLevel(ctx, "test-user", room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES) - if err != nil { - t.Fatalf("SetRoomNotificationLevel failed: %v", err) - } - - level, err := core.GetEffectiveNotificationLevel(ctx, "test-user", room.Id) - if err != nil { - t.Fatalf("GetEffectiveNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES { - t.Errorf("Expected ALL_MESSAGES (room override), got %v", level) - } - }) - - // Clear room-level: should fall back to space-level (MUTED) - t.Run("room cleared falls back to space", func(t *testing.T) { - err := core.SetRoomNotificationLevel(ctx, "test-user", room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED) - if err != nil { - t.Fatalf("SetRoomNotificationLevel failed: %v", err) - } - - level, err := core.GetEffectiveNotificationLevel(ctx, "test-user", room.Id) - if err != nil { - t.Fatalf("GetEffectiveNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Errorf("Expected MUTED (from space after clearing room), got %v", level) - } - }) - - // Clear space-level: should fall back to NORMAL - t.Run("all cleared falls back to NORMAL", func(t *testing.T) { - err := core.SetSpaceNotificationLevel(ctx, "test-user", corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED) - if err != nil { - t.Fatalf("SetSpaceNotificationLevel failed: %v", err) - } - - level, err := core.GetEffectiveNotificationLevel(ctx, "test-user", room.Id) - if err != nil { - t.Fatalf("GetEffectiveNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL { - t.Errorf("Expected NORMAL (system default), got %v", level) - } - }) -} - -// ============================================================================ -// Integration Tests: User Isolation -// ============================================================================ - -func TestChattoCore_NotificationLevel_UserIsolation(t *testing.T) { - core, _ := setupTestCore(t) - ctx := testContext(t) - - // Set userA's space level to MUTED - err := core.SetSpaceNotificationLevel(ctx, "userA", corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED) - if err != nil { - t.Fatalf("SetSpaceNotificationLevel failed: %v", err) - } - - // userB's space level should still be DEFAULT - level, err := core.GetSpaceNotificationLevel(ctx, "userB") - if err != nil { - t.Fatalf("GetSpaceNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - t.Errorf("Expected DEFAULT for userB (isolated from userA), got %v", level) - } -} - -// ============================================================================ -// Integration Tests: Cleanup -// ============================================================================ - -func TestChattoCore_DeleteUserNotificationLevels(t *testing.T) { - core, _ := setupTestCore(t) - ctx := testContext(t) - - room1, err := core.CreateRoom(ctx, "test-user", KindChannel, "", "room-1", "") - if err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - room2, err := core.CreateRoom(ctx, "test-user", KindChannel, "", "room-2", "") - if err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - // Set space-level and room-level preferences - err = core.SetSpaceNotificationLevel(ctx, "test-user", corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED) - if err != nil { - t.Fatalf("SetSpaceNotificationLevel failed: %v", err) - } - err = core.SetRoomNotificationLevel(ctx, "test-user", room1.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES) - if err != nil { - t.Fatalf("SetRoomNotificationLevel failed: %v", err) - } - err = core.SetRoomNotificationLevel(ctx, "test-user", room2.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL) - if err != nil { - t.Fatalf("SetRoomNotificationLevel failed: %v", err) - } - - // Delete all notification levels - err = core.deleteUserNotificationLevels(ctx, "test-user") - if err != nil { - t.Fatalf("deleteUserNotificationLevels failed: %v", err) - } - - // Verify all levels are DEFAULT - level, err := core.GetSpaceNotificationLevel(ctx, "test-user") - if err != nil { - t.Fatalf("GetSpaceNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - t.Errorf("Expected DEFAULT for space after cleanup, got %v", level) - } - - level, err = core.GetRoomNotificationLevel(ctx, "test-user", room1.Id) - if err != nil { - t.Fatalf("GetRoomNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - t.Errorf("Expected DEFAULT for room1 after cleanup, got %v", level) - } - - level, err = core.GetRoomNotificationLevel(ctx, "test-user", room2.Id) - if err != nil { - t.Fatalf("GetRoomNotificationLevel failed: %v", err) - } - if level != corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED { - t.Errorf("Expected DEFAULT for room2 after cleanup, got %v", level) - } -} - -// ============================================================================ -// Integration Tests: HasUnread respects mute -// ============================================================================ - -func TestChattoCore_HasUnread_MutedRoom(t *testing.T) { - core, _ := setupTestCore(t) - ctx := testContext(t) - - // Create user, space, room, and join - user, err := core.CreateUser(ctx, "system", "muteduser", "Muted User", "password123") - if err != nil { - t.Fatalf("CreateUser failed: %v", err) - } - - room, err := core.CreateRoom(ctx, user.Id, KindChannel, "", "General", "") - if err != nil { - t.Fatalf("CreateRoom failed: %v", err) - } - - // Join the room (CreateRoom does NOT auto-join the creator) - _, err = core.JoinRoom(ctx, user.Id, KindChannel, user.Id, room.Id) - if err != nil { - t.Fatalf("JoinRoom failed: %v", err) - } - - // Create a second user who will post a message - poster, err := core.CreateUser(ctx, "system", "poster", "Poster User", "password123") - if err != nil { - t.Fatalf("CreateUser (poster) failed: %v", err) - } - - _, err = core.JoinRoom(ctx, poster.Id, KindChannel, poster.Id, room.Id) - if err != nil { - t.Fatalf("JoinRoom (poster) failed: %v", err) - } - - // Post a message from the poster (spaceID, roomID, userID, body, attachments, inThread, inReplyTo) - _, err = core.PostMessage(ctx, KindChannel, room.Id, poster.Id, "Hello!", nil, "", "", nil, false) - if err != nil { - t.Fatalf("PostMessage failed: %v", err) - } - - // Verify room has unread messages normally - hasUnread, err := core.HasUnread(ctx, KindChannel, user.Id, room.Id) - if err != nil { - t.Fatalf("HasUnread failed: %v", err) - } - if !hasUnread { - t.Error("Expected HasUnread=true before muting") - } - - // Mute the room - err = core.SetRoomNotificationLevel(ctx, user.Id, room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED) - if err != nil { - t.Fatalf("SetRoomNotificationLevel failed: %v", err) - } - - // HasUnread should now return false for muted room - hasUnread, err = core.HasUnread(ctx, KindChannel, user.Id, room.Id) - if err != nil { - t.Fatalf("HasUnread failed: %v", err) - } - if hasUnread { - t.Error("Expected HasUnread=false for muted room") - } - - // Unmute the room - err = core.SetRoomNotificationLevel(ctx, user.Id, room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED) - if err != nil { - t.Fatalf("SetRoomNotificationLevel failed: %v", err) - } - - // HasUnread should return true again - hasUnread, err = core.HasUnread(ctx, KindChannel, user.Id, room.Id) - if err != nil { - t.Fatalf("HasUnread failed: %v", err) - } - if !hasUnread { - t.Error("Expected HasUnread=true after unmuting") - } -} diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go new file mode 100644 index 000000000..bbee86e8a --- /dev/null +++ b/cli/internal/core/notification_materializer.go @@ -0,0 +1,957 @@ +package core + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "sync" + "time" + + "github.com/nats-io/nats.go/jetstream" + "google.golang.org/protobuf/proto" + + "hmans.de/chatto/internal/evtstream" + "hmans.de/chatto/internal/jetstreamutil" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" + "hmans.de/chatto/pkg/events" +) + +const ( + notificationMaterializerPollEvery = 250 * time.Millisecond + notificationWorkerConsumerName = "chatto-notification-materializer-v2" + notificationWorkerAckWait = time.Minute + notificationWorkerHeartbeat = 15 * time.Second + notificationWorkerRetryDelay = 10 * time.Second + notificationWorkerAckTimeout = 5 * time.Second + // Notification lifecycle is causal: one shared in-flight delivery keeps a + // later leave/retraction/removal behind the source it supersedes, including + // when several Chatto replicas share the consumer. + notificationWorkerMaxPending = 1 + notificationWorkKeyPrefix = "notification_work." + notificationReadFenceKey = "notification_v2.read_fence" + maxNotificationWorkWriteRetries = 8 +) + +// NotificationMaterializer consumes existing domain facts and applies their +// notification effects. Exact source-time recipient decisions are staged as +// short-lived RUNTIME_STATE work records before the source fact commits; EVT +// contains no notification-only planning events. +type NotificationMaterializer struct { + core *ChattoCore + visibility events.ProjectionHandle[*NotificationVisibilityProjection] + assignConfiguredOwnerRole func(context.Context, string) error + pollEvery time.Duration + waitCurrent func(context.Context) error + ready chan struct{} + consumer jetstream.Consumer + consumerInfoMu sync.Mutex +} + +func NewNotificationMaterializer(core *ChattoCore, visibility events.ProjectionHandle[*NotificationVisibilityProjection]) *NotificationMaterializer { + materializer := &NotificationMaterializer{ + core: core, + visibility: visibility, + assignConfiguredOwnerRole: func(ctx context.Context, userID string) error { + return core.AssignServerRoleToExistingUser(ctx, SystemActorID, userID, RoleOwner) + }, + pollEvery: notificationMaterializerPollEvery, + ready: make(chan struct{}), + } + materializer.waitCurrent = materializer.WaitCurrent + return materializer +} + +// Initialize creates the DeliverNew consumer before projectors start. Its +// acknowledged floor caps visibility snapshot restore, ensuring every pending +// administrative fact is replayed into an exact event-time boundary. +func (m *NotificationMaterializer) Initialize(ctx context.Context) error { + consumer, err := m.createConsumer(ctx) + if err != nil { + return err + } + // Capture the stream tail before reading consumer state. If the consumer is + // idle at the later read, every worker fact through this earlier tail is + // acknowledged; facts racing after the tail remain beyond the restore cap. + tail, err := m.eventStreamTail(ctx) + if err != nil { + return fmt.Errorf("read notification consumer initialization tail: %w", err) + } + info, err := consumer.Info(ctx) + if err != nil { + return fmt.Errorf("read notification consumer initialization floor: %w", err) + } + processed, err := m.initialNotificationAcknowledgedThrough(ctx, tail, info) + if err != nil { + return fmt.Errorf("reconstruct notification consumer initialization floor: %w", err) + } + m.visibility.Projection().SetAcknowledgedThrough(processed) + m.consumer = consumer + close(m.ready) + return nil +} + +func (m *NotificationMaterializer) Run(ctx context.Context) error { + if err := m.core.WaitForProjectionsCurrent(ctx); err != nil { + return fmt.Errorf("wait for projections before notification worker: %w", err) + } + if err := m.core.notificationOccurrences.WaitReady(ctx); err != nil { + return fmt.Errorf("wait for notification index before worker: %w", err) + } + + worker, err := events.NewDurableWorker( + m.consumer, + m.processDelivery, + events.DurableWorkerOptions{ + MaxConcurrent: notificationWorkerMaxPending, + FetchMaxWait: time.Second, + RetryDelay: notificationWorkerRetryDelay, + AckTimeout: notificationWorkerAckTimeout, + HeartbeatInterval: notificationWorkerHeartbeat, + Logger: m.core.logger.WithPrefix("NotificationWorker"), + }, + ) + if err != nil { + return fmt.Errorf("configure notification worker: %w", err) + } + + workerDone := make(chan error, 1) + go func() { workerDone <- worker.Run(ctx) }() + + ticker := time.NewTicker(m.pollEvery) + defer ticker.Stop() + for { + select { + case err := <-workerDone: + if err == nil && ctx.Err() == nil { + return errors.New("notification worker stopped unexpectedly") + } + return err + case <-ctx.Done(): + return <-workerDone + case <-ticker.C: + m.releaseAcknowledgedVisibilityBoundaries(ctx) + m.deliverPendingAlerts(ctx) + } + } +} + +func (m *NotificationMaterializer) releaseAcknowledgedVisibilityBoundaries(ctx context.Context) { + // Capture the tail before consumer state, matching initialization. If the + // later consumer read is idle, every worker fact through this earlier tail + // is confirmed; a fact racing after the tail remains beyond the safe floor. + tail, err := m.eventStreamTail(ctx) + if err != nil { + m.core.logger.Warn("Failed to read EVT tail for visibility cleanup", "error", err) + return + } + info, err := m.consumerInfo(ctx) + if err != nil { + m.core.logger.Warn("Failed to read notification worker floor for visibility cleanup", "error", err) + return + } + if err := m.visibility.Projection().ReleaseThrough(notificationAcknowledgedThrough(tail, info)); err != nil { + m.core.logger.Warn("Failed to compact acknowledged notification visibility boundaries", "error", err) + } +} + +// consumerInfo serializes nats.go's cached consumer metadata mutation. The +// materializer polls this from its lifecycle loop while request paths wait for +// read-your-writes, and the shared consumer handle is not safe for concurrent +// Info calls. +func (m *NotificationMaterializer) consumerInfo(ctx context.Context) (*jetstream.ConsumerInfo, error) { + m.consumerInfoMu.Lock() + defer m.consumerInfoMu.Unlock() + return m.consumer.Info(ctx) +} + +// eventStreamTail opens an isolated stream handle because nats.go mutates a +// handle's cached StreamInfo during Info while direct message reads inspect the +// same cache. The materializer polls concurrently with ordinary EVT reads and +// must not call Info through their shared handle. +func (m *NotificationMaterializer) eventStreamTail(ctx context.Context) (uint64, error) { + stream, err := m.core.js.Stream(ctx, "EVT") + if err != nil { + return 0, err + } + info := stream.CachedInfo() + if info == nil { + return 0, fmt.Errorf("EVT stream info is unavailable") + } + return info.State.LastSeq, nil +} + +// notificationAcknowledgedThrough returns a race-safe full-EVT floor for the +// filtered consumer. When the later consumer read is idle, no matching fact at +// or below the earlier tail can still be outstanding. Otherwise AckFloor is the +// only confirmed bound, including when the pending fact is not a visibility +// boundary itself. +func notificationAcknowledgedThrough(tail uint64, info *jetstream.ConsumerInfo) uint64 { + if info.NumPending == 0 && info.NumAckPending == 0 { + return tail + } + return info.AckFloor.Stream +} + +// initialNotificationAcknowledgedThrough reconstructs the full-EVT prefix +// immediately before the earliest fact that could still be pending for the +// filtered consumer. Unlike its sparse AckFloor, this bound remains derivable +// after restart without adding another persisted watermark. +func (m *NotificationMaterializer) initialNotificationAcknowledgedThrough(ctx context.Context, tail uint64, info *jetstream.ConsumerInfo) (uint64, error) { + if info.NumPending == 0 && info.NumAckPending == 0 { + return tail, nil + } + if info.AckFloor.Stream >= tail { + return tail, nil + } + firstPending := uint64(0) + for _, filter := range notificationWorkerFilterSubjects() { + message, err := m.core.storage.serverEvtStream.GetMsg(ctx, info.AckFloor.Stream+1, jetstream.WithGetMsgSubject(filter)) + if errors.Is(err, jetstream.ErrMsgNotFound) { + continue + } + if err != nil { + return 0, fmt.Errorf("read next notification fact for %q: %w", filter, err) + } + if firstPending == 0 || message.Sequence < firstPending { + firstPending = message.Sequence + } + } + if firstPending == 0 { + // EVT is append-only in normal operation, so this is defensive. The raw + // floor is conservative if consumer state and direct reads disagree. + return info.AckFloor.Stream, nil + } + if firstPending > tail { + return tail, nil + } + return firstPending - 1, nil +} + +// WaitReady waits until the durable consumer exists. Serving must not begin +// before this boundary: DeliverNew can recover only source facts committed +// after the consumer was created. +func (m *NotificationMaterializer) WaitReady(ctx context.Context) error { + select { + case <-m.ready: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// WaitThrough waits until the shared durable consumer has acknowledged the +// triggering EVT sequence. Request paths use this only for read-your-writes; +// the worker remains the sole owner of occurrence creation and cleanup. +func (m *NotificationMaterializer) WaitThrough(ctx context.Context, streamSequence uint64) error { + if m == nil || streamSequence == 0 { + return nil + } + if err := m.WaitReady(ctx); err != nil { + return err + } + ticker := time.NewTicker(5 * time.Millisecond) + defer ticker.Stop() + for { + info, err := m.consumerInfo(ctx) + if err != nil { + return fmt.Errorf("read notification consumer progress: %w", err) + } + if info.AckFloor.Stream >= streamSequence || + (info.Delivered.Consumer == 0 && info.NumAckPending == 0 && info.Delivered.Stream >= streamSequence) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +// WaitCurrent captures the latest EVT sequence relevant to the notification +// worker and waits until the durable consumer has acknowledged it. A fresh +// DeliverNew consumer reports its creation-time stream floor as delivered with +// consumer sequence zero; that boundary is intentionally considered current +// because Notifications 2.0 does not replay older facts. +func (m *NotificationMaterializer) WaitCurrent(ctx context.Context) error { + if m == nil { + return nil + } + if err := m.WaitReady(ctx); err != nil { + return err + } + var boundary uint64 + for _, filter := range notificationWorkerFilterSubjects() { + position, err := m.core.EventPublisher.LastSubjectPosition(ctx, filter) + if err != nil { + return fmt.Errorf("capture current notification worker boundary for %s: %w", filter, err) + } + if position.Seq > boundary { + boundary = position.Seq + } + } + if err := m.WaitThrough(ctx, boundary); err != nil { + return err + } + return m.fenceLocalOccurrenceIndex(ctx, boundary) +} + +func notificationWorkerFilterSubjects() []string { + return []string{ + evtstream.RoomEventTypeFilter(evtstream.EventMessagePosted), + evtstream.RoomEventTypeFilter(evtstream.EventReactionAdded), + evtstream.RoomEventTypeFilter(evtstream.EventReactionRemoved), + evtstream.RoomEventTypeFilter(evtstream.EventMessageRetracted), + evtstream.RoomEventTypeFilter(evtstream.EventUserLeftRoom), + evtstream.RoomEventTypeFilter(evtstream.EventRoomMemberRemoved), + evtstream.RoomEventTypeFilter(evtstream.EventRoomMemberBanned), + evtstream.RoomEventTypeFilter(evtstream.EventRoomUniversalChanged), + evtstream.RoomEventTypeFilter(evtstream.EventRoomDeleted), + evtstream.GroupEventTypeFilter(evtstream.EventRoomAddedToGroup), + evtstream.UserEventTypeFilter(evtstream.EventUserAccountDeleted), + evtstream.UserEventTypeFilter(evtstream.EventUserVerifiedEmailAdded), + evtstream.RBACEventTypeFilter(evtstream.EventRBACRoleDeleted), + evtstream.RBACEventTypeFilter(evtstream.EventRBACRoleAssigned), + evtstream.RBACEventTypeFilter(evtstream.EventRBACRoleRevoked), + evtstream.RBACEventTypeFilter(evtstream.EventRBACPermissionGranted), + evtstream.RBACEventTypeFilter(evtstream.EventRBACPermissionDenied), + evtstream.RBACEventTypeFilter(evtstream.EventRBACPermissionCleared), + } +} + +func (m *NotificationMaterializer) createConsumer(ctx context.Context) (jetstream.Consumer, error) { + consumer, err := m.core.storage.serverEvtStream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{ + Name: notificationWorkerConsumerName, + Durable: notificationWorkerConsumerName, + Description: "Shared durable queue for Chatto notification materialization", + // Prepared work exists only for events committed after Notifications + // 2.0 starts. Beginning at the consumer's creation boundary avoids + // replaying the server's entire message history on first rollout. + DeliverPolicy: jetstream.DeliverNewPolicy, + AckPolicy: jetstream.AckExplicitPolicy, + AckWait: notificationWorkerAckWait, + MaxDeliver: -1, + FilterSubjects: notificationWorkerFilterSubjects(), + ReplayPolicy: jetstream.ReplayInstantPolicy, + MaxAckPending: notificationWorkerMaxPending, + MaxRequestBatch: notificationWorkerMaxPending, + }) + if err != nil { + return nil, fmt.Errorf("create notification materializer consumer: %w", err) + } + return consumer, nil +} + +func (m *NotificationMaterializer) processDelivery(ctx context.Context, delivery events.DurableDelivery) error { + var event corev1.Event + if err := proto.Unmarshal(delivery.Data, &event); err != nil { + m.core.logger.Error("Terminating malformed notification delivery", "error", err) + return events.TerminateDelivery("invalid Chatto event envelope", err) + } + position := events.SubjectPosition(delivery.Subject, delivery.StreamSequence) + hasVisibilityBoundary := notificationVisibilityBoundaryEvent(&event) + if hasVisibilityBoundary { + if err := m.visibility.Projector().WaitFor(ctx, position); err != nil { + return fmt.Errorf("wait for notification visibility projection: %w", err) + } + } + switch event.GetEvent().(type) { + case *corev1.Event_UserAccountDeleted, *corev1.Event_UserVerifiedEmailAdded: + if err := m.core.userModel.waitForUsers(ctx, position); err != nil { + return fmt.Errorf("wait for user projection: %w", err) + } + case *corev1.Event_RbacRoleDeleted, + *corev1.Event_RbacRoleAssigned, + *corev1.Event_RbacRoleRevoked, + *corev1.Event_RbacPermissionGranted, + *corev1.Event_RbacPermissionDenied, + *corev1.Event_RbacPermissionCleared: + if err := m.core.rbacModel.waitFor(ctx, position); err != nil { + return fmt.Errorf("wait for RBAC projection: %w", err) + } + case *corev1.Event_RoomAddedToGroup: + if err := m.core.roomModel.waitForGroupLayout(ctx, position); err != nil { + return fmt.Errorf("wait for room group projection: %w", err) + } + default: + if err := m.core.roomModel.waitForLiveEVTEvent(ctx, position, &event); err != nil { + return fmt.Errorf("wait for room projections: %w", err) + } + } + if err := m.materializeEvent(ctx, &event, delivery.StreamSequence, true); err != nil { + return err + } + return nil +} + +// fenceLocalOccurrenceIndex appends a marker to the same KV stream as +// occurrences after the shared worker has acknowledged the captured EVT +// boundary. Observing its revision proves this replica's ordered watcher has +// applied every earlier occurrence mutation without adding a write to every +// worker delivery. +func (m *NotificationMaterializer) fenceLocalOccurrenceIndex(ctx context.Context, streamSequence uint64) error { + value := make([]byte, 8) + binary.BigEndian.PutUint64(value, streamSequence) + revision, err := m.core.storage.runtimeStateKV.Put(ctx, notificationReadFenceKey, value) + if err != nil { + return fmt.Errorf("write notification read fence: %w", err) + } + if err := m.core.notificationOccurrences.index.waitForObservedRevision(ctx, revision); err != nil { + return fmt.Errorf("wait for local notification index through read fence: %w", err) + } + return nil +} + +// StoreWork writes exact prepared occurrences before their triggering domain +// event is appended. Orphans from a failed append expire at the same absolute +// 90-day boundary as the occurrence they would have created. +func (m *NotificationMaterializer) StoreWork(ctx context.Context, trigger *corev1.Event, work []*corev1.NotificationOccurrence) error { + if m == nil { + if len(work) == 0 { + return nil + } + return errors.New("notification materializer is not configured") + } + if trigger == nil || trigger.GetId() == "" || trigger.GetCreatedAt() == nil { + return nil + } + ttl := trigger.GetCreatedAt().AsTime().UTC().Add(notificationTTL).Sub(time.Now().UTC()) + if ttl <= 0 { + return nil + } + desired := make(map[string][]byte, len(work)) + for _, occurrence := range work { + if occurrence == nil || occurrence.GetRecipientId() == "" || occurrence.GetSourceEventId() == "" { + return invalidArgument("notification work requires recipient_id and source_event_id") + } + data, err := proto.Marshal(occurrence) + if err != nil { + return fmt.Errorf("marshal notification work: %w", err) + } + desired[notificationWorkKey(trigger.GetId(), occurrence.GetRecipientId())] = data + } + + // StoreWork can run more than once while an OCC mutation retries. A marker + // means an earlier attempt prepared a complete set, so reconcile it exactly; + // the overwhelmingly common first no-work decision remains one direct Get. + existingKeys := make([]string, 0) + markerKey := notificationWorkMarkerKey(trigger.GetId()) + _, markerErr := m.core.storage.runtimeStateKV.Get(ctx, markerKey) + markerExists := markerErr == nil + if markerErr != nil && !errors.Is(markerErr, jetstream.ErrKeyNotFound) && !errors.Is(markerErr, jetstream.ErrKeyDeleted) { + return fmt.Errorf("read existing notification work marker: %w", markerErr) + } + if !markerExists && len(desired) == 0 { + return nil + } + if markerExists { + lister, err := m.core.storage.runtimeStateKV.ListKeysFiltered(ctx, notificationWorkFilter(trigger.GetId())) + if err != nil && !errors.Is(err, jetstream.ErrNoKeysFound) { + return fmt.Errorf("list existing notification work: %w", err) + } + if err == nil { + for key := range lister.Keys() { + existingKeys = append(existingKeys, key) + } + } + } + + if len(desired) == 0 { + if err := m.deleteRuntimeStateKey(ctx, markerKey); err != nil { + return err + } + } + for key, data := range desired { + if err := m.putWorkWithTTL(ctx, key, data, ttl); err != nil { + return err + } + } + for _, key := range existingKeys { + if _, keep := desired[key]; keep { + continue + } + if err := m.deleteRuntimeStateKey(ctx, key); err != nil { + return err + } + } + if len(desired) == 0 { + return nil + } + // The marker turns the overwhelmingly common no-work delivery into one + // direct KV lookup. Recipient keys remain separate so one failed recipient + // can be retried without rebuilding decisions for the others. + return m.putWorkWithTTL(ctx, markerKey, nil, ttl) +} + +func (m *NotificationMaterializer) deleteRuntimeStateKey(ctx context.Context, key string) error { + for attempt := 0; attempt < maxNotificationWorkWriteRetries; attempt++ { + entry, err := m.core.storage.runtimeStateKV.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + return nil + } + if err != nil { + return fmt.Errorf("read runtime-state key for deletion: %w", err) + } + err = m.core.storage.runtimeStateKV.Delete(ctx, key, jetstream.LastRevision(entry.Revision())) + if err == nil || errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + return nil + } + if !jetstreamutil.IsSequenceConflict(err) { + return fmt.Errorf("delete runtime-state key: %w", err) + } + } + return fmt.Errorf("delete runtime-state key after %d attempts", maxNotificationWorkWriteRetries) +} + +func (m *NotificationMaterializer) putWorkWithTTL(ctx context.Context, key string, data []byte, ttl time.Duration) error { + for attempt := 0; attempt < maxNotificationWorkWriteRetries; attempt++ { + entry, err := m.core.storage.runtimeStateKV.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + if _, err := m.core.storage.runtimeStateKV.Create(ctx, key, data, jetstream.KeyTTL(ttl)); err == nil { + return nil + } else if !jetstreamutil.IsSequenceConflict(err) { + return fmt.Errorf("create notification work: %w", err) + } + continue + } + if err != nil { + return fmt.Errorf("read notification work: %w", err) + } + _, err = m.core.js.Publish( + ctx, + "$KV.RUNTIME_STATE."+key, + data, + jetstream.WithExpectLastSequencePerSubject(entry.Revision()), + jetstream.WithMsgTTL(ttl), + ) + if err == nil { + return nil + } + if !jetstreamutil.IsSequenceConflict(err) { + return fmt.Errorf("update notification work: %w", err) + } + } + return fmt.Errorf("write notification work after %d attempts", maxNotificationWorkWriteRetries) +} + +func notificationWorkKey(triggerEventID, recipientID string) string { + return notificationWorkKeyPrefix + triggerEventID + "." + recipientID +} + +func notificationWorkMarkerKey(triggerEventID string) string { + return notificationWorkKeyPrefix + triggerEventID +} + +func notificationWorkFilter(triggerEventID string) string { + return notificationWorkKeyPrefix + triggerEventID + ".*" +} + +func (m *NotificationMaterializer) deliverPendingAlerts(ctx context.Context) { + if m.core.OnNotificationOccurrenceCreated == nil { + return + } + for { + hasCandidate, err := m.core.notificationOccurrences.HasClaimableAlert(ctx) + if err != nil { + m.core.logger.Warn("Failed to inspect pending notification alerts", "error", err) + return + } + if !hasCandidate { + return + } + // A visibility-loss fact may be committed while the occurrence index + // still contains an older Alert. The sole durable writer must process + // every relevant fact through this boundary before an OCC claim can make + // that occurrence externally visible. + if err := m.waitCurrent(ctx); err != nil { + m.core.logger.Warn("Failed to fence pending notification alert", "error", err) + return + } + occurrence, claimed, err := m.core.notificationOccurrences.ClaimPendingAlert(ctx) + if err != nil { + m.core.logger.Warn("Failed to claim notification alert", "error", err) + return + } + if !claimed { + return + } + deliveryErr := m.core.OnNotificationOccurrenceCreated(context.WithoutCancel(ctx), occurrence) + if err := m.core.notificationOccurrences.CompleteAlertClaim(ctx, occurrence, deliveryErr == nil); err != nil { + m.core.logger.Warn("Failed to complete notification alert claim", "notification_id", occurrence.GetId(), "error", err) + return + } + if deliveryErr != nil { + m.core.logger.Warn("Notification alert delivery failed", "notification_id", occurrence.GetId(), "error", deliveryErr) + return + } + } +} + +func (m *NotificationMaterializer) materializeEvent(ctx context.Context, event *corev1.Event, streamSequence uint64, durableDelivery bool) error { + if event == nil { + return nil + } + visibilityAt := time.Now().UTC() + if event.GetCreatedAt() != nil { + visibilityAt = event.GetCreatedAt().AsTime() + } + switch payload := event.GetEvent().(type) { + case *corev1.Event_MessagePosted, *corev1.Event_ReactionAdded: + if streamSequence == 0 { + return invalidArgument("notification materialization requires an EVT stream sequence") + } + _, err := m.materializeWork(ctx, event, streamSequence, durableDelivery) + return err + case *corev1.Event_ReactionRemoved: + hadWork, err := m.materializeWork(ctx, event, streamSequence, durableDelivery) + if err != nil || hadWork || streamSequence == 0 { + return err + } + return m.removeReactionWithoutPreparedWork(ctx, event, payload.ReactionRemoved, streamSequence) + case *corev1.Event_MessageRetracted: + _, err := m.core.notificationOccurrences.RemoveTarget(ctx, payload.MessageRetracted.GetRoomId(), payload.MessageRetracted.GetEventId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_TARGET_RETRACTED) + return err + case *corev1.Event_UserLeftRoom: + if err := m.recordVisibilityBoundary(ctx, event.GetActorId(), payload.UserLeftRoom.GetRoomId(), streamSequence); err != nil { + return err + } + _, err := m.core.notificationOccurrences.RemoveRoomForUser(ctx, event.GetActorId(), payload.UserLeftRoom.GetRoomId(), streamSequence, corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return err + case *corev1.Event_RoomMemberRemoved: + if err := m.recordVisibilityBoundary(ctx, payload.RoomMemberRemoved.GetUserId(), payload.RoomMemberRemoved.GetRoomId(), streamSequence); err != nil { + return err + } + _, err := m.core.notificationOccurrences.RemoveRoomForUser(ctx, payload.RoomMemberRemoved.GetUserId(), payload.RoomMemberRemoved.GetRoomId(), streamSequence, corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return err + case *corev1.Event_RoomMemberBanned: + return m.reconcileOccurrenceVisibility(ctx, payload.RoomMemberBanned.GetUserId(), payload.RoomMemberBanned.GetRoomId(), streamSequence, visibilityAt) + case *corev1.Event_RoomUniversalChanged: + return m.reconcileOccurrenceVisibility(ctx, "", payload.RoomUniversalChanged.GetRoomId(), streamSequence, visibilityAt) + case *corev1.Event_RoomAddedToGroup: + return m.reconcileOccurrenceVisibility(ctx, "", payload.RoomAddedToGroup.GetRoomId(), streamSequence, visibilityAt) + case *corev1.Event_RoomDeleted: + _, err := m.core.notificationOccurrences.RemoveRoom(ctx, payload.RoomDeleted.GetRoomId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return err + case *corev1.Event_UserAccountDeleted: + userID := payload.UserAccountDeleted.GetUserId() + if _, err := m.core.notificationOccurrences.PurgeUser(ctx, userID); err != nil { + return err + } + if err := m.core.notificationOccurrences.purgeNotificationReadBoundaries(ctx, userID); err != nil { + return err + } + return m.purgeVisibilityBoundaries(ctx, userID) + case *corev1.Event_UserVerifiedEmailAdded: + return m.materializeConfiguredOwner(ctx, payload.UserVerifiedEmailAdded.GetUserId()) + case *corev1.Event_RbacRoleAssigned: + return m.reconcileOccurrenceVisibility(ctx, payload.RbacRoleAssigned.GetUserId(), "", streamSequence, visibilityAt) + case *corev1.Event_RbacRoleRevoked: + return m.reconcileOccurrenceVisibility(ctx, payload.RbacRoleRevoked.GetUserId(), "", streamSequence, visibilityAt) + case *corev1.Event_RbacRoleDeleted: + return m.reconcileOccurrenceVisibility(ctx, "", "", streamSequence, visibilityAt) + case *corev1.Event_RbacPermissionGranted: + return m.reconcilePermissionVisibility(ctx, payload.RbacPermissionGranted.GetPermission(), payload.RbacPermissionGranted.GetScope(), payload.RbacPermissionGranted.GetSubject(), streamSequence, visibilityAt) + case *corev1.Event_RbacPermissionDenied: + return m.reconcilePermissionVisibility(ctx, payload.RbacPermissionDenied.GetPermission(), payload.RbacPermissionDenied.GetScope(), payload.RbacPermissionDenied.GetSubject(), streamSequence, visibilityAt) + case *corev1.Event_RbacPermissionCleared: + return m.reconcilePermissionVisibility(ctx, payload.RbacPermissionCleared.GetPermission(), payload.RbacPermissionCleared.GetScope(), payload.RbacPermissionCleared.GetSubject(), streamSequence, visibilityAt) + } + return nil +} + +// materializeConfiguredOwner keeps owners.emails authorization represented by +// the same durable RBAC fact used by event-time notification visibility. The +// source email fact remains pending and is redelivered until this converges. +func (m *NotificationMaterializer) materializeConfiguredOwner(ctx context.Context, userID string) error { + if userID == "" || len(m.core.config.Owners.Emails) == 0 { + return nil + } + emails, err := m.core.userModel.verifiedEmails(ctx, userID) + if err != nil { + return fmt.Errorf("read configured-owner verified emails: %w", err) + } + for _, verified := range emails { + if !m.core.config.Owners.IsServerOwnerEmail(verified.Email) { + continue + } + if m.core.rbacModel.hasRole(userID, RoleOwner) { + return nil + } + if err := m.assignConfiguredOwnerRole(ctx, userID); err != nil { + return fmt.Errorf("materialize configured-owner role: %w", err) + } + return nil + } + return nil +} + +func (m *NotificationMaterializer) reconcilePermissionVisibility( + ctx context.Context, + permission string, + scope *corev1.RbacPermissionScope, + subject *corev1.RbacPermissionSubject, + streamSequence uint64, + visibilityAt time.Time, +) error { + if permission != string(PermRoomJoin) { + return nil + } + var userID, roomID string + if subject.GetKind() == corev1.RbacPermissionSubjectKind_RBAC_PERMISSION_SUBJECT_KIND_USER { + userID = subject.GetId() + } + if scope.GetKind() == corev1.RbacPermissionScopeKind_RBAC_PERMISSION_SCOPE_KIND_ROOM { + roomID = scope.GetId() + } + return m.reconcileOccurrenceVisibility(ctx, userID, roomID, streamSequence, visibilityAt) +} + +// reconcileOccurrenceVisibility handles effective membership changes that do +// not emit an explicit leave event, such as disabling a universal room, +// moving it across permission scopes, or changing room.join RBAC. These facts +// are rare administrative operations, so an authoritative occurrence scan is +// preferable to maintaining another derived recipient index. +func (m *NotificationMaterializer) reconcileOccurrenceVisibility(ctx context.Context, userID, roomID string, streamSequence uint64, visibilityAt time.Time) error { + entries, err := m.core.notificationOccurrences.storedOccurrenceEntries(ctx, userID) + if err != nil { + return err + } + type recipientRoom struct { + recipientID string + roomID string + } + entriesByPair := make(map[recipientRoom][]notificationOccurrenceIndexEntry) + for _, entry := range entries { + occurrence := entry.occurrence + targetRoomID := occurrence.GetTarget().GetRoomId() + if occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED || + targetRoomID == "" || (roomID != "" && targetRoomID != roomID) || + (streamSequence != 0 && occurrence.GetSourceStreamSequence() >= streamSequence) { + continue + } + pair := recipientRoom{recipientID: occurrence.GetRecipientId(), roomID: targetRoomID} + entriesByPair[pair] = append(entriesByPair[pair], entry) + } + if len(entriesByPair) == 0 { + return nil + } + snapshot, err := m.visibility.Projection().Boundary(streamSequence, visibilityAt) + if err != nil { + return err + } + + for pair, pairEntries := range entriesByPair { + if snapshot.membershipExists(pair.recipientID, pair.roomID) { + continue + } + if err := m.recordVisibilityBoundary(ctx, pair.recipientID, pair.roomID, streamSequence); err != nil { + return err + } + for _, entry := range pairEntries { + written, removed, err := m.core.notificationOccurrences.deleteStoredOccurrence( + ctx, + pair.recipientID, + entry.occurrence.GetSourceEventId(), + corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST, + ) + if err != nil { + return err + } + if removed { + m.core.publishNotificationOccurrenceChanged(ctx, written, false, true) + } + } + } + return nil +} + +func (m *NotificationMaterializer) removeReactionWithoutPreparedWork(ctx context.Context, event *corev1.Event, reaction *corev1.ReactionRemovedEvent, streamSequence uint64) error { + room, err := m.core.FindRoomByID(ctx, reaction.GetRoomId()) + if errors.Is(err, ErrNotFound) { + return nil + } + if err != nil { + return fmt.Errorf("resolve reaction-removal room: %w", err) + } + target, err := m.core.GetRoomEventByEventID(ctx, KindOfRoom(room), room.GetId(), reaction.GetMessageEventId()) + if err != nil { + return fmt.Errorf("resolve reaction-removal target: %w", err) + } + if target == nil || target.GetActorId() == "" || target.GetActorId() == event.GetActorId() { + return nil + } + _, err = m.core.notificationOccurrences.RemoveReaction( + ctx, + target.GetActorId(), + reaction.GetRoomId(), + reaction.GetMessageEventId(), + event.GetActorId(), + reaction.GetEmoji(), + streamSequence, + ) + return err +} + +func (m *NotificationMaterializer) materializeWork(ctx context.Context, event *corev1.Event, streamSequence uint64, durableDelivery bool) (bool, error) { + // StoreWork uses the same absolute retention boundary, so an event older + // than the notification TTL cannot have live prepared work. This also keeps + // a lagging consumer from spending KV operations on already-expired facts. + if createdAt := event.GetCreatedAt(); createdAt != nil && !createdAt.AsTime().UTC().Add(notificationTTL).After(time.Now().UTC()) { + return false, nil + } + + markerKey := notificationWorkMarkerKey(event.GetId()) + marker, err := m.core.storage.runtimeStateKV.Get(ctx, markerKey) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read notification work marker: %w", err) + } + + lister, err := m.core.storage.runtimeStateKV.ListKeysFiltered(ctx, notificationWorkFilter(event.GetId())) + if errors.Is(err, jetstream.ErrNoKeysFound) { + if durableDelivery { + return true, m.deleteWorkMarker(ctx, markerKey, marker.Revision()) + } + return true, nil + } + if err != nil { + return true, fmt.Errorf("list notification work: %w", err) + } + for key := range lister.Keys() { + entry, err := m.core.storage.runtimeStateKV.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + continue + } + if err != nil { + return true, fmt.Errorf("read notification work: %w", err) + } + var occurrence corev1.NotificationOccurrence + if err := proto.Unmarshal(entry.Value(), &occurrence); err != nil { + m.core.logger.Error("Discarding invalid notification work", "key", key, "error", err) + if durableDelivery { + if deleteErr := m.core.storage.runtimeStateKV.Delete(ctx, key, jetstream.LastRevision(entry.Revision())); deleteErr != nil && + !errors.Is(deleteErr, jetstream.ErrKeyNotFound) && !errors.Is(deleteErr, jetstream.ErrKeyDeleted) { + return true, fmt.Errorf("delete invalid notification work: %w", deleteErr) + } + } + continue + } + if err := m.materializeOccurrence(ctx, event, &occurrence, streamSequence); err != nil { + return true, err + } + if durableDelivery { + if err := m.core.storage.runtimeStateKV.Delete(ctx, key, jetstream.LastRevision(entry.Revision())); err != nil && + !errors.Is(err, jetstream.ErrKeyNotFound) && !errors.Is(err, jetstream.ErrKeyDeleted) { + return true, fmt.Errorf("delete notification work: %w", err) + } + } + } + if durableDelivery { + return true, m.deleteWorkMarker(ctx, markerKey, marker.Revision()) + } + return true, nil +} + +func (m *NotificationMaterializer) deleteWorkMarker(ctx context.Context, key string, revision uint64) error { + for attempt := 0; attempt < maxNotificationWorkWriteRetries; attempt++ { + err := m.core.storage.runtimeStateKV.Delete(ctx, key, jetstream.LastRevision(revision)) + if err == nil || errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + return nil + } + if !jetstreamutil.IsSequenceConflict(err) { + return fmt.Errorf("delete notification work marker: %w", err) + } + entry, getErr := m.core.storage.runtimeStateKV.Get(ctx, key) + if errors.Is(getErr, jetstream.ErrKeyNotFound) || errors.Is(getErr, jetstream.ErrKeyDeleted) { + return nil + } + if getErr != nil { + return fmt.Errorf("refresh notification work marker: %w", getErr) + } + revision = entry.Revision() + } + return fmt.Errorf("delete notification work marker after %d attempts", maxNotificationWorkWriteRetries) +} + +func (m *NotificationMaterializer) materializeOccurrence(ctx context.Context, event *corev1.Event, occurrence *corev1.NotificationOccurrence, streamSequence uint64) error { + if occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { + _, err := m.core.notificationOccurrences.RemoveSource(ctx, occurrence.GetRecipientId(), occurrence.GetSourceEventId(), occurrence.GetRemovalReason()) + return err + } + if occurrence.GetTarget() == nil || len(occurrence.GetReasons()) == 0 { + return nil + } + switch payload := event.GetEvent().(type) { + case *corev1.Event_MessagePosted: + if _, retracted, known := m.core.roomModel.latestBody(event.GetId()); known && retracted { + return nil + } + case *corev1.Event_ReactionAdded: + reaction := payload.ReactionAdded + snapshot := m.core.roomModel.reactionMutationSnapshot(reaction.GetRoomId(), reaction.GetMessageEventId(), reaction.GetEmoji(), event.GetActorId()) + if !snapshot.Exists || snapshot.SourceEventID != event.GetId() { + return nil + } + default: + return nil + } + visible, err := m.activeVisibleRecipient(ctx, occurrence.GetRecipientId(), occurrence.GetTarget().GetRoomId()) + if err != nil { + return err + } + if !visible { + return nil + } + afterBoundary, err := m.sourceAfterVisibilityBoundary(ctx, occurrence.GetRecipientId(), occurrence.GetTarget().GetRoomId(), streamSequence) + if err != nil { + return err + } + if !afterBoundary { + return nil + } + _, _, err = m.core.notificationOccurrences.Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: occurrence.GetRecipientId(), + SourceEventID: occurrence.GetSourceEventId(), + SourceCreated: occurrence.GetSourceCreatedAt().AsTime(), + ActorID: occurrence.GetActorId(), + Target: occurrence.GetTarget(), + Reasons: occurrence.GetReasons(), + ReactionEmoji: occurrence.GetReactionEmoji(), + SourceStreamSequence: streamSequence, + EvaluatedAt: occurrence.GetEvaluatedAt().AsTime(), + }) + if err != nil { + return fmt.Errorf("create occurrence for recipient %s: %w", occurrence.GetRecipientId(), err) + } + return nil +} + +func (m *NotificationMaterializer) activeVisibleRecipient(ctx context.Context, userID, roomID string) (bool, error) { + active, err := m.activeRecipient(ctx, userID) + if err != nil || !active { + return active, err + } + room, err := m.core.FindRoomByID(ctx, roomID) + if errors.Is(err, ErrNotFound) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("verify notification room: %w", err) + } + member, err := m.core.RoomMembershipExists(ctx, KindOfRoom(room), userID, roomID) + if err != nil { + return false, fmt.Errorf("verify notification room membership: %w", err) + } + return member, nil +} + +func (m *NotificationMaterializer) activeRecipient(ctx context.Context, userID string) (bool, error) { + _, err := m.core.GetUser(ctx, userID) + if errors.Is(err, ErrNotFound) { + return false, nil + } + return err == nil, err +} diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go new file mode 100644 index 000000000..fe77cbc9f --- /dev/null +++ b/cli/internal/core/notification_materializer_test.go @@ -0,0 +1,1473 @@ +package core + +import ( + "context" + "encoding/binary" + "errors" + "slices" + "testing" + "time" + + "github.com/nats-io/nats.go/jetstream" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + "hmans.de/chatto/internal/config" + "hmans.de/chatto/internal/evtstream" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" + "hmans.de/chatto/internal/testutil" + "hmans.de/chatto/pkg/events" +) + +func TestMessageNotificationWorkRecomputesAfterOCCConflict(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "notify-retry-author", "Notify Retry Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + lateMember, err := chattoCore.CreateUser(ctx, SystemActorID, "notify-retry-member", "Notify Retry Member", "password") + if err != nil { + t.Fatalf("CreateUser late member: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "notification-retry-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + if _, err := chattoCore.JoinRoom(ctx, author.Id, KindChannel, author.Id, room.Id); err != nil { + t.Fatalf("JoinRoom author: %v", err) + } + if _, err := chattoCore.NotificationPolicy().SetServerNotificationIntensity( + ctx, + lateMember.Id, + corev1.NotificationReason_NOTIFICATION_REASON_ALL, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + ); err != nil { + t.Fatalf("SetServerNotificationIntensity: %v", err) + } + + preparedAttempts := 0 + posted, err := chattoCore.PostMessage( + ctx, + KindChannel, + room.Id, + author.Id, + "@all retry recipients", + nil, + "", + "", + nil, + false, + withPostMessageNotificationAttemptPrepared(func(attemptCtx context.Context) error { + preparedAttempts++ + if preparedAttempts != 1 { + return nil + } + _, err := chattoCore.JoinRoom(attemptCtx, lateMember.Id, KindChannel, lateMember.Id, room.Id) + return err + }), + ) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + if preparedAttempts < 2 { + t.Fatalf("notification preparation attempts = %d, want an OCC retry", preparedAttempts) + } + if !slices.Contains(posted.GetMessagePosted().GetMentionedUserIds(), lateMember.Id) { + t.Fatalf("mentioned users = %v, want late member %s", posted.GetMessagePosted().GetMentionedUserIds(), lateMember.Id) + } + if err := chattoCore.notificationMaterializer.WaitCurrent(ctx); err != nil { + t.Fatalf("WaitCurrent: %v", err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, lateMember.Id) + if err != nil { + t.Fatalf("List late member occurrences: %v", err) + } + if len(occurrences) != 1 || occurrences[0].GetSourceEventId() != posted.GetId() { + t.Fatalf("late member occurrences = %+v, want source %s", occurrences, posted.GetId()) + } +} + +func TestMessageNotificationWorkWaitsForCurrentPolicyProjection(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "notify-policy-author", "Notify Policy Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + recipient, err := chattoCore.CreateUser(ctx, SystemActorID, "notify-policy-recipient", "Notify Policy Recipient", "password") + if err != nil { + t.Fatalf("CreateUser recipient: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "notification-policy-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + if _, err := chattoCore.JoinRoom(ctx, author.Id, KindChannel, author.Id, room.Id); err != nil { + t.Fatalf("JoinRoom author: %v", err) + } + if _, err := chattoCore.JoinRoom(ctx, recipient.Id, KindChannel, recipient.Id, room.Id); err != nil { + t.Fatalf("JoinRoom recipient: %v", err) + } + if _, err := chattoCore.NotificationPolicy().SetRoomNotificationIntensity( + ctx, + recipient.Id, + room.Id, + corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF, + ); err != nil { + t.Fatalf("SetRoomNotificationIntensity: %v", err) + } + + delayedConfig := evtstream.NewProjectionHandle( + chattoCore.js, + chattoCore.storage.serverEvtStream, + NewConfigProjection(), + testCoreLogger(), + ) + chattoCore.configModel = NewConfigModel(chattoCore.EventPublisher, delayedConfig) + + type postResult struct { + posted *corev1.Event + err error + } + result := make(chan postResult, 1) + go func() { + posted, err := chattoCore.PostMessage( + ctx, + KindChannel, + room.Id, + author.Id, + "Please look, @notify-policy-recipient", + nil, + "", + "", + nil, + false, + ) + result <- postResult{posted: posted, err: err} + }() + select { + case early := <-result: + t.Fatalf("PostMessage returned before delayed policy projection started: (%+v, %v)", early.posted, early.err) + case <-time.After(50 * time.Millisecond): + } + + runCtx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- delayedConfig.Projector().Run(runCtx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("delayed config projector did not stop") + } + }) + select { + case got := <-result: + if got.err != nil || got.posted == nil { + t.Fatalf("PostMessage after policy catch-up = (%+v, %v), want event, nil", got.posted, got.err) + } + case <-ctx.Done(): + t.Fatalf("PostMessage after policy catch-up: %v", ctx.Err()) + } + if err := chattoCore.notificationMaterializer.WaitCurrent(ctx); err != nil { + t.Fatalf("WaitCurrent: %v", err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id) + if err != nil { + t.Fatalf("List recipient occurrences: %v", err) + } + if len(occurrences) != 0 { + t.Fatalf("recipient occurrences = %+v, want none for projected Off policy", occurrences) + } +} + +func TestNotificationAlertClaimWaitsForMaterializerFence(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + model := chattoCore.NotificationOccurrences() + now := time.Now().UTC() + created, wasCreated, err := model.Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: "U-alert-fence", SourceEventID: "E-alert-fence", SourceCreated: now, + Target: &corev1.NotificationTarget{RoomId: "R-alert-fence", EventId: "E-alert-target"}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + }}, + SkipReadLookup: true, + }) + if err != nil || !wasCreated { + t.Fatalf("Create alert occurrence = (%+v, %v, %v)", created, wasCreated, err) + } + fenceCalls := 0 + deliveryCalls := 0 + chattoCore.notificationMaterializer.waitCurrent = func(fenceCtx context.Context) error { + fenceCalls++ + _, err := model.Delete( + fenceCtx, + created.GetRecipientId(), + created.GetId(), + corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST, + ) + return err + } + chattoCore.OnNotificationOccurrenceCreated = func(context.Context, *corev1.NotificationOccurrence) error { + deliveryCalls++ + return nil + } + + chattoCore.notificationMaterializer.deliverPendingAlerts(ctx) + if fenceCalls == 0 { + t.Fatal("alert delivery did not wait for the materializer fence") + } + if deliveryCalls != 0 { + t.Fatalf("delivery calls = %d, want none after fenced visibility cleanup", deliveryCalls) + } +} + +func TestMessageNotificationMaterializationMergesReasonsAndReconcilesReadState(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + bob, err := chattoCore.CreateUser(ctx, SystemActorID, "notify-bob", "Notify Bob", "password") + if err != nil { + t.Fatalf("CreateUser bob: %v", err) + } + alice, err := chattoCore.CreateUser(ctx, SystemActorID, "notify-alice", "Notify Alice", "password") + if err != nil { + t.Fatalf("CreateUser alice: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, alice.Id, KindChannel, "", "notification-materializer-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + for _, userID := range []string{alice.Id, bob.Id} { + if _, err := chattoCore.JoinRoom(ctx, userID, KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %s: %v", userID, err) + } + } + + root, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, bob.Id, "root", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage root: %v", err) + } + reply, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, alice.Id, "@notify-bob hello", nil, "", root.Id, nil, false) + if err != nil { + t.Fatalf("PostMessage reply: %v", err) + } + if seq, err := chattoCore.EventPublisher.LastSubjectSeq(ctx, "evt.notification.>"); err != nil || seq != 0 { + t.Fatalf("notification-only EVT sequence = (%d, %v), want none", seq, err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, bob.Id) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(occurrences) != 1 { + t.Fatalf("occurrences = %d, want one merged occurrence", len(occurrences)) + } + occurrence := occurrences[0] + if occurrence.GetSourceEventId() != reply.Id || occurrence.GetTarget().GetEventId() != reply.Id || occurrence.GetTarget().GetParentEventId() != root.Id { + t.Fatalf("occurrence target = %+v, source = %q", occurrence.GetTarget(), occurrence.GetSourceEventId()) + } + wantReasons := map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity{ + corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + corev1.NotificationReason_NOTIFICATION_REASON_REPLY: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF, + } + if len(occurrence.GetReasons()) != len(wantReasons) { + t.Fatalf("reasons = %+v, want %d", occurrence.GetReasons(), len(wantReasons)) + } + for _, match := range occurrence.GetReasons() { + if wantReasons[match.GetReason()] != match.GetIntensity() { + t.Fatalf("reason %v intensity = %v, want %v", match.GetReason(), match.GetIntensity(), wantReasons[match.GetReason()]) + } + } + + if _, err := chattoCore.ReadState().MarkRoomAsRead(ctx, bob.Id, room.Id, reply.Id); err != nil { + t.Fatalf("MarkRoomAsRead: %v", err) + } + read, err := chattoCore.NotificationOccurrences().Get(ctx, bob.Id, occurrence.GetId()) + if err != nil { + t.Fatalf("Get after room read: %v", err) + } + if read.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ { + t.Fatalf("inbox state = %v, want READ", read.GetInboxState()) + } + + if err := chattoCore.DeleteMessage(ctx, alice.Id, KindChannel, room.Id, reply.Id); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + if occurrences, err := chattoCore.NotificationOccurrences().List(ctx, bob.Id); err != nil || len(occurrences) != 0 { + t.Fatalf("Inbox after retraction = (%v, %v), want empty", occurrences, err) + } +} + +func TestNotificationVisibilityBoundarySurvivesSuccessfulHandlerUntilAckFloor(t *testing.T) { + _, nc := testutil.StartNATS(t) + ctx := testContext(t) + chattoCore, err := NewChattoCore(ctx, nc, config.CoreConfig{ + SecretKey: "notification-ack-boundary-secret", + Assets: config.AssetsConfig{SigningSecret: "notification-ack-boundary-signing-secret"}, + }) + if err != nil { + t.Fatalf("NewChattoCore: %v", err) + } + // Prevent the confirmed-ACK cleanup ticker from racing this explicit + // handler/redelivery assertion. + chattoCore.notificationMaterializer.pollEvery = time.Hour + startCoreServices(t, chattoCore) + + owner, err := chattoCore.CreateUser(ctx, SystemActorID, "ack-boundary-owner", "Ack Boundary Owner", "password") + if err != nil { + t.Fatalf("CreateUser owner: %v", err) + } + member, err := chattoCore.CreateUser(ctx, SystemActorID, "ack-boundary-member", "Ack Boundary Member", "password") + if err != nil { + t.Fatalf("CreateUser member: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, owner.Id, KindChannel, "", "ack-boundary-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + if _, err := chattoCore.JoinRoom(ctx, member.Id, KindChannel, member.Id, room.Id); err != nil { + t.Fatalf("JoinRoom: %v", err) + } + posted, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, owner.Id, "ack boundary target", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + postedSequence, err := chattoCore.GetEventSequence(ctx, KindChannel, room.Id, posted.Id) + if err != nil { + t.Fatalf("GetEventSequence: %v", err) + } + if _, created, err := chattoCore.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: member.Id, SourceEventID: "ack-boundary-source", SourceCreated: time.Now().UTC(), + SourceStreamSequence: postedSequence, ActorID: owner.Id, + Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: posted.Id}, + Reasons: []*corev1.NotificationReasonMatch{{Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE}}, + SkipReadLookup: true, + }); err != nil || !created { + t.Fatalf("Create occurrence = (%v, %v)", created, err) + } + + if _, err := chattoCore.SetRoomUniversal(ctx, owner.Id, KindChannel, room.Id, true); err != nil { + t.Fatalf("SetRoomUniversal true: %v", err) + } + if _, err := chattoCore.SetRoomUniversal(ctx, owner.Id, KindChannel, room.Id, false); err != nil { + t.Fatalf("SetRoomUniversal: %v", err) + } + losses, _, err := chattoCore.EventPublisher.SubjectEventsWithSubjectsAfter(ctx, evtstream.RoomEventTypeFilter(evtstream.EventRoomUniversalChanged), 0) + if err != nil || len(losses) == 0 { + t.Fatalf("read loss event = (%d, %v)", len(losses), err) + } + loss := losses[len(losses)-1] + if _, err := chattoCore.notificationMaterializer.visibility.Projection().Boundary(loss.Sequence, time.Now()); err != nil { + t.Fatalf("boundary after successful handler: %v", err) + } + data, err := proto.Marshal(loss.Event) + if err != nil { + t.Fatalf("marshal loss event: %v", err) + } + // Model DoubleAck failing after a successful handler: JetStream may deliver + // the same fact again, and that evaluation must still have its exact state. + if err := chattoCore.notificationMaterializer.processDelivery(ctx, events.DurableDelivery{ + Subject: loss.Subject, Data: data, StreamSequence: loss.Sequence, NumDelivered: 2, + }); err != nil { + t.Fatalf("redelivered loss: %v", err) + } + if _, err := chattoCore.notificationMaterializer.visibility.Projection().Boundary(loss.Sequence, time.Now()); err != nil { + t.Fatalf("boundary after redelivered handler: %v", err) + } + if err := chattoCore.notificationMaterializer.visibility.Projection().ReleaseThrough(loss.Sequence); err != nil { + t.Fatalf("ReleaseThrough: %v", err) + } + if _, err := chattoCore.notificationMaterializer.visibility.Projection().Boundary(loss.Sequence, time.Now()); err == nil { + t.Fatal("boundary remained after confirmed acknowledgement floor") + } +} + +func TestNotificationAcknowledgedThroughUsesFullConsumerFloor(t *testing.T) { + tests := []struct { + name string + tail uint64 + info *jetstream.ConsumerInfo + want uint64 + }{ + { + name: "idle consumer reaches earlier stream tail", + tail: 90, + info: &jetstream.ConsumerInfo{AckFloor: jetstream.SequenceInfo{Stream: 41}}, + want: 90, + }, + { + name: "undelivered fact retains confirmed ack floor", + tail: 90, + info: &jetstream.ConsumerInfo{AckFloor: jetstream.SequenceInfo{Stream: 41}, NumPending: 1}, + want: 41, + }, + { + name: "delivered fact retains confirmed ack floor", + tail: 90, + info: &jetstream.ConsumerInfo{AckFloor: jetstream.SequenceInfo{Stream: 41}, NumAckPending: 1}, + want: 41, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if got := notificationAcknowledgedThrough(test.tail, test.info); got != test.want { + t.Fatalf("notificationAcknowledgedThrough() = %d, want %d", got, test.want) + } + }) + } +} + +func TestNotificationAcknowledgedFloorReconstructsIdleTailOnStartupWithPendingFact(t *testing.T) { + _, nc := testutil.StartNATS(t) + ctx := testContext(t) + cfg := config.CoreConfig{ + SecretKey: "notification-floor-restart-secret", + Assets: config.AssetsConfig{SigningSecret: "notification-floor-restart-signing-secret"}, + } + first, err := NewChattoCore(ctx, nc, cfg) + if err != nil { + t.Fatalf("NewChattoCore first: %v", err) + } + + roomCreated := &corev1.Event{ + Id: "E-floor-room-created", CreatedAt: timestamppb.Now(), ActorId: SystemActorID, + Event: &corev1.Event_RoomCreated{RoomCreated: &corev1.RoomCreatedEvent{RoomId: "R-floor-restart", Kind: corev1.RoomKind_ROOM_KIND_CHANNEL}}, + } + safeTail, err := first.EventPublisher.AppendEventually(ctx, evtstream.RoomAggregate("R-floor-restart").SubjectFor(roomCreated), roomCreated) + if err != nil { + t.Fatalf("append non-worker fact: %v", err) + } + first.notificationMaterializer.releaseAcknowledgedVisibilityBoundaries(ctx) + if got := first.notificationMaterializer.visibility.Projection().RestoreMaxCutoff(); got != safeTail { + t.Fatalf("idle full floor = %d, want safe tail %d", got, safeTail) + } + + message := &corev1.Event{ + Id: "E-floor-message", CreatedAt: timestamppb.Now(), ActorId: "U-floor-author", + Event: &corev1.Event_MessagePosted{MessagePosted: &corev1.MessagePostedEvent{RoomId: "R-floor-restart"}}, + } + pendingSequence, err := first.EventPublisher.AppendEventually(ctx, evtstream.RoomAggregate("R-floor-restart").SubjectFor(message), message) + if err != nil { + t.Fatalf("append pending worker fact: %v", err) + } + info, err := first.notificationMaterializer.consumerInfo(ctx) + if err != nil { + t.Fatalf("notification consumer Info: %v", err) + } + if info.NumPending == 0 || info.AckFloor.Stream >= safeTail { + t.Fatalf("consumer pending=%d ack floor=%d, want pending fact %d behind durable safe tail %d", info.NumPending, info.AckFloor.Stream, pendingSequence, safeTail) + } + + second, err := NewChattoCore(ctx, nc, cfg) + if err != nil { + t.Fatalf("NewChattoCore second: %v", err) + } + if got := second.notificationMaterializer.visibility.Projection().RestoreMaxCutoff(); got != safeTail { + t.Fatalf("restart restore floor = %d, want durable safe tail %d", got, safeTail) + } +} + +func TestConfiguredOwnerMaterializationRetriesWithoutLiveFallbackDivergence(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + user, err := chattoCore.CreateVerifiedUser(ctx, SystemActorID, "retry-config-owner", "Retry Config Owner", "password", "owner@example.com") + if err != nil { + t.Fatalf("CreateVerifiedUser: %v", err) + } + if err := chattoCore.notificationMaterializer.WaitCurrent(ctx); err != nil { + t.Fatalf("WaitCurrent before installing assignment test double: %v", err) + } + chattoCore.config.Owners = config.OwnersConfig{Emails: []string{"owner@example.com"}} + + realAssign := chattoCore.notificationMaterializer.assignConfiguredOwnerRole + attempts := 0 + chattoCore.notificationMaterializer.assignConfiguredOwnerRole = func(ctx context.Context, userID string) error { + attempts++ + if attempts == 1 { + return errors.New("forced transient assignment failure") + } + return realAssign(ctx, userID) + } + if err := chattoCore.notificationMaterializer.materializeConfiguredOwner(ctx, user.Id); err == nil { + t.Fatal("first materialization unexpectedly succeeded") + } + if owner, err := chattoCore.IsServerOwner(ctx, user.Id); err != nil || owner { + t.Fatalf("configured email became a live-only owner after failed durable assignment: owner=%v err=%v", owner, err) + } + if err := chattoCore.notificationMaterializer.materializeConfiguredOwner(ctx, user.Id); err != nil { + t.Fatalf("retry configured-owner materialization: %v", err) + } + if owner, err := chattoCore.IsServerOwner(ctx, user.Id); err != nil || !owner { + t.Fatalf("durably materialized owner = %v, err=%v", owner, err) + } +} + +func TestNotificationDurableWorkerMaterializesPreparedRuntimeWork(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "worker-author", "Worker Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + recipient, err := chattoCore.CreateUser(ctx, SystemActorID, "worker-recipient", "Worker Recipient", "password") + if err != nil { + t.Fatalf("CreateUser recipient: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "notification-worker-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + for _, userID := range []string{author.Id, recipient.Id} { + if _, err := chattoCore.JoinRoom(ctx, userID, KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %s: %v", userID, err) + } + } + + source := newEvent(author.Id, &corev1.Event{Event: &corev1.Event_MessagePosted{ + MessagePosted: &corev1.MessagePostedEvent{RoomId: room.Id}, + }}) + work := newNotificationOccurrenceWork( + source, + &corev1.NotificationTarget{RoomId: room.Id, EventId: source.Id}, + []notificationRecipientDecision{{ + recipientID: recipient.Id, + reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + }}, + ) + if err := chattoCore.notificationMaterializer.StoreWork(ctx, source, work); err != nil { + t.Fatalf("StoreWork: %v", err) + } + if _, err := chattoCore.storage.runtimeStateKV.Get(ctx, notificationWorkMarkerKey(source.Id)); err != nil { + t.Fatalf("notification work marker after StoreWork: %v", err) + } + if _, err := chattoCore.EventPublisher.AppendEventually(ctx, evtstream.RoomAggregate(room.Id).SubjectFor(source), source); err != nil { + t.Fatalf("append source: %v", err) + } + + deadline := time.Now().Add(5 * time.Second) + for { + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id) + if err == nil && len(occurrences) == 1 && occurrences[0].GetSourceEventId() == source.Id && occurrences[0].GetSourceStreamSequence() != 0 { + break + } + if time.Now().After(deadline) { + t.Fatalf("durable worker did not materialize occurrence: occurrences=%+v err=%v", occurrences, err) + } + time.Sleep(10 * time.Millisecond) + } + if _, err := chattoCore.storage.runtimeStateKV.Get(ctx, notificationWorkKey(source.Id, recipient.Id)); !errors.Is(err, jetstream.ErrKeyNotFound) && !errors.Is(err, jetstream.ErrKeyDeleted) { + t.Fatalf("notification work remains after materialization: %v", err) + } + if _, err := chattoCore.storage.runtimeStateKV.Get(ctx, notificationWorkMarkerKey(source.Id)); !errors.Is(err, jetstream.ErrKeyNotFound) && !errors.Is(err, jetstream.ErrKeyDeleted) { + t.Fatalf("notification work marker remains after materialization: %v", err) + } +} + +func TestStoreWorkClearsStaleRecipientsWhenRetryNowProducesNoWork(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + source := newEvent("U-work-actor", &corev1.Event{Event: &corev1.Event_ReactionAdded{ + ReactionAdded: &corev1.ReactionAddedEvent{RoomId: "R-work", MessageEventId: "E-target", Emoji: "thumbsup"}, + }}) + work := newNotificationOccurrenceWork( + source, + &corev1.NotificationTarget{RoomId: "R-work", EventId: "E-target"}, + []notificationRecipientDecision{{ + recipientID: "U-work-recipient", + reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_REACTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + }}, + ) + if err := chattoCore.notificationMaterializer.StoreWork(ctx, source, work); err != nil { + t.Fatalf("StoreWork initial: %v", err) + } + if err := chattoCore.notificationMaterializer.StoreWork(ctx, source, nil); err != nil { + t.Fatalf("StoreWork retry without recipients: %v", err) + } + for _, key := range []string{ + notificationWorkMarkerKey(source.GetId()), + notificationWorkKey(source.GetId(), "U-work-recipient"), + } { + if _, err := chattoCore.storage.runtimeStateKV.Get(ctx, key); !errors.Is(err, jetstream.ErrKeyNotFound) && !errors.Is(err, jetstream.ErrKeyDeleted) { + t.Fatalf("stale notification work key %q remains: %v", key, err) + } + } +} + +func TestNotificationMaterializerConsumerStartsAtCreationBoundary(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + + consumer, err := chattoCore.notificationMaterializer.createConsumer(ctx) + if err != nil { + t.Fatalf("createConsumer: %v", err) + } + info, err := consumer.Info(ctx) + if err != nil { + t.Fatalf("consumer Info: %v", err) + } + if info.Config.DeliverPolicy != jetstream.DeliverNewPolicy { + t.Fatalf("deliver policy = %v, want DeliverNewPolicy", info.Config.DeliverPolicy) + } +} + +func TestNotificationMaterializerWaitCurrentFencesRelevantEventTail(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + secondIndex := NewNotificationOccurrenceIndex(chattoCore.storage.runtimeStateKV, testCoreLogger()) + runCtx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- secondIndex.Run(runCtx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("second notification index did not stop") + } + }) + if err := secondIndex.WaitReady(ctx); err != nil { + t.Fatalf("second index WaitReady: %v", err) + } + event := &corev1.Event{ + Id: "E-notification-wait-current", + CreatedAt: timestamppb.Now(), + ActorId: SystemActorID, + Event: &corev1.Event_UserAccountDeleted{UserAccountDeleted: &corev1.UserAccountDeletedEvent{ + UserId: "U-notification-wait-current", + }}, + } + sequence, err := chattoCore.EventPublisher.AppendEventually(ctx, evtstream.UserAggregate("U-notification-wait-current").SubjectFor(event), event) + if err != nil { + t.Fatalf("append notification-relevant event: %v", err) + } + if err := chattoCore.notificationMaterializer.WaitCurrent(ctx); err != nil { + t.Fatalf("WaitCurrent: %v", err) + } + info, err := chattoCore.notificationMaterializer.consumerInfo(ctx) + if err != nil { + t.Fatalf("consumer Info: %v", err) + } + if info.AckFloor.Stream < sequence { + t.Fatalf("notification ack floor = %d, want at least %d", info.AckFloor.Stream, sequence) + } + readFence, err := chattoCore.storage.runtimeStateKV.Get(ctx, notificationReadFenceKey) + if err != nil { + t.Fatalf("get notification read fence: %v", err) + } + if len(readFence.Value()) != 8 || binary.BigEndian.Uint64(readFence.Value()) < sequence { + t.Fatalf("notification read fence = %v, want EVT sequence at least %d", readFence.Value(), sequence) + } + chattoCore.notificationOccurrences.index.mu.RLock() + observedRevision := chattoCore.notificationOccurrences.index.observedRevision + chattoCore.notificationOccurrences.index.mu.RUnlock() + if observedRevision < readFence.Revision() { + t.Fatalf("local index revision = %d, want read fence revision at least %d", observedRevision, readFence.Revision()) + } + if err := secondIndex.waitForObservedRevision(ctx, readFence.Revision()); err != nil { + t.Fatalf("second index read fence wait: %v", err) + } +} + +func TestNotificationMaterializerRemovesOccurrencesAfterImplicitMembershipLoss(t *testing.T) { + for _, testCase := range []struct { + name string + revoke func(context.Context, *ChattoCore, string, string, string) error + wantFilter string + }{ + { + name: "universal room disabled", + revoke: func(ctx context.Context, chattoCore *ChattoCore, actorID, roomID, _ string) error { + _, err := chattoCore.SetRoomUniversal(ctx, actorID, KindChannel, roomID, false) + return err + }, + wantFilter: evtstream.RoomEventTypeFilter(evtstream.EventRoomUniversalChanged), + }, + { + name: "room join permission denied", + revoke: func(ctx context.Context, chattoCore *ChattoCore, _, roomID, recipientID string) error { + return chattoCore.DenyUserRoomPermission(ctx, SystemActorID, roomID, recipientID, PermRoomJoin) + }, + wantFilter: evtstream.RBACEventTypeFilter(evtstream.EventRBACPermissionDenied), + }, + } { + t.Run(testCase.name, func(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "implicit-author", "Implicit Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + recipient, err := chattoCore.CreateUser(ctx, SystemActorID, "implicit-recipient", "Implicit Recipient", "password") + if err != nil { + t.Fatalf("CreateUser recipient: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "implicit-notification-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + if _, err := chattoCore.SetRoomUniversal(ctx, author.Id, KindChannel, room.Id, true); err != nil { + t.Fatalf("SetRoomUniversal true: %v", err) + } + + message, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "@implicit-recipient hello", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + if err := chattoCore.notificationMaterializer.WaitCurrent(ctx); err != nil { + t.Fatalf("WaitCurrent after message: %v", err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id) + if err != nil || len(occurrences) != 1 || occurrences[0].GetSourceEventId() != message.Id { + t.Fatalf("occurrences before visibility loss = (%+v, %v), want source %s", occurrences, err, message.Id) + } + + if err := testCase.revoke(ctx, chattoCore, author.Id, room.Id, recipient.Id); err != nil { + t.Fatalf("revoke implicit membership: %v", err) + } + boundary, err := chattoCore.EventPublisher.LastSubjectPosition(ctx, testCase.wantFilter) + if err != nil { + t.Fatalf("read visibility event boundary: %v", err) + } + if err := chattoCore.notificationMaterializer.WaitCurrent(ctx); err != nil { + t.Fatalf("WaitCurrent after visibility loss: %v", err) + } + if occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id); err != nil || len(occurrences) != 0 { + t.Fatalf("occurrences after visibility loss = (%+v, %v), want empty", occurrences, err) + } + visibilityEntry, err := chattoCore.storage.runtimeStateKV.Get(ctx, notificationVisibilityBoundaryKey(recipient.Id, room.Id)) + if err != nil { + t.Fatalf("get visibility boundary: %v", err) + } + if len(visibilityEntry.Value()) != 8 || binary.BigEndian.Uint64(visibilityEntry.Value()) < boundary.Seq { + t.Fatalf("visibility boundary = %v, want sequence at least %d", visibilityEntry.Value(), boundary.Seq) + } + }) + } +} + +func TestNotificationVisibilityReconciliationUsesEventBoundaryWhenProjectionIsAhead(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "ahead-author", "Ahead Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + recipient, err := chattoCore.CreateUser(ctx, SystemActorID, "ahead-recipient", "Ahead Recipient", "password") + if err != nil { + t.Fatalf("CreateUser recipient: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "ahead-notification-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + if _, err := chattoCore.SetRoomUniversal(ctx, author.Id, KindChannel, room.Id, true); err != nil { + t.Fatalf("SetRoomUniversal true: %v", err) + } + beforeLoss, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "before loss", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage before loss: %v", err) + } + beforeLossSequence, err := chattoCore.GetEventSequence(ctx, KindChannel, room.Id, beforeLoss.Id) + if err != nil { + t.Fatalf("GetEventSequence before loss: %v", err) + } + + if _, err := chattoCore.SetRoomUniversal(ctx, author.Id, KindChannel, room.Id, false); err != nil { + t.Fatalf("SetRoomUniversal false: %v", err) + } + lossFilter := evtstream.RoomEventTypeFilter(evtstream.EventRoomUniversalChanged) + lossEvents, _, err := chattoCore.EventPublisher.SubjectEventsWithSubjectsAfter(ctx, lossFilter, 0) + if err != nil { + t.Fatalf("read universal events: %v", err) + } + if len(lossEvents) == 0 { + t.Fatal("read universal events: got none") + } + loss := lossEvents[len(lossEvents)-1] + if loss.Event.GetRoomUniversalChanged().GetUniversal() { + t.Fatalf("latest universal event = true, want loss event") + } + if _, err := chattoCore.SetRoomUniversal(ctx, author.Id, KindChannel, room.Id, true); err != nil { + t.Fatalf("SetRoomUniversal restore: %v", err) + } + afterRegain, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "after regain", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage after regain: %v", err) + } + afterRegainSequence, err := chattoCore.GetEventSequence(ctx, KindChannel, room.Id, afterRegain.Id) + if err != nil { + t.Fatalf("GetEventSequence after regain: %v", err) + } + member, err := chattoCore.RoomMembershipExists(ctx, KindChannel, recipient.Id, room.Id) + if err != nil || !member { + t.Fatalf("current restored membership = (%v, %v), want true", member, err) + } + + createOccurrence := func(sourceID, targetID string, sequence uint64) *corev1.NotificationOccurrence { + t.Helper() + occurrence, created, err := chattoCore.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: recipient.Id, + SourceEventID: sourceID, + SourceCreated: time.Now().UTC(), + SourceStreamSequence: sequence, + ActorID: author.Id, + Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: targetID}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }) + if err != nil || !created { + t.Fatalf("Create occurrence %s = (%+v, %v, %v), want created", sourceID, occurrence, created, err) + } + return occurrence + } + preLossOccurrence := createOccurrence("ahead-pre-loss", beforeLoss.Id, beforeLossSequence) + postRegainOccurrence := createOccurrence("ahead-post-regain", afterRegain.Id, afterRegainSequence) + // The live worker has already released this acknowledged boundary. Reapply + // the loss to the dedicated projection to exercise reconciliation against + // the same retained exact-boundary state while the owning projections remain + // ahead at the restored value. + if err := chattoCore.notificationMaterializer.visibility.Projection().Apply(loss.Event, loss.Sequence); err != nil { + t.Fatalf("recapture loss boundary: %v", err) + } + + if err := chattoCore.notificationMaterializer.reconcileOccurrenceVisibility( + ctx, + recipient.Id, + room.Id, + loss.Sequence, + loss.Event.GetCreatedAt().AsTime(), + ); err != nil { + t.Fatalf("reconcile loss after projection restored: %v", err) + } + if _, err := chattoCore.NotificationOccurrences().Get(ctx, recipient.Id, preLossOccurrence.Id); !errors.Is(err, ErrNotFound) { + t.Fatalf("pre-loss occurrence error = %v, want not found", err) + } + if occurrence, err := chattoCore.NotificationOccurrences().Get(ctx, recipient.Id, postRegainOccurrence.Id); err != nil || occurrence.GetId() != postRegainOccurrence.Id { + t.Fatalf("post-regain occurrence = (%+v, %v), want preserved", occurrence, err) + } +} + +func TestNotificationMaterializerSkipsFactsOutsideRetentionWindow(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + source := &corev1.Event{ + Id: "E-expired-notification-source", + CreatedAt: timestamppb.New(time.Now().UTC().Add(-notificationTTL - time.Hour)), + Event: &corev1.Event_MessagePosted{MessagePosted: &corev1.MessagePostedEvent{ + RoomId: "R-expired-notification-source", + }}, + } + markerKey := notificationWorkMarkerKey(source.GetId()) + if _, err := chattoCore.storage.runtimeStateKV.Create(ctx, markerKey, nil); err != nil { + t.Fatalf("create stale marker: %v", err) + } + if _, err := chattoCore.storage.runtimeStateKV.Create(ctx, notificationWorkKey(source.GetId(), "U-stale"), []byte("not protobuf")); err != nil { + t.Fatalf("create stale work: %v", err) + } + + if err := chattoCore.notificationMaterializer.materializeEvent(ctx, source, 1, true); err != nil { + t.Fatalf("MaterializeEvent: %v", err) + } + if _, err := chattoCore.storage.runtimeStateKV.Get(ctx, markerKey); err != nil { + t.Fatalf("expired delivery unexpectedly inspected or deleted work: %v", err) + } +} + +func TestReactionRemovalFromLegacyWriterRemovesV2Occurrence(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "legacy-remove-author", "Legacy Remove Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + reactor, err := chattoCore.CreateUser(ctx, SystemActorID, "legacy-remove-reactor", "Legacy Remove Reactor", "password") + if err != nil { + t.Fatalf("CreateUser reactor: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "legacy-reaction-removal-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + for _, userID := range []string{author.Id, reactor.Id} { + if _, err := chattoCore.JoinRoom(ctx, userID, KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %s: %v", userID, err) + } + } + posted, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "react here", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + input := ReactionMutationInput{ActorID: reactor.Id, RoomID: room.Id, MessageEventID: posted.Id, Emoji: "thumbsup"} + if added, err := chattoCore.ReactionModel().AddReaction(ctx, input); err != nil || !added { + t.Fatalf("AddReaction = (%v, %v), want added", added, err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, author.Id) + if err != nil || len(occurrences) != 1 { + t.Fatalf("reaction occurrences = (%d, %v), want one", len(occurrences), err) + } + if occurrences[0].GetReactionEmoji() != "thumbsup" { + t.Fatalf("reaction emoji = %q, want thumbsup", occurrences[0].GetReactionEmoji()) + } + + // Simulate an old replica: append the existing domain fact without any + // Notifications 2.0 runtime work or marker. + removed := newReactionRemovedEvent(reactor.Id, room.Id, posted.Id, "thumbsup") + if _, err := chattoCore.EventPublisher.AppendEventually(ctx, evtstream.RoomAggregate(room.Id).SubjectFor(removed), removed); err != nil { + t.Fatalf("append legacy removal: %v", err) + } + + deadline := time.Now().Add(5 * time.Second) + for { + occurrences, err = chattoCore.NotificationOccurrences().List(ctx, author.Id) + if err == nil && len(occurrences) == 0 { + break + } + if time.Now().After(deadline) { + t.Fatalf("legacy removal left occurrence visible: occurrences=%+v err=%v", occurrences, err) + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestLateNotificationOccurrenceStartsReadWhenCursorAlreadyCoversTarget(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "late-author", "Late Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + reader, err := chattoCore.CreateUser(ctx, SystemActorID, "late-reader", "Late Reader", "password") + if err != nil { + t.Fatalf("CreateUser reader: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "late-notification-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + for _, userID := range []string{author.Id, reader.Id} { + if _, err := chattoCore.JoinRoom(ctx, userID, KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %s: %v", userID, err) + } + } + posted, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "already read", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + if _, err := chattoCore.ReadState().MarkRoomAsRead(ctx, reader.Id, room.Id, posted.Id); err != nil { + t.Fatalf("MarkRoomAsRead: %v", err) + } + postedEntry, ok := chattoCore.roomModel.timelineEntry(posted.Id) + if !ok { + t.Fatal("posted message missing from timeline") + } + + occurrence, created, err := chattoCore.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: reader.Id, + SourceEventID: posted.Id, + // Coverage is causal, not timestamp-based. A skewed future timestamp must + // not turn an already-covered source back into an unread notification. + SourceCreated: posted.GetCreatedAt().AsTime().Add(time.Hour), + SourceStreamSequence: postedEntry.StreamSeq, + ActorID: author.Id, + Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: posted.Id}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + }}, + }) + if err != nil || !created { + t.Fatalf("Create late occurrence = (%v, %v, %v)", occurrence, created, err) + } + if occurrence.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ { + t.Fatalf("late occurrence state = %v, want READ", occurrence.GetInboxState()) + } + if occurrence.GetAlertState() != corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED { + t.Fatalf("late occurrence alert state = %v, want SILENCED", occurrence.GetAlertState()) + } +} + +func TestDuplicateMaterializationPreservesReadState(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "race-author", "Race Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + reader, err := chattoCore.CreateUser(ctx, SystemActorID, "race-reader", "Race Reader", "password") + if err != nil { + t.Fatalf("CreateUser reader: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "race-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + for _, userID := range []string{author.Id, reader.Id} { + if _, err := chattoCore.JoinRoom(ctx, userID, KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %s: %v", userID, err) + } + } + posted, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "race target", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + postedEntry, ok := chattoCore.roomModel.timelineEntry(posted.Id) + if !ok { + t.Fatal("posted message missing from timeline") + } + input := CreateNotificationOccurrenceInput{ + RecipientID: reader.Id, + SourceEventID: posted.Id, + SourceCreated: posted.GetCreatedAt().AsTime(), + SourceStreamSequence: postedEntry.StreamSeq, + ActorID: author.Id, + Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: posted.Id}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + }}, + SkipReadLookup: true, + } + created, wasCreated, err := chattoCore.NotificationOccurrences().Create(ctx, input) + if err != nil || !wasCreated || created.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { + t.Fatalf("Create missed-race occurrence = (%v, %v, %v)", created, wasCreated, err) + } + if _, err := chattoCore.ReadState().MarkRoomAsRead(ctx, reader.Id, room.Id, posted.Id); err != nil { + t.Fatalf("MarkRoomAsRead: %v", err) + } + input.SkipReadLookup = false + reconciled, wasCreated, err := chattoCore.NotificationOccurrences().Create(ctx, input) + if err != nil || wasCreated { + t.Fatalf("duplicate Create = (%v, %v, %v)", reconciled, wasCreated, err) + } + if reconciled.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ { + t.Fatalf("duplicate state = %v, want READ", reconciled.GetInboxState()) + } + if reconciled.GetAlertState() != corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED { + t.Fatalf("duplicate alert state = %v, want SILENCED", reconciled.GetAlertState()) + } +} + +func TestReactionRemovalPreservesCausallyLaterReadd(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + now := time.Now().UTC() + create := func(sourceID string, sequence uint64) *corev1.NotificationOccurrence { + t.Helper() + occurrence, created, err := chattoCore.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: "U-reaction-recipient", + SourceEventID: sourceID, + SourceCreated: now, + SourceStreamSequence: sequence, + ActorID: "U-reaction-actor", + Target: &corev1.NotificationTarget{RoomId: "R-reaction", EventId: "E-message"}, + ReactionEmoji: "thumbsup", + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_REACTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }) + if err != nil || !created { + t.Fatalf("Create(%s) = (%v, %v, %v)", sourceID, occurrence, created, err) + } + return occurrence + } + older := create("E-reaction-before-removal", 100) + later := create("E-reaction-after-removal", 300) + + removed, err := chattoCore.NotificationOccurrences().RemoveReaction( + ctx, + "U-reaction-recipient", + "R-reaction", + "E-message", + "U-reaction-actor", + "thumbsup", + 200, + ) + if err != nil || removed != 1 { + t.Fatalf("RemoveReaction = (%d, %v), want one", removed, err) + } + if _, err := chattoCore.NotificationOccurrences().Get(ctx, "U-reaction-recipient", older.GetId()); !errors.Is(err, ErrNotFound) { + t.Fatalf("older reaction occurrence remains: %v", err) + } + if _, err := chattoCore.NotificationOccurrences().Get(ctx, "U-reaction-recipient", later.GetId()); err != nil { + t.Fatalf("causally later re-add was removed: %v", err) + } +} + +func TestVisibilityBoundaryRejectsDelayedSourceAfterRejoin(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + owner, err := chattoCore.CreateUser(ctx, SystemActorID, "boundary-owner", "Boundary Owner", "password") + if err != nil { + t.Fatalf("CreateUser owner: %v", err) + } + member, err := chattoCore.CreateUser(ctx, SystemActorID, "boundary-member", "Boundary Member", "password") + if err != nil { + t.Fatalf("CreateUser member: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, owner.Id, KindChannel, "", "boundary-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + if _, err := chattoCore.JoinRoom(ctx, member.Id, KindChannel, member.Id, room.Id); err != nil { + t.Fatalf("JoinRoom: %v", err) + } + if err := chattoCore.LeaveRoom(ctx, member.Id, KindChannel, member.Id, room.Id); err != nil { + t.Fatalf("LeaveRoom: %v", err) + } + boundaryEntry, err := chattoCore.storage.runtimeStateKV.Get(ctx, notificationVisibilityBoundaryKey(member.Id, room.Id)) + if err != nil { + t.Fatalf("read visibility boundary: %v", err) + } + boundarySequence := binary.BigEndian.Uint64(boundaryEntry.Value()) + if boundarySequence == 0 { + t.Fatal("visibility boundary sequence is zero") + } + if _, err := chattoCore.JoinRoom(ctx, member.Id, KindChannel, member.Id, room.Id); err != nil { + t.Fatalf("rejoin room: %v", err) + } + + makeSource := func(id string) (*corev1.Event, *corev1.NotificationOccurrence) { + t.Helper() + source := newEvent(owner.Id, &corev1.Event{Event: &corev1.Event_MessagePosted{ + MessagePosted: &corev1.MessagePostedEvent{RoomId: room.Id}, + }}) + source.Id = id + work := newNotificationOccurrenceWork( + source, + &corev1.NotificationTarget{RoomId: room.Id, EventId: source.Id}, + []notificationRecipientDecision{{ + recipientID: member.Id, + reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + }}, + ) + return source, work[0] + } + older, olderWork := makeSource("E-before-leave") + if err := chattoCore.notificationMaterializer.materializeOccurrence(ctx, older, olderWork, boundarySequence); err != nil { + t.Fatalf("materialize older source: %v", err) + } + if occurrences, err := chattoCore.NotificationOccurrences().List(ctx, member.Id); err != nil || len(occurrences) != 0 { + t.Fatalf("older occurrences = (%v, %v), want none", occurrences, err) + } + + newer, newerWork := makeSource("E-after-rejoin") + if err := chattoCore.notificationMaterializer.materializeOccurrence(ctx, newer, newerWork, boundarySequence+1); err != nil { + t.Fatalf("materialize newer source: %v", err) + } + if occurrences, err := chattoCore.NotificationOccurrences().List(ctx, member.Id); err != nil || len(occurrences) != 1 || occurrences[0].GetSourceEventId() != newer.GetId() { + t.Fatalf("newer occurrences = (%v, %v), want newer source", occurrences, err) + } +} + +func TestHistoricalLeaveReplayDoesNotRemoveNotificationsAfterRejoin(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + owner, err := chattoCore.CreateUser(ctx, SystemActorID, "replay-owner", "Replay Owner", "password") + if err != nil { + t.Fatalf("CreateUser owner: %v", err) + } + member, err := chattoCore.CreateUser(ctx, SystemActorID, "replay-member", "Replay Member", "password") + if err != nil { + t.Fatalf("CreateUser member: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, owner.Id, KindChannel, "", "replay-membership-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + if _, err := chattoCore.JoinRoom(ctx, member.Id, KindChannel, member.Id, room.Id); err != nil { + t.Fatalf("JoinRoom: %v", err) + } + leftAt := time.Now().UTC() + if err := chattoCore.LeaveRoom(ctx, member.Id, KindChannel, member.Id, room.Id); err != nil { + t.Fatalf("LeaveRoom: %v", err) + } + if _, err := chattoCore.JoinRoom(ctx, member.Id, KindChannel, member.Id, room.Id); err != nil { + t.Fatalf("rejoin room: %v", err) + } + olderOccurrence, _, err := chattoCore.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: member.Id, + SourceEventID: "E-before-leave", + SourceCreated: leftAt.Add(-time.Second), + ActorID: owner.Id, + SourceStreamSequence: 100, + Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: "E-before-leave"}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }) + if err != nil { + t.Fatalf("Create older occurrence: %v", err) + } + occurrence, _, err := chattoCore.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: member.Id, + SourceEventID: "E-after-rejoin", + SourceCreated: leftAt.Add(time.Second), + ActorID: owner.Id, + SourceStreamSequence: 300, + Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: "E-after-rejoin"}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }) + if err != nil { + t.Fatalf("Create occurrence: %v", err) + } + if isMember, err := chattoCore.RoomMembershipExists(ctx, KindChannel, member.Id, room.Id); err != nil || !isMember { + t.Fatalf("membership after rejoin = %v, %v", isMember, err) + } + if _, err := chattoCore.NotificationOccurrences().Get(ctx, member.Id, occurrence.GetId()); err != nil { + t.Fatalf("notification before historical leave replay: %v", err) + } + + err = chattoCore.notificationMaterializer.materializeEvent(ctx, &corev1.Event{ + Id: "E-old-leave", + ActorId: member.Id, + CreatedAt: timestamppb.New(leftAt), + Event: &corev1.Event_UserLeftRoom{UserLeftRoom: &corev1.UserLeftRoomEvent{ + RoomId: room.Id, + }}, + }, 200, true) + if err != nil { + t.Fatalf("replay historical leave: %v", err) + } + if _, err := chattoCore.NotificationOccurrences().Get(ctx, member.Id, occurrence.GetId()); err != nil { + t.Fatalf("notification after historical leave replay: %v", err) + } + if _, err := chattoCore.NotificationOccurrences().Get(ctx, member.Id, olderOccurrence.GetId()); !errors.Is(err, ErrNotFound) { + t.Fatalf("older notification after historical leave replay = %v, want not found", err) + } +} + +func TestHistoricalNotificationReplaySkipsDeletedRecipient(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + recipient, err := chattoCore.CreateUser(ctx, SystemActorID, "deleted-notification-recipient", "Deleted Recipient", "password") + if err != nil { + t.Fatalf("CreateUser recipient: %v", err) + } + actor, err := chattoCore.CreateUser(ctx, SystemActorID, "deleted-notification-actor", "Notification Actor", "password") + if err != nil { + t.Fatalf("CreateUser actor: %v", err) + } + if err := chattoCore.DeleteUser(ctx, recipient.Id, recipient.Id); err != nil { + t.Fatalf("DeleteUser recipient: %v", err) + } + + source := &corev1.Event{ + Id: "E-before-account-deletion", + ActorId: actor.Id, + CreatedAt: timestamppb.Now(), + Event: &corev1.Event_MessagePosted{MessagePosted: &corev1.MessagePostedEvent{ + RoomId: "R-deleted-recipient", + }}, + } + work := newNotificationOccurrenceWork( + source, + &corev1.NotificationTarget{RoomId: "R-deleted-recipient", EventId: source.GetId()}, + []notificationRecipientDecision{{ + recipientID: recipient.Id, + reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + }}, + ) + if err := chattoCore.notificationMaterializer.StoreWork(ctx, source, work); err != nil { + t.Fatalf("StoreWork: %v", err) + } + err = chattoCore.notificationMaterializer.materializeEvent(ctx, source, 1, true) + if err != nil { + t.Fatalf("replay notification source after account deletion: %v", err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id) + if err != nil { + t.Fatalf("List occurrences: %v", err) + } + if len(occurrences) != 0 { + t.Fatalf("occurrences after deleted-recipient replay = %d, want 0", len(occurrences)) + } +} + +func TestHistoricalNotificationReplaySkipsDeletedRoom(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + recipient, err := chattoCore.CreateUser(ctx, SystemActorID, "deleted-room-recipient", "Deleted Room Recipient", "password") + if err != nil { + t.Fatalf("CreateUser recipient: %v", err) + } + actor, err := chattoCore.CreateUser(ctx, SystemActorID, "deleted-room-actor", "Deleted Room Actor", "password") + if err != nil { + t.Fatalf("CreateUser actor: %v", err) + } + + source := &corev1.Event{ + Id: "E-in-deleted-room", + ActorId: actor.Id, + CreatedAt: timestamppb.Now(), + Event: &corev1.Event_MessagePosted{MessagePosted: &corev1.MessagePostedEvent{ + RoomId: "R-already-deleted", + }}, + } + work := newNotificationOccurrenceWork( + source, + &corev1.NotificationTarget{RoomId: "R-already-deleted", EventId: source.GetId()}, + []notificationRecipientDecision{{ + recipientID: recipient.Id, + reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + }}, + ) + if err := chattoCore.notificationMaterializer.StoreWork(ctx, source, work); err != nil { + t.Fatalf("StoreWork: %v", err) + } + err = chattoCore.notificationMaterializer.materializeEvent(ctx, source, 1, true) + if err != nil { + t.Fatalf("replay notification source after room deletion: %v", err) + } + if occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id); err != nil || len(occurrences) != 0 { + t.Fatalf("occurrences after deleted-room replay = (%v, %v), want none", occurrences, err) + } +} + +func TestDelayedMessageNotificationRetryDoesNotOutrunRetraction(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "retracted-notification-author", "Retracted Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + recipient, err := chattoCore.CreateUser(ctx, SystemActorID, "retracted-notification-recipient", "Retracted Recipient", "password") + if err != nil { + t.Fatalf("CreateUser recipient: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "retracted-notification-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + if _, err := chattoCore.JoinRoom(ctx, recipient.Id, KindChannel, recipient.Id, room.Id); err != nil { + t.Fatalf("JoinRoom: %v", err) + } + posted, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "@retracted-notification-recipient hello", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + if _, err := chattoCore.NotificationOccurrences().PurgeUser(ctx, recipient.Id); err != nil { + t.Fatalf("PurgeUser: %v", err) + } + if err := chattoCore.DeleteMessage(ctx, author.Id, KindChannel, room.Id, posted.Id); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + work := newNotificationOccurrenceWork( + posted, + &corev1.NotificationTarget{RoomId: room.Id, EventId: posted.Id}, + []notificationRecipientDecision{{ + recipientID: recipient.Id, + reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + }}, + }}, + ) + if err := chattoCore.notificationMaterializer.StoreWork(ctx, posted, work); err != nil { + t.Fatalf("StoreWork: %v", err) + } + if err := chattoCore.notificationMaterializer.materializeEvent(ctx, posted, 100, true); err != nil { + t.Fatalf("retry message materialization: %v", err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id) + if err != nil || len(occurrences) != 0 { + t.Fatalf("occurrences after delayed retracted retry = (%+v, %v), want empty", occurrences, err) + } +} + +func TestDelayedReactionNotificationRetryDoesNotOutrunRemoval(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "removed-reaction-author", "Reaction Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + reactor, err := chattoCore.CreateUser(ctx, SystemActorID, "removed-reaction-actor", "Reaction Actor", "password") + if err != nil { + t.Fatalf("CreateUser reactor: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "removed-reaction-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + for _, userID := range []string{author.Id, reactor.Id} { + if _, err := chattoCore.JoinRoom(ctx, userID, KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %s: %v", userID, err) + } + } + posted, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "react here", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + input := ReactionMutationInput{ActorID: reactor.Id, RoomID: room.Id, MessageEventID: posted.Id, Emoji: "thumbsup"} + if added, err := chattoCore.ReactionModel().AddReaction(ctx, input); err != nil || !added { + t.Fatalf("AddReaction = (%v, %v), want added", added, err) + } + snapshot := chattoCore.roomModel.reactionMutationSnapshot(room.Id, posted.Id, "thumbsup", reactor.Id) + if snapshot.SourceEventID == "" { + t.Fatal("reaction source event ID is empty") + } + if occurrences, err := chattoCore.NotificationOccurrences().List(ctx, author.Id); err != nil || len(occurrences) != 1 { + t.Fatalf("reaction notification occurrences = (%d, %v), want one", len(occurrences), err) + } + if _, err := chattoCore.NotificationOccurrences().PurgeUser(ctx, author.Id); err != nil { + t.Fatalf("PurgeUser: %v", err) + } + if removed, err := chattoCore.ReactionModel().RemoveReaction(ctx, input); err != nil || !removed { + t.Fatalf("RemoveReaction = (%v, %v), want removed", removed, err) + } + addEvent := newReactionAddedEvent(reactor.Id, room.Id, posted.Id, "thumbsup") + addEvent.Id = snapshot.SourceEventID + addEvent.CreatedAt = timestamppb.Now() + decision := notificationRecipientDecision{ + recipientID: author.Id, + reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_REACTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + }}, + } + work := newNotificationOccurrenceWork( + addEvent, + &corev1.NotificationTarget{RoomId: room.Id, EventId: posted.Id}, + []notificationRecipientDecision{decision}, + ) + if err := chattoCore.notificationMaterializer.StoreWork(ctx, addEvent, work); err != nil { + t.Fatalf("StoreWork: %v", err) + } + if err := chattoCore.notificationMaterializer.materializeEvent(ctx, addEvent, 100, true); err != nil { + t.Fatalf("retry reaction materialization: %v", err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, author.Id) + if err != nil || len(occurrences) != 0 { + t.Fatalf("occurrences after delayed removed-reaction retry = (%+v, %v), want empty", occurrences, err) + } +} diff --git a/cli/internal/core/notification_occurrence_index.go b/cli/internal/core/notification_occurrence_index.go new file mode 100644 index 000000000..7a8146483 --- /dev/null +++ b/cli/internal/core/notification_occurrence_index.go @@ -0,0 +1,495 @@ +package core + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/charmbracelet/log" + "github.com/nats-io/nats.go/jetstream" + "google.golang.org/protobuf/proto" + + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +const notificationOccurrenceWatchFilter = "notification_v2.>" + +type notificationOccurrenceIndexEntry struct { + key string + revision uint64 + deleted bool + occurrence *corev1.NotificationOccurrence +} + +// NotificationOccurrenceIndex mirrors the versioned notification occurrence +// keyspace through one process-wide RUNTIME_STATE watcher. KV remains the +// authority; this index makes user lists, counts, and realtime reconciliation +// finite in-memory reads rather than one key scan per request or socket. +type NotificationOccurrenceIndex struct { + kv jetstream.KeyValue + logger *log.Logger + + mu sync.RWMutex + entriesByUser map[string]map[string]notificationOccurrenceIndexEntry + alertEntries map[string]notificationOccurrenceIndexEntry + keyRevisions map[string]uint64 + // observedRevision includes non-occurrence markers delivered by the same + // ordered watcher and is therefore usable as a local KV read fence. + observedRevision uint64 + changed chan struct{} + ready chan struct{} + readyOnce sync.Once + resyncRequests chan chan error +} + +func NewNotificationOccurrenceIndex(kv jetstream.KeyValue, logger *log.Logger) *NotificationOccurrenceIndex { + return &NotificationOccurrenceIndex{ + kv: kv, + logger: logger, + entriesByUser: make(map[string]map[string]notificationOccurrenceIndexEntry), + alertEntries: make(map[string]notificationOccurrenceIndexEntry), + keyRevisions: make(map[string]uint64), + changed: make(chan struct{}), + ready: make(chan struct{}), + resyncRequests: make(chan chan error), + } +} + +func (i *NotificationOccurrenceIndex) Run(ctx context.Context) error { + if i.logger != nil { + i.logger.Debug("Notification occurrence index started") + defer i.logger.Debug("Notification occurrence index stopped") + } + + var pendingResync chan error + for { + watcher, err := i.kv.Watch(ctx, notificationOccurrenceWatchFilter) + if err != nil { + if pendingResync != nil { + select { + case <-ctx.Done(): + pendingResync <- ctx.Err() + return ctx.Err() + case <-time.After(natsRecoveryRetryWait): + continue + } + } + return fmt.Errorf("notification occurrence index: create watcher: %w", err) + } + + restart := false + for !restart { + var resyncRequests <-chan chan error + if pendingResync == nil { + resyncRequests = i.resyncRequests + } + select { + case <-ctx.Done(): + watcher.Stop() + if pendingResync != nil { + pendingResync <- ctx.Err() + } + return ctx.Err() + case pendingResync = <-resyncRequests: + i.resetSnapshot() + restart = true + case entry, ok := <-watcher.Updates(): + if !ok { + watcher.Stop() + if err := ctx.Err(); err != nil { + return err + } + return fmt.Errorf("notification occurrence index: watcher stopped") + } + if entry == nil { + i.readyOnce.Do(func() { close(i.ready) }) + if pendingResync != nil { + pendingResync <- nil + pendingResync = nil + } + if i.logger != nil { + i.logger.Debug("Notification occurrence index sync complete", "occurrences", i.entryCount()) + } + continue + } + i.apply(entry) + } + } + watcher.Stop() + } +} + +func (i *NotificationOccurrenceIndex) WaitReady(ctx context.Context) error { + select { + case <-i.ready: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (i *NotificationOccurrenceIndex) Resync(ctx context.Context) error { + done := make(chan error, 1) + select { + case i.resyncRequests <- done: + case <-ctx.Done(): + return ctx.Err() + } + select { + case err := <-done: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (i *NotificationOccurrenceIndex) resetSnapshot() { + i.mu.Lock() + defer i.mu.Unlock() + i.entriesByUser = make(map[string]map[string]notificationOccurrenceIndexEntry) + i.alertEntries = make(map[string]notificationOccurrenceIndexEntry) + i.keyRevisions = make(map[string]uint64) + i.observedRevision = 0 + close(i.changed) + i.changed = make(chan struct{}) +} + +func (i *NotificationOccurrenceIndex) apply(entry jetstream.KeyValueEntry) { + userID, _, ok := parseNotificationOccurrenceKey(entry.Key()) + if !ok { + i.advanceObservedRevision(entry.Revision()) + return + } + + indexed := notificationOccurrenceIndexEntry{ + key: entry.Key(), + revision: entry.Revision(), + deleted: entry.Operation() == jetstream.KeyValueDelete || + entry.Operation() == jetstream.KeyValuePurge, + } + if !indexed.deleted { + var occurrence corev1.NotificationOccurrence + if err := proto.Unmarshal(entry.Value(), &occurrence); err != nil { + if i.logger != nil { + i.logger.Warn("Ignoring malformed notification occurrence", "key", entry.Key(), "error", err) + } + } else if occurrence.GetRecipientId() != userID { + if i.logger != nil { + i.logger.Warn("Ignoring notification occurrence with mismatched recipient", "key", entry.Key()) + } + } else { + indexed.occurrence = &occurrence + } + } + + i.mu.Lock() + defer i.mu.Unlock() + if entry.Revision() <= i.keyRevisions[entry.Key()] { + if entry.Revision() > i.observedRevision { + i.observedRevision = entry.Revision() + close(i.changed) + i.changed = make(chan struct{}) + } + return + } + i.observedRevision = max(i.observedRevision, entry.Revision()) + if indexed.deleted || indexed.occurrence == nil { + delete(i.keyRevisions, entry.Key()) + delete(i.alertEntries, entry.Key()) + if entries := i.entriesByUser[userID]; entries != nil { + delete(entries, entry.Key()) + if len(entries) == 0 { + delete(i.entriesByUser, userID) + } + } + close(i.changed) + i.changed = make(chan struct{}) + return + } + i.keyRevisions[entry.Key()] = entry.Revision() + if i.entriesByUser[userID] == nil { + i.entriesByUser[userID] = make(map[string]notificationOccurrenceIndexEntry) + } + i.entriesByUser[userID][entry.Key()] = indexed + if isNotificationAlertCandidate(indexed.occurrence) { + i.alertEntries[entry.Key()] = indexed + } else { + delete(i.alertEntries, entry.Key()) + } + close(i.changed) + i.changed = make(chan struct{}) +} + +func (i *NotificationOccurrenceIndex) advanceObservedRevision(revision uint64) { + i.mu.Lock() + defer i.mu.Unlock() + if revision <= i.observedRevision { + return + } + i.observedRevision = revision + close(i.changed) + i.changed = make(chan struct{}) +} + +func (i *NotificationOccurrenceIndex) userEntries(ctx context.Context, userID string) ([]notificationOccurrenceIndexEntry, error) { + if err := i.WaitReady(ctx); err != nil { + return nil, err + } + i.mu.Lock() + i.pruneExpiredUserLocked(userID, time.Now().UTC()) + entries := make([]notificationOccurrenceIndexEntry, 0, len(i.entriesByUser[userID])) + for _, entry := range i.entriesByUser[userID] { + if entry.deleted || entry.occurrence == nil { + continue + } + entry.occurrence = proto.Clone(entry.occurrence).(*corev1.NotificationOccurrence) + entries = append(entries, entry) + } + i.mu.Unlock() + return entries, nil +} + +func (i *NotificationOccurrenceIndex) allEntries(ctx context.Context) ([]notificationOccurrenceIndexEntry, error) { + if err := i.WaitReady(ctx); err != nil { + return nil, err + } + i.mu.Lock() + i.pruneExpiredLocked(time.Now().UTC()) + entries := make([]notificationOccurrenceIndexEntry, 0) + for _, userEntries := range i.entriesByUser { + for _, entry := range userEntries { + if entry.deleted || entry.occurrence == nil { + continue + } + entry.occurrence = proto.Clone(entry.occurrence).(*corev1.NotificationOccurrence) + entries = append(entries, entry) + } + } + i.mu.Unlock() + return entries, nil +} + +func (i *NotificationOccurrenceIndex) alertCandidates(ctx context.Context) ([]notificationOccurrenceIndexEntry, error) { + if err := i.WaitReady(ctx); err != nil { + return nil, err + } + i.mu.Lock() + now := time.Now().UTC() + entries := make([]notificationOccurrenceIndexEntry, 0, len(i.alertEntries)) + for key, entry := range i.alertEntries { + if entry.deleted || entry.occurrence == nil { + continue + } + if expiresAt := entry.occurrence.GetExpiresAt(); expiresAt != nil && !expiresAt.AsTime().After(now) { + delete(i.alertEntries, key) + continue + } + entry.occurrence = proto.Clone(entry.occurrence).(*corev1.NotificationOccurrence) + entries = append(entries, entry) + } + i.mu.Unlock() + return entries, nil +} + +func isNotificationAlertCandidate(occurrence *corev1.NotificationOccurrence) bool { + if occurrence == nil || occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED || + occurrence.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD || + occurrence.GetStrongestIntensity() != corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT { + return false + } + return occurrence.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_PENDING || + occurrence.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED +} + +func (i *NotificationOccurrenceIndex) occurrenceByID(ctx context.Context, userID, occurrenceID string) (notificationOccurrenceIndexEntry, bool, error) { + entries, err := i.userEntries(ctx, userID) + if err != nil { + return notificationOccurrenceIndexEntry{}, false, err + } + for _, entry := range entries { + if entry.occurrence.GetId() == occurrenceID { + return entry, true, nil + } + } + return notificationOccurrenceIndexEntry{}, false, nil +} + +func (i *NotificationOccurrenceIndex) occurrenceBySource(ctx context.Context, userID, sourceEventID string) (notificationOccurrenceIndexEntry, bool, error) { + if err := i.WaitReady(ctx); err != nil { + return notificationOccurrenceIndexEntry{}, false, err + } + key := notificationOccurrenceKey(userID, sourceEventID) + i.mu.Lock() + i.pruneExpiredUserLocked(userID, time.Now().UTC()) + entry, ok := i.entriesByUser[userID][key] + i.mu.Unlock() + if !ok || entry.deleted || entry.occurrence == nil { + return notificationOccurrenceIndexEntry{}, false, nil + } + entry.occurrence = proto.Clone(entry.occurrence).(*corev1.NotificationOccurrence) + return entry, true, nil +} + +func (i *NotificationOccurrenceIndex) pruneExpiredLocked(now time.Time) { + changed := false + for userID := range i.entriesByUser { + changed = i.pruneExpiredUserEntriesLocked(userID, now) || changed + } + if changed { + close(i.changed) + i.changed = make(chan struct{}) + } +} + +func (i *NotificationOccurrenceIndex) pruneExpiredUserLocked(userID string, now time.Time) { + if i.pruneExpiredUserEntriesLocked(userID, now) { + close(i.changed) + i.changed = make(chan struct{}) + } +} + +func (i *NotificationOccurrenceIndex) pruneExpiredUserEntriesLocked(userID string, now time.Time) bool { + entries := i.entriesByUser[userID] + changed := false + for key, entry := range entries { + if entry.deleted || entry.occurrence == nil { + continue + } + expiresAt := entry.occurrence.GetExpiresAt() + if expiresAt == nil || expiresAt.AsTime().After(now) { + continue + } + delete(entries, key) + delete(i.keyRevisions, key) + delete(i.alertEntries, key) + changed = true + } + if len(entries) == 0 { + delete(i.entriesByUser, userID) + } + return changed +} + +func (i *NotificationOccurrenceIndex) waitForRevision(ctx context.Context, key string, revision uint64) error { + if err := i.WaitReady(ctx); err != nil { + return err + } + for { + i.mu.RLock() + current := i.keyRevisions[key] + changed := i.changed + i.mu.RUnlock() + if current >= revision { + return nil + } + if current == 0 { + gone, err := i.authoritativeRevisionGone(ctx, key) + if err != nil { + return err + } + if gone { + // Expiry or purge is already authoritative. There is no live + // occurrence left for a realtime assembler to wait for. + return nil + } + } + select { + case <-changed: + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (i *NotificationOccurrenceIndex) waitForRevisionAfter(ctx context.Context, key string, revision uint64) error { + if err := i.WaitReady(ctx); err != nil { + return err + } + for { + i.mu.RLock() + current := i.keyRevisions[key] + changed := i.changed + i.mu.RUnlock() + if current > revision { + return nil + } + if current == 0 { + gone, err := i.authoritativeRevisionGone(ctx, key) + if err != nil { + return err + } + if gone { + return nil + } + } + select { + case <-changed: + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (i *NotificationOccurrenceIndex) waitForObservedRevision(ctx context.Context, revision uint64) error { + if revision == 0 { + return nil + } + if err := i.WaitReady(ctx); err != nil { + return err + } + for { + i.mu.RLock() + current := i.observedRevision + changed := i.changed + i.mu.RUnlock() + if current >= revision { + return nil + } + select { + case <-changed: + case <-ctx.Done(): + return ctx.Err() + } + } +} + +func (i *NotificationOccurrenceIndex) authoritativeRevisionGone(ctx context.Context, key string) (bool, error) { + entry, err := i.kv.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + return true, nil + } + if err != nil { + return false, err + } + var occurrence corev1.NotificationOccurrence + if err := proto.Unmarshal(entry.Value(), &occurrence); err != nil { + return false, fmt.Errorf("decode notification occurrence revision fence: %w", err) + } + expiresAt := occurrence.GetExpiresAt() + return expiresAt != nil && !expiresAt.AsTime().After(time.Now().UTC()), nil +} + +func (i *NotificationOccurrenceIndex) entryCount() int { + i.mu.RLock() + defer i.mu.RUnlock() + count := 0 + for _, entries := range i.entriesByUser { + for _, entry := range entries { + if !entry.deleted && entry.occurrence != nil { + count++ + } + } + } + return count +} + +func parseNotificationOccurrenceKey(key string) (userID, sourceEventID string, ok bool) { + parts := strings.Split(key, ".") + if len(parts) != 3 || parts[0] != "notification_v2" || parts[1] == "" || parts[2] == "" { + return "", "", false + } + return parts[1], parts[2], true +} diff --git a/cli/internal/core/notification_occurrence_live.go b/cli/internal/core/notification_occurrence_live.go new file mode 100644 index 000000000..cb8edaad9 --- /dev/null +++ b/cli/internal/core/notification_occurrence_live.go @@ -0,0 +1,40 @@ +package core + +import ( + "context" + + "hmans.de/chatto/internal/core/subjects" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +func (c *ChattoCore) publishNotificationOccurrenceChanged(ctx context.Context, occurrence *corev1.NotificationOccurrence, created, deleted bool) { + if c == nil || occurrence == nil || occurrence.GetRecipientId() == "" { + return + } + alert := created && occurrence.GetStrongestIntensity() == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT && + occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD && + occurrence.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_PENDING && + !c.suppressesNotificationAlertsForPresence(ctx, occurrence.GetRecipientId()) + revision := uint64(0) + if entry, exists, err := c.notificationOccurrences.index.occurrenceBySource(ctx, occurrence.GetRecipientId(), occurrence.GetSourceEventId()); err == nil && exists { + revision = entry.revision + } + event := newLiveEvent(occurrence.GetActorId(), &corev1.LiveEvent{ + Event: &corev1.LiveEvent_NotificationOccurrenceChanged{ + NotificationOccurrenceChanged: &corev1.NotificationOccurrenceChangedEvent{ + NotificationId: occurrence.GetId(), + Created: created, + Deleted: deleted, + Alert: alert, + SourceEventId: occurrence.GetSourceEventId(), + RuntimeStateRevision: revision, + }, + }, + }) + if err := c.publishLiveEvent(ctx, subjects.LiveSyncUserEvent(occurrence.GetRecipientId(), "notification_v2"), event); err != nil { + c.logger.Warn("Failed to publish notification occurrence invalidation", + "notification_id", occurrence.GetId(), + "recipient_id", occurrence.GetRecipientId(), + "error", err) + } +} diff --git a/cli/internal/core/notification_occurrence_model.go b/cli/internal/core/notification_occurrence_model.go new file mode 100644 index 000000000..b7fd7cd6b --- /dev/null +++ b/cli/internal/core/notification_occurrence_model.go @@ -0,0 +1,1054 @@ +package core + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "slices" + "sort" + "strings" + "time" + + "github.com/charmbracelet/log" + "github.com/nats-io/nats.go/jetstream" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "hmans.de/chatto/internal/evtstream" + "hmans.de/chatto/internal/jetstreamutil" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +const ( + notificationOccurrenceKeyPrefix = "notification_v2." + maxNotificationUpdateRetries = 8 + notificationAlertClaimTTL = 30 * time.Second + notificationAlertDeliveryTTL = 2 * time.Minute + notificationAlertRetryDelay = 30 * time.Second +) + +type CreateNotificationOccurrenceInput struct { + RecipientID string + SourceEventID string + SourceCreated time.Time + ActorID string + Target *corev1.NotificationTarget + Reasons []*corev1.NotificationReasonMatch + ReactionEmoji string + SourceStreamSequence uint64 + EvaluatedAt time.Time + InitialState corev1.NotificationInboxState + SkipReadLookup bool +} + +type NotificationOccurrenceGroup struct { + ID string + Key string + Occurrences []*corev1.NotificationOccurrence +} + +// WaitCurrent waits until the sole durable occurrence/lifecycle writer has +// processed every notification-relevant EVT fact visible at a captured +// boundary. Exhaustive counts and group metadata should read after this fence. +func (m *NotificationOccurrenceModel) WaitCurrent(ctx context.Context) error { + return m.core.notificationMaterializer.WaitCurrent(ctx) +} + +// NotificationOccurrenceModel owns the versioned occurrence keyspace, its +// process-wide watcher index, and every recipient triage mutation. +type NotificationOccurrenceModel struct { + core *ChattoCore + kv jetstream.KeyValue + index *NotificationOccurrenceIndex + logger *log.Logger + now func() time.Time +} + +func NewNotificationOccurrenceModel(core *ChattoCore, kv jetstream.KeyValue, logger *log.Logger) *NotificationOccurrenceModel { + return &NotificationOccurrenceModel{ + core: core, + kv: kv, + index: NewNotificationOccurrenceIndex(kv, logger.WithPrefix("Index")), + logger: logger, + now: time.Now, + } +} + +func (c *ChattoCore) NotificationOccurrences() *NotificationOccurrenceModel { + return c.notificationOccurrences +} + +func (m *NotificationOccurrenceModel) Run(ctx context.Context) error { + return m.index.Run(ctx) +} + +func (m *NotificationOccurrenceModel) WaitReady(ctx context.Context) error { + return m.index.WaitReady(ctx) +} + +func (m *NotificationOccurrenceModel) Resync(ctx context.Context) error { + return m.index.Resync(ctx) +} + +// WaitForSourceRevision fences a local index before a realtime replacement is +// assembled on a replica other than the one that wrote the occurrence. +func (m *NotificationOccurrenceModel) WaitForSourceRevision(ctx context.Context, recipientID, sourceEventID string, revision uint64) error { + if revision == 0 || recipientID == "" || sourceEventID == "" { + return nil + } + return m.index.waitForRevision(ctx, notificationOccurrenceKey(recipientID, sourceEventID), revision) +} + +func notificationOccurrenceKey(recipientID, sourceEventID string) string { + return notificationOccurrenceKeyPrefix + recipientID + "." + sourceEventID +} + +func notificationOccurrenceID(recipientID, sourceEventID string) string { + digest := sha256.Sum256([]byte(recipientID + "\x00" + sourceEventID)) + return "ntf_" + base64.RawURLEncoding.EncodeToString(digest[:20]) +} + +func notificationGroupID(recipientID, groupKey string) string { + digest := sha256.Sum256([]byte(recipientID + "\x00" + groupKey)) + return "ntg_" + base64.RawURLEncoding.EncodeToString(digest[:20]) +} + +func (m *NotificationOccurrenceModel) Create(ctx context.Context, input CreateNotificationOccurrenceInput) (*corev1.NotificationOccurrence, bool, error) { + if strings.TrimSpace(input.RecipientID) == "" || strings.TrimSpace(input.SourceEventID) == "" { + return nil, false, invalidArgument("recipient_id and source_event_id are required") + } + if input.Target == nil || input.Target.GetRoomId() == "" || input.Target.GetEventId() == "" { + return nil, false, invalidArgument("notification target room_id and event_id are required") + } + if input.SourceCreated.IsZero() { + return nil, false, invalidArgument("source_created_at is required") + } + + reasons := normalizeNotificationReasons(input.Reasons) + strongest := strongestNotificationIntensity(reasons) + if strongest == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF || + strongest == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED { + return nil, false, nil + } + + now := m.now().UTC() + expiresAt := input.SourceCreated.UTC().Add(notificationTTL) + remaining := expiresAt.Sub(now) + if remaining <= 0 { + return nil, false, nil + } + evaluatedAt := input.EvaluatedAt.UTC() + if evaluatedAt.IsZero() { + evaluatedAt = now + } + state := input.InitialState + if state == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNSPECIFIED { + state = corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD + } + alertState := corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_NOT_APPLICABLE + if strongest == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT { + // UNSPECIFIED is a persisted initialization fence. Alert claimers ignore + // it until the authoritative read-boundary check below finalizes the row. + alertState = corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_UNSPECIFIED + } + occurrence := &corev1.NotificationOccurrence{ + Id: notificationOccurrenceID(input.RecipientID, input.SourceEventID), + RecipientId: input.RecipientID, + SourceEventId: input.SourceEventID, + SourceCreatedAt: timestamppb.New(input.SourceCreated.UTC()), + ActorId: input.ActorID, + Target: proto.Clone(input.Target).(*corev1.NotificationTarget), + Reasons: reasons, + ReactionEmoji: input.ReactionEmoji, + SourceStreamSequence: input.SourceStreamSequence, + StrongestIntensity: strongest, + InboxState: state, + EvaluatedAt: timestamppb.New(evaluatedAt), + ExpiresAt: timestamppb.New(expiresAt), + AlertState: alertState, + } + data, err := proto.Marshal(occurrence) + if err != nil { + return nil, false, fmt.Errorf("marshal notification occurrence: %w", err) + } + key := notificationOccurrenceKey(input.RecipientID, input.SourceEventID) + revision, err := m.kv.Create(ctx, key, data, jetstream.KeyTTL(remaining)) + if jetstreamutil.IsSequenceConflict(err) { + existing, exists, readErr := m.storedOccurrenceBySource(ctx, input.RecipientID, input.SourceEventID) + if readErr != nil || !exists { + return nil, false, readErr + } + if existing.occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { + return nil, false, nil + } + existingOccurrence, changed, reconcileErr := m.finalizeOccurrence(ctx, existing.occurrence, input.SkipReadLookup) + if changed { + m.core.publishNotificationOccurrenceChanged(ctx, existingOccurrence, false, false) + } + return existingOccurrence, false, reconcileErr + } + if err != nil { + return nil, false, fmt.Errorf("create notification occurrence: %w", err) + } + if err := m.index.waitForRevision(ctx, key, revision); err != nil { + return nil, false, fmt.Errorf("wait for notification occurrence: %w", err) + } + occurrence, _, err = m.finalizeOccurrence(ctx, occurrence, input.SkipReadLookup) + if err != nil { + return nil, true, fmt.Errorf("finalize created notification occurrence: %w", err) + } + m.logger.Debug("Notification occurrence created", + "notification_id", occurrence.GetId(), + "recipient_id", input.RecipientID, + "source_event_id", input.SourceEventID, + "intensity", strongest.String(), + ) + m.core.publishNotificationOccurrenceChanged(ctx, occurrence, true, false) + return proto.Clone(occurrence).(*corev1.NotificationOccurrence), true, nil +} + +// finalizeOccurrence closes the cross-replica race between a read action and +// occurrence creation. UpdatedAt is intentionally absent on the initial KV +// row, so a durable redelivery may finish interrupted initialization without +// reapplying read state after initialization has completed. +func (m *NotificationOccurrenceModel) finalizeOccurrence(ctx context.Context, occurrence *corev1.NotificationOccurrence, skipReadLookup bool) (*corev1.NotificationOccurrence, bool, error) { + if occurrence == nil { + return nil, false, nil + } + for attempt := 0; attempt < maxNotificationUpdateRetries; attempt++ { + entry, exists, err := m.storedOccurrenceBySource(ctx, occurrence.GetRecipientId(), occurrence.GetSourceEventId()) + if err != nil || !exists { + return nil, false, err + } + if entry.occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED || + entry.occurrence.GetUpdatedAt() != nil { + return entry.occurrence, false, nil + } + updated := proto.Clone(entry.occurrence).(*corev1.NotificationOccurrence) + if !skipReadLookup && updated.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { + covered, err := m.occurrenceCoveredByReadBoundary(ctx, updated) + if err != nil { + return nil, false, err + } + if covered { + updated.InboxState = corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ + } + } + if updated.GetStrongestIntensity() == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT { + updated.AlertState = corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_PENDING + if updated.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { + updated.AlertState = corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED + } + } + updated.UpdatedAt = timestamppb.New(m.now().UTC()) + written, err := m.updateAtRevision(ctx, entry, updated) + if jetstreamutil.IsSequenceConflict(err) { + continue + } + return written, err == nil, err + } + return nil, false, fmt.Errorf("notification occurrence finalization failed after %d retries", maxNotificationUpdateRetries) +} + +func (m *NotificationOccurrenceModel) Get(ctx context.Context, userID, occurrenceID string) (*corev1.NotificationOccurrence, error) { + entry, exists, err := m.index.occurrenceByID(ctx, userID, occurrenceID) + if err != nil { + return nil, err + } + if !exists || entry.occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { + return nil, ErrNotFound + } + return entry.occurrence, nil +} + +func (m *NotificationOccurrenceModel) List(ctx context.Context, userID string) ([]*corev1.NotificationOccurrence, error) { + entries, err := m.index.userEntries(ctx, userID) + if err != nil { + return nil, err + } + result := make([]*corev1.NotificationOccurrence, 0, len(entries)) + for _, entry := range entries { + occurrence := entry.occurrence + if occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { + continue + } + if occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD || + occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ || + occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE { + result = append(result, occurrence) + } + } + sort.Slice(result, func(a, b int) bool { + return result[a].GetSourceCreatedAt().AsTime().After(result[b].GetSourceCreatedAt().AsTime()) + }) + return result, nil +} + +func (m *NotificationOccurrenceModel) Groups(ctx context.Context, userID string) ([]NotificationOccurrenceGroup, error) { + occurrences, err := m.List(ctx, userID) + if err != nil { + return nil, err + } + grouped := make(map[string][]*corev1.NotificationOccurrence) + for _, occurrence := range occurrences { + key := notificationOccurrenceGroupKey(occurrence) + grouped[key] = append(grouped[key], occurrence) + } + groups := make([]NotificationOccurrenceGroup, 0, len(grouped)) + for key, members := range grouped { + groups = append(groups, NotificationOccurrenceGroup{ + ID: notificationGroupID(userID, key), + Key: key, + Occurrences: members, + }) + } + sort.Slice(groups, func(a, b int) bool { + return groups[a].Occurrences[0].GetSourceCreatedAt().AsTime().After(groups[b].Occurrences[0].GetSourceCreatedAt().AsTime()) + }) + return groups, nil +} + +// UnreadGroupCount returns the bell/app-badge count. Multiple unread +// occurrences in one conversation group count once. +func (m *NotificationOccurrenceModel) UnreadGroupCount(ctx context.Context, userID string) (int, error) { + groups, err := m.Groups(ctx, userID) + if err != nil { + return 0, err + } + count := 0 + for _, group := range groups { + for _, occurrence := range group.Occurrences { + if occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { + count++ + break + } + } + } + return count, nil +} + +func (m *NotificationOccurrenceModel) MarkRead(ctx context.Context, userID, occurrenceID string) (*corev1.NotificationOccurrence, error) { + for attempt := 0; attempt < maxNotificationUpdateRetries; attempt++ { + entry, exists, err := m.index.occurrenceByID(ctx, userID, occurrenceID) + if err != nil { + return nil, err + } + if !exists || entry.occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { + return nil, ErrNotFound + } + updated := proto.Clone(entry.occurrence).(*corev1.NotificationOccurrence) + updated.InboxState = corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ + if updated.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_PENDING || + updated.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED { + updated.AlertState = corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED + updated.AlertClaimedUntil = nil + } + if proto.Equal(updated, entry.occurrence) { + return updated, nil + } + updated.UpdatedAt = timestamppb.New(m.now().UTC()) + written, err := m.updateAtRevision(ctx, entry, updated) + if jetstreamutil.IsSequenceConflict(err) { + if waitErr := m.index.waitForRevisionAfter(ctx, entry.key, entry.revision); waitErr != nil { + return nil, waitErr + } + continue + } + if err == nil { + m.core.publishNotificationOccurrenceChanged(ctx, written, false, false) + } + return written, err + } + return nil, fmt.Errorf("notification occurrence update failed after %d retries", maxNotificationUpdateRetries) +} + +func (m *NotificationOccurrenceModel) Delete(ctx context.Context, userID, occurrenceID string, reason corev1.NotificationRemovalReason) (bool, error) { + return m.delete(ctx, userID, occurrenceID, reason, true) +} + +func (m *NotificationOccurrenceModel) delete(ctx context.Context, userID, occurrenceID string, reason corev1.NotificationRemovalReason, publish bool) (bool, error) { + if reason == corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { + reason = corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_DELETED + } + for attempt := 0; attempt < maxNotificationUpdateRetries; attempt++ { + entry, exists, err := m.index.occurrenceByID(ctx, userID, occurrenceID) + if err != nil { + return false, err + } + if !exists { + return false, nil + } + if entry.occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { + return false, nil + } + now := m.now().UTC() + tombstone := &corev1.NotificationOccurrence{ + Id: entry.occurrence.GetId(), + RecipientId: entry.occurrence.GetRecipientId(), + SourceEventId: entry.occurrence.GetSourceEventId(), + SourceCreatedAt: entry.occurrence.GetSourceCreatedAt(), + InboxState: corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ, + UpdatedAt: timestamppb.New(now), + ExpiresAt: entry.occurrence.GetExpiresAt(), + RemovalReason: reason, + RemovedAt: timestamppb.New(now), + AlertState: corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_NOT_APPLICABLE, + } + written, err := m.updateAtRevision(ctx, entry, tombstone) + if jetstreamutil.IsSequenceConflict(err) { + if waitErr := m.index.waitForRevisionAfter(ctx, entry.key, entry.revision); waitErr != nil { + return false, waitErr + } + continue + } + if err == nil && publish { + m.core.publishNotificationOccurrenceChanged(ctx, written, false, true) + } + return err == nil, err + } + return false, fmt.Errorf("notification occurrence delete failed after %d retries", maxNotificationUpdateRetries) +} + +func (m *NotificationOccurrenceModel) DeleteGroup(ctx context.Context, userID, groupID string) (int, error) { + groups, err := m.Groups(ctx, userID) + if err != nil { + return 0, err + } + for _, group := range groups { + if group.ID != groupID { + continue + } + deleted := 0 + var lastDeleted *corev1.NotificationOccurrence + for _, occurrence := range group.Occurrences { + ok, err := m.delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_DELETED, false) + if err != nil { + if lastDeleted != nil { + m.core.publishNotificationOccurrenceChanged(ctx, lastDeleted, false, true) + } + return deleted, err + } + if ok { + deleted++ + lastDeleted = occurrence + } + } + if lastDeleted != nil { + m.core.publishNotificationOccurrenceChanged(ctx, lastDeleted, false, true) + } + return deleted, nil + } + return 0, ErrNotFound +} + +// DeleteAll replaces every occurrence current at the authoritative +// mutation boundary with an anti-recreation tombstone. Activity materialized +// after the boundary remains visible. +func (m *NotificationOccurrenceModel) DeleteAll(ctx context.Context, userID string) (int, error) { + occurrences, err := m.List(ctx, userID) + if err != nil { + return 0, err + } + deleted := 0 + var lastDeleted *corev1.NotificationOccurrence + for _, occurrence := range occurrences { + ok, err := m.delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_DELETED, false) + if err != nil { + if lastDeleted != nil { + m.core.publishNotificationOccurrenceChanged(ctx, lastDeleted, false, true) + } + return deleted, err + } + if ok { + deleted++ + lastDeleted = occurrence + } + } + if lastDeleted != nil { + m.core.publishNotificationOccurrenceChanged(ctx, lastDeleted, false, true) + } + return deleted, nil +} + +func (m *NotificationOccurrenceModel) MarkCoveredRead(ctx context.Context, userID, roomID, threadRootEventID, targetEventID string) (int, error) { + if _, err := m.recordNotificationReadBoundary(ctx, userID, roomID, threadRootEventID, targetEventID); err != nil { + return 0, err + } + // This authoritative scan pairs with Create's post-write boundary read. No + // matter which cross-key write wins, one side observes and reconciles the + // other without relying on replica-local watcher timing. + entries, err := m.storedOccurrenceEntries(ctx, userID) + if err != nil { + return 0, err + } + updated := 0 + var lastUpdated *corev1.NotificationOccurrence + for _, entry := range entries { + occurrence := entry.occurrence + if occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED || + occurrence.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD || + occurrence.GetTarget().GetRoomId() != roomID || + occurrence.GetTarget().GetThreadRootEventId() != threadRootEventID { + continue + } + covered, err := m.occurrenceCoveredByReadBoundary(ctx, occurrence) + if err != nil { + return updated, err + } + if !covered { + continue + } + var item *corev1.NotificationOccurrence + for attempt := 0; attempt < maxNotificationUpdateRetries; attempt++ { + current, exists, err := m.storedOccurrenceBySource(ctx, userID, occurrence.GetSourceEventId()) + if err != nil || !exists { + if err != nil { + return updated, err + } + break + } + if current.occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED || + current.occurrence.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { + break + } + covered, err := m.occurrenceCoveredByReadBoundary(ctx, current.occurrence) + if err != nil { + return updated, err + } + if !covered { + break + } + next := proto.Clone(current.occurrence).(*corev1.NotificationOccurrence) + next.InboxState = corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ + if next.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_PENDING || + next.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED || + next.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_UNSPECIFIED { + next.AlertState = corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED + next.AlertClaimedUntil = nil + } + next.UpdatedAt = timestamppb.New(m.now().UTC()) + item, err = m.updateAtRevision(ctx, current, next) + if jetstreamutil.IsSequenceConflict(err) { + continue + } + if err != nil { + return updated, err + } + break + } + if item == nil { + continue + } + updated++ + lastUpdated = item + } + if lastUpdated != nil { + m.core.publishNotificationOccurrenceChanged(ctx, lastUpdated, false, false) + } + return updated, nil +} + +// ClaimPendingAlert leases one interruptive delivery to this replica. A +// crashed or failed claim becomes eligible again after the short lease. +func (m *NotificationOccurrenceModel) ClaimPendingAlert(ctx context.Context) (*corev1.NotificationOccurrence, bool, error) { + entries, err := m.index.alertCandidates(ctx) + if err != nil { + return nil, false, err + } + now := m.now().UTC() + for _, entry := range entries { + occurrence := entry.occurrence + if !notificationAlertClaimable(occurrence, now) { + continue + } + if m.core.suppressesNotificationAlertsForPresence(ctx, occurrence.GetRecipientId()) { + if _, updateErr := m.setAlertState(ctx, entry, corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED, time.Time{}); updateErr != nil && !jetstreamutil.IsSequenceConflict(updateErr) { + return nil, false, updateErr + } + continue + } + claimed, updateErr := m.setAlertState(ctx, entry, corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED, now.Add(notificationAlertClaimTTL)) + if jetstreamutil.IsSequenceConflict(updateErr) { + continue + } + if updateErr != nil { + return nil, false, updateErr + } + return claimed, true, nil + } + return nil, false, nil +} + +// HasClaimableAlert avoids an expensive durable-worker fence on idle delivery +// polls. A positive result is only a hint: callers must still fence the worker +// and use ClaimPendingAlert's OCC mutation before delivering anything. +func (m *NotificationOccurrenceModel) HasClaimableAlert(ctx context.Context) (bool, error) { + entries, err := m.index.alertCandidates(ctx) + if err != nil { + return false, err + } + now := m.now().UTC() + for _, entry := range entries { + if notificationAlertClaimable(entry.occurrence, now) { + return true, nil + } + } + return false, nil +} + +func notificationAlertClaimable(occurrence *corev1.NotificationOccurrence, now time.Time) bool { + if occurrence == nil || + occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED || + occurrence.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD || + occurrence.GetStrongestIntensity() != corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT { + return false + } + if occurrence.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_PENDING { + return true + } + return occurrence.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED && + occurrence.GetAlertClaimedUntil() != nil && occurrence.GetAlertClaimedUntil().AsTime().Before(now) +} + +// CompleteAlertClaim records whether a claimed alert reached its configured +// delivery callback. Failed attempts return to Pending for retry. +func (m *NotificationOccurrenceModel) CompleteAlertClaim(ctx context.Context, occurrence *corev1.NotificationOccurrence, delivered bool) error { + if occurrence == nil { + return nil + } + entry, exists, err := m.index.occurrenceBySource(ctx, occurrence.GetRecipientId(), occurrence.GetSourceEventId()) + if err != nil || !exists || entry.occurrence.GetAlertState() != corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED || + entry.occurrence.GetAlertClaimedUntil() == nil || occurrence.GetAlertClaimedUntil() == nil || + !entry.occurrence.GetAlertClaimedUntil().AsTime().Equal(occurrence.GetAlertClaimedUntil().AsTime()) { + return err + } + now := m.now().UTC() + if delivered && !entry.occurrence.GetAlertClaimedUntil().AsTime().After(now) { + // A callback may return nil after discovering that its lease expired. + // Leave the expired claim retryable instead of recording a delivery that + // never reached a provider. + return nil + } + state := corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED + claimedUntil := now.Add(notificationAlertRetryDelay) + if delivered { + state = corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_DELIVERED + claimedUntil = time.Time{} + } + _, err = m.setAlertState(ctx, entry, state, claimedUntil) + return err +} + +// AlertClaimCurrent checks that the caller still owns the exact unexpired +// claim and that user triage has not made the occurrence ineligible. +func (m *NotificationOccurrenceModel) AlertClaimCurrent(ctx context.Context, expected *corev1.NotificationOccurrence) (bool, error) { + if expected == nil || expected.GetAlertClaimedUntil() == nil { + return false, nil + } + current, err := m.Get(ctx, expected.GetRecipientId(), expected.GetId()) + if err != nil { + if errors.Is(err, ErrNotFound) { + return false, nil + } + return false, err + } + return current.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD && + current.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED && + current.GetAlertClaimedUntil() != nil && + current.GetAlertClaimedUntil().AsTime().Equal(expected.GetAlertClaimedUntil().AsTime()) && + current.GetAlertClaimedUntil().AsTime().After(m.now().UTC()), nil +} + +// RenewAlertClaim fences provider delivery with a fresh delivery-sized lease. +// The caller must use the returned occurrence when completing the claim. +func (m *NotificationOccurrenceModel) RenewAlertClaim(ctx context.Context, expected *corev1.NotificationOccurrence) (*corev1.NotificationOccurrence, bool, error) { + if expected == nil || expected.GetAlertClaimedUntil() == nil { + return nil, false, nil + } + entry, exists, err := m.index.occurrenceBySource(ctx, expected.GetRecipientId(), expected.GetSourceEventId()) + if err != nil || !exists { + return nil, false, err + } + current := entry.occurrence + if current.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD || + current.GetAlertState() != corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED || + current.GetAlertClaimedUntil() == nil || + !current.GetAlertClaimedUntil().AsTime().Equal(expected.GetAlertClaimedUntil().AsTime()) || + !current.GetAlertClaimedUntil().AsTime().After(m.now().UTC()) { + return nil, false, nil + } + renewed, err := m.setAlertState(ctx, entry, corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED, m.now().UTC().Add(notificationAlertDeliveryTTL)) + if jetstreamutil.IsSequenceConflict(err) { + return nil, false, nil + } + return renewed, err == nil, err +} + +// SilenceAlertClaim terminates the exact in-flight claim without delivery. +// It is used when DND becomes active after the original claim but before the +// provider call. +func (m *NotificationOccurrenceModel) SilenceAlertClaim(ctx context.Context, expected *corev1.NotificationOccurrence) (bool, error) { + if expected == nil || expected.GetAlertClaimedUntil() == nil { + return false, nil + } + entry, exists, err := m.index.occurrenceBySource(ctx, expected.GetRecipientId(), expected.GetSourceEventId()) + if err != nil || !exists { + return false, err + } + current := entry.occurrence + if current.GetAlertState() != corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED || + current.GetAlertClaimedUntil() == nil || + !current.GetAlertClaimedUntil().AsTime().Equal(expected.GetAlertClaimedUntil().AsTime()) { + return false, nil + } + _, err = m.setAlertState(ctx, entry, corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED, time.Time{}) + if jetstreamutil.IsSequenceConflict(err) { + return false, nil + } + return err == nil, err +} + +// VisibleOccurrences waits this replica's authoritative projections through a +// freshly captured user, room, group-layout, and RBAC boundaries, then returns the occurrences whose +// recipient, membership, target-message lifecycle, and exact reaction remain +// visible. One room-subject boundary covers the whole batch, preventing both +// per-room broker work and projection lag from being mistaken for permanent +// visibility loss. +func (m *NotificationOccurrenceModel) VisibleOccurrences(ctx context.Context, recipientID string, occurrences []*corev1.NotificationOccurrence) ([]*corev1.NotificationOccurrence, error) { + if len(occurrences) == 0 { + return nil, nil + } + userPosition, err := m.core.EventPublisher.LastSubjectPosition(ctx, evtstream.UserAggregate(recipientID).AllEventsFilter()) + if err != nil { + return nil, fmt.Errorf("capture notification recipient boundary: %w", err) + } + roomPosition, err := m.core.EventPublisher.LastSubjectPosition(ctx, evtstream.RoomSubjectFilter()) + if err != nil { + return nil, fmt.Errorf("capture notification rooms boundary: %w", err) + } + groupPosition, err := m.core.EventPublisher.LastSubjectPosition(ctx, evtstream.GroupSubjectFilter()) + if err != nil { + return nil, fmt.Errorf("capture notification room-group boundary: %w", err) + } + rbacPosition, err := m.core.EventPublisher.LastSubjectPosition(ctx, evtstream.RBACSubjectFilter()) + if err != nil { + return nil, fmt.Errorf("capture notification RBAC boundary: %w", err) + } + if !userPosition.IsZero() { + if err := m.core.userModel.waitForUsers(ctx, userPosition); err != nil { + return nil, fmt.Errorf("wait for notification recipient boundary: %w", err) + } + } + if !roomPosition.IsZero() { + if err := waitForPositionAll(ctx, roomPosition, + waitForProjection("notification room directory", m.core.roomModel.directory.Projector()), + waitForProjection("notification room timeline", m.core.roomModel.timeline.Projector()), + waitForProjection("notification reactions", m.core.roomModel.reactions.Projector()), + ); err != nil { + return nil, fmt.Errorf("wait for notification rooms visibility boundary: %w", err) + } + } + if err := m.core.roomModel.waitForGroupLayout(ctx, groupPosition); err != nil { + return nil, fmt.Errorf("wait for notification room-group visibility boundary: %w", err) + } + if err := m.core.rbacModel.waitFor(ctx, rbacPosition); err != nil { + return nil, fmt.Errorf("wait for notification RBAC visibility boundary: %w", err) + } + visible := make([]*corev1.NotificationOccurrence, 0, len(occurrences)) + for _, occurrence := range occurrences { + allowed, err := m.targetVisibleFromCurrentProjections(ctx, recipientID, occurrence) + if err != nil { + return nil, err + } + if allowed { + visible = append(visible, occurrence) + } + } + return visible, nil +} + +func (m *NotificationOccurrenceModel) targetVisibleFromCurrentProjections(ctx context.Context, recipientID string, occurrence *corev1.NotificationOccurrence) (bool, error) { + if occurrence == nil || occurrence.GetRecipientId() != recipientID || occurrence.GetTarget().GetRoomId() == "" { + return false, nil + } + if _, err := m.core.GetUser(ctx, recipientID); errors.Is(err, ErrNotFound) { + return false, nil + } else if err != nil { + return false, err + } + room, err := m.core.FindRoomByID(ctx, occurrence.GetTarget().GetRoomId()) + if errors.Is(err, ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + member, err := m.core.RoomMembershipExists(ctx, KindOfRoom(room), recipientID, room.GetId()) + if err != nil || !member { + return member, err + } + target := occurrence.GetTarget() + messageVisible := func(eventID string) bool { + entry, ok := m.core.roomModel.timelineEntry(eventID) + if !ok || entry.Event == nil || roomIDOfEvent(entry.Event) != room.GetId() { + return false + } + _, retracted, known := m.core.roomModel.latestBody(eventID) + return known && !retracted + } + if !messageVisible(target.GetEventId()) { + return false, nil + } + if target.GetThreadRootEventId() != "" && !messageVisible(target.GetThreadRootEventId()) { + return false, nil + } + if notificationOccurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REACTION) { + snapshot := m.core.roomModel.reactionMutationSnapshot( + room.GetId(), + target.GetEventId(), + occurrence.GetReactionEmoji(), + occurrence.GetActorId(), + ) + return snapshot.Exists && snapshot.SourceEventID == occurrence.GetSourceEventId(), nil + } + return true, nil +} + +func (m *NotificationOccurrenceModel) setAlertState(ctx context.Context, entry notificationOccurrenceIndexEntry, state corev1.NotificationAlertState, claimedUntil time.Time) (*corev1.NotificationOccurrence, error) { + updated := proto.Clone(entry.occurrence).(*corev1.NotificationOccurrence) + updated.AlertState = state + updated.AlertClaimedUntil = nil + if !claimedUntil.IsZero() { + updated.AlertClaimedUntil = timestamppb.New(claimedUntil.UTC()) + } + updated.UpdatedAt = timestamppb.New(m.now().UTC()) + return m.updateAtRevision(ctx, entry, updated) +} + +func (m *NotificationOccurrenceModel) RemoveTarget(ctx context.Context, roomID, eventID string, reason corev1.NotificationRemovalReason) (int, error) { + entries, err := m.storedOccurrenceEntries(ctx, "") + if err != nil { + return 0, err + } + removed := 0 + for _, entry := range entries { + target := entry.occurrence.GetTarget() + if target.GetRoomId() != roomID || (target.GetEventId() != eventID && target.GetThreadRootEventId() != eventID) { + continue + } + written, ok, err := m.deleteStoredOccurrence(ctx, entry.occurrence.GetRecipientId(), entry.occurrence.GetSourceEventId(), reason) + if err != nil { + return removed, err + } + if ok { + removed++ + m.core.publishNotificationOccurrenceChanged(ctx, written, false, true) + } + } + return removed, nil +} + +func (m *NotificationOccurrenceModel) RemoveSource(ctx context.Context, userID, sourceEventID string, reason corev1.NotificationRemovalReason) (bool, error) { + written, removed, err := m.deleteStoredOccurrence(ctx, userID, sourceEventID, reason) + if err == nil && removed { + m.core.publishNotificationOccurrenceChanged(ctx, written, false, true) + } + return removed, err +} + +func (m *NotificationOccurrenceModel) RemoveReaction(ctx context.Context, recipientID, roomID, messageEventID, actorID, emoji string, removedAtSequence uint64) (int, error) { + entries, err := m.storedOccurrenceEntries(ctx, recipientID) + if err != nil { + return 0, err + } + removed := 0 + for _, entry := range entries { + occurrence := entry.occurrence + target := occurrence.GetTarget() + if occurrence.GetActorId() != actorID || occurrence.GetReactionEmoji() != emoji || + target.GetRoomId() != roomID || target.GetEventId() != messageEventID || + !notificationOccurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REACTION) { + continue + } + if occurrence.GetSourceStreamSequence() >= removedAtSequence { + continue + } + written, ok, err := m.deleteStoredOccurrence(ctx, occurrence.GetRecipientId(), occurrence.GetSourceEventId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_REACTION_REMOVED) + if err != nil { + return removed, err + } + if ok { + removed++ + m.core.publishNotificationOccurrenceChanged(ctx, written, false, true) + } + } + return removed, nil +} + +func notificationOccurrenceHasReason(occurrence *corev1.NotificationOccurrence, reason corev1.NotificationReason) bool { + for _, match := range occurrence.GetReasons() { + if match.GetReason() == reason { + return true + } + } + return false +} + +func (m *NotificationOccurrenceModel) RemoveRoomForUser(ctx context.Context, userID, roomID string, removedThroughSequence uint64, reason corev1.NotificationRemovalReason) (int, error) { + entries, err := m.storedOccurrenceEntries(ctx, userID) + if err != nil { + return 0, err + } + removed := 0 + for _, entry := range entries { + if entry.occurrence.GetTarget().GetRoomId() != roomID { + continue + } + if removedThroughSequence != 0 && entry.occurrence.GetSourceStreamSequence() >= removedThroughSequence { + continue + } + written, ok, err := m.deleteStoredOccurrence(ctx, userID, entry.occurrence.GetSourceEventId(), reason) + if err != nil { + return removed, err + } + if ok { + removed++ + m.core.publishNotificationOccurrenceChanged(ctx, written, false, true) + } + } + return removed, nil +} + +func (m *NotificationOccurrenceModel) RemoveRoom(ctx context.Context, roomID string, reason corev1.NotificationRemovalReason) (int, error) { + entries, err := m.storedOccurrenceEntries(ctx, "") + if err != nil { + return 0, err + } + removed := 0 + for _, entry := range entries { + if entry.occurrence.GetTarget().GetRoomId() != roomID { + continue + } + written, ok, err := m.deleteStoredOccurrence(ctx, entry.occurrence.GetRecipientId(), entry.occurrence.GetSourceEventId(), reason) + if err != nil { + return removed, err + } + if ok { + removed++ + m.core.publishNotificationOccurrenceChanged(ctx, written, false, true) + } + } + return removed, nil +} + +func (m *NotificationOccurrenceModel) PurgeUser(ctx context.Context, userID string) (int, error) { + purged := 0 + for { + entries, err := m.storedOccurrenceEntries(ctx, userID) + if err != nil { + return purged, err + } + if len(entries) == 0 { + return purged, nil + } + for _, entry := range entries { + if err := m.kv.Purge(ctx, entry.key, jetstream.LastRevision(entry.revision)); err != nil { + if jetstreamutil.IsSequenceConflict(err) || errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + continue + } + return purged, err + } + if err := m.index.waitForRevisionAfter(ctx, entry.key, entry.revision); err != nil { + return purged, err + } + purged++ + } + if err := ctx.Err(); err != nil { + return purged, err + } + } +} + +func (m *NotificationOccurrenceModel) updateAtRevision(ctx context.Context, entry notificationOccurrenceIndexEntry, updated *corev1.NotificationOccurrence) (*corev1.NotificationOccurrence, error) { + expiresAt := updated.GetExpiresAt().AsTime() + remaining := expiresAt.Sub(m.now().UTC()) + if remaining <= 0 { + return nil, ErrNotFound + } + data, err := proto.Marshal(updated) + if err != nil { + return nil, fmt.Errorf("marshal notification occurrence: %w", err) + } + // KV.Update resets a per-key TTL to its original duration. Publish the + // revision-checked replacement with the remaining lifetime instead so + // triage changes can never extend the occurrence's absolute 90-day expiry. + revision, err := m.core.updateRuntimeStateTokenTTL(ctx, entry.key, data, entry.revision, remaining) + if err != nil { + return nil, err + } + if err := m.index.waitForRevision(ctx, entry.key, revision); err != nil { + return nil, err + } + fresh, exists, err := m.index.occurrenceBySource(ctx, updated.GetRecipientId(), updated.GetSourceEventId()) + if err != nil || !exists { + return nil, err + } + return fresh.occurrence, nil +} + +func normalizeNotificationReasons(input []*corev1.NotificationReasonMatch) []*corev1.NotificationReasonMatch { + byReason := make(map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity) + for _, match := range input { + if match == nil || match.GetReason() == corev1.NotificationReason_NOTIFICATION_REASON_UNSPECIFIED { + continue + } + if match.GetIntensity() > byReason[match.GetReason()] { + byReason[match.GetReason()] = match.GetIntensity() + } + } + reasons := make([]corev1.NotificationReason, 0, len(byReason)) + for reason := range byReason { + reasons = append(reasons, reason) + } + slices.Sort(reasons) + result := make([]*corev1.NotificationReasonMatch, 0, len(reasons)) + for _, reason := range reasons { + result = append(result, &corev1.NotificationReasonMatch{Reason: reason, Intensity: byReason[reason]}) + } + return result +} + +func strongestNotificationIntensity(reasons []*corev1.NotificationReasonMatch) corev1.NotificationDeliveryIntensity { + strongest := corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED + for _, reason := range reasons { + if reason != nil && reason.GetIntensity() > strongest { + strongest = reason.GetIntensity() + } + } + return strongest +} + +func notificationOccurrenceGroupKey(occurrence *corev1.NotificationOccurrence) string { + target := occurrence.GetTarget() + if hasNotificationReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REACTION) { + return "reaction:" + target.GetRoomId() + ":" + target.GetEventId() + } + if target.GetThreadRootEventId() != "" { + return "thread:" + target.GetRoomId() + ":" + target.GetThreadRootEventId() + } + if hasNotificationReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE) { + return "dm:" + target.GetRoomId() + } + return "room:" + target.GetRoomId() +} + +func hasNotificationReason(occurrence *corev1.NotificationOccurrence, wanted corev1.NotificationReason) bool { + for _, reason := range occurrence.GetReasons() { + if reason.GetReason() == wanted { + return true + } + } + return false +} diff --git a/cli/internal/core/notification_occurrence_model_test.go b/cli/internal/core/notification_occurrence_model_test.go new file mode 100644 index 000000000..4a90b1b2c --- /dev/null +++ b/cli/internal/core/notification_occurrence_model_test.go @@ -0,0 +1,569 @@ +package core + +import ( + "context" + "errors" + "testing" + "time" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "hmans.de/chatto/internal/evtstream" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +func TestNotificationOccurrenceLifecycleAndDeterministicIdentity(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + model := chattoCore.NotificationOccurrences() + now := time.Now().UTC().Truncate(time.Millisecond) + model.now = func() time.Time { return now } + + input := CreateNotificationOccurrenceInput{ + RecipientID: "U-notification-recipient", + SourceEventID: "E-notification-source", + SourceCreated: now.Add(-24 * time.Hour), + ActorID: "U-notification-actor", + Target: &corev1.NotificationTarget{ + RoomId: "R-notification-room", + EventId: "E-notification-source", + }, + Reasons: []*corev1.NotificationReasonMatch{ + { + Reason: corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }, + { + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + }, + { + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }, + }, + SkipReadLookup: true, + } + + created, wasCreated, err := model.Create(ctx, input) + if err != nil { + t.Fatalf("Create: %v", err) + } + if candidates, err := model.index.alertCandidates(ctx); err != nil || len(candidates) != 1 { + t.Fatalf("alert candidates after create = (%d, %v), want one", len(candidates), err) + } + if !wasCreated || created == nil { + t.Fatalf("Create = (%v, %v), want a new occurrence", created, wasCreated) + } + if created.GetId() != notificationOccurrenceID(input.RecipientID, input.SourceEventID) { + t.Fatalf("id = %q, want deterministic identity", created.GetId()) + } + if got := created.GetStrongestIntensity(); got != corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT { + t.Fatalf("strongest intensity = %v, want ALERT", got) + } + if got := len(created.GetReasons()); got != 2 { + t.Fatalf("reasons = %d, want two deduplicated reasons", got) + } + originalExpiry := created.GetExpiresAt().AsTime() + + claim, claimed, err := model.ClaimPendingAlert(ctx) + if err != nil || !claimed || claim.GetId() != created.GetId() { + t.Fatalf("ClaimPendingAlert = (%v, %v, %v), want created occurrence", claim, claimed, err) + } + renewed, renewedClaim, err := model.RenewAlertClaim(ctx, claim) + if err != nil || !renewedClaim || !renewed.GetAlertClaimedUntil().AsTime().After(claim.GetAlertClaimedUntil().AsTime()) { + t.Fatalf("RenewAlertClaim = (%v, %v, %v), want extended exact claim", renewed, renewedClaim, err) + } + claim = renewed + if err := model.CompleteAlertClaim(ctx, claim, false); err != nil { + t.Fatalf("CompleteAlertClaim failed delivery: %v", err) + } + if retry, retryClaimed, err := model.ClaimPendingAlert(ctx); err != nil || retryClaimed || retry != nil { + t.Fatalf("immediate retry ClaimPendingAlert = (%v, %v, %v), want paced retry", retry, retryClaimed, err) + } + now = now.Add(notificationAlertRetryDelay + time.Millisecond) + claim, claimed, err = model.ClaimPendingAlert(ctx) + if err != nil || !claimed { + t.Fatalf("retry ClaimPendingAlert = (%v, %v, %v), want retry", claim, claimed, err) + } + if err := model.CompleteAlertClaim(ctx, claim, true); err != nil { + t.Fatalf("CompleteAlertClaim delivered: %v", err) + } + if claim, claimed, err := model.ClaimPendingAlert(ctx); err != nil || claimed || claim != nil { + t.Fatalf("ClaimPendingAlert after delivery = (%v, %v, %v), want none", claim, claimed, err) + } + + duplicate, wasCreated, err := model.Create(ctx, input) + if err != nil { + t.Fatalf("duplicate Create: %v", err) + } + if wasCreated || duplicate.GetId() != created.GetId() { + t.Fatalf("duplicate Create = (%v, %v), want existing occurrence", duplicate, wasCreated) + } + + updated, err := model.MarkRead(ctx, input.RecipientID, created.GetId()) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ { + t.Fatalf("Update = %+v, want read", updated) + } + if !updated.GetExpiresAt().AsTime().Equal(originalExpiry) { + t.Fatalf("expiry changed from %v to %v", originalExpiry, updated.GetExpiresAt().AsTime()) + } + secondInput := input + secondInput.SourceEventID = "E-notification-source-2" + secondInput.SourceCreated = input.SourceCreated.Add(time.Minute) + secondInput.Target = proto.Clone(input.Target).(*corev1.NotificationTarget) + secondInput.Target.EventId = secondInput.SourceEventID + if second, wasCreated, err := model.Create(ctx, secondInput); err != nil || !wasCreated || second == nil { + t.Fatalf("Create second grouped occurrence = (%v, %v, %v), want occurrence, true, nil", second, wasCreated, err) + } + + groups, err := model.Groups(ctx, input.RecipientID) + if err != nil || len(groups) != 1 { + t.Fatalf("Groups = (%v, %v), want one", groups, err) + } + if got := len(groups[0].Occurrences); got != 2 { + t.Fatalf("Group occurrences = %d, want two", got) + } + deleted, err := model.DeleteGroup(ctx, input.RecipientID, groups[0].ID) + if err != nil || deleted != 2 { + t.Fatalf("DeleteGroup = (%v, %v), want two", deleted, err) + } + if groups, err := model.Groups(ctx, input.RecipientID); err != nil || len(groups) != 0 { + t.Fatalf("Groups after delete = (%v, %v), want empty", groups, err) + } + if _, err := model.Get(ctx, input.RecipientID, created.GetId()); !errors.Is(err, ErrNotFound) { + t.Fatalf("Get deleted occurrence = %v, want ErrNotFound", err) + } + if recreated, wasCreated, err := model.Create(ctx, input); err != nil || wasCreated || recreated != nil { + t.Fatalf("Create after tombstone = (%v, %v, %v), want nil, false, nil", recreated, wasCreated, err) + } +} + +func TestNotificationOccurrenceDeleteAll(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + model := chattoCore.NotificationOccurrences() + now := time.Now().UTC().Truncate(time.Millisecond) + model.now = func() time.Time { return now } + inputs := []CreateNotificationOccurrenceInput{ + { + RecipientID: "U-delete-all-recipient", + SourceEventID: "E-delete-all-source-1", + SourceCreated: now, + Target: &corev1.NotificationTarget{RoomId: "R-delete-all-1", EventId: "E-delete-all-source-1"}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }, + { + RecipientID: "U-delete-all-recipient", + SourceEventID: "E-delete-all-source-2", + SourceCreated: now.Add(time.Minute), + Target: &corev1.NotificationTarget{RoomId: "R-delete-all-2", EventId: "E-delete-all-source-2"}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_REPLY, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }, + } + for _, input := range inputs { + if occurrence, created, err := model.Create(ctx, input); err != nil || !created || occurrence == nil { + t.Fatalf("Create(%q) = (%v, %v, %v), want occurrence, true, nil", input.SourceEventID, occurrence, created, err) + } + } + + deleted, err := model.DeleteAll(ctx, inputs[0].RecipientID) + if err != nil || deleted != 2 { + t.Fatalf("DeleteAll = (%d, %v), want two", deleted, err) + } + if groups, err := model.Groups(ctx, inputs[0].RecipientID); err != nil || len(groups) != 0 { + t.Fatalf("Groups after DeleteAll = (%v, %v), want empty", groups, err) + } + for _, input := range inputs { + if occurrence, created, err := model.Create(ctx, input); err != nil || created || occurrence != nil { + t.Fatalf("Create tombstoned %q = (%v, %v, %v), want nil, false, nil", input.SourceEventID, occurrence, created, err) + } + } +} + +func TestCompleteAlertClaimDoesNotDeliverAfterLeaseExpiry(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + model := chattoCore.NotificationOccurrences() + now := time.Now().UTC().Truncate(time.Millisecond) + model.now = func() time.Time { return now } + created, _, err := model.Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: "U-expired-claim-recipient", + SourceEventID: "E-expired-claim-source", + SourceCreated: now, + Target: &corev1.NotificationTarget{RoomId: "R-expired-claim", EventId: "E-expired-claim-source"}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + }}, + SkipReadLookup: true, + }) + if err != nil || created == nil { + t.Fatalf("Create = (%v, %v)", created, err) + } + claim, claimed, err := model.ClaimPendingAlert(ctx) + if err != nil || !claimed { + t.Fatalf("ClaimPendingAlert = (%v, %v, %v)", claim, claimed, err) + } + now = claim.GetAlertClaimedUntil().AsTime().Add(time.Millisecond) + if err := model.CompleteAlertClaim(ctx, claim, true); err != nil { + t.Fatalf("CompleteAlertClaim: %v", err) + } + current, err := model.Get(ctx, created.GetRecipientId(), created.GetId()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if current.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_DELIVERED { + t.Fatal("expired claim was recorded as delivered") + } + if retry, retryClaimed, err := model.ClaimPendingAlert(ctx); err != nil || !retryClaimed || retry == nil { + t.Fatalf("ClaimPendingAlert after expiry = (%v, %v, %v), want retry", retry, retryClaimed, err) + } +} + +func TestNotificationOccurrenceReadCancelsPendingAlert(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + model := chattoCore.NotificationOccurrences() + now := time.Now().UTC() + model.now = func() time.Time { return now } + created, _, err := model.Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: "U-read-alert-recipient", + SourceEventID: "E-read-alert-source", + SourceCreated: now, + Target: &corev1.NotificationTarget{RoomId: "R-read-alert", EventId: "E-read-alert-source"}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + }}, + SkipReadLookup: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + updated, err := model.MarkRead(ctx, created.GetRecipientId(), created.GetId()) + if err != nil { + t.Fatalf("Update read: %v", err) + } + if updated.GetAlertState() != corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED { + t.Fatalf("alert state = %v, want SILENCED", updated.GetAlertState()) + } + if candidates, err := model.index.alertCandidates(ctx); err != nil || len(candidates) != 0 { + t.Fatalf("alert candidates after read = (%d, %v), want none", len(candidates), err) + } + if claim, claimed, err := model.ClaimPendingAlert(ctx); err != nil || claimed || claim != nil { + t.Fatalf("ClaimPendingAlert after read = (%v, %v, %v), want none", claim, claimed, err) + } +} + +func TestSilenceAlertClaimTerminatesExactClaim(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + model := chattoCore.NotificationOccurrences() + now := time.Now().UTC() + model.now = func() time.Time { return now } + created, _, err := model.Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: "U-dnd-claim-recipient", + SourceEventID: "E-dnd-claim-source", + SourceCreated: now, + Target: &corev1.NotificationTarget{RoomId: "R-dnd-claim", EventId: "E-dnd-claim-source"}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + }}, + SkipReadLookup: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + claim, claimed, err := model.ClaimPendingAlert(ctx) + if err != nil || !claimed { + t.Fatalf("ClaimPendingAlert = (%v, %v, %v)", claim, claimed, err) + } + if silenced, err := model.SilenceAlertClaim(ctx, claim); err != nil || !silenced { + t.Fatalf("SilenceAlertClaim = (%v, %v), want true, nil", silenced, err) + } + current, err := model.Get(ctx, created.GetRecipientId(), created.GetId()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if current.GetAlertState() != corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED { + t.Fatalf("alert state = %v, want SILENCED", current.GetAlertState()) + } + if err := model.CompleteAlertClaim(ctx, claim, true); err != nil { + t.Fatalf("CompleteAlertClaim after silence: %v", err) + } + if current, err = model.Get(ctx, created.GetRecipientId(), created.GetId()); err != nil || current.GetAlertState() != corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED { + t.Fatalf("state after completion = (%v, %v), want SILENCED", current.GetAlertState(), err) + } +} + +func TestVisibleOccurrencesChecksMessageAndExactReactionLifecycle(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "visible-target-author", "Visible Target Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + actor, err := chattoCore.CreateUser(ctx, SystemActorID, "visible-target-actor", "Visible Target Actor", "password") + if err != nil { + t.Fatalf("CreateUser actor: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "visible-target-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + for _, userID := range []string{author.Id, actor.Id} { + if _, err := chattoCore.JoinRoom(ctx, userID, KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %s: %v", userID, err) + } + } + posted, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "target", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + if added, err := chattoCore.ReactionModel().AddReaction(ctx, ReactionMutationInput{ + ActorID: actor.Id, RoomID: room.Id, MessageEventID: posted.Id, Emoji: "thumbsup", + }); err != nil || !added { + t.Fatalf("AddReaction = (%v, %v)", added, err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, author.Id) + if err != nil || len(occurrences) != 1 { + t.Fatalf("reaction occurrences = (%v, %v), want one", occurrences, err) + } + reactionOccurrence := proto.Clone(occurrences[0]).(*corev1.NotificationOccurrence) + if visible, err := chattoCore.NotificationOccurrences().VisibleOccurrences(ctx, author.Id, []*corev1.NotificationOccurrence{reactionOccurrence}); err != nil || len(visible) != 1 { + t.Fatalf("VisibleOccurrences before removal = (%v, %v), want one, nil", visible, err) + } + if removed, err := chattoCore.ReactionModel().RemoveReaction(ctx, ReactionMutationInput{ + ActorID: actor.Id, RoomID: room.Id, MessageEventID: posted.Id, Emoji: "thumbsup", + }); err != nil || !removed { + t.Fatalf("RemoveReaction = (%v, %v)", removed, err) + } + if visible, err := chattoCore.NotificationOccurrences().VisibleOccurrences(ctx, author.Id, []*corev1.NotificationOccurrence{reactionOccurrence}); err != nil || len(visible) != 0 { + t.Fatalf("VisibleOccurrences after reaction removal = (%v, %v), want empty, nil", visible, err) + } + if err := chattoCore.DeleteMessage(ctx, author.Id, KindChannel, room.Id, posted.Id); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + if visible, err := chattoCore.NotificationOccurrences().VisibleOccurrences(ctx, author.Id, []*corev1.NotificationOccurrence{reactionOccurrence}); err != nil || len(visible) != 0 { + t.Fatalf("VisibleOccurrences after target retraction = (%v, %v), want empty, nil", visible, err) + } +} + +func TestVisibleOccurrencesWaitsForGroupAndRBACProjectionTails(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "visible-fence-author", "Visible Fence Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + recipient, err := chattoCore.CreateUser(ctx, SystemActorID, "visible-fence-recipient", "Visible Fence Recipient", "password") + if err != nil { + t.Fatalf("CreateUser recipient: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "visible-fence-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + if _, err := chattoCore.SetRoomUniversal(ctx, author.Id, KindChannel, room.Id, true); err != nil { + t.Fatalf("SetRoomUniversal: %v", err) + } + posted, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "visibility fence target", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + sequence, err := chattoCore.GetEventSequence(ctx, KindChannel, room.Id, posted.Id) + if err != nil { + t.Fatalf("GetEventSequence: %v", err) + } + occurrence, created, err := chattoCore.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: recipient.Id, SourceEventID: "visible-fence-source", SourceCreated: posted.GetCreatedAt().AsTime(), + SourceStreamSequence: sequence, ActorID: author.Id, + Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: posted.Id}, + Reasons: []*corev1.NotificationReasonMatch{{Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE}}, + SkipReadLookup: true, + }) + if err != nil || !created { + t.Fatalf("Create occurrence = (%+v, %v, %v)", occurrence, created, err) + } + group, err := chattoCore.CreateRoomGroup(ctx, author.Id, "Visibility Fence Group", "") + if err != nil { + t.Fatalf("CreateRoomGroup: %v", err) + } + if err := chattoCore.MoveRoomToGroup(ctx, author.Id, room.Id, group.GetId()); err != nil { + t.Fatalf("MoveRoomToGroup: %v", err) + } + if err := chattoCore.DenyUserRoomPermission(ctx, SystemActorID, room.Id, recipient.Id, PermRoomJoin); err != nil { + t.Fatalf("DenyUserRoomPermission: %v", err) + } + + delayedGroup := evtstream.NewProjectionHandle( + chattoCore.js, + chattoCore.storage.serverEvtStream, + NewRoomGroupLayoutProjection(), + testCoreLogger(), + ) + delayedRBAC := evtstream.NewProjectionHandle( + chattoCore.js, + chattoCore.storage.serverEvtStream, + NewRBACProjection(), + testCoreLogger(), + ) + chattoCore.roomModel.groupLayout = delayedGroup + chattoCore.rbacModel = newRBACModel(delayedRBAC) + + type visibleResult struct { + items []*corev1.NotificationOccurrence + err error + } + result := make(chan visibleResult, 1) + go func() { + items, err := chattoCore.NotificationOccurrences().VisibleOccurrences(ctx, recipient.Id, []*corev1.NotificationOccurrence{occurrence}) + result <- visibleResult{items: items, err: err} + }() + select { + case early := <-result: + t.Fatalf("VisibleOccurrences returned before delayed authorization projections started: (%+v, %v)", early.items, early.err) + case <-time.After(50 * time.Millisecond): + } + + runCtx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 2) + go func() { done <- delayedGroup.Projector().Run(runCtx) }() + go func() { done <- delayedRBAC.Projector().Run(runCtx) }() + t.Cleanup(func() { + cancel() + for range 2 { + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("delayed visibility projector did not stop") + } + } + }) + select { + case got := <-result: + if got.err != nil || len(got.items) != 0 { + t.Fatalf("VisibleOccurrences after authorization catch-up = (%+v, %v), want empty", got.items, got.err) + } + case <-time.After(5 * time.Second): + t.Fatal("VisibleOccurrences did not finish after authorization projections caught up") + } +} + +func TestNotificationOccurrenceIndexConvergesAcrossReplicas(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + second := NewNotificationOccurrenceModel(chattoCore, chattoCore.storage.runtimeStateKV, testCoreLogger()) + runCtx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- second.Run(runCtx) }() + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("second notification index did not stop") + } + }) + if err := second.WaitReady(ctx); err != nil { + t.Fatalf("WaitReady: %v", err) + } + + sourceTime := time.Now().UTC() + created, _, err := chattoCore.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: "U-replica-recipient", + SourceEventID: "E-replica-source", + SourceCreated: sourceTime, + Target: &corev1.NotificationTarget{ + RoomId: "R-replica-room", + EventId: "E-replica-source", + }, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_REPLY, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + entry, err := chattoCore.storage.runtimeStateKV.Get(ctx, notificationOccurrenceKey("U-replica-recipient", "E-replica-source")) + if err != nil { + t.Fatalf("Get KV entry: %v", err) + } + if err := second.WaitForSourceRevision(ctx, created.GetRecipientId(), created.GetSourceEventId(), entry.Revision()); err != nil { + t.Fatalf("wait for source revision on second model: %v", err) + } + got, exists, err := second.index.occurrenceByID(ctx, "U-replica-recipient", created.GetId()) + if err != nil || !exists || got.occurrence.GetId() != created.GetId() { + t.Fatalf("second index occurrence = (%v, %v, %v)", got.occurrence, exists, err) + } +} + +func TestNotificationOccurrenceIndexPrunesExpiredRecordsWithoutKVDeleteEvent(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + now := time.Now().UTC() + created, _, err := chattoCore.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: "U-expired-recipient", + SourceEventID: "E-expired-source", + SourceCreated: now, + Target: &corev1.NotificationTarget{RoomId: "R-expired-room", EventId: "E-expired-source"}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_REPLY, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }) + if err != nil { + t.Fatalf("Create: %v", err) + } + key := notificationOccurrenceKey(created.GetRecipientId(), created.GetSourceEventId()) + entry, err := chattoCore.storage.runtimeStateKV.Get(ctx, key) + if err != nil { + t.Fatalf("Get occurrence KV: %v", err) + } + expired := proto.Clone(created).(*corev1.NotificationOccurrence) + expired.ExpiresAt = timestamppb.New(now.Add(-time.Second)) + data, err := proto.Marshal(expired) + if err != nil { + t.Fatalf("marshal expired occurrence: %v", err) + } + revision, err := chattoCore.storage.runtimeStateKV.Update(ctx, key, data, entry.Revision()) + if err != nil { + t.Fatalf("write stale expired occurrence: %v", err) + } + if err := chattoCore.NotificationOccurrences().index.waitForRevision(ctx, key, revision); err != nil { + t.Fatalf("wait for stale expiry revision: %v", err) + } + + if occurrences, err := chattoCore.NotificationOccurrences().List(ctx, created.GetRecipientId()); err != nil || len(occurrences) != 0 { + t.Fatalf("List expired occurrences = (%v, %v), want empty", occurrences, err) + } + if _, exists, err := chattoCore.NotificationOccurrences().index.occurrenceBySource(ctx, created.GetRecipientId(), created.GetSourceEventId()); err != nil || exists { + t.Fatalf("expired index entry exists=%v, err=%v", exists, err) + } + chattoCore.NotificationOccurrences().index.mu.RLock() + _, retainsRevisionFence := chattoCore.NotificationOccurrences().index.keyRevisions[key] + chattoCore.NotificationOccurrences().index.mu.RUnlock() + if retainsRevisionFence { + t.Fatal("expired index entry retained its revision fence") + } + if err := chattoCore.NotificationOccurrences().index.waitForRevision(ctx, key, revision); err != nil { + t.Fatalf("wait for pruned expiry revision: %v", err) + } +} diff --git a/cli/internal/core/notification_occurrence_storage.go b/cli/internal/core/notification_occurrence_storage.go new file mode 100644 index 000000000..5bbfb48b4 --- /dev/null +++ b/cli/internal/core/notification_occurrence_storage.go @@ -0,0 +1,115 @@ +package core + +import ( + "context" + "errors" + "fmt" + + "github.com/nats-io/nats.go/jetstream" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + "hmans.de/chatto/internal/jetstreamutil" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +func notificationOccurrenceFilter(userID string) string { + if userID == "" { + return notificationOccurrenceWatchFilter + } + return notificationOccurrenceKeyPrefix + userID + ".*" +} + +// storedOccurrenceEntries reads the authoritative KV state. It is reserved +// for cross-replica handshakes and causally ordered lifecycle cleanup; normal +// hot list/count reads continue to use the process-wide watcher index. +func (m *NotificationOccurrenceModel) storedOccurrenceEntries(ctx context.Context, userID string) ([]notificationOccurrenceIndexEntry, error) { + lister, err := m.kv.ListKeysFiltered(ctx, notificationOccurrenceFilter(userID)) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("list notification occurrences: %w", err) + } + entries := make([]notificationOccurrenceIndexEntry, 0) + for key := range lister.Keys() { + if _, _, ok := parseNotificationOccurrenceKey(key); !ok { + // The watcher prefix also contains internal ordering markers such as + // the cross-replica read fence. They are not occurrence records. + continue + } + entry, err := m.kv.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + continue + } + if err != nil { + return nil, fmt.Errorf("read notification occurrence %s: %w", key, err) + } + var occurrence corev1.NotificationOccurrence + if err := proto.Unmarshal(entry.Value(), &occurrence); err != nil { + return nil, fmt.Errorf("decode notification occurrence %s: %w", key, err) + } + if userID != "" && occurrence.GetRecipientId() != userID { + return nil, fmt.Errorf("notification occurrence %s has mismatched recipient", key) + } + entries = append(entries, notificationOccurrenceIndexEntry{ + key: key, + revision: entry.Revision(), + occurrence: &occurrence, + }) + } + return entries, nil +} + +func (m *NotificationOccurrenceModel) storedOccurrenceBySource(ctx context.Context, userID, sourceEventID string) (notificationOccurrenceIndexEntry, bool, error) { + key := notificationOccurrenceKey(userID, sourceEventID) + entry, err := m.kv.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + return notificationOccurrenceIndexEntry{}, false, nil + } + if err != nil { + return notificationOccurrenceIndexEntry{}, false, fmt.Errorf("read notification occurrence: %w", err) + } + var occurrence corev1.NotificationOccurrence + if err := proto.Unmarshal(entry.Value(), &occurrence); err != nil { + return notificationOccurrenceIndexEntry{}, false, fmt.Errorf("decode notification occurrence: %w", err) + } + if occurrence.GetRecipientId() != userID || occurrence.GetSourceEventId() != sourceEventID { + return notificationOccurrenceIndexEntry{}, false, fmt.Errorf("notification occurrence key does not match payload") + } + return notificationOccurrenceIndexEntry{key: key, revision: entry.Revision(), occurrence: &occurrence}, true, nil +} + +func (m *NotificationOccurrenceModel) deleteStoredOccurrence(ctx context.Context, userID, sourceEventID string, reason corev1.NotificationRemovalReason) (*corev1.NotificationOccurrence, bool, error) { + if reason == corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { + reason = corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_DELETED + } + for attempt := 0; attempt < maxNotificationUpdateRetries; attempt++ { + entry, exists, err := m.storedOccurrenceBySource(ctx, userID, sourceEventID) + if err != nil || !exists { + return nil, false, err + } + if entry.occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { + return entry.occurrence, false, nil + } + now := m.now().UTC() + tombstone := &corev1.NotificationOccurrence{ + Id: entry.occurrence.GetId(), + RecipientId: entry.occurrence.GetRecipientId(), + SourceEventId: entry.occurrence.GetSourceEventId(), + SourceCreatedAt: entry.occurrence.GetSourceCreatedAt(), + InboxState: corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ, + UpdatedAt: timestamppb.New(now), + ExpiresAt: entry.occurrence.GetExpiresAt(), + RemovalReason: reason, + RemovedAt: timestamppb.New(now), + AlertState: corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_NOT_APPLICABLE, + } + written, err := m.updateAtRevision(ctx, entry, tombstone) + if jetstreamutil.IsSequenceConflict(err) { + continue + } + return written, err == nil, err + } + return nil, false, fmt.Errorf("notification occurrence delete failed after %d retries", maxNotificationUpdateRetries) +} diff --git a/cli/internal/core/notification_policy.go b/cli/internal/core/notification_policy.go new file mode 100644 index 000000000..85577826e --- /dev/null +++ b/cli/internal/core/notification_policy.go @@ -0,0 +1,222 @@ +package core + +import ( + "context" + "fmt" + + "hmans.de/chatto/internal/evtstream" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +var notificationPolicyReasons = []corev1.NotificationReason{ + corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE, + corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + corev1.NotificationReason_NOTIFICATION_REASON_REPLY, + corev1.NotificationReason_NOTIFICATION_REASON_ROLE_MENTION, + corev1.NotificationReason_NOTIFICATION_REASON_HERE, + corev1.NotificationReason_NOTIFICATION_REASON_ALL, + corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD, + corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + corev1.NotificationReason_NOTIFICATION_REASON_REACTION, +} + +// NotificationPolicyPreference is one cause's explicit and effective policy +// at server scope and, when RoomID is non-empty, room scope. +type NotificationPolicyPreference struct { + RoomID string + Reason corev1.NotificationReason + ServerIntensity corev1.NotificationDeliveryIntensity + RoomIntensity corev1.NotificationDeliveryIntensity + Effective corev1.NotificationDeliveryIntensity +} + +// NotificationPolicyModel owns authenticated Notifications 2.0 policy reads +// and writes. Legacy notification-level events are replay-decodable only and +// are deliberately not consulted here. +type NotificationPolicyModel struct { + core *ChattoCore +} + +// NotificationPolicy returns the operation-level Notifications 2.0 policy model. +func (c *ChattoCore) NotificationPolicy() *NotificationPolicyModel { + return c.notificationPolicy +} + +func defaultNotificationIntensity(reason corev1.NotificationReason) corev1.NotificationDeliveryIntensity { + switch reason { + case corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD, + corev1.NotificationReason_NOTIFICATION_REASON_REACTION: + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE + case corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM: + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF + case corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE, + corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + corev1.NotificationReason_NOTIFICATION_REASON_REPLY, + corev1.NotificationReason_NOTIFICATION_REASON_ROLE_MENTION, + corev1.NotificationReason_NOTIFICATION_REASON_HERE, + corev1.NotificationReason_NOTIFICATION_REASON_ALL: + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT + default: + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED + } +} + +func validNotificationReason(reason corev1.NotificationReason) bool { + return defaultNotificationIntensity(reason) != corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +func validNotificationIntensity(intensity corev1.NotificationDeliveryIntensity, allowInherit bool) bool { + if allowInherit && intensity == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED { + return true + } + return intensity >= corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF && + intensity <= corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT +} + +func (cm *ConfigModel) notificationServerIntensity(userID string, reason corev1.NotificationReason) corev1.NotificationDeliveryIntensity { + if cm == nil || cm.config.Projection() == nil { + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED + } + cm.config.Projection().RLock() + defer cm.config.Projection().RUnlock() + u := cm.config.Projection().users[userID] + if u == nil { + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED + } + return u.serverIntensityByReason[reason] +} + +func (cm *ConfigModel) notificationRoomIntensity(userID, roomID string, reason corev1.NotificationReason) corev1.NotificationDeliveryIntensity { + if cm == nil || cm.config.Projection() == nil { + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED + } + cm.config.Projection().RLock() + defer cm.config.Projection().RUnlock() + u := cm.config.Projection().users[userID] + if u == nil { + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED + } + return u.roomIntensityByRoomAndCause[roomID][reason] +} + +// GetEffectiveNotificationIntensity resolves room override, then server +// override, then the product default for one cause. +func (c *ChattoCore) GetEffectiveNotificationIntensity(userID, roomID string, reason corev1.NotificationReason) corev1.NotificationDeliveryIntensity { + if roomID != "" { + if intensity := c.configModel.notificationRoomIntensity(userID, roomID, reason); intensity != corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED { + return intensity + } + } + if intensity := c.configModel.notificationServerIntensity(userID, reason); intensity != corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED { + return intensity + } + return defaultNotificationIntensity(reason) +} + +// waitForCurrentNotificationPolicy makes source-time policy evaluation observe +// every preference fact committed before this attempt captured the config +// boundary. A preference write that overlaps the source command may linearize +// on either side of that capture; every OCC retry captures it again. +func (c *ChattoCore) waitForCurrentNotificationPolicy(ctx context.Context) error { + position, err := c.EventPublisher.LastSubjectPosition(ctx, evtstream.ConfigSubjectFilter()) + if err != nil { + return fmt.Errorf("capture notification policy boundary: %w", err) + } + if position.IsZero() { + return nil + } + if err := c.configModel.waitFor(ctx, position); err != nil { + return fmt.Errorf("wait for notification policy boundary: %w", err) + } + return nil +} + +func (c *ChattoCore) setServerNotificationIntensity(ctx context.Context, userID string, reason corev1.NotificationReason, intensity corev1.NotificationDeliveryIntensity) error { + if !validNotificationReason(reason) || !validNotificationIntensity(intensity, true) { + return invalidArgument("invalid notification reason or delivery intensity") + } + return c.configModel.updateSubject(ctx, userID, func(_ evtstream.Aggregate, _ string, _ uint64) ([]*corev1.Event, error) { + if c.configModel.notificationServerIntensity(userID, reason) == intensity { + return nil, nil + } + return []*corev1.Event{newEvent(userID, &corev1.Event{Event: &corev1.Event_UserNotificationPreferenceChanged{ + UserNotificationPreferenceChanged: &corev1.UserNotificationPreferenceChangedEvent{UserId: userID, Reason: reason, Intensity: intensity}, + }})}, nil + }) +} + +func (c *ChattoCore) setRoomNotificationIntensity(ctx context.Context, userID, roomID string, reason corev1.NotificationReason, intensity corev1.NotificationDeliveryIntensity) error { + if !validNotificationReason(reason) || !validNotificationIntensity(intensity, true) { + return invalidArgument("invalid notification reason or delivery intensity") + } + return c.configModel.updateSubject(ctx, userID, func(_ evtstream.Aggregate, _ string, _ uint64) ([]*corev1.Event, error) { + if c.configModel.notificationRoomIntensity(userID, roomID, reason) == intensity { + return nil, nil + } + return []*corev1.Event{newEvent(userID, &corev1.Event{Event: &corev1.Event_UserNotificationPreferenceChanged{ + UserNotificationPreferenceChanged: &corev1.UserNotificationPreferenceChangedEvent{UserId: userID, RoomId: &roomID, Reason: reason, Intensity: intensity}, + }})}, nil + }) +} + +// GetNotificationPolicy returns every supported cause with its explicit and +// effective values. If roomID is set, the actor must be a room member. +func (s *NotificationPolicyModel) GetNotificationPolicy(ctx context.Context, actorID, roomID string) ([]NotificationPolicyPreference, error) { + if err := requireAuthenticatedActor(actorID); err != nil { + return nil, err + } + if roomID != "" { + if err := s.requireRoomMember(ctx, actorID, roomID); err != nil { + return nil, err + } + } + result := make([]NotificationPolicyPreference, 0, len(notificationPolicyReasons)) + for _, reason := range notificationPolicyReasons { + result = append(result, NotificationPolicyPreference{ + RoomID: roomID, + Reason: reason, + ServerIntensity: s.core.configModel.notificationServerIntensity(actorID, reason), + RoomIntensity: s.core.configModel.notificationRoomIntensity(actorID, roomID, reason), + Effective: s.core.GetEffectiveNotificationIntensity(actorID, roomID, reason), + }) + } + return result, nil +} + +func (s *NotificationPolicyModel) SetServerNotificationIntensity(ctx context.Context, actorID string, reason corev1.NotificationReason, intensity corev1.NotificationDeliveryIntensity) ([]NotificationPolicyPreference, error) { + if err := requireAuthenticatedActor(actorID); err != nil { + return nil, err + } + if err := s.core.setServerNotificationIntensity(ctx, actorID, reason, intensity); err != nil { + return nil, fmt.Errorf("set server notification preference: %w", err) + } + return s.GetNotificationPolicy(ctx, actorID, "") +} + +func (s *NotificationPolicyModel) SetRoomNotificationIntensity(ctx context.Context, actorID, roomID string, reason corev1.NotificationReason, intensity corev1.NotificationDeliveryIntensity) ([]NotificationPolicyPreference, error) { + if err := requireAuthenticatedActor(actorID); err != nil { + return nil, err + } + if err := s.requireRoomMember(ctx, actorID, roomID); err != nil { + return nil, err + } + if err := s.core.setRoomNotificationIntensity(ctx, actorID, roomID, reason, intensity); err != nil { + return nil, fmt.Errorf("set room notification preference: %w", err) + } + return s.GetNotificationPolicy(ctx, actorID, roomID) +} + +func (s *NotificationPolicyModel) requireRoomMember(ctx context.Context, actorID, roomID string) error { + room, err := s.core.FindRoomByID(ctx, roomID) + if err != nil { + return err + } + isMember, err := s.core.RoomMembershipExists(ctx, KindOfRoom(room), actorID, roomID) + if err != nil { + return err + } + if !isMember { + return ErrPermissionDenied + } + return nil +} diff --git a/cli/internal/core/notification_policy_test.go b/cli/internal/core/notification_policy_test.go new file mode 100644 index 000000000..3f413c4b1 --- /dev/null +++ b/cli/internal/core/notification_policy_test.go @@ -0,0 +1,108 @@ +package core + +import ( + "testing" + + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +func TestNotificationPolicyInheritanceByCause(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + user, err := chattoCore.CreateUser(ctx, SystemActorID, "policy-user", "Policy User", "password") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, user.Id, KindChannel, "", "policy-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + if _, err := chattoCore.JoinRoom(ctx, user.Id, KindChannel, user.Id, room.Id); err != nil { + t.Fatalf("JoinRoom: %v", err) + } + preferences := chattoCore.NotificationPolicy() + + policy, err := preferences.GetNotificationPolicy(ctx, user.Id, room.Id) + if err != nil { + t.Fatalf("GetNotificationPolicy: %v", err) + } + for _, preference := range policy { + if preference.Reason == corev1.NotificationReason_NOTIFICATION_REASON_ROOM_INVITATION { + t.Fatal("policy exposed room invitations without an occurrence producer") + } + } + if _, err := preferences.SetServerNotificationIntensity(ctx, user.Id, + corev1.NotificationReason_NOTIFICATION_REASON_ROOM_INVITATION, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + ); err == nil { + t.Fatal("SetServerNotificationIntensity accepted unsupported room invitations") + } + assertNotificationPolicyIntensity(t, policy, corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF, + ) + assertNotificationPolicyIntensity(t, policy, corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + ) + + policy, err = preferences.SetServerNotificationIntensity(ctx, user.Id, + corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + ) + if err != nil { + t.Fatalf("SetServerNotificationIntensity: %v", err) + } + assertNotificationPolicyIntensity(t, policy, corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + ) + + policy, err = preferences.SetRoomNotificationIntensity(ctx, user.Id, room.Id, + corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + ) + if err != nil { + t.Fatalf("SetRoomNotificationIntensity: %v", err) + } + assertNotificationPolicyIntensity(t, policy, corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + ) + + policy, err = preferences.SetRoomNotificationIntensity(ctx, user.Id, room.Id, + corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED, + ) + if err != nil { + t.Fatalf("clear room notification intensity: %v", err) + } + assertNotificationPolicyIntensity(t, policy, corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + ) +} + +func assertNotificationPolicyIntensity( + t *testing.T, + preferences []NotificationPolicyPreference, + reason corev1.NotificationReason, + server, room, effective corev1.NotificationDeliveryIntensity, +) { + t.Helper() + for _, preference := range preferences { + if preference.Reason != reason { + continue + } + if preference.ServerIntensity != server || preference.RoomIntensity != room || preference.Effective != effective { + t.Fatalf("preference %v = (%v, %v, %v), want (%v, %v, %v)", reason, preference.ServerIntensity, preference.RoomIntensity, preference.Effective, server, room, effective) + } + return + } + t.Fatalf("preference %v not found", reason) +} diff --git a/cli/internal/core/notification_read_boundary.go b/cli/internal/core/notification_read_boundary.go new file mode 100644 index 000000000..e21da1a41 --- /dev/null +++ b/cli/internal/core/notification_read_boundary.go @@ -0,0 +1,148 @@ +package core + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/nats-io/nats.go/jetstream" + + "hmans.de/chatto/internal/jetstreamutil" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +const notificationReadBoundaryKeyPrefix = "notification_read_boundary." + +type notificationReadBoundary struct { + targetSequence uint64 + observedSequence uint64 +} + +func notificationReadBoundaryKey(userID, roomID, threadRootEventID string) string { + key := notificationReadBoundaryKeyPrefix + userID + "." + roomID + if threadRootEventID != "" { + key += "." + threadRootEventID + } + return key +} + +func notificationReadBoundaryFilter(userID string) string { + return notificationReadBoundaryKeyPrefix + userID + ".>" +} + +func encodeNotificationReadBoundary(boundary notificationReadBoundary) []byte { + value := make([]byte, 16) + binary.BigEndian.PutUint64(value[:8], boundary.targetSequence) + binary.BigEndian.PutUint64(value[8:], boundary.observedSequence) + return value +} + +func decodeNotificationReadBoundary(value []byte) (notificationReadBoundary, error) { + if len(value) != 16 { + return notificationReadBoundary{}, fmt.Errorf("notification read boundary has invalid length %d", len(value)) + } + return notificationReadBoundary{ + targetSequence: binary.BigEndian.Uint64(value[:8]), + observedSequence: binary.BigEndian.Uint64(value[8:]), + }, nil +} + +// recordNotificationReadBoundary durably records both the timeline item the +// user read through and the EVT horizon visible when they performed the read. +// The second coordinate lets a later read cover reactions to an older message +// without incorrectly treating reactions that arrive afterwards as read. +func (m *NotificationOccurrenceModel) recordNotificationReadBoundary(ctx context.Context, userID, roomID, threadRootEventID, targetEventID string) (notificationReadBoundary, error) { + entry, ok := m.core.roomModel.timelineEntry(targetEventID) + if !ok || entry.Event == nil || roomIDOfEvent(entry.Event) != roomID { + return notificationReadBoundary{}, ErrNotFound + } + // Reactions are coverable only through the local reaction projection's + // applied horizon, not merely because a newer fact exists in EVT but has not + // yet become observable to this read operation. + next := notificationReadBoundary{ + targetSequence: entry.StreamSeq, + observedSequence: m.core.roomModel.reactions.Projector().Status().LastSeq, + } + if next.observedSequence < next.targetSequence { + next.observedSequence = next.targetSequence + } + key := notificationReadBoundaryKey(userID, roomID, threadRootEventID) + for attempt := 0; attempt < maxNotificationUpdateRetries; attempt++ { + current, err := m.kv.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + if _, err := m.kv.Create(ctx, key, encodeNotificationReadBoundary(next), jetstream.KeyTTL(notificationTTL)); err == nil { + return next, nil + } else if !jetstreamutil.IsSequenceConflict(err) { + return notificationReadBoundary{}, fmt.Errorf("create notification read boundary: %w", err) + } + continue + } + if err != nil { + return notificationReadBoundary{}, fmt.Errorf("read notification read boundary: %w", err) + } + previous, err := decodeNotificationReadBoundary(current.Value()) + if err != nil { + return notificationReadBoundary{}, err + } + if previous.targetSequence > next.targetSequence { + next.targetSequence = previous.targetSequence + } + if previous.observedSequence > next.observedSequence { + next.observedSequence = previous.observedSequence + } + if previous == next { + return next, nil + } + if _, err := m.core.updateRuntimeStateTokenTTL(ctx, key, encodeNotificationReadBoundary(next), current.Revision(), notificationTTL); err == nil { + return next, nil + } else if !jetstreamutil.IsSequenceConflict(err) { + return notificationReadBoundary{}, fmt.Errorf("update notification read boundary: %w", err) + } + } + return notificationReadBoundary{}, fmt.Errorf("write notification read boundary after %d attempts", maxNotificationUpdateRetries) +} + +func (m *NotificationOccurrenceModel) notificationReadBoundary(ctx context.Context, userID, roomID, threadRootEventID string) (notificationReadBoundary, bool, error) { + entry, err := m.kv.Get(ctx, notificationReadBoundaryKey(userID, roomID, threadRootEventID)) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + return notificationReadBoundary{}, false, nil + } + if err != nil { + return notificationReadBoundary{}, false, fmt.Errorf("read notification read boundary: %w", err) + } + boundary, err := decodeNotificationReadBoundary(entry.Value()) + return boundary, err == nil, err +} + +func (m *NotificationOccurrenceModel) occurrenceCoveredByReadBoundary(ctx context.Context, occurrence *corev1.NotificationOccurrence) (bool, error) { + if occurrence == nil || occurrence.GetSourceStreamSequence() == 0 || occurrence.GetTarget() == nil { + return false, nil + } + target := occurrence.GetTarget() + boundary, exists, err := m.notificationReadBoundary(ctx, occurrence.GetRecipientId(), target.GetRoomId(), target.GetThreadRootEventId()) + if err != nil || !exists { + return false, err + } + if notificationOccurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REACTION) { + targetEntry, ok := m.core.roomModel.timelineEntry(target.GetEventId()) + return ok && targetEntry.StreamSeq <= boundary.targetSequence && occurrence.GetSourceStreamSequence() <= boundary.observedSequence, nil + } + return occurrence.GetSourceStreamSequence() <= boundary.targetSequence, nil +} + +func (m *NotificationOccurrenceModel) purgeNotificationReadBoundaries(ctx context.Context, userID string) error { + lister, err := m.kv.ListKeysFiltered(ctx, notificationReadBoundaryFilter(userID)) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil + } + if err != nil { + return fmt.Errorf("list notification read boundaries: %w", err) + } + for key := range lister.Keys() { + if err := m.core.notificationMaterializer.deleteRuntimeStateKey(ctx, key); err != nil { + return err + } + } + return nil +} diff --git a/cli/internal/core/notification_test_helpers_test.go b/cli/internal/core/notification_test_helpers_test.go new file mode 100644 index 000000000..e987c71a6 --- /dev/null +++ b/cli/internal/core/notification_test_helpers_test.go @@ -0,0 +1,34 @@ +package core + +import ( + "testing" + + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +func testNotificationOccurrences(t *testing.T, chattoCore *ChattoCore, userID string) []*corev1.NotificationOccurrence { + t.Helper() + items, err := chattoCore.NotificationOccurrences().List(testContext(t), userID) + if err != nil { + t.Fatalf("List notification occurrences: %v", err) + } + return items +} + +func testOccurrenceHasReason(occurrence *corev1.NotificationOccurrence, reason corev1.NotificationReason) bool { + for _, match := range occurrence.GetReasons() { + if match.GetReason() == reason { + return true + } + } + return false +} + +func testDeleteAllNotificationOccurrences(t *testing.T, chattoCore *ChattoCore, userID string) { + t.Helper() + for _, occurrence := range testNotificationOccurrences(t, chattoCore, userID) { + if _, err := chattoCore.NotificationOccurrences().Delete(testContext(t), userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_DELETED); err != nil { + t.Fatalf("delete notification occurrence: %v", err) + } + } +} diff --git a/cli/internal/core/notification_visibility_boundary.go b/cli/internal/core/notification_visibility_boundary.go new file mode 100644 index 000000000..f6654b8f3 --- /dev/null +++ b/cli/internal/core/notification_visibility_boundary.go @@ -0,0 +1,90 @@ +package core + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + + "github.com/nats-io/nats.go/jetstream" + + "hmans.de/chatto/internal/jetstreamutil" +) + +const notificationVisibilityKeyPrefix = "notification_visibility_boundary." + +func notificationVisibilityBoundaryKey(userID, roomID string) string { + return notificationVisibilityKeyPrefix + userID + "." + roomID +} + +func notificationVisibilityBoundaryFilter(userID string) string { + return notificationVisibilityKeyPrefix + userID + ".*" +} + +func (m *NotificationMaterializer) recordVisibilityBoundary(ctx context.Context, userID, roomID string, sequence uint64) error { + if userID == "" || roomID == "" || sequence == 0 { + return nil + } + key := notificationVisibilityBoundaryKey(userID, roomID) + value := make([]byte, 8) + binary.BigEndian.PutUint64(value, sequence) + for attempt := 0; attempt < maxNotificationWorkWriteRetries; attempt++ { + entry, err := m.core.storage.runtimeStateKV.Get(ctx, key) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + if _, err := m.core.storage.runtimeStateKV.Create(ctx, key, value, jetstream.KeyTTL(notificationTTL)); err == nil { + return nil + } else if !jetstreamutil.IsSequenceConflict(err) { + return fmt.Errorf("create notification visibility boundary: %w", err) + } + continue + } + if err != nil { + return fmt.Errorf("read notification visibility boundary: %w", err) + } + if len(entry.Value()) != 8 { + return fmt.Errorf("notification visibility boundary has invalid length %d", len(entry.Value())) + } + if binary.BigEndian.Uint64(entry.Value()) >= sequence { + return nil + } + if _, err := m.core.updateRuntimeStateTokenTTL(ctx, key, value, entry.Revision(), notificationTTL); err == nil { + return nil + } else if !jetstreamutil.IsSequenceConflict(err) { + return fmt.Errorf("update notification visibility boundary: %w", err) + } + } + return fmt.Errorf("write notification visibility boundary after %d attempts", maxNotificationWorkWriteRetries) +} + +func (m *NotificationMaterializer) sourceAfterVisibilityBoundary(ctx context.Context, userID, roomID string, sequence uint64) (bool, error) { + if sequence == 0 { + return false, nil + } + entry, err := m.core.storage.runtimeStateKV.Get(ctx, notificationVisibilityBoundaryKey(userID, roomID)) + if errors.Is(err, jetstream.ErrKeyNotFound) || errors.Is(err, jetstream.ErrKeyDeleted) { + return true, nil + } + if err != nil { + return false, fmt.Errorf("read notification visibility boundary: %w", err) + } + if len(entry.Value()) != 8 { + return false, fmt.Errorf("notification visibility boundary has invalid length %d", len(entry.Value())) + } + return sequence > binary.BigEndian.Uint64(entry.Value()), nil +} + +func (m *NotificationMaterializer) purgeVisibilityBoundaries(ctx context.Context, userID string) error { + lister, err := m.core.storage.runtimeStateKV.ListKeysFiltered(ctx, notificationVisibilityBoundaryFilter(userID)) + if errors.Is(err, jetstream.ErrNoKeysFound) { + return nil + } + if err != nil { + return fmt.Errorf("list notification visibility boundaries: %w", err) + } + for key := range lister.Keys() { + if err := m.deleteRuntimeStateKey(ctx, key); err != nil { + return err + } + } + return nil +} diff --git a/cli/internal/core/notification_visibility_projection.go b/cli/internal/core/notification_visibility_projection.go new file mode 100644 index 000000000..0e14d470a --- /dev/null +++ b/cli/internal/core/notification_visibility_projection.go @@ -0,0 +1,415 @@ +package core + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "time" + + "google.golang.org/protobuf/proto" + + "hmans.de/chatto/internal/evtstream" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" + "hmans.de/chatto/pkg/events" +) + +var notificationVisibilitySnapshotContractID = snapshotContractID("v1", &corev1.NotificationVisibilityProjectionSnapshot{}) + +// NotificationVisibilityProjection keeps the minimum event-time state needed +// to enforce persistent notification privacy boundaries. It snapshots only +// administrative facts that the notification worker still has to acknowledge; +// ordinary membership history therefore does not add work to each cleanup. +type NotificationVisibilityProjection struct { + mu sync.RWMutex + + rooms *RoomDirectoryProjection + groups *RoomGroupLayoutProjection + rbac *RBACProjection + + // A pending run keeps one full checkpoint at its earliest boundary and a + // compact event journal after it. This makes projector-ahead replay cost + // O(state + events), rather than copying all visibility state once per + // administrative fact. + checkpointSequence uint64 + checkpoint []byte + deltas []notificationVisibilityDelta + boundaries map[uint64]struct{} + // Boundary calls are serialized by the single-lane durable worker. Keep a + // decoded cursor so processing P pending facts replays each compact delta at + // most once instead of repeatedly decoding the full checkpoint. + evaluatorSequence uint64 + evaluator *notificationVisibilitySnapshot + acknowledgedThrough atomic.Uint64 +} + +type notificationVisibilityDelta struct { + sequence uint64 + event *corev1.Event +} + +func NewNotificationVisibilityProjection() *NotificationVisibilityProjection { + return &NotificationVisibilityProjection{ + rooms: NewRoomDirectoryProjection(), + groups: NewRoomGroupLayoutProjection(), + rbac: NewRBACProjection(), + boundaries: make(map[uint64]struct{}), + } +} + +func (*NotificationVisibilityProjection) Subjects() []string { + return notificationVisibilityProjectionSubjects() +} + +func notificationVisibilityProjectionSubjects() []string { + return []string{ + evtstream.RoomEventTypeFilter(evtstream.EventRoomCreated), + evtstream.RoomEventTypeFilter(evtstream.EventRoomUniversalChanged), + evtstream.RoomEventTypeFilter(evtstream.EventRoomDeleted), + evtstream.RoomEventTypeFilter(evtstream.EventUserJoinedRoom), + evtstream.RoomEventTypeFilter(evtstream.EventUserLeftRoom), + evtstream.RoomEventTypeFilter(evtstream.EventRoomMemberBanned), + evtstream.RoomEventTypeFilter(evtstream.EventRoomMemberUnbanned), + evtstream.GroupEventTypeFilter(evtstream.EventRoomGroupCreated), + evtstream.GroupEventTypeFilter(evtstream.EventRoomGroupDeleted), + evtstream.GroupEventTypeFilter(evtstream.EventRoomAddedToGroup), + evtstream.GroupEventTypeFilter(evtstream.EventRoomRemovedFromGroup), + evtstream.RBACSubjectFilter(), + } +} + +func (p *NotificationVisibilityProjection) Apply(event *corev1.Event, seq uint64) error { + p.mu.Lock() + defer p.mu.Unlock() + if err := p.rooms.Apply(event, seq); err != nil { + return err + } + if err := p.groups.Apply(event, seq); err != nil { + return err + } + if err := p.rbac.Apply(event, seq); err != nil { + return err + } + boundary := seq > p.acknowledgedThrough.Load() && notificationVisibilityBoundaryEvent(event) + if len(p.checkpoint) == 0 { + if !boundary { + return nil + } + payload, err := encodeNotificationVisibilityState(p.rooms, p.groups, p.rbac) + if err != nil { + return fmt.Errorf("capture notification visibility checkpoint %d: %w", seq, err) + } + p.checkpointSequence = seq + p.checkpoint = payload + p.boundaries[seq] = struct{}{} + return nil + } + if seq > p.checkpointSequence { + p.deltas = append(p.deltas, notificationVisibilityDelta{ + sequence: seq, + event: proto.Clone(event).(*corev1.Event), + }) + } + if boundary { + p.boundaries[seq] = struct{}{} + } + return nil +} + +func notificationVisibilityBoundaryEvent(event *corev1.Event) bool { + if event == nil { + return false + } + switch payload := event.GetEvent().(type) { + case *corev1.Event_RoomMemberBanned, + *corev1.Event_RoomUniversalChanged, + *corev1.Event_RoomAddedToGroup, + *corev1.Event_RbacRoleAssigned, + *corev1.Event_RbacRoleRevoked, + *corev1.Event_RbacRoleDeleted: + return true + case *corev1.Event_RbacPermissionGranted: + return payload.RbacPermissionGranted.GetPermission() == string(PermRoomJoin) + case *corev1.Event_RbacPermissionDenied: + return payload.RbacPermissionDenied.GetPermission() == string(PermRoomJoin) + case *corev1.Event_RbacPermissionCleared: + return payload.RbacPermissionCleared.GetPermission() == string(PermRoomJoin) + default: + return false + } +} + +func (*NotificationVisibilityProjection) SnapshotContractID() string { + return notificationVisibilitySnapshotContractID +} + +func (p *NotificationVisibilityProjection) Snapshot() ([]byte, error) { + p.mu.RLock() + defer p.mu.RUnlock() + return encodeNotificationVisibilityState(p.rooms, p.groups, p.rbac) +} + +func (p *NotificationVisibilityProjection) Restore(data []byte) error { + rooms, groups, rbac, err := decodeNotificationVisibilityState(data) + if err != nil { + return err + } + p.mu.Lock() + p.rooms, p.groups, p.rbac = rooms, groups, rbac + p.checkpointSequence = 0 + p.checkpoint = nil + p.deltas = nil + p.boundaries = make(map[uint64]struct{}) + p.evaluatorSequence = 0 + p.evaluator = nil + p.mu.Unlock() + return nil +} + +func (p *NotificationVisibilityProjection) CompleteStartupReplay() { + p.mu.Lock() + p.rbac.CompleteStartupReplay() + p.mu.Unlock() +} + +func encodeNotificationVisibilityState(rooms *RoomDirectoryProjection, groups *RoomGroupLayoutProjection, rbac *RBACProjection) ([]byte, error) { + roomData, err := rooms.Snapshot() + if err != nil { + return nil, fmt.Errorf("snapshot room visibility: %w", err) + } + groupData, err := groups.Snapshot() + if err != nil { + return nil, fmt.Errorf("snapshot room-group visibility: %w", err) + } + rbacData, err := rbac.Snapshot() + if err != nil { + return nil, fmt.Errorf("snapshot RBAC visibility: %w", err) + } + snapshot := &corev1.NotificationVisibilityProjectionSnapshot{ + RoomDirectory: &corev1.RoomDirectoryProjectionSnapshot{}, + RoomGroupLayout: &corev1.RoomGroupLayoutProjectionSnapshot{}, + Rbac: &corev1.RBACProjectionSnapshot{}, + } + if err := proto.Unmarshal(roomData, snapshot.RoomDirectory); err != nil { + return nil, fmt.Errorf("decode room visibility snapshot: %w", err) + } + if err := proto.Unmarshal(groupData, snapshot.RoomGroupLayout); err != nil { + return nil, fmt.Errorf("decode room-group visibility snapshot: %w", err) + } + if err := proto.Unmarshal(rbacData, snapshot.Rbac); err != nil { + return nil, fmt.Errorf("decode RBAC visibility snapshot: %w", err) + } + return proto.MarshalOptions{Deterministic: true}.Marshal(snapshot) +} + +func decodeNotificationVisibilityState(data []byte) (*RoomDirectoryProjection, *RoomGroupLayoutProjection, *RBACProjection, error) { + snapshot := &corev1.NotificationVisibilityProjectionSnapshot{} + if len(data) > 0 { + if err := proto.Unmarshal(data, snapshot); err != nil { + return nil, nil, nil, fmt.Errorf("unmarshal notification visibility snapshot: %w", err) + } + } + rooms := NewRoomDirectoryProjection() + groups := NewRoomGroupLayoutProjection() + rbac := NewRBACProjection() + marshalRestore := func(value proto.Message, restore func([]byte) error) error { + payload, err := proto.MarshalOptions{Deterministic: true}.Marshal(value) + if err != nil { + return err + } + return restore(payload) + } + if err := marshalRestore(snapshot.GetRoomDirectory(), rooms.Restore); err != nil { + return nil, nil, nil, fmt.Errorf("restore room visibility: %w", err) + } + if err := marshalRestore(snapshot.GetRoomGroupLayout(), groups.Restore); err != nil { + return nil, nil, nil, fmt.Errorf("restore room-group visibility: %w", err) + } + if err := marshalRestore(snapshot.GetRbac(), rbac.Restore); err != nil { + return nil, nil, nil, fmt.Errorf("restore RBAC visibility: %w", err) + } + return rooms, groups, rbac, nil +} + +// SetAcknowledgedThrough seeds the notification consumer's confirmed floor +// before snapshot restore. Pending deliveries are replayed instead of being +// hidden behind a newer projection snapshot; ReleaseThrough advances the same +// floor after startup so unsafe generations cannot be published either. +func (p *NotificationVisibilityProjection) SetAcknowledgedThrough(sequence uint64) { + p.acknowledgedThrough.Store(sequence) +} + +func (p *NotificationVisibilityProjection) RestoreMaxCutoff() uint64 { + return p.acknowledgedThrough.Load() +} + +// AllowSnapshotPublication uses the same full durable-consumer floor as +// snapshot restore. Any filtered delivery—not only an implicit visibility +// boundary—can hold that floor behind the projector's current state. +func (p *NotificationVisibilityProjection) AllowSnapshotPublication(cutoff uint64) bool { + return cutoff <= p.acknowledgedThrough.Load() +} + +func (p *NotificationVisibilityProjection) Boundary(sequence uint64, at time.Time) (*notificationVisibilitySnapshot, error) { + p.mu.Lock() + defer p.mu.Unlock() + _, retained := p.boundaries[sequence] + if !retained || len(p.checkpoint) == 0 || sequence < p.checkpointSequence { + return nil, fmt.Errorf("notification visibility boundary %d is unavailable", sequence) + } + if p.evaluator == nil || sequence < p.evaluatorSequence { + rooms, groups, rbac, err := decodeNotificationVisibilityState(p.checkpoint) + if err != nil { + return nil, fmt.Errorf("restore notification visibility boundary %d: %w", sequence, err) + } + p.evaluator = ¬ificationVisibilitySnapshot{rooms: rooms, groups: groups, rbac: rbac} + p.evaluatorSequence = p.checkpointSequence + } + start := 0 + for start < len(p.deltas) && p.deltas[start].sequence <= p.evaluatorSequence { + start++ + } + end := start + for end < len(p.deltas) && p.deltas[end].sequence <= sequence { + end++ + } + if err := applyNotificationVisibilityDeltas(p.evaluator.rooms, p.evaluator.groups, p.evaluator.rbac, p.deltas[start:end]); err != nil { + return nil, fmt.Errorf("replay notification visibility boundary %d: %w", sequence, err) + } + p.evaluatorSequence = sequence + p.evaluator.at = at + return p.evaluator, nil +} + +func applyNotificationVisibilityDeltas(rooms *RoomDirectoryProjection, groups *RoomGroupLayoutProjection, rbac *RBACProjection, deltas []notificationVisibilityDelta) error { + for _, delta := range deltas { + if err := rooms.Apply(delta.event, delta.sequence); err != nil { + return err + } + if err := groups.Apply(delta.event, delta.sequence); err != nil { + return err + } + if err := rbac.Apply(delta.event, delta.sequence); err != nil { + return err + } + } + return nil +} + +// ReleaseThrough drops compact boundary state only through facts whose durable +// acknowledgement has been confirmed by the shared consumer. The journal is +// released as one run when its final pending boundary is acknowledged; keeping +// the single checkpoint avoids re-serializing full state per acknowledgement. +func (p *NotificationVisibilityProjection) ReleaseThrough(sequence uint64) error { + for current := p.acknowledgedThrough.Load(); sequence > current; current = p.acknowledgedThrough.Load() { + if p.acknowledgedThrough.CompareAndSwap(current, sequence) { + break + } + } + p.mu.Lock() + defer p.mu.Unlock() + if len(p.checkpoint) == 0 || sequence < p.checkpointSequence { + return nil + } + for boundary := range p.boundaries { + if boundary <= sequence { + delete(p.boundaries, boundary) + } + } + if len(p.boundaries) == 0 { + p.checkpointSequence = 0 + p.checkpoint = nil + p.deltas = nil + p.evaluatorSequence = 0 + p.evaluator = nil + return nil + } + return nil +} + +func (p *NotificationVisibilityProjection) adminProjectionEstimate() (int64, int64, []ProjectionAdminMetric) { + p.mu.RLock() + defer p.mu.RUnlock() + roomEntries, roomBytes, roomMetrics := p.rooms.adminProjectionEstimate() + groupEntries, groupBytes, groupMetrics := p.groups.adminProjectionEstimate() + rbacEntries, rbacBytes, rbacMetrics := p.rbac.adminProjectionEstimate() + metrics := append(roomMetrics, groupMetrics...) + metrics = append(metrics, rbacMetrics...) + var deltaBytes int64 + for _, delta := range p.deltas { + deltaBytes += int64(proto.Size(delta.event)) + } + metrics = append(metrics, + ProjectionAdminMetric{Name: "pending_visibility_boundaries", Value: int64(len(p.boundaries))}, + ProjectionAdminMetric{Name: "visibility_boundary_deltas", Value: int64(len(p.deltas)), Bytes: deltaBytes}, + ) + return roomEntries + groupEntries + rbacEntries + int64(len(p.boundaries)+len(p.deltas)), roomBytes + groupBytes + rbacBytes + int64(len(p.checkpoint)) + deltaBytes, metrics +} + +// cappedNotificationVisibilitySnapshotSource prevents projection restore from +// advancing beyond the shared worker's acknowledged floor. +type cappedNotificationVisibilitySnapshotSource struct { + source events.ProjectionSnapshotSource + projection *NotificationVisibilityProjection +} + +func (s cappedNotificationVisibilitySnapshotSource) LoadProjectionSnapshot(ctx context.Context, request events.ProjectionSnapshotLoadRequest) (events.ProjectionSnapshot, error) { + if cutoff := s.projection.RestoreMaxCutoff(); cutoff < request.MaxCutoff { + request.MaxCutoff = cutoff + } + return s.source.LoadProjectionSnapshot(ctx, request) +} + +type notificationVisibilitySnapshot struct { + rooms *RoomDirectoryProjection + groups *RoomGroupLayoutProjection + rbac *RBACProjection + at time.Time +} + +func (s *notificationVisibilitySnapshot) membershipExists(userID, roomID string) bool { + if s.rooms.Membership.IsMember(roomID, userID) { + return true + } + room, exists := s.rooms.Catalog.Get(roomID) + if !exists || room.GetKind() != corev1.RoomKind_ROOM_KIND_CHANNEL || !room.GetUniversal() { + return false + } + if s.rooms.Bans.IsActive(roomID, userID, s.at) { + return false + } + return s.roomJoinAllowed(userID, roomID, s.groups.Groups.GroupForRoom(roomID)) +} + +func (s *notificationVisibilitySnapshot) roomJoinAllowed(userID, roomID, groupID string) bool { + if s.rbac.HasRole(userID, RoleOwner) { + return true + } + scopes := []permissionScopeTarget{{scope: ScopeRoom, level: LevelRoom, id: roomID}} + if groupID != "" { + scopes = append(scopes, permissionScopeTarget{scope: ScopeGroup, level: LevelGroup, id: groupID}) + } + scopes = append(scopes, permissionScopeTarget{scope: ScopeServer, level: LevelServer}) + + nearest := func(subject string) (TraceEntry, bool) { + for _, target := range scopes { + decision := s.rbac.GetDecision(target.scope, target.id, subject, PermRoomJoin) + if decision != DecisionNone { + return TraceEntry{Level: target.level, RoleName: subject, Decision: decision, ObjectID: target.objectID()}, true + } + } + return TraceEntry{}, false + } + + var decisions applicablePermissionDecisions + for _, subject := range append([]string{userID}, s.rbac.GetUserRoles(userID)...) { + if entry, ok := nearest(subject); ok { + decisions.named = append(decisions.named, entry) + } + } + if entry, ok := nearest(RoleEveryone); ok { + decisions.everyone = &entry + } + decision, _, _ := resolveApplicablePermissionDecisions(decisions) + return decision == DecisionAllow +} diff --git a/cli/internal/core/notification_visibility_projection_test.go b/cli/internal/core/notification_visibility_projection_test.go new file mode 100644 index 000000000..a1fdeb8af --- /dev/null +++ b/cli/internal/core/notification_visibility_projection_test.go @@ -0,0 +1,222 @@ +package core + +import ( + "context" + "fmt" + "testing" + "time" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" + "hmans.de/chatto/pkg/events" +) + +func TestNotificationVisibilityProjectionRetainsExactBoundaryWhenCurrentStateAdvances(t *testing.T) { + p := NewNotificationVisibilityProjection() + created := &corev1.Event{Id: "create", CreatedAt: timestamppb.Now(), Event: &corev1.Event_RoomCreated{RoomCreated: &corev1.RoomCreatedEvent{ + RoomId: "R1", Kind: corev1.RoomKind_ROOM_KIND_CHANNEL, Universal: true, + }}} + loss := &corev1.Event{Id: "loss", CreatedAt: timestamppb.Now(), Event: &corev1.Event_RoomUniversalChanged{RoomUniversalChanged: &corev1.RoomUniversalChangedEvent{ + RoomId: "R1", Universal: false, + }}} + regain := &corev1.Event{Id: "regain", CreatedAt: timestamppb.Now(), Event: &corev1.Event_RoomUniversalChanged{RoomUniversalChanged: &corev1.RoomUniversalChangedEvent{ + RoomId: "R1", Universal: true, + }}} + for seq, event := range []*corev1.Event{created, loss, regain} { + if err := p.Apply(event, uint64(seq+1)); err != nil { + t.Fatalf("Apply sequence %d: %v", seq+1, err) + } + } + + lossState, err := p.Boundary(2, time.Now()) + if err != nil { + t.Fatalf("Boundary loss: %v", err) + } + lossRoom, ok := lossState.rooms.Catalog.Get("R1") + if !ok || lossRoom.GetUniversal() { + t.Fatalf("loss boundary room = (%+v, %v), want non-universal", lossRoom, ok) + } + regainState, err := p.Boundary(3, time.Now()) + if err != nil { + t.Fatalf("Boundary regain: %v", err) + } + regainRoom, ok := regainState.rooms.Catalog.Get("R1") + if !ok || !regainRoom.GetUniversal() { + t.Fatalf("regain boundary room = (%+v, %v), want universal", regainRoom, ok) + } +} + +func TestNotificationVisibilityProjectionCompactsManyPendingBoundariesOverLargeState(t *testing.T) { + p := NewNotificationVisibilityProjection() + created := &corev1.Event{Id: "create", Event: &corev1.Event_RoomCreated{RoomCreated: &corev1.RoomCreatedEvent{ + RoomId: "R1", Kind: corev1.RoomKind_ROOM_KIND_CHANNEL, Universal: true, + }}} + if err := p.Apply(created, 1); err != nil { + t.Fatalf("Apply room create: %v", err) + } + const members = 2_000 + for i := 0; i < members; i++ { + userID := fmt.Sprintf("U%04d", i) + joined := &corev1.Event{Id: "join-" + userID, ActorId: userID, Event: &corev1.Event_UserJoinedRoom{UserJoinedRoom: &corev1.UserJoinedRoomEvent{RoomId: "R1"}}} + if err := p.Apply(joined, uint64(i+2)); err != nil { + t.Fatalf("Apply join %d: %v", i, err) + } + } + + const pendingBoundaries = 500 + firstBoundary := uint64(members + 2) + for i := 0; i < pendingBoundaries; i++ { + event := &corev1.Event{Id: fmt.Sprintf("universal-%d", i), Event: &corev1.Event_RoomUniversalChanged{RoomUniversalChanged: &corev1.RoomUniversalChangedEvent{ + RoomId: "R1", Universal: i%2 == 1, + }}} + if err := p.Apply(event, firstBoundary+uint64(i)); err != nil { + t.Fatalf("Apply boundary %d: %v", i, err) + } + } + + p.mu.RLock() + checkpointBytes := len(p.checkpoint) + deltaCount := len(p.deltas) + boundaryCount := len(p.boundaries) + deltaBytes := 0 + for _, delta := range p.deltas { + deltaBytes += proto.Size(delta.event) + } + p.mu.RUnlock() + if checkpointBytes == 0 || boundaryCount != pendingBoundaries || deltaCount != pendingBoundaries-1 { + t.Fatalf("retained state = checkpoint %d bytes, %d boundaries, %d deltas", checkpointBytes, boundaryCount, deltaCount) + } + if total := checkpointBytes + deltaBytes; total >= checkpointBytes*4 { + t.Fatalf("compact journal = %d bytes for %d boundaries over %d-byte state; appears to retain repeated full snapshots", total, pendingBoundaries, checkpointBytes) + } + + lastSequence := firstBoundary + pendingBoundaries - 1 + last, err := p.Boundary(lastSequence, time.Now()) + if err != nil { + t.Fatalf("Boundary last: %v", err) + } + room, ok := last.rooms.Catalog.Get("R1") + if !ok || !room.GetUniversal() { + t.Fatalf("last boundary room = (%+v, %v), want universal", room, ok) + } +} + +func TestNotificationVisibilityProjectionBoundaryWorkDoesNotGrowWithMembershipHistory(t *testing.T) { + p := NewNotificationVisibilityProjection() + created := &corev1.Event{Id: "create", Event: &corev1.Event_RoomCreated{RoomCreated: &corev1.RoomCreatedEvent{ + RoomId: "R1", Kind: corev1.RoomKind_ROOM_KIND_CHANNEL, Universal: true, + }}} + if err := p.Apply(created, 1); err != nil { + t.Fatalf("Apply room create: %v", err) + } + const historyEvents = 10_000 + for i := 0; i < historyEvents/2; i++ { + userID := fmt.Sprintf("U%d", i) + joined := &corev1.Event{Id: fmt.Sprintf("join-%d", i), ActorId: userID, Event: &corev1.Event_UserJoinedRoom{UserJoinedRoom: &corev1.UserJoinedRoomEvent{RoomId: "R1"}}} + left := &corev1.Event{Id: fmt.Sprintf("left-%d", i), ActorId: userID, Event: &corev1.Event_UserLeftRoom{UserLeftRoom: &corev1.UserLeftRoomEvent{RoomId: "R1"}}} + if err := p.Apply(joined, uint64(2+i*2)); err != nil { + t.Fatalf("Apply join %d: %v", i, err) + } + if err := p.Apply(left, uint64(3+i*2)); err != nil { + t.Fatalf("Apply leave %d: %v", i, err) + } + } + lossSequence := uint64(historyEvents + 2) + loss := &corev1.Event{Id: "loss", Event: &corev1.Event_RoomUniversalChanged{RoomUniversalChanged: &corev1.RoomUniversalChangedEvent{RoomId: "R1", Universal: false}}} + if err := p.Apply(loss, lossSequence); err != nil { + t.Fatalf("Apply loss after membership history: %v", err) + } + + p.mu.RLock() + boundaryCount := len(p.boundaries) + p.mu.RUnlock() + if boundaryCount != 1 { + t.Fatalf("retained boundaries = %d, want 1 independent of %d membership events", boundaryCount, historyEvents) + } + if _, err := p.Boundary(lossSequence, time.Now()); err != nil { + t.Fatalf("Boundary after membership history: %v", err) + } +} + +type notificationVisibilityCapturingSnapshotSource struct { + request events.ProjectionSnapshotLoadRequest +} + +func (s *notificationVisibilityCapturingSnapshotSource) LoadProjectionSnapshot(_ context.Context, request events.ProjectionSnapshotLoadRequest) (events.ProjectionSnapshot, error) { + s.request = request + return events.ProjectionSnapshot{}, nil +} + +func TestNotificationVisibilitySnapshotRestoreIsCappedAtWorkerFloor(t *testing.T) { + projection := NewNotificationVisibilityProjection() + projection.SetAcknowledgedThrough(41) + underlying := ¬ificationVisibilityCapturingSnapshotSource{} + source := cappedNotificationVisibilitySnapshotSource{source: underlying, projection: projection} + if _, err := source.LoadProjectionSnapshot(context.Background(), events.ProjectionSnapshotLoadRequest{MaxCutoff: 99}); err != nil { + t.Fatalf("LoadProjectionSnapshot: %v", err) + } + if underlying.request.MaxCutoff != 41 { + t.Fatalf("snapshot max cutoff = %d, want worker floor 41", underlying.request.MaxCutoff) + } +} + +func TestNotificationVisibilitySnapshotPublicationPreservesSafeGenerationWhilePending(t *testing.T) { + p := NewNotificationVisibilityProjection() + p.SetAcknowledgedThrough(1) + created := &corev1.Event{Id: "create", Event: &corev1.Event_RoomCreated{RoomCreated: &corev1.RoomCreatedEvent{ + RoomId: "R1", Kind: corev1.RoomKind_ROOM_KIND_CHANNEL, Universal: true, + }}} + if err := p.Apply(created, 1); err != nil { + t.Fatalf("Apply room create: %v", err) + } + if !p.AllowSnapshotPublication(1) { + t.Fatal("snapshot before pending boundary was rejected") + } + loss := &corev1.Event{Id: "loss", Event: &corev1.Event_RoomUniversalChanged{RoomUniversalChanged: &corev1.RoomUniversalChangedEvent{ + RoomId: "R1", Universal: false, + }}} + if err := p.Apply(loss, 2); err != nil { + t.Fatalf("Apply visibility loss: %v", err) + } + if p.AllowSnapshotPublication(2) { + t.Fatal("snapshot including an unacknowledged boundary was allowed to rotate the safe generation") + } + if !p.AllowSnapshotPublication(1) { + t.Fatal("older capture before pending boundary should remain publishable") + } + if err := p.ReleaseThrough(2); err != nil { + t.Fatalf("ReleaseThrough: %v", err) + } + if !p.AllowSnapshotPublication(2) { + t.Fatal("snapshot remained blocked after confirmed acknowledgement") + } +} + +func TestNotificationVisibilitySnapshotPublicationUsesFullWorkerFloor(t *testing.T) { + p := NewNotificationVisibilityProjection() + p.SetAcknowledgedThrough(1) + created := &corev1.Event{Id: "create", Event: &corev1.Event_RoomCreated{RoomCreated: &corev1.RoomCreatedEvent{ + RoomId: "R1", Kind: corev1.RoomKind_ROOM_KIND_CHANNEL, + }}} + if err := p.Apply(created, 1); err != nil { + t.Fatalf("Apply room create: %v", err) + } + // UserJoinedRoom changes visibility state but is not an implicit-loss + // boundary. A different non-boundary worker delivery can hold AckFloor at + // the same point, so publication must still use the full shared floor. + joined := &corev1.Event{Id: "join", ActorId: "U1", Event: &corev1.Event_UserJoinedRoom{UserJoinedRoom: &corev1.UserJoinedRoomEvent{RoomId: "R1"}}} + if err := p.Apply(joined, 2); err != nil { + t.Fatalf("Apply membership delta: %v", err) + } + if p.AllowSnapshotPublication(2) { + t.Fatal("snapshot above non-boundary worker floor was allowed") + } + if err := p.ReleaseThrough(2); err != nil { + t.Fatalf("ReleaseThrough: %v", err) + } + if !p.AllowSnapshotPublication(2) { + t.Fatal("snapshot remained blocked after worker floor advanced") + } +} diff --git a/cli/internal/core/notifications.go b/cli/internal/core/notifications.go deleted file mode 100644 index 16727e01f..000000000 --- a/cli/internal/core/notifications.go +++ /dev/null @@ -1,570 +0,0 @@ -package core - -import ( - "context" - "errors" - "fmt" - "sort" - "strings" - "time" - - "github.com/nats-io/nats.go/jetstream" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/timestamppb" - - "hmans.de/chatto/internal/core/subjects" - "hmans.de/chatto/internal/jetstreamutil" - corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" -) - -// ============================================================================ -// Notification Key Helpers -// ============================================================================ - -const ( - notificationTTL = 90 * 24 * time.Hour - notificationKeyPrefix = "notification." -) - -// notificationKey returns the KV key for a notification. -// Format: notification.{userId}.{notificationId} -func notificationKey(userID, notificationID string) string { - return fmt.Sprintf("%s%s.%s", notificationKeyPrefix, userID, notificationID) -} - -// notificationKeyFilter returns the NATS subject filter for all notifications for a user. -// Uses NATS subject wildcard syntax: "notification.userID.*" matches all keys for the user. -func notificationKeyFilter(userID string) string { - return notificationKeyPrefix + userID + ".*" -} - -// ============================================================================ -// Notification CRUD Operations -// ============================================================================ - -// CreateNotification creates a new notification and publishes a sync event. -// The notification is stored in RUNTIME_STATE with a per-key TTL. -// Authorization: Internal use only - called by message posting logic. -// -// The notification parameter should already have its oneof payload set. -// Example: &corev1.Notification{Notification: &corev1.Notification_DmMessage{...}} -func (c *ChattoCore) CreateNotification( - ctx context.Context, - recipientID, actorID string, - notification *corev1.Notification, -) (*corev1.Notification, error) { - silent := c.suppressesNotificationAlertsForPresence(ctx, recipientID) - - notificationID := NewNotificationID() - now := time.Now() - - // Set/override common fields - notification.Id = notificationID - notification.RecipientId = recipientID - notification.CreatedAt = timestamppb.New(now) - notification.ActorId = actorID - - // Store in KV - data, err := proto.Marshal(notification) - if err != nil { - return nil, fmt.Errorf("failed to marshal notification: %w", err) - } - - key := notificationKey(recipientID, notificationID) - _, err = c.storage.runtimeStateKV.Create(ctx, key, data, jetstream.KeyTTL(notificationTTL)) - if err != nil { - return nil, fmt.Errorf("failed to store notification: %w", err) - } - - // Publish sync event to recipient for real-time delivery - c.publishNotificationCreatedEvent(ctx, notification, silent) - - // Call the notification callback for push notifications (if set) - // Run asynchronously to avoid blocking notification creation if push is slow - if c.OnNotificationCreated != nil && !silent { - go c.OnNotificationCreated(context.WithoutCancel(ctx), notification) - } - - c.logger.Debug("Notification created", - "notification_id", notificationID, - "recipient_id", recipientID, - "type", notificationTypeName(notification), - "silent", silent) - - return notification, nil -} - -func (c *ChattoCore) suppressesNotificationAlertsForPresence(ctx context.Context, userID string) bool { - status, err := c.GetUserPresence(ctx, userID) - if err != nil { - c.logger.Warn("Failed to get presence for notification suppression", - "user_id", userID, "error", err) - return false - } - return status == PresenceStatusDoNotDisturb -} - -// GetNotifications returns all notifications for a user, ordered by creation time (newest first). -// Authorization: Caller must verify userID matches authenticated user. -func (c *ChattoCore) GetNotifications(ctx context.Context, userID string) ([]*corev1.Notification, error) { - prefix := notificationKeyFilter(userID) - lister, err := c.storage.runtimeStateKV.ListKeysFiltered(ctx, prefix) - if err != nil { - if errors.Is(err, jetstream.ErrNoKeysFound) { - return []*corev1.Notification{}, nil - } - return nil, fmt.Errorf("failed to list notification keys: %w", err) - } - - var notifications []*corev1.Notification - for key := range lister.Keys() { - entry, err := c.storage.runtimeStateKV.Get(ctx, key) - if err != nil { - c.logger.Warn("Failed to get notification", "key", key, "error", err) - continue - } - - var notif corev1.Notification - if err := proto.Unmarshal(entry.Value(), ¬if); err != nil { - c.logger.Warn("Failed to unmarshal notification", "key", key, "error", err) - continue - } - notifications = append(notifications, ¬if) - } - - // Sort by created_at descending (newest first) - sort.Slice(notifications, func(i, j int) bool { - return notifications[i].CreatedAt.AsTime().After(notifications[j].CreatedAt.AsTime()) - }) - - return notifications, nil -} - -// GetNotification retrieves a single notification. -// Returns nil if the notification doesn't exist. -// Authorization: Caller must verify userID matches authenticated user. -func (c *ChattoCore) GetNotification(ctx context.Context, userID, notificationID string) (*corev1.Notification, error) { - key := notificationKey(userID, notificationID) - entry, err := c.storage.runtimeStateKV.Get(ctx, key) - if err != nil { - if errors.Is(err, jetstream.ErrKeyNotFound) { - return nil, nil - } - return nil, fmt.Errorf("failed to get notification: %w", err) - } - - var notif corev1.Notification - if err := proto.Unmarshal(entry.Value(), ¬if); err != nil { - return nil, fmt.Errorf("failed to unmarshal notification: %w", err) - } - - return ¬if, nil -} - -// DismissNotification deletes a notification and publishes a sync event. -// Returns true if notification existed and was deleted, false if already dismissed. -// Authorization: Caller must verify userID matches authenticated user. -func (c *ChattoCore) DismissNotification(ctx context.Context, userID, notificationID string) (bool, error) { - key := notificationKey(userID, notificationID) - - // Fetch notification before deleting (needed for push dismissal callback) - entry, err := c.storage.runtimeStateKV.Get(ctx, key) - if err != nil { - if errors.Is(err, jetstream.ErrKeyNotFound) { - return false, nil // Already dismissed - } - return false, fmt.Errorf("failed to get notification: %w", err) - } - - var notif corev1.Notification - if err := proto.Unmarshal(entry.Value(), ¬if); err != nil { - return false, fmt.Errorf("failed to unmarshal notification: %w", err) - } - - // Delete only the revision we fetched. Concurrent dismissals on another - // replica then become an idempotent no-op instead of publishing duplicate - // live events and push-dismiss callbacks. - err = c.storage.runtimeStateKV.Delete(ctx, key, jetstream.LastRevision(entry.Revision())) - if jetstreamutil.IsSequenceConflict(err) || errors.Is(err, jetstream.ErrKeyNotFound) { - return false, nil - } - if err != nil { - return false, fmt.Errorf("failed to delete notification: %w", err) - } - - // Publish sync event for cross-device sync (WebSocket) - c.publishNotificationDismissedEvent(ctx, userID, notificationID) - - // Call the notification callback for push dismissal (if set) - // Run asynchronously to avoid blocking notification dismissal - if c.OnNotificationDismissed != nil { - go c.OnNotificationDismissed(context.WithoutCancel(ctx), userID, ¬if) - } - - c.logger.Debug("Notification dismissed", - "notification_id", notificationID, - "user_id", userID) - - return true, nil -} - -// DismissAllNotifications deletes all notifications for a user. -// Returns the count of deleted notifications. -// Authorization: Caller must verify userID matches authenticated user. -func (c *ChattoCore) DismissAllNotifications(ctx context.Context, userID string) (int, error) { - prefix := notificationKeyFilter(userID) - lister, err := c.storage.runtimeStateKV.ListKeysFiltered(ctx, prefix) - if err != nil { - if errors.Is(err, jetstream.ErrNoKeysFound) { - return 0, nil - } - return 0, fmt.Errorf("failed to list notification keys: %w", err) - } - - // Collect keys first to avoid modifying while iterating - var keys []string - for key := range lister.Keys() { - keys = append(keys, key) - } - - deleted := 0 - for _, key := range keys { - var notif *corev1.Notification - entry, err := c.storage.runtimeStateKV.Get(ctx, key) - if err != nil { - if errors.Is(err, jetstream.ErrKeyNotFound) { - continue - } - return deleted, fmt.Errorf("failed to get notification before dismissing: %w", err) - } - - var decoded corev1.Notification - if err := proto.Unmarshal(entry.Value(), &decoded); err != nil { - c.logger.Warn("Failed to unmarshal notification before dismissing", "key", key, "error", err) - } else { - notif = &decoded - } - - if err := c.storage.runtimeStateKV.Delete(ctx, key, jetstream.LastRevision(entry.Revision())); err != nil { - if jetstreamutil.IsSequenceConflict(err) || errors.Is(err, jetstream.ErrKeyNotFound) { - continue - } - return deleted, fmt.Errorf("failed to delete notification: %w", err) - } - - keyPrefix := notificationKeyPrefix + userID + "." - notificationID := strings.TrimPrefix(key, keyPrefix) - if notificationID == key { - return deleted, fmt.Errorf("invalid notification key %q", key) - } - if notificationID == "" { - continue - } - deleted++ - - c.publishNotificationDismissedEvent(ctx, userID, notificationID) - - if notif != nil && c.OnNotificationDismissed != nil { - go c.OnNotificationDismissed(context.WithoutCancel(ctx), userID, notif) - } - } - - c.logger.Debug("Dismissed all notifications", - "user_id", userID, - "count", deleted) - - return deleted, nil -} - -// HasUnreadNotifications checks if a user has any notifications. -// Used for the bell icon indicator. -// Authorization: Caller must verify userID matches authenticated user. -func (c *ChattoCore) HasUnreadNotifications(ctx context.Context, userID string) (bool, error) { - prefix := notificationKeyFilter(userID) - lister, err := c.storage.runtimeStateKV.ListKeysFiltered(ctx, prefix) - if err != nil { - if errors.Is(err, jetstream.ErrNoKeysFound) { - return false, nil - } - return false, fmt.Errorf("failed to check notifications: %w", err) - } - - // Just need to check if at least one key exists - for range lister.Keys() { - return true, nil - } - return false, nil -} - -// GetNotificationCount returns the count of notifications for a user. -// Authorization: Caller must verify userID matches authenticated user. -func (c *ChattoCore) GetNotificationCount(ctx context.Context, userID string) (int, error) { - prefix := notificationKeyFilter(userID) - lister, err := c.storage.runtimeStateKV.ListKeysFiltered(ctx, prefix) - if err != nil { - if errors.Is(err, jetstream.ErrNoKeysFound) { - return 0, nil - } - return 0, fmt.Errorf("failed to count notifications: %w", err) - } - - count := 0 - for range lister.Keys() { - count++ - } - return count, nil -} - -func (c *ChattoCore) GetRoomNotificationsForMember(ctx context.Context, actorID, roomID string) ([]*corev1.Notification, error) { - if err := requireAuthenticatedActor(actorID); err != nil { - return nil, err - } - if strings.TrimSpace(roomID) == "" { - return nil, invalidArgument("room_id is required") - } - room, err := c.FindRoomByID(ctx, roomID) - if err != nil { - return nil, err - } - isMember, err := c.RoomMembershipExists(ctx, KindOfRoom(room), actorID, room.GetId()) - if err != nil || !isMember { - return []*corev1.Notification{}, nil - } - - notifications, err := c.GetNotifications(ctx, actorID) - if err != nil { - return nil, err - } - filtered := make([]*corev1.Notification, 0, len(notifications)) - for _, notification := range notifications { - if notificationTargetRoomID(notification) == room.GetId() { - filtered = append(filtered, notification) - } - } - return filtered, nil -} - -// DismissRoomReadNotifications clears pending room-level notifications covered -// by a room read marker and emits the same cross-device dismissal side effects -// as explicit notification dismissal. -func (c *ChattoCore) DismissRoomReadNotifications(ctx context.Context, kind RoomKind, userID, roomID string, readThrough time.Time) int { - if readThrough.IsZero() { - return 0 - } - count, err := c.dismissMatchingNotifications(ctx, userID, func(notification *corev1.Notification) bool { - switch payload := notification.GetNotification().(type) { - case *corev1.Notification_DmMessage: - return payload.DmMessage.GetRoomId() == roomID && - c.notificationEventAtOrBefore(ctx, kind, roomID, payload.DmMessage.GetEventId(), readThrough) - case *corev1.Notification_Mention: - return payload.Mention.GetRoomId() == roomID && - payload.Mention.GetInThread() == "" && - c.notificationEventAtOrBefore(ctx, kind, roomID, payload.Mention.GetEventId(), readThrough) - case *corev1.Notification_Reply: - return payload.Reply.GetRoomId() == roomID && - payload.Reply.GetInThread() == "" && - c.notificationEventAtOrBefore(ctx, kind, roomID, payload.Reply.GetEventId(), readThrough) - case *corev1.Notification_RoomMessage: - return payload.RoomMessage.GetRoomId() == roomID && - c.notificationEventAtOrBefore(ctx, kind, roomID, payload.RoomMessage.GetEventId(), readThrough) - default: - return false - } - }) - if err != nil { - c.logger.Warn("Failed to dismiss read room notifications", - "user_id", userID, - "room_id", roomID, - "error", err) - } - return count -} - -// DismissThreadReadNotifications clears pending thread-scoped notifications -// covered by a thread read marker and emits the same cross-device dismissal -// side effects as explicit notification dismissal. -func (c *ChattoCore) DismissThreadReadNotifications(ctx context.Context, kind RoomKind, userID, roomID, threadRootEventID string, readThrough time.Time) int { - if readThrough.IsZero() { - return 0 - } - count, err := c.dismissMatchingNotifications(ctx, userID, func(notification *corev1.Notification) bool { - switch payload := notification.GetNotification().(type) { - case *corev1.Notification_Mention: - return payload.Mention.GetRoomId() == roomID && - payload.Mention.GetInThread() == threadRootEventID && - c.notificationEventAtOrBefore(ctx, kind, roomID, payload.Mention.GetEventId(), readThrough) - case *corev1.Notification_Reply: - return payload.Reply.GetRoomId() == roomID && - payload.Reply.GetInThread() == threadRootEventID && - c.notificationEventAtOrBefore(ctx, kind, roomID, payload.Reply.GetEventId(), readThrough) - default: - return false - } - }) - if err != nil { - c.logger.Warn("Failed to dismiss read thread notifications", - "user_id", userID, - "room_id", roomID, - "thread_root_event_id", threadRootEventID, - "error", err) - } - return count -} - -func (c *ChattoCore) dismissMatchingNotifications(ctx context.Context, userID string, match func(*corev1.Notification) bool) (int, error) { - prefix := notificationKeyFilter(userID) - lister, err := c.storage.runtimeStateKV.ListKeysFiltered(ctx, prefix) - if err != nil { - if errors.Is(err, jetstream.ErrNoKeysFound) { - return 0, nil - } - return 0, fmt.Errorf("failed to list notification keys: %w", err) - } - - notificationIDs := []string{} - for key := range lister.Keys() { - entry, err := c.storage.runtimeStateKV.Get(ctx, key) - if err != nil { - if errors.Is(err, jetstream.ErrKeyNotFound) { - continue - } - return len(notificationIDs), fmt.Errorf("failed to get notification: %w", err) - } - - var notification corev1.Notification - if err := proto.Unmarshal(entry.Value(), ¬ification); err != nil { - return len(notificationIDs), fmt.Errorf("failed to unmarshal notification: %w", err) - } - if match(¬ification) { - notificationIDs = append(notificationIDs, notification.GetId()) - } - } - - dismissed := 0 - for _, notificationID := range notificationIDs { - ok, err := c.DismissNotification(ctx, userID, notificationID) - if err != nil { - return dismissed, err - } - if ok { - dismissed++ - } - } - return dismissed, nil -} - -func (c *ChattoCore) notificationEventAtOrBefore(ctx context.Context, kind RoomKind, roomID, eventID string, cutoff time.Time) bool { - if eventID == "" || cutoff.IsZero() { - return false - } - eventTime, err := c.GetEventTimestamp(ctx, kind, roomID, eventID) - if err != nil { - c.logger.Warn("Failed to resolve notification event timestamp", - "kind", kind, - "room_id", roomID, - "event_id", eventID, - "error", err) - return false - } - return !eventTime.IsZero() && !eventTime.After(cutoff) -} - -// ============================================================================ -// Real-time Sync Events -// ============================================================================ - -// publishNotificationCreatedEvent publishes a live event for cross-device sync. -func (c *ChattoCore) publishNotificationCreatedEvent(ctx context.Context, notif *corev1.Notification, silent bool) { - // Extract navigation context from the notification payload - var roomID, eventID, inReplyToID string - switch n := notif.Notification.(type) { - case *corev1.Notification_DmMessage: - roomID = n.DmMessage.RoomId - case *corev1.Notification_Mention: - roomID = n.Mention.RoomId - eventID = n.Mention.EventId - case *corev1.Notification_Reply: - roomID = n.Reply.RoomId - eventID = n.Reply.EventId - inReplyToID = n.Reply.InReplyToId - case *corev1.Notification_RoomMessage: - roomID = n.RoomMessage.RoomId - eventID = n.RoomMessage.EventId - } - - event := newLiveEvent(notif.ActorId, &corev1.LiveEvent{ - CreatedAt: notif.CreatedAt, - Event: &corev1.LiveEvent_NotificationCreated{ - NotificationCreated: &corev1.NotificationCreatedEvent{ - NotificationId: notif.Id, - RoomId: roomID, - EventId: eventID, - InReplyToId: inReplyToID, - Silent: silent, - }, - }, - }) - - subject := subjects.LiveSyncUserEvent(notif.RecipientId, "notification_created") - if err := c.publishLiveEvent(ctx, subject, event); err != nil { - c.logger.Warn("Failed to publish notification created event", - "notification_id", notif.Id, - "error", err) - } -} - -// publishNotificationDismissedEvent publishes a live event for cross-device sync. -func (c *ChattoCore) publishNotificationDismissedEvent(ctx context.Context, userID, notificationID string) { - event := newLiveEvent(userID, &corev1.LiveEvent{ - Event: &corev1.LiveEvent_NotificationDismissed{ - NotificationDismissed: &corev1.NotificationDismissedEvent{ - NotificationId: notificationID, - }, - }, - }) - - subject := subjects.LiveSyncUserEvent(userID, "notification_dismissed") - if err := c.publishLiveEvent(ctx, subject, event); err != nil { - c.logger.Warn("Failed to publish notification dismissed event", - "notification_id", notificationID, - "error", err) - } -} - -// ============================================================================ -// Helpers -// ============================================================================ - -// notificationTypeName returns a string name for the notification type. -func notificationTypeName(notif *corev1.Notification) string { - switch notif.Notification.(type) { - case *corev1.Notification_DmMessage: - return "dm_message" - case *corev1.Notification_Mention: - return "mention" - case *corev1.Notification_Reply: - return "reply" - case *corev1.Notification_RoomMessage: - return "room_message" - default: - return "unknown" - } -} - -func notificationTargetRoomID(notification *corev1.Notification) string { - if notification == nil { - return "" - } - switch payload := notification.GetNotification().(type) { - case *corev1.Notification_DmMessage: - return payload.DmMessage.GetRoomId() - case *corev1.Notification_Mention: - return payload.Mention.GetRoomId() - case *corev1.Notification_Reply: - return payload.Reply.GetRoomId() - case *corev1.Notification_RoomMessage: - return payload.RoomMessage.GetRoomId() - default: - return "" - } -} diff --git a/cli/internal/core/notifications_test.go b/cli/internal/core/notifications_test.go deleted file mode 100644 index 2b186367c..000000000 --- a/cli/internal/core/notifications_test.go +++ /dev/null @@ -1,1057 +0,0 @@ -package core - -import ( - "context" - "sync" - "testing" - "time" - - "google.golang.org/protobuf/proto" - - "hmans.de/chatto/internal/core/subjects" - corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" -) - -func TestCreateNotification(t *testing.T) { - core, nc := setupTestCore(t) - ctx := context.Background() - - recipientID := "recipient-user" - actorID := "actor-user" - - t.Run("creates DM notification", func(t *testing.T) { - notif := &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{ - RoomId: "dm-room-123", - }, - }, - } - - created, err := core.CreateNotification(ctx, recipientID, actorID, notif) - if err != nil { - t.Fatalf("CreateNotification error: %v", err) - } - if created == nil { - t.Fatal("Expected notification to be non-nil") - } - if created.Id == "" { - t.Error("Expected notification to have an ID") - } - if created.RecipientId != recipientID { - t.Errorf("Expected recipient %s, got %s", recipientID, created.RecipientId) - } - if created.ActorId != actorID { - t.Errorf("Expected actor %s, got %s", actorID, created.ActorId) - } - if created.CreatedAt == nil { - t.Error("Expected CreatedAt to be set") - } - - // Verify it's a DM notification - dmNotif := created.GetDmMessage() - if dmNotif == nil { - t.Error("Expected DM notification payload") - } - if dmNotif.RoomId != "dm-room-123" { - t.Errorf("Expected room ID dm-room-123, got %s", dmNotif.RoomId) - } - if _, err := core.storage.runtimeStateKV.Get(ctx, notificationKey(recipientID, created.Id)); err != nil { - t.Fatalf("expected notification in RUNTIME_STATE: %v", err) - } - }) - - t.Run("creates mention notification", func(t *testing.T) { - notif := &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{ - RoomId: "room-456", - EventId: "event-789", - }, - }, - } - - created, err := core.CreateNotification(ctx, recipientID, actorID, notif) - if err != nil { - t.Fatalf("CreateNotification error: %v", err) - } - - mentionNotif := created.GetMention() - if mentionNotif == nil { - t.Fatal("Expected mention notification payload") - } - if mentionNotif.RoomId != "room-456" { - t.Errorf("Expected room ID room-456, got %s", mentionNotif.RoomId) - } - if mentionNotif.EventId != "event-789" { - t.Errorf("Expected event ID event-789, got %s", mentionNotif.EventId) - } - }) - - t.Run("creates reply notification", func(t *testing.T) { - notif := &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{ - RoomId: "room-456", - EventId: "reply-event", - InReplyToId: "root-event", - }, - }, - } - - created, err := core.CreateNotification(ctx, recipientID, actorID, notif) - if err != nil { - t.Fatalf("CreateNotification error: %v", err) - } - - replyNotif := created.GetReply() - if replyNotif == nil { - t.Fatal("Expected reply notification payload") - } - if replyNotif.InReplyToId != "root-event" { - t.Errorf("Expected in reply to ID root-event, got %s", replyNotif.InReplyToId) - } - }) - - t.Run("publishes room message notification routing context", func(t *testing.T) { - subject := subjects.LiveSyncUserEvent(recipientID, "notification_created") - sub, err := nc.SubscribeSync(subject) - if err != nil { - t.Fatalf("SubscribeSync(%s): %v", subject, err) - } - defer sub.Unsubscribe() - if err := nc.Flush(); err != nil { - t.Fatalf("Flush subscription: %v", err) - } - - created, err := core.CreateNotification(ctx, recipientID, actorID, &corev1.Notification{ - Notification: &corev1.Notification_RoomMessage{ - RoomMessage: &corev1.RoomMessageNotification{ - RoomId: "all-messages-room", - EventId: "all-messages-event", - }, - }, - }) - if err != nil { - t.Fatalf("CreateNotification error: %v", err) - } - - msg, err := sub.NextMsg(2 * time.Second) - if err != nil { - t.Fatalf("waiting for notification_created live event: %v", err) - } - - var live corev1.LiveEvent - if err := proto.Unmarshal(msg.Data, &live); err != nil { - t.Fatalf("unmarshal live event: %v", err) - } - event := live.GetNotificationCreated() - if event == nil { - t.Fatalf("expected NotificationCreatedEvent, got %T", live.Event) - } - if event.NotificationId != created.Id { - t.Errorf("NotificationId = %q, want %q", event.NotificationId, created.Id) - } - if event.RoomId != "all-messages-room" { - t.Errorf("RoomId = %q, want all-messages-room", event.RoomId) - } - if event.EventId != "all-messages-event" { - t.Errorf("EventId = %q, want all-messages-event", event.EventId) - } - if event.Silent { - t.Fatal("NotificationCreatedEvent.Silent = true, want false") - } - }) - - t.Run("creates silent notifications for do not disturb recipients", func(t *testing.T) { - dndRecipientID := "dnd-recipient-user" - if err := core.SetPresence(ctx, dndRecipientID, PresenceStatusDoNotDisturb); err != nil { - t.Fatalf("SetPresence: %v", err) - } - pushCalls := make(chan *corev1.Notification, 1) - core.OnNotificationCreated = func(ctx context.Context, notification *corev1.Notification) { - pushCalls <- notification - } - t.Cleanup(func() { - core.OnNotificationCreated = nil - }) - - subject := subjects.LiveSyncUserEvent(dndRecipientID, "notification_created") - sub, err := nc.SubscribeSync(subject) - if err != nil { - t.Fatalf("SubscribeSync(%s): %v", subject, err) - } - defer sub.Unsubscribe() - if err := nc.Flush(); err != nil { - t.Fatalf("Flush subscription: %v", err) - } - - created, err := core.CreateNotification(ctx, dndRecipientID, actorID, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{ - RoomId: "dnd-room", - EventId: "dnd-event", - }, - }, - }) - if err != nil { - t.Fatalf("CreateNotification error: %v", err) - } - if created == nil { - t.Fatal("created notification = nil, want stored silent notification") - } - - notifs, err := core.GetNotifications(ctx, dndRecipientID) - if err != nil { - t.Fatalf("GetNotifications: %v", err) - } - if len(notifs) != 1 { - t.Fatalf("notifications = %d, want 1", len(notifs)) - } - - msg, err := sub.NextMsg(2 * time.Second) - if err != nil { - t.Fatalf("waiting for notification_created live event: %v", err) - } - var live corev1.LiveEvent - if err := proto.Unmarshal(msg.Data, &live); err != nil { - t.Fatalf("unmarshal live event: %v", err) - } - event := live.GetNotificationCreated() - if event == nil { - t.Fatalf("expected NotificationCreatedEvent, got %T", live.Event) - } - if !event.Silent { - t.Fatal("NotificationCreatedEvent.Silent = false, want true") - } - - select { - case notification := <-pushCalls: - t.Fatalf("push callback called with %+v, want no push for DND", notification) - case <-time.After(50 * time.Millisecond): - } - }) -} - -func TestGetNotifications(t *testing.T) { - core, _ := setupTestCore(t) - ctx := context.Background() - - userID := "get-notifs-user" - - t.Run("returns empty list when no notifications", func(t *testing.T) { - notifs, err := core.GetNotifications(ctx, userID) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) - } - if len(notifs) != 0 { - t.Errorf("Expected 0 notifications, got %d", len(notifs)) - } - }) - - t.Run("returns notifications in reverse chronological order", func(t *testing.T) { - // Create three notifications with small delays - for i := 0; i < 3; i++ { - notif := &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{ - RoomId: "room-" + string(rune('a'+i)), - }, - }, - } - _, err := core.CreateNotification(ctx, userID, "actor", notif) - if err != nil { - t.Fatalf("CreateNotification error: %v", err) - } - time.Sleep(10 * time.Millisecond) // Small delay to ensure different timestamps - } - - notifs, err := core.GetNotifications(ctx, userID) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) - } - if len(notifs) != 3 { - t.Fatalf("Expected 3 notifications, got %d", len(notifs)) - } - - // Verify order (newest first) - for i := 1; i < len(notifs); i++ { - if notifs[i-1].CreatedAt.AsTime().Before(notifs[i].CreatedAt.AsTime()) { - t.Error("Notifications not in descending chronological order") - } - } - }) -} - -func TestGetNotification(t *testing.T) { - core, _ := setupTestCore(t) - ctx := context.Background() - - userID := "single-notif-user" - - t.Run("returns nil for non-existent notification", func(t *testing.T) { - notif, err := core.GetNotification(ctx, userID, "non-existent-id") - if err != nil { - t.Fatalf("GetNotification error: %v", err) - } - if notif != nil { - t.Error("Expected nil for non-existent notification") - } - }) - - t.Run("returns existing notification", func(t *testing.T) { - created, _ := core.CreateNotification(ctx, userID, "actor", &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "test-room"}, - }, - }) - - retrieved, err := core.GetNotification(ctx, userID, created.Id) - if err != nil { - t.Fatalf("GetNotification error: %v", err) - } - if retrieved == nil { - t.Fatal("Expected notification to be found") - } - if retrieved.Id != created.Id { - t.Errorf("Expected ID %s, got %s", created.Id, retrieved.Id) - } - }) -} - -func TestDismissNotification(t *testing.T) { - core, _ := setupTestCore(t) - ctx := context.Background() - - userID := "dismiss-user" - - t.Run("returns false for non-existent notification", func(t *testing.T) { - dismissed, err := core.DismissNotification(ctx, userID, "non-existent-id") - if err != nil { - t.Fatalf("DismissNotification error: %v", err) - } - if dismissed { - t.Error("Expected false for non-existent notification") - } - }) - - t.Run("dismisses existing notification", func(t *testing.T) { - created, _ := core.CreateNotification(ctx, userID, "actor", &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "test-room"}, - }, - }) - - dismissed, err := core.DismissNotification(ctx, userID, created.Id) - if err != nil { - t.Fatalf("DismissNotification error: %v", err) - } - if !dismissed { - t.Error("Expected notification to be dismissed") - } - - // Verify it's gone - retrieved, _ := core.GetNotification(ctx, userID, created.Id) - if retrieved != nil { - t.Error("Expected notification to be deleted") - } - }) - - t.Run("returns false when dismissing same notification twice", func(t *testing.T) { - created, _ := core.CreateNotification(ctx, userID, "actor", &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "double-dismiss"}, - }, - }) - - // First dismiss - _, _ = core.DismissNotification(ctx, userID, created.Id) - - // Second dismiss should return false - dismissed, err := core.DismissNotification(ctx, userID, created.Id) - if err != nil { - t.Fatalf("Second DismissNotification error: %v", err) - } - if dismissed { - t.Error("Expected false when dismissing already dismissed notification") - } - }) - - t.Run("concurrent dismissals publish side effects once", func(t *testing.T) { - created, err := core.CreateNotification(ctx, userID, "actor", &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "concurrent-dismiss"}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification: %v", err) - } - - callbacks := make(chan struct{}, 2) - core.OnNotificationDismissed = func(context.Context, string, *corev1.Notification) { - callbacks <- struct{}{} - } - t.Cleanup(func() { core.OnNotificationDismissed = nil }) - - const attempts = 8 - start := make(chan struct{}) - results := make(chan bool, attempts) - errs := make(chan error, attempts) - var wg sync.WaitGroup - wg.Add(attempts) - for range attempts { - go func() { - defer wg.Done() - <-start - dismissed, err := core.DismissNotification(ctx, userID, created.Id) - results <- dismissed - errs <- err - }() - } - close(start) - wg.Wait() - close(results) - close(errs) - - for err := range errs { - if err != nil { - t.Fatalf("DismissNotification: %v", err) - } - } - dismissedCount := 0 - for dismissed := range results { - if dismissed { - dismissedCount++ - } - } - if dismissedCount != 1 { - t.Fatalf("successful dismissals = %d, want 1", dismissedCount) - } - - select { - case <-callbacks: - case <-time.After(time.Second): - t.Fatal("timed out waiting for dismissal callback") - } - select { - case <-callbacks: - t.Fatal("dismissal callback ran more than once") - case <-time.After(100 * time.Millisecond): - } - }) -} - -func TestDismissAllNotifications(t *testing.T) { - core, _ := setupTestCore(t) - ctx := context.Background() - - userID := "dismiss-all-user" - - t.Run("returns 0 when no notifications", func(t *testing.T) { - callbacks := make(chan *corev1.Notification, 1) - core.OnNotificationDismissed = func(ctx context.Context, userID string, notification *corev1.Notification) { - callbacks <- notification - } - - count, err := core.DismissAllNotifications(ctx, userID) - if err != nil { - t.Fatalf("DismissAllNotifications error: %v", err) - } - if count != 0 { - t.Errorf("Expected 0, got %d", count) - } - - select { - case notification := <-callbacks: - t.Fatalf("Expected no dismiss callback, got notification %s", notification.Id) - default: - } - }) - - t.Run("dismisses all notifications for user and sends dismiss callbacks", func(t *testing.T) { - callbacks := make(chan *corev1.Notification, 3) - core.OnNotificationDismissed = func(ctx context.Context, userID string, notification *corev1.Notification) { - callbacks <- notification - } - - // Create 3 notifications - expectedRoomsByID := map[string]string{} - roomIDs := []string{"room-a", "room-b", "room-c"} - for i := 0; i < 3; i++ { - created, err := core.CreateNotification(ctx, userID, "actor", &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: roomIDs[i]}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification error: %v", err) - } - expectedRoomsByID[created.Id] = created.GetDmMessage().RoomId - } - - count, err := core.DismissAllNotifications(ctx, userID) - if err != nil { - t.Fatalf("DismissAllNotifications error: %v", err) - } - if count != 3 { - t.Errorf("Expected 3 dismissed, got %d", count) - } - - // Verify all are gone - remaining, _ := core.GetNotifications(ctx, userID) - if len(remaining) != 0 { - t.Errorf("Expected 0 remaining, got %d", len(remaining)) - } - - received := map[string]string{} - for i := 0; i < 3; i++ { - select { - case notification := <-callbacks: - dm := notification.GetDmMessage() - if dm == nil { - t.Fatalf("Expected DM notification callback, got %T", notification.Notification) - } - received[notification.Id] = dm.RoomId - case <-time.After(time.Second): - t.Fatalf("Timed out waiting for dismiss callback %d", i+1) - } - } - if len(received) != len(expectedRoomsByID) { - t.Fatalf("Expected %d callbacks, got %d", len(expectedRoomsByID), len(received)) - } - for id, expectedRoom := range expectedRoomsByID { - if received[id] != expectedRoom { - t.Errorf("Expected callback for %s with room %s, got %s", id, expectedRoom, received[id]) - } - } - select { - case notification := <-callbacks: - t.Fatalf("Expected no extra dismiss callback, got notification %s", notification.Id) - default: - } - }) -} - -func TestReadMarkerNotificationDismissalPublishesSyncAndPushDismissal(t *testing.T) { - core, nc := setupTestCore(t) - ctx := testContext(t) - - author, err := core.CreateUser(ctx, SystemActorID, "read-notification-author", "Read Notification Author", "password") - if err != nil { - t.Fatalf("CreateUser author: %v", err) - } - reader, err := core.CreateUser(ctx, SystemActorID, "read-notification-reader", "Read Notification Reader", "password") - if err != nil { - t.Fatalf("CreateUser reader: %v", err) - } - room, err := core.CreateRoom(ctx, author.Id, KindChannel, "", "read-notification-room", "") - if err != nil { - t.Fatalf("CreateRoom: %v", err) - } - if _, err := core.JoinRoom(ctx, author.Id, KindChannel, author.Id, room.Id); err != nil { - t.Fatalf("JoinRoom author: %v", err) - } - if _, err := core.JoinRoom(ctx, reader.Id, KindChannel, reader.Id, room.Id); err != nil { - t.Fatalf("JoinRoom reader: %v", err) - } - - root, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "root", nil, "", "", nil, false) - if err != nil { - t.Fatalf("PostMessage root: %v", err) - } - reply1, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "reply one", nil, root.Id, "", nil, false) - if err != nil { - t.Fatalf("PostMessage reply1: %v", err) - } - reply2, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "reply two", nil, root.Id, "", nil, false) - if err != nil { - t.Fatalf("PostMessage reply2: %v", err) - } - reply3, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "reply three", nil, root.Id, "", nil, false) - if err != nil { - t.Fatalf("PostMessage reply3: %v", err) - } - - dismissedPush := make(chan string, 3) - core.OnNotificationDismissed = func(ctx context.Context, userID string, notification *corev1.Notification) { - dismissedPush <- notification.GetId() - } - - subject := subjects.LiveSyncUserEvent(reader.Id, "notification_dismissed") - sub, err := nc.SubscribeSync(subject) - if err != nil { - t.Fatalf("SubscribeSync(%s): %v", subject, err) - } - defer sub.Unsubscribe() - if err := nc.Flush(); err != nil { - t.Fatalf("Flush subscription: %v", err) - } - - coveredReply, err := core.CreateNotification(ctx, reader.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{ - RoomId: room.Id, - EventId: reply1.Id, - InReplyToId: root.Id, - InThread: root.Id, - }, - }, - }) - if err != nil { - t.Fatalf("CreateNotification covered reply: %v", err) - } - coveredMention, err := core.CreateNotification(ctx, reader.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{ - RoomId: room.Id, - EventId: reply2.Id, - InThread: root.Id, - }, - }, - }) - if err != nil { - t.Fatalf("CreateNotification covered mention: %v", err) - } - futureReply, err := core.CreateNotification(ctx, reader.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{ - RoomId: room.Id, - EventId: reply3.Id, - InReplyToId: root.Id, - InThread: root.Id, - }, - }, - }) - if err != nil { - t.Fatalf("CreateNotification future reply: %v", err) - } - - cutoff, err := core.GetEventTimestamp(ctx, KindChannel, room.Id, reply2.Id) - if err != nil { - t.Fatalf("GetEventTimestamp: %v", err) - } - if got := core.DismissThreadReadNotifications(ctx, KindChannel, reader.Id, room.Id, root.Id, cutoff); got != 2 { - t.Fatalf("DismissThreadReadNotifications = %d, want 2", got) - } - - remaining, err := core.GetNotifications(ctx, reader.Id) - if err != nil { - t.Fatalf("GetNotifications: %v", err) - } - if len(remaining) != 1 || remaining[0].GetId() != futureReply.GetId() { - t.Fatalf("remaining notifications = %+v, want only %s", remaining, futureReply.GetId()) - } - - wantIDs := map[string]bool{ - coveredReply.GetId(): true, - coveredMention.GetId(): true, - } - for i := 0; i < 2; i++ { - msg, err := sub.NextMsg(2 * time.Second) - if err != nil { - t.Fatalf("waiting for notification_dismissed live event %d: %v", i+1, err) - } - var live corev1.LiveEvent - if err := proto.Unmarshal(msg.Data, &live); err != nil { - t.Fatalf("unmarshal live event: %v", err) - } - event := live.GetNotificationDismissed() - if event == nil { - t.Fatalf("expected NotificationDismissedEvent, got %T", live.Event) - } - if !wantIDs[event.GetNotificationId()] { - t.Fatalf("unexpected live dismissal id %s", event.GetNotificationId()) - } - delete(wantIDs, event.GetNotificationId()) - } - if len(wantIDs) != 0 { - t.Fatalf("missing live dismissal ids: %v", wantIDs) - } - - wantPushIDs := map[string]bool{ - coveredReply.GetId(): true, - coveredMention.GetId(): true, - } - for i := 0; i < 2; i++ { - select { - case id := <-dismissedPush: - if !wantPushIDs[id] { - t.Fatalf("unexpected push dismissal id %s", id) - } - delete(wantPushIDs, id) - case <-time.After(2 * time.Second): - t.Fatalf("waiting for push dismissal callback %d", i+1) - } - } - if len(wantPushIDs) != 0 { - t.Fatalf("missing push dismissal ids: %v", wantPushIDs) - } -} - -func TestRoomReadNotificationDismissalPublishesSyncAndPushDismissal(t *testing.T) { - core, nc := setupTestCore(t) - ctx := testContext(t) - - author, err := core.CreateUser(ctx, SystemActorID, "room-read-notification-author", "Room Read Notification Author", "password") - if err != nil { - t.Fatalf("CreateUser author: %v", err) - } - reader, err := core.CreateUser(ctx, SystemActorID, "room-read-notification-reader", "Room Read Notification Reader", "password") - if err != nil { - t.Fatalf("CreateUser reader: %v", err) - } - room, err := core.CreateRoom(ctx, author.Id, KindChannel, "", "room-read-notification-room", "") - if err != nil { - t.Fatalf("CreateRoom: %v", err) - } - if _, err := core.JoinRoom(ctx, author.Id, KindChannel, author.Id, room.Id); err != nil { - t.Fatalf("JoinRoom author: %v", err) - } - if _, err := core.JoinRoom(ctx, reader.Id, KindChannel, reader.Id, room.Id); err != nil { - t.Fatalf("JoinRoom reader: %v", err) - } - - root, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "root", nil, "", "", nil, false) - if err != nil { - t.Fatalf("PostMessage root: %v", err) - } - threadReply, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "thread reply", nil, root.Id, "", nil, false) - if err != nil { - t.Fatalf("PostMessage thread reply: %v", err) - } - dmEvent, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "dm branch", nil, "", "", nil, false) - if err != nil { - t.Fatalf("PostMessage dm branch: %v", err) - } - mentionEvent, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "mention branch", nil, "", "", nil, false) - if err != nil { - t.Fatalf("PostMessage mention branch: %v", err) - } - replyEvent, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "reply branch", nil, "", "", nil, false) - if err != nil { - t.Fatalf("PostMessage reply branch: %v", err) - } - roomMessageEvent, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "room message branch", nil, "", "", nil, false) - if err != nil { - t.Fatalf("PostMessage room message branch: %v", err) - } - futureEvent, err := core.PostMessage(ctx, KindChannel, room.Id, author.Id, "future room message", nil, "", "", nil, false) - if err != nil { - t.Fatalf("PostMessage future room message: %v", err) - } - - dismissedPush := make(chan string, 4) - core.OnNotificationDismissed = func(ctx context.Context, userID string, notification *corev1.Notification) { - dismissedPush <- notification.GetId() - } - - subject := subjects.LiveSyncUserEvent(reader.Id, "notification_dismissed") - sub, err := nc.SubscribeSync(subject) - if err != nil { - t.Fatalf("SubscribeSync(%s): %v", subject, err) - } - defer sub.Unsubscribe() - if err := nc.Flush(); err != nil { - t.Fatalf("Flush subscription: %v", err) - } - - coveredDM, err := core.CreateNotification(ctx, reader.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: room.Id, EventId: dmEvent.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification covered dm: %v", err) - } - coveredMention, err := core.CreateNotification(ctx, reader.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{RoomId: room.Id, EventId: mentionEvent.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification covered mention: %v", err) - } - coveredReply, err := core.CreateNotification(ctx, reader.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{RoomId: room.Id, EventId: replyEvent.Id, InReplyToId: root.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification covered reply: %v", err) - } - coveredRoomMessage, err := core.CreateNotification(ctx, reader.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_RoomMessage{ - RoomMessage: &corev1.RoomMessageNotification{RoomId: room.Id, EventId: roomMessageEvent.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification covered room message: %v", err) - } - threadMention, err := core.CreateNotification(ctx, reader.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{RoomId: room.Id, EventId: threadReply.Id, InThread: root.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification thread mention: %v", err) - } - futureRoomMessage, err := core.CreateNotification(ctx, reader.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_RoomMessage{ - RoomMessage: &corev1.RoomMessageNotification{RoomId: room.Id, EventId: futureEvent.Id}, - }, - }) - if err != nil { - t.Fatalf("CreateNotification future room message: %v", err) - } - - cutoff, err := core.GetEventTimestamp(ctx, KindChannel, room.Id, roomMessageEvent.Id) - if err != nil { - t.Fatalf("GetEventTimestamp: %v", err) - } - if got := core.DismissRoomReadNotifications(ctx, KindChannel, reader.Id, room.Id, cutoff); got != 4 { - t.Fatalf("DismissRoomReadNotifications = %d, want 4", got) - } - - remaining, err := core.GetNotifications(ctx, reader.Id) - if err != nil { - t.Fatalf("GetNotifications: %v", err) - } - remainingIDs := map[string]bool{} - for _, notification := range remaining { - remainingIDs[notification.GetId()] = true - } - if !remainingIDs[threadMention.GetId()] || !remainingIDs[futureRoomMessage.GetId()] || len(remainingIDs) != 2 { - t.Fatalf("remaining notifications = %v, want thread %s and future room %s", remainingIDs, threadMention.GetId(), futureRoomMessage.GetId()) - } - - wantIDs := map[string]bool{ - coveredDM.GetId(): true, - coveredMention.GetId(): true, - coveredReply.GetId(): true, - coveredRoomMessage.GetId(): true, - } - for i := 0; i < 4; i++ { - msg, err := sub.NextMsg(2 * time.Second) - if err != nil { - t.Fatalf("waiting for notification_dismissed live event %d: %v", i+1, err) - } - var live corev1.LiveEvent - if err := proto.Unmarshal(msg.Data, &live); err != nil { - t.Fatalf("unmarshal live event: %v", err) - } - event := live.GetNotificationDismissed() - if event == nil { - t.Fatalf("expected NotificationDismissedEvent, got %T", live.Event) - } - if !wantIDs[event.GetNotificationId()] { - t.Fatalf("unexpected live dismissal id %s", event.GetNotificationId()) - } - delete(wantIDs, event.GetNotificationId()) - } - if len(wantIDs) != 0 { - t.Fatalf("missing live dismissal ids: %v", wantIDs) - } - - wantPushIDs := map[string]bool{ - coveredDM.GetId(): true, - coveredMention.GetId(): true, - coveredReply.GetId(): true, - coveredRoomMessage.GetId(): true, - } - for i := 0; i < 4; i++ { - select { - case id := <-dismissedPush: - if !wantPushIDs[id] { - t.Fatalf("unexpected push dismissal id %s", id) - } - delete(wantPushIDs, id) - case <-time.After(2 * time.Second): - t.Fatalf("waiting for push dismissal callback %d", i+1) - } - } - if len(wantPushIDs) != 0 { - t.Fatalf("missing push dismissal ids: %v", wantPushIDs) - } -} - -func TestHasUnreadNotifications(t *testing.T) { - core, _ := setupTestCore(t) - ctx := context.Background() - - userID := "has-unread-user" - - t.Run("returns false when no notifications", func(t *testing.T) { - has, err := core.HasUnreadNotifications(ctx, userID) - if err != nil { - t.Fatalf("HasUnreadNotifications error: %v", err) - } - if has { - t.Error("Expected false when no notifications") - } - }) - - t.Run("returns true when has notifications", func(t *testing.T) { - core.CreateNotification(ctx, userID, "actor", &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "room"}, - }, - }) - - has, err := core.HasUnreadNotifications(ctx, userID) - if err != nil { - t.Fatalf("HasUnreadNotifications error: %v", err) - } - if !has { - t.Error("Expected true when has notifications") - } - }) -} - -func TestGetNotificationCount(t *testing.T) { - core, _ := setupTestCore(t) - ctx := context.Background() - - userID := "count-user" - - t.Run("returns 0 when no notifications", func(t *testing.T) { - count, err := core.GetNotificationCount(ctx, userID) - if err != nil { - t.Fatalf("GetNotificationCount error: %v", err) - } - if count != 0 { - t.Errorf("Expected 0, got %d", count) - } - }) - - t.Run("returns correct count", func(t *testing.T) { - for i := 0; i < 5; i++ { - core.CreateNotification(ctx, userID, "actor", &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "room"}, - }, - }) - } - - count, err := core.GetNotificationCount(ctx, userID) - if err != nil { - t.Fatalf("GetNotificationCount error: %v", err) - } - if count != 5 { - t.Errorf("Expected 5, got %d", count) - } - }) -} - -func TestNotificationIsolation(t *testing.T) { - core, _ := setupTestCore(t) - ctx := context.Background() - - userA := "user-a" - userB := "user-b" - - t.Run("user cannot see other user's notifications", func(t *testing.T) { - // Create notification for userA - core.CreateNotification(ctx, userA, "actor", &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "room"}, - }, - }) - - // userB should not see userA's notification - userBNotifs, _ := core.GetNotifications(ctx, userB) - if len(userBNotifs) != 0 { - t.Error("userB should not see userA's notifications") - } - - // userA should see their notification - userANotifs, _ := core.GetNotifications(ctx, userA) - if len(userANotifs) != 1 { - t.Errorf("userA should have 1 notification, got %d", len(userANotifs)) - } - }) - - t.Run("dismissing does not affect other user's notifications", func(t *testing.T) { - // Clear userA's notifications - core.DismissAllNotifications(ctx, userA) - - // Create notifications for both users - core.CreateNotification(ctx, userA, "actor", &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "room"}, - }, - }) - core.CreateNotification(ctx, userB, "actor", &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "room"}, - }, - }) - - // Dismiss userA's notifications - core.DismissAllNotifications(ctx, userA) - - // userB should still have their notification - userBNotifs, _ := core.GetNotifications(ctx, userB) - if len(userBNotifs) != 1 { - t.Errorf("userB should still have 1 notification after userA dismisses, got %d", len(userBNotifs)) - } - }) -} - -func TestNotificationTypeName(t *testing.T) { - tests := []struct { - name string - notif *corev1.Notification - expected string - }{ - { - name: "dm_message", - notif: &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{}, - }, - }, - expected: "dm_message", - }, - { - name: "mention", - notif: &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{}, - }, - }, - expected: "mention", - }, - { - name: "reply", - notif: &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{}, - }, - }, - expected: "reply", - }, - { - name: "room_message", - notif: &corev1.Notification{ - Notification: &corev1.Notification_RoomMessage{ - RoomMessage: &corev1.RoomMessageNotification{}, - }, - }, - expected: "room_message", - }, - { - name: "unknown", - notif: &corev1.Notification{}, - expected: "unknown", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := notificationTypeName(tt.notif) - if got != tt.expected { - t.Errorf("notificationTypeName() = %s, want %s", got, tt.expected) - } - }) - } -} diff --git a/cli/internal/core/projection_admin.go b/cli/internal/core/projection_admin.go index e975d830e..02c1949b5 100644 --- a/cli/internal/core/projection_admin.go +++ b/cli/internal/core/projection_admin.go @@ -213,10 +213,6 @@ func (p *ConfigProjection) adminProjectionEstimate() (int64, int64, []Projection if u.timeFormat != nil { values++ } - if u.serverLevel != nil { - values++ - } - values += int64(len(u.roomLevelByRoom)) } subjects := int64(len(p.users)) if p.server.serverName != "" || diff --git a/cli/internal/core/projection_registry_test.go b/cli/internal/core/projection_registry_test.go index 5fa9ec14e..b093cfc8b 100644 --- a/cli/internal/core/projection_registry_test.go +++ b/cli/internal/core/projection_registry_test.go @@ -24,8 +24,8 @@ func registeredProjector(t *testing.T, core *ChattoCore, key string) *events.Pro func TestProjectionRegistryDrivesAdminStates(t *testing.T) { core, _ := setupTestCore(t) - if len(core.projections) != 14 { - t.Fatalf("registered projections = %d, want 14", len(core.projections)) + if len(core.projections) != 15 { + t.Fatalf("registered projections = %d, want 15", len(core.projections)) } registryNames := make(map[string]struct{}, len(core.projections)) @@ -68,6 +68,9 @@ func TestProjectionRegistryDrivesAdminStates(t *testing.T) { if _, ok := registryNames["Room Group Layout"]; !ok { t.Fatal("Room Group Layout projection is not registered") } + if _, ok := registryNames["Notification Visibility"]; !ok { + t.Fatal("Notification Visibility projection is not registered") + } if _, ok := registryNames["Call State"]; !ok { t.Fatal("Call State projection is not registered") } @@ -130,18 +133,19 @@ func TestProjectionRegistryDefinesSnapshotEligibility(t *testing.T) { core, _ := setupTestCore(t) wantEligible := map[string]struct{}{ - projectionsnapshot.ProjectionThreadsKey: {}, - projectionsnapshot.ProjectionRoomDirectoryKey: {}, - projectionsnapshot.ProjectionServerConfigKey: {}, - projectionsnapshot.ProjectionRoomGroupLayoutKey: {}, - projectionsnapshot.ProjectionRoomTimelineKey: {}, - projectionsnapshot.ProjectionCallStateKey: {}, - projectionsnapshot.ProjectionAssetsKey: {}, - projectionsnapshot.ProjectionReactionsKey: {}, - projectionsnapshot.ProjectionContentKeysKey: {}, - projectionsnapshot.ProjectionRBACKey: {}, - projectionsnapshot.ProjectionMentionablesKey: {}, - projectionsnapshot.ProjectionUsersKey: {}, + projectionsnapshot.ProjectionThreadsKey: {}, + projectionsnapshot.ProjectionRoomDirectoryKey: {}, + projectionsnapshot.ProjectionNotificationVisibilityKey: {}, + projectionsnapshot.ProjectionServerConfigKey: {}, + projectionsnapshot.ProjectionRoomGroupLayoutKey: {}, + projectionsnapshot.ProjectionRoomTimelineKey: {}, + projectionsnapshot.ProjectionCallStateKey: {}, + projectionsnapshot.ProjectionAssetsKey: {}, + projectionsnapshot.ProjectionReactionsKey: {}, + projectionsnapshot.ProjectionContentKeysKey: {}, + projectionsnapshot.ProjectionRBACKey: {}, + projectionsnapshot.ProjectionMentionablesKey: {}, + projectionsnapshot.ProjectionUsersKey: {}, } for _, registration := range core.projections { diff --git a/cli/internal/core/projection_snapshot_integration_test.go b/cli/internal/core/projection_snapshot_integration_test.go index cf5a079a2..2e4e63e00 100644 --- a/cli/internal/core/projection_snapshot_integration_test.go +++ b/cli/internal/core/projection_snapshot_integration_test.go @@ -105,6 +105,12 @@ func TestProjectionSnapshotsPersistAndRestoreCohort(t *testing.T) { // therefore do not publish or restore a zero-cutoff generation. continue } + if registration.key == projectionsnapshot.ProjectionNotificationVisibilityKey && !status.SnapshotRestored { + // A generation newer than the durable notification worker's + // acknowledged floor is intentionally rejected so pending exact + // visibility boundaries replay. + continue + } if !status.SnapshotRestored || status.SnapshotCutoffSeq == 0 { t.Errorf("%s projector did not restore its snapshot: %#v", registration.key, status) } @@ -122,8 +128,8 @@ func TestProjectionSnapshotsPersistAndRestoreCohort(t *testing.T) { } stopSecond() refreshedObjects := projectionSnapshotObjectNames(t, ctx, second) - if len(refreshedObjects) != len(firstSnapshotObjects) { - t.Fatalf("fresh restore changed generation count from %d to %d", len(firstSnapshotObjects), len(refreshedObjects)) + if len(refreshedObjects) < len(firstSnapshotObjects) || len(refreshedObjects) > len(firstSnapshotObjects)+1 { + t.Fatalf("fresh restore changed generation count unexpectedly from %d to %d", len(firstSnapshotObjects), len(refreshedObjects)) } for _, previous := range firstSnapshotObjects { if !slices.Contains(refreshedObjects, previous) { diff --git a/cli/internal/core/projection_snapshot_worker.go b/cli/internal/core/projection_snapshot_worker.go index 8930f8b6d..b392d78be 100644 --- a/cli/internal/core/projection_snapshot_worker.go +++ b/cli/internal/core/projection_snapshot_worker.go @@ -29,10 +29,11 @@ const ( ) type projectionSnapshotJob struct { - projector *events.Projector - repository *projectionsnapshot.Repository - projectionKey string - streamName string + projector *events.Projector + repository *projectionsnapshot.Repository + projectionKey string + streamName string + allowPublication func(cutoff uint64) bool } type projectionSnapshotWorker struct { @@ -215,6 +216,13 @@ func (w *projectionSnapshotWorker) generateJob(ctx context.Context, job projecti if err != nil { return fmt.Errorf("capture projection snapshot: %w", err) } + if job.allowPublication != nil && !job.allowPublication(captured.CutoffSequence) { + w.logger.Debug("Projection snapshot generation deferred behind a durable worker boundary", + "projection", job.projectionKey, + "stage", "generate_skip", + "cutoff_seq", captured.CutoffSequence) + return nil + } if err := w.lease.CheckOwnership(ctx); err != nil { return fmt.Errorf("recheck snapshot lease before publish: %w", err) } diff --git a/cli/internal/core/projection_snapshot_worker_test.go b/cli/internal/core/projection_snapshot_worker_test.go index d89f85cc7..9dc631b3c 100644 --- a/cli/internal/core/projection_snapshot_worker_test.go +++ b/cli/internal/core/projection_snapshot_worker_test.go @@ -284,6 +284,29 @@ func TestProjectionSnapshotRefreshDue(t *testing.T) { } } +func TestProjectionSnapshotWorkerDefersBeforeRepositoryWrite(t *testing.T) { + core, _ := setupTestCore(t) + ctx := testContext(t) + guardCalls := 0 + worker := &projectionSnapshotWorker{lease: &fakeSnapshotWorkerLease{}, logger: testCoreLogger()} + job := projectionSnapshotJob{ + projector: core.notificationMaterializer.visibility.Projector(), + projectionKey: projectionsnapshot.ProjectionNotificationVisibilityKey, + allowPublication: func(uint64) bool { + guardCalls++ + return false + }, + // A nil repository makes this a regression assertion that the guard is + // evaluated before any generation can be written or rotated. + } + if err := worker.generateJob(ctx, job, true); err != nil { + t.Fatalf("generateJob: %v", err) + } + if guardCalls != 1 { + t.Fatalf("publication guard calls = %d, want 1", guardCalls) + } +} + func TestProjectionSnapshotWorkerDoesNotAcquireLeaseBeforeBoot(t *testing.T) { lease := &fakeSnapshotWorkerLease{} worker := &projectionSnapshotWorker{lease: lease, logger: testCoreLogger()} diff --git a/cli/internal/core/projection_snapshots_test.go b/cli/internal/core/projection_snapshots_test.go index 67af32423..e9a3f2f90 100644 --- a/cli/internal/core/projection_snapshots_test.go +++ b/cli/internal/core/projection_snapshots_test.go @@ -69,6 +69,7 @@ func TestProjectionSnapshotContractsIncludeCurrentSchema(t *testing.T) { {configSnapshotContractID, "v1", &corev1.ConfigProjectionSnapshot{}}, {contentKeySnapshotContractID, "v1", &corev1.ContentKeyProjectionSnapshot{}}, {mentionablesSnapshotContractID, "v2", &corev1.MentionablesProjectionSnapshot{}}, + {notificationVisibilitySnapshotContractID, "v1", &corev1.NotificationVisibilityProjectionSnapshot{}}, {rbacSnapshotContractID, "v1", &corev1.RBACProjectionSnapshot{}}, {reactionSnapshotContractID, "v1", &corev1.ReactionProjectionSnapshot{}}, {roomDirectorySnapshotContractID, "v1", &corev1.RoomDirectoryProjectionSnapshot{}}, @@ -190,10 +191,9 @@ func TestProjectionSnapshotsRoundTripTransactionally(t *testing.T) { blocked := "admin" timezone := "Europe/Berlin" format := corev1.TimeFormat_TIME_FORMAT_24H - level := corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL p.server.serverName = "Chatto" p.server.blockedUsernames = &blocked - p.users["U1"] = &userConfigState{timezone: &timezone, timeFormat: &format, serverLevel: &level, roomLevelByRoom: map[string]corev1.NotificationLevel{"R1": corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES}} + p.users["U1"] = &userConfigState{timezone: &timezone, timeFormat: &format} }}, {"room_group_layout", func() snapshotProjection { return NewRoomGroupLayoutProjection() }, func(raw snapshotProjection) { p := raw.(*RoomGroupLayoutProjection) @@ -201,6 +201,13 @@ func TestProjectionSnapshotsRoundTripTransactionally(t *testing.T) { p.Groups.seq = 42 p.Layout.groupIDs = []string{"G1"} }}, + {"notification_visibility", func() snapshotProjection { return NewNotificationVisibilityProjection() }, func(raw snapshotProjection) { + p := raw.(*NotificationVisibilityProjection) + event := &corev1.Event{Id: "R1-created", Event: &corev1.Event_RoomCreated{RoomCreated: &corev1.RoomCreatedEvent{RoomId: "R1", Kind: corev1.RoomKind_ROOM_KIND_CHANNEL, Universal: true}}} + if err := p.Apply(event, 41); err != nil { + t.Fatal(err) + } + }}, {"room_timeline", func() snapshotProjection { return NewRoomTimelineProjection() }, func(raw snapshotProjection) { p := raw.(*RoomTimelineProjection) bodyEvent := &corev1.Event{Id: "BODY1", CreatedAt: timestamppb.New(now), Event: &corev1.Event_MessageBody{MessageBody: &corev1.MessageBodyEvent{RoomId: "R1", EventId: "M1", Body: &corev1.MessageBody{AuthorId: "U1", BodyEventId: "BODY1", EncryptionVersion: 2, ContentKeyEpoch: 1, EncryptedBody: []byte("ciphertext"), EncryptionNonce: bytes.Repeat([]byte{1}, 24)}}}} @@ -234,7 +241,7 @@ func TestProjectionSnapshotsRoundTripTransactionally(t *testing.T) { }}, {"reactions", func() snapshotProjection { return NewReactionProjection() }, func(raw snapshotProjection) { p := raw.(*ReactionProjection) - p.byMessage["M1"] = map[string]map[string]int64{"+1": {"U1": now.UnixNano()}} + p.byMessage["M1"] = map[string]map[string]reactionProjectionEntry{"+1": {"U1": {AddedAtNanos: now.UnixNano(), SourceEventID: "E-reaction"}}} p.roomSeq["R1"] = 41 p.messageRoom["M1"] = "R1" p.echoOriginal["M2"] = "M1" @@ -270,7 +277,8 @@ func TestProjectionSnapshotsRoundTripTransactionally(t *testing.T) { expectedContractPrefix := map[string]string{ "room_directory": "v1-", "server_config": "v1-", "room_group_layout": "v1-", - "room_timeline": "v5-", "call_state": "v1-", "assets": "v2-", "reactions": "v1-", + "notification_visibility": "v1-", + "room_timeline": "v5-", "call_state": "v1-", "assets": "v2-", "reactions": "v1-", "content_keys": "v1-", "rbac": "v1-", "mentionables": "v2-", "users": "v3-", } for _, tt := range tests { diff --git a/cli/internal/core/projection_subjects_test.go b/cli/internal/core/projection_subjects_test.go index 60c98a4d3..9be710f26 100644 --- a/cli/internal/core/projection_subjects_test.go +++ b/cli/internal/core/projection_subjects_test.go @@ -19,6 +19,11 @@ func TestProjectionSubjectPolicy(t *testing.T) { got: NewRoomDirectoryProjection().Subjects(), want: []string{evtstream.RoomSubjectFilter()}, }, + { + name: "notification visibility uses focused authorization state facts", + got: NewNotificationVisibilityProjection().Subjects(), + want: notificationVisibilityProjectionSubjects(), + }, { name: "room membership uses room aggregate namespace", got: NewRoomMembershipProjection().Subjects(), diff --git a/cli/internal/core/projection_wiring.go b/cli/internal/core/projection_wiring.go index ad8b869aa..ad9b977e6 100644 --- a/cli/internal/core/projection_wiring.go +++ b/cli/internal/core/projection_wiring.go @@ -19,19 +19,20 @@ type coreProjections struct { registrations []projectionRegistration snapshotJobs []projectionSnapshotJob - roomDirectory events.ProjectionHandle[*RoomDirectoryProjection] - serverConfig events.ProjectionHandle[*ConfigProjection] - roomGroupLayout events.ProjectionHandle[*RoomGroupLayoutProjection] - roomTimeline events.ProjectionHandle[*RoomTimelineProjection] - callState events.ProjectionHandle[*CallStateProjection] - assets events.ProjectionHandle[*AssetProjection] - threads events.ProjectionHandle[*ThreadProjection] - reactions events.ProjectionHandle[*ReactionProjection] - users events.ProjectionHandle[*UserProjection] - userAuth events.ProjectionHandle[*UserAuthProjection] - contentKeys events.ProjectionHandle[*ContentKeyProjection] - rbac events.ProjectionHandle[*RBACProjection] - mentionables events.ProjectionHandle[*MentionablesProjection] + roomDirectory events.ProjectionHandle[*RoomDirectoryProjection] + notificationVisibility events.ProjectionHandle[*NotificationVisibilityProjection] + serverConfig events.ProjectionHandle[*ConfigProjection] + roomGroupLayout events.ProjectionHandle[*RoomGroupLayoutProjection] + roomTimeline events.ProjectionHandle[*RoomTimelineProjection] + callState events.ProjectionHandle[*CallStateProjection] + assets events.ProjectionHandle[*AssetProjection] + threads events.ProjectionHandle[*ThreadProjection] + reactions events.ProjectionHandle[*ReactionProjection] + users events.ProjectionHandle[*UserProjection] + userAuth events.ProjectionHandle[*UserAuthProjection] + contentKeys events.ProjectionHandle[*ContentKeyProjection] + rbac events.ProjectionHandle[*RBACProjection] + mentionables events.ProjectionHandle[*MentionablesProjection] invitations events.ProjectionHandle[*InvitationProjection] } @@ -93,6 +94,16 @@ func initializeCoreProjections( sharedSnapshots, ) + notificationVisibility := NewNotificationVisibilityProjection() + projections.notificationVisibility = registerProjection( + registrar, + notificationVisibility, + projectionsnapshot.ProjectionNotificationVisibilityKey, + "Notification Visibility", + notificationVisibility.adminProjectionEstimate, + sharedSnapshots, + ) + serverConfig := NewConfigProjection() projections.serverConfig = registerProjection( registrar, @@ -243,19 +254,30 @@ func configureProjectionSnapshots( if registration.snapshotPolicy == coldReplayOnly { continue } + source := events.ProjectionSnapshotSource(projectionSnapshotSource{repository: infra.snapshotRepository}) + if registration.key == projectionsnapshot.ProjectionNotificationVisibilityKey { + source = cappedNotificationVisibilitySnapshotSource{ + source: source, + projection: projections.notificationVisibility.Projection(), + } + } if err := registration.projector.ConfigureSnapshots( registration.key, - projectionSnapshotSource{repository: infra.snapshotRepository}, + source, evtstream.IdentityFromInfo, ); err != nil { return fmt.Errorf("configure %s projection snapshots: %w", registration.key, err) } - projections.snapshotJobs = append(projections.snapshotJobs, projectionSnapshotJob{ + job := projectionSnapshotJob{ projector: registration.projector, repository: infra.snapshotRepository, projectionKey: registration.key, streamName: streamName, - }) + } + if registration.key == projectionsnapshot.ProjectionNotificationVisibilityKey { + job.allowPublication = projections.notificationVisibility.Projection().AllowSnapshotPublication + } + projections.snapshotJobs = append(projections.snapshotJobs, job) registration.snapshotEnabled = true } return nil diff --git a/cli/internal/core/push.go b/cli/internal/core/push.go index 5c0fa7b5b..83690697e 100644 --- a/cli/internal/core/push.go +++ b/cli/internal/core/push.go @@ -310,14 +310,12 @@ func (c *ChattoCore) GetUserPushSubscriptions(ctx context.Context, userID string for key := range lister.Keys() { entry, err := c.storage.runtimeStateKV.Get(ctx, key) if err != nil { - c.logger.Warn("Failed to get push subscription", "key", key, "error", err) - continue + return nil, fmt.Errorf("failed to get push subscription %s: %w", key, err) } var sub corev1.PushSubscription if err := proto.Unmarshal(entry.Value(), &sub); err != nil { - c.logger.Warn("Failed to unmarshal push subscription", "key", key, "error", err) - continue + return nil, fmt.Errorf("failed to unmarshal push subscription %s: %w", key, err) } owned, err := c.pushSubscriptionRevisionOwnedByUser(ctx, userID, sub.Endpoint, entry.Revision()) if err != nil { diff --git a/cli/internal/core/push_test.go b/cli/internal/core/push_test.go index 886eaf558..bc5882274 100644 --- a/cli/internal/core/push_test.go +++ b/cli/internal/core/push_test.go @@ -318,6 +318,19 @@ func TestGetUserPushSubscriptions(t *testing.T) { }) } +func TestGetUserPushSubscriptionsPropagatesCorruptRecord(t *testing.T) { + core, _ := setupTestCore(t) + ctx := testContext(t) + userID := "U-corrupt-push-record" + key := pushSubscriptionKey(userID, "https://push.example.test/corrupt") + if _, err := core.storage.runtimeStateKV.Create(ctx, key, []byte("not protobuf")); err != nil { + t.Fatalf("create corrupt push record: %v", err) + } + if _, err := core.GetUserPushSubscriptions(ctx, userID); err == nil { + t.Fatal("GetUserPushSubscriptions accepted a corrupt record") + } +} + func TestPushSubscriptionEndpointOwnershipTransfer(t *testing.T) { core, _ := setupTestCore(t) ctx := context.Background() diff --git a/cli/internal/core/rbac.go b/cli/internal/core/rbac.go index 790cc0be3..8c73a2775 100644 --- a/cli/internal/core/rbac.go +++ b/cli/internal/core/rbac.go @@ -101,12 +101,17 @@ func (c *ChattoCore) IsServerAdmin(ctx context.Context, userID string) (bool, er return c.rbacModel.hasRole(userID, RoleAdmin), nil } -// IsServerOwner checks whether a user is an effective server owner. Durable -// owner-role assignments and configured owners.emails both count so a -// configured owner cannot be locked out by edited RBAC state. +// IsServerOwner checks whether a user has the durable owner role. Configured +// owners.emails entries are materialized into that role at boot and by the +// durable notification-effects worker after email verification, so live and +// event-time authorization cannot diverge. func (c *ChattoCore) IsServerOwner(ctx context.Context, userID string) (bool, error) { - if c.rbacModel.hasRole(userID, RoleOwner) { - return true, nil + return c.rbacModel.hasRole(userID, RoleOwner), nil +} + +func (c *ChattoCore) isConfiguredOwner(ctx context.Context, userID string) (bool, error) { + if len(c.config.Owners.Emails) == 0 { + return false, nil } emails, err := c.userModel.verifiedEmails(ctx, userID) if err != nil { @@ -291,6 +296,15 @@ func (c *ChattoCore) RevokeServerRole(ctx context.Context, actorID, userID, role if roleName == RoleOwner && actorID == userID { return ErrCannotRevokeSelfAdmin } + if roleName == RoleOwner { + configured, err := c.isConfiguredOwner(ctx, userID) + if err != nil { + return err + } + if configured { + return ErrPermissionDenied + } + } if _, ok := c.rbacModel.role(roleName); !ok { return ErrRoleNotFound } @@ -323,6 +337,15 @@ func (c *ChattoCore) RevokeServerRoleFromExistingUser(ctx context.Context, actor if roleName == RoleOwner && actorID == userID { return ErrCannotRevokeSelfAdmin } + if roleName == RoleOwner { + configured, err := c.isConfiguredOwner(ctx, userID) + if err != nil { + return err + } + if configured { + return ErrPermissionDenied + } + } if _, ok := c.rbacModel.role(roleName); !ok { return ErrRoleNotFound } diff --git a/cli/internal/core/reaction_projection.go b/cli/internal/core/reaction_projection.go index 8d828e119..50bd3f07d 100644 --- a/cli/internal/core/reaction_projection.go +++ b/cli/internal/core/reaction_projection.go @@ -18,7 +18,7 @@ import ( // known. type ReactionProjection struct { events.MemoryProjection - byMessage map[string]map[string]map[string]int64 // message event ID -> emoji -> user ID -> added timestamp + byMessage map[string]map[string]map[string]reactionProjectionEntry // message event ID -> emoji -> user ID -> active reaction roomSeq map[string]uint64 messageRoom map[string]string echoOriginal map[string]string @@ -30,11 +30,17 @@ type ReactionMutationSnapshot struct { Exists bool UserReactionCount int Seq uint64 + SourceEventID string +} + +type reactionProjectionEntry struct { + AddedAtNanos int64 + SourceEventID string } func NewReactionProjection() *ReactionProjection { return &ReactionProjection{ - byMessage: make(map[string]map[string]map[string]int64), + byMessage: make(map[string]map[string]map[string]reactionProjectionEntry), roomSeq: make(map[string]uint64), messageRoom: make(map[string]string), echoOriginal: make(map[string]string), @@ -75,7 +81,7 @@ func (p *ReactionProjection) Apply(event *corev1.Event, seq uint64) error { switch e := payload.(type) { case *corev1.Event_ReactionAdded: - p.applyAdded(e.ReactionAdded, event.GetActorId(), eventCreatedNanos(event)) + p.applyAdded(e.ReactionAdded, event.GetActorId(), eventCreatedNanos(event), event.GetId()) case *corev1.Event_ReactionRemoved: p.applyRemoved(e.ReactionRemoved, event.GetActorId()) } @@ -144,23 +150,23 @@ func (p *ReactionProjection) noteRoomOwnershipLocked(event *corev1.Event, roomID } } -func (p *ReactionProjection) applyAdded(e *corev1.ReactionAddedEvent, userID string, nanos int64) { +func (p *ReactionProjection) applyAdded(e *corev1.ReactionAddedEvent, userID string, nanos int64, sourceEventID string) { if e == nil || userID == "" || e.GetMessageEventId() == "" || e.GetEmoji() == "" { return } messageEventID := p.canonicalMessageEventIDLocked(e.GetMessageEventId()) byEmoji := p.byMessage[messageEventID] if byEmoji == nil { - byEmoji = make(map[string]map[string]int64) + byEmoji = make(map[string]map[string]reactionProjectionEntry) p.byMessage[messageEventID] = byEmoji } byUser := byEmoji[e.GetEmoji()] if byUser == nil { - byUser = make(map[string]int64) + byUser = make(map[string]reactionProjectionEntry) byEmoji[e.GetEmoji()] = byUser } if _, exists := byUser[userID]; !exists { - byUser[userID] = nanos + byUser[userID] = reactionProjectionEntry{AddedAtNanos: nanos, SourceEventID: sourceEventID} } } @@ -218,7 +224,9 @@ func (p *ReactionProjection) ReactionMutationSnapshot(roomID, messageEventID, em snapshot.UserReactionCount++ } } - _, snapshot.Exists = byEmoji[emoji][userID] + entry, exists := byEmoji[emoji][userID] + snapshot.Exists = exists + snapshot.SourceEventID = entry.SourceEventID return snapshot } @@ -254,7 +262,7 @@ func (p *ReactionProjection) Stats() (messages int, activeReactions int) { return messages, activeReactions } -func reactionSummariesForMessage(byEmoji map[string]map[string]int64) []ReactionSummary { +func reactionSummariesForMessage(byEmoji map[string]map[string]reactionProjectionEntry) []ReactionSummary { if len(byEmoji) == 0 { return nil } @@ -266,10 +274,10 @@ func reactionSummariesForMessage(byEmoji map[string]map[string]int64) []Reaction for emoji, byUser := range byEmoji { userIDs := make([]string, 0, len(byUser)) var earliest int64 - for userID, nanos := range byUser { + for userID, entry := range byUser { userIDs = append(userIDs, userID) - if earliest == 0 || nanos < earliest { - earliest = nanos + if earliest == 0 || entry.AddedAtNanos < earliest { + earliest = entry.AddedAtNanos } } slices.Sort(userIDs) diff --git a/cli/internal/core/reaction_projection_snapshot.go b/cli/internal/core/reaction_projection_snapshot.go index cc94284f4..13ab60549 100644 --- a/cli/internal/core/reaction_projection_snapshot.go +++ b/cli/internal/core/reaction_projection_snapshot.go @@ -21,7 +21,8 @@ func (p *ReactionProjection) Snapshot() ([]byte, error) { for _, emoji := range sortedMapKeys(p.byMessage[messageID]) { group := &corev1.EmojiReactionsSnapshot{Emoji: emoji} for _, userID := range sortedMapKeys(p.byMessage[messageID][emoji]) { - group.Users = append(group.Users, &corev1.UserReactionSnapshot{UserId: userID, AddedAtNanos: p.byMessage[messageID][emoji][userID]}) + entry := p.byMessage[messageID][emoji][userID] + group.Users = append(group.Users, &corev1.UserReactionSnapshot{UserId: userID, AddedAtNanos: entry.AddedAtNanos, SourceEventId: entry.SourceEventID}) } message.Emojis = append(message.Emojis, group) } @@ -54,7 +55,7 @@ func (p *ReactionProjection) Restore(data []byte) error { if err != nil { return fmt.Errorf("reaction snapshot replay guard: %w", err) } - byMessage := make(map[string]map[string]map[string]int64, len(snapshot.GetMessages())) + byMessage := make(map[string]map[string]map[string]reactionProjectionEntry, len(snapshot.GetMessages())) for _, message := range snapshot.GetMessages() { if message.GetMessageEventId() == "" { return fmt.Errorf("reaction snapshot has empty message ID") @@ -62,7 +63,7 @@ func (p *ReactionProjection) Restore(data []byte) error { if _, duplicate := byMessage[message.GetMessageEventId()]; duplicate { return fmt.Errorf("reaction snapshot repeats message %q", message.GetMessageEventId()) } - emojis := make(map[string]map[string]int64) + emojis := make(map[string]map[string]reactionProjectionEntry) for _, group := range message.GetEmojis() { if group.GetEmoji() == "" { return fmt.Errorf("reaction snapshot has empty emoji") @@ -70,7 +71,7 @@ func (p *ReactionProjection) Restore(data []byte) error { if _, duplicate := emojis[group.GetEmoji()]; duplicate { return fmt.Errorf("reaction snapshot repeats emoji") } - users := make(map[string]int64) + users := make(map[string]reactionProjectionEntry) for _, user := range group.GetUsers() { if user.GetUserId() == "" { return fmt.Errorf("reaction snapshot has empty user ID") @@ -78,7 +79,7 @@ func (p *ReactionProjection) Restore(data []byte) error { if _, duplicate := users[user.GetUserId()]; duplicate { return fmt.Errorf("reaction snapshot repeats user") } - users[user.GetUserId()] = user.GetAddedAtNanos() + users[user.GetUserId()] = reactionProjectionEntry{AddedAtNanos: user.GetAddedAtNanos(), SourceEventID: user.GetSourceEventId()} } emojis[group.GetEmoji()] = users } diff --git a/cli/internal/core/reactions.go b/cli/internal/core/reactions.go index 5597d4f1b..a34269709 100644 --- a/cli/internal/core/reactions.go +++ b/cli/internal/core/reactions.go @@ -65,15 +65,25 @@ func (s *ReactionModel) addReaction(ctx context.Context, kind RoomKind, roomID, if err != nil { return false, err } + target, err := s.core.GetRoomEventByEventID(ctx, kind, roomID, messageEventID) + if err != nil { + return false, fmt.Errorf("resolve reaction notification target: %w", err) + } + if target == nil { + return false, ErrNotFound + } event := newReactionAddedEvent(userID, roomID, messageEventID, emojiName) - added, err := s.publishReactionMutation(ctx, kind, roomID, messageEventID, emojiName, userID, event) + added, sequence, err := s.publishReactionMutation(ctx, kind, roomID, messageEventID, emojiName, userID, event, target, "") if err != nil { return false, fmt.Errorf("failed to add reaction: %w", err) } if !added { return false, nil } - + if err := s.core.notificationMaterializer.WaitThrough(ctx, sequence); err != nil { + s.core.logger.Warn("Notification materialization did not reach the committed reaction before the request completed", + "room_id", roomID, "message_event_id", messageEventID, "error", err) + } s.core.logger.Debug("Reaction added", "kind", kind, "room_id", roomID, @@ -99,15 +109,29 @@ func (s *ReactionModel) removeReaction(ctx context.Context, kind RoomKind, roomI if err != nil { return false, err } + target, err := s.core.GetRoomEventByEventID(ctx, kind, roomID, messageEventID) + if err != nil { + return false, fmt.Errorf("resolve reaction notification target: %w", err) + } + if target == nil { + return false, ErrNotFound + } event := newReactionRemovedEvent(userID, roomID, messageEventID, emojiName) - removed, err := s.publishReactionMutation(ctx, kind, roomID, messageEventID, emojiName, userID, event) + notificationRecipientID := "" + if target.GetActorId() != userID { + notificationRecipientID = target.GetActorId() + } + removed, sequence, err := s.publishReactionMutation(ctx, kind, roomID, messageEventID, emojiName, userID, event, nil, notificationRecipientID) if err != nil { return false, fmt.Errorf("failed to remove reaction: %w", err) } if !removed { return false, nil } - + if err := s.core.notificationMaterializer.WaitThrough(ctx, sequence); err != nil { + s.core.logger.Warn("Notification cleanup did not reach the committed reaction removal before the request completed", + "room_id", roomID, "message_event_id", messageEventID, "error", err) + } s.core.logger.Debug("Reaction removed", "kind", kind, "room_id", roomID, @@ -291,7 +315,6 @@ func (s *ReactionModel) mutateAuthorizedReaction(ctx context.Context, input Reac publishSubject := agg.SubjectFor(event) committedKind := KindChannel committedMessageEventID := input.MessageEventID - result, err := s.executeMutation(ctx, events.AtSubject(agg.AllEventsFilter()), func(ctx context.Context, _ events.MutationAttempt) ([]evtstream.MutationEntry, error) { kind, err := s.prepareAuthorizedReactionAttempt(ctx, input) if err != nil { @@ -331,7 +354,35 @@ func (s *ReactionModel) mutateAuthorizedReaction(ctx context.Context, input Reac committedKind = kind committedMessageEventID = messageEventID - return []evtstream.MutationEntry{{Subject: publishSubject, Event: event}}, nil + entries := []evtstream.MutationEntry{{Subject: publishSubject, Event: event}} + target, err := s.core.GetRoomEventByEventID(ctx, kind, input.RoomID, messageEventID) + if err != nil { + return nil, fmt.Errorf("resolve reaction notification target: %w", err) + } + if target == nil { + return nil, ErrNotFound + } + recipientID := target.GetActorId() + var notificationWork []*corev1.NotificationOccurrence + if recipientID != "" && recipientID != input.ActorID { + if add { + if err := s.core.waitForCurrentNotificationPolicy(ctx); err != nil { + return nil, err + } + notificationWork = s.core.buildReactionNotificationWork(event, target, input.RoomID, messageEventID) + } else { + notificationWork = newNotificationRevocationWork( + event, + recipientID, + snapshot.SourceEventID, + corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_REACTION_REMOVED, + ) + } + } + if err := s.core.notificationMaterializer.StoreWork(ctx, event, notificationWork); err != nil { + return nil, fmt.Errorf("prepare reaction notification work: %w", err) + } + return entries, nil }) if err != nil { verb := "remove" @@ -343,13 +394,16 @@ func (s *ReactionModel) mutateAuthorizedReaction(ctx context.Context, input Reac if !result.Committed { return false, nil } - if len(result.Sequences) != 1 { - return false, fmt.Errorf("reaction mutation committed %d events, want 1", len(result.Sequences)) + if len(result.Sequences) == 0 { + return false, fmt.Errorf("reaction mutation committed without a sequence") } if err := s.core.roomModel.waitForReactions(ctx, events.SubjectPosition(publishSubject, result.Sequences[0])); err != nil { return false, fmt.Errorf("wait for reactions projection: %w", err) } - + if err := s.core.notificationMaterializer.WaitThrough(ctx, result.Sequences[0]); err != nil { + s.core.logger.Warn("Notification effect did not reach the committed reaction mutation before the request completed", + "room_id", input.RoomID, "message_event_id", committedMessageEventID, "error", err) + } action := "removed" if add { action = "added" @@ -410,11 +464,11 @@ func (s *ReactionModel) prepareAuthorizedReactionAttempt(ctx context.Context, in return s.authorizeReaction(ctx, input) } -func (s *ReactionModel) publishReactionMutation(ctx context.Context, kind RoomKind, roomID, messageEventID, emoji, userID string, event *corev1.Event) (bool, error) { +func (s *ReactionModel) publishReactionMutation(ctx context.Context, kind RoomKind, roomID, messageEventID, emoji, userID string, event, notificationTarget *corev1.Event, notificationRecipientID string) (bool, uint64, error) { add := event.GetReactionAdded() != nil remove := event.GetReactionRemoved() != nil if !add && !remove { - return false, fmt.Errorf("unsupported reaction event %T", event.GetEvent()) + return false, 0, fmt.Errorf("unsupported reaction event %T", event.GetEvent()) } agg := evtstream.RoomAggregate(roomID) @@ -438,20 +492,38 @@ func (s *ReactionModel) publishReactionMutation(ctx context.Context, kind RoomKi } else if !snapshot.Exists { return nil, nil } + entries := []evtstream.MutationEntry{{Subject: publishSubject, Event: event}} + var work []*corev1.NotificationOccurrence + if add && notificationTarget != nil { + if err := s.core.waitForCurrentNotificationPolicy(ctx); err != nil { + return nil, err + } + work = s.core.buildReactionNotificationWork(event, notificationTarget, roomID, messageEventID) + } else if remove { + work = newNotificationRevocationWork( + event, + notificationRecipientID, + snapshot.SourceEventID, + corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_REACTION_REMOVED, + ) + } + if err := s.core.notificationMaterializer.StoreWork(ctx, event, work); err != nil { + return nil, fmt.Errorf("prepare reaction notification work: %w", err) + } - return []evtstream.MutationEntry{{Subject: publishSubject, Event: event}}, nil + return entries, nil }) if err != nil { - return false, err + return false, 0, err } if !result.Committed { - return false, nil + return false, 0, nil } - if len(result.Sequences) != 1 { - return false, fmt.Errorf("reaction mutation committed %d events, want 1", len(result.Sequences)) + if len(result.Sequences) == 0 { + return false, 0, fmt.Errorf("reaction mutation committed without a sequence") } if err := s.core.roomModel.waitForReactions(ctx, events.SubjectPosition(publishSubject, result.Sequences[0])); err != nil { - return false, fmt.Errorf("wait for reactions projection: %w", err) + return false, 0, fmt.Errorf("wait for reactions projection: %w", err) } - return true, nil + return true, result.Sequences[0], nil } diff --git a/cli/internal/core/reactions_test.go b/cli/internal/core/reactions_test.go index a8ece8e68..95f5208d7 100644 --- a/cli/internal/core/reactions_test.go +++ b/cli/internal/core/reactions_test.go @@ -120,6 +120,52 @@ func TestReactionModel_AddReactionWrite(t *testing.T) { }) } +func TestReactionNotificationOccurrenceLifecycle(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "reaction-notification-author", "Reaction Author", "password123") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + reactor, err := chattoCore.CreateUser(ctx, SystemActorID, "reaction-notification-reactor", "Reaction Reactor", "password123") + if err != nil { + t.Fatalf("CreateUser reactor: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "reaction-notification-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + for _, userID := range []string{author.Id, reactor.Id} { + if _, err := chattoCore.JoinRoom(ctx, author.Id, KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %q: %v", userID, err) + } + } + message, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "react to this", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + + added, err := chattoCore.ReactionModel().addReaction(ctx, KindChannel, room.Id, message.Id, "thumbsup", reactor.Id) + if err != nil || !added { + t.Fatalf("addReaction = %v, %v", added, err) + } + occurrences := testNotificationOccurrences(t, chattoCore, author.Id) + if len(occurrences) != 1 || !testOccurrenceHasReason(occurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_REACTION) { + t.Fatalf("reaction occurrences = %+v, want one reaction occurrence", occurrences) + } + if occurrences[0].GetTarget().GetEventId() != message.Id || occurrences[0].GetActorId() != reactor.Id { + t.Fatalf("reaction occurrence = %+v, want message %q and actor %q", occurrences[0], message.Id, reactor.Id) + } + + removed, err := chattoCore.ReactionModel().removeReaction(ctx, KindChannel, room.Id, message.Id, "thumbsup", reactor.Id) + if err != nil || !removed { + t.Fatalf("removeReaction = %v, %v", removed, err) + } + if occurrences := testNotificationOccurrences(t, chattoCore, author.Id); len(occurrences) != 0 { + t.Fatalf("reaction occurrences after removal = %+v, want none", occurrences) + } +} + func TestReactionModel_AddReactionConcurrentDuplicate(t *testing.T) { core, _ := setupTestCore(t) ctx := testContext(t) @@ -309,7 +355,7 @@ func TestReactionModel_AddReactionRefreshesStaleNoopSnapshot(t *testing.T) { } resultCh := make(chan result, 1) go func() { - added, err := service.publishReactionMutation( + added, _, err := service.publishReactionMutation( ctx, KindChannel, "R1", @@ -317,6 +363,8 @@ func TestReactionModel_AddReactionRefreshesStaleNoopSnapshot(t *testing.T) { "thumbsup", "U1", newReactionAddedEvent("U1", "R1", "M1", "thumbsup"), + nil, + "", ) resultCh <- result{added: added, err: err} }() @@ -377,7 +425,7 @@ func TestReactionModel_AddReactionRefreshesStaleLimitSnapshot(t *testing.T) { } resultCh := make(chan result, 1) go func() { - added, err := service.publishReactionMutation(ctx, KindChannel, "R1", "M1", "wink", "U1", newReactionAddedEvent("U1", "R1", "M1", "wink")) + added, _, err := service.publishReactionMutation(ctx, KindChannel, "R1", "M1", "wink", "U1", newReactionAddedEvent("U1", "R1", "M1", "wink"), nil, "") resultCh <- result{added: added, err: err} }() diff --git a/cli/internal/core/read_state_model.go b/cli/internal/core/read_state_model.go index 0875c952d..4719329ac 100644 --- a/cli/internal/core/read_state_model.go +++ b/cli/internal/core/read_state_model.go @@ -111,11 +111,14 @@ func (s *ReadStateModel) MarkRoomAsRead(ctx context.Context, actorID, roomID, up } } - dismissedNotifications := 0 - if hasLast && !lastTime.IsZero() { - dismissedNotifications = s.core.DismissRoomReadNotifications(ctx, kind, actorID, room.Id, lastTime) + readNotifications := 0 + if hasLast && lastEventID != "" { + readNotifications, err = s.core.notificationOccurrences.MarkCoveredRead(ctx, actorID, room.Id, "", lastEventID) + if err != nil { + return nil, fmt.Errorf("reconcile room read state with notification inbox: %w", err) + } } - if markerUpdated || dismissedNotifications > 0 { + if markerUpdated || readNotifications > 0 { s.core.NotifyRoomMarkedAsRead(ctx, actorID, kind, room.Id) } @@ -165,8 +168,8 @@ func (s *ReadStateModel) MarkThreadAsRead(ctx context.Context, actorID, roomID, return nil, err } if markerEventID != "" { - if markerTime, err := s.core.GetEventTimestamp(ctx, kind, room.Id, markerEventID); err == nil && !markerTime.IsZero() { - s.core.DismissThreadReadNotifications(ctx, kind, actorID, room.Id, threadRootEventID, markerTime) + if _, err := s.core.notificationOccurrences.MarkCoveredRead(ctx, actorID, room.Id, threadRootEventID, markerEventID); err != nil { + return nil, fmt.Errorf("reconcile thread read state with notification inbox: %w", err) } } return &MarkThreadAsReadResult{PreviousReadAt: previousReadAt}, nil diff --git a/cli/internal/core/read_state_model_test.go b/cli/internal/core/read_state_model_test.go index aeef48ce7..46b757808 100644 --- a/cli/internal/core/read_state_model_test.go +++ b/cli/internal/core/read_state_model_test.go @@ -122,7 +122,7 @@ func TestReadStateModel_MarkRoomAsReadPublishesLiveEventWhenCursorAdvances(t *te expectRoomReadLiveEvent(t, sub, room.Id) } -func TestReadStateModel_MarkRoomAsReadPublishesLiveEventWhenNotificationsDismissed(t *testing.T) { +func TestReadStateModel_MarkRoomAsReadPublishesLiveEventWhenOccurrencesBecomeRead(t *testing.T) { core, nc := setupTestCore(t) ctx := testContext(t) @@ -147,13 +147,26 @@ func TestReadStateModel_MarkRoomAsReadPublishesLiveEventWhenNotificationsDismiss if err := core.SetLastReadEventID(ctx, KindChannel, reader.Id, room.Id, second.Id); err != nil { t.Fatalf("SetLastReadEventID: %v", err) } - notification, err := core.CreateNotification(ctx, reader.Id, poster.Id, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{RoomId: room.Id, EventId: first.Id}, - }, + firstEntry, ok := core.roomModel.timelineEntry(first.Id) + if !ok { + t.Fatal("first message missing from timeline") + } + notification, _, err := core.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: reader.Id, + SourceEventID: first.Id, + SourceCreated: first.GetCreatedAt().AsTime(), + ActorID: poster.Id, + Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: first.Id}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + InitialState: corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD, + SkipReadLookup: true, + SourceStreamSequence: firstEntry.StreamSeq, }) if err != nil { - t.Fatalf("CreateNotification: %v", err) + t.Fatalf("Create occurrence: %v", err) } sub := subscribeRoomReadLiveEvents(t, nc, reader.Id) @@ -162,13 +175,62 @@ func TestReadStateModel_MarkRoomAsReadPublishesLiveEventWhenNotificationsDismiss } expectRoomReadLiveEvent(t, sub, room.Id) - remaining, err := core.GetNotifications(ctx, reader.Id) + remaining, err := core.NotificationOccurrences().List(ctx, reader.Id) if err != nil { - t.Fatalf("GetNotifications: %v", err) + t.Fatalf("List occurrences: %v", err) } for _, item := range remaining { - if item.GetId() == notification.GetId() { - t.Fatalf("notification %s was not dismissed", notification.GetId()) + if item.GetId() == notification.GetId() && item.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ { + t.Fatalf("notification %s state = %v, want read", notification.GetId(), item.GetInboxState()) } } } + +func TestReadStateModel_MarkRoomAsReadCoversReactionToReadMessage(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + author, err := chattoCore.CreateUser(ctx, SystemActorID, "reaction-read-author", "Reaction Read Author", "password") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } + reactor, err := chattoCore.CreateUser(ctx, SystemActorID, "reaction-read-reactor", "Reaction Read Reactor", "password") + if err != nil { + t.Fatalf("CreateUser reactor: %v", err) + } + room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "reaction-read-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + for _, userID := range []string{author.Id, reactor.Id} { + if _, err := chattoCore.JoinRoom(ctx, userID, KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %s: %v", userID, err) + } + } + posted, err := chattoCore.PostMessage(ctx, KindChannel, room.Id, author.Id, "react here", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + if added, err := chattoCore.ReactionModel().AddReaction(ctx, ReactionMutationInput{ + ActorID: reactor.Id, RoomID: room.Id, MessageEventID: posted.Id, Emoji: "thumbsup", + }); err != nil || !added { + t.Fatalf("AddReaction = (%v, %v)", added, err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, author.Id) + if err != nil || len(occurrences) != 1 { + t.Fatalf("List reaction occurrences = (%d, %v), want one", len(occurrences), err) + } + if occurrences[0].GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { + t.Fatalf("reaction occurrence starts %v, want UNREAD", occurrences[0].GetInboxState()) + } + + if _, err := chattoCore.ReadState().MarkRoomAsRead(ctx, author.Id, room.Id, posted.Id); err != nil { + t.Fatalf("MarkRoomAsRead: %v", err) + } + updated, err := chattoCore.NotificationOccurrences().Get(ctx, author.Id, occurrences[0].GetId()) + if err != nil { + t.Fatalf("Get reaction occurrence: %v", err) + } + if updated.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ { + t.Fatalf("reaction occurrence state = %v, want READ", updated.GetInboxState()) + } +} diff --git a/cli/internal/core/role_assignment_authorization.go b/cli/internal/core/role_assignment_authorization.go index 5aac2f61b..e52caa71b 100644 --- a/cli/internal/core/role_assignment_authorization.go +++ b/cli/internal/core/role_assignment_authorization.go @@ -44,6 +44,15 @@ func (c *ChattoCore) CanRevokeRoleFromUser(ctx context.Context, actorID, targetU if isProtectedSelfRoleRevocation(actorID, targetUserID, roleName) { return false, nil } + if roleName == RoleOwner { + configured, err := c.isConfiguredOwner(ctx, targetUserID) + if err != nil { + return false, err + } + if configured { + return false, nil + } + } return c.CanRevokeRole(ctx, actorID, roleName) } diff --git a/cli/internal/core/room_member_read_authorization_test.go b/cli/internal/core/room_member_read_authorization_test.go index 1f59bb7e9..26a597690 100644 --- a/cli/internal/core/room_member_read_authorization_test.go +++ b/cli/internal/core/room_member_read_authorization_test.go @@ -120,27 +120,6 @@ func TestRoomMemberReadOperationsRequireMembership(t *testing.T) { t.Fatalf("archived manager ListRoomMemberReferencesForLookup: %v", err) } - if _, err := core.CreateNotification(ctx, member.Id, actor.Id, &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{RoomId: room.Id, EventId: "event-id"}, - }, - }); err != nil { - t.Fatalf("CreateNotification: %v", err) - } - outsiderNotifications, err := core.GetRoomNotificationsForMember(ctx, outsider.Id, room.Id) - if err != nil { - t.Fatalf("GetRoomNotificationsForMember outsider: %v", err) - } - if len(outsiderNotifications) != 0 { - t.Fatalf("outsider room notifications = %+v, want empty", outsiderNotifications) - } - memberNotifications, err := core.GetRoomNotificationsForMember(ctx, member.Id, room.Id) - if err != nil { - t.Fatalf("GetRoomNotificationsForMember member: %v", err) - } - if len(memberNotifications) != 1 || memberNotifications[0].GetMention().GetRoomId() != room.Id { - t.Fatalf("member room notifications = %+v, want one room mention", memberNotifications) - } } func TestRoomMemberReferenceReadsOmitDeletedUsers(t *testing.T) { diff --git a/cli/internal/core/room_membership.go b/cli/internal/core/room_membership.go index ece1763a1..c6f88a33b 100644 --- a/cli/internal/core/room_membership.go +++ b/cli/internal/core/room_membership.go @@ -417,6 +417,15 @@ func (c *ChattoCore) appendRoomLeaveBatch(ctx context.Context, kind RoomKind, ro if err != nil { return fmt.Errorf("publish room leave batch: %w", err) } + leaveSequence := seqs[len(prefixEvents)] + // Record the committed cutoff before a rejoin can make an older delayed + // source look visible again. Creation itself remains owned exclusively by + // the causally ordered durable worker. + if c.notificationMaterializer != nil { + if err := c.notificationMaterializer.recordVisibilityBoundary(ctx, userID, roomID, leaveSequence); err != nil { + return fmt.Errorf("record notification visibility boundary: %w", err) + } + } pos := events.SubjectPosition(filter, seqs[len(seqs)-1]) var cleanupErr error diff --git a/cli/internal/core/room_unread.go b/cli/internal/core/room_unread.go index cfb707dbd..2abed358e 100644 --- a/cli/internal/core/room_unread.go +++ b/cli/internal/core/room_unread.go @@ -332,9 +332,8 @@ func (c *ChattoCore) GetEventTimestamp(ctx context.Context, kind RoomKind, roomI } // HasUnread reports whether a room has unread messages for a user. Returns -// false if the user is not a member, the room is muted, or there are no -// messages. Compares the user's stored read marker (event ID) against the -// room's current last root message. +// false if the user is not a member or there are no messages. Notification +// policy is intentionally independent from ordinary room unread state. func (c *ChattoCore) HasUnread(ctx context.Context, kind RoomKind, userID, roomID string) (bool, error) { isMember, err := c.RoomMembershipExists(ctx, kind, userID, roomID) if err != nil { @@ -344,14 +343,6 @@ func (c *ChattoCore) HasUnread(ctx context.Context, kind RoomKind, userID, roomI return false, nil } - level, err := c.GetEffectiveNotificationLevel(ctx, userID, roomID) - if err != nil { - c.logger.Warn("Failed to get notification level for unread check, continuing with default", - "user_id", userID, "kind", kind, "room_id", roomID, "error", err) - } else if level == corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - return false, nil - } - lastID, lastTime, exists, err := c.GetRoomLastEvent(ctx, kind, roomID) if err != nil { return false, err diff --git a/cli/internal/core/spaces.go b/cli/internal/core/spaces.go index 43288bdf5..710ad2e6d 100644 --- a/cli/internal/core/spaces.go +++ b/cli/internal/core/spaces.go @@ -69,8 +69,8 @@ func (c *ChattoCore) SeedDefaultRooms(ctx context.Context) error { // Per-Kind User Cleanup // ============================================================================ -// CleanupUserState removes a user's per-kind artifacts: room memberships, -// notification levels, and (during account deletion) emits a live +// CleanupUserState removes a user's per-kind artifacts: room memberships and, +// during account deletion, emits a live // ServerMemberDeletedEvent so clients can re-render messages as "Deleted User". // Idempotent; safe to call for kinds the user never interacted with. // @@ -81,10 +81,6 @@ func (c *ChattoCore) CleanupUserState(ctx context.Context, userID string, kind R c.logger.Warn("Failed to delete room memberships during cleanup", "user_id", userID, "kind", kind, "error", err) } - if err := c.deleteUserNotificationLevels(ctx, userID); err != nil { - c.logger.Warn("Failed to delete notification levels during cleanup", "user_id", userID, "kind", kind, "error", err) - } - if isAccountDeletion { memberDeletedEvent := newLiveEvent(userID, &corev1.LiveEvent{ Event: &corev1.LiveEvent_ServerMemberDeleted{ diff --git a/cli/internal/core/threads.go b/cli/internal/core/threads.go index 91ca57e87..e746891d6 100644 --- a/cli/internal/core/threads.go +++ b/cli/internal/core/threads.go @@ -254,156 +254,6 @@ func setRoomEventsResultCursors(result *RoomEventsResult) { result.EndCursorSeq = result.Events[len(result.Events)-1].Sequence } -// notifyThreadFollowers creates persistent notifications for all thread followers when someone replies. -// Followers are users who have explicitly or automatically followed the thread (stored in RUNTIME KV). -// Users in skipIDs are excluded (e.g., already notified via inReplyTo). -// This is best-effort - failures are logged but don't affect message posting. -func (c *ChattoCore) notifyThreadFollowers(ctx context.Context, kind RoomKind, roomID, replyAuthorID, replyEventID, threadRootID string, skipIDs []string) { - // Get all users following this thread - followerIDs, err := c.GetThreadFollowers(ctx, kind, roomID, threadRootID) - if err != nil { - c.logger.Warn("Failed to get thread followers for notification", - "thread_root_id", threadRootID, - "error", err) - return - } - - // Build skip set for O(1) lookups - skipSet := make(map[string]bool, len(skipIDs)) - for _, id := range skipIDs { - skipSet[id] = true - } - - // Notify each follower except the reply author and skipped users - notifiedCount := 0 - for _, followerID := range followerIDs { - // Don't notify the person who posted the reply - if followerID == replyAuthorID { - continue - } - - // Skip users already notified via other means (e.g., inReplyTo) - if skipSet[followerID] { - continue - } - - // Skip if user has muted this room - level, err := c.GetEffectiveNotificationLevel(ctx, followerID, roomID) - if err != nil { - c.logger.Warn("Failed to get notification level for thread follower, continuing", - "user_id", followerID, "error", err) - } else if level == corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - continue - } - - // Create persistent notification (for bell icon and notification center) - // This also publishes NotificationCreatedEvent for real-time updates - created, err := c.CreateNotification(ctx, followerID, replyAuthorID, &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{ - RoomId: roomID, - EventId: replyEventID, - InReplyToId: threadRootID, - InThread: threadRootID, - }, - }, - }) - if err != nil { - c.logger.Warn("Failed to create reply notification", - "recipient_id", followerID, - "reply_author_id", replyAuthorID, - "kind", kind, - "room_id", roomID, - "error", err) - } else if created != nil { - notifiedCount++ - } - } - - if notifiedCount > 0 { - c.logger.Debug("Created reply notifications for thread followers", - "thread_root_id", threadRootID, - "reply_author_id", replyAuthorID, - "notified_count", notifiedCount, - "kind", kind, - "room_id", roomID) - } -} - -// notifyInReplyToAuthor creates a persistent notification for the author of a message -// that received a reply (via inReplyTo). Works for both room-level and in-thread replies. -// Returns the notified user ID so the caller can add it to the already-notified set, -// or empty string if no notification was sent. -// This is best-effort - failures are logged but don't affect message posting. -func (c *ChattoCore) notifyInReplyToAuthor(ctx context.Context, kind RoomKind, roomID, replyAuthorID, replyEventID, inReplyToEventID, inThread string, alreadyNotifiedIDs []string) string { - // Look up the original message to find its author - originalEvent, err := c.GetRoomEventByEventID(ctx, kind, roomID, inReplyToEventID) - if err != nil || originalEvent == nil { - c.logger.Warn("Failed to get in-reply-to message for notification", - "in_reply_to_id", inReplyToEventID, - "error", err) - return "" - } - - originalAuthorID := originalEvent.ActorId - if originalAuthorID == "" { - return "" - } - - // Don't notify yourself - if originalAuthorID == replyAuthorID { - return "" - } - - // Don't notify if the user was already notified (e.g., via @mention) - for _, notifiedID := range alreadyNotifiedIDs { - if notifiedID == originalAuthorID { - return "" - } - } - - // Skip if user has muted this room - level, err := c.GetEffectiveNotificationLevel(ctx, originalAuthorID, roomID) - if err != nil { - c.logger.Warn("Failed to get notification level for in-reply-to author, continuing", - "user_id", originalAuthorID, "error", err) - } else if level == corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - return "" - } - - // Create persistent notification (for bell icon and notification center) - created, err := c.CreateNotification(ctx, originalAuthorID, replyAuthorID, &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{ - RoomId: roomID, - EventId: replyEventID, - InReplyToId: inReplyToEventID, - InThread: inThread, - }, - }, - }) - if err != nil { - c.logger.Warn("Failed to create in-reply-to notification", - "recipient_id", originalAuthorID, - "reply_author_id", replyAuthorID, - "kind", kind, - "room_id", roomID, - "error", err) - return "" - } - if created == nil { - return "" - } - - c.logger.Debug("Created in-reply-to notification", - "recipient_id", originalAuthorID, - "reply_author_id", replyAuthorID, - "kind", kind, - "room_id", roomID) - - return originalAuthorID -} - // GetThreadMetadata returns existence, reply count, last reply timestamp, and // participants for a thread root message. Derived from ThreadProjection. func (c *ChattoCore) GetThreadMetadata(ctx context.Context, kind RoomKind, roomID string, rootEventId string) (*ThreadMetadata, error) { diff --git a/cli/internal/core/threads_test.go b/cli/internal/core/threads_test.go index 7a3da16a9..ab938df31 100644 --- a/cli/internal/core/threads_test.go +++ b/cli/internal/core/threads_test.go @@ -1140,11 +1140,8 @@ func TestChattoCore_PostMessage_DirectMentionAutoFollowsThread(t *testing.T) { t.Fatal("directly mentioned user should be auto-following the thread") } - notifications, err := core.GetNotifications(ctx, mentioned.Id) - if err != nil { - t.Fatalf("GetNotifications: %v", err) - } - if len(notifications) != 1 || notifications[0].GetMention() == nil { + notifications := testNotificationOccurrences(t, core, mentioned.Id) + if len(notifications) != 1 || !testOccurrenceHasReason(notifications[0], corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) { t.Fatalf("expected one mention notification, got %#v", notifications) } } @@ -1181,11 +1178,8 @@ func TestChattoCore_PostMessage_DirectMentionRespectsExplicitUnfollow(t *testing t.Fatal("direct mention should not restore an explicitly unfollowed thread") } - notifications, err := core.GetNotifications(ctx, mentioned.Id) - if err != nil { - t.Fatalf("GetNotifications: %v", err) - } - if len(notifications) != 1 || notifications[0].GetMention() == nil { + notifications := testNotificationOccurrences(t, core, mentioned.Id) + if len(notifications) != 1 || !testOccurrenceHasReason(notifications[0], corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) { t.Fatalf("expected mention notification despite explicit unfollow, got %#v", notifications) } @@ -1233,7 +1227,7 @@ func TestChattoCore_PostMessage_BroadMentionsDoNotAutoFollowThread(t *testing.T) } } -func TestChattoCore_PostMessage_MutedDirectMentionDoesNotAutoFollowThread(t *testing.T) { +func TestChattoCore_PostMessage_DisabledDirectMentionDoesNotAutoFollowThread(t *testing.T) { core, _ := setupTestCore(t) ctx := testContext(t) @@ -1244,8 +1238,8 @@ func TestChattoCore_PostMessage_MutedDirectMentionDoesNotAutoFollowThread(t *tes core.JoinRoom(ctx, rootAuthor.Id, KindChannel, rootAuthor.Id, room.Id) core.JoinRoom(ctx, replyAuthor.Id, KindChannel, replyAuthor.Id, room.Id) core.JoinRoom(ctx, mentioned.Id, KindChannel, mentioned.Id, room.Id) - if err := core.SetRoomNotificationLevel(ctx, mentioned.Id, room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED); err != nil { - t.Fatalf("SetRoomNotificationLevel: %v", err) + if _, err := core.NotificationPolicy().SetRoomNotificationIntensity(ctx, mentioned.Id, room.Id, corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF); err != nil { + t.Fatalf("SetRoomNotificationIntensity: %v", err) } rootMsg, _ := core.PostMessage(ctx, KindChannel, room.Id, rootAuthor.Id, "Root", nil, "", "", nil, false) @@ -1258,14 +1252,11 @@ func TestChattoCore_PostMessage_MutedDirectMentionDoesNotAutoFollowThread(t *tes t.Fatalf("IsFollowingThread: %v", err) } if isFollowing { - t.Fatal("muted direct mention should not auto-follow the recipient") - } - notifications, err := core.GetNotifications(ctx, mentioned.Id) - if err != nil { - t.Fatalf("GetNotifications: %v", err) + t.Fatal("disabled direct mention should not auto-follow the recipient") } + notifications := testNotificationOccurrences(t, core, mentioned.Id) if len(notifications) != 0 { - t.Fatalf("expected no notifications for muted direct mention, got %#v", notifications) + t.Fatalf("expected no notifications for disabled direct mention, got %#v", notifications) } } @@ -1315,17 +1306,17 @@ func TestChattoCore_NotifyThreadFollowers(t *testing.T) { core.UnfollowThread(ctx, KindChannel, userC.Id, room.Id, rootMsg.Id) // Clear all existing notifications - core.DismissAllNotifications(ctx, userA.Id) - core.DismissAllNotifications(ctx, userB.Id) - core.DismissAllNotifications(ctx, userC.Id) + testDeleteAllNotificationOccurrences(t, core, userA.Id) + testDeleteAllNotificationOccurrences(t, core, userB.Id) + testDeleteAllNotificationOccurrences(t, core, userC.Id) // User B posts another reply - should notify A (follower) but NOT C (unfollowed) or B (author) core.PostMessage(ctx, KindChannel, room.Id, userB.Id, "Another reply from B", nil, rootMsg.Id, "", nil, false) // Check notifications - notifsA, _ := core.GetNotifications(ctx, userA.Id) - notifsB, _ := core.GetNotifications(ctx, userB.Id) - notifsC, _ := core.GetNotifications(ctx, userC.Id) + notifsA := testNotificationOccurrences(t, core, userA.Id) + notifsB := testNotificationOccurrences(t, core, userB.Id) + notifsC := testNotificationOccurrences(t, core, userC.Id) if len(notifsA) != 1 { t.Errorf("Expected 1 notification for user A (follower), got %d", len(notifsA)) @@ -1508,9 +1499,9 @@ func TestChattoCore_PostMessage_EchoMentionNotification(t *testing.T) { core.JoinRoom(ctx, target.Id, KindChannel, target.Id, room.Id) t.Run("echo with mention produces exactly one notification", func(t *testing.T) { - // Subscribe to live mention events for the target user + // Subscribe to occurrence invalidations for the target user. mentionCount := 0 - sub, err := nc.Subscribe(subjects.LiveSyncUserEvent(target.Id, "mentioned"), func(msg *nats.Msg) { + sub, err := nc.Subscribe(subjects.LiveSyncUserEvent(target.Id, "notification_v2"), func(msg *nats.Msg) { mentionCount++ }) if err != nil { @@ -1534,18 +1525,14 @@ func TestChattoCore_PostMessage_EchoMentionNotification(t *testing.T) { nc.Flush() time.Sleep(500 * time.Millisecond) - // Should have received exactly 1 live mention event (not 2) + // Should have received exactly one occurrence creation (not one per echo). if mentionCount != 1 { t.Errorf("Expected exactly 1 live mention event, got %d", mentionCount) } - // Should have exactly 1 persistent notification - count, err := core.GetNotificationCount(ctx, target.Id) - if err != nil { - t.Fatalf("GetNotificationCount error: %v", err) - } - if count != 1 { - t.Errorf("Expected exactly 1 persistent notification, got %d", count) + occurrences := testNotificationOccurrences(t, core, target.Id) + if len(occurrences) != 1 || !testOccurrenceHasReason(occurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) { + t.Errorf("Expected exactly one persistent mention occurrence, got %+v", occurrences) } }) } @@ -1574,45 +1561,23 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { t.Fatalf("Failed to post reply: %v", err) } - // Alice should have a ReplyNotification - notifications, err := core.GetNotifications(ctx, alice.Id) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) + occurrences := testNotificationOccurrences(t, core, alice.Id) + if len(occurrences) != 1 { + t.Fatalf("expected exactly one occurrence for Alice, got %d", len(occurrences)) } - if len(notifications) == 0 { - t.Fatal("Expected at least 1 notification for Alice") + occurrence := occurrences[0] + if !testOccurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REPLY) { + t.Errorf("expected reply reason, got %+v", occurrence.GetReasons()) } - - // Find the reply notification - var found bool - for _, n := range notifications { - replyNotif := n.GetReply() - if replyNotif != nil { - found = true - if replyNotif.RoomId != room.Id { - t.Errorf("ReplyNotification.RoomId = %s, want %s", replyNotif.RoomId, room.Id) - } - if replyNotif.InReplyToId != aliceMsg.Id { - t.Errorf("ReplyNotification.InReplyToId = %s, want %s", replyNotif.InReplyToId, aliceMsg.Id) - } - if replyNotif.InThread != "" { - t.Errorf("ReplyNotification.InThread should be empty for room-level reply, got %s", replyNotif.InThread) - } - if n.ActorId != bob.Id { - t.Errorf("Notification.ActorId = %s, want %s (bob)", n.ActorId, bob.Id) - } - break - } + if occurrence.GetTarget().GetRoomId() != room.Id || occurrence.GetTarget().GetParentEventId() != aliceMsg.Id || occurrence.GetTarget().GetThreadRootEventId() != "" { + t.Errorf("occurrence target = %+v, want room %q and parent %q without thread", occurrence.GetTarget(), room.Id, aliceMsg.Id) } - if !found { - t.Error("Expected a ReplyNotification for Alice, but none found") + if occurrence.GetActorId() != bob.Id { + t.Errorf("occurrence actor = %q, want Bob %q", occurrence.GetActorId(), bob.Id) } // Bob should NOT have any notifications - bobNotifs, err := core.GetNotifications(ctx, bob.Id) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) - } + bobNotifs := testNotificationOccurrences(t, core, bob.Id) if len(bobNotifs) != 0 { t.Errorf("Expected 0 notifications for Bob, got %d", len(bobNotifs)) } @@ -1620,7 +1585,7 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { t.Run("self-reply does not create notification", func(t *testing.T) { // Clear existing notifications - core.DismissAllNotifications(ctx, alice.Id) + testDeleteAllNotificationOccurrences(t, core, alice.Id) // Alice posts a message aliceMsg, err := core.PostMessage(ctx, KindChannel, room.Id, alice.Id, "Talking to myself", nil, "", "", nil, false) @@ -1635,24 +1600,20 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { } // Alice should have no reply notifications - notifications, err := core.GetNotifications(ctx, alice.Id) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) - } - for _, n := range notifications { - if n.GetReply() != nil { - t.Error("Expected no ReplyNotification for self-reply") + for _, occurrence := range testNotificationOccurrences(t, core, alice.Id) { + if testOccurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REPLY) { + t.Error("expected no reply occurrence for self-reply") } } }) - t.Run("muted room skips notification", func(t *testing.T) { + t.Run("disabled reply cause skips notification", func(t *testing.T) { // Clear existing notifications - core.DismissAllNotifications(ctx, alice.Id) + testDeleteAllNotificationOccurrences(t, core, alice.Id) - // Alice mutes the room - core.SetRoomNotificationLevel(ctx, alice.Id, room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED) - defer core.SetRoomNotificationLevel(ctx, alice.Id, room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED) + if _, err := core.NotificationPolicy().SetRoomNotificationIntensity(ctx, alice.Id, room.Id, corev1.NotificationReason_NOTIFICATION_REASON_REPLY, corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF); err != nil { + t.Fatalf("SetRoomNotificationIntensity: %v", err) + } // Alice posts a message aliceMsg, err := core.PostMessage(ctx, KindChannel, room.Id, alice.Id, "Muted test", nil, "", "", nil, false) @@ -1661,26 +1622,22 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { } // Bob replies - _, err = core.PostMessage(ctx, KindChannel, room.Id, bob.Id, "Reply to muted", nil, "", aliceMsg.Id, nil, false) + _, err = core.PostMessage(ctx, KindChannel, room.Id, bob.Id, "Reply with notifications disabled", nil, "", aliceMsg.Id, nil, false) if err != nil { t.Fatalf("Failed to post reply: %v", err) } - // Alice should have no notifications (muted) - notifications, err := core.GetNotifications(ctx, alice.Id) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) - } - for _, n := range notifications { - if n.GetReply() != nil { - t.Error("Expected no ReplyNotification when room is muted") + // Alice should have no reply occurrence while that cause is disabled. + for _, occurrence := range testNotificationOccurrences(t, core, alice.Id) { + if testOccurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REPLY) { + t.Error("expected no reply occurrence when the reply cause is disabled") } } }) - t.Run("mention + reply deduplicates to mention only", func(t *testing.T) { + t.Run("mention and reply merge into one occurrence", func(t *testing.T) { // Clear existing notifications - core.DismissAllNotifications(ctx, alice.Id) + testDeleteAllNotificationOccurrences(t, core, alice.Id) // Alice posts a message aliceMsg, err := core.PostMessage(ctx, KindChannel, room.Id, alice.Id, "Dedup test", nil, "", "", nil, false) @@ -1694,34 +1651,18 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { t.Fatalf("Failed to post reply with mention: %v", err) } - // Alice should have exactly 1 notification (the mention, not a duplicate reply) - notifications, err := core.GetNotifications(ctx, alice.Id) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) - } - - mentionCount := 0 - replyCount := 0 - for _, n := range notifications { - if n.GetMention() != nil { - mentionCount++ - } - if n.GetReply() != nil { - replyCount++ - } - } - - if mentionCount != 1 { - t.Errorf("Expected 1 mention notification, got %d", mentionCount) + occurrences := testNotificationOccurrences(t, core, alice.Id) + if len(occurrences) != 1 { + t.Fatalf("expected one merged occurrence, got %d", len(occurrences)) } - if replyCount != 0 { - t.Errorf("Expected 0 reply notifications (deduped by mention), got %d", replyCount) + if !testOccurrenceHasReason(occurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) || !testOccurrenceHasReason(occurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_REPLY) { + t.Errorf("expected mention and reply reasons, got %+v", occurrences[0].GetReasons()) } }) t.Run("thread reply sets InThread field", func(t *testing.T) { // Clear existing notifications - core.DismissAllNotifications(ctx, alice.Id) + testDeleteAllNotificationOccurrences(t, core, alice.Id) // Alice posts a root message rootMsg, err := core.PostMessage(ctx, KindChannel, room.Id, alice.Id, "Thread root", nil, "", "", nil, false) @@ -1735,31 +1676,18 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { t.Fatalf("Failed to post thread reply: %v", err) } - // Alice should have a ReplyNotification with InThread set - notifications, err := core.GetNotifications(ctx, alice.Id) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) + occurrences := testNotificationOccurrences(t, core, alice.Id) + if len(occurrences) != 1 || !testOccurrenceHasReason(occurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD) { + t.Fatalf("expected one followed-thread occurrence, got %+v", occurrences) } - - var found bool - for _, n := range notifications { - replyNotif := n.GetReply() - if replyNotif != nil { - found = true - if replyNotif.InThread != rootMsg.Id { - t.Errorf("ReplyNotification.InThread = %q, want %q", replyNotif.InThread, rootMsg.Id) - } - break - } - } - if !found { - t.Error("Expected a ReplyNotification for thread reply") + if occurrences[0].GetTarget().GetThreadRootEventId() != rootMsg.Id { + t.Errorf("thread target = %q, want %q", occurrences[0].GetTarget().GetThreadRootEventId(), rootMsg.Id) } }) t.Run("thread mention keeps existing follower notification", func(t *testing.T) { // Clear existing notifications - core.DismissAllNotifications(ctx, alice.Id) + testDeleteAllNotificationOccurrences(t, core, alice.Id) // Alice posts a root message. The first thread reply auto-follows her // as root author before notifications are fanned out. @@ -1773,34 +1701,19 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { t.Fatalf("Failed to post thread reply with mention: %v", err) } - notifications, err := core.GetNotifications(ctx, alice.Id) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) + occurrences := testNotificationOccurrences(t, core, alice.Id) + if len(occurrences) != 1 { + t.Fatalf("expected one merged occurrence, got %d", len(occurrences)) } - - mentionCount := 0 - threadReplyCount := 0 - for _, n := range notifications { - if mention := n.GetMention(); mention != nil && mention.InThread == rootMsg.Id { - mentionCount++ - } - if reply := n.GetReply(); reply != nil && reply.InThread == rootMsg.Id { - threadReplyCount++ - } - } - - if mentionCount != 1 { - t.Errorf("Expected 1 thread mention notification, got %d", mentionCount) - } - if threadReplyCount != 1 { - t.Errorf("Expected 1 followed-thread notification, got %d", threadReplyCount) + if !testOccurrenceHasReason(occurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) || !testOccurrenceHasReason(occurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD) { + t.Errorf("expected mention and followed-thread reasons, got %+v", occurrences[0].GetReasons()) } }) t.Run("in-thread inReplyTo notifies original author with InThread set", func(t *testing.T) { // Clear existing notifications - core.DismissAllNotifications(ctx, alice.Id) - core.DismissAllNotifications(ctx, bob.Id) + testDeleteAllNotificationOccurrences(t, core, alice.Id) + testDeleteAllNotificationOccurrences(t, core, bob.Id) // Create a third user for this test charlie, _ := core.CreateUser(ctx, "system", "charlie", "Charlie", "password123") @@ -1819,8 +1732,8 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { } // Clear notifications from thread participant notifications - core.DismissAllNotifications(ctx, alice.Id) - core.DismissAllNotifications(ctx, bob.Id) + testDeleteAllNotificationOccurrences(t, core, alice.Id) + testDeleteAllNotificationOccurrences(t, core, bob.Id) // Charlie replies to Bob's specific message within the thread (inThread + inReplyTo) _, err = core.PostMessage(ctx, KindChannel, room.Id, charlie.Id, "Replying to Bob in thread", nil, rootMsg.Id, bobMsg.Id, nil, false) @@ -1828,33 +1741,20 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { t.Fatalf("Failed to post in-thread inReplyTo: %v", err) } - // Bob should have a ReplyNotification from notifyInReplyToAuthor with InThread set - // (He also gets one from notifyThreadParticipants, but we check that at least one has InThread) - bobNotifs, err := core.GetNotifications(ctx, bob.Id) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) - } - - var foundReply bool - for _, n := range bobNotifs { - replyNotif := n.GetReply() - if replyNotif != nil && replyNotif.InReplyToId == bobMsg.Id { - foundReply = true - if replyNotif.InThread != rootMsg.Id { - t.Errorf("ReplyNotification.InThread = %q, want %q", replyNotif.InThread, rootMsg.Id) - } - break - } + bobOccurrences := testNotificationOccurrences(t, core, bob.Id) + if len(bobOccurrences) != 1 || !testOccurrenceHasReason(bobOccurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_REPLY) { + t.Fatalf("expected Bob to get one reply occurrence, got %+v", bobOccurrences) } - if !foundReply { - t.Error("Expected Bob to get a ReplyNotification for in-thread inReplyTo") + target := bobOccurrences[0].GetTarget() + if target.GetParentEventId() != bobMsg.Id || target.GetThreadRootEventId() != rootMsg.Id { + t.Errorf("occurrence target = %+v, want parent %q and thread %q", target, bobMsg.Id, rootMsg.Id) } }) t.Run("in-thread inReplyTo deduplicates with thread participant notification", func(t *testing.T) { // Clear existing notifications - core.DismissAllNotifications(ctx, alice.Id) - core.DismissAllNotifications(ctx, bob.Id) + testDeleteAllNotificationOccurrences(t, core, alice.Id) + testDeleteAllNotificationOccurrences(t, core, bob.Id) // Alice posts a root message rootMsg, err := core.PostMessage(ctx, KindChannel, room.Id, alice.Id, "Dedup thread root", nil, "", "", nil, false) @@ -1863,7 +1763,7 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { } // Clear Alice's thread participant notification - core.DismissAllNotifications(ctx, alice.Id) + testDeleteAllNotificationOccurrences(t, core, alice.Id) // Bob replies to Alice's root message in the thread (both inThread and inReplyTo point to root) // Alice is both the thread root author (notifyThreadParticipants) and the inReplyTo author (notifyInReplyToAuthor) @@ -1872,20 +1772,12 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { t.Fatalf("Failed to post reply: %v", err) } - // Alice should have exactly 1 ReplyNotification, not 2 (dedup between thread + inReplyTo) - notifications, err := core.GetNotifications(ctx, alice.Id) - if err != nil { - t.Fatalf("GetNotifications error: %v", err) - } - - replyCount := 0 - for _, n := range notifications { - if n.GetReply() != nil { - replyCount++ - } + occurrences := testNotificationOccurrences(t, core, alice.Id) + if len(occurrences) != 1 { + t.Fatalf("expected exactly one merged occurrence, got %d", len(occurrences)) } - if replyCount != 1 { - t.Errorf("Expected exactly 1 ReplyNotification (deduped), got %d", replyCount) + if !testOccurrenceHasReason(occurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_REPLY) || !testOccurrenceHasReason(occurrences[0], corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD) { + t.Errorf("expected reply and followed-thread reasons, got %+v", occurrences[0].GetReasons()) } }) } diff --git a/cli/internal/core/verified_emails.go b/cli/internal/core/verified_emails.go index 56cba73d0..891f3af87 100644 --- a/cli/internal/core/verified_emails.go +++ b/cli/internal/core/verified_emails.go @@ -254,7 +254,7 @@ func (c *ChattoCore) addVerifiedEmailAs(ctx context.Context, actorID, userID, em return fmt.Errorf("encrypt verified email: %w", err) } event.GetUserVerifiedEmailAdded().EncryptedEmail = encryptedEmail - if _, err := c.appendUserEvent(ctx, userID, event, evtstream.UserSubjectFilter(), func() error { + sequence, err := c.appendUserEvent(ctx, userID, event, evtstream.UserSubjectFilter(), func() error { if _, err := c.GetUser(ctx, userID); err != nil { return fmt.Errorf("user not found: %w", err) } @@ -268,10 +268,11 @@ func (c *ChattoCore) addVerifiedEmailAs(ctx context.Context, actorID, userID, em return err } return nil - }); err != nil { + }) + if err != nil { if errors.Is(err, errVerifiedEmailNoop) { - // Already verified for this user. Keep going so owner-email - // auto-promotion below still catches config changes. + // Already verified for this user. Keep going so a retry can wait + // for an owner assignment that was still pending previously. } else if errors.Is(err, ErrEmailAlreadyVerified) { return ErrEmailAlreadyVerified } else { @@ -279,17 +280,37 @@ func (c *ChattoCore) addVerifiedEmailAs(ctx context.Context, actorID, userID, em } } - // Auto-promote on config-owner email match. This is what closes the - // chicken-and-egg gap on fresh deployments: as soon as the operator's - // account verifies their email, they pick up the `owner` role without - // waiting for the next boot-time owner sync. + // The durable effects lane materializes owners.emails into RBAC and retries + // transient assignment failures. Wait through this source fact so a + // successful verification cannot return while live authorization and + // event-time notification visibility disagree about owner status. if c.config.Owners.IsServerOwnerEmail(email) { - if err := c.AssignServerRoleToExistingUser(ctx, SystemActorID, userID, RoleOwner); err != nil { - c.logger.Warn("Failed to auto-assign owner role on email verification", - "user_id", userID, "error", err) + if c.notificationMaterializer == nil { + return errors.New("notification materializer is not configured") + } + var waitErr error + if sequence == 0 { + // An idempotent verification may be retrying after the original + // request timed out while durable owner assignment was pending. + waitErr = c.notificationMaterializer.WaitCurrent(ctx) } else { - c.logger.Info("Auto-promoted user to owner via owners.emails match", - "user_id", userID) + waitErr = c.notificationMaterializer.WaitThrough(ctx, sequence) + } + if waitErr != nil { + return fmt.Errorf("wait for configured-owner role materialization: %w", waitErr) + } + // The shared delivery may have run on another replica. Its ACK proves + // the RBAC fact committed, not that this replica's RBAC projection has + // observed that later fact yet. + rbacPosition, err := c.EventPublisher.LastSubjectPosition(ctx, evtstream.RBACSubjectFilter()) + if err != nil { + return fmt.Errorf("capture configured-owner RBAC boundary: %w", err) + } + if err := c.rbacModel.waitFor(ctx, rbacPosition); err != nil { + return fmt.Errorf("wait for configured-owner RBAC boundary: %w", err) + } + if !c.rbacModel.hasRole(userID, RoleOwner) { + return errors.New("configured-owner role was not materialized") } } diff --git a/cli/internal/core/verified_emails_test.go b/cli/internal/core/verified_emails_test.go index 84c1e6512..aedf10a8b 100644 --- a/cli/internal/core/verified_emails_test.go +++ b/cli/internal/core/verified_emails_test.go @@ -1,11 +1,14 @@ package core import ( + "context" "errors" "sync" "testing" + "time" "hmans.de/chatto/internal/config" + "hmans.de/chatto/internal/testutil" ) // ============================================================================ @@ -317,6 +320,161 @@ func TestChattoCore_ApplyConfigOwners(t *testing.T) { } } +func TestConfiguredOwnerVerificationWaitsForDurableRole(t *testing.T) { + core, _ := setupTestCore(t) + ctx := testContext(t) + core.config.Owners = config.OwnersConfig{Emails: []string{"owner@example.com"}} + user, err := core.CreateUser(ctx, SystemActorID, "new-config-owner", "New Config Owner", "password123") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := core.AddVerifiedEmailDirect(ctx, user.Id, "OWNER@example.com"); err != nil { + t.Fatalf("AddVerifiedEmailDirect: %v", err) + } + if owner, err := core.IsServerOwner(ctx, user.Id); err != nil || !owner { + t.Fatalf("owner after successful verification = %v, err=%v", owner, err) + } + if !core.rbacModel.hasRole(user.Id, RoleOwner) { + t.Fatal("successful configured-owner verification returned without durable owner role") + } +} + +func TestConfiguredOwnerVerificationFencesServingReplicaRBAC(t *testing.T) { + _, nc := testutil.StartNATS(t) + ctx := testContext(t) + cfg := config.CoreConfig{ + SecretKey: "configured-owner-replica-fence-secret", + Assets: config.AssetsConfig{SigningSecret: "configured-owner-replica-fence-signing-secret"}, + Owners: config.OwnersConfig{Emails: []string{"owner@example.com"}}, + } + worker, err := NewChattoCore(ctx, nc, cfg) + if err != nil { + t.Fatalf("NewChattoCore worker: %v", err) + } + startCoreServices(t, worker) + + serving, err := NewChattoCore(ctx, nc, cfg) + if err != nil { + t.Fatalf("NewChattoCore serving: %v", err) + } + projectorCtx, cancelProjectors := context.WithCancel(context.Background()) + var projectorWG sync.WaitGroup + for _, registration := range serving.projections { + registration := registration + projectorWG.Add(1) + go func() { + defer projectorWG.Done() + _ = registration.projector.Run(projectorCtx) + }() + } + t.Cleanup(func() { + cancelProjectors() + projectorWG.Wait() + }) + if err := serving.waitForProjectorsStarted(ctx, 5*time.Second); err != nil { + t.Fatalf("wait for serving projectors: %v", err) + } + if err := serving.WaitForProjectionsCurrent(ctx); err != nil { + t.Fatalf("wait for serving projections current: %v", err) + } + + user, err := worker.CreateUser(ctx, SystemActorID, "replica-fenced-config-owner", "Replica Fenced Config Owner", "password123") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := serving.WaitForProjectionsCurrent(ctx); err != nil { + t.Fatalf("wait for serving user: %v", err) + } + + // Hold only the serving replica's RBAC projection. The other replica can + // commit and acknowledge the owner assignment, but this request must not + // return until its own later RBAC fact is visible. + servingRBAC := serving.rbacModel.rbac.Projection() + servingRBAC.Lock() + locked := true + defer func() { + if locked { + servingRBAC.Unlock() + } + }() + done := make(chan error, 1) + go func() { done <- serving.AddVerifiedEmailDirect(ctx, user.Id, "owner@example.com") }() + + deadline := time.NewTimer(5 * time.Second) + defer deadline.Stop() + roleAssigned := time.NewTicker(5 * time.Millisecond) + defer roleAssigned.Stop() + for !worker.rbacModel.hasRole(user.Id, RoleOwner) { + select { + case err := <-done: + t.Fatalf("verification returned before worker assignment: %v", err) + case <-deadline.C: + t.Fatal("worker did not materialize configured-owner role") + case <-roleAssigned.C: + } + } + select { + case err := <-done: + t.Fatalf("verification returned before serving RBAC caught up: %v", err) + case <-time.After(50 * time.Millisecond): + } + servingRBAC.Unlock() + locked = false + select { + case err := <-done: + if err != nil { + t.Fatalf("AddVerifiedEmailDirect: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("verification did not finish after serving RBAC caught up") + } +} + +func TestConfiguredOwnerRoleCannotDivergeFromEffectiveVisibility(t *testing.T) { + core, _ := setupTestCore(t) + ctx := testContext(t) + + configuredOwner, err := core.CreateVerifiedUser(ctx, SystemActorID, "configured-visibility-owner", "Configured Visibility Owner", "password123", "owner@example.com") + if err != nil { + t.Fatalf("create configured owner: %v", err) + } + otherOwner, err := core.CreateUser(ctx, SystemActorID, "other-visibility-owner", "Other Visibility Owner", "password123") + if err != nil { + t.Fatalf("create other owner: %v", err) + } + if err := core.AssignOwnerRole(ctx, otherOwner.Id); err != nil { + t.Fatalf("assign other owner: %v", err) + } + core.config.Owners = config.OwnersConfig{Emails: []string{"owner@example.com"}} + if err := core.applyConfigOwners(ctx); err != nil { + t.Fatalf("apply configured owner: %v", err) + } + + room, err := core.CreateRoom(ctx, otherOwner.Id, KindChannel, "", "configured-owner-visibility", "") + if err != nil { + t.Fatalf("create room: %v", err) + } + if _, err := core.SetRoomUniversal(ctx, otherOwner.Id, KindChannel, room.Id, true); err != nil { + t.Fatalf("set room universal: %v", err) + } + if err := core.DenyRoomPermission(ctx, otherOwner.Id, room.Id, RoleEveryone, PermRoomJoin); err != nil { + t.Fatalf("deny everyone room.join: %v", err) + } + if visible, err := core.RoomMembershipExists(ctx, KindChannel, configuredOwner.Id, room.Id); err != nil || !visible { + t.Fatalf("configured owner visibility before revoke = (%v, %v), want true", visible, err) + } + + if err := core.RevokeServerRole(ctx, otherOwner.Id, configuredOwner.Id, RoleOwner); !errors.Is(err, ErrPermissionDenied) { + t.Fatalf("revoke configured owner role error = %v, want ErrPermissionDenied", err) + } + if !core.rbacModel.hasRole(configuredOwner.Id, RoleOwner) { + t.Fatal("configured owner role was revoked") + } + if visible, err := core.RoomMembershipExists(ctx, KindChannel, configuredOwner.Id, room.Id); err != nil || !visible { + t.Fatalf("configured owner visibility after rejected revoke = (%v, %v), want true", visible, err) + } +} + func TestChattoCore_AddVerifiedEmailDirect(t *testing.T) { core, _ := setupTestCore(t) ctx := testContext(t) diff --git a/cli/internal/evtstream/publisher.go b/cli/internal/evtstream/publisher.go index c644b513f..cff39a27e 100644 --- a/cli/internal/evtstream/publisher.go +++ b/cli/internal/evtstream/publisher.go @@ -236,14 +236,16 @@ func (p *Publisher) SubjectEventsAfter( return events, lastSeq, nil } -// SubjectEvent preserves the durable subject alongside a decoded event. +// SubjectEvent preserves the durable subject and stream sequence alongside a +// decoded event. type SubjectEvent struct { - Subject string - Event *corev1.Event + Subject string + Sequence uint64 + Event *corev1.Event } // SubjectEventsWithSubjectsAfter decodes opaque records while preserving their -// matched durable subjects. +// matched durable subjects and stream sequences. func (p *Publisher) SubjectEventsWithSubjectsAfter( ctx context.Context, subject string, @@ -261,7 +263,7 @@ func (p *Publisher) SubjectEventsWithSubjectsAfter( if err := proto.Unmarshal(record.Data, &event); err != nil { return nil, 0, fmt.Errorf("unmarshal event at seq %d: %w", record.Sequence, err) } - events = append(events, &SubjectEvent{Subject: record.Subject, Event: &event}) + events = append(events, &SubjectEvent{Subject: record.Subject, Sequence: record.Sequence, Event: &event}) } if page.LastSequence > lastSeq { lastSeq = page.LastSequence diff --git a/cli/internal/evtstream/subjects.go b/cli/internal/evtstream/subjects.go index 6137ba21b..fcc15fcb6 100644 --- a/cli/internal/evtstream/subjects.go +++ b/cli/internal/evtstream/subjects.go @@ -143,6 +143,7 @@ const ( EventUserServerNotificationLevelCleared = "user_server_notification_level_cleared" EventUserRoomNotificationLevelSet = "user_room_notification_level_set" EventUserRoomNotificationLevelCleared = "user_room_notification_level_cleared" + EventUserNotificationPreferenceChanged = "user_notification_preference_changed" // User aggregate EventUserAccountCreated = "account_created" @@ -283,7 +284,6 @@ func EventTypeOf(e *corev1.Event) string { return EventReactionAdded case *corev1.Event_ReactionRemoved: return EventReactionRemoved - case *corev1.Event_RoomGroupCreated: return EventRoomGroupCreated case *corev1.Event_RoomGroupUpdated: @@ -342,6 +342,8 @@ func EventTypeOf(e *corev1.Event) string { return EventUserRoomNotificationLevelSet case *corev1.Event_UserRoomNotificationLevelCleared: return EventUserRoomNotificationLevelCleared + case *corev1.Event_UserNotificationPreferenceChanged: + return EventUserNotificationPreferenceChanged case *corev1.Event_UserAccountCreated: return EventUserAccountCreated diff --git a/cli/internal/http_server/connect_test.go b/cli/internal/http_server/connect_test.go index b29738150..c89fa241b 100644 --- a/cli/internal/http_server/connect_test.go +++ b/cli/internal/http_server/connect_test.go @@ -900,194 +900,3 @@ func TestConnectRequestBaseURLTrustModel(t *testing.T) { } }) } - -func TestConnectNotificationPreferencesService(t *testing.T) { - t.Run("requires authentication", func(t *testing.T) { - s, ts := setupConnectTestServer(t, config.AuthConfig{}) - ctx := context.Background() - member, err := s.core.CreateUser(ctx, core.SystemActorID, "connect-member", "Connect Member", "password") - if err != nil { - t.Fatalf("CreateUser: %v", err) - } - room, err := s.core.CreateRoom(ctx, member.Id, core.KindChannel, "", "connect-room", "") - if err != nil { - t.Fatalf("CreateRoom: %v", err) - } - if _, err := s.core.JoinRoom(ctx, member.Id, core.KindChannel, member.Id, room.Id); err != nil { - t.Fatalf("JoinRoom: %v", err) - } - - client := apiv1connect.NewNotificationPreferencesServiceClient(ts.Client(), ts.URL+connectAPIPrefix) - _, err = client.UpdateRoomNotificationPreference(ctx, connect.NewRequest(&apiv1.UpdateRoomNotificationPreferenceRequest{ - RoomId: room.Id, - Level: apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, - })) - if connect.CodeOf(err) != connect.CodeUnauthenticated { - t.Fatalf("UpdateRoomNotificationPreference err = %v, want unauthenticated", err) - } - - _, err = client.GetRoomNotificationPreference(ctx, connect.NewRequest(&apiv1.GetRoomNotificationPreferenceRequest{ - RoomId: room.Id, - })) - if connect.CodeOf(err) != connect.CodeUnauthenticated { - t.Fatalf("GetRoomNotificationPreference err = %v, want unauthenticated", err) - } - }) - - t.Run("requires room membership", func(t *testing.T) { - s, ts := setupConnectTestServer(t, config.AuthConfig{}) - ctx := context.Background() - member, err := s.core.CreateUser(ctx, core.SystemActorID, "connect-member", "Connect Member", "password") - if err != nil { - t.Fatalf("CreateUser(member): %v", err) - } - other, err := s.core.CreateUser(ctx, core.SystemActorID, "connect-other", "Connect Other", "password") - if err != nil { - t.Fatalf("CreateUser(other): %v", err) - } - room, err := s.core.CreateRoom(ctx, member.Id, core.KindChannel, "", "connect-room", "") - if err != nil { - t.Fatalf("CreateRoom: %v", err) - } - if _, err := s.core.JoinRoom(ctx, member.Id, core.KindChannel, member.Id, room.Id); err != nil { - t.Fatalf("JoinRoom: %v", err) - } - token, err := s.core.CreateAuthToken(ctx, other.Id) - if err != nil { - t.Fatalf("CreateAuthToken: %v", err) - } - - client := apiv1connect.NewNotificationPreferencesServiceClient(ts.Client(), ts.URL+connectAPIPrefix) - req := connect.NewRequest(&apiv1.UpdateRoomNotificationPreferenceRequest{ - RoomId: room.Id, - Level: apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, - }) - req.Header().Set("Authorization", "Bearer "+token) - _, err = client.UpdateRoomNotificationPreference(ctx, req) - if connect.CodeOf(err) != connect.CodePermissionDenied { - t.Fatalf("UpdateRoomNotificationPreference err = %v, want permission denied", err) - } - - getReq := connect.NewRequest(&apiv1.GetRoomNotificationPreferenceRequest{ - RoomId: room.Id, - }) - getReq.Header().Set("Authorization", "Bearer "+token) - _, err = client.GetRoomNotificationPreference(ctx, getReq) - if connect.CodeOf(err) != connect.CodePermissionDenied { - t.Fatalf("GetRoomNotificationPreference err = %v, want permission denied", err) - } - }) - - t.Run("rejects invalid room notification requests", func(t *testing.T) { - s, ts := setupConnectTestServer(t, config.AuthConfig{}) - ctx := context.Background() - member, err := s.core.CreateUser(ctx, core.SystemActorID, "connect-member", "Connect Member", "password") - if err != nil { - t.Fatalf("CreateUser: %v", err) - } - token, err := s.core.CreateAuthToken(ctx, member.Id) - if err != nil { - t.Fatalf("CreateAuthToken: %v", err) - } - - client := apiv1connect.NewNotificationPreferencesServiceClient(ts.Client(), ts.URL+connectAPIPrefix) - req := connect.NewRequest(&apiv1.UpdateRoomNotificationPreferenceRequest{ - RoomId: "", - Level: apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, - }) - req.Header().Set("Authorization", "Bearer "+token) - _, err = client.UpdateRoomNotificationPreference(ctx, req) - if connect.CodeOf(err) != connect.CodeInvalidArgument { - t.Fatalf("UpdateRoomNotificationPreference empty room err = %v, want invalid argument", err) - } - - req = connect.NewRequest(&apiv1.UpdateRoomNotificationPreferenceRequest{ - RoomId: "missing-room", - Level: apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, - }) - req.Header().Set("Authorization", "Bearer "+token) - _, err = client.UpdateRoomNotificationPreference(ctx, req) - if connect.CodeOf(err) != connect.CodeNotFound { - t.Fatalf("UpdateRoomNotificationPreference missing room err = %v, want not found", err) - } - - req = connect.NewRequest(&apiv1.UpdateRoomNotificationPreferenceRequest{ - RoomId: "missing-room", - Level: apiv1.NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED, - }) - req.Header().Set("Authorization", "Bearer "+token) - _, err = client.UpdateRoomNotificationPreference(ctx, req) - if connect.CodeOf(err) != connect.CodeInvalidArgument { - t.Fatalf("UpdateRoomNotificationPreference unspecified level err = %v, want invalid argument", err) - } - - getReq := connect.NewRequest(&apiv1.GetRoomNotificationPreferenceRequest{ - RoomId: "", - }) - getReq.Header().Set("Authorization", "Bearer "+token) - _, err = client.GetRoomNotificationPreference(ctx, getReq) - if connect.CodeOf(err) != connect.CodeInvalidArgument { - t.Fatalf("GetRoomNotificationPreference empty room err = %v, want invalid argument", err) - } - }) - - t.Run("sets a room notification level for a member", func(t *testing.T) { - s, ts := setupConnectTestServer(t, config.AuthConfig{}) - ctx := context.Background() - member, err := s.core.CreateUser(ctx, core.SystemActorID, "connect-member", "Connect Member", "password") - if err != nil { - t.Fatalf("CreateUser: %v", err) - } - room, err := s.core.CreateRoom(ctx, member.Id, core.KindChannel, "", "connect-room", "") - if err != nil { - t.Fatalf("CreateRoom: %v", err) - } - if _, err := s.core.JoinRoom(ctx, member.Id, core.KindChannel, member.Id, room.Id); err != nil { - t.Fatalf("JoinRoom: %v", err) - } - token, err := s.core.CreateAuthToken(ctx, member.Id) - if err != nil { - t.Fatalf("CreateAuthToken: %v", err) - } - - client := apiv1connect.NewNotificationPreferencesServiceClient(ts.Client(), ts.URL+connectAPIPrefix) - req := connect.NewRequest(&apiv1.UpdateRoomNotificationPreferenceRequest{ - RoomId: room.Id, - Level: apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED, - }) - req.Header().Set("Authorization", "Bearer "+token) - resp, err := client.UpdateRoomNotificationPreference(ctx, req) - if err != nil { - t.Fatalf("UpdateRoomNotificationPreference: %v", err) - } - if resp.Msg.GetPreference().GetLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Fatalf("Level = %v, want muted", resp.Msg.GetPreference().GetLevel()) - } - if resp.Msg.GetPreference().GetEffectiveLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Fatalf("EffectiveLevel = %v, want muted", resp.Msg.GetPreference().GetEffectiveLevel()) - } - - getReq := connect.NewRequest(&apiv1.GetRoomNotificationPreferenceRequest{ - RoomId: room.Id, - }) - getReq.Header().Set("Authorization", "Bearer "+token) - getResp, err := client.GetRoomNotificationPreference(ctx, getReq) - if err != nil { - t.Fatalf("GetRoomNotificationPreference: %v", err) - } - if getResp.Msg.GetPreference().GetLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Fatalf("Get level = %v, want muted", getResp.Msg.GetPreference().GetLevel()) - } - if getResp.Msg.GetPreference().GetEffectiveLevel() != apiv1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Fatalf("Get effective level = %v, want muted", getResp.Msg.GetPreference().GetEffectiveLevel()) - } - - got, err := s.core.GetRoomNotificationLevel(ctx, member.Id, room.Id) - if err != nil { - t.Fatalf("GetRoomNotificationLevel: %v", err) - } - if got != corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED { - t.Fatalf("stored level = %v, want muted", got) - } - }) -} diff --git a/cli/internal/http_server/cors_test.go b/cli/internal/http_server/cors_test.go index 246e1bb94..e5ac947b6 100644 --- a/cli/internal/http_server/cors_test.go +++ b/cli/internal/http_server/cors_test.go @@ -170,7 +170,7 @@ func TestCORSMiddleware(t *testing.T) { // AllowedOrigins not set — remote bearer-token clients match the wildcard. }) - req := httptest.NewRequest("OPTIONS", connectAPIPrefix+"/chatto.api.v1.NotificationPreferencesService/UpdateRoomNotificationPreference", nil) + req := httptest.NewRequest("OPTIONS", connectAPIPrefix+"/chatto.api.v1.NotificationService/SetNotificationPolicyPreference", nil) req.Header.Set("Origin", "https://integration.example.com") req.Header.Set("Access-Control-Request-Method", "POST") req.Header.Set("Access-Control-Request-Headers", "authorization, content-type, connect-protocol-version, connect-timeout-ms") diff --git a/cli/internal/http_server/realtime.go b/cli/internal/http_server/realtime.go index 5806d362e..f279c8097 100644 --- a/cli/internal/http_server/realtime.go +++ b/cli/internal/http_server/realtime.go @@ -612,12 +612,6 @@ func (s *HTTPServer) mapRealtimeLive(ctx context.Context, viewerID string, envel envelope.Event = &realtimev1.RealtimeEventEnvelope_PresenceChanged{PresenceChanged: &realtimev1.RealtimePresenceChangedEvent{ UserId: event.GetActorId(), Status: apiPresenceStatus(payload.PresenceChanged.GetStatus()), }} - case *corev1.LiveEvent_MentionNotification: - mention := payload.MentionNotification - envelope.Event = &realtimev1.RealtimeEventEnvelope_MentionNotification{MentionNotification: s.realtimeMentionNotification(ctx, viewerID, mention)} - case *corev1.LiveEvent_NewDirectMessageNotification: - dm := payload.NewDirectMessageNotification - envelope.Event = &realtimev1.RealtimeEventEnvelope_NewDirectMessageNotification{NewDirectMessageNotification: s.realtimeNewDirectMessageNotification(ctx, viewerID, dm)} case *corev1.LiveEvent_SessionTerminated: envelope.Event = &realtimev1.RealtimeEventEnvelope_SessionTerminated{SessionTerminated: &realtimev1.RealtimeSessionTerminatedEvent{ Reason: payload.SessionTerminated.GetReason(), @@ -635,44 +629,6 @@ func optionalRealtimeString(value string) *string { return proto.String(value) } -func (s *HTTPServer) realtimeMentionNotification(ctx context.Context, viewerID string, mention *corev1.MentionNotificationEvent) *realtimev1.RealtimeMentionNotificationEvent { - out := &realtimev1.RealtimeMentionNotificationEvent{ - RoomId: mention.GetRoomId(), - ActorUserId: mention.GetMentionedByUserId(), - } - if s == nil || s.core == nil { - return out - } - if room, err := s.core.FindRoomByID(ctx, mention.GetRoomId()); err == nil && s.viewerCanReadRealtimeRoomLabel(ctx, viewerID, room) { - out.RoomName = proto.String(room.GetName()) - } - if actor, err := s.core.GetUser(ctx, mention.GetMentionedByUserId()); err == nil { - out.ActorDisplayName = proto.String(actor.GetDisplayName()) - } - return out -} - -func (s *HTTPServer) realtimeNewDirectMessageNotification(ctx context.Context, viewerID string, dm *corev1.NewDirectMessageNotificationEvent) *realtimev1.RealtimeNewDirectMessageNotificationEvent { - out := &realtimev1.RealtimeNewDirectMessageNotificationEvent{ - RoomId: dm.GetRoomId(), - SenderId: dm.GetSenderId(), - } - if s == nil || s.core == nil { - return out - } - if ok, err := s.core.RoomMembershipExists(ctx, core.KindDM, viewerID, dm.GetRoomId()); viewerID != "" && (err != nil || !ok) { - return out - } - if sender, err := s.core.GetUser(ctx, dm.GetSenderId()); err == nil { - out.SenderDisplayName = proto.String(sender.GetDisplayName()) - if avatarURL, err := s.core.GetUserAvatarURL(ctx, sender.GetId(), nil, nil, ""); err == nil { - out.SenderAvatarUrl = proto.String(avatarURL) - } - } - out.ConversationName = proto.String(s.realtimeDMConversationName(ctx, viewerID, dm.GetRoomId())) - return out -} - func (s *HTTPServer) realtimeDMConversationName(ctx context.Context, viewerID, roomID string) string { participants, err := s.core.GetRoomMembersList(ctx, core.KindDM, roomID) if err != nil { diff --git a/cli/internal/http_server/realtime_projection.go b/cli/internal/http_server/realtime_projection.go index d6249d841..d31905127 100644 --- a/cli/internal/http_server/realtime_projection.go +++ b/cli/internal/http_server/realtime_projection.go @@ -112,7 +112,7 @@ func (s *HTTPServer) realtimeProjectionRoomTimelineFrame(ctx context.Context, vi // realtimeProjectionReconciliationFrame captures latest-value viewer state // that is not fully represented by an EVT gap: room/thread read markers, -// pending notifications, and presence. Viewer config is included as a cheap +// notification Inbox state, and presence. Viewer config is included as a cheap // authoritative replacement so all self-only fields converge together. // Room viewer state is needed after incremental replay. A compacted reset // supplies it in snapshot room upserts and repairs only markers that changed @@ -264,48 +264,31 @@ func (s *HTTPServer) realtimeProjectionFrameForEventWithRooms(ctx context.Contex return nil, false, err } appendOperation(&realtimev1.RealtimeProjectionOperation{Operation: &realtimev1.RealtimeProjectionOperation_ViewerUpsert{ViewerUpsert: viewer}}) - case *corev1.LiveEvent_NotificationLevelChanged: - viewer, err := s.connectAPI.BuildRealtimeProjectionViewer(ctx, viewerID) - if err != nil { + case *corev1.LiveEvent_NotificationOccurrenceChanged: + change := payload.NotificationOccurrenceChanged + if err := s.core.NotificationOccurrences().WaitForSourceRevision( + ctx, + viewerID, + change.GetSourceEventId(), + change.GetRuntimeStateRevision(), + ); err != nil { return nil, false, err } - appendOperation(&realtimev1.RealtimeProjectionOperation{Operation: &realtimev1.RealtimeProjectionOperation_ViewerUpsert{ViewerUpsert: viewer}}) - case *corev1.LiveEvent_NotificationCreated: notifications, err := s.connectAPI.BuildRealtimeProjectionNotifications(ctx, viewerID) if err != nil { return nil, false, err } - replacement := realtimeProjectionNotifications(notifications) - replacement.Change = &realtimev1.RealtimeProjectionNotificationChange{ - Action: realtimev1.RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED, - NotificationId: payload.NotificationCreated.GetNotificationId(), - Silent: payload.NotificationCreated.GetSilent(), - } - appendOperation(&realtimev1.RealtimeProjectionOperation{Operation: &realtimev1.RealtimeProjectionOperation_NotificationsReplace{ - NotificationsReplace: replacement, - }}) - // Reply notifications are also the live signal that a followed - // thread became unread. Replace the complete latest-value set so - // unretained rooms and the My Threads view converge without a - // ConnectRPC refresh. - if payload.NotificationCreated.GetInReplyToId() != "" { - threadStates, err := s.connectAPI.BuildRealtimeProjectionThreadViewerStates(ctx, viewerID) - if err != nil { - return nil, false, err - } - appendOperation(&realtimev1.RealtimeProjectionOperation{Operation: &realtimev1.RealtimeProjectionOperation_ThreadViewerStatesReplace{ - ThreadViewerStatesReplace: realtimeProjectionThreadViewerStates(threadStates), - }}) - } - case *corev1.LiveEvent_NotificationDismissed: - notifications, err := s.connectAPI.BuildRealtimeProjectionNotifications(ctx, viewerID) - if err != nil { - return nil, false, err + action := realtimev1.RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED + if change.GetCreated() { + action = realtimev1.RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED + } else if change.GetDeleted() { + action = realtimev1.RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED } replacement := realtimeProjectionNotifications(notifications) replacement.Change = &realtimev1.RealtimeProjectionNotificationChange{ - Action: realtimev1.RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED, - NotificationId: payload.NotificationDismissed.GetNotificationId(), + Action: action, + NotificationId: change.GetNotificationId(), + Silent: !change.GetAlert(), } appendOperation(&realtimev1.RealtimeProjectionOperation{Operation: &realtimev1.RealtimeProjectionOperation_NotificationsReplace{ NotificationsReplace: replacement, @@ -541,6 +524,55 @@ func (s *HTTPServer) realtimeProjectionFrameForEventWithRooms(ctx context.Contex if err := appendTimeline(roomID, rootID, nil); err != nil { return nil, false, err } + // Thread unread state is independent of notification policy. An + // existing follower whose FOLLOWED_THREAD intensity is Off receives + // no occurrence invalidation, so reconcile from the durable message + // fact whenever this viewer actually follows the affected thread. + kind, err := s.core.FindRoomKind(ctx, roomID) + if err != nil { + return nil, false, err + } + following, err := s.core.IsFollowingThread(ctx, kind, viewerID, roomID, rootID) + if err != nil { + return nil, false, err + } + if following { + threadStates, err := s.connectAPI.BuildRealtimeProjectionThreadViewerStates(ctx, viewerID) + if err != nil { + return nil, false, err + } + found := false + for _, state := range threadStates { + if state != nil && state.RoomID == roomID && state.ThreadRootEventID == rootID { + found = true + if state.ViewerState == nil { + state.ViewerState = &apiv1.ThreadViewerState{} + } + isFollowing := true + state.ViewerState.IsFollowing = &isFollowing + if evt.GetActorId() != viewerID { + hasUnread := true + state.ViewerState.HasUnread = &hasUnread + } + break + } + } + if !found { + isFollowing := true + hasUnread := evt.GetActorId() != viewerID + threadStates = append(threadStates, &connectapi.RealtimeProjectionThreadViewerState{ + RoomID: roomID, + ThreadRootEventID: rootID, + ViewerState: &apiv1.ThreadViewerState{ + IsFollowing: &isFollowing, + HasUnread: &hasUnread, + }, + }) + } + appendOperation(&realtimev1.RealtimeProjectionOperation{Operation: &realtimev1.RealtimeProjectionOperation_ThreadViewerStatesReplace{ + ThreadViewerStatesReplace: realtimeProjectionThreadViewerStates(threadStates), + }}) + } } case *corev1.Event_MessageEdited: roomID := payload.MessageEdited.GetRoomId() @@ -835,10 +867,9 @@ func realtimeProjectionRoom(room *connectapi.RealtimeProjectionRoom) *realtimev1 return &realtimev1.RealtimeProjectionRoom{} } return &realtimev1.RealtimeProjectionRoom{ - Room: room.Room, - MemberUserIds: append([]string(nil), room.MemberUserIDs...), - ViewerNotificationCount: room.ViewerNotificationCount, - HasMessageHistory: room.HasMessageHistory, + Room: room.Room, + MemberUserIds: append([]string(nil), room.MemberUserIDs...), + HasMessageHistory: room.HasMessageHistory, } } @@ -847,8 +878,7 @@ func realtimeProjectionNotifications(notifications *connectapi.RealtimeProjectio return &realtimev1.RealtimeProjectionNotificationsReplace{} } return &realtimev1.RealtimeProjectionNotificationsReplace{ - Page: notifications.Page, - RoomCounts: notifications.RoomCounts, + Groups: notifications.Groups, } } diff --git a/cli/internal/http_server/realtime_test.go b/cli/internal/http_server/realtime_test.go index 2ea6dc674..978e2c119 100644 --- a/cli/internal/http_server/realtime_test.go +++ b/cli/internal/http_server/realtime_test.go @@ -442,9 +442,7 @@ func TestRealtimeTransientMapperRejectsProjectionOwnedLiveEvents(t *testing.T) { name string event *corev1.LiveEvent }{ - {"notification created", &corev1.LiveEvent{Event: &corev1.LiveEvent_NotificationCreated{NotificationCreated: &corev1.NotificationCreatedEvent{NotificationId: "N1"}}}}, - {"notification dismissed", &corev1.LiveEvent{Event: &corev1.LiveEvent_NotificationDismissed{NotificationDismissed: &corev1.NotificationDismissedEvent{NotificationId: "N1"}}}}, - {"notification level", &corev1.LiveEvent{Event: &corev1.LiveEvent_NotificationLevelChanged{NotificationLevelChanged: &corev1.NotificationLevelChangedEvent{RoomId: "R1"}}}}, + {"notification occurrence changed", &corev1.LiveEvent{Event: &corev1.LiveEvent_NotificationOccurrenceChanged{NotificationOccurrenceChanged: &corev1.NotificationOccurrenceChangedEvent{NotificationId: "N2"}}}}, {"thread follow", &corev1.LiveEvent{Event: &corev1.LiveEvent_ThreadFollowChanged{ThreadFollowChanged: &corev1.ThreadFollowChangedEvent{RoomId: "R1", ThreadRootEventId: "M1"}}}}, {"room read", &corev1.LiveEvent{Event: &corev1.LiveEvent_RoomMarkedAsRead{RoomMarkedAsRead: &corev1.RoomMarkedAsReadEvent{RoomId: "R1"}}}}, {"server updated", &corev1.LiveEvent{Event: &corev1.LiveEvent_ServerUpdated{ServerUpdated: &corev1.ServerUpdatedEvent{}}}}, @@ -463,106 +461,6 @@ func TestRealtimeTransientMapperRejectsProjectionOwnedLiveEvents(t *testing.T) { } } -func TestRealtimeMapperHydratesMentionNotificationDisplayData(t *testing.T) { - env := setupWebSocketTestServer(t) - actor, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-mention-actor", "RT Mention Actor", "password123") - if err != nil { - t.Fatalf("CreateUser actor: %v", err) - } - viewer, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-mention-viewer", "RT Mention Viewer", "password123") - if err != nil { - t.Fatalf("CreateUser viewer: %v", err) - } - room, err := env.core.CreateRoom(env.ctx, actor.Id, core.KindChannel, "", "rt-mention-room", "") - if err != nil { - t.Fatalf("CreateRoom: %v", err) - } - if _, err := env.core.JoinRoom(env.ctx, actor.Id, core.KindChannel, actor.Id, room.Id); err != nil { - t.Fatalf("JoinRoom actor: %v", err) - } - if _, err := env.core.JoinRoom(env.ctx, viewer.Id, core.KindChannel, viewer.Id, room.Id); err != nil { - t.Fatalf("JoinRoom viewer: %v", err) - } - - frame, err := env.httpServer.realtimeEventEnvelope(env.ctx, viewer.Id, core.NewLiveEventEnvelope(&corev1.LiveEvent{ - Id: "mention-display-1", - ActorId: actor.Id, - Event: &corev1.LiveEvent_MentionNotification{MentionNotification: &corev1.MentionNotificationEvent{ - RoomId: room.Id, - MentionedByUserId: actor.Id, - }}, - })) - if err != nil { - t.Fatalf("realtimeEventEnvelope: %v", err) - } - mention := frame.GetMentionNotification() - if mention == nil { - t.Fatalf("event = %T, want mention_notification", frame.GetEvent()) - } - if mention.RoomName == nil { - t.Fatal("room name is absent, want hydrated room name") - } - if mention.GetRoomName() != room.Name { - t.Fatalf("room name = %q, want %q", mention.GetRoomName(), room.Name) - } - if mention.ActorDisplayName == nil { - t.Fatal("actor display name is absent, want hydrated actor display name") - } - if mention.GetActorDisplayName() != actor.DisplayName { - t.Fatalf("actor display name = %q, want %q", mention.GetActorDisplayName(), actor.DisplayName) - } -} - -func TestRealtimeMapperHydratesDMNotificationDisplayData(t *testing.T) { - env := setupWebSocketTestServer(t) - sender, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-dm-sender", "RT DM Sender", "password123") - if err != nil { - t.Fatalf("CreateUser sender: %v", err) - } - viewer, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-dm-viewer", "RT DM Viewer", "password123") - if err != nil { - t.Fatalf("CreateUser viewer: %v", err) - } - room, _, err := env.core.FindOrCreateDM(env.ctx, sender.Id, []string{viewer.Id}) - if err != nil { - t.Fatalf("FindOrCreateDM: %v", err) - } - - frame, err := env.httpServer.realtimeEventEnvelope(env.ctx, viewer.Id, core.NewLiveEventEnvelope(&corev1.LiveEvent{ - Id: "dm-display-1", - ActorId: sender.Id, - Event: &corev1.LiveEvent_NewDirectMessageNotification{NewDirectMessageNotification: &corev1.NewDirectMessageNotificationEvent{ - RoomId: room.Id, - SenderId: sender.Id, - }}, - })) - if err != nil { - t.Fatalf("realtimeEventEnvelope: %v", err) - } - dm := frame.GetNewDirectMessageNotification() - if dm == nil { - t.Fatalf("event = %T, want new_direct_message_notification", frame.GetEvent()) - } - if dm.SenderDisplayName == nil { - t.Fatal("sender display name is absent, want hydrated sender display name") - } - if dm.GetSenderDisplayName() != sender.DisplayName { - t.Fatalf("sender display name = %q, want %q", dm.GetSenderDisplayName(), sender.DisplayName) - } - if dm.ConversationName == nil { - t.Fatal("conversation name is absent, want hydrated conversation name") - } - if dm.GetConversationName() != sender.DisplayName { - t.Fatalf("conversation name = %q, want %q", dm.GetConversationName(), sender.DisplayName) - } - if dm.SenderAvatarUrl == nil { - t.Fatal("sender avatar URL is absent, want hydrated empty avatar URL") - } - if dm.GetSenderAvatarUrl() != "" { - t.Fatalf("sender avatar URL = %q, want empty", dm.GetSenderAvatarUrl()) - } -} - func TestRealtimeWebSocketAuthenticatesWithBearerHello(t *testing.T) { env := setupWebSocketTestServer(t) user, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-bearer", "RT Bearer", "password123") @@ -1511,17 +1409,17 @@ func TestRealtimeWebSocketAdvancesPastRetainedUnarchiveForNonMember(t *testing.T } } -func TestRealtimeProjectionNotificationChangesReplaceStateAndCarryLiveTransitions(t *testing.T) { +func TestRealtimeProjectionNotificationOccurrenceChangesReplaceGroups(t *testing.T) { env := setupWebSocketTestServer(t) - viewer, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-notification-viewer", "RT Notification Viewer", "password123") + viewer, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-notification-v2-viewer", "RT Notification V2 Viewer", "password123") if err != nil { t.Fatalf("CreateUser viewer: %v", err) } - author, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-notification-author", "RT Notification Author", "password123") + author, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-notification-v2-author", "RT Notification V2 Author", "password123") if err != nil { t.Fatalf("CreateUser author: %v", err) } - room, err := env.core.CreateRoom(env.ctx, viewer.Id, core.KindChannel, "", "rt-notification-room", "") + room, err := env.core.CreateRoom(env.ctx, viewer.Id, core.KindChannel, "", "rt-notification-v2-room", "") if err != nil { t.Fatalf("CreateRoom: %v", err) } @@ -1530,88 +1428,64 @@ func TestRealtimeProjectionNotificationChangesReplaceStateAndCarryLiveTransition t.Fatalf("JoinRoom %q: %v", userID, err) } } - message, err := env.core.PostMessage(env.ctx, core.KindChannel, room.Id, author.Id, "notify me", nil, "", "", nil, false) + root, err := env.core.PostMessage(env.ctx, core.KindChannel, room.Id, viewer.Id, "thread root", nil, "", "", nil, false) if err != nil { - t.Fatalf("PostMessage: %v", err) - } - if err := env.core.FollowThread(env.ctx, core.KindChannel, viewer.Id, room.Id, message.Id); err != nil { - t.Fatalf("FollowThread: %v", err) + t.Fatalf("PostMessage root: %v", err) } - notification, err := env.core.CreateNotification(env.ctx, viewer.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_Reply{Reply: &corev1.ReplyNotification{ - RoomId: room.Id, EventId: message.Id, InReplyToId: message.Id, InThread: message.Id, - }}, - }) + _, err = env.core.PostMessage(env.ctx, core.KindChannel, room.Id, author.Id, "@rt-notification-v2-viewer hello", nil, root.Id, "", nil, false) if err != nil { - t.Fatalf("CreateNotification: %v", err) + t.Fatalf("PostMessage: %v", err) + } + occurrences, err := env.core.NotificationOccurrences().List(env.ctx, viewer.Id) + if err != nil || len(occurrences) != 1 { + t.Fatalf("List occurrences = %+v, %v, want one", occurrences, err) } + occurrence := occurrences[0] frame, handled, err := env.httpServer.realtimeProjectionFrameForEvent(env.ctx, viewer.Id, core.NewLiveEventEnvelope(&corev1.LiveEvent{ - Id: "notification-created-1", ActorId: author.Id, - Event: &corev1.LiveEvent_NotificationCreated{NotificationCreated: &corev1.NotificationCreatedEvent{ - NotificationId: notification.Id, RoomId: room.Id, EventId: message.Id, InReplyToId: message.Id, Silent: true, + Id: "notification-v2-created", + ActorId: author.Id, + Event: &corev1.LiveEvent_NotificationOccurrenceChanged{NotificationOccurrenceChanged: &corev1.NotificationOccurrenceChangedEvent{ + NotificationId: occurrence.GetId(), Created: true, Alert: true, }}, })) - if err != nil { - t.Fatalf("realtimeProjectionFrameForEvent: %v", err) - } - if !handled { - t.Fatal("notification-created event was not handled as a projection mutation") + if err != nil || !handled { + t.Fatalf("created projection frame = %+v, handled=%v, err=%v", frame, handled, err) } replacement := frame.GetProjectionEvent().GetOperations()[0].GetNotificationsReplace() - if replacement == nil || len(replacement.GetPage().GetNotifications()) != 1 { - t.Fatalf("notification replacement = %+v, want authoritative one-row page", replacement) + if replacement == nil || len(replacement.GetGroups().GetGroups()) != 1 || replacement.GetGroups().GetUnreadGroupCount() != 1 { + t.Fatalf("created replacement = %+v, want one unread group", replacement) + } + if counts := replacement.GetGroups().GetRoomUnreadGroupCounts(); len(counts) != 1 || counts[0].GetRoomId() != room.Id || counts[0].GetUnreadGroupCount() != 1 { + t.Fatalf("created room unread-group counts = %+v, want one group for %s", counts, room.Id) } - change := replacement.GetChange() - if change.GetAction() != realtimev1.RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED || change.GetNotificationId() != notification.Id || !change.GetSilent() { - t.Fatalf("notification transition = %+v", change) + if change := replacement.GetChange(); change.GetAction() != realtimev1.RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED || change.GetNotificationId() != occurrence.GetId() || change.GetSilent() { + t.Fatalf("created change = %+v", change) } - threadStates := frame.GetProjectionEvent().GetOperations()[1].GetThreadViewerStatesReplace() - if threadStates == nil || len(threadStates.GetStates()) != 1 || threadStates.GetStates()[0].GetThreadRootEventId() != message.Id { - t.Fatalf("reply notification thread-state replacement = %+v, want followed thread %q", threadStates, message.Id) + operations := frame.GetProjectionEvent().GetOperations() + if len(operations) != 1 { + t.Fatalf("created notification operations = %+v, want one notification replacement", operations) } - dismissed, err := env.core.DismissNotification(env.ctx, viewer.Id, notification.Id) - if err != nil || !dismissed { - t.Fatalf("DismissNotification: dismissed=%v err=%v", dismissed, err) + if _, err := env.core.NotificationOccurrences().MarkRead(env.ctx, viewer.Id, occurrence.GetId()); err != nil { + t.Fatalf("mark occurrence read: %v", err) } - dismissFrame, handled, err := env.httpServer.realtimeProjectionFrameForEvent(env.ctx, viewer.Id, core.NewLiveEventEnvelope(&corev1.LiveEvent{ - Id: "notification-dismissed-1", ActorId: viewer.Id, - Event: &corev1.LiveEvent_NotificationDismissed{NotificationDismissed: &corev1.NotificationDismissedEvent{ - NotificationId: notification.Id, + frame, handled, err = env.httpServer.realtimeProjectionFrameForEvent(env.ctx, viewer.Id, core.NewLiveEventEnvelope(&corev1.LiveEvent{ + Id: "notification-v2-updated", + ActorId: viewer.Id, + Event: &corev1.LiveEvent_NotificationOccurrenceChanged{NotificationOccurrenceChanged: &corev1.NotificationOccurrenceChangedEvent{ + NotificationId: occurrence.GetId(), }}, })) - if err != nil { - t.Fatalf("realtimeProjectionFrameForEvent dismissed: %v", err) - } - if !handled { - t.Fatal("notification-dismissed event was not handled as a projection mutation") + if err != nil || !handled { + t.Fatalf("updated projection frame = %+v, handled=%v, err=%v", frame, handled, err) } - dismissReplacement := dismissFrame.GetProjectionEvent().GetOperations()[0].GetNotificationsReplace() - if dismissReplacement == nil || len(dismissReplacement.GetPage().GetNotifications()) != 0 { - t.Fatalf("dismissed notification replacement = %+v, want authoritative empty page", dismissReplacement) + replacement = frame.GetProjectionEvent().GetOperations()[0].GetNotificationsReplace() + if replacement == nil || len(replacement.GetGroups().GetGroups()) != 1 || replacement.GetGroups().GetGroups()[0].GetUnread() || replacement.GetGroups().GetUnreadGroupCount() != 0 { + t.Fatalf("updated replacement = %+v, want one read group", replacement) } - dismissChange := dismissReplacement.GetChange() - if dismissChange.GetAction() != realtimev1.RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED || dismissChange.GetNotificationId() != notification.Id || dismissChange.GetSilent() { - t.Fatalf("dismissed notification transition = %+v", dismissChange) - } -} - -func TestRealtimeProjectionNotificationLevelChangedReplacesViewer(t *testing.T) { - env := setupWebSocketTestServer(t) - viewer, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-notification-level-viewer", "RT Notification Level Viewer", "password123") - if err != nil { - t.Fatalf("CreateUser: %v", err) - } - frame, handled, err := env.httpServer.realtimeProjectionFrameForEvent(env.ctx, viewer.Id, core.NewLiveEventEnvelope(&corev1.LiveEvent{ - Id: "notification-level-1", ActorId: viewer.Id, - Event: &corev1.LiveEvent_NotificationLevelChanged{NotificationLevelChanged: &corev1.NotificationLevelChangedEvent{}}, - })) - if err != nil { - t.Fatalf("realtimeProjectionFrameForEvent: %v", err) - } - if !handled || frame.GetProjectionEvent().GetOperations()[0].GetViewerUpsert() == nil { - t.Fatalf("notification-level projection = %+v, handled=%v; want viewer_upsert", frame, handled) + if change := replacement.GetChange(); change.GetAction() != realtimev1.RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED || change.GetNotificationId() != occurrence.GetId() || !change.GetSilent() { + t.Fatalf("updated change = %+v", change) } } @@ -1754,13 +1628,6 @@ func TestRealtimeProjectionRoomReadReplacesOnlyThatRoomViewerState(t *testing.T) if err != nil { t.Fatalf("PostMessage: %v", err) } - if _, err := env.core.CreateNotification(env.ctx, viewer.Id, author.Id, &corev1.Notification{ - Notification: &corev1.Notification_Mention{Mention: &corev1.MentionNotification{ - RoomId: room.Id, EventId: message.Id, - }}, - }); err != nil { - t.Fatalf("CreateNotification: %v", err) - } if _, err := env.core.ReadState().MarkRoomAsRead(env.ctx, viewer.Id, room.Id, message.Id); err != nil { t.Fatalf("MarkRoomAsRead: %v", err) } @@ -1788,8 +1655,8 @@ func TestRealtimeProjectionRoomReadReplacesOnlyThatRoomViewerState(t *testing.T) } if notifications := operations[1].GetNotificationsReplace(); notifications == nil { t.Fatal("room-read event did not replace current notification state") - } else if len(notifications.GetPage().GetNotifications()) != 0 || len(notifications.GetRoomCounts()) != 0 { - t.Fatalf("room-read notifications = %+v, want no pending notification state", notifications) + } else if len(notifications.GetGroups().GetGroups()) != 0 { + t.Fatalf("room-read notifications = %+v, want no unread notification state", notifications) } } @@ -2022,6 +1889,10 @@ func TestRealtimeWebSocketThreadReplyUpdatesRootSummary(t *testing.T) { if err != nil { t.Fatalf("CreateUser: %v", err) } + author, err := env.core.CreateUser(env.ctx, core.SystemActorID, "rt-thread-author", "RT Thread Author", "password123") + if err != nil { + t.Fatalf("CreateUser author: %v", err) + } room, err := env.core.CreateRoom(env.ctx, user.Id, core.KindChannel, "", "rt-thread-room", "") if err != nil { t.Fatalf("CreateRoom: %v", err) @@ -2029,10 +1900,29 @@ func TestRealtimeWebSocketThreadReplyUpdatesRootSummary(t *testing.T) { if _, err := env.core.JoinRoom(env.ctx, user.Id, core.KindChannel, user.Id, room.Id); err != nil { t.Fatalf("JoinRoom: %v", err) } + if _, err := env.core.JoinRoom(env.ctx, user.Id, core.KindChannel, author.Id, room.Id); err != nil { + t.Fatalf("JoinRoom author: %v", err) + } root, err := env.core.PostMessage(env.ctx, core.KindChannel, room.Id, user.Id, "root", nil, "", "", nil, false) if err != nil { t.Fatalf("PostMessage root: %v", err) } + if err := env.core.FollowThread(env.ctx, core.KindChannel, user.Id, room.Id, root.Id); err != nil { + t.Fatalf("FollowThread: %v", err) + } + following, err := env.core.IsFollowingThread(env.ctx, core.KindChannel, user.Id, room.Id, root.Id) + if err != nil || !following { + t.Fatalf("IsFollowingThread after FollowThread = %v, %v, want true", following, err) + } + if _, err := env.core.NotificationPolicy().SetRoomNotificationIntensity( + env.ctx, + user.Id, + room.Id, + corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF, + ); err != nil { + t.Fatalf("SetRoomNotificationIntensity: %v", err) + } token, err := env.core.CreateAuthToken(env.ctx, user.Id) if err != nil { t.Fatalf("CreateAuthToken: %v", err) @@ -2040,20 +1930,41 @@ func TestRealtimeWebSocketThreadReplyUpdatesRootSummary(t *testing.T) { conn := env.connectRealtime(t) subscribeRealtime(t, conn, token, room.Id) - reply, err := env.core.PostMessage(env.ctx, core.KindChannel, room.Id, user.Id, "reply", nil, root.Id, root.Id, nil, false) + reply, err := env.core.PostMessage(env.ctx, core.KindChannel, room.Id, author.Id, "reply", nil, root.Id, "", nil, false) if err != nil { t.Fatalf("PostMessage reply: %v", err) } - upsert := waitRealtimeTimelineUpsert(t, conn, 5*time.Second, func(upsert *realtimev1.RealtimeProjectionRoomTimelineEventUpsert) bool { - return upsert.GetEvent().GetId() == root.Id - }) + var upsert *realtimev1.RealtimeProjectionRoomTimelineEventUpsert + var states []*realtimev1.RealtimeProjectionThreadViewerState + for upsert == nil || len(states) == 0 { + frame, ok := readRealtimeServerFrame(t, conn, 5*time.Second) + if !ok { + t.Fatal("timed out waiting for thread reply projection") + } + for _, operation := range frame.GetProjectionEvent().GetOperations() { + candidate := operation.GetRoomTimelineEventUpsert() + if candidate.GetEvent().GetId() == root.Id { + upsert = candidate + } + if replacement := operation.GetThreadViewerStatesReplace(); replacement != nil { + states = replacement.GetStates() + } + } + } if upsert == nil { t.Fatal("did not receive root summary upsert") } if got := upsert.GetEvent().GetMessagePosted().GetMessage().GetThread().GetReplyCount(); got != 1 { t.Fatalf("root reply count = %d, want 1 (reply %q)", got, reply.Id) } + if len(states) != 1 || states[0].GetThreadRootEventId() != root.Id || !states[0].GetViewerState().GetHasUnread() { + t.Fatalf("thread viewer states = %+v, want followed root unread", states) + } + occurrences, err := env.core.NotificationOccurrences().List(env.ctx, user.Id) + if err != nil || len(occurrences) != 0 { + t.Fatalf("off-policy notification occurrences = %+v, %v, want none", occurrences, err) + } } func TestRealtimeWebSocketMessageRetractionUpsertsDeletedRow(t *testing.T) { @@ -2773,8 +2684,8 @@ func TestRealtimeWebSocketResumesAssetAndHiddenEchoGapThenContinuesLive(t *testi if caughtUpCursor == resumeCursor { t.Fatal("caught_up cursor did not advance across durable replay gap") } - if replyUpserts != 1 || echoRemovals != 2 || assetUpserts != 3 || notificationReconciliations != 1 || presenceReconciliations != 1 || viewerReconciliations != 1 || roomViewerReconciliations == 0 || threadViewerReconciliations != 1 { - t.Fatalf("replay reply/echo/asset/notifications/presence/viewer/room-viewer/thread-viewer = %d/%d/%d/%d/%d/%d/%d/%d, want 1/2/3/1/1/1/>0/1", replyUpserts, echoRemovals, assetUpserts, notificationReconciliations, presenceReconciliations, viewerReconciliations, roomViewerReconciliations, threadViewerReconciliations) + if replyUpserts != 1 || echoRemovals != 2 || assetUpserts != 3 || notificationReconciliations != 1 || presenceReconciliations != 1 || viewerReconciliations != 1 || roomViewerReconciliations == 0 || threadViewerReconciliations == 0 { + t.Fatalf("replay reply/echo/asset/notifications/presence/viewer/room-viewer/thread-viewer = %d/%d/%d/%d/%d/%d/%d/%d, want 1/2/3/1/1/1/>0/>0", replyUpserts, echoRemovals, assetUpserts, notificationReconciliations, presenceReconciliations, viewerReconciliations, roomViewerReconciliations, threadViewerReconciliations) } liveMessage, err := env.core.PostMessage(env.ctx, core.KindChannel, room.Id, user.Id, "after caught up", nil, "", "", nil, false) diff --git a/cli/internal/pb/chatto/api/v1/apiv1connect/notification_preferences.connect.go b/cli/internal/pb/chatto/api/v1/apiv1connect/notification_preferences.connect.go deleted file mode 100644 index 1a19a1e7c..000000000 --- a/cli/internal/pb/chatto/api/v1/apiv1connect/notification_preferences.connect.go +++ /dev/null @@ -1,224 +0,0 @@ -// Code generated by protoc-gen-connect-go. DO NOT EDIT. -// -// Source: chatto/api/v1/notification_preferences.proto - -package apiv1connect - -import ( - connect "connectrpc.com/connect" - context "context" - errors "errors" - v1 "hmans.de/chatto/internal/pb/chatto/api/v1" - http "net/http" - strings "strings" -) - -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - -const ( - // NotificationPreferencesServiceName is the fully-qualified name of the - // NotificationPreferencesService service. - NotificationPreferencesServiceName = "chatto.api.v1.NotificationPreferencesService" -) - -// These constants are the fully-qualified names of the RPCs defined in this package. They're -// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. -// -// Note that these are different from the fully-qualified method names used by -// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to -// reflection-formatted method names, remove the leading slash and convert the remaining slash to a -// period. -const ( - // NotificationPreferencesServiceGetServerNotificationPreferenceProcedure is the fully-qualified - // name of the NotificationPreferencesService's GetServerNotificationPreference RPC. - NotificationPreferencesServiceGetServerNotificationPreferenceProcedure = "/chatto.api.v1.NotificationPreferencesService/GetServerNotificationPreference" - // NotificationPreferencesServiceUpdateServerNotificationPreferenceProcedure is the fully-qualified - // name of the NotificationPreferencesService's UpdateServerNotificationPreference RPC. - NotificationPreferencesServiceUpdateServerNotificationPreferenceProcedure = "/chatto.api.v1.NotificationPreferencesService/UpdateServerNotificationPreference" - // NotificationPreferencesServiceGetRoomNotificationPreferenceProcedure is the fully-qualified name - // of the NotificationPreferencesService's GetRoomNotificationPreference RPC. - NotificationPreferencesServiceGetRoomNotificationPreferenceProcedure = "/chatto.api.v1.NotificationPreferencesService/GetRoomNotificationPreference" - // NotificationPreferencesServiceUpdateRoomNotificationPreferenceProcedure is the fully-qualified - // name of the NotificationPreferencesService's UpdateRoomNotificationPreference RPC. - NotificationPreferencesServiceUpdateRoomNotificationPreferenceProcedure = "/chatto.api.v1.NotificationPreferencesService/UpdateRoomNotificationPreference" -) - -// NotificationPreferencesServiceClient is a client for the -// chatto.api.v1.NotificationPreferencesService service. -type NotificationPreferencesServiceClient interface { - // Returns the current user's explicit and effective server notification level. - GetServerNotificationPreference(context.Context, *connect.Request[v1.GetServerNotificationPreferenceRequest]) (*connect.Response[v1.GetNotificationPreferenceResponse], error) - // Updates the current user's explicit server notification level and returns the - // resulting stored and effective levels. - UpdateServerNotificationPreference(context.Context, *connect.Request[v1.UpdateServerNotificationPreferenceRequest]) (*connect.Response[v1.UpdateNotificationPreferenceResponse], error) - // Returns the current user's explicit and effective notification level for a - // room. Use this before rendering room notification controls so the UI can - // distinguish inherited defaults from an explicit room override. Returns - // NOT_FOUND when the room does not exist and PERMISSION_DENIED when the room - // exists but is inaccessible to the caller. - GetRoomNotificationPreference(context.Context, *connect.Request[v1.GetRoomNotificationPreferenceRequest]) (*connect.Response[v1.GetNotificationPreferenceResponse], error) - // Updates the current user's explicit notification level for a room and returns - // the resulting stored and effective levels. - UpdateRoomNotificationPreference(context.Context, *connect.Request[v1.UpdateRoomNotificationPreferenceRequest]) (*connect.Response[v1.UpdateNotificationPreferenceResponse], error) -} - -// NewNotificationPreferencesServiceClient constructs a client for the -// chatto.api.v1.NotificationPreferencesService service. By default, it uses the Connect protocol -// with the binary Protobuf Codec, asks for gzipped responses, and sends uncompressed requests. To -// use the gRPC or gRPC-Web protocols, supply the connect.WithGRPC() or connect.WithGRPCWeb() -// options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewNotificationPreferencesServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) NotificationPreferencesServiceClient { - baseURL = strings.TrimRight(baseURL, "/") - notificationPreferencesServiceMethods := v1.File_chatto_api_v1_notification_preferences_proto.Services().ByName("NotificationPreferencesService").Methods() - return ¬ificationPreferencesServiceClient{ - getServerNotificationPreference: connect.NewClient[v1.GetServerNotificationPreferenceRequest, v1.GetNotificationPreferenceResponse]( - httpClient, - baseURL+NotificationPreferencesServiceGetServerNotificationPreferenceProcedure, - connect.WithSchema(notificationPreferencesServiceMethods.ByName("GetServerNotificationPreference")), - connect.WithClientOptions(opts...), - ), - updateServerNotificationPreference: connect.NewClient[v1.UpdateServerNotificationPreferenceRequest, v1.UpdateNotificationPreferenceResponse]( - httpClient, - baseURL+NotificationPreferencesServiceUpdateServerNotificationPreferenceProcedure, - connect.WithSchema(notificationPreferencesServiceMethods.ByName("UpdateServerNotificationPreference")), - connect.WithClientOptions(opts...), - ), - getRoomNotificationPreference: connect.NewClient[v1.GetRoomNotificationPreferenceRequest, v1.GetNotificationPreferenceResponse]( - httpClient, - baseURL+NotificationPreferencesServiceGetRoomNotificationPreferenceProcedure, - connect.WithSchema(notificationPreferencesServiceMethods.ByName("GetRoomNotificationPreference")), - connect.WithClientOptions(opts...), - ), - updateRoomNotificationPreference: connect.NewClient[v1.UpdateRoomNotificationPreferenceRequest, v1.UpdateNotificationPreferenceResponse]( - httpClient, - baseURL+NotificationPreferencesServiceUpdateRoomNotificationPreferenceProcedure, - connect.WithSchema(notificationPreferencesServiceMethods.ByName("UpdateRoomNotificationPreference")), - connect.WithClientOptions(opts...), - ), - } -} - -// notificationPreferencesServiceClient implements NotificationPreferencesServiceClient. -type notificationPreferencesServiceClient struct { - getServerNotificationPreference *connect.Client[v1.GetServerNotificationPreferenceRequest, v1.GetNotificationPreferenceResponse] - updateServerNotificationPreference *connect.Client[v1.UpdateServerNotificationPreferenceRequest, v1.UpdateNotificationPreferenceResponse] - getRoomNotificationPreference *connect.Client[v1.GetRoomNotificationPreferenceRequest, v1.GetNotificationPreferenceResponse] - updateRoomNotificationPreference *connect.Client[v1.UpdateRoomNotificationPreferenceRequest, v1.UpdateNotificationPreferenceResponse] -} - -// GetServerNotificationPreference calls -// chatto.api.v1.NotificationPreferencesService.GetServerNotificationPreference. -func (c *notificationPreferencesServiceClient) GetServerNotificationPreference(ctx context.Context, req *connect.Request[v1.GetServerNotificationPreferenceRequest]) (*connect.Response[v1.GetNotificationPreferenceResponse], error) { - return c.getServerNotificationPreference.CallUnary(ctx, req) -} - -// UpdateServerNotificationPreference calls -// chatto.api.v1.NotificationPreferencesService.UpdateServerNotificationPreference. -func (c *notificationPreferencesServiceClient) UpdateServerNotificationPreference(ctx context.Context, req *connect.Request[v1.UpdateServerNotificationPreferenceRequest]) (*connect.Response[v1.UpdateNotificationPreferenceResponse], error) { - return c.updateServerNotificationPreference.CallUnary(ctx, req) -} - -// GetRoomNotificationPreference calls -// chatto.api.v1.NotificationPreferencesService.GetRoomNotificationPreference. -func (c *notificationPreferencesServiceClient) GetRoomNotificationPreference(ctx context.Context, req *connect.Request[v1.GetRoomNotificationPreferenceRequest]) (*connect.Response[v1.GetNotificationPreferenceResponse], error) { - return c.getRoomNotificationPreference.CallUnary(ctx, req) -} - -// UpdateRoomNotificationPreference calls -// chatto.api.v1.NotificationPreferencesService.UpdateRoomNotificationPreference. -func (c *notificationPreferencesServiceClient) UpdateRoomNotificationPreference(ctx context.Context, req *connect.Request[v1.UpdateRoomNotificationPreferenceRequest]) (*connect.Response[v1.UpdateNotificationPreferenceResponse], error) { - return c.updateRoomNotificationPreference.CallUnary(ctx, req) -} - -// NotificationPreferencesServiceHandler is an implementation of the -// chatto.api.v1.NotificationPreferencesService service. -type NotificationPreferencesServiceHandler interface { - // Returns the current user's explicit and effective server notification level. - GetServerNotificationPreference(context.Context, *connect.Request[v1.GetServerNotificationPreferenceRequest]) (*connect.Response[v1.GetNotificationPreferenceResponse], error) - // Updates the current user's explicit server notification level and returns the - // resulting stored and effective levels. - UpdateServerNotificationPreference(context.Context, *connect.Request[v1.UpdateServerNotificationPreferenceRequest]) (*connect.Response[v1.UpdateNotificationPreferenceResponse], error) - // Returns the current user's explicit and effective notification level for a - // room. Use this before rendering room notification controls so the UI can - // distinguish inherited defaults from an explicit room override. Returns - // NOT_FOUND when the room does not exist and PERMISSION_DENIED when the room - // exists but is inaccessible to the caller. - GetRoomNotificationPreference(context.Context, *connect.Request[v1.GetRoomNotificationPreferenceRequest]) (*connect.Response[v1.GetNotificationPreferenceResponse], error) - // Updates the current user's explicit notification level for a room and returns - // the resulting stored and effective levels. - UpdateRoomNotificationPreference(context.Context, *connect.Request[v1.UpdateRoomNotificationPreferenceRequest]) (*connect.Response[v1.UpdateNotificationPreferenceResponse], error) -} - -// NewNotificationPreferencesServiceHandler builds an HTTP handler from the service implementation. -// It returns the path on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewNotificationPreferencesServiceHandler(svc NotificationPreferencesServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - notificationPreferencesServiceMethods := v1.File_chatto_api_v1_notification_preferences_proto.Services().ByName("NotificationPreferencesService").Methods() - notificationPreferencesServiceGetServerNotificationPreferenceHandler := connect.NewUnaryHandler( - NotificationPreferencesServiceGetServerNotificationPreferenceProcedure, - svc.GetServerNotificationPreference, - connect.WithSchema(notificationPreferencesServiceMethods.ByName("GetServerNotificationPreference")), - connect.WithHandlerOptions(opts...), - ) - notificationPreferencesServiceUpdateServerNotificationPreferenceHandler := connect.NewUnaryHandler( - NotificationPreferencesServiceUpdateServerNotificationPreferenceProcedure, - svc.UpdateServerNotificationPreference, - connect.WithSchema(notificationPreferencesServiceMethods.ByName("UpdateServerNotificationPreference")), - connect.WithHandlerOptions(opts...), - ) - notificationPreferencesServiceGetRoomNotificationPreferenceHandler := connect.NewUnaryHandler( - NotificationPreferencesServiceGetRoomNotificationPreferenceProcedure, - svc.GetRoomNotificationPreference, - connect.WithSchema(notificationPreferencesServiceMethods.ByName("GetRoomNotificationPreference")), - connect.WithHandlerOptions(opts...), - ) - notificationPreferencesServiceUpdateRoomNotificationPreferenceHandler := connect.NewUnaryHandler( - NotificationPreferencesServiceUpdateRoomNotificationPreferenceProcedure, - svc.UpdateRoomNotificationPreference, - connect.WithSchema(notificationPreferencesServiceMethods.ByName("UpdateRoomNotificationPreference")), - connect.WithHandlerOptions(opts...), - ) - return "/chatto.api.v1.NotificationPreferencesService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case NotificationPreferencesServiceGetServerNotificationPreferenceProcedure: - notificationPreferencesServiceGetServerNotificationPreferenceHandler.ServeHTTP(w, r) - case NotificationPreferencesServiceUpdateServerNotificationPreferenceProcedure: - notificationPreferencesServiceUpdateServerNotificationPreferenceHandler.ServeHTTP(w, r) - case NotificationPreferencesServiceGetRoomNotificationPreferenceProcedure: - notificationPreferencesServiceGetRoomNotificationPreferenceHandler.ServeHTTP(w, r) - case NotificationPreferencesServiceUpdateRoomNotificationPreferenceProcedure: - notificationPreferencesServiceUpdateRoomNotificationPreferenceHandler.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - }) -} - -// UnimplementedNotificationPreferencesServiceHandler returns CodeUnimplemented from all methods. -type UnimplementedNotificationPreferencesServiceHandler struct{} - -func (UnimplementedNotificationPreferencesServiceHandler) GetServerNotificationPreference(context.Context, *connect.Request[v1.GetServerNotificationPreferenceRequest]) (*connect.Response[v1.GetNotificationPreferenceResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationPreferencesService.GetServerNotificationPreference is not implemented")) -} - -func (UnimplementedNotificationPreferencesServiceHandler) UpdateServerNotificationPreference(context.Context, *connect.Request[v1.UpdateServerNotificationPreferenceRequest]) (*connect.Response[v1.UpdateNotificationPreferenceResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationPreferencesService.UpdateServerNotificationPreference is not implemented")) -} - -func (UnimplementedNotificationPreferencesServiceHandler) GetRoomNotificationPreference(context.Context, *connect.Request[v1.GetRoomNotificationPreferenceRequest]) (*connect.Response[v1.GetNotificationPreferenceResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationPreferencesService.GetRoomNotificationPreference is not implemented")) -} - -func (UnimplementedNotificationPreferencesServiceHandler) UpdateRoomNotificationPreference(context.Context, *connect.Request[v1.UpdateRoomNotificationPreferenceRequest]) (*connect.Response[v1.UpdateNotificationPreferenceResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationPreferencesService.UpdateRoomNotificationPreference is not implemented")) -} diff --git a/cli/internal/pb/chatto/api/v1/apiv1connect/notifications.connect.go b/cli/internal/pb/chatto/api/v1/apiv1connect/notifications.connect.go index 76f03966d..be3bf7fe5 100644 --- a/cli/internal/pb/chatto/api/v1/apiv1connect/notifications.connect.go +++ b/cli/internal/pb/chatto/api/v1/apiv1connect/notifications.connect.go @@ -33,52 +33,51 @@ const ( // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( - // NotificationServiceListNotificationsProcedure is the fully-qualified name of the - // NotificationService's ListNotifications RPC. - NotificationServiceListNotificationsProcedure = "/chatto.api.v1.NotificationService/ListNotifications" - // NotificationServiceGetNotificationProcedure is the fully-qualified name of the - // NotificationService's GetNotification RPC. - NotificationServiceGetNotificationProcedure = "/chatto.api.v1.NotificationService/GetNotification" - // NotificationServiceBatchGetNotificationsProcedure is the fully-qualified name of the - // NotificationService's BatchGetNotifications RPC. - NotificationServiceBatchGetNotificationsProcedure = "/chatto.api.v1.NotificationService/BatchGetNotifications" - // NotificationServiceListRoomNotificationsProcedure is the fully-qualified name of the - // NotificationService's ListRoomNotifications RPC. - NotificationServiceListRoomNotificationsProcedure = "/chatto.api.v1.NotificationService/ListRoomNotifications" - // NotificationServiceListRoomNotificationCountsProcedure is the fully-qualified name of the - // NotificationService's ListRoomNotificationCounts RPC. - NotificationServiceListRoomNotificationCountsProcedure = "/chatto.api.v1.NotificationService/ListRoomNotificationCounts" - // NotificationServiceHasNotificationsProcedure is the fully-qualified name of the - // NotificationService's HasNotifications RPC. - NotificationServiceHasNotificationsProcedure = "/chatto.api.v1.NotificationService/HasNotifications" - // NotificationServiceDismissNotificationProcedure is the fully-qualified name of the - // NotificationService's DismissNotification RPC. - NotificationServiceDismissNotificationProcedure = "/chatto.api.v1.NotificationService/DismissNotification" - // NotificationServiceDismissAllNotificationsProcedure is the fully-qualified name of the - // NotificationService's DismissAllNotifications RPC. - NotificationServiceDismissAllNotificationsProcedure = "/chatto.api.v1.NotificationService/DismissAllNotifications" + // NotificationServiceListNotificationGroupsProcedure is the fully-qualified name of the + // NotificationService's ListNotificationGroups RPC. + NotificationServiceListNotificationGroupsProcedure = "/chatto.api.v1.NotificationService/ListNotificationGroups" + // NotificationServiceMarkNotificationReadProcedure is the fully-qualified name of the + // NotificationService's MarkNotificationRead RPC. + NotificationServiceMarkNotificationReadProcedure = "/chatto.api.v1.NotificationService/MarkNotificationRead" + // NotificationServiceDeleteNotificationOccurrenceProcedure is the fully-qualified name of the + // NotificationService's DeleteNotificationOccurrence RPC. + NotificationServiceDeleteNotificationOccurrenceProcedure = "/chatto.api.v1.NotificationService/DeleteNotificationOccurrence" + // NotificationServiceDeleteNotificationGroupProcedure is the fully-qualified name of the + // NotificationService's DeleteNotificationGroup RPC. + NotificationServiceDeleteNotificationGroupProcedure = "/chatto.api.v1.NotificationService/DeleteNotificationGroup" + // NotificationServiceDeleteAllNotificationOccurrencesProcedure is the fully-qualified name of the + // NotificationService's DeleteAllNotificationOccurrences RPC. + NotificationServiceDeleteAllNotificationOccurrencesProcedure = "/chatto.api.v1.NotificationService/DeleteAllNotificationOccurrences" + // NotificationServiceGetNotificationPolicyProcedure is the fully-qualified name of the + // NotificationService's GetNotificationPolicy RPC. + NotificationServiceGetNotificationPolicyProcedure = "/chatto.api.v1.NotificationService/GetNotificationPolicy" + // NotificationServiceSetNotificationPolicyPreferenceProcedure is the fully-qualified name of the + // NotificationService's SetNotificationPolicyPreference RPC. + NotificationServiceSetNotificationPolicyPreferenceProcedure = "/chatto.api.v1.NotificationService/SetNotificationPolicyPreference" ) // NotificationServiceClient is a client for the chatto.api.v1.NotificationService service. type NotificationServiceClient interface { - // Lists the authenticated viewer's pending notifications. - ListNotifications(context.Context, *connect.Request[v1.ListNotificationsRequest]) (*connect.Response[v1.ListNotificationsResponse], error) - // Gets one pending notification. Returns NOT_FOUND when the notification is - // unknown or has been dismissed. - GetNotification(context.Context, *connect.Request[v1.GetNotificationRequest]) (*connect.Response[v1.GetNotificationResponse], error) - // Gets pending notifications by ID. - BatchGetNotifications(context.Context, *connect.Request[v1.BatchGetNotificationsRequest]) (*connect.Response[v1.BatchGetNotificationsResponse], error) - // Lists pending notifications for one room. Non-members receive an empty page. - ListRoomNotifications(context.Context, *connect.Request[v1.ListRoomNotificationsRequest]) (*connect.Response[v1.ListRoomNotificationsResponse], error) - // Lists pending notification counts grouped by room as a finite snapshot. - ListRoomNotificationCounts(context.Context, *connect.Request[v1.ListRoomNotificationCountsRequest]) (*connect.Response[v1.ListRoomNotificationCountsResponse], error) - // Checks whether the authenticated viewer has any pending notifications. - HasNotifications(context.Context, *connect.Request[v1.HasNotificationsRequest]) (*connect.Response[v1.HasNotificationsResponse], error) - // Dismisses one pending notification. Already-dismissed notifications are - // treated as idempotent success. - DismissNotification(context.Context, *connect.Request[v1.DismissNotificationRequest]) (*connect.Response[v1.DismissNotificationResponse], error) - // Dismisses all pending notifications. - DismissAllNotifications(context.Context, *connect.Request[v1.DismissAllNotificationsRequest]) (*connect.Response[v1.DismissAllNotificationsResponse], error) + // Lists Notifications 2.0 groups. + ListNotificationGroups(context.Context, *connect.Request[v1.ListNotificationGroupsRequest]) (*connect.Response[v1.ListNotificationGroupsResponse], error) + // Marks one occurrence read. Repeating the call is safe. + MarkNotificationRead(context.Context, *connect.Request[v1.MarkNotificationReadRequest]) (*connect.Response[v1.MarkNotificationReadResponse], error) + // Permanently deletes one occurrence while retaining its anti-recreation + // tombstone through the original expiry. Repeating the call is safe. + DeleteNotificationOccurrence(context.Context, *connect.Request[v1.DeleteNotificationOccurrenceRequest]) (*connect.Response[v1.DeleteNotificationOccurrenceResponse], error) + // Permanently deletes occurrences currently belonging to one derived group. + // Group membership is captured when the request is handled. Callers must not + // retry this mutation automatically because later activity may reuse the + // same derived group ID. + DeleteNotificationGroup(context.Context, *connect.Request[v1.DeleteNotificationGroupRequest]) (*connect.Response[v1.DeleteNotificationGroupResponse], error) + // Permanently deletes every occurrence current at the server's authoritative + // mutation boundary. Callers must not retry this mutation automatically + // because later activity may arrive after that boundary. + DeleteAllNotificationOccurrences(context.Context, *connect.Request[v1.DeleteAllNotificationOccurrencesRequest]) (*connect.Response[v1.DeleteAllNotificationOccurrencesResponse], error) + // Gets every supported cause and its effective inherited delivery intensity. + GetNotificationPolicy(context.Context, *connect.Request[v1.GetNotificationPolicyRequest]) (*connect.Response[v1.GetNotificationPolicyResponse], error) + // Sets or clears one server- or room-scoped cause override. + SetNotificationPolicyPreference(context.Context, *connect.Request[v1.SetNotificationPolicyPreferenceRequest]) (*connect.Response[v1.SetNotificationPolicyPreferenceResponse], error) } // NewNotificationServiceClient constructs a client for the chatto.api.v1.NotificationService @@ -92,130 +91,125 @@ func NewNotificationServiceClient(httpClient connect.HTTPClient, baseURL string, baseURL = strings.TrimRight(baseURL, "/") notificationServiceMethods := v1.File_chatto_api_v1_notifications_proto.Services().ByName("NotificationService").Methods() return ¬ificationServiceClient{ - listNotifications: connect.NewClient[v1.ListNotificationsRequest, v1.ListNotificationsResponse]( + listNotificationGroups: connect.NewClient[v1.ListNotificationGroupsRequest, v1.ListNotificationGroupsResponse]( httpClient, - baseURL+NotificationServiceListNotificationsProcedure, - connect.WithSchema(notificationServiceMethods.ByName("ListNotifications")), + baseURL+NotificationServiceListNotificationGroupsProcedure, + connect.WithSchema(notificationServiceMethods.ByName("ListNotificationGroups")), connect.WithClientOptions(opts...), ), - getNotification: connect.NewClient[v1.GetNotificationRequest, v1.GetNotificationResponse]( + markNotificationRead: connect.NewClient[v1.MarkNotificationReadRequest, v1.MarkNotificationReadResponse]( httpClient, - baseURL+NotificationServiceGetNotificationProcedure, - connect.WithSchema(notificationServiceMethods.ByName("GetNotification")), + baseURL+NotificationServiceMarkNotificationReadProcedure, + connect.WithSchema(notificationServiceMethods.ByName("MarkNotificationRead")), + connect.WithIdempotency(connect.IdempotencyIdempotent), connect.WithClientOptions(opts...), ), - batchGetNotifications: connect.NewClient[v1.BatchGetNotificationsRequest, v1.BatchGetNotificationsResponse]( + deleteNotificationOccurrence: connect.NewClient[v1.DeleteNotificationOccurrenceRequest, v1.DeleteNotificationOccurrenceResponse]( httpClient, - baseURL+NotificationServiceBatchGetNotificationsProcedure, - connect.WithSchema(notificationServiceMethods.ByName("BatchGetNotifications")), + baseURL+NotificationServiceDeleteNotificationOccurrenceProcedure, + connect.WithSchema(notificationServiceMethods.ByName("DeleteNotificationOccurrence")), + connect.WithIdempotency(connect.IdempotencyIdempotent), connect.WithClientOptions(opts...), ), - listRoomNotifications: connect.NewClient[v1.ListRoomNotificationsRequest, v1.ListRoomNotificationsResponse]( + deleteNotificationGroup: connect.NewClient[v1.DeleteNotificationGroupRequest, v1.DeleteNotificationGroupResponse]( httpClient, - baseURL+NotificationServiceListRoomNotificationsProcedure, - connect.WithSchema(notificationServiceMethods.ByName("ListRoomNotifications")), + baseURL+NotificationServiceDeleteNotificationGroupProcedure, + connect.WithSchema(notificationServiceMethods.ByName("DeleteNotificationGroup")), connect.WithClientOptions(opts...), ), - listRoomNotificationCounts: connect.NewClient[v1.ListRoomNotificationCountsRequest, v1.ListRoomNotificationCountsResponse]( + deleteAllNotificationOccurrences: connect.NewClient[v1.DeleteAllNotificationOccurrencesRequest, v1.DeleteAllNotificationOccurrencesResponse]( httpClient, - baseURL+NotificationServiceListRoomNotificationCountsProcedure, - connect.WithSchema(notificationServiceMethods.ByName("ListRoomNotificationCounts")), + baseURL+NotificationServiceDeleteAllNotificationOccurrencesProcedure, + connect.WithSchema(notificationServiceMethods.ByName("DeleteAllNotificationOccurrences")), connect.WithClientOptions(opts...), ), - hasNotifications: connect.NewClient[v1.HasNotificationsRequest, v1.HasNotificationsResponse]( + getNotificationPolicy: connect.NewClient[v1.GetNotificationPolicyRequest, v1.GetNotificationPolicyResponse]( httpClient, - baseURL+NotificationServiceHasNotificationsProcedure, - connect.WithSchema(notificationServiceMethods.ByName("HasNotifications")), + baseURL+NotificationServiceGetNotificationPolicyProcedure, + connect.WithSchema(notificationServiceMethods.ByName("GetNotificationPolicy")), connect.WithClientOptions(opts...), ), - dismissNotification: connect.NewClient[v1.DismissNotificationRequest, v1.DismissNotificationResponse]( + setNotificationPolicyPreference: connect.NewClient[v1.SetNotificationPolicyPreferenceRequest, v1.SetNotificationPolicyPreferenceResponse]( httpClient, - baseURL+NotificationServiceDismissNotificationProcedure, - connect.WithSchema(notificationServiceMethods.ByName("DismissNotification")), + baseURL+NotificationServiceSetNotificationPolicyPreferenceProcedure, + connect.WithSchema(notificationServiceMethods.ByName("SetNotificationPolicyPreference")), connect.WithIdempotency(connect.IdempotencyIdempotent), connect.WithClientOptions(opts...), ), - dismissAllNotifications: connect.NewClient[v1.DismissAllNotificationsRequest, v1.DismissAllNotificationsResponse]( - httpClient, - baseURL+NotificationServiceDismissAllNotificationsProcedure, - connect.WithSchema(notificationServiceMethods.ByName("DismissAllNotifications")), - connect.WithClientOptions(opts...), - ), } } // notificationServiceClient implements NotificationServiceClient. type notificationServiceClient struct { - listNotifications *connect.Client[v1.ListNotificationsRequest, v1.ListNotificationsResponse] - getNotification *connect.Client[v1.GetNotificationRequest, v1.GetNotificationResponse] - batchGetNotifications *connect.Client[v1.BatchGetNotificationsRequest, v1.BatchGetNotificationsResponse] - listRoomNotifications *connect.Client[v1.ListRoomNotificationsRequest, v1.ListRoomNotificationsResponse] - listRoomNotificationCounts *connect.Client[v1.ListRoomNotificationCountsRequest, v1.ListRoomNotificationCountsResponse] - hasNotifications *connect.Client[v1.HasNotificationsRequest, v1.HasNotificationsResponse] - dismissNotification *connect.Client[v1.DismissNotificationRequest, v1.DismissNotificationResponse] - dismissAllNotifications *connect.Client[v1.DismissAllNotificationsRequest, v1.DismissAllNotificationsResponse] -} - -// ListNotifications calls chatto.api.v1.NotificationService.ListNotifications. -func (c *notificationServiceClient) ListNotifications(ctx context.Context, req *connect.Request[v1.ListNotificationsRequest]) (*connect.Response[v1.ListNotificationsResponse], error) { - return c.listNotifications.CallUnary(ctx, req) + listNotificationGroups *connect.Client[v1.ListNotificationGroupsRequest, v1.ListNotificationGroupsResponse] + markNotificationRead *connect.Client[v1.MarkNotificationReadRequest, v1.MarkNotificationReadResponse] + deleteNotificationOccurrence *connect.Client[v1.DeleteNotificationOccurrenceRequest, v1.DeleteNotificationOccurrenceResponse] + deleteNotificationGroup *connect.Client[v1.DeleteNotificationGroupRequest, v1.DeleteNotificationGroupResponse] + deleteAllNotificationOccurrences *connect.Client[v1.DeleteAllNotificationOccurrencesRequest, v1.DeleteAllNotificationOccurrencesResponse] + getNotificationPolicy *connect.Client[v1.GetNotificationPolicyRequest, v1.GetNotificationPolicyResponse] + setNotificationPolicyPreference *connect.Client[v1.SetNotificationPolicyPreferenceRequest, v1.SetNotificationPolicyPreferenceResponse] } -// GetNotification calls chatto.api.v1.NotificationService.GetNotification. -func (c *notificationServiceClient) GetNotification(ctx context.Context, req *connect.Request[v1.GetNotificationRequest]) (*connect.Response[v1.GetNotificationResponse], error) { - return c.getNotification.CallUnary(ctx, req) +// ListNotificationGroups calls chatto.api.v1.NotificationService.ListNotificationGroups. +func (c *notificationServiceClient) ListNotificationGroups(ctx context.Context, req *connect.Request[v1.ListNotificationGroupsRequest]) (*connect.Response[v1.ListNotificationGroupsResponse], error) { + return c.listNotificationGroups.CallUnary(ctx, req) } -// BatchGetNotifications calls chatto.api.v1.NotificationService.BatchGetNotifications. -func (c *notificationServiceClient) BatchGetNotifications(ctx context.Context, req *connect.Request[v1.BatchGetNotificationsRequest]) (*connect.Response[v1.BatchGetNotificationsResponse], error) { - return c.batchGetNotifications.CallUnary(ctx, req) +// MarkNotificationRead calls chatto.api.v1.NotificationService.MarkNotificationRead. +func (c *notificationServiceClient) MarkNotificationRead(ctx context.Context, req *connect.Request[v1.MarkNotificationReadRequest]) (*connect.Response[v1.MarkNotificationReadResponse], error) { + return c.markNotificationRead.CallUnary(ctx, req) } -// ListRoomNotifications calls chatto.api.v1.NotificationService.ListRoomNotifications. -func (c *notificationServiceClient) ListRoomNotifications(ctx context.Context, req *connect.Request[v1.ListRoomNotificationsRequest]) (*connect.Response[v1.ListRoomNotificationsResponse], error) { - return c.listRoomNotifications.CallUnary(ctx, req) +// DeleteNotificationOccurrence calls +// chatto.api.v1.NotificationService.DeleteNotificationOccurrence. +func (c *notificationServiceClient) DeleteNotificationOccurrence(ctx context.Context, req *connect.Request[v1.DeleteNotificationOccurrenceRequest]) (*connect.Response[v1.DeleteNotificationOccurrenceResponse], error) { + return c.deleteNotificationOccurrence.CallUnary(ctx, req) } -// ListRoomNotificationCounts calls chatto.api.v1.NotificationService.ListRoomNotificationCounts. -func (c *notificationServiceClient) ListRoomNotificationCounts(ctx context.Context, req *connect.Request[v1.ListRoomNotificationCountsRequest]) (*connect.Response[v1.ListRoomNotificationCountsResponse], error) { - return c.listRoomNotificationCounts.CallUnary(ctx, req) +// DeleteNotificationGroup calls chatto.api.v1.NotificationService.DeleteNotificationGroup. +func (c *notificationServiceClient) DeleteNotificationGroup(ctx context.Context, req *connect.Request[v1.DeleteNotificationGroupRequest]) (*connect.Response[v1.DeleteNotificationGroupResponse], error) { + return c.deleteNotificationGroup.CallUnary(ctx, req) } -// HasNotifications calls chatto.api.v1.NotificationService.HasNotifications. -func (c *notificationServiceClient) HasNotifications(ctx context.Context, req *connect.Request[v1.HasNotificationsRequest]) (*connect.Response[v1.HasNotificationsResponse], error) { - return c.hasNotifications.CallUnary(ctx, req) +// DeleteAllNotificationOccurrences calls +// chatto.api.v1.NotificationService.DeleteAllNotificationOccurrences. +func (c *notificationServiceClient) DeleteAllNotificationOccurrences(ctx context.Context, req *connect.Request[v1.DeleteAllNotificationOccurrencesRequest]) (*connect.Response[v1.DeleteAllNotificationOccurrencesResponse], error) { + return c.deleteAllNotificationOccurrences.CallUnary(ctx, req) } -// DismissNotification calls chatto.api.v1.NotificationService.DismissNotification. -func (c *notificationServiceClient) DismissNotification(ctx context.Context, req *connect.Request[v1.DismissNotificationRequest]) (*connect.Response[v1.DismissNotificationResponse], error) { - return c.dismissNotification.CallUnary(ctx, req) +// GetNotificationPolicy calls chatto.api.v1.NotificationService.GetNotificationPolicy. +func (c *notificationServiceClient) GetNotificationPolicy(ctx context.Context, req *connect.Request[v1.GetNotificationPolicyRequest]) (*connect.Response[v1.GetNotificationPolicyResponse], error) { + return c.getNotificationPolicy.CallUnary(ctx, req) } -// DismissAllNotifications calls chatto.api.v1.NotificationService.DismissAllNotifications. -func (c *notificationServiceClient) DismissAllNotifications(ctx context.Context, req *connect.Request[v1.DismissAllNotificationsRequest]) (*connect.Response[v1.DismissAllNotificationsResponse], error) { - return c.dismissAllNotifications.CallUnary(ctx, req) +// SetNotificationPolicyPreference calls +// chatto.api.v1.NotificationService.SetNotificationPolicyPreference. +func (c *notificationServiceClient) SetNotificationPolicyPreference(ctx context.Context, req *connect.Request[v1.SetNotificationPolicyPreferenceRequest]) (*connect.Response[v1.SetNotificationPolicyPreferenceResponse], error) { + return c.setNotificationPolicyPreference.CallUnary(ctx, req) } // NotificationServiceHandler is an implementation of the chatto.api.v1.NotificationService service. type NotificationServiceHandler interface { - // Lists the authenticated viewer's pending notifications. - ListNotifications(context.Context, *connect.Request[v1.ListNotificationsRequest]) (*connect.Response[v1.ListNotificationsResponse], error) - // Gets one pending notification. Returns NOT_FOUND when the notification is - // unknown or has been dismissed. - GetNotification(context.Context, *connect.Request[v1.GetNotificationRequest]) (*connect.Response[v1.GetNotificationResponse], error) - // Gets pending notifications by ID. - BatchGetNotifications(context.Context, *connect.Request[v1.BatchGetNotificationsRequest]) (*connect.Response[v1.BatchGetNotificationsResponse], error) - // Lists pending notifications for one room. Non-members receive an empty page. - ListRoomNotifications(context.Context, *connect.Request[v1.ListRoomNotificationsRequest]) (*connect.Response[v1.ListRoomNotificationsResponse], error) - // Lists pending notification counts grouped by room as a finite snapshot. - ListRoomNotificationCounts(context.Context, *connect.Request[v1.ListRoomNotificationCountsRequest]) (*connect.Response[v1.ListRoomNotificationCountsResponse], error) - // Checks whether the authenticated viewer has any pending notifications. - HasNotifications(context.Context, *connect.Request[v1.HasNotificationsRequest]) (*connect.Response[v1.HasNotificationsResponse], error) - // Dismisses one pending notification. Already-dismissed notifications are - // treated as idempotent success. - DismissNotification(context.Context, *connect.Request[v1.DismissNotificationRequest]) (*connect.Response[v1.DismissNotificationResponse], error) - // Dismisses all pending notifications. - DismissAllNotifications(context.Context, *connect.Request[v1.DismissAllNotificationsRequest]) (*connect.Response[v1.DismissAllNotificationsResponse], error) + // Lists Notifications 2.0 groups. + ListNotificationGroups(context.Context, *connect.Request[v1.ListNotificationGroupsRequest]) (*connect.Response[v1.ListNotificationGroupsResponse], error) + // Marks one occurrence read. Repeating the call is safe. + MarkNotificationRead(context.Context, *connect.Request[v1.MarkNotificationReadRequest]) (*connect.Response[v1.MarkNotificationReadResponse], error) + // Permanently deletes one occurrence while retaining its anti-recreation + // tombstone through the original expiry. Repeating the call is safe. + DeleteNotificationOccurrence(context.Context, *connect.Request[v1.DeleteNotificationOccurrenceRequest]) (*connect.Response[v1.DeleteNotificationOccurrenceResponse], error) + // Permanently deletes occurrences currently belonging to one derived group. + // Group membership is captured when the request is handled. Callers must not + // retry this mutation automatically because later activity may reuse the + // same derived group ID. + DeleteNotificationGroup(context.Context, *connect.Request[v1.DeleteNotificationGroupRequest]) (*connect.Response[v1.DeleteNotificationGroupResponse], error) + // Permanently deletes every occurrence current at the server's authoritative + // mutation boundary. Callers must not retry this mutation automatically + // because later activity may arrive after that boundary. + DeleteAllNotificationOccurrences(context.Context, *connect.Request[v1.DeleteAllNotificationOccurrencesRequest]) (*connect.Response[v1.DeleteAllNotificationOccurrencesResponse], error) + // Gets every supported cause and its effective inherited delivery intensity. + GetNotificationPolicy(context.Context, *connect.Request[v1.GetNotificationPolicyRequest]) (*connect.Response[v1.GetNotificationPolicyResponse], error) + // Sets or clears one server- or room-scoped cause override. + SetNotificationPolicyPreference(context.Context, *connect.Request[v1.SetNotificationPolicyPreferenceRequest]) (*connect.Response[v1.SetNotificationPolicyPreferenceResponse], error) } // NewNotificationServiceHandler builds an HTTP handler from the service implementation. It returns @@ -225,73 +219,67 @@ type NotificationServiceHandler interface { // and JSON codecs. They also support gzip compression. func NewNotificationServiceHandler(svc NotificationServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { notificationServiceMethods := v1.File_chatto_api_v1_notifications_proto.Services().ByName("NotificationService").Methods() - notificationServiceListNotificationsHandler := connect.NewUnaryHandler( - NotificationServiceListNotificationsProcedure, - svc.ListNotifications, - connect.WithSchema(notificationServiceMethods.ByName("ListNotifications")), + notificationServiceListNotificationGroupsHandler := connect.NewUnaryHandler( + NotificationServiceListNotificationGroupsProcedure, + svc.ListNotificationGroups, + connect.WithSchema(notificationServiceMethods.ByName("ListNotificationGroups")), connect.WithHandlerOptions(opts...), ) - notificationServiceGetNotificationHandler := connect.NewUnaryHandler( - NotificationServiceGetNotificationProcedure, - svc.GetNotification, - connect.WithSchema(notificationServiceMethods.ByName("GetNotification")), + notificationServiceMarkNotificationReadHandler := connect.NewUnaryHandler( + NotificationServiceMarkNotificationReadProcedure, + svc.MarkNotificationRead, + connect.WithSchema(notificationServiceMethods.ByName("MarkNotificationRead")), + connect.WithIdempotency(connect.IdempotencyIdempotent), connect.WithHandlerOptions(opts...), ) - notificationServiceBatchGetNotificationsHandler := connect.NewUnaryHandler( - NotificationServiceBatchGetNotificationsProcedure, - svc.BatchGetNotifications, - connect.WithSchema(notificationServiceMethods.ByName("BatchGetNotifications")), + notificationServiceDeleteNotificationOccurrenceHandler := connect.NewUnaryHandler( + NotificationServiceDeleteNotificationOccurrenceProcedure, + svc.DeleteNotificationOccurrence, + connect.WithSchema(notificationServiceMethods.ByName("DeleteNotificationOccurrence")), + connect.WithIdempotency(connect.IdempotencyIdempotent), connect.WithHandlerOptions(opts...), ) - notificationServiceListRoomNotificationsHandler := connect.NewUnaryHandler( - NotificationServiceListRoomNotificationsProcedure, - svc.ListRoomNotifications, - connect.WithSchema(notificationServiceMethods.ByName("ListRoomNotifications")), + notificationServiceDeleteNotificationGroupHandler := connect.NewUnaryHandler( + NotificationServiceDeleteNotificationGroupProcedure, + svc.DeleteNotificationGroup, + connect.WithSchema(notificationServiceMethods.ByName("DeleteNotificationGroup")), connect.WithHandlerOptions(opts...), ) - notificationServiceListRoomNotificationCountsHandler := connect.NewUnaryHandler( - NotificationServiceListRoomNotificationCountsProcedure, - svc.ListRoomNotificationCounts, - connect.WithSchema(notificationServiceMethods.ByName("ListRoomNotificationCounts")), + notificationServiceDeleteAllNotificationOccurrencesHandler := connect.NewUnaryHandler( + NotificationServiceDeleteAllNotificationOccurrencesProcedure, + svc.DeleteAllNotificationOccurrences, + connect.WithSchema(notificationServiceMethods.ByName("DeleteAllNotificationOccurrences")), connect.WithHandlerOptions(opts...), ) - notificationServiceHasNotificationsHandler := connect.NewUnaryHandler( - NotificationServiceHasNotificationsProcedure, - svc.HasNotifications, - connect.WithSchema(notificationServiceMethods.ByName("HasNotifications")), + notificationServiceGetNotificationPolicyHandler := connect.NewUnaryHandler( + NotificationServiceGetNotificationPolicyProcedure, + svc.GetNotificationPolicy, + connect.WithSchema(notificationServiceMethods.ByName("GetNotificationPolicy")), connect.WithHandlerOptions(opts...), ) - notificationServiceDismissNotificationHandler := connect.NewUnaryHandler( - NotificationServiceDismissNotificationProcedure, - svc.DismissNotification, - connect.WithSchema(notificationServiceMethods.ByName("DismissNotification")), + notificationServiceSetNotificationPolicyPreferenceHandler := connect.NewUnaryHandler( + NotificationServiceSetNotificationPolicyPreferenceProcedure, + svc.SetNotificationPolicyPreference, + connect.WithSchema(notificationServiceMethods.ByName("SetNotificationPolicyPreference")), connect.WithIdempotency(connect.IdempotencyIdempotent), connect.WithHandlerOptions(opts...), ) - notificationServiceDismissAllNotificationsHandler := connect.NewUnaryHandler( - NotificationServiceDismissAllNotificationsProcedure, - svc.DismissAllNotifications, - connect.WithSchema(notificationServiceMethods.ByName("DismissAllNotifications")), - connect.WithHandlerOptions(opts...), - ) return "/chatto.api.v1.NotificationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { - case NotificationServiceListNotificationsProcedure: - notificationServiceListNotificationsHandler.ServeHTTP(w, r) - case NotificationServiceGetNotificationProcedure: - notificationServiceGetNotificationHandler.ServeHTTP(w, r) - case NotificationServiceBatchGetNotificationsProcedure: - notificationServiceBatchGetNotificationsHandler.ServeHTTP(w, r) - case NotificationServiceListRoomNotificationsProcedure: - notificationServiceListRoomNotificationsHandler.ServeHTTP(w, r) - case NotificationServiceListRoomNotificationCountsProcedure: - notificationServiceListRoomNotificationCountsHandler.ServeHTTP(w, r) - case NotificationServiceHasNotificationsProcedure: - notificationServiceHasNotificationsHandler.ServeHTTP(w, r) - case NotificationServiceDismissNotificationProcedure: - notificationServiceDismissNotificationHandler.ServeHTTP(w, r) - case NotificationServiceDismissAllNotificationsProcedure: - notificationServiceDismissAllNotificationsHandler.ServeHTTP(w, r) + case NotificationServiceListNotificationGroupsProcedure: + notificationServiceListNotificationGroupsHandler.ServeHTTP(w, r) + case NotificationServiceMarkNotificationReadProcedure: + notificationServiceMarkNotificationReadHandler.ServeHTTP(w, r) + case NotificationServiceDeleteNotificationOccurrenceProcedure: + notificationServiceDeleteNotificationOccurrenceHandler.ServeHTTP(w, r) + case NotificationServiceDeleteNotificationGroupProcedure: + notificationServiceDeleteNotificationGroupHandler.ServeHTTP(w, r) + case NotificationServiceDeleteAllNotificationOccurrencesProcedure: + notificationServiceDeleteAllNotificationOccurrencesHandler.ServeHTTP(w, r) + case NotificationServiceGetNotificationPolicyProcedure: + notificationServiceGetNotificationPolicyHandler.ServeHTTP(w, r) + case NotificationServiceSetNotificationPolicyPreferenceProcedure: + notificationServiceSetNotificationPolicyPreferenceHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -301,34 +289,30 @@ func NewNotificationServiceHandler(svc NotificationServiceHandler, opts ...conne // UnimplementedNotificationServiceHandler returns CodeUnimplemented from all methods. type UnimplementedNotificationServiceHandler struct{} -func (UnimplementedNotificationServiceHandler) ListNotifications(context.Context, *connect.Request[v1.ListNotificationsRequest]) (*connect.Response[v1.ListNotificationsResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.ListNotifications is not implemented")) -} - -func (UnimplementedNotificationServiceHandler) GetNotification(context.Context, *connect.Request[v1.GetNotificationRequest]) (*connect.Response[v1.GetNotificationResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.GetNotification is not implemented")) +func (UnimplementedNotificationServiceHandler) ListNotificationGroups(context.Context, *connect.Request[v1.ListNotificationGroupsRequest]) (*connect.Response[v1.ListNotificationGroupsResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.ListNotificationGroups is not implemented")) } -func (UnimplementedNotificationServiceHandler) BatchGetNotifications(context.Context, *connect.Request[v1.BatchGetNotificationsRequest]) (*connect.Response[v1.BatchGetNotificationsResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.BatchGetNotifications is not implemented")) +func (UnimplementedNotificationServiceHandler) MarkNotificationRead(context.Context, *connect.Request[v1.MarkNotificationReadRequest]) (*connect.Response[v1.MarkNotificationReadResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.MarkNotificationRead is not implemented")) } -func (UnimplementedNotificationServiceHandler) ListRoomNotifications(context.Context, *connect.Request[v1.ListRoomNotificationsRequest]) (*connect.Response[v1.ListRoomNotificationsResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.ListRoomNotifications is not implemented")) +func (UnimplementedNotificationServiceHandler) DeleteNotificationOccurrence(context.Context, *connect.Request[v1.DeleteNotificationOccurrenceRequest]) (*connect.Response[v1.DeleteNotificationOccurrenceResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.DeleteNotificationOccurrence is not implemented")) } -func (UnimplementedNotificationServiceHandler) ListRoomNotificationCounts(context.Context, *connect.Request[v1.ListRoomNotificationCountsRequest]) (*connect.Response[v1.ListRoomNotificationCountsResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.ListRoomNotificationCounts is not implemented")) +func (UnimplementedNotificationServiceHandler) DeleteNotificationGroup(context.Context, *connect.Request[v1.DeleteNotificationGroupRequest]) (*connect.Response[v1.DeleteNotificationGroupResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.DeleteNotificationGroup is not implemented")) } -func (UnimplementedNotificationServiceHandler) HasNotifications(context.Context, *connect.Request[v1.HasNotificationsRequest]) (*connect.Response[v1.HasNotificationsResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.HasNotifications is not implemented")) +func (UnimplementedNotificationServiceHandler) DeleteAllNotificationOccurrences(context.Context, *connect.Request[v1.DeleteAllNotificationOccurrencesRequest]) (*connect.Response[v1.DeleteAllNotificationOccurrencesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.DeleteAllNotificationOccurrences is not implemented")) } -func (UnimplementedNotificationServiceHandler) DismissNotification(context.Context, *connect.Request[v1.DismissNotificationRequest]) (*connect.Response[v1.DismissNotificationResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.DismissNotification is not implemented")) +func (UnimplementedNotificationServiceHandler) GetNotificationPolicy(context.Context, *connect.Request[v1.GetNotificationPolicyRequest]) (*connect.Response[v1.GetNotificationPolicyResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.GetNotificationPolicy is not implemented")) } -func (UnimplementedNotificationServiceHandler) DismissAllNotifications(context.Context, *connect.Request[v1.DismissAllNotificationsRequest]) (*connect.Response[v1.DismissAllNotificationsResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.DismissAllNotifications is not implemented")) +func (UnimplementedNotificationServiceHandler) SetNotificationPolicyPreference(context.Context, *connect.Request[v1.SetNotificationPolicyPreferenceRequest]) (*connect.Response[v1.SetNotificationPolicyPreferenceResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.SetNotificationPolicyPreference is not implemented")) } diff --git a/cli/internal/pb/chatto/api/v1/notification_preferences.pb.go b/cli/internal/pb/chatto/api/v1/notification_preferences.pb.go deleted file mode 100644 index 62aa8a121..000000000 --- a/cli/internal/pb/chatto/api/v1/notification_preferences.pb.go +++ /dev/null @@ -1,527 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: chatto/api/v1/notification_preferences.proto - -package apiv1 - -import ( - _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// Notification delivery level for a room. -type NotificationLevel int32 - -const ( - // The level was not specified. - NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED NotificationLevel = 0 - // Use the inherited default for the room. - NotificationLevel_NOTIFICATION_LEVEL_DEFAULT NotificationLevel = 1 - // Do not notify for this room. - NotificationLevel_NOTIFICATION_LEVEL_MUTED NotificationLevel = 2 - // Notify according to the normal room rules. - NotificationLevel_NOTIFICATION_LEVEL_NORMAL NotificationLevel = 3 - // Notify for every message in the room. - NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES NotificationLevel = 4 -) - -// Enum value maps for NotificationLevel. -var ( - NotificationLevel_name = map[int32]string{ - 0: "NOTIFICATION_LEVEL_UNSPECIFIED", - 1: "NOTIFICATION_LEVEL_DEFAULT", - 2: "NOTIFICATION_LEVEL_MUTED", - 3: "NOTIFICATION_LEVEL_NORMAL", - 4: "NOTIFICATION_LEVEL_ALL_MESSAGES", - } - NotificationLevel_value = map[string]int32{ - "NOTIFICATION_LEVEL_UNSPECIFIED": 0, - "NOTIFICATION_LEVEL_DEFAULT": 1, - "NOTIFICATION_LEVEL_MUTED": 2, - "NOTIFICATION_LEVEL_NORMAL": 3, - "NOTIFICATION_LEVEL_ALL_MESSAGES": 4, - } -) - -func (x NotificationLevel) Enum() *NotificationLevel { - p := new(NotificationLevel) - *p = x - return p -} - -func (x NotificationLevel) String() string { - return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) -} - -func (NotificationLevel) Descriptor() protoreflect.EnumDescriptor { - return file_chatto_api_v1_notification_preferences_proto_enumTypes[0].Descriptor() -} - -func (NotificationLevel) Type() protoreflect.EnumType { - return &file_chatto_api_v1_notification_preferences_proto_enumTypes[0] -} - -func (x NotificationLevel) Number() protoreflect.EnumNumber { - return protoreflect.EnumNumber(x) -} - -// Deprecated: Use NotificationLevel.Descriptor instead. -func (NotificationLevel) EnumDescriptor() ([]byte, []int) { - return file_chatto_api_v1_notification_preferences_proto_rawDescGZIP(), []int{0} -} - -// Stored and effective notification preference. -type NotificationPreference struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Explicit level stored for the current user. - Level NotificationLevel `protobuf:"varint,1,opt,name=level,proto3,enum=chatto.api.v1.NotificationLevel" json:"level,omitempty"` - // Level after applying defaults and inheritance. - EffectiveLevel NotificationLevel `protobuf:"varint,2,opt,name=effective_level,json=effectiveLevel,proto3,enum=chatto.api.v1.NotificationLevel" json:"effective_level,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *NotificationPreference) Reset() { - *x = NotificationPreference{} - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *NotificationPreference) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*NotificationPreference) ProtoMessage() {} - -func (x *NotificationPreference) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use NotificationPreference.ProtoReflect.Descriptor instead. -func (*NotificationPreference) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notification_preferences_proto_rawDescGZIP(), []int{0} -} - -func (x *NotificationPreference) GetLevel() NotificationLevel { - if x != nil { - return x.Level - } - return NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED -} - -func (x *NotificationPreference) GetEffectiveLevel() NotificationLevel { - if x != nil { - return x.EffectiveLevel - } - return NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED -} - -// Current notification preference. -type GetNotificationPreferenceResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Current stored and effective notification preference. - Preference *NotificationPreference `protobuf:"bytes,3,opt,name=preference,proto3" json:"preference,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetNotificationPreferenceResponse) Reset() { - *x = GetNotificationPreferenceResponse{} - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetNotificationPreferenceResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetNotificationPreferenceResponse) ProtoMessage() {} - -func (x *GetNotificationPreferenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetNotificationPreferenceResponse.ProtoReflect.Descriptor instead. -func (*GetNotificationPreferenceResponse) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notification_preferences_proto_rawDescGZIP(), []int{1} -} - -func (x *GetNotificationPreferenceResponse) GetPreference() *NotificationPreference { - if x != nil { - return x.Preference - } - return nil -} - -// Updated notification preference. -type UpdateNotificationPreferenceResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Stored and effective notification preference after the update. - Preference *NotificationPreference `protobuf:"bytes,3,opt,name=preference,proto3" json:"preference,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *UpdateNotificationPreferenceResponse) Reset() { - *x = UpdateNotificationPreferenceResponse{} - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *UpdateNotificationPreferenceResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UpdateNotificationPreferenceResponse) ProtoMessage() {} - -func (x *UpdateNotificationPreferenceResponse) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UpdateNotificationPreferenceResponse.ProtoReflect.Descriptor instead. -func (*UpdateNotificationPreferenceResponse) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notification_preferences_proto_rawDescGZIP(), []int{2} -} - -func (x *UpdateNotificationPreferenceResponse) GetPreference() *NotificationPreference { - if x != nil { - return x.Preference - } - return nil -} - -// Request for the current user's server-level notification preference. -type GetServerNotificationPreferenceRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetServerNotificationPreferenceRequest) Reset() { - *x = GetServerNotificationPreferenceRequest{} - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetServerNotificationPreferenceRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetServerNotificationPreferenceRequest) ProtoMessage() {} - -func (x *GetServerNotificationPreferenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[3] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetServerNotificationPreferenceRequest.ProtoReflect.Descriptor instead. -func (*GetServerNotificationPreferenceRequest) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notification_preferences_proto_rawDescGZIP(), []int{3} -} - -// Request to update the current user's server-level notification level. -type UpdateServerNotificationPreferenceRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Required. New explicit notification level. Use NOTIFICATION_LEVEL_DEFAULT - // to return the server to default behavior. - Level NotificationLevel `protobuf:"varint,1,opt,name=level,proto3,enum=chatto.api.v1.NotificationLevel" json:"level,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *UpdateServerNotificationPreferenceRequest) Reset() { - *x = UpdateServerNotificationPreferenceRequest{} - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[4] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *UpdateServerNotificationPreferenceRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UpdateServerNotificationPreferenceRequest) ProtoMessage() {} - -func (x *UpdateServerNotificationPreferenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[4] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UpdateServerNotificationPreferenceRequest.ProtoReflect.Descriptor instead. -func (*UpdateServerNotificationPreferenceRequest) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notification_preferences_proto_rawDescGZIP(), []int{4} -} - -func (x *UpdateServerNotificationPreferenceRequest) GetLevel() NotificationLevel { - if x != nil { - return x.Level - } - return NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED -} - -// Request for the current user's notification preference in one room. -type GetRoomNotificationPreferenceRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Required. Room whose notification preference should be loaded for the current user. - RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetRoomNotificationPreferenceRequest) Reset() { - *x = GetRoomNotificationPreferenceRequest{} - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetRoomNotificationPreferenceRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetRoomNotificationPreferenceRequest) ProtoMessage() {} - -func (x *GetRoomNotificationPreferenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetRoomNotificationPreferenceRequest.ProtoReflect.Descriptor instead. -func (*GetRoomNotificationPreferenceRequest) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notification_preferences_proto_rawDescGZIP(), []int{5} -} - -func (x *GetRoomNotificationPreferenceRequest) GetRoomId() string { - if x != nil { - return x.RoomId - } - return "" -} - -// Request to update the current user's notification level in one room. -type UpdateRoomNotificationPreferenceRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Required. Room whose notification level should be changed for the current user. - RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - // Required. New explicit notification level. Use NOTIFICATION_LEVEL_DEFAULT - // to return the room to inherited/default behavior. - Level NotificationLevel `protobuf:"varint,2,opt,name=level,proto3,enum=chatto.api.v1.NotificationLevel" json:"level,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *UpdateRoomNotificationPreferenceRequest) Reset() { - *x = UpdateRoomNotificationPreferenceRequest{} - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[6] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *UpdateRoomNotificationPreferenceRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UpdateRoomNotificationPreferenceRequest) ProtoMessage() {} - -func (x *UpdateRoomNotificationPreferenceRequest) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notification_preferences_proto_msgTypes[6] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UpdateRoomNotificationPreferenceRequest.ProtoReflect.Descriptor instead. -func (*UpdateRoomNotificationPreferenceRequest) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notification_preferences_proto_rawDescGZIP(), []int{6} -} - -func (x *UpdateRoomNotificationPreferenceRequest) GetRoomId() string { - if x != nil { - return x.RoomId - } - return "" -} - -func (x *UpdateRoomNotificationPreferenceRequest) GetLevel() NotificationLevel { - if x != nil { - return x.Level - } - return NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED -} - -var File_chatto_api_v1_notification_preferences_proto protoreflect.FileDescriptor - -const file_chatto_api_v1_notification_preferences_proto_rawDesc = "" + - "\n" + - ",chatto/api/v1/notification_preferences.proto\x12\rchatto.api.v1\x1a\x1bbuf/validate/validate.proto\"\x9b\x01\n" + - "\x16NotificationPreference\x126\n" + - "\x05level\x18\x01 \x01(\x0e2 .chatto.api.v1.NotificationLevelR\x05level\x12I\n" + - "\x0feffective_level\x18\x02 \x01(\x0e2 .chatto.api.v1.NotificationLevelR\x0eeffectiveLevel\"\x8e\x01\n" + - "!GetNotificationPreferenceResponse\x12E\n" + - "\n" + - "preference\x18\x03 \x01(\v2%.chatto.api.v1.NotificationPreferenceR\n" + - "preferenceJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x05levelR\x0feffective_level\"\x91\x01\n" + - "$UpdateNotificationPreferenceResponse\x12E\n" + - "\n" + - "preference\x18\x03 \x01(\v2%.chatto.api.v1.NotificationPreferenceR\n" + - "preferenceJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x05levelR\x0feffective_level\"(\n" + - "&GetServerNotificationPreferenceRequest\"o\n" + - ")UpdateServerNotificationPreferenceRequest\x12B\n" + - "\x05level\x18\x01 \x01(\x0e2 .chatto.api.v1.NotificationLevelB\n" + - "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x05level\"H\n" + - "$GetRoomNotificationPreferenceRequest\x12 \n" + - "\aroom_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06roomId\"\x8f\x01\n" + - "'UpdateRoomNotificationPreferenceRequest\x12 \n" + - "\aroom_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06roomId\x12B\n" + - "\x05level\x18\x02 \x01(\x0e2 .chatto.api.v1.NotificationLevelB\n" + - "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x05level*\xb9\x01\n" + - "\x11NotificationLevel\x12\"\n" + - "\x1eNOTIFICATION_LEVEL_UNSPECIFIED\x10\x00\x12\x1e\n" + - "\x1aNOTIFICATION_LEVEL_DEFAULT\x10\x01\x12\x1c\n" + - "\x18NOTIFICATION_LEVEL_MUTED\x10\x02\x12\x1d\n" + - "\x19NOTIFICATION_LEVEL_NORMAL\x10\x03\x12#\n" + - "\x1fNOTIFICATION_LEVEL_ALL_MESSAGES\x10\x042\xde\x04\n" + - "\x1eNotificationPreferencesService\x12\x8a\x01\n" + - "\x1fGetServerNotificationPreference\x125.chatto.api.v1.GetServerNotificationPreferenceRequest\x1a0.chatto.api.v1.GetNotificationPreferenceResponse\x12\x93\x01\n" + - "\"UpdateServerNotificationPreference\x128.chatto.api.v1.UpdateServerNotificationPreferenceRequest\x1a3.chatto.api.v1.UpdateNotificationPreferenceResponse\x12\x86\x01\n" + - "\x1dGetRoomNotificationPreference\x123.chatto.api.v1.GetRoomNotificationPreferenceRequest\x1a0.chatto.api.v1.GetNotificationPreferenceResponse\x12\x8f\x01\n" + - " UpdateRoomNotificationPreference\x126.chatto.api.v1.UpdateRoomNotificationPreferenceRequest\x1a3.chatto.api.v1.UpdateNotificationPreferenceResponseB\xb8\x01\n" + - "\x11com.chatto.api.v1B\x1cNotificationPreferencesProtoP\x01Z/hmans.de/chatto/internal/pb/chatto/api/v1;apiv1\xa2\x02\x03CAX\xaa\x02\rChatto.Api.V1\xca\x02\rChatto\\Api\\V1\xe2\x02\x19Chatto\\Api\\V1\\GPBMetadata\xea\x02\x0fChatto::Api::V1b\x06proto3" - -var ( - file_chatto_api_v1_notification_preferences_proto_rawDescOnce sync.Once - file_chatto_api_v1_notification_preferences_proto_rawDescData []byte -) - -func file_chatto_api_v1_notification_preferences_proto_rawDescGZIP() []byte { - file_chatto_api_v1_notification_preferences_proto_rawDescOnce.Do(func() { - file_chatto_api_v1_notification_preferences_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_chatto_api_v1_notification_preferences_proto_rawDesc), len(file_chatto_api_v1_notification_preferences_proto_rawDesc))) - }) - return file_chatto_api_v1_notification_preferences_proto_rawDescData -} - -var file_chatto_api_v1_notification_preferences_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_chatto_api_v1_notification_preferences_proto_msgTypes = make([]protoimpl.MessageInfo, 7) -var file_chatto_api_v1_notification_preferences_proto_goTypes = []any{ - (NotificationLevel)(0), // 0: chatto.api.v1.NotificationLevel - (*NotificationPreference)(nil), // 1: chatto.api.v1.NotificationPreference - (*GetNotificationPreferenceResponse)(nil), // 2: chatto.api.v1.GetNotificationPreferenceResponse - (*UpdateNotificationPreferenceResponse)(nil), // 3: chatto.api.v1.UpdateNotificationPreferenceResponse - (*GetServerNotificationPreferenceRequest)(nil), // 4: chatto.api.v1.GetServerNotificationPreferenceRequest - (*UpdateServerNotificationPreferenceRequest)(nil), // 5: chatto.api.v1.UpdateServerNotificationPreferenceRequest - (*GetRoomNotificationPreferenceRequest)(nil), // 6: chatto.api.v1.GetRoomNotificationPreferenceRequest - (*UpdateRoomNotificationPreferenceRequest)(nil), // 7: chatto.api.v1.UpdateRoomNotificationPreferenceRequest -} -var file_chatto_api_v1_notification_preferences_proto_depIdxs = []int32{ - 0, // 0: chatto.api.v1.NotificationPreference.level:type_name -> chatto.api.v1.NotificationLevel - 0, // 1: chatto.api.v1.NotificationPreference.effective_level:type_name -> chatto.api.v1.NotificationLevel - 1, // 2: chatto.api.v1.GetNotificationPreferenceResponse.preference:type_name -> chatto.api.v1.NotificationPreference - 1, // 3: chatto.api.v1.UpdateNotificationPreferenceResponse.preference:type_name -> chatto.api.v1.NotificationPreference - 0, // 4: chatto.api.v1.UpdateServerNotificationPreferenceRequest.level:type_name -> chatto.api.v1.NotificationLevel - 0, // 5: chatto.api.v1.UpdateRoomNotificationPreferenceRequest.level:type_name -> chatto.api.v1.NotificationLevel - 4, // 6: chatto.api.v1.NotificationPreferencesService.GetServerNotificationPreference:input_type -> chatto.api.v1.GetServerNotificationPreferenceRequest - 5, // 7: chatto.api.v1.NotificationPreferencesService.UpdateServerNotificationPreference:input_type -> chatto.api.v1.UpdateServerNotificationPreferenceRequest - 6, // 8: chatto.api.v1.NotificationPreferencesService.GetRoomNotificationPreference:input_type -> chatto.api.v1.GetRoomNotificationPreferenceRequest - 7, // 9: chatto.api.v1.NotificationPreferencesService.UpdateRoomNotificationPreference:input_type -> chatto.api.v1.UpdateRoomNotificationPreferenceRequest - 2, // 10: chatto.api.v1.NotificationPreferencesService.GetServerNotificationPreference:output_type -> chatto.api.v1.GetNotificationPreferenceResponse - 3, // 11: chatto.api.v1.NotificationPreferencesService.UpdateServerNotificationPreference:output_type -> chatto.api.v1.UpdateNotificationPreferenceResponse - 2, // 12: chatto.api.v1.NotificationPreferencesService.GetRoomNotificationPreference:output_type -> chatto.api.v1.GetNotificationPreferenceResponse - 3, // 13: chatto.api.v1.NotificationPreferencesService.UpdateRoomNotificationPreference:output_type -> chatto.api.v1.UpdateNotificationPreferenceResponse - 10, // [10:14] is the sub-list for method output_type - 6, // [6:10] is the sub-list for method input_type - 6, // [6:6] is the sub-list for extension type_name - 6, // [6:6] is the sub-list for extension extendee - 0, // [0:6] is the sub-list for field type_name -} - -func init() { file_chatto_api_v1_notification_preferences_proto_init() } -func file_chatto_api_v1_notification_preferences_proto_init() { - if File_chatto_api_v1_notification_preferences_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_chatto_api_v1_notification_preferences_proto_rawDesc), len(file_chatto_api_v1_notification_preferences_proto_rawDesc)), - NumEnums: 1, - NumMessages: 7, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_chatto_api_v1_notification_preferences_proto_goTypes, - DependencyIndexes: file_chatto_api_v1_notification_preferences_proto_depIdxs, - EnumInfos: file_chatto_api_v1_notification_preferences_proto_enumTypes, - MessageInfos: file_chatto_api_v1_notification_preferences_proto_msgTypes, - }.Build() - File_chatto_api_v1_notification_preferences_proto = out.File - file_chatto_api_v1_notification_preferences_proto_goTypes = nil - file_chatto_api_v1_notification_preferences_proto_depIdxs = nil -} diff --git a/cli/internal/pb/chatto/api/v1/notifications.pb.go b/cli/internal/pb/chatto/api/v1/notifications.pb.go index ba79fea5c..39fbb707f 100644 --- a/cli/internal/pb/chatto/api/v1/notifications.pb.go +++ b/cli/internal/pb/chatto/api/v1/notifications.pb.go @@ -24,31 +24,173 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// Direct-message notification payload. -type DirectMessageNotification struct { +// Why source activity matched the authenticated viewer's notification policy. +type NotificationReason int32 + +const ( + // No cause was specified. This value is not valid in preference writes. + NotificationReason_NOTIFICATION_REASON_UNSPECIFIED NotificationReason = 0 + // A message was posted in a direct-message conversation. + NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE NotificationReason = 1 + // The viewer's username was mentioned directly. + NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION NotificationReason = 2 + // Activity replied directly to the viewer's message. + NotificationReason_NOTIFICATION_REASON_REPLY NotificationReason = 3 + // A role held by the viewer was mentioned. + NotificationReason_NOTIFICATION_REASON_ROLE_MENTION NotificationReason = 4 + // An `@here` mention included the viewer. + NotificationReason_NOTIFICATION_REASON_HERE NotificationReason = 5 + // An `@all` mention included the viewer. + NotificationReason_NOTIFICATION_REASON_ALL NotificationReason = 6 + // New activity appeared in a thread followed by the viewer. + NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD NotificationReason = 7 + // New activity appeared in a room followed by the viewer. + NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM NotificationReason = 8 + // Someone reacted to the viewer's message. + NotificationReason_NOTIFICATION_REASON_REACTION NotificationReason = 9 + // The viewer was invited to a room. + NotificationReason_NOTIFICATION_REASON_ROOM_INVITATION NotificationReason = 10 +) + +// Enum value maps for NotificationReason. +var ( + NotificationReason_name = map[int32]string{ + 0: "NOTIFICATION_REASON_UNSPECIFIED", + 1: "NOTIFICATION_REASON_DIRECT_MESSAGE", + 2: "NOTIFICATION_REASON_DIRECT_MENTION", + 3: "NOTIFICATION_REASON_REPLY", + 4: "NOTIFICATION_REASON_ROLE_MENTION", + 5: "NOTIFICATION_REASON_HERE", + 6: "NOTIFICATION_REASON_ALL", + 7: "NOTIFICATION_REASON_FOLLOWED_THREAD", + 8: "NOTIFICATION_REASON_FOLLOWED_ROOM", + 9: "NOTIFICATION_REASON_REACTION", + 10: "NOTIFICATION_REASON_ROOM_INVITATION", + } + NotificationReason_value = map[string]int32{ + "NOTIFICATION_REASON_UNSPECIFIED": 0, + "NOTIFICATION_REASON_DIRECT_MESSAGE": 1, + "NOTIFICATION_REASON_DIRECT_MENTION": 2, + "NOTIFICATION_REASON_REPLY": 3, + "NOTIFICATION_REASON_ROLE_MENTION": 4, + "NOTIFICATION_REASON_HERE": 5, + "NOTIFICATION_REASON_ALL": 6, + "NOTIFICATION_REASON_FOLLOWED_THREAD": 7, + "NOTIFICATION_REASON_FOLLOWED_ROOM": 8, + "NOTIFICATION_REASON_REACTION": 9, + "NOTIFICATION_REASON_ROOM_INVITATION": 10, + } +) + +func (x NotificationReason) Enum() *NotificationReason { + p := new(NotificationReason) + *p = x + return p +} + +func (x NotificationReason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NotificationReason) Descriptor() protoreflect.EnumDescriptor { + return file_chatto_api_v1_notifications_proto_enumTypes[0].Descriptor() +} + +func (NotificationReason) Type() protoreflect.EnumType { + return &file_chatto_api_v1_notifications_proto_enumTypes[0] +} + +func (x NotificationReason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NotificationReason.Descriptor instead. +func (NotificationReason) EnumDescriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{0} +} + +// Delivery strength for one notification cause. +type NotificationDeliveryIntensity int32 + +const ( + // In preference writes, unspecified clears the override (Inherit). + NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED NotificationDeliveryIntensity = 0 + // Matching activity does not create a notification occurrence. + NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF NotificationDeliveryIntensity = 1 + // Matching activity appears in the notification list without interruptive delivery. + NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE NotificationDeliveryIntensity = 2 + // Matching activity appears in the notification list and may trigger sound or push. + NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT NotificationDeliveryIntensity = 3 +) + +// Enum value maps for NotificationDeliveryIntensity. +var ( + NotificationDeliveryIntensity_name = map[int32]string{ + 0: "NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED", + 1: "NOTIFICATION_DELIVERY_INTENSITY_OFF", + 2: "NOTIFICATION_DELIVERY_INTENSITY_BADGE", + 3: "NOTIFICATION_DELIVERY_INTENSITY_ALERT", + } + NotificationDeliveryIntensity_value = map[string]int32{ + "NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED": 0, + "NOTIFICATION_DELIVERY_INTENSITY_OFF": 1, + "NOTIFICATION_DELIVERY_INTENSITY_BADGE": 2, + "NOTIFICATION_DELIVERY_INTENSITY_ALERT": 3, + } +) + +func (x NotificationDeliveryIntensity) Enum() *NotificationDeliveryIntensity { + p := new(NotificationDeliveryIntensity) + *p = x + return p +} + +func (x NotificationDeliveryIntensity) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NotificationDeliveryIntensity) Descriptor() protoreflect.EnumDescriptor { + return file_chatto_api_v1_notifications_proto_enumTypes[1].Descriptor() +} + +func (NotificationDeliveryIntensity) Type() protoreflect.EnumType { + return &file_chatto_api_v1_notifications_proto_enumTypes[1] +} + +func (x NotificationDeliveryIntensity) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NotificationDeliveryIntensity.Descriptor instead. +func (NotificationDeliveryIntensity) EnumDescriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{1} +} + +// One cause that matched an occurrence and its evaluated delivery intensity. +type NotificationReasonMatch struct { state protoimpl.MessageState `protogen:"open.v1"` - // Message event ID. - EventId string `protobuf:"bytes,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` - // DM room where the message was posted. - Room *RoomSummary `protobuf:"bytes,3,opt,name=room,proto3" json:"room,omitempty"` + // Cause that matched the viewer. + Reason NotificationReason `protobuf:"varint,1,opt,name=reason,proto3,enum=chatto.api.v1.NotificationReason" json:"reason,omitempty"` + // Effective intensity when the source activity occurred. + Intensity NotificationDeliveryIntensity `protobuf:"varint,2,opt,name=intensity,proto3,enum=chatto.api.v1.NotificationDeliveryIntensity" json:"intensity,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *DirectMessageNotification) Reset() { - *x = DirectMessageNotification{} +func (x *NotificationReasonMatch) Reset() { + *x = NotificationReasonMatch{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *DirectMessageNotification) String() string { +func (x *NotificationReasonMatch) String() string { return protoimpl.X.MessageStringOf(x) } -func (*DirectMessageNotification) ProtoMessage() {} +func (*NotificationReasonMatch) ProtoMessage() {} -func (x *DirectMessageNotification) ProtoReflect() protoreflect.Message { +func (x *NotificationReasonMatch) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[0] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -60,52 +202,54 @@ func (x *DirectMessageNotification) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use DirectMessageNotification.ProtoReflect.Descriptor instead. -func (*DirectMessageNotification) Descriptor() ([]byte, []int) { +// Deprecated: Use NotificationReasonMatch.ProtoReflect.Descriptor instead. +func (*NotificationReasonMatch) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{0} } -func (x *DirectMessageNotification) GetEventId() string { +func (x *NotificationReasonMatch) GetReason() NotificationReason { if x != nil { - return x.EventId + return x.Reason } - return "" + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED } -func (x *DirectMessageNotification) GetRoom() *RoomSummary { +func (x *NotificationReasonMatch) GetIntensity() NotificationDeliveryIntensity { if x != nil { - return x.Room + return x.Intensity } - return nil + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED } -// Mention notification payload. -type MentionNotification struct { +// Exact visible destination of one notification occurrence. +type NotificationTarget struct { state protoimpl.MessageState `protogen:"open.v1"` - // Room where the mention occurred. + // Room containing the source activity. Room *RoomSummary `protobuf:"bytes,1,opt,name=room,proto3" json:"room,omitempty"` - // Message event ID. + // Exact source or reacted-to message event to reveal. EventId string `protobuf:"bytes,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` - // Thread root event ID when the mention happened inside a thread. + // Thread root when the target is inside a thread. ThreadRootEventId *string `protobuf:"bytes,3,opt,name=thread_root_event_id,json=threadRootEventId,proto3,oneof" json:"thread_root_event_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Direct reply target when the occurrence was caused by a reply. + ParentEventId *string `protobuf:"bytes,4,opt,name=parent_event_id,json=parentEventId,proto3,oneof" json:"parent_event_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *MentionNotification) Reset() { - *x = MentionNotification{} +func (x *NotificationTarget) Reset() { + *x = NotificationTarget{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *MentionNotification) String() string { +func (x *NotificationTarget) String() string { return protoimpl.X.MessageStringOf(x) } -func (*MentionNotification) ProtoMessage() {} +func (*NotificationTarget) ProtoMessage() {} -func (x *MentionNotification) ProtoReflect() protoreflect.Message { +func (x *NotificationTarget) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -117,61 +261,78 @@ func (x *MentionNotification) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use MentionNotification.ProtoReflect.Descriptor instead. -func (*MentionNotification) Descriptor() ([]byte, []int) { +// Deprecated: Use NotificationTarget.ProtoReflect.Descriptor instead. +func (*NotificationTarget) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{1} } -func (x *MentionNotification) GetRoom() *RoomSummary { +func (x *NotificationTarget) GetRoom() *RoomSummary { if x != nil { return x.Room } return nil } -func (x *MentionNotification) GetEventId() string { +func (x *NotificationTarget) GetEventId() string { if x != nil { return x.EventId } return "" } -func (x *MentionNotification) GetThreadRootEventId() string { +func (x *NotificationTarget) GetThreadRootEventId() string { if x != nil && x.ThreadRootEventId != nil { return *x.ThreadRootEventId } return "" } -// Reply notification payload. -type ReplyNotification struct { +func (x *NotificationTarget) GetParentEventId() string { + if x != nil && x.ParentEventId != nil { + return *x.ParentEventId + } + return "" +} + +// One exact Notifications 2.0 source occurrence. +type NotificationOccurrence struct { state protoimpl.MessageState `protogen:"open.v1"` - // Room where the reply occurred. - Room *RoomSummary `protobuf:"bytes,1,opt,name=room,proto3" json:"room,omitempty"` - // Reply event ID. - EventId string `protobuf:"bytes,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` - // Event ID of the message being replied to. - InReplyToId string `protobuf:"bytes,3,opt,name=in_reply_to_id,json=inReplyToId,proto3" json:"in_reply_to_id,omitempty"` - // Thread root event ID when the reply happened inside a thread. - ThreadRootEventId *string `protobuf:"bytes,4,opt,name=thread_root_event_id,json=threadRootEventId,proto3,oneof" json:"thread_root_event_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Stable occurrence ID. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Durable source event from which this occurrence was derived. + SourceEventId string `protobuf:"bytes,2,opt,name=source_event_id,json=sourceEventId,proto3" json:"source_event_id,omitempty"` + // Time of the source activity. + CreatedAt *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + // User who caused the source activity, when still visible. + Actor *User `protobuf:"bytes,4,opt,name=actor,proto3" json:"actor,omitempty"` + // Exact current destination for navigation. + Target *NotificationTarget `protobuf:"bytes,5,opt,name=target,proto3" json:"target,omitempty"` + // Every cause that matched when the source activity occurred. + Reasons []*NotificationReasonMatch `protobuf:"bytes,6,rep,name=reasons,proto3" json:"reasons,omitempty"` + // Strongest evaluated intensity across all matching causes. + StrongestIntensity NotificationDeliveryIntensity `protobuf:"varint,7,opt,name=strongest_intensity,json=strongestIntensity,proto3,enum=chatto.api.v1.NotificationDeliveryIntensity" json:"strongest_intensity,omitempty"` + // Whether this occurrence still contributes unread attention. + Unread bool `protobuf:"varint,8,opt,name=unread,proto3" json:"unread,omitempty"` + // Absolute expiry, 90 days after the source activity. + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,10,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *ReplyNotification) Reset() { - *x = ReplyNotification{} +func (x *NotificationOccurrence) Reset() { + *x = NotificationOccurrence{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ReplyNotification) String() string { +func (x *NotificationOccurrence) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ReplyNotification) ProtoMessage() {} +func (*NotificationOccurrence) ProtoMessage() {} -func (x *ReplyNotification) ProtoReflect() protoreflect.Message { +func (x *NotificationOccurrence) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -183,129 +344,119 @@ func (x *ReplyNotification) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ReplyNotification.ProtoReflect.Descriptor instead. -func (*ReplyNotification) Descriptor() ([]byte, []int) { +// Deprecated: Use NotificationOccurrence.ProtoReflect.Descriptor instead. +func (*NotificationOccurrence) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{2} } -func (x *ReplyNotification) GetRoom() *RoomSummary { +func (x *NotificationOccurrence) GetId() string { if x != nil { - return x.Room + return x.Id } - return nil + return "" } -func (x *ReplyNotification) GetEventId() string { +func (x *NotificationOccurrence) GetSourceEventId() string { if x != nil { - return x.EventId + return x.SourceEventId } return "" } -func (x *ReplyNotification) GetInReplyToId() string { +func (x *NotificationOccurrence) GetCreatedAt() *timestamppb.Timestamp { if x != nil { - return x.InReplyToId + return x.CreatedAt } - return "" + return nil } -func (x *ReplyNotification) GetThreadRootEventId() string { - if x != nil && x.ThreadRootEventId != nil { - return *x.ThreadRootEventId +func (x *NotificationOccurrence) GetActor() *User { + if x != nil { + return x.Actor } - return "" -} - -// All-messages room notification payload. -type RoomMessageNotification struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Room where the message was posted. - Room *RoomSummary `protobuf:"bytes,1,opt,name=room,proto3" json:"room,omitempty"` - // Message event ID. - EventId string `protobuf:"bytes,2,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RoomMessageNotification) Reset() { - *x = RoomMessageNotification{} - mi := &file_chatto_api_v1_notifications_proto_msgTypes[3] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) + return nil } -func (x *RoomMessageNotification) String() string { - return protoimpl.X.MessageStringOf(x) +func (x *NotificationOccurrence) GetTarget() *NotificationTarget { + if x != nil { + return x.Target + } + return nil } -func (*RoomMessageNotification) ProtoMessage() {} - -func (x *RoomMessageNotification) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notifications_proto_msgTypes[3] +func (x *NotificationOccurrence) GetReasons() []*NotificationReasonMatch { if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms + return x.Reasons } - return mi.MessageOf(x) + return nil } -// Deprecated: Use RoomMessageNotification.ProtoReflect.Descriptor instead. -func (*RoomMessageNotification) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{3} +func (x *NotificationOccurrence) GetStrongestIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.StrongestIntensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED } -func (x *RoomMessageNotification) GetRoom() *RoomSummary { +func (x *NotificationOccurrence) GetUnread() bool { if x != nil { - return x.Room + return x.Unread } - return nil + return false } -func (x *RoomMessageNotification) GetEventId() string { +func (x *NotificationOccurrence) GetExpiresAt() *timestamppb.Timestamp { if x != nil { - return x.EventId + return x.ExpiresAt } - return "" + return nil } -// One pending notification for the authenticated viewer. -type NotificationItem struct { +// A presentation group derived from related notification occurrences. +type NotificationGroup struct { state protoimpl.MessageState `protogen:"open.v1"` - // Stable notification ID. + // Stable ID derived from the viewer and grouping target. Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - // Creation time. - CreatedAt *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` - // User who triggered the notification, when still resolvable. - Actor *User `protobuf:"bytes,3,opt,name=actor,proto3" json:"actor,omitempty"` - // Types that are valid to be assigned to Kind: - // - // *NotificationItem_DirectMessage - // *NotificationItem_Mention - // *NotificationItem_Reply - // *NotificationItem_RoomMessage - Kind isNotificationItem_Kind `protobuf_oneof:"kind"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *NotificationItem) Reset() { - *x = NotificationItem{} - mi := &file_chatto_api_v1_notifications_proto_msgTypes[4] + // Bounded newest-occurrence preview. It also includes the open target when + // that target falls outside the newest preview window. + Occurrences []*NotificationOccurrence `protobuf:"bytes,2,rep,name=occurrences,proto3" json:"occurrences,omitempty"` + // Target to open: newest unread, or newest when all are read. + OpenTarget *NotificationTarget `protobuf:"bytes,3,opt,name=open_target,json=openTarget,proto3" json:"open_target,omitempty"` + // True when at least one member occurrence is unread. + Unread bool `protobuf:"varint,4,opt,name=unread,proto3" json:"unread,omitempty"` + // Total number of occurrences in this group, including those not in the + // bounded preview. + OccurrenceCount int32 `protobuf:"varint,5,opt,name=occurrence_count,json=occurrenceCount,proto3" json:"occurrence_count,omitempty"` + // Time of the newest occurrence. + LatestAt *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=latest_at,json=latestAt,proto3" json:"latest_at,omitempty"` + // Strongest intensity among member occurrences. + StrongestIntensity NotificationDeliveryIntensity `protobuf:"varint,7,opt,name=strongest_intensity,json=strongestIntensity,proto3,enum=chatto.api.v1.NotificationDeliveryIntensity" json:"strongest_intensity,omitempty"` + // Distinct causes represented by member occurrences. + Reasons []NotificationReason `protobuf:"varint,8,rep,packed,name=reasons,proto3,enum=chatto.api.v1.NotificationReason" json:"reasons,omitempty"` + // Earliest member expiry. Clients refresh the group at this boundary. + NextExpiryAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=next_expiry_at,json=nextExpiryAt,proto3" json:"next_expiry_at,omitempty"` + // Occurrence ID corresponding to open_target, including when several + // occurrences share the same message target. + OpenNotificationId string `protobuf:"bytes,12,opt,name=open_notification_id,json=openNotificationId,proto3" json:"open_notification_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationGroup) Reset() { + *x = NotificationGroup{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *NotificationItem) String() string { +func (x *NotificationGroup) String() string { return protoimpl.X.MessageStringOf(x) } -func (*NotificationItem) ProtoMessage() {} +func (*NotificationGroup) ProtoMessage() {} -func (x *NotificationItem) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notifications_proto_msgTypes[4] +func (x *NotificationGroup) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -316,130 +467,160 @@ func (x *NotificationItem) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use NotificationItem.ProtoReflect.Descriptor instead. -func (*NotificationItem) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{4} +// Deprecated: Use NotificationGroup.ProtoReflect.Descriptor instead. +func (*NotificationGroup) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{3} } -func (x *NotificationItem) GetId() string { +func (x *NotificationGroup) GetId() string { if x != nil { return x.Id } return "" } -func (x *NotificationItem) GetCreatedAt() *timestamppb.Timestamp { +func (x *NotificationGroup) GetOccurrences() []*NotificationOccurrence { if x != nil { - return x.CreatedAt + return x.Occurrences } return nil } -func (x *NotificationItem) GetActor() *User { +func (x *NotificationGroup) GetOpenTarget() *NotificationTarget { if x != nil { - return x.Actor + return x.OpenTarget } return nil } -func (x *NotificationItem) GetKind() isNotificationItem_Kind { +func (x *NotificationGroup) GetUnread() bool { if x != nil { - return x.Kind + return x.Unread } - return nil + return false } -func (x *NotificationItem) GetDirectMessage() *DirectMessageNotification { +func (x *NotificationGroup) GetOccurrenceCount() int32 { if x != nil { - if x, ok := x.Kind.(*NotificationItem_DirectMessage); ok { - return x.DirectMessage - } + return x.OccurrenceCount } - return nil + return 0 } -func (x *NotificationItem) GetMention() *MentionNotification { +func (x *NotificationGroup) GetLatestAt() *timestamppb.Timestamp { if x != nil { - if x, ok := x.Kind.(*NotificationItem_Mention); ok { - return x.Mention - } + return x.LatestAt } return nil } -func (x *NotificationItem) GetReply() *ReplyNotification { +func (x *NotificationGroup) GetStrongestIntensity() NotificationDeliveryIntensity { if x != nil { - if x, ok := x.Kind.(*NotificationItem_Reply); ok { - return x.Reply - } + return x.StrongestIntensity } - return nil + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED } -func (x *NotificationItem) GetRoomMessage() *RoomMessageNotification { +func (x *NotificationGroup) GetReasons() []NotificationReason { if x != nil { - if x, ok := x.Kind.(*NotificationItem_RoomMessage); ok { - return x.RoomMessage - } + return x.Reasons } return nil } -type isNotificationItem_Kind interface { - isNotificationItem_Kind() +func (x *NotificationGroup) GetNextExpiryAt() *timestamppb.Timestamp { + if x != nil { + return x.NextExpiryAt + } + return nil } -type NotificationItem_DirectMessage struct { - // Direct-message notification. - DirectMessage *DirectMessageNotification `protobuf:"bytes,10,opt,name=direct_message,json=directMessage,proto3,oneof"` +func (x *NotificationGroup) GetOpenNotificationId() string { + if x != nil { + return x.OpenNotificationId + } + return "" } -type NotificationItem_Mention struct { - // Mention notification. - Mention *MentionNotification `protobuf:"bytes,11,opt,name=mention,proto3,oneof"` +// Request for one page of grouped notification occurrences. +type ListNotificationGroupsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Page request. Defaults to 50 results when absent or limit is zero. + Page *PageRequest `protobuf:"bytes,1,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -type NotificationItem_Reply struct { - // Reply notification. - Reply *ReplyNotification `protobuf:"bytes,12,opt,name=reply,proto3,oneof"` +func (x *ListNotificationGroupsRequest) Reset() { + *x = ListNotificationGroupsRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) } -type NotificationItem_RoomMessage struct { - // All-messages room notification. - RoomMessage *RoomMessageNotification `protobuf:"bytes,13,opt,name=room_message,json=roomMessage,proto3,oneof"` +func (x *ListNotificationGroupsRequest) String() string { + return protoimpl.X.MessageStringOf(x) } -func (*NotificationItem_DirectMessage) isNotificationItem_Kind() {} - -func (*NotificationItem_Mention) isNotificationItem_Kind() {} +func (*ListNotificationGroupsRequest) ProtoMessage() {} -func (*NotificationItem_Reply) isNotificationItem_Kind() {} +func (x *ListNotificationGroupsRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} -func (*NotificationItem_RoomMessage) isNotificationItem_Kind() {} +// Deprecated: Use ListNotificationGroupsRequest.ProtoReflect.Descriptor instead. +func (*ListNotificationGroupsRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{4} +} -// Request for the authenticated viewer's pending notifications. -type ListNotificationsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Page request. Defaults to 50 results when absent or limit is zero. - Page *PageRequest `protobuf:"bytes,3,opt,name=page,proto3" json:"page,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +func (x *ListNotificationGroupsRequest) GetPage() *PageRequest { + if x != nil { + return x.Page + } + return nil } -func (x *ListNotificationsRequest) Reset() { - *x = ListNotificationsRequest{} +// One page of derived notification groups. +type ListNotificationGroupsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Groups newest activity first. + Groups []*NotificationGroup `protobuf:"bytes,1,rep,name=groups,proto3" json:"groups,omitempty"` + // Page metadata. + Page *PageInfo `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"` + // Total unread group count. + UnreadGroupCount int32 `protobuf:"varint,3,opt,name=unread_group_count,json=unreadGroupCount,proto3" json:"unread_group_count,omitempty"` + // Earliest expiry in the complete list, including groups outside this page. + // Clients refresh authoritative notification state at this boundary. + NextExpiryAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=next_expiry_at,json=nextExpiryAt,proto3" json:"next_expiry_at,omitempty"` + // Complete unread-group counts by target room. This is independent of the + // bounded group page so room indicators remain authoritative. + RoomUnreadGroupCounts []*NotificationRoomUnreadGroupCount `protobuf:"bytes,5,rep,name=room_unread_group_counts,json=roomUnreadGroupCounts,proto3" json:"room_unread_group_counts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListNotificationGroupsResponse) Reset() { + *x = ListNotificationGroupsResponse{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListNotificationsRequest) String() string { +func (x *ListNotificationGroupsResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListNotificationsRequest) ProtoMessage() {} +func (*ListNotificationGroupsResponse) ProtoMessage() {} -func (x *ListNotificationsRequest) ProtoReflect() protoreflect.Message { +func (x *ListNotificationGroupsResponse) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -451,43 +632,71 @@ func (x *ListNotificationsRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListNotificationsRequest.ProtoReflect.Descriptor instead. -func (*ListNotificationsRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use ListNotificationGroupsResponse.ProtoReflect.Descriptor instead. +func (*ListNotificationGroupsResponse) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{5} } -func (x *ListNotificationsRequest) GetPage() *PageRequest { +func (x *ListNotificationGroupsResponse) GetGroups() []*NotificationGroup { + if x != nil { + return x.Groups + } + return nil +} + +func (x *ListNotificationGroupsResponse) GetPage() *PageInfo { if x != nil { return x.Page } return nil } -// Request for pending notifications scoped to one room. -type ListRoomNotificationsRequest struct { +func (x *ListNotificationGroupsResponse) GetUnreadGroupCount() int32 { + if x != nil { + return x.UnreadGroupCount + } + return 0 +} + +func (x *ListNotificationGroupsResponse) GetNextExpiryAt() *timestamppb.Timestamp { + if x != nil { + return x.NextExpiryAt + } + return nil +} + +func (x *ListNotificationGroupsResponse) GetRoomUnreadGroupCounts() []*NotificationRoomUnreadGroupCount { + if x != nil { + return x.RoomUnreadGroupCounts + } + return nil +} + +// Unread notification groups currently targeting one room. +type NotificationRoomUnreadGroupCount struct { state protoimpl.MessageState `protogen:"open.v1"` - // Required. Room whose notifications should be listed. + // Target room ID. RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - // Page request. Defaults to 50 results when absent or limit is zero. - Page *PageRequest `protobuf:"bytes,4,opt,name=page,proto3" json:"page,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Number of unread groups targeting this room. + UnreadGroupCount int32 `protobuf:"varint,2,opt,name=unread_group_count,json=unreadGroupCount,proto3" json:"unread_group_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *ListRoomNotificationsRequest) Reset() { - *x = ListRoomNotificationsRequest{} +func (x *NotificationRoomUnreadGroupCount) Reset() { + *x = NotificationRoomUnreadGroupCount{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListRoomNotificationsRequest) String() string { +func (x *NotificationRoomUnreadGroupCount) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListRoomNotificationsRequest) ProtoMessage() {} +func (*NotificationRoomUnreadGroupCount) ProtoMessage() {} -func (x *ListRoomNotificationsRequest) ProtoReflect() protoreflect.Message { +func (x *NotificationRoomUnreadGroupCount) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -499,50 +708,48 @@ func (x *ListRoomNotificationsRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListRoomNotificationsRequest.ProtoReflect.Descriptor instead. -func (*ListRoomNotificationsRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use NotificationRoomUnreadGroupCount.ProtoReflect.Descriptor instead. +func (*NotificationRoomUnreadGroupCount) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{6} } -func (x *ListRoomNotificationsRequest) GetRoomId() string { +func (x *NotificationRoomUnreadGroupCount) GetRoomId() string { if x != nil { return x.RoomId } return "" } -func (x *ListRoomNotificationsRequest) GetPage() *PageRequest { +func (x *NotificationRoomUnreadGroupCount) GetUnreadGroupCount() int32 { if x != nil { - return x.Page + return x.UnreadGroupCount } - return nil + return 0 } -// Pending notification page. -type ListNotificationsResponse struct { +// Request to mark one notification occurrence read. +type MarkNotificationReadRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Page notifications, newest first. - Notifications []*NotificationItem `protobuf:"bytes,1,rep,name=notifications,proto3" json:"notifications,omitempty"` - // Page metadata. - Page *PageInfo `protobuf:"bytes,5,opt,name=page,proto3" json:"page,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Required stable occurrence ID. + NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *ListNotificationsResponse) Reset() { - *x = ListNotificationsResponse{} +func (x *MarkNotificationReadRequest) Reset() { + *x = MarkNotificationReadRequest{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[7] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListNotificationsResponse) String() string { +func (x *MarkNotificationReadRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListNotificationsResponse) ProtoMessage() {} +func (*MarkNotificationReadRequest) ProtoMessage() {} -func (x *ListNotificationsResponse) ProtoReflect() protoreflect.Message { +func (x *MarkNotificationReadRequest) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[7] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -554,48 +761,41 @@ func (x *ListNotificationsResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListNotificationsResponse.ProtoReflect.Descriptor instead. -func (*ListNotificationsResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use MarkNotificationReadRequest.ProtoReflect.Descriptor instead. +func (*MarkNotificationReadRequest) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{7} } -func (x *ListNotificationsResponse) GetNotifications() []*NotificationItem { +func (x *MarkNotificationReadRequest) GetNotificationId() string { if x != nil { - return x.Notifications - } - return nil -} - -func (x *ListNotificationsResponse) GetPage() *PageInfo { - if x != nil { - return x.Page + return x.NotificationId } - return nil + return "" } -// Request one pending notification for the authenticated viewer. -type GetNotificationRequest struct { +// Notification occurrence after marking it read. +type MarkNotificationReadResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // Required notification ID. - NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Updated occurrence. + Notification *NotificationOccurrence `protobuf:"bytes,1,opt,name=notification,proto3" json:"notification,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *GetNotificationRequest) Reset() { - *x = GetNotificationRequest{} +func (x *MarkNotificationReadResponse) Reset() { + *x = MarkNotificationReadResponse{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *GetNotificationRequest) String() string { +func (x *MarkNotificationReadResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GetNotificationRequest) ProtoMessage() {} +func (*MarkNotificationReadResponse) ProtoMessage() {} -func (x *GetNotificationRequest) ProtoReflect() protoreflect.Message { +func (x *MarkNotificationReadResponse) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[8] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -607,41 +807,41 @@ func (x *GetNotificationRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use GetNotificationRequest.ProtoReflect.Descriptor instead. -func (*GetNotificationRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use MarkNotificationReadResponse.ProtoReflect.Descriptor instead. +func (*MarkNotificationReadResponse) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{8} } -func (x *GetNotificationRequest) GetNotificationId() string { +func (x *MarkNotificationReadResponse) GetNotification() *NotificationOccurrence { if x != nil { - return x.NotificationId + return x.Notification } - return "" + return nil } -// Pending notification response. -type GetNotificationResponse struct { +// Request permanent deletion of one notification occurrence. +type DeleteNotificationOccurrenceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Requested notification. - Notification *NotificationItem `protobuf:"bytes,1,opt,name=notification,proto3" json:"notification,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Required stable occurrence ID. + NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *GetNotificationResponse) Reset() { - *x = GetNotificationResponse{} +func (x *DeleteNotificationOccurrenceRequest) Reset() { + *x = DeleteNotificationOccurrenceRequest{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *GetNotificationResponse) String() string { +func (x *DeleteNotificationOccurrenceRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*GetNotificationResponse) ProtoMessage() {} +func (*DeleteNotificationOccurrenceRequest) ProtoMessage() {} -func (x *GetNotificationResponse) ProtoReflect() protoreflect.Message { +func (x *DeleteNotificationOccurrenceRequest) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[9] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -653,42 +853,42 @@ func (x *GetNotificationResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use GetNotificationResponse.ProtoReflect.Descriptor instead. -func (*GetNotificationResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use DeleteNotificationOccurrenceRequest.ProtoReflect.Descriptor instead. +func (*DeleteNotificationOccurrenceRequest) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{9} } -func (x *GetNotificationResponse) GetNotification() *NotificationItem { +func (x *DeleteNotificationOccurrenceRequest) GetNotificationId() string { if x != nil { - return x.Notification + return x.NotificationId } - return nil + return "" } -// Request pending notifications for a set of stable notification IDs. -type BatchGetNotificationsRequest struct { +// Result of deleting one notification occurrence. +type DeleteNotificationOccurrenceResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // Required notification IDs. Unknown or dismissed IDs are omitted from the - // response. - NotificationIds []string `protobuf:"bytes,1,rep,name=notification_ids,json=notificationIds,proto3" json:"notification_ids,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // True when this call replaced a visible occurrence with a deletion tombstone. + // False when the occurrence was already absent or deleted. + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *BatchGetNotificationsRequest) Reset() { - *x = BatchGetNotificationsRequest{} +func (x *DeleteNotificationOccurrenceResponse) Reset() { + *x = DeleteNotificationOccurrenceResponse{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[10] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *BatchGetNotificationsRequest) String() string { +func (x *DeleteNotificationOccurrenceResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*BatchGetNotificationsRequest) ProtoMessage() {} +func (*DeleteNotificationOccurrenceResponse) ProtoMessage() {} -func (x *BatchGetNotificationsRequest) ProtoReflect() protoreflect.Message { +func (x *DeleteNotificationOccurrenceResponse) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[10] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -700,42 +900,41 @@ func (x *BatchGetNotificationsRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use BatchGetNotificationsRequest.ProtoReflect.Descriptor instead. -func (*BatchGetNotificationsRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use DeleteNotificationOccurrenceResponse.ProtoReflect.Descriptor instead. +func (*DeleteNotificationOccurrenceResponse) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{10} } -func (x *BatchGetNotificationsRequest) GetNotificationIds() []string { +func (x *DeleteNotificationOccurrenceResponse) GetDeleted() bool { if x != nil { - return x.NotificationIds + return x.Deleted } - return nil + return false } -// Batch pending notification response. -type BatchGetNotificationsResponse struct { +// Request permanent deletion of one derived notification group. +type DeleteNotificationGroupRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Found notifications. The server preserves first-seen request order and - // de-duplicates repeated IDs. - Notifications []*NotificationItem `protobuf:"bytes,1,rep,name=notifications,proto3" json:"notifications,omitempty"` + // Required stable group ID. + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *BatchGetNotificationsResponse) Reset() { - *x = BatchGetNotificationsResponse{} +func (x *DeleteNotificationGroupRequest) Reset() { + *x = DeleteNotificationGroupRequest{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *BatchGetNotificationsResponse) String() string { +func (x *DeleteNotificationGroupRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*BatchGetNotificationsResponse) ProtoMessage() {} +func (*DeleteNotificationGroupRequest) ProtoMessage() {} -func (x *BatchGetNotificationsResponse) ProtoReflect() protoreflect.Message { +func (x *DeleteNotificationGroupRequest) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -747,43 +946,41 @@ func (x *BatchGetNotificationsResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use BatchGetNotificationsResponse.ProtoReflect.Descriptor instead. -func (*BatchGetNotificationsResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use DeleteNotificationGroupRequest.ProtoReflect.Descriptor instead. +func (*DeleteNotificationGroupRequest) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{11} } -func (x *BatchGetNotificationsResponse) GetNotifications() []*NotificationItem { +func (x *DeleteNotificationGroupRequest) GetGroupId() string { if x != nil { - return x.Notifications + return x.GroupId } - return nil + return "" } -// Pending notification page scoped to one room. -type ListRoomNotificationsResponse struct { +// Result of deleting one derived notification group. +type DeleteNotificationGroupResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // Page notifications, newest first. - Notifications []*NotificationItem `protobuf:"bytes,1,rep,name=notifications,proto3" json:"notifications,omitempty"` - // Page metadata. - Page *PageInfo `protobuf:"bytes,5,opt,name=page,proto3" json:"page,omitempty"` + // Number of visible occurrences replaced by deletion tombstones. + DeletedCount int32 `protobuf:"varint,1,opt,name=deleted_count,json=deletedCount,proto3" json:"deleted_count,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ListRoomNotificationsResponse) Reset() { - *x = ListRoomNotificationsResponse{} +func (x *DeleteNotificationGroupResponse) Reset() { + *x = DeleteNotificationGroupResponse{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListRoomNotificationsResponse) String() string { +func (x *DeleteNotificationGroupResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListRoomNotificationsResponse) ProtoMessage() {} +func (*DeleteNotificationGroupResponse) ProtoMessage() {} -func (x *ListRoomNotificationsResponse) ProtoReflect() protoreflect.Message { +func (x *DeleteNotificationGroupResponse) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -795,46 +992,39 @@ func (x *ListRoomNotificationsResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use ListRoomNotificationsResponse.ProtoReflect.Descriptor instead. -func (*ListRoomNotificationsResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use DeleteNotificationGroupResponse.ProtoReflect.Descriptor instead. +func (*DeleteNotificationGroupResponse) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{12} } -func (x *ListRoomNotificationsResponse) GetNotifications() []*NotificationItem { +func (x *DeleteNotificationGroupResponse) GetDeletedCount() int32 { if x != nil { - return x.Notifications + return x.DeletedCount } - return nil -} - -func (x *ListRoomNotificationsResponse) GetPage() *PageInfo { - if x != nil { - return x.Page - } - return nil + return 0 } -// Request for a lightweight pending notification check. -type HasNotificationsRequest struct { +// Request permanent deletion of every current notification occurrence. +type DeleteAllNotificationOccurrencesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *HasNotificationsRequest) Reset() { - *x = HasNotificationsRequest{} +func (x *DeleteAllNotificationOccurrencesRequest) Reset() { + *x = DeleteAllNotificationOccurrencesRequest{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *HasNotificationsRequest) String() string { +func (x *DeleteAllNotificationOccurrencesRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*HasNotificationsRequest) ProtoMessage() {} +func (*DeleteAllNotificationOccurrencesRequest) ProtoMessage() {} -func (x *HasNotificationsRequest) ProtoReflect() protoreflect.Message { +func (x *DeleteAllNotificationOccurrencesRequest) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -846,34 +1036,34 @@ func (x *HasNotificationsRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use HasNotificationsRequest.ProtoReflect.Descriptor instead. -func (*HasNotificationsRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use DeleteAllNotificationOccurrencesRequest.ProtoReflect.Descriptor instead. +func (*DeleteAllNotificationOccurrencesRequest) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{13} } -// Lightweight pending notification check. -type HasNotificationsResponse struct { +// Result of deleting every current notification occurrence. +type DeleteAllNotificationOccurrencesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // True when the authenticated viewer has at least one pending notification. - HasNotifications bool `protobuf:"varint,1,opt,name=has_notifications,json=hasNotifications,proto3" json:"has_notifications,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Number of current occurrences replaced by deletion tombstones. + DeletedCount int32 `protobuf:"varint,1,opt,name=deleted_count,json=deletedCount,proto3" json:"deleted_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *HasNotificationsResponse) Reset() { - *x = HasNotificationsResponse{} +func (x *DeleteAllNotificationOccurrencesResponse) Reset() { + *x = DeleteAllNotificationOccurrencesResponse{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *HasNotificationsResponse) String() string { +func (x *DeleteAllNotificationOccurrencesResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*HasNotificationsResponse) ProtoMessage() {} +func (*DeleteAllNotificationOccurrencesResponse) ProtoMessage() {} -func (x *HasNotificationsResponse) ProtoReflect() protoreflect.Message { +func (x *DeleteAllNotificationOccurrencesResponse) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -885,43 +1075,47 @@ func (x *HasNotificationsResponse) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use HasNotificationsResponse.ProtoReflect.Descriptor instead. -func (*HasNotificationsResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use DeleteAllNotificationOccurrencesResponse.ProtoReflect.Descriptor instead. +func (*DeleteAllNotificationOccurrencesResponse) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{14} } -func (x *HasNotificationsResponse) GetHasNotifications() bool { +func (x *DeleteAllNotificationOccurrencesResponse) GetDeletedCount() int32 { if x != nil { - return x.HasNotifications + return x.DeletedCount } - return false + return 0 } -// Pending notification count for one room. -type RoomNotificationCount struct { +// Explicit and effective delivery policy for one notification cause. +type NotificationPolicyPreference struct { state protoimpl.MessageState `protogen:"open.v1"` - // Room ID. - RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - // Pending notification count for the authenticated viewer in this room. - TotalCount int32 `protobuf:"varint,2,opt,name=total_count,json=totalCount,proto3" json:"total_count,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RoomNotificationCount) Reset() { - *x = RoomNotificationCount{} + // Notification cause controlled by this row. + Reason NotificationReason `protobuf:"varint,1,opt,name=reason,proto3,enum=chatto.api.v1.NotificationReason" json:"reason,omitempty"` + // Explicit server override, or unspecified when inherited from product defaults. + ServerIntensity NotificationDeliveryIntensity `protobuf:"varint,2,opt,name=server_intensity,json=serverIntensity,proto3,enum=chatto.api.v1.NotificationDeliveryIntensity" json:"server_intensity,omitempty"` + // Explicit room override, or unspecified when inherited from server scope. + RoomIntensity NotificationDeliveryIntensity `protobuf:"varint,3,opt,name=room_intensity,json=roomIntensity,proto3,enum=chatto.api.v1.NotificationDeliveryIntensity" json:"room_intensity,omitempty"` + // Effective intensity after applying product, server, and room inheritance. + EffectiveIntensity NotificationDeliveryIntensity `protobuf:"varint,4,opt,name=effective_intensity,json=effectiveIntensity,proto3,enum=chatto.api.v1.NotificationDeliveryIntensity" json:"effective_intensity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationPolicyPreference) Reset() { + *x = NotificationPolicyPreference{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *RoomNotificationCount) String() string { +func (x *NotificationPolicyPreference) String() string { return protoimpl.X.MessageStringOf(x) } -func (*RoomNotificationCount) ProtoMessage() {} +func (*NotificationPolicyPreference) ProtoMessage() {} -func (x *RoomNotificationCount) ProtoReflect() protoreflect.Message { +func (x *NotificationPolicyPreference) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -933,46 +1127,63 @@ func (x *RoomNotificationCount) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use RoomNotificationCount.ProtoReflect.Descriptor instead. -func (*RoomNotificationCount) Descriptor() ([]byte, []int) { +// Deprecated: Use NotificationPolicyPreference.ProtoReflect.Descriptor instead. +func (*NotificationPolicyPreference) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{15} } -func (x *RoomNotificationCount) GetRoomId() string { +func (x *NotificationPolicyPreference) GetReason() NotificationReason { if x != nil { - return x.RoomId + return x.Reason } - return "" + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED } -func (x *RoomNotificationCount) GetTotalCount() int32 { +func (x *NotificationPolicyPreference) GetServerIntensity() NotificationDeliveryIntensity { if x != nil { - return x.TotalCount + return x.ServerIntensity } - return 0 + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED } -// Request for pending notification counts grouped by room. -type ListRoomNotificationCountsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` +func (x *NotificationPolicyPreference) GetRoomIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.RoomIntensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +func (x *NotificationPolicyPreference) GetEffectiveIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.EffectiveIntensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +// Request the authenticated viewer's notification policy. +type GetNotificationPolicyRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Empty returns server-scoped preferences. A room ID returns the inherited + // effective policy for that room and requires current membership. + RoomId *string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3,oneof" json:"room_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ListRoomNotificationCountsRequest) Reset() { - *x = ListRoomNotificationCountsRequest{} +func (x *GetNotificationPolicyRequest) Reset() { + *x = GetNotificationPolicyRequest{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListRoomNotificationCountsRequest) String() string { +func (x *GetNotificationPolicyRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListRoomNotificationCountsRequest) ProtoMessage() {} +func (*GetNotificationPolicyRequest) ProtoMessage() {} -func (x *ListRoomNotificationCountsRequest) ProtoReflect() protoreflect.Message { +func (x *GetNotificationPolicyRequest) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -984,34 +1195,43 @@ func (x *ListRoomNotificationCountsRequest) ProtoReflect() protoreflect.Message return mi.MessageOf(x) } -// Deprecated: Use ListRoomNotificationCountsRequest.ProtoReflect.Descriptor instead. -func (*ListRoomNotificationCountsRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use GetNotificationPolicyRequest.ProtoReflect.Descriptor instead. +func (*GetNotificationPolicyRequest) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{16} } -// Finite snapshot of pending notification counts grouped by room. -type ListRoomNotificationCountsResponse struct { +func (x *GetNotificationPolicyRequest) GetRoomId() string { + if x != nil && x.RoomId != nil { + return *x.RoomId + } + return "" +} + +// Complete supported notification policy for one scope. +type GetNotificationPolicyResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - // Counts for rooms with at least one pending notification. - RoomCounts []*RoomNotificationCount `protobuf:"bytes,1,rep,name=room_counts,json=roomCounts,proto3" json:"room_counts,omitempty"` + // Room scope when requested; absent for server scope. + RoomId *string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3,oneof" json:"room_id,omitempty"` + // One row for every supported notification cause. + Preferences []*NotificationPolicyPreference `protobuf:"bytes,2,rep,name=preferences,proto3" json:"preferences,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *ListRoomNotificationCountsResponse) Reset() { - *x = ListRoomNotificationCountsResponse{} +func (x *GetNotificationPolicyResponse) Reset() { + *x = GetNotificationPolicyResponse{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *ListRoomNotificationCountsResponse) String() string { +func (x *GetNotificationPolicyResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*ListRoomNotificationCountsResponse) ProtoMessage() {} +func (*GetNotificationPolicyResponse) ProtoMessage() {} -func (x *ListRoomNotificationCountsResponse) ProtoReflect() protoreflect.Message { +func (x *GetNotificationPolicyResponse) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -1023,41 +1243,52 @@ func (x *ListRoomNotificationCountsResponse) ProtoReflect() protoreflect.Message return mi.MessageOf(x) } -// Deprecated: Use ListRoomNotificationCountsResponse.ProtoReflect.Descriptor instead. -func (*ListRoomNotificationCountsResponse) Descriptor() ([]byte, []int) { +// Deprecated: Use GetNotificationPolicyResponse.ProtoReflect.Descriptor instead. +func (*GetNotificationPolicyResponse) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{17} } -func (x *ListRoomNotificationCountsResponse) GetRoomCounts() []*RoomNotificationCount { +func (x *GetNotificationPolicyResponse) GetRoomId() string { + if x != nil && x.RoomId != nil { + return *x.RoomId + } + return "" +} + +func (x *GetNotificationPolicyResponse) GetPreferences() []*NotificationPolicyPreference { if x != nil { - return x.RoomCounts + return x.Preferences } return nil } -// Request to dismiss one pending notification. -type DismissNotificationRequest struct { +// Set or clear one notification preference override. +type SetNotificationPolicyPreferenceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // Required. Notification to dismiss. - NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Room scope to change; absent changes the server scope. + RoomId *string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3,oneof" json:"room_id,omitempty"` + // Required notification cause. + Reason NotificationReason `protobuf:"varint,2,opt,name=reason,proto3,enum=chatto.api.v1.NotificationReason" json:"reason,omitempty"` + // Unspecified clears the selected server or room override. + Intensity NotificationDeliveryIntensity `protobuf:"varint,3,opt,name=intensity,proto3,enum=chatto.api.v1.NotificationDeliveryIntensity" json:"intensity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *DismissNotificationRequest) Reset() { - *x = DismissNotificationRequest{} +func (x *SetNotificationPolicyPreferenceRequest) Reset() { + *x = SetNotificationPolicyPreferenceRequest{} mi := &file_chatto_api_v1_notifications_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *DismissNotificationRequest) String() string { +func (x *SetNotificationPolicyPreferenceRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*DismissNotificationRequest) ProtoMessage() {} +func (*SetNotificationPolicyPreferenceRequest) ProtoMessage() {} -func (x *DismissNotificationRequest) ProtoReflect() protoreflect.Message { +func (x *SetNotificationPolicyPreferenceRequest) ProtoReflect() protoreflect.Message { mi := &file_chatto_api_v1_notifications_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -1069,86 +1300,58 @@ func (x *DismissNotificationRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use DismissNotificationRequest.ProtoReflect.Descriptor instead. -func (*DismissNotificationRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use SetNotificationPolicyPreferenceRequest.ProtoReflect.Descriptor instead. +func (*SetNotificationPolicyPreferenceRequest) Descriptor() ([]byte, []int) { return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{18} } -func (x *DismissNotificationRequest) GetNotificationId() string { - if x != nil { - return x.NotificationId +func (x *SetNotificationPolicyPreferenceRequest) GetRoomId() string { + if x != nil && x.RoomId != nil { + return *x.RoomId } return "" } -// Result of dismissing one pending notification. -type DismissNotificationResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // True when the notification is no longer pending. - Dismissed bool `protobuf:"varint,1,opt,name=dismissed,proto3" json:"dismissed,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DismissNotificationResponse) Reset() { - *x = DismissNotificationResponse{} - mi := &file_chatto_api_v1_notifications_proto_msgTypes[19] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DismissNotificationResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*DismissNotificationResponse) ProtoMessage() {} - -func (x *DismissNotificationResponse) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notifications_proto_msgTypes[19] +func (x *SetNotificationPolicyPreferenceRequest) GetReason() NotificationReason { if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms + return x.Reason } - return mi.MessageOf(x) + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED } -// Deprecated: Use DismissNotificationResponse.ProtoReflect.Descriptor instead. -func (*DismissNotificationResponse) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{19} -} - -func (x *DismissNotificationResponse) GetDismissed() bool { +func (x *SetNotificationPolicyPreferenceRequest) GetIntensity() NotificationDeliveryIntensity { if x != nil { - return x.Dismissed + return x.Intensity } - return false + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED } -// Request to dismiss all pending notifications. -type DismissAllNotificationsRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` +// Complete supported notification policy after one preference change. +type SetNotificationPolicyPreferenceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Room scope when changed; absent for server scope. + RoomId *string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3,oneof" json:"room_id,omitempty"` + // One row for every supported notification cause. + Preferences []*NotificationPolicyPreference `protobuf:"bytes,2,rep,name=preferences,proto3" json:"preferences,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *DismissAllNotificationsRequest) Reset() { - *x = DismissAllNotificationsRequest{} - mi := &file_chatto_api_v1_notifications_proto_msgTypes[20] +func (x *SetNotificationPolicyPreferenceResponse) Reset() { + *x = SetNotificationPolicyPreferenceResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *DismissAllNotificationsRequest) String() string { +func (x *SetNotificationPolicyPreferenceResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*DismissAllNotificationsRequest) ProtoMessage() {} +func (*SetNotificationPolicyPreferenceResponse) ProtoMessage() {} -func (x *DismissAllNotificationsRequest) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notifications_proto_msgTypes[20] +func (x *SetNotificationPolicyPreferenceResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1159,137 +1362,143 @@ func (x *DismissAllNotificationsRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use DismissAllNotificationsRequest.ProtoReflect.Descriptor instead. -func (*DismissAllNotificationsRequest) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{20} -} - -// Result of dismissing all pending notifications. -type DismissAllNotificationsResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Number of notifications dismissed. - DismissedCount int32 `protobuf:"varint,1,opt,name=dismissed_count,json=dismissedCount,proto3" json:"dismissed_count,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *DismissAllNotificationsResponse) Reset() { - *x = DismissAllNotificationsResponse{} - mi := &file_chatto_api_v1_notifications_proto_msgTypes[21] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *DismissAllNotificationsResponse) String() string { - return protoimpl.X.MessageStringOf(x) +// Deprecated: Use SetNotificationPolicyPreferenceResponse.ProtoReflect.Descriptor instead. +func (*SetNotificationPolicyPreferenceResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{19} } -func (*DismissAllNotificationsResponse) ProtoMessage() {} - -func (x *DismissAllNotificationsResponse) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_notifications_proto_msgTypes[21] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms +func (x *SetNotificationPolicyPreferenceResponse) GetRoomId() string { + if x != nil && x.RoomId != nil { + return *x.RoomId } - return mi.MessageOf(x) -} - -// Deprecated: Use DismissAllNotificationsResponse.ProtoReflect.Descriptor instead. -func (*DismissAllNotificationsResponse) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{21} + return "" } -func (x *DismissAllNotificationsResponse) GetDismissedCount() int32 { +func (x *SetNotificationPolicyPreferenceResponse) GetPreferences() []*NotificationPolicyPreference { if x != nil { - return x.DismissedCount + return x.Preferences } - return 0 + return nil } var File_chatto_api_v1_notifications_proto protoreflect.FileDescriptor const file_chatto_api_v1_notifications_proto_rawDesc = "" + "\n" + - "!chatto/api/v1/notifications.proto\x12\rchatto.api.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1echatto/api/v1/pagination.proto\x1a\x19chatto/api/v1/rooms.proto\x1a\x19chatto/api/v1/users.proto\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"u\n" + - "\x19DirectMessageNotification\x12\x19\n" + - "\bevent_id\x18\x02 \x01(\tR\aeventId\x12.\n" + - "\x04room\x18\x03 \x01(\v2\x1a.chatto.api.v1.RoomSummaryR\x04roomJ\x04\b\x01\x10\x02R\aroom_id\"\xaf\x01\n" + - "\x13MentionNotification\x12.\n" + + "!chatto/api/v1/notifications.proto\x12\rchatto.api.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1echatto/api/v1/pagination.proto\x1a\x19chatto/api/v1/rooms.proto\x1a\x19chatto/api/v1/users.proto\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa0\x01\n" + + "\x17NotificationReasonMatch\x129\n" + + "\x06reason\x18\x01 \x01(\x0e2!.chatto.api.v1.NotificationReasonR\x06reason\x12J\n" + + "\tintensity\x18\x02 \x01(\x0e2,.chatto.api.v1.NotificationDeliveryIntensityR\tintensity\"\xef\x01\n" + + "\x12NotificationTarget\x12.\n" + "\x04room\x18\x01 \x01(\v2\x1a.chatto.api.v1.RoomSummaryR\x04room\x12\x19\n" + "\bevent_id\x18\x02 \x01(\tR\aeventId\x124\n" + - "\x14thread_root_event_id\x18\x03 \x01(\tH\x00R\x11threadRootEventId\x88\x01\x01B\x17\n" + - "\x15_thread_root_event_id\"\xd2\x01\n" + - "\x11ReplyNotification\x12.\n" + - "\x04room\x18\x01 \x01(\v2\x1a.chatto.api.v1.RoomSummaryR\x04room\x12\x19\n" + - "\bevent_id\x18\x02 \x01(\tR\aeventId\x12#\n" + - "\x0ein_reply_to_id\x18\x03 \x01(\tR\vinReplyToId\x124\n" + - "\x14thread_root_event_id\x18\x04 \x01(\tH\x00R\x11threadRootEventId\x88\x01\x01B\x17\n" + - "\x15_thread_root_event_id\"d\n" + - "\x17RoomMessageNotification\x12.\n" + - "\x04room\x18\x01 \x01(\v2\x1a.chatto.api.v1.RoomSummaryR\x04room\x12\x19\n" + - "\bevent_id\x18\x02 \x01(\tR\aeventId\"\xb9\x03\n" + - "\x10NotificationItem\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x129\n" + + "\x14thread_root_event_id\x18\x03 \x01(\tH\x00R\x11threadRootEventId\x88\x01\x01\x12+\n" + + "\x0fparent_event_id\x18\x04 \x01(\tH\x01R\rparentEventId\x88\x01\x01B\x17\n" + + "\x15_thread_root_event_idB\x12\n" + + "\x10_parent_event_id\"\xe5\x03\n" + + "\x16NotificationOccurrence\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12&\n" + + "\x0fsource_event_id\x18\x02 \x01(\tR\rsourceEventId\x129\n" + + "\n" + + "created_at\x18\x03 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12)\n" + + "\x05actor\x18\x04 \x01(\v2\x13.chatto.api.v1.UserR\x05actor\x129\n" + + "\x06target\x18\x05 \x01(\v2!.chatto.api.v1.NotificationTargetR\x06target\x12@\n" + + "\areasons\x18\x06 \x03(\v2&.chatto.api.v1.NotificationReasonMatchR\areasons\x12]\n" + + "\x13strongest_intensity\x18\a \x01(\x0e2,.chatto.api.v1.NotificationDeliveryIntensityR\x12strongestIntensity\x12\x16\n" + + "\x06unread\x18\b \x01(\bR\x06unread\x129\n" + "\n" + - "created_at\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\tcreatedAt\x12)\n" + - "\x05actor\x18\x03 \x01(\v2\x13.chatto.api.v1.UserR\x05actor\x12Q\n" + - "\x0edirect_message\x18\n" + - " \x01(\v2(.chatto.api.v1.DirectMessageNotificationH\x00R\rdirectMessage\x12>\n" + - "\amention\x18\v \x01(\v2\".chatto.api.v1.MentionNotificationH\x00R\amention\x128\n" + - "\x05reply\x18\f \x01(\v2 .chatto.api.v1.ReplyNotificationH\x00R\x05reply\x12K\n" + - "\froom_message\x18\r \x01(\v2&.chatto.api.v1.RoomMessageNotificationH\x00R\vroomMessageB\x06\n" + - "\x04kindJ\x04\b\x04\x10\x05R\asummary\"e\n" + - "\x18ListNotificationsRequest\x12.\n" + - "\x04page\x18\x03 \x01(\v2\x1a.chatto.api.v1.PageRequestR\x04pageJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x05limitR\x06offset\"\x8b\x01\n" + - "\x1cListRoomNotificationsRequest\x12 \n" + - "\aroom_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x06roomId\x12.\n" + - "\x04page\x18\x04 \x01(\v2\x1a.chatto.api.v1.PageRequestR\x04pageJ\x04\b\x02\x10\x03J\x04\b\x03\x10\x04R\x05limitR\x06offset\"\xc5\x01\n" + - "\x19ListNotificationsResponse\x12E\n" + - "\rnotifications\x18\x01 \x03(\v2\x1f.chatto.api.v1.NotificationItemR\rnotifications\x12+\n" + - "\x04page\x18\x05 \x01(\v2\x17.chatto.api.v1.PageInfoR\x04pageJ\x04\b\x02\x10\x03J\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\vtotal_countR\bhas_moreR\vserver_name\"J\n" + - "\x16GetNotificationRequest\x120\n" + - "\x0fnotification_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0enotificationId\"q\n" + - "\x17GetNotificationResponse\x12C\n" + - "\fnotification\x18\x01 \x01(\v2\x1f.chatto.api.v1.NotificationItemR\fnotificationJ\x04\b\x02\x10\x03R\vserver_name\"[\n" + - "\x1cBatchGetNotificationsRequest\x12;\n" + - "\x10notification_ids\x18\x01 \x03(\tB\x10\xbaH\r\x92\x01\n" + - "\b\x01\x10d\"\x04r\x02\x10\x01R\x0fnotificationIds\"y\n" + - "\x1dBatchGetNotificationsResponse\x12E\n" + - "\rnotifications\x18\x01 \x03(\v2\x1f.chatto.api.v1.NotificationItemR\rnotificationsJ\x04\b\x02\x10\x03R\vserver_name\"\xc9\x01\n" + - "\x1dListRoomNotificationsResponse\x12E\n" + - "\rnotifications\x18\x01 \x03(\v2\x1f.chatto.api.v1.NotificationItemR\rnotifications\x12+\n" + - "\x04page\x18\x05 \x01(\v2\x17.chatto.api.v1.PageInfoR\x04pageJ\x04\b\x02\x10\x03J\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\vtotal_countR\bhas_moreR\vserver_name\"\x19\n" + - "\x17HasNotificationsRequest\"G\n" + - "\x18HasNotificationsResponse\x12+\n" + - "\x11has_notifications\x18\x01 \x01(\bR\x10hasNotifications\"Q\n" + - "\x15RoomNotificationCount\x12\x17\n" + - "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\x1f\n" + - "\vtotal_count\x18\x02 \x01(\x05R\n" + - "totalCount\"#\n" + - "!ListRoomNotificationCountsRequest\"k\n" + - "\"ListRoomNotificationCountsResponse\x12E\n" + - "\vroom_counts\x18\x01 \x03(\v2$.chatto.api.v1.RoomNotificationCountR\n" + - "roomCounts\"N\n" + - "\x1aDismissNotificationRequest\x120\n" + - "\x0fnotification_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0enotificationId\";\n" + - "\x1bDismissNotificationResponse\x12\x1c\n" + - "\tdismissed\x18\x01 \x01(\bR\tdismissed\" \n" + - "\x1eDismissAllNotificationsRequest\"J\n" + - "\x1fDismissAllNotificationsResponse\x12'\n" + - "\x0fdismissed_count\x18\x01 \x01(\x05R\x0edismissedCount2\x9d\a\n" + - "\x13NotificationService\x12f\n" + - "\x11ListNotifications\x12'.chatto.api.v1.ListNotificationsRequest\x1a(.chatto.api.v1.ListNotificationsResponse\x12`\n" + - "\x0fGetNotification\x12%.chatto.api.v1.GetNotificationRequest\x1a&.chatto.api.v1.GetNotificationResponse\x12r\n" + - "\x15BatchGetNotifications\x12+.chatto.api.v1.BatchGetNotificationsRequest\x1a,.chatto.api.v1.BatchGetNotificationsResponse\x12r\n" + - "\x15ListRoomNotifications\x12+.chatto.api.v1.ListRoomNotificationsRequest\x1a,.chatto.api.v1.ListRoomNotificationsResponse\x12\x81\x01\n" + - "\x1aListRoomNotificationCounts\x120.chatto.api.v1.ListRoomNotificationCountsRequest\x1a1.chatto.api.v1.ListRoomNotificationCountsResponse\x12c\n" + - "\x10HasNotifications\x12&.chatto.api.v1.HasNotificationsRequest\x1a'.chatto.api.v1.HasNotificationsResponse\x12q\n" + - "\x13DismissNotification\x12).chatto.api.v1.DismissNotificationRequest\x1a*.chatto.api.v1.DismissNotificationResponse\"\x03\x90\x02\x02\x12x\n" + - "\x17DismissAllNotifications\x12-.chatto.api.v1.DismissAllNotificationsRequest\x1a..chatto.api.v1.DismissAllNotificationsResponseB\xae\x01\n" + + "expires_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\xbc\x04\n" + + "\x11NotificationGroup\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12G\n" + + "\voccurrences\x18\x02 \x03(\v2%.chatto.api.v1.NotificationOccurrenceR\voccurrences\x12B\n" + + "\vopen_target\x18\x03 \x01(\v2!.chatto.api.v1.NotificationTargetR\n" + + "openTarget\x12\x16\n" + + "\x06unread\x18\x04 \x01(\bR\x06unread\x12)\n" + + "\x10occurrence_count\x18\x05 \x01(\x05R\x0foccurrenceCount\x127\n" + + "\tlatest_at\x18\x06 \x01(\v2\x1a.google.protobuf.TimestampR\blatestAt\x12]\n" + + "\x13strongest_intensity\x18\a \x01(\x0e2,.chatto.api.v1.NotificationDeliveryIntensityR\x12strongestIntensity\x12;\n" + + "\areasons\x18\b \x03(\x0e2!.chatto.api.v1.NotificationReasonR\areasons\x12@\n" + + "\x0enext_expiry_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\fnextExpiryAt\x120\n" + + "\x14open_notification_id\x18\f \x01(\tR\x12openNotificationId\"O\n" + + "\x1dListNotificationGroupsRequest\x12.\n" + + "\x04page\x18\x01 \x01(\v2\x1a.chatto.api.v1.PageRequestR\x04page\"\xe1\x02\n" + + "\x1eListNotificationGroupsResponse\x128\n" + + "\x06groups\x18\x01 \x03(\v2 .chatto.api.v1.NotificationGroupR\x06groups\x12+\n" + + "\x04page\x18\x02 \x01(\v2\x17.chatto.api.v1.PageInfoR\x04page\x12,\n" + + "\x12unread_group_count\x18\x03 \x01(\x05R\x10unreadGroupCount\x12@\n" + + "\x0enext_expiry_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\fnextExpiryAt\x12h\n" + + "\x18room_unread_group_counts\x18\x05 \x03(\v2/.chatto.api.v1.NotificationRoomUnreadGroupCountR\x15roomUnreadGroupCounts\"i\n" + + " NotificationRoomUnreadGroupCount\x12\x17\n" + + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12,\n" + + "\x12unread_group_count\x18\x02 \x01(\x05R\x10unreadGroupCount\"O\n" + + "\x1bMarkNotificationReadRequest\x120\n" + + "\x0fnotification_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0enotificationId\"i\n" + + "\x1cMarkNotificationReadResponse\x12I\n" + + "\fnotification\x18\x01 \x01(\v2%.chatto.api.v1.NotificationOccurrenceR\fnotification\"W\n" + + "#DeleteNotificationOccurrenceRequest\x120\n" + + "\x0fnotification_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0enotificationId\"@\n" + + "$DeleteNotificationOccurrenceResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"D\n" + + "\x1eDeleteNotificationGroupRequest\x12\"\n" + + "\bgroup_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\agroupId\"F\n" + + "\x1fDeleteNotificationGroupResponse\x12#\n" + + "\rdeleted_count\x18\x01 \x01(\x05R\fdeletedCount\")\n" + + "'DeleteAllNotificationOccurrencesRequest\"O\n" + + "(DeleteAllNotificationOccurrencesResponse\x12#\n" + + "\rdeleted_count\x18\x01 \x01(\x05R\fdeletedCount\"\xe6\x02\n" + + "\x1cNotificationPolicyPreference\x129\n" + + "\x06reason\x18\x01 \x01(\x0e2!.chatto.api.v1.NotificationReasonR\x06reason\x12W\n" + + "\x10server_intensity\x18\x02 \x01(\x0e2,.chatto.api.v1.NotificationDeliveryIntensityR\x0fserverIntensity\x12S\n" + + "\x0eroom_intensity\x18\x03 \x01(\x0e2,.chatto.api.v1.NotificationDeliveryIntensityR\rroomIntensity\x12]\n" + + "\x13effective_intensity\x18\x04 \x01(\x0e2,.chatto.api.v1.NotificationDeliveryIntensityR\x12effectiveIntensity\"Q\n" + + "\x1cGetNotificationPolicyRequest\x12%\n" + + "\aroom_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\x06roomId\x88\x01\x01B\n" + + "\n" + + "\b_room_id\"\x98\x01\n" + + "\x1dGetNotificationPolicyResponse\x12\x1c\n" + + "\aroom_id\x18\x01 \x01(\tH\x00R\x06roomId\x88\x01\x01\x12M\n" + + "\vpreferences\x18\x02 \x03(\v2+.chatto.api.v1.NotificationPolicyPreferenceR\vpreferencesB\n" + + "\n" + + "\b_room_id\"\xf8\x01\n" + + "&SetNotificationPolicyPreferenceRequest\x12%\n" + + "\aroom_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\x06roomId\x88\x01\x01\x12E\n" + + "\x06reason\x18\x02 \x01(\x0e2!.chatto.api.v1.NotificationReasonB\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x06reason\x12T\n" + + "\tintensity\x18\x03 \x01(\x0e2,.chatto.api.v1.NotificationDeliveryIntensityB\b\xbaH\x05\x82\x01\x02\x10\x01R\tintensityB\n" + + "\n" + + "\b_room_id\"\xa2\x01\n" + + "'SetNotificationPolicyPreferenceResponse\x12\x1c\n" + + "\aroom_id\x18\x01 \x01(\tH\x00R\x06roomId\x88\x01\x01\x12M\n" + + "\vpreferences\x18\x02 \x03(\v2+.chatto.api.v1.NotificationPolicyPreferenceR\vpreferencesB\n" + + "\n" + + "\b_room_id*\xa4\x03\n" + + "\x12NotificationReason\x12#\n" + + "\x1fNOTIFICATION_REASON_UNSPECIFIED\x10\x00\x12&\n" + + "\"NOTIFICATION_REASON_DIRECT_MESSAGE\x10\x01\x12&\n" + + "\"NOTIFICATION_REASON_DIRECT_MENTION\x10\x02\x12\x1d\n" + + "\x19NOTIFICATION_REASON_REPLY\x10\x03\x12$\n" + + " NOTIFICATION_REASON_ROLE_MENTION\x10\x04\x12\x1c\n" + + "\x18NOTIFICATION_REASON_HERE\x10\x05\x12\x1b\n" + + "\x17NOTIFICATION_REASON_ALL\x10\x06\x12'\n" + + "#NOTIFICATION_REASON_FOLLOWED_THREAD\x10\a\x12%\n" + + "!NOTIFICATION_REASON_FOLLOWED_ROOM\x10\b\x12 \n" + + "\x1cNOTIFICATION_REASON_REACTION\x10\t\x12'\n" + + "#NOTIFICATION_REASON_ROOM_INVITATION\x10\n" + + "*\xcf\x01\n" + + "\x1dNotificationDeliveryIntensity\x12/\n" + + "+NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED\x10\x00\x12'\n" + + "#NOTIFICATION_DELIVERY_INTENSITY_OFF\x10\x01\x12)\n" + + "%NOTIFICATION_DELIVERY_INTENSITY_BADGE\x10\x02\x12)\n" + + "%NOTIFICATION_DELIVERY_INTENSITY_ALERT\x10\x032\xad\a\n" + + "\x13NotificationService\x12u\n" + + "\x16ListNotificationGroups\x12,.chatto.api.v1.ListNotificationGroupsRequest\x1a-.chatto.api.v1.ListNotificationGroupsResponse\x12t\n" + + "\x14MarkNotificationRead\x12*.chatto.api.v1.MarkNotificationReadRequest\x1a+.chatto.api.v1.MarkNotificationReadResponse\"\x03\x90\x02\x02\x12\x8c\x01\n" + + "\x1cDeleteNotificationOccurrence\x122.chatto.api.v1.DeleteNotificationOccurrenceRequest\x1a3.chatto.api.v1.DeleteNotificationOccurrenceResponse\"\x03\x90\x02\x02\x12x\n" + + "\x17DeleteNotificationGroup\x12-.chatto.api.v1.DeleteNotificationGroupRequest\x1a..chatto.api.v1.DeleteNotificationGroupResponse\x12\x93\x01\n" + + " DeleteAllNotificationOccurrences\x126.chatto.api.v1.DeleteAllNotificationOccurrencesRequest\x1a7.chatto.api.v1.DeleteAllNotificationOccurrencesResponse\x12r\n" + + "\x15GetNotificationPolicy\x12+.chatto.api.v1.GetNotificationPolicyRequest\x1a,.chatto.api.v1.GetNotificationPolicyResponse\x12\x95\x01\n" + + "\x1fSetNotificationPolicyPreference\x125.chatto.api.v1.SetNotificationPolicyPreferenceRequest\x1a6.chatto.api.v1.SetNotificationPolicyPreferenceResponse\"\x03\x90\x02\x02B\xae\x01\n" + "\x11com.chatto.api.v1B\x12NotificationsProtoP\x01Z/hmans.de/chatto/internal/pb/chatto/api/v1;apiv1\xa2\x02\x03CAX\xaa\x02\rChatto.Api.V1\xca\x02\rChatto\\Api\\V1\xe2\x02\x19Chatto\\Api\\V1\\GPBMetadata\xea\x02\x0fChatto::Api::V1b\x06proto3" var ( @@ -1304,77 +1513,86 @@ func file_chatto_api_v1_notifications_proto_rawDescGZIP() []byte { return file_chatto_api_v1_notifications_proto_rawDescData } -var file_chatto_api_v1_notifications_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_chatto_api_v1_notifications_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_chatto_api_v1_notifications_proto_msgTypes = make([]protoimpl.MessageInfo, 20) var file_chatto_api_v1_notifications_proto_goTypes = []any{ - (*DirectMessageNotification)(nil), // 0: chatto.api.v1.DirectMessageNotification - (*MentionNotification)(nil), // 1: chatto.api.v1.MentionNotification - (*ReplyNotification)(nil), // 2: chatto.api.v1.ReplyNotification - (*RoomMessageNotification)(nil), // 3: chatto.api.v1.RoomMessageNotification - (*NotificationItem)(nil), // 4: chatto.api.v1.NotificationItem - (*ListNotificationsRequest)(nil), // 5: chatto.api.v1.ListNotificationsRequest - (*ListRoomNotificationsRequest)(nil), // 6: chatto.api.v1.ListRoomNotificationsRequest - (*ListNotificationsResponse)(nil), // 7: chatto.api.v1.ListNotificationsResponse - (*GetNotificationRequest)(nil), // 8: chatto.api.v1.GetNotificationRequest - (*GetNotificationResponse)(nil), // 9: chatto.api.v1.GetNotificationResponse - (*BatchGetNotificationsRequest)(nil), // 10: chatto.api.v1.BatchGetNotificationsRequest - (*BatchGetNotificationsResponse)(nil), // 11: chatto.api.v1.BatchGetNotificationsResponse - (*ListRoomNotificationsResponse)(nil), // 12: chatto.api.v1.ListRoomNotificationsResponse - (*HasNotificationsRequest)(nil), // 13: chatto.api.v1.HasNotificationsRequest - (*HasNotificationsResponse)(nil), // 14: chatto.api.v1.HasNotificationsResponse - (*RoomNotificationCount)(nil), // 15: chatto.api.v1.RoomNotificationCount - (*ListRoomNotificationCountsRequest)(nil), // 16: chatto.api.v1.ListRoomNotificationCountsRequest - (*ListRoomNotificationCountsResponse)(nil), // 17: chatto.api.v1.ListRoomNotificationCountsResponse - (*DismissNotificationRequest)(nil), // 18: chatto.api.v1.DismissNotificationRequest - (*DismissNotificationResponse)(nil), // 19: chatto.api.v1.DismissNotificationResponse - (*DismissAllNotificationsRequest)(nil), // 20: chatto.api.v1.DismissAllNotificationsRequest - (*DismissAllNotificationsResponse)(nil), // 21: chatto.api.v1.DismissAllNotificationsResponse - (*RoomSummary)(nil), // 22: chatto.api.v1.RoomSummary - (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp - (*User)(nil), // 24: chatto.api.v1.User - (*PageRequest)(nil), // 25: chatto.api.v1.PageRequest - (*PageInfo)(nil), // 26: chatto.api.v1.PageInfo + (NotificationReason)(0), // 0: chatto.api.v1.NotificationReason + (NotificationDeliveryIntensity)(0), // 1: chatto.api.v1.NotificationDeliveryIntensity + (*NotificationReasonMatch)(nil), // 2: chatto.api.v1.NotificationReasonMatch + (*NotificationTarget)(nil), // 3: chatto.api.v1.NotificationTarget + (*NotificationOccurrence)(nil), // 4: chatto.api.v1.NotificationOccurrence + (*NotificationGroup)(nil), // 5: chatto.api.v1.NotificationGroup + (*ListNotificationGroupsRequest)(nil), // 6: chatto.api.v1.ListNotificationGroupsRequest + (*ListNotificationGroupsResponse)(nil), // 7: chatto.api.v1.ListNotificationGroupsResponse + (*NotificationRoomUnreadGroupCount)(nil), // 8: chatto.api.v1.NotificationRoomUnreadGroupCount + (*MarkNotificationReadRequest)(nil), // 9: chatto.api.v1.MarkNotificationReadRequest + (*MarkNotificationReadResponse)(nil), // 10: chatto.api.v1.MarkNotificationReadResponse + (*DeleteNotificationOccurrenceRequest)(nil), // 11: chatto.api.v1.DeleteNotificationOccurrenceRequest + (*DeleteNotificationOccurrenceResponse)(nil), // 12: chatto.api.v1.DeleteNotificationOccurrenceResponse + (*DeleteNotificationGroupRequest)(nil), // 13: chatto.api.v1.DeleteNotificationGroupRequest + (*DeleteNotificationGroupResponse)(nil), // 14: chatto.api.v1.DeleteNotificationGroupResponse + (*DeleteAllNotificationOccurrencesRequest)(nil), // 15: chatto.api.v1.DeleteAllNotificationOccurrencesRequest + (*DeleteAllNotificationOccurrencesResponse)(nil), // 16: chatto.api.v1.DeleteAllNotificationOccurrencesResponse + (*NotificationPolicyPreference)(nil), // 17: chatto.api.v1.NotificationPolicyPreference + (*GetNotificationPolicyRequest)(nil), // 18: chatto.api.v1.GetNotificationPolicyRequest + (*GetNotificationPolicyResponse)(nil), // 19: chatto.api.v1.GetNotificationPolicyResponse + (*SetNotificationPolicyPreferenceRequest)(nil), // 20: chatto.api.v1.SetNotificationPolicyPreferenceRequest + (*SetNotificationPolicyPreferenceResponse)(nil), // 21: chatto.api.v1.SetNotificationPolicyPreferenceResponse + (*RoomSummary)(nil), // 22: chatto.api.v1.RoomSummary + (*timestamppb.Timestamp)(nil), // 23: google.protobuf.Timestamp + (*User)(nil), // 24: chatto.api.v1.User + (*PageRequest)(nil), // 25: chatto.api.v1.PageRequest + (*PageInfo)(nil), // 26: chatto.api.v1.PageInfo } var file_chatto_api_v1_notifications_proto_depIdxs = []int32{ - 22, // 0: chatto.api.v1.DirectMessageNotification.room:type_name -> chatto.api.v1.RoomSummary - 22, // 1: chatto.api.v1.MentionNotification.room:type_name -> chatto.api.v1.RoomSummary - 22, // 2: chatto.api.v1.ReplyNotification.room:type_name -> chatto.api.v1.RoomSummary - 22, // 3: chatto.api.v1.RoomMessageNotification.room:type_name -> chatto.api.v1.RoomSummary - 23, // 4: chatto.api.v1.NotificationItem.created_at:type_name -> google.protobuf.Timestamp - 24, // 5: chatto.api.v1.NotificationItem.actor:type_name -> chatto.api.v1.User - 0, // 6: chatto.api.v1.NotificationItem.direct_message:type_name -> chatto.api.v1.DirectMessageNotification - 1, // 7: chatto.api.v1.NotificationItem.mention:type_name -> chatto.api.v1.MentionNotification - 2, // 8: chatto.api.v1.NotificationItem.reply:type_name -> chatto.api.v1.ReplyNotification - 3, // 9: chatto.api.v1.NotificationItem.room_message:type_name -> chatto.api.v1.RoomMessageNotification - 25, // 10: chatto.api.v1.ListNotificationsRequest.page:type_name -> chatto.api.v1.PageRequest - 25, // 11: chatto.api.v1.ListRoomNotificationsRequest.page:type_name -> chatto.api.v1.PageRequest - 4, // 12: chatto.api.v1.ListNotificationsResponse.notifications:type_name -> chatto.api.v1.NotificationItem - 26, // 13: chatto.api.v1.ListNotificationsResponse.page:type_name -> chatto.api.v1.PageInfo - 4, // 14: chatto.api.v1.GetNotificationResponse.notification:type_name -> chatto.api.v1.NotificationItem - 4, // 15: chatto.api.v1.BatchGetNotificationsResponse.notifications:type_name -> chatto.api.v1.NotificationItem - 4, // 16: chatto.api.v1.ListRoomNotificationsResponse.notifications:type_name -> chatto.api.v1.NotificationItem - 26, // 17: chatto.api.v1.ListRoomNotificationsResponse.page:type_name -> chatto.api.v1.PageInfo - 15, // 18: chatto.api.v1.ListRoomNotificationCountsResponse.room_counts:type_name -> chatto.api.v1.RoomNotificationCount - 5, // 19: chatto.api.v1.NotificationService.ListNotifications:input_type -> chatto.api.v1.ListNotificationsRequest - 8, // 20: chatto.api.v1.NotificationService.GetNotification:input_type -> chatto.api.v1.GetNotificationRequest - 10, // 21: chatto.api.v1.NotificationService.BatchGetNotifications:input_type -> chatto.api.v1.BatchGetNotificationsRequest - 6, // 22: chatto.api.v1.NotificationService.ListRoomNotifications:input_type -> chatto.api.v1.ListRoomNotificationsRequest - 16, // 23: chatto.api.v1.NotificationService.ListRoomNotificationCounts:input_type -> chatto.api.v1.ListRoomNotificationCountsRequest - 13, // 24: chatto.api.v1.NotificationService.HasNotifications:input_type -> chatto.api.v1.HasNotificationsRequest - 18, // 25: chatto.api.v1.NotificationService.DismissNotification:input_type -> chatto.api.v1.DismissNotificationRequest - 20, // 26: chatto.api.v1.NotificationService.DismissAllNotifications:input_type -> chatto.api.v1.DismissAllNotificationsRequest - 7, // 27: chatto.api.v1.NotificationService.ListNotifications:output_type -> chatto.api.v1.ListNotificationsResponse - 9, // 28: chatto.api.v1.NotificationService.GetNotification:output_type -> chatto.api.v1.GetNotificationResponse - 11, // 29: chatto.api.v1.NotificationService.BatchGetNotifications:output_type -> chatto.api.v1.BatchGetNotificationsResponse - 12, // 30: chatto.api.v1.NotificationService.ListRoomNotifications:output_type -> chatto.api.v1.ListRoomNotificationsResponse - 17, // 31: chatto.api.v1.NotificationService.ListRoomNotificationCounts:output_type -> chatto.api.v1.ListRoomNotificationCountsResponse - 14, // 32: chatto.api.v1.NotificationService.HasNotifications:output_type -> chatto.api.v1.HasNotificationsResponse - 19, // 33: chatto.api.v1.NotificationService.DismissNotification:output_type -> chatto.api.v1.DismissNotificationResponse - 21, // 34: chatto.api.v1.NotificationService.DismissAllNotifications:output_type -> chatto.api.v1.DismissAllNotificationsResponse - 27, // [27:35] is the sub-list for method output_type - 19, // [19:27] is the sub-list for method input_type - 19, // [19:19] is the sub-list for extension type_name - 19, // [19:19] is the sub-list for extension extendee - 0, // [0:19] is the sub-list for field type_name + 0, // 0: chatto.api.v1.NotificationReasonMatch.reason:type_name -> chatto.api.v1.NotificationReason + 1, // 1: chatto.api.v1.NotificationReasonMatch.intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 22, // 2: chatto.api.v1.NotificationTarget.room:type_name -> chatto.api.v1.RoomSummary + 23, // 3: chatto.api.v1.NotificationOccurrence.created_at:type_name -> google.protobuf.Timestamp + 24, // 4: chatto.api.v1.NotificationOccurrence.actor:type_name -> chatto.api.v1.User + 3, // 5: chatto.api.v1.NotificationOccurrence.target:type_name -> chatto.api.v1.NotificationTarget + 2, // 6: chatto.api.v1.NotificationOccurrence.reasons:type_name -> chatto.api.v1.NotificationReasonMatch + 1, // 7: chatto.api.v1.NotificationOccurrence.strongest_intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 23, // 8: chatto.api.v1.NotificationOccurrence.expires_at:type_name -> google.protobuf.Timestamp + 4, // 9: chatto.api.v1.NotificationGroup.occurrences:type_name -> chatto.api.v1.NotificationOccurrence + 3, // 10: chatto.api.v1.NotificationGroup.open_target:type_name -> chatto.api.v1.NotificationTarget + 23, // 11: chatto.api.v1.NotificationGroup.latest_at:type_name -> google.protobuf.Timestamp + 1, // 12: chatto.api.v1.NotificationGroup.strongest_intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 0, // 13: chatto.api.v1.NotificationGroup.reasons:type_name -> chatto.api.v1.NotificationReason + 23, // 14: chatto.api.v1.NotificationGroup.next_expiry_at:type_name -> google.protobuf.Timestamp + 25, // 15: chatto.api.v1.ListNotificationGroupsRequest.page:type_name -> chatto.api.v1.PageRequest + 5, // 16: chatto.api.v1.ListNotificationGroupsResponse.groups:type_name -> chatto.api.v1.NotificationGroup + 26, // 17: chatto.api.v1.ListNotificationGroupsResponse.page:type_name -> chatto.api.v1.PageInfo + 23, // 18: chatto.api.v1.ListNotificationGroupsResponse.next_expiry_at:type_name -> google.protobuf.Timestamp + 8, // 19: chatto.api.v1.ListNotificationGroupsResponse.room_unread_group_counts:type_name -> chatto.api.v1.NotificationRoomUnreadGroupCount + 4, // 20: chatto.api.v1.MarkNotificationReadResponse.notification:type_name -> chatto.api.v1.NotificationOccurrence + 0, // 21: chatto.api.v1.NotificationPolicyPreference.reason:type_name -> chatto.api.v1.NotificationReason + 1, // 22: chatto.api.v1.NotificationPolicyPreference.server_intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 1, // 23: chatto.api.v1.NotificationPolicyPreference.room_intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 1, // 24: chatto.api.v1.NotificationPolicyPreference.effective_intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 17, // 25: chatto.api.v1.GetNotificationPolicyResponse.preferences:type_name -> chatto.api.v1.NotificationPolicyPreference + 0, // 26: chatto.api.v1.SetNotificationPolicyPreferenceRequest.reason:type_name -> chatto.api.v1.NotificationReason + 1, // 27: chatto.api.v1.SetNotificationPolicyPreferenceRequest.intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 17, // 28: chatto.api.v1.SetNotificationPolicyPreferenceResponse.preferences:type_name -> chatto.api.v1.NotificationPolicyPreference + 6, // 29: chatto.api.v1.NotificationService.ListNotificationGroups:input_type -> chatto.api.v1.ListNotificationGroupsRequest + 9, // 30: chatto.api.v1.NotificationService.MarkNotificationRead:input_type -> chatto.api.v1.MarkNotificationReadRequest + 11, // 31: chatto.api.v1.NotificationService.DeleteNotificationOccurrence:input_type -> chatto.api.v1.DeleteNotificationOccurrenceRequest + 13, // 32: chatto.api.v1.NotificationService.DeleteNotificationGroup:input_type -> chatto.api.v1.DeleteNotificationGroupRequest + 15, // 33: chatto.api.v1.NotificationService.DeleteAllNotificationOccurrences:input_type -> chatto.api.v1.DeleteAllNotificationOccurrencesRequest + 18, // 34: chatto.api.v1.NotificationService.GetNotificationPolicy:input_type -> chatto.api.v1.GetNotificationPolicyRequest + 20, // 35: chatto.api.v1.NotificationService.SetNotificationPolicyPreference:input_type -> chatto.api.v1.SetNotificationPolicyPreferenceRequest + 7, // 36: chatto.api.v1.NotificationService.ListNotificationGroups:output_type -> chatto.api.v1.ListNotificationGroupsResponse + 10, // 37: chatto.api.v1.NotificationService.MarkNotificationRead:output_type -> chatto.api.v1.MarkNotificationReadResponse + 12, // 38: chatto.api.v1.NotificationService.DeleteNotificationOccurrence:output_type -> chatto.api.v1.DeleteNotificationOccurrenceResponse + 14, // 39: chatto.api.v1.NotificationService.DeleteNotificationGroup:output_type -> chatto.api.v1.DeleteNotificationGroupResponse + 16, // 40: chatto.api.v1.NotificationService.DeleteAllNotificationOccurrences:output_type -> chatto.api.v1.DeleteAllNotificationOccurrencesResponse + 19, // 41: chatto.api.v1.NotificationService.GetNotificationPolicy:output_type -> chatto.api.v1.GetNotificationPolicyResponse + 21, // 42: chatto.api.v1.NotificationService.SetNotificationPolicyPreference:output_type -> chatto.api.v1.SetNotificationPolicyPreferenceResponse + 36, // [36:43] is the sub-list for method output_type + 29, // [29:36] is the sub-list for method input_type + 29, // [29:29] is the sub-list for extension type_name + 29, // [29:29] is the sub-list for extension extendee + 0, // [0:29] is the sub-list for field type_name } func init() { file_chatto_api_v1_notifications_proto_init() } @@ -1386,25 +1604,23 @@ func file_chatto_api_v1_notifications_proto_init() { file_chatto_api_v1_rooms_proto_init() file_chatto_api_v1_users_proto_init() file_chatto_api_v1_notifications_proto_msgTypes[1].OneofWrappers = []any{} - file_chatto_api_v1_notifications_proto_msgTypes[2].OneofWrappers = []any{} - file_chatto_api_v1_notifications_proto_msgTypes[4].OneofWrappers = []any{ - (*NotificationItem_DirectMessage)(nil), - (*NotificationItem_Mention)(nil), - (*NotificationItem_Reply)(nil), - (*NotificationItem_RoomMessage)(nil), - } + file_chatto_api_v1_notifications_proto_msgTypes[16].OneofWrappers = []any{} + file_chatto_api_v1_notifications_proto_msgTypes[17].OneofWrappers = []any{} + file_chatto_api_v1_notifications_proto_msgTypes[18].OneofWrappers = []any{} + file_chatto_api_v1_notifications_proto_msgTypes[19].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chatto_api_v1_notifications_proto_rawDesc), len(file_chatto_api_v1_notifications_proto_rawDesc)), - NumEnums: 0, - NumMessages: 22, + NumEnums: 2, + NumMessages: 20, NumExtensions: 0, NumServices: 1, }, GoTypes: file_chatto_api_v1_notifications_proto_goTypes, DependencyIndexes: file_chatto_api_v1_notifications_proto_depIdxs, + EnumInfos: file_chatto_api_v1_notifications_proto_enumTypes, MessageInfos: file_chatto_api_v1_notifications_proto_msgTypes, }.Build() File_chatto_api_v1_notifications_proto = out.File diff --git a/cli/internal/pb/chatto/api/v1/viewer.pb.go b/cli/internal/pb/chatto/api/v1/viewer.pb.go index 5bdbcf599..49e9a588d 100644 --- a/cli/internal/pb/chatto/api/v1/viewer.pb.go +++ b/cli/internal/pb/chatto/api/v1/viewer.pb.go @@ -373,61 +373,6 @@ func (x *ServerViewerState) GetHasUnreadRooms() bool { return false } -// Room notification preference for one joined room. -type RoomNotificationPreference struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Room whose preference is represented. - RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - // Stored and effective notification preference. - Preference *NotificationPreference `protobuf:"bytes,4,opt,name=preference,proto3" json:"preference,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RoomNotificationPreference) Reset() { - *x = RoomNotificationPreference{} - mi := &file_chatto_api_v1_viewer_proto_msgTypes[5] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RoomNotificationPreference) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RoomNotificationPreference) ProtoMessage() {} - -func (x *RoomNotificationPreference) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_viewer_proto_msgTypes[5] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RoomNotificationPreference.ProtoReflect.Descriptor instead. -func (*RoomNotificationPreference) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_viewer_proto_rawDescGZIP(), []int{5} -} - -func (x *RoomNotificationPreference) GetRoomId() string { - if x != nil { - return x.RoomId - } - return "" -} - -func (x *RoomNotificationPreference) GetPreference() *NotificationPreference { - if x != nil { - return x.Preference - } - return nil -} - // Request for the authenticated viewer snapshot. type GetViewerRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -437,7 +382,7 @@ type GetViewerRequest struct { func (x *GetViewerRequest) Reset() { *x = GetViewerRequest{} - mi := &file_chatto_api_v1_viewer_proto_msgTypes[6] + mi := &file_chatto_api_v1_viewer_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -449,7 +394,7 @@ func (x *GetViewerRequest) String() string { func (*GetViewerRequest) ProtoMessage() {} func (x *GetViewerRequest) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_viewer_proto_msgTypes[6] + mi := &file_chatto_api_v1_viewer_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -462,7 +407,7 @@ func (x *GetViewerRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetViewerRequest.ProtoReflect.Descriptor instead. func (*GetViewerRequest) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_viewer_proto_rawDescGZIP(), []int{6} + return file_chatto_api_v1_viewer_proto_rawDescGZIP(), []int{5} } // Authenticated viewer snapshot needed by application shells. @@ -472,10 +417,6 @@ type GetViewerResponse struct { User *ViewerUser `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` // Permission-derived capabilities. Capabilities *ViewerCapabilities `protobuf:"bytes,2,opt,name=capabilities,proto3" json:"capabilities,omitempty"` - // Server-wide notification preference. - ServerNotificationPreference *NotificationPreference `protobuf:"bytes,3,opt,name=server_notification_preference,json=serverNotificationPreference,proto3" json:"server_notification_preference,omitempty"` - // Notification preferences for rooms the user participates in. - RoomNotificationPreferences []*RoomNotificationPreference `protobuf:"bytes,4,rep,name=room_notification_preferences,json=roomNotificationPreferences,proto3" json:"room_notification_preferences,omitempty"` // Effective server/channel permission decisions for the authenticated user. ViewerPermissions *ServerViewerPermissions `protobuf:"bytes,5,opt,name=viewer_permissions,json=viewerPermissions,proto3" json:"viewer_permissions,omitempty"` // Non-permission server state for the authenticated user. @@ -486,7 +427,7 @@ type GetViewerResponse struct { func (x *GetViewerResponse) Reset() { *x = GetViewerResponse{} - mi := &file_chatto_api_v1_viewer_proto_msgTypes[7] + mi := &file_chatto_api_v1_viewer_proto_msgTypes[6] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -498,7 +439,7 @@ func (x *GetViewerResponse) String() string { func (*GetViewerResponse) ProtoMessage() {} func (x *GetViewerResponse) ProtoReflect() protoreflect.Message { - mi := &file_chatto_api_v1_viewer_proto_msgTypes[7] + mi := &file_chatto_api_v1_viewer_proto_msgTypes[6] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -511,7 +452,7 @@ func (x *GetViewerResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetViewerResponse.ProtoReflect.Descriptor instead. func (*GetViewerResponse) Descriptor() ([]byte, []int) { - return file_chatto_api_v1_viewer_proto_rawDescGZIP(), []int{7} + return file_chatto_api_v1_viewer_proto_rawDescGZIP(), []int{6} } func (x *GetViewerResponse) GetUser() *ViewerUser { @@ -528,20 +469,6 @@ func (x *GetViewerResponse) GetCapabilities() *ViewerCapabilities { return nil } -func (x *GetViewerResponse) GetServerNotificationPreference() *NotificationPreference { - if x != nil { - return x.ServerNotificationPreference - } - return nil -} - -func (x *GetViewerResponse) GetRoomNotificationPreferences() []*RoomNotificationPreference { - if x != nil { - return x.RoomNotificationPreferences - } - return nil -} - func (x *GetViewerResponse) GetViewerPermissions() *ServerViewerPermissions { if x != nil { return x.ViewerPermissions @@ -560,7 +487,7 @@ var File_chatto_api_v1_viewer_proto protoreflect.FileDescriptor const file_chatto_api_v1_viewer_proto_rawDesc = "" + "\n" + - "\x1achatto/api/v1/viewer.proto\x12\rchatto.api.v1\x1a,chatto/api/v1/notification_preferences.proto\x1a\x1fchatto/api/v1/permissions.proto\x1a\x19chatto/api/v1/users.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"x\n" + + "\x1achatto/api/v1/viewer.proto\x12\rchatto.api.v1\x1a\x1fchatto/api/v1/permissions.proto\x1a\x19chatto/api/v1/users.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"x\n" + "\fUserSettings\x12\x1f\n" + "\btimezone\x18\x01 \x01(\tH\x00R\btimezone\x88\x01\x01\x12:\n" + "\vtime_format\x18\x02 \x01(\x0e2\x19.chatto.api.v1.TimeFormatR\n" + @@ -582,20 +509,13 @@ const file_chatto_api_v1_viewer_proto_rawDesc = "" + "\x17ServerViewerPermissions\x12@\n" + "\vpermissions\x18\x01 \x03(\v2\x1e.chatto.api.v1.PermissionGrantR\vpermissions\"=\n" + "\x11ServerViewerState\x12(\n" + - "\x10has_unread_rooms\x18\x01 \x01(\bR\x0ehasUnreadRooms\"\xa0\x01\n" + - "\x1aRoomNotificationPreference\x12\x17\n" + - "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12E\n" + - "\n" + - "preference\x18\x04 \x01(\v2%.chatto.api.v1.NotificationPreferenceR\n" + - "preferenceJ\x04\b\x02\x10\x03J\x04\b\x03\x10\x04R\x05levelR\x0feffective_level\"\x12\n" + - "\x10GetViewerRequest\"\x81\x04\n" + + "\x10has_unread_rooms\x18\x01 \x01(\bR\x0ehasUnreadRooms\"\x12\n" + + "\x10GetViewerRequest\"\xf0\x02\n" + "\x11GetViewerResponse\x12-\n" + "\x04user\x18\x01 \x01(\v2\x19.chatto.api.v1.ViewerUserR\x04user\x12E\n" + - "\fcapabilities\x18\x02 \x01(\v2!.chatto.api.v1.ViewerCapabilitiesR\fcapabilities\x12k\n" + - "\x1eserver_notification_preference\x18\x03 \x01(\v2%.chatto.api.v1.NotificationPreferenceR\x1cserverNotificationPreference\x12m\n" + - "\x1droom_notification_preferences\x18\x04 \x03(\v2).chatto.api.v1.RoomNotificationPreferenceR\x1broomNotificationPreferences\x12U\n" + + "\fcapabilities\x18\x02 \x01(\v2!.chatto.api.v1.ViewerCapabilitiesR\fcapabilities\x12U\n" + "\x12viewer_permissions\x18\x05 \x01(\v2&.chatto.api.v1.ServerViewerPermissionsR\x11viewerPermissions\x12C\n" + - "\fviewer_state\x18\x06 \x01(\v2 .chatto.api.v1.ServerViewerStateR\vviewerState*q\n" + + "\fviewer_state\x18\x06 \x01(\v2 .chatto.api.v1.ServerViewerStateR\vviewerStateJ\x04\b\x03\x10\x04J\x04\b\x04\x10\x05R\x1eserver_notification_preferenceR\x1droom_notification_preferences*q\n" + "\n" + "TimeFormat\x12\x1b\n" + "\x17TIME_FORMAT_UNSPECIFIED\x10\x00\x12\x14\n" + @@ -619,44 +539,39 @@ func file_chatto_api_v1_viewer_proto_rawDescGZIP() []byte { } var file_chatto_api_v1_viewer_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_chatto_api_v1_viewer_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_chatto_api_v1_viewer_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_chatto_api_v1_viewer_proto_goTypes = []any{ - (TimeFormat)(0), // 0: chatto.api.v1.TimeFormat - (*UserSettings)(nil), // 1: chatto.api.v1.UserSettings - (*ViewerUser)(nil), // 2: chatto.api.v1.ViewerUser - (*ViewerCapabilities)(nil), // 3: chatto.api.v1.ViewerCapabilities - (*ServerViewerPermissions)(nil), // 4: chatto.api.v1.ServerViewerPermissions - (*ServerViewerState)(nil), // 5: chatto.api.v1.ServerViewerState - (*RoomNotificationPreference)(nil), // 6: chatto.api.v1.RoomNotificationPreference - (*GetViewerRequest)(nil), // 7: chatto.api.v1.GetViewerRequest - (*GetViewerResponse)(nil), // 8: chatto.api.v1.GetViewerResponse - (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp - (*User)(nil), // 10: chatto.api.v1.User - (*CapabilityGrant)(nil), // 11: chatto.api.v1.CapabilityGrant - (*PermissionGrant)(nil), // 12: chatto.api.v1.PermissionGrant - (*NotificationPreference)(nil), // 13: chatto.api.v1.NotificationPreference + (TimeFormat)(0), // 0: chatto.api.v1.TimeFormat + (*UserSettings)(nil), // 1: chatto.api.v1.UserSettings + (*ViewerUser)(nil), // 2: chatto.api.v1.ViewerUser + (*ViewerCapabilities)(nil), // 3: chatto.api.v1.ViewerCapabilities + (*ServerViewerPermissions)(nil), // 4: chatto.api.v1.ServerViewerPermissions + (*ServerViewerState)(nil), // 5: chatto.api.v1.ServerViewerState + (*GetViewerRequest)(nil), // 6: chatto.api.v1.GetViewerRequest + (*GetViewerResponse)(nil), // 7: chatto.api.v1.GetViewerResponse + (*timestamppb.Timestamp)(nil), // 8: google.protobuf.Timestamp + (*User)(nil), // 9: chatto.api.v1.User + (*CapabilityGrant)(nil), // 10: chatto.api.v1.CapabilityGrant + (*PermissionGrant)(nil), // 11: chatto.api.v1.PermissionGrant } var file_chatto_api_v1_viewer_proto_depIdxs = []int32{ 0, // 0: chatto.api.v1.UserSettings.time_format:type_name -> chatto.api.v1.TimeFormat 1, // 1: chatto.api.v1.ViewerUser.settings:type_name -> chatto.api.v1.UserSettings - 9, // 2: chatto.api.v1.ViewerUser.last_login_change:type_name -> google.protobuf.Timestamp - 10, // 3: chatto.api.v1.ViewerUser.profile:type_name -> chatto.api.v1.User - 11, // 4: chatto.api.v1.ViewerCapabilities.grants:type_name -> chatto.api.v1.CapabilityGrant - 12, // 5: chatto.api.v1.ServerViewerPermissions.permissions:type_name -> chatto.api.v1.PermissionGrant - 13, // 6: chatto.api.v1.RoomNotificationPreference.preference:type_name -> chatto.api.v1.NotificationPreference - 2, // 7: chatto.api.v1.GetViewerResponse.user:type_name -> chatto.api.v1.ViewerUser - 3, // 8: chatto.api.v1.GetViewerResponse.capabilities:type_name -> chatto.api.v1.ViewerCapabilities - 13, // 9: chatto.api.v1.GetViewerResponse.server_notification_preference:type_name -> chatto.api.v1.NotificationPreference - 6, // 10: chatto.api.v1.GetViewerResponse.room_notification_preferences:type_name -> chatto.api.v1.RoomNotificationPreference - 4, // 11: chatto.api.v1.GetViewerResponse.viewer_permissions:type_name -> chatto.api.v1.ServerViewerPermissions - 5, // 12: chatto.api.v1.GetViewerResponse.viewer_state:type_name -> chatto.api.v1.ServerViewerState - 7, // 13: chatto.api.v1.ViewerService.GetViewer:input_type -> chatto.api.v1.GetViewerRequest - 8, // 14: chatto.api.v1.ViewerService.GetViewer:output_type -> chatto.api.v1.GetViewerResponse - 14, // [14:15] is the sub-list for method output_type - 13, // [13:14] is the sub-list for method input_type - 13, // [13:13] is the sub-list for extension type_name - 13, // [13:13] is the sub-list for extension extendee - 0, // [0:13] is the sub-list for field type_name + 8, // 2: chatto.api.v1.ViewerUser.last_login_change:type_name -> google.protobuf.Timestamp + 9, // 3: chatto.api.v1.ViewerUser.profile:type_name -> chatto.api.v1.User + 10, // 4: chatto.api.v1.ViewerCapabilities.grants:type_name -> chatto.api.v1.CapabilityGrant + 11, // 5: chatto.api.v1.ServerViewerPermissions.permissions:type_name -> chatto.api.v1.PermissionGrant + 2, // 6: chatto.api.v1.GetViewerResponse.user:type_name -> chatto.api.v1.ViewerUser + 3, // 7: chatto.api.v1.GetViewerResponse.capabilities:type_name -> chatto.api.v1.ViewerCapabilities + 4, // 8: chatto.api.v1.GetViewerResponse.viewer_permissions:type_name -> chatto.api.v1.ServerViewerPermissions + 5, // 9: chatto.api.v1.GetViewerResponse.viewer_state:type_name -> chatto.api.v1.ServerViewerState + 6, // 10: chatto.api.v1.ViewerService.GetViewer:input_type -> chatto.api.v1.GetViewerRequest + 7, // 11: chatto.api.v1.ViewerService.GetViewer:output_type -> chatto.api.v1.GetViewerResponse + 11, // [11:12] is the sub-list for method output_type + 10, // [10:11] is the sub-list for method input_type + 10, // [10:10] is the sub-list for extension type_name + 10, // [10:10] is the sub-list for extension extendee + 0, // [0:10] is the sub-list for field type_name } func init() { file_chatto_api_v1_viewer_proto_init() } @@ -664,7 +579,6 @@ func file_chatto_api_v1_viewer_proto_init() { if File_chatto_api_v1_viewer_proto != nil { return } - file_chatto_api_v1_notification_preferences_proto_init() file_chatto_api_v1_permissions_proto_init() file_chatto_api_v1_users_proto_init() file_chatto_api_v1_viewer_proto_msgTypes[0].OneofWrappers = []any{} @@ -674,7 +588,7 @@ func file_chatto_api_v1_viewer_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chatto_api_v1_viewer_proto_rawDesc), len(file_chatto_api_v1_viewer_proto_rawDesc)), NumEnums: 1, - NumMessages: 8, + NumMessages: 7, NumExtensions: 0, NumServices: 1, }, diff --git a/cli/internal/pb/chatto/core/v1/config_events.pb.go b/cli/internal/pb/chatto/core/v1/config_events.pb.go index 11ccfc654..bb4d469f9 100644 --- a/cli/internal/pb/chatto/core/v1/config_events.pb.go +++ b/cli/internal/pb/chatto/core/v1/config_events.pb.go @@ -593,6 +593,8 @@ func (x *UserTimeFormatClearedEvent) GetUserId() string { return "" } +// Legacy Notifications 1.0 fact retained only so existing EVT histories remain +// decodable. Current writers emit UserNotificationPreferenceChangedEvent. type UserServerNotificationLevelSetEvent struct { state protoimpl.MessageState `protogen:"open.v1"` UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` @@ -645,6 +647,7 @@ func (x *UserServerNotificationLevelSetEvent) GetLevel() NotificationLevel { return NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED } +// Legacy Notifications 1.0 fact retained only for historical replay decoding. type UserServerNotificationLevelClearedEvent struct { state protoimpl.MessageState `protogen:"open.v1"` UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` @@ -689,6 +692,7 @@ func (x *UserServerNotificationLevelClearedEvent) GetUserId() string { return "" } +// Legacy Notifications 1.0 fact retained only for historical replay decoding. type UserRoomNotificationLevelSetEvent struct { state protoimpl.MessageState `protogen:"open.v1"` UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` @@ -749,6 +753,7 @@ func (x *UserRoomNotificationLevelSetEvent) GetLevel() NotificationLevel { return NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED } +// Legacy Notifications 1.0 fact retained only for historical replay decoding. type UserRoomNotificationLevelClearedEvent struct { state protoimpl.MessageState `protogen:"open.v1"` UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` @@ -801,11 +806,81 @@ func (x *UserRoomNotificationLevelClearedEvent) GetRoomId() string { return "" } +// Records one server- or room-scoped notification policy override. An absent +// room_id selects server scope; an unspecified intensity clears the override. +type UserNotificationPreferenceChangedEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Reason NotificationReason `protobuf:"varint,2,opt,name=reason,proto3,enum=chatto.core.v1.NotificationReason" json:"reason,omitempty"` + Intensity NotificationDeliveryIntensity `protobuf:"varint,3,opt,name=intensity,proto3,enum=chatto.core.v1.NotificationDeliveryIntensity" json:"intensity,omitempty"` + RoomId *string `protobuf:"bytes,4,opt,name=room_id,json=roomId,proto3,oneof" json:"room_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserNotificationPreferenceChangedEvent) Reset() { + *x = UserNotificationPreferenceChangedEvent{} + mi := &file_chatto_core_v1_config_events_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserNotificationPreferenceChangedEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserNotificationPreferenceChangedEvent) ProtoMessage() {} + +func (x *UserNotificationPreferenceChangedEvent) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_config_events_proto_msgTypes[17] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserNotificationPreferenceChangedEvent.ProtoReflect.Descriptor instead. +func (*UserNotificationPreferenceChangedEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_config_events_proto_rawDescGZIP(), []int{17} +} + +func (x *UserNotificationPreferenceChangedEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *UserNotificationPreferenceChangedEvent) GetReason() NotificationReason { + if x != nil { + return x.Reason + } + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED +} + +func (x *UserNotificationPreferenceChangedEvent) GetIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.Intensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +func (x *UserNotificationPreferenceChangedEvent) GetRoomId() string { + if x != nil && x.RoomId != nil { + return *x.RoomId + } + return "" +} + var File_chatto_core_v1_config_events_proto protoreflect.FileDescriptor const file_chatto_core_v1_config_events_proto_rawDesc = "" + "\n" + - "\"chatto/core/v1/config_events.proto\x12\x0echatto.core.v1\x1a\x1bchatto/core/v1/models.proto\x1a%chatto/core/v1/user_preferences.proto\",\n" + + "\"chatto/core/v1/config_events.proto\x12\x0echatto.core.v1\x1a\x1bchatto/core/v1/models.proto\x1a!chatto/core/v1/notification.proto\x1a%chatto/core/v1/user_preferences.proto\",\n" + "\x16ServerNameChangedEvent\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"A\n" + "\x1dServerDescriptionChangedEvent\x12 \n" + @@ -844,7 +919,14 @@ const file_chatto_core_v1_config_events_proto_rawDesc = "" + "\x05level\x18\x03 \x01(\x0e2!.chatto.core.v1.NotificationLevelR\x05level\"Y\n" + "%UserRoomNotificationLevelClearedEvent\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x17\n" + - "\aroom_id\x18\x02 \x01(\tR\x06roomIdB\xb4\x01\n" + + "\aroom_id\x18\x02 \x01(\tR\x06roomId\"\xf4\x01\n" + + "&UserNotificationPreferenceChangedEvent\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12:\n" + + "\x06reason\x18\x02 \x01(\x0e2\".chatto.core.v1.NotificationReasonR\x06reason\x12K\n" + + "\tintensity\x18\x03 \x01(\x0e2-.chatto.core.v1.NotificationDeliveryIntensityR\tintensity\x12\x1c\n" + + "\aroom_id\x18\x04 \x01(\tH\x00R\x06roomId\x88\x01\x01B\n" + + "\n" + + "\b_room_idB\xb4\x01\n" + "\x12com.chatto.core.v1B\x11ConfigEventsProtoP\x01Z1hmans.de/chatto/internal/pb/chatto/core/v1;corev1\xa2\x02\x03CCX\xaa\x02\x0eChatto.Core.V1\xca\x02\x0eChatto\\Core\\V1\xe2\x02\x1aChatto\\Core\\V1\\GPBMetadata\xea\x02\x10Chatto::Core::V1b\x06proto3" var ( @@ -859,7 +941,7 @@ func file_chatto_core_v1_config_events_proto_rawDescGZIP() []byte { return file_chatto_core_v1_config_events_proto_rawDescData } -var file_chatto_core_v1_config_events_proto_msgTypes = make([]protoimpl.MessageInfo, 17) +var file_chatto_core_v1_config_events_proto_msgTypes = make([]protoimpl.MessageInfo, 18) var file_chatto_core_v1_config_events_proto_goTypes = []any{ (*ServerNameChangedEvent)(nil), // 0: chatto.core.v1.ServerNameChangedEvent (*ServerDescriptionChangedEvent)(nil), // 1: chatto.core.v1.ServerDescriptionChangedEvent @@ -878,21 +960,26 @@ var file_chatto_core_v1_config_events_proto_goTypes = []any{ (*UserServerNotificationLevelClearedEvent)(nil), // 14: chatto.core.v1.UserServerNotificationLevelClearedEvent (*UserRoomNotificationLevelSetEvent)(nil), // 15: chatto.core.v1.UserRoomNotificationLevelSetEvent (*UserRoomNotificationLevelClearedEvent)(nil), // 16: chatto.core.v1.UserRoomNotificationLevelClearedEvent - (*AssetRecord)(nil), // 17: chatto.core.v1.AssetRecord - (TimeFormat)(0), // 18: chatto.core.v1.TimeFormat - (NotificationLevel)(0), // 19: chatto.core.v1.NotificationLevel + (*UserNotificationPreferenceChangedEvent)(nil), // 17: chatto.core.v1.UserNotificationPreferenceChangedEvent + (*AssetRecord)(nil), // 18: chatto.core.v1.AssetRecord + (TimeFormat)(0), // 19: chatto.core.v1.TimeFormat + (NotificationLevel)(0), // 20: chatto.core.v1.NotificationLevel + (NotificationReason)(0), // 21: chatto.core.v1.NotificationReason + (NotificationDeliveryIntensity)(0), // 22: chatto.core.v1.NotificationDeliveryIntensity } var file_chatto_core_v1_config_events_proto_depIdxs = []int32{ - 17, // 0: chatto.core.v1.ServerLogoSetEvent.asset:type_name -> chatto.core.v1.AssetRecord - 17, // 1: chatto.core.v1.ServerBannerSetEvent.asset:type_name -> chatto.core.v1.AssetRecord - 18, // 2: chatto.core.v1.UserTimeFormatChangedEvent.time_format:type_name -> chatto.core.v1.TimeFormat - 19, // 3: chatto.core.v1.UserServerNotificationLevelSetEvent.level:type_name -> chatto.core.v1.NotificationLevel - 19, // 4: chatto.core.v1.UserRoomNotificationLevelSetEvent.level:type_name -> chatto.core.v1.NotificationLevel - 5, // [5:5] is the sub-list for method output_type - 5, // [5:5] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 18, // 0: chatto.core.v1.ServerLogoSetEvent.asset:type_name -> chatto.core.v1.AssetRecord + 18, // 1: chatto.core.v1.ServerBannerSetEvent.asset:type_name -> chatto.core.v1.AssetRecord + 19, // 2: chatto.core.v1.UserTimeFormatChangedEvent.time_format:type_name -> chatto.core.v1.TimeFormat + 20, // 3: chatto.core.v1.UserServerNotificationLevelSetEvent.level:type_name -> chatto.core.v1.NotificationLevel + 20, // 4: chatto.core.v1.UserRoomNotificationLevelSetEvent.level:type_name -> chatto.core.v1.NotificationLevel + 21, // 5: chatto.core.v1.UserNotificationPreferenceChangedEvent.reason:type_name -> chatto.core.v1.NotificationReason + 22, // 6: chatto.core.v1.UserNotificationPreferenceChangedEvent.intensity:type_name -> chatto.core.v1.NotificationDeliveryIntensity + 7, // [7:7] is the sub-list for method output_type + 7, // [7:7] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_chatto_core_v1_config_events_proto_init() } @@ -901,14 +988,16 @@ func file_chatto_core_v1_config_events_proto_init() { return } file_chatto_core_v1_models_proto_init() + file_chatto_core_v1_notification_proto_init() file_chatto_core_v1_user_preferences_proto_init() + file_chatto_core_v1_config_events_proto_msgTypes[17].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chatto_core_v1_config_events_proto_rawDesc), len(file_chatto_core_v1_config_events_proto_rawDesc)), NumEnums: 0, - NumMessages: 17, + NumMessages: 18, NumExtensions: 0, NumServices: 0, }, diff --git a/cli/internal/pb/chatto/core/v1/event.pb.go b/cli/internal/pb/chatto/core/v1/event.pb.go index 14ffca9e4..c2d90a8d5 100644 --- a/cli/internal/pb/chatto/core/v1/event.pb.go +++ b/cli/internal/pb/chatto/core/v1/event.pb.go @@ -101,6 +101,7 @@ type Event struct { // *Event_UserServerNotificationLevelCleared // *Event_UserRoomNotificationLevelSet // *Event_UserRoomNotificationLevelCleared + // *Event_UserNotificationPreferenceChanged // *Event_RoomGroupCreated // *Event_RoomGroupUpdated // *Event_RoomGroupDeleted @@ -599,6 +600,7 @@ func (x *Event) GetUserTimeFormatCleared() *UserTimeFormatClearedEvent { return nil } +// Deprecated: Marked as deprecated in chatto/core/v1/event.proto. func (x *Event) GetUserServerNotificationLevelSet() *UserServerNotificationLevelSetEvent { if x != nil { if x, ok := x.Event.(*Event_UserServerNotificationLevelSet); ok { @@ -608,6 +610,7 @@ func (x *Event) GetUserServerNotificationLevelSet() *UserServerNotificationLevel return nil } +// Deprecated: Marked as deprecated in chatto/core/v1/event.proto. func (x *Event) GetUserServerNotificationLevelCleared() *UserServerNotificationLevelClearedEvent { if x != nil { if x, ok := x.Event.(*Event_UserServerNotificationLevelCleared); ok { @@ -617,6 +620,7 @@ func (x *Event) GetUserServerNotificationLevelCleared() *UserServerNotificationL return nil } +// Deprecated: Marked as deprecated in chatto/core/v1/event.proto. func (x *Event) GetUserRoomNotificationLevelSet() *UserRoomNotificationLevelSetEvent { if x != nil { if x, ok := x.Event.(*Event_UserRoomNotificationLevelSet); ok { @@ -626,6 +630,7 @@ func (x *Event) GetUserRoomNotificationLevelSet() *UserRoomNotificationLevelSetE return nil } +// Deprecated: Marked as deprecated in chatto/core/v1/event.proto. func (x *Event) GetUserRoomNotificationLevelCleared() *UserRoomNotificationLevelClearedEvent { if x != nil { if x, ok := x.Event.(*Event_UserRoomNotificationLevelCleared); ok { @@ -635,6 +640,15 @@ func (x *Event) GetUserRoomNotificationLevelCleared() *UserRoomNotificationLevel return nil } +func (x *Event) GetUserNotificationPreferenceChanged() *UserNotificationPreferenceChangedEvent { + if x != nil { + if x, ok := x.Event.(*Event_UserNotificationPreferenceChanged); ok { + return x.UserNotificationPreferenceChanged + } + } + return nil +} + func (x *Event) GetRoomGroupCreated() *RoomGroupCreatedEvent { if x != nil { if x, ok := x.Event.(*Event_RoomGroupCreated); ok { @@ -1417,21 +1431,32 @@ type Event_UserTimeFormatCleared struct { } type Event_UserServerNotificationLevelSet struct { + // Legacy Notifications 1.0 variants retained only to decode EVT history. + // Current code never writes them. + // + // Deprecated: Marked as deprecated in chatto/core/v1/event.proto. UserServerNotificationLevelSet *UserServerNotificationLevelSetEvent `protobuf:"bytes,514,opt,name=user_server_notification_level_set,json=userServerNotificationLevelSet,proto3,oneof"` } type Event_UserServerNotificationLevelCleared struct { + // Deprecated: Marked as deprecated in chatto/core/v1/event.proto. UserServerNotificationLevelCleared *UserServerNotificationLevelClearedEvent `protobuf:"bytes,515,opt,name=user_server_notification_level_cleared,json=userServerNotificationLevelCleared,proto3,oneof"` } type Event_UserRoomNotificationLevelSet struct { + // Deprecated: Marked as deprecated in chatto/core/v1/event.proto. UserRoomNotificationLevelSet *UserRoomNotificationLevelSetEvent `protobuf:"bytes,516,opt,name=user_room_notification_level_set,json=userRoomNotificationLevelSet,proto3,oneof"` } type Event_UserRoomNotificationLevelCleared struct { + // Deprecated: Marked as deprecated in chatto/core/v1/event.proto. UserRoomNotificationLevelCleared *UserRoomNotificationLevelClearedEvent `protobuf:"bytes,517,opt,name=user_room_notification_level_cleared,json=userRoomNotificationLevelCleared,proto3,oneof"` } +type Event_UserNotificationPreferenceChanged struct { + UserNotificationPreferenceChanged *UserNotificationPreferenceChangedEvent `protobuf:"bytes,518,opt,name=user_notification_preference_changed,json=userNotificationPreferenceChanged,proto3,oneof"` +} + type Event_RoomGroupCreated struct { // ----- Room groups (600-609, durable, evt.group.{groupId}) ----- // The group aggregate owns its room-membership AND room-ordering. @@ -1810,6 +1835,8 @@ func (*Event_UserRoomNotificationLevelSet) isEvent_Event() {} func (*Event_UserRoomNotificationLevelCleared) isEvent_Event() {} +func (*Event_UserNotificationPreferenceChanged) isEvent_Event() {} + func (*Event_RoomGroupCreated) isEvent_Event() {} func (*Event_RoomGroupUpdated) isEvent_Event() {} @@ -1946,7 +1973,7 @@ var File_chatto_core_v1_event_proto protoreflect.FileDescriptor const file_chatto_core_v1_event_proto_rawDesc = "" + "\n" + - "\x1achatto/core/v1/event.proto\x12\x0echatto.core.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a chatto/core/v1/auth_events.proto\x1a)chatto/core/v1/authorization_events.proto\x1a!chatto/core/v1/asset_events.proto\x1a#chatto/core/v1/message_events.proto\x1a&chatto/core/v1/moderation_events.proto\x1a chatto/core/v1/rbac_events.proto\x1a$chatto/core/v1/reaction_events.proto\x1a chatto/core/v1/room_events.proto\x1a&chatto/core/v1/room_group_events.proto\x1a\"chatto/core/v1/config_events.proto\x1a\"chatto/core/v1/thread_events.proto\x1a chatto/core/v1/user_events.proto\x1a&chatto/core/v1/invitation_events.proto\"\x9f[\n" + + "\x1achatto/core/v1/event.proto\x12\x0echatto.core.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a chatto/core/v1/auth_events.proto\x1a)chatto/core/v1/authorization_events.proto\x1a!chatto/core/v1/asset_events.proto\x1a#chatto/core/v1/message_events.proto\x1a&chatto/core/v1/moderation_events.proto\x1a!chatto/core/v1/notification.proto\x1a chatto/core/v1/rbac_events.proto\x1a$chatto/core/v1/reaction_events.proto\x1a chatto/core/v1/room_events.proto\x1a&chatto/core/v1/room_group_events.proto\x1a\"chatto/core/v1/config_events.proto\x1a\"chatto/core/v1/thread_events.proto\x1a chatto/core/v1/user_events.proto\x1a&chatto/core/v1/invitation_events.proto\"\xbd\\\n" + "\x05Event\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x129\n" + "\n" + @@ -1992,11 +2019,12 @@ const file_chatto_core_v1_event_proto_rawDesc = "" + "\x15user_timezone_changed\x18\xfe\x03 \x01(\v2(.chatto.core.v1.UserTimezoneChangedEventH\x00R\x13userTimezoneChanged\x12_\n" + "\x15user_timezone_cleared\x18\xff\x03 \x01(\v2(.chatto.core.v1.UserTimezoneClearedEventH\x00R\x13userTimezoneCleared\x12f\n" + "\x18user_time_format_changed\x18\x80\x04 \x01(\v2*.chatto.core.v1.UserTimeFormatChangedEventH\x00R\x15userTimeFormatChanged\x12f\n" + - "\x18user_time_format_cleared\x18\x81\x04 \x01(\v2*.chatto.core.v1.UserTimeFormatClearedEventH\x00R\x15userTimeFormatCleared\x12\x82\x01\n" + - "\"user_server_notification_level_set\x18\x82\x04 \x01(\v23.chatto.core.v1.UserServerNotificationLevelSetEventH\x00R\x1euserServerNotificationLevelSet\x12\x8e\x01\n" + - "&user_server_notification_level_cleared\x18\x83\x04 \x01(\v27.chatto.core.v1.UserServerNotificationLevelClearedEventH\x00R\"userServerNotificationLevelCleared\x12|\n" + - " user_room_notification_level_set\x18\x84\x04 \x01(\v21.chatto.core.v1.UserRoomNotificationLevelSetEventH\x00R\x1cuserRoomNotificationLevelSet\x12\x88\x01\n" + - "$user_room_notification_level_cleared\x18\x85\x04 \x01(\v25.chatto.core.v1.UserRoomNotificationLevelClearedEventH\x00R userRoomNotificationLevelCleared\x12V\n" + + "\x18user_time_format_cleared\x18\x81\x04 \x01(\v2*.chatto.core.v1.UserTimeFormatClearedEventH\x00R\x15userTimeFormatCleared\x12\x86\x01\n" + + "\"user_server_notification_level_set\x18\x82\x04 \x01(\v23.chatto.core.v1.UserServerNotificationLevelSetEventB\x02\x18\x01H\x00R\x1euserServerNotificationLevelSet\x12\x92\x01\n" + + "&user_server_notification_level_cleared\x18\x83\x04 \x01(\v27.chatto.core.v1.UserServerNotificationLevelClearedEventB\x02\x18\x01H\x00R\"userServerNotificationLevelCleared\x12\x80\x01\n" + + " user_room_notification_level_set\x18\x84\x04 \x01(\v21.chatto.core.v1.UserRoomNotificationLevelSetEventB\x02\x18\x01H\x00R\x1cuserRoomNotificationLevelSet\x12\x8c\x01\n" + + "$user_room_notification_level_cleared\x18\x85\x04 \x01(\v25.chatto.core.v1.UserRoomNotificationLevelClearedEventB\x02\x18\x01H\x00R userRoomNotificationLevelCleared\x12\x8a\x01\n" + + "$user_notification_preference_changed\x18\x86\x04 \x01(\v26.chatto.core.v1.UserNotificationPreferenceChangedEventH\x00R!userNotificationPreferenceChanged\x12V\n" + "\x12room_group_created\x18\xd8\x04 \x01(\v2%.chatto.core.v1.RoomGroupCreatedEventH\x00R\x10roomGroupCreated\x12V\n" + "\x12room_group_updated\x18\xd9\x04 \x01(\v2%.chatto.core.v1.RoomGroupUpdatedEventH\x00R\x10roomGroupUpdated\x12V\n" + "\x12room_group_deleted\x18\xda\x04 \x01(\v2%.chatto.core.v1.RoomGroupDeletedEventH\x00R\x10roomGroupDeleted\x12W\n" + @@ -2128,72 +2156,73 @@ var file_chatto_core_v1_event_proto_goTypes = []any{ (*UserServerNotificationLevelClearedEvent)(nil), // 44: chatto.core.v1.UserServerNotificationLevelClearedEvent (*UserRoomNotificationLevelSetEvent)(nil), // 45: chatto.core.v1.UserRoomNotificationLevelSetEvent (*UserRoomNotificationLevelClearedEvent)(nil), // 46: chatto.core.v1.UserRoomNotificationLevelClearedEvent - (*RoomGroupCreatedEvent)(nil), // 47: chatto.core.v1.RoomGroupCreatedEvent - (*RoomGroupUpdatedEvent)(nil), // 48: chatto.core.v1.RoomGroupUpdatedEvent - (*RoomGroupDeletedEvent)(nil), // 49: chatto.core.v1.RoomGroupDeletedEvent - (*RoomAddedToGroupEvent)(nil), // 50: chatto.core.v1.RoomAddedToGroupEvent - (*RoomRemovedFromGroupEvent)(nil), // 51: chatto.core.v1.RoomRemovedFromGroupEvent - (*RoomsInGroupReorderedEvent)(nil), // 52: chatto.core.v1.RoomsInGroupReorderedEvent - (*SidebarLinkAddedToGroupEvent)(nil), // 53: chatto.core.v1.SidebarLinkAddedToGroupEvent - (*SidebarLinkUpdatedEvent)(nil), // 54: chatto.core.v1.SidebarLinkUpdatedEvent - (*SidebarLinkRemovedFromGroupEvent)(nil), // 55: chatto.core.v1.SidebarLinkRemovedFromGroupEvent - (*SidebarGroupEntriesReorderedEvent)(nil), // 56: chatto.core.v1.SidebarGroupEntriesReorderedEvent - (*RoomGroupsReorderedEvent)(nil), // 57: chatto.core.v1.RoomGroupsReorderedEvent - (*UserAccountCreatedEvent)(nil), // 58: chatto.core.v1.UserAccountCreatedEvent - (*UserLoginChangedEvent)(nil), // 59: chatto.core.v1.UserLoginChangedEvent - (*UserDisplayNameChangedEvent)(nil), // 60: chatto.core.v1.UserDisplayNameChangedEvent - (*UserAvatarSetEvent)(nil), // 61: chatto.core.v1.UserAvatarSetEvent - (*UserAvatarClearedEvent)(nil), // 62: chatto.core.v1.UserAvatarClearedEvent - (*UserVerifiedEmailAddedEvent)(nil), // 63: chatto.core.v1.UserVerifiedEmailAddedEvent - (*UserPasswordHashChangedEvent)(nil), // 64: chatto.core.v1.UserPasswordHashChangedEvent - (*UserOIDCSubjectLinkedEvent)(nil), // 65: chatto.core.v1.UserOIDCSubjectLinkedEvent - (*UserServerPreferencesChangedEvent)(nil), // 66: chatto.core.v1.UserServerPreferencesChangedEvent - (*UserLoginCooldownClearedEvent)(nil), // 67: chatto.core.v1.UserLoginCooldownClearedEvent - (*UserAccountDeletedEvent)(nil), // 68: chatto.core.v1.UserAccountDeletedEvent - (*UserLoginCooldownStartedEvent)(nil), // 69: chatto.core.v1.UserLoginCooldownStartedEvent - (*UserKeyShreddedEvent)(nil), // 70: chatto.core.v1.UserKeyShreddedEvent - (*UserDEKGeneratedEvent)(nil), // 71: chatto.core.v1.UserDEKGeneratedEvent - (*UserExternalIdentityLinkedEvent)(nil), // 72: chatto.core.v1.UserExternalIdentityLinkedEvent - (*UserCustomStatusSetEvent)(nil), // 73: chatto.core.v1.UserCustomStatusSetEvent - (*UserCustomStatusClearedEvent)(nil), // 74: chatto.core.v1.UserCustomStatusClearedEvent - (*UserExternalIdentityUnlinkedEvent)(nil), // 75: chatto.core.v1.UserExternalIdentityUnlinkedEvent - (*UserKeyShreddingRequestedEvent)(nil), // 76: chatto.core.v1.UserKeyShreddingRequestedEvent - (*RbacRoleCreatedEvent)(nil), // 77: chatto.core.v1.RbacRoleCreatedEvent - (*RbacRoleDisplayNameChangedEvent)(nil), // 78: chatto.core.v1.RbacRoleDisplayNameChangedEvent - (*RbacRoleDescriptionChangedEvent)(nil), // 79: chatto.core.v1.RbacRoleDescriptionChangedEvent - (*RbacRoleDeletedEvent)(nil), // 80: chatto.core.v1.RbacRoleDeletedEvent - (*RbacRolesReorderedEvent)(nil), // 81: chatto.core.v1.RbacRolesReorderedEvent - (*RbacRoleAssignedEvent)(nil), // 82: chatto.core.v1.RbacRoleAssignedEvent - (*RbacRoleRevokedEvent)(nil), // 83: chatto.core.v1.RbacRoleRevokedEvent - (*RbacPermissionGrantedEvent)(nil), // 84: chatto.core.v1.RbacPermissionGrantedEvent - (*RbacPermissionDeniedEvent)(nil), // 85: chatto.core.v1.RbacPermissionDeniedEvent - (*RbacPermissionClearedEvent)(nil), // 86: chatto.core.v1.RbacPermissionClearedEvent - (*RbacRolePingableChangedEvent)(nil), // 87: chatto.core.v1.RbacRolePingableChangedEvent - (*AuthorizationFenceAdvancedEvent)(nil), // 88: chatto.core.v1.AuthorizationFenceAdvancedEvent - (*RoomMemberBannedEvent)(nil), // 89: chatto.core.v1.RoomMemberBannedEvent - (*RoomMemberUnbannedEvent)(nil), // 90: chatto.core.v1.RoomMemberUnbannedEvent - (*RoomMemberAddedEvent)(nil), // 91: chatto.core.v1.RoomMemberAddedEvent - (*RoomMemberRemovedEvent)(nil), // 92: chatto.core.v1.RoomMemberRemovedEvent - (*RegistrationVerificationCodeIssuedEvent)(nil), // 93: chatto.core.v1.RegistrationVerificationCodeIssuedEvent - (*EmailVerificationCodeIssuedEvent)(nil), // 94: chatto.core.v1.EmailVerificationCodeIssuedEvent - (*PasswordResetLinkIssuedEvent)(nil), // 95: chatto.core.v1.PasswordResetLinkIssuedEvent - (*AccountDeletionConfirmationIssuedEvent)(nil), // 96: chatto.core.v1.AccountDeletionConfirmationIssuedEvent - (*PasswordResetCompletedEvent)(nil), // 97: chatto.core.v1.PasswordResetCompletedEvent - (*LoginSucceededEvent)(nil), // 98: chatto.core.v1.LoginSucceededEvent - (*LoginFailedEvent)(nil), // 99: chatto.core.v1.LoginFailedEvent - (*LogoutSucceededEvent)(nil), // 100: chatto.core.v1.LogoutSucceededEvent - (*AuthCodeIssuedEvent)(nil), // 101: chatto.core.v1.AuthCodeIssuedEvent - (*AuthCodeExchangeSucceededEvent)(nil), // 102: chatto.core.v1.AuthCodeExchangeSucceededEvent - (*AuthCodeExchangeFailedEvent)(nil), // 103: chatto.core.v1.AuthCodeExchangeFailedEvent - (*BearerTokenIssuedEvent)(nil), // 104: chatto.core.v1.BearerTokenIssuedEvent - (*BearerTokenRevokedEvent)(nil), // 105: chatto.core.v1.BearerTokenRevokedEvent - (*OAuthConsentGrantedEvent)(nil), // 106: chatto.core.v1.OAuthConsentGrantedEvent - (*OAuthConsentDeniedEvent)(nil), // 107: chatto.core.v1.OAuthConsentDeniedEvent - (*InvitationCreatedEvent)(nil), // 108: chatto.core.v1.InvitationCreatedEvent - (*InvitationRedeemedEvent)(nil), // 109: chatto.core.v1.InvitationRedeemedEvent - (*InvitationRevokedEvent)(nil), // 110: chatto.core.v1.InvitationRevokedEvent - (*ReactionAddedEvent)(nil), // 111: chatto.core.v1.ReactionAddedEvent - (*ReactionRemovedEvent)(nil), // 112: chatto.core.v1.ReactionRemovedEvent + (*UserNotificationPreferenceChangedEvent)(nil), // 47: chatto.core.v1.UserNotificationPreferenceChangedEvent + (*RoomGroupCreatedEvent)(nil), // 48: chatto.core.v1.RoomGroupCreatedEvent + (*RoomGroupUpdatedEvent)(nil), // 49: chatto.core.v1.RoomGroupUpdatedEvent + (*RoomGroupDeletedEvent)(nil), // 50: chatto.core.v1.RoomGroupDeletedEvent + (*RoomAddedToGroupEvent)(nil), // 51: chatto.core.v1.RoomAddedToGroupEvent + (*RoomRemovedFromGroupEvent)(nil), // 52: chatto.core.v1.RoomRemovedFromGroupEvent + (*RoomsInGroupReorderedEvent)(nil), // 53: chatto.core.v1.RoomsInGroupReorderedEvent + (*SidebarLinkAddedToGroupEvent)(nil), // 54: chatto.core.v1.SidebarLinkAddedToGroupEvent + (*SidebarLinkUpdatedEvent)(nil), // 55: chatto.core.v1.SidebarLinkUpdatedEvent + (*SidebarLinkRemovedFromGroupEvent)(nil), // 56: chatto.core.v1.SidebarLinkRemovedFromGroupEvent + (*SidebarGroupEntriesReorderedEvent)(nil), // 57: chatto.core.v1.SidebarGroupEntriesReorderedEvent + (*RoomGroupsReorderedEvent)(nil), // 58: chatto.core.v1.RoomGroupsReorderedEvent + (*UserAccountCreatedEvent)(nil), // 59: chatto.core.v1.UserAccountCreatedEvent + (*UserLoginChangedEvent)(nil), // 60: chatto.core.v1.UserLoginChangedEvent + (*UserDisplayNameChangedEvent)(nil), // 61: chatto.core.v1.UserDisplayNameChangedEvent + (*UserAvatarSetEvent)(nil), // 62: chatto.core.v1.UserAvatarSetEvent + (*UserAvatarClearedEvent)(nil), // 63: chatto.core.v1.UserAvatarClearedEvent + (*UserVerifiedEmailAddedEvent)(nil), // 64: chatto.core.v1.UserVerifiedEmailAddedEvent + (*UserPasswordHashChangedEvent)(nil), // 65: chatto.core.v1.UserPasswordHashChangedEvent + (*UserOIDCSubjectLinkedEvent)(nil), // 66: chatto.core.v1.UserOIDCSubjectLinkedEvent + (*UserServerPreferencesChangedEvent)(nil), // 67: chatto.core.v1.UserServerPreferencesChangedEvent + (*UserLoginCooldownClearedEvent)(nil), // 68: chatto.core.v1.UserLoginCooldownClearedEvent + (*UserAccountDeletedEvent)(nil), // 69: chatto.core.v1.UserAccountDeletedEvent + (*UserLoginCooldownStartedEvent)(nil), // 70: chatto.core.v1.UserLoginCooldownStartedEvent + (*UserKeyShreddedEvent)(nil), // 71: chatto.core.v1.UserKeyShreddedEvent + (*UserDEKGeneratedEvent)(nil), // 72: chatto.core.v1.UserDEKGeneratedEvent + (*UserExternalIdentityLinkedEvent)(nil), // 73: chatto.core.v1.UserExternalIdentityLinkedEvent + (*UserCustomStatusSetEvent)(nil), // 74: chatto.core.v1.UserCustomStatusSetEvent + (*UserCustomStatusClearedEvent)(nil), // 75: chatto.core.v1.UserCustomStatusClearedEvent + (*UserExternalIdentityUnlinkedEvent)(nil), // 76: chatto.core.v1.UserExternalIdentityUnlinkedEvent + (*UserKeyShreddingRequestedEvent)(nil), // 77: chatto.core.v1.UserKeyShreddingRequestedEvent + (*RbacRoleCreatedEvent)(nil), // 78: chatto.core.v1.RbacRoleCreatedEvent + (*RbacRoleDisplayNameChangedEvent)(nil), // 79: chatto.core.v1.RbacRoleDisplayNameChangedEvent + (*RbacRoleDescriptionChangedEvent)(nil), // 80: chatto.core.v1.RbacRoleDescriptionChangedEvent + (*RbacRoleDeletedEvent)(nil), // 81: chatto.core.v1.RbacRoleDeletedEvent + (*RbacRolesReorderedEvent)(nil), // 82: chatto.core.v1.RbacRolesReorderedEvent + (*RbacRoleAssignedEvent)(nil), // 83: chatto.core.v1.RbacRoleAssignedEvent + (*RbacRoleRevokedEvent)(nil), // 84: chatto.core.v1.RbacRoleRevokedEvent + (*RbacPermissionGrantedEvent)(nil), // 85: chatto.core.v1.RbacPermissionGrantedEvent + (*RbacPermissionDeniedEvent)(nil), // 86: chatto.core.v1.RbacPermissionDeniedEvent + (*RbacPermissionClearedEvent)(nil), // 87: chatto.core.v1.RbacPermissionClearedEvent + (*RbacRolePingableChangedEvent)(nil), // 88: chatto.core.v1.RbacRolePingableChangedEvent + (*AuthorizationFenceAdvancedEvent)(nil), // 89: chatto.core.v1.AuthorizationFenceAdvancedEvent + (*RoomMemberBannedEvent)(nil), // 90: chatto.core.v1.RoomMemberBannedEvent + (*RoomMemberUnbannedEvent)(nil), // 91: chatto.core.v1.RoomMemberUnbannedEvent + (*RoomMemberAddedEvent)(nil), // 92: chatto.core.v1.RoomMemberAddedEvent + (*RoomMemberRemovedEvent)(nil), // 93: chatto.core.v1.RoomMemberRemovedEvent + (*RegistrationVerificationCodeIssuedEvent)(nil), // 94: chatto.core.v1.RegistrationVerificationCodeIssuedEvent + (*EmailVerificationCodeIssuedEvent)(nil), // 95: chatto.core.v1.EmailVerificationCodeIssuedEvent + (*PasswordResetLinkIssuedEvent)(nil), // 96: chatto.core.v1.PasswordResetLinkIssuedEvent + (*AccountDeletionConfirmationIssuedEvent)(nil), // 97: chatto.core.v1.AccountDeletionConfirmationIssuedEvent + (*PasswordResetCompletedEvent)(nil), // 98: chatto.core.v1.PasswordResetCompletedEvent + (*LoginSucceededEvent)(nil), // 99: chatto.core.v1.LoginSucceededEvent + (*LoginFailedEvent)(nil), // 100: chatto.core.v1.LoginFailedEvent + (*LogoutSucceededEvent)(nil), // 101: chatto.core.v1.LogoutSucceededEvent + (*AuthCodeIssuedEvent)(nil), // 102: chatto.core.v1.AuthCodeIssuedEvent + (*AuthCodeExchangeSucceededEvent)(nil), // 103: chatto.core.v1.AuthCodeExchangeSucceededEvent + (*AuthCodeExchangeFailedEvent)(nil), // 104: chatto.core.v1.AuthCodeExchangeFailedEvent + (*BearerTokenIssuedEvent)(nil), // 105: chatto.core.v1.BearerTokenIssuedEvent + (*BearerTokenRevokedEvent)(nil), // 106: chatto.core.v1.BearerTokenRevokedEvent + (*OAuthConsentGrantedEvent)(nil), // 107: chatto.core.v1.OAuthConsentGrantedEvent + (*OAuthConsentDeniedEvent)(nil), // 108: chatto.core.v1.OAuthConsentDeniedEvent + (*InvitationCreatedEvent)(nil), // 109: chatto.core.v1.InvitationCreatedEvent + (*InvitationRedeemedEvent)(nil), // 110: chatto.core.v1.InvitationRedeemedEvent + (*InvitationRevokedEvent)(nil), // 111: chatto.core.v1.InvitationRevokedEvent + (*ReactionAddedEvent)(nil), // 112: chatto.core.v1.ReactionAddedEvent + (*ReactionRemovedEvent)(nil), // 113: chatto.core.v1.ReactionRemovedEvent } var file_chatto_core_v1_event_proto_depIdxs = []int32{ 1, // 0: chatto.core.v1.Event.created_at:type_name -> google.protobuf.Timestamp @@ -2242,77 +2271,78 @@ var file_chatto_core_v1_event_proto_depIdxs = []int32{ 44, // 43: chatto.core.v1.Event.user_server_notification_level_cleared:type_name -> chatto.core.v1.UserServerNotificationLevelClearedEvent 45, // 44: chatto.core.v1.Event.user_room_notification_level_set:type_name -> chatto.core.v1.UserRoomNotificationLevelSetEvent 46, // 45: chatto.core.v1.Event.user_room_notification_level_cleared:type_name -> chatto.core.v1.UserRoomNotificationLevelClearedEvent - 47, // 46: chatto.core.v1.Event.room_group_created:type_name -> chatto.core.v1.RoomGroupCreatedEvent - 48, // 47: chatto.core.v1.Event.room_group_updated:type_name -> chatto.core.v1.RoomGroupUpdatedEvent - 49, // 48: chatto.core.v1.Event.room_group_deleted:type_name -> chatto.core.v1.RoomGroupDeletedEvent - 50, // 49: chatto.core.v1.Event.room_added_to_group:type_name -> chatto.core.v1.RoomAddedToGroupEvent - 51, // 50: chatto.core.v1.Event.room_removed_from_group:type_name -> chatto.core.v1.RoomRemovedFromGroupEvent - 52, // 51: chatto.core.v1.Event.rooms_in_group_reordered:type_name -> chatto.core.v1.RoomsInGroupReorderedEvent - 53, // 52: chatto.core.v1.Event.sidebar_link_added_to_group:type_name -> chatto.core.v1.SidebarLinkAddedToGroupEvent - 54, // 53: chatto.core.v1.Event.sidebar_link_updated:type_name -> chatto.core.v1.SidebarLinkUpdatedEvent - 55, // 54: chatto.core.v1.Event.sidebar_link_removed_from_group:type_name -> chatto.core.v1.SidebarLinkRemovedFromGroupEvent - 56, // 55: chatto.core.v1.Event.sidebar_group_entries_reordered:type_name -> chatto.core.v1.SidebarGroupEntriesReorderedEvent - 57, // 56: chatto.core.v1.Event.room_groups_reordered:type_name -> chatto.core.v1.RoomGroupsReorderedEvent - 58, // 57: chatto.core.v1.Event.user_account_created:type_name -> chatto.core.v1.UserAccountCreatedEvent - 59, // 58: chatto.core.v1.Event.user_login_changed:type_name -> chatto.core.v1.UserLoginChangedEvent - 60, // 59: chatto.core.v1.Event.user_display_name_changed:type_name -> chatto.core.v1.UserDisplayNameChangedEvent - 61, // 60: chatto.core.v1.Event.user_avatar_set:type_name -> chatto.core.v1.UserAvatarSetEvent - 62, // 61: chatto.core.v1.Event.user_avatar_cleared:type_name -> chatto.core.v1.UserAvatarClearedEvent - 63, // 62: chatto.core.v1.Event.user_verified_email_added:type_name -> chatto.core.v1.UserVerifiedEmailAddedEvent - 64, // 63: chatto.core.v1.Event.user_password_hash_changed:type_name -> chatto.core.v1.UserPasswordHashChangedEvent - 65, // 64: chatto.core.v1.Event.user_oidc_subject_linked:type_name -> chatto.core.v1.UserOIDCSubjectLinkedEvent - 66, // 65: chatto.core.v1.Event.user_server_preferences_changed:type_name -> chatto.core.v1.UserServerPreferencesChangedEvent - 67, // 66: chatto.core.v1.Event.user_login_cooldown_cleared:type_name -> chatto.core.v1.UserLoginCooldownClearedEvent - 68, // 67: chatto.core.v1.Event.user_account_deleted:type_name -> chatto.core.v1.UserAccountDeletedEvent - 69, // 68: chatto.core.v1.Event.user_login_cooldown_started:type_name -> chatto.core.v1.UserLoginCooldownStartedEvent - 70, // 69: chatto.core.v1.Event.user_key_shredded:type_name -> chatto.core.v1.UserKeyShreddedEvent - 71, // 70: chatto.core.v1.Event.user_dek_generated:type_name -> chatto.core.v1.UserDEKGeneratedEvent - 72, // 71: chatto.core.v1.Event.user_external_identity_linked:type_name -> chatto.core.v1.UserExternalIdentityLinkedEvent - 73, // 72: chatto.core.v1.Event.user_custom_status_set:type_name -> chatto.core.v1.UserCustomStatusSetEvent - 74, // 73: chatto.core.v1.Event.user_custom_status_cleared:type_name -> chatto.core.v1.UserCustomStatusClearedEvent - 75, // 74: chatto.core.v1.Event.user_external_identity_unlinked:type_name -> chatto.core.v1.UserExternalIdentityUnlinkedEvent - 76, // 75: chatto.core.v1.Event.user_key_shredding_requested:type_name -> chatto.core.v1.UserKeyShreddingRequestedEvent - 77, // 76: chatto.core.v1.Event.rbac_role_created:type_name -> chatto.core.v1.RbacRoleCreatedEvent - 78, // 77: chatto.core.v1.Event.rbac_role_display_name_changed:type_name -> chatto.core.v1.RbacRoleDisplayNameChangedEvent - 79, // 78: chatto.core.v1.Event.rbac_role_description_changed:type_name -> chatto.core.v1.RbacRoleDescriptionChangedEvent - 80, // 79: chatto.core.v1.Event.rbac_role_deleted:type_name -> chatto.core.v1.RbacRoleDeletedEvent - 81, // 80: chatto.core.v1.Event.rbac_roles_reordered:type_name -> chatto.core.v1.RbacRolesReorderedEvent - 82, // 81: chatto.core.v1.Event.rbac_role_assigned:type_name -> chatto.core.v1.RbacRoleAssignedEvent - 83, // 82: chatto.core.v1.Event.rbac_role_revoked:type_name -> chatto.core.v1.RbacRoleRevokedEvent - 84, // 83: chatto.core.v1.Event.rbac_permission_granted:type_name -> chatto.core.v1.RbacPermissionGrantedEvent - 85, // 84: chatto.core.v1.Event.rbac_permission_denied:type_name -> chatto.core.v1.RbacPermissionDeniedEvent - 86, // 85: chatto.core.v1.Event.rbac_permission_cleared:type_name -> chatto.core.v1.RbacPermissionClearedEvent - 87, // 86: chatto.core.v1.Event.rbac_role_pingable_changed:type_name -> chatto.core.v1.RbacRolePingableChangedEvent - 88, // 87: chatto.core.v1.Event.authorization_fence_advanced:type_name -> chatto.core.v1.AuthorizationFenceAdvancedEvent - 89, // 88: chatto.core.v1.Event.room_member_banned:type_name -> chatto.core.v1.RoomMemberBannedEvent - 90, // 89: chatto.core.v1.Event.room_member_unbanned:type_name -> chatto.core.v1.RoomMemberUnbannedEvent - 91, // 90: chatto.core.v1.Event.room_member_added:type_name -> chatto.core.v1.RoomMemberAddedEvent - 92, // 91: chatto.core.v1.Event.room_member_removed:type_name -> chatto.core.v1.RoomMemberRemovedEvent - 93, // 92: chatto.core.v1.Event.registration_verification_code_issued:type_name -> chatto.core.v1.RegistrationVerificationCodeIssuedEvent - 94, // 93: chatto.core.v1.Event.email_verification_code_issued:type_name -> chatto.core.v1.EmailVerificationCodeIssuedEvent - 95, // 94: chatto.core.v1.Event.password_reset_link_issued:type_name -> chatto.core.v1.PasswordResetLinkIssuedEvent - 96, // 95: chatto.core.v1.Event.account_deletion_confirmation_issued:type_name -> chatto.core.v1.AccountDeletionConfirmationIssuedEvent - 97, // 96: chatto.core.v1.Event.password_reset_completed:type_name -> chatto.core.v1.PasswordResetCompletedEvent - 98, // 97: chatto.core.v1.Event.login_succeeded:type_name -> chatto.core.v1.LoginSucceededEvent - 99, // 98: chatto.core.v1.Event.login_failed:type_name -> chatto.core.v1.LoginFailedEvent - 100, // 99: chatto.core.v1.Event.logout_succeeded:type_name -> chatto.core.v1.LogoutSucceededEvent - 101, // 100: chatto.core.v1.Event.auth_code_issued:type_name -> chatto.core.v1.AuthCodeIssuedEvent - 102, // 101: chatto.core.v1.Event.auth_code_exchange_succeeded:type_name -> chatto.core.v1.AuthCodeExchangeSucceededEvent - 103, // 102: chatto.core.v1.Event.auth_code_exchange_failed:type_name -> chatto.core.v1.AuthCodeExchangeFailedEvent - 104, // 103: chatto.core.v1.Event.bearer_token_issued:type_name -> chatto.core.v1.BearerTokenIssuedEvent - 105, // 104: chatto.core.v1.Event.bearer_token_revoked:type_name -> chatto.core.v1.BearerTokenRevokedEvent - 106, // 105: chatto.core.v1.Event.oauth_consent_granted:type_name -> chatto.core.v1.OAuthConsentGrantedEvent - 107, // 106: chatto.core.v1.Event.oauth_consent_denied:type_name -> chatto.core.v1.OAuthConsentDeniedEvent - 108, // 107: chatto.core.v1.Event.invitation_created:type_name -> chatto.core.v1.InvitationCreatedEvent - 109, // 108: chatto.core.v1.Event.invitation_redeemed:type_name -> chatto.core.v1.InvitationRedeemedEvent - 110, // 109: chatto.core.v1.Event.invitation_revoked:type_name -> chatto.core.v1.InvitationRevokedEvent - 111, // 110: chatto.core.v1.Event.reaction_added:type_name -> chatto.core.v1.ReactionAddedEvent - 112, // 111: chatto.core.v1.Event.reaction_removed:type_name -> chatto.core.v1.ReactionRemovedEvent - 112, // [112:112] is the sub-list for method output_type - 112, // [112:112] is the sub-list for method input_type - 112, // [112:112] is the sub-list for extension type_name - 112, // [112:112] is the sub-list for extension extendee - 0, // [0:112] is the sub-list for field type_name + 47, // 46: chatto.core.v1.Event.user_notification_preference_changed:type_name -> chatto.core.v1.UserNotificationPreferenceChangedEvent + 48, // 47: chatto.core.v1.Event.room_group_created:type_name -> chatto.core.v1.RoomGroupCreatedEvent + 49, // 48: chatto.core.v1.Event.room_group_updated:type_name -> chatto.core.v1.RoomGroupUpdatedEvent + 50, // 49: chatto.core.v1.Event.room_group_deleted:type_name -> chatto.core.v1.RoomGroupDeletedEvent + 51, // 50: chatto.core.v1.Event.room_added_to_group:type_name -> chatto.core.v1.RoomAddedToGroupEvent + 52, // 51: chatto.core.v1.Event.room_removed_from_group:type_name -> chatto.core.v1.RoomRemovedFromGroupEvent + 53, // 52: chatto.core.v1.Event.rooms_in_group_reordered:type_name -> chatto.core.v1.RoomsInGroupReorderedEvent + 54, // 53: chatto.core.v1.Event.sidebar_link_added_to_group:type_name -> chatto.core.v1.SidebarLinkAddedToGroupEvent + 55, // 54: chatto.core.v1.Event.sidebar_link_updated:type_name -> chatto.core.v1.SidebarLinkUpdatedEvent + 56, // 55: chatto.core.v1.Event.sidebar_link_removed_from_group:type_name -> chatto.core.v1.SidebarLinkRemovedFromGroupEvent + 57, // 56: chatto.core.v1.Event.sidebar_group_entries_reordered:type_name -> chatto.core.v1.SidebarGroupEntriesReorderedEvent + 58, // 57: chatto.core.v1.Event.room_groups_reordered:type_name -> chatto.core.v1.RoomGroupsReorderedEvent + 59, // 58: chatto.core.v1.Event.user_account_created:type_name -> chatto.core.v1.UserAccountCreatedEvent + 60, // 59: chatto.core.v1.Event.user_login_changed:type_name -> chatto.core.v1.UserLoginChangedEvent + 61, // 60: chatto.core.v1.Event.user_display_name_changed:type_name -> chatto.core.v1.UserDisplayNameChangedEvent + 62, // 61: chatto.core.v1.Event.user_avatar_set:type_name -> chatto.core.v1.UserAvatarSetEvent + 63, // 62: chatto.core.v1.Event.user_avatar_cleared:type_name -> chatto.core.v1.UserAvatarClearedEvent + 64, // 63: chatto.core.v1.Event.user_verified_email_added:type_name -> chatto.core.v1.UserVerifiedEmailAddedEvent + 65, // 64: chatto.core.v1.Event.user_password_hash_changed:type_name -> chatto.core.v1.UserPasswordHashChangedEvent + 66, // 65: chatto.core.v1.Event.user_oidc_subject_linked:type_name -> chatto.core.v1.UserOIDCSubjectLinkedEvent + 67, // 66: chatto.core.v1.Event.user_server_preferences_changed:type_name -> chatto.core.v1.UserServerPreferencesChangedEvent + 68, // 67: chatto.core.v1.Event.user_login_cooldown_cleared:type_name -> chatto.core.v1.UserLoginCooldownClearedEvent + 69, // 68: chatto.core.v1.Event.user_account_deleted:type_name -> chatto.core.v1.UserAccountDeletedEvent + 70, // 69: chatto.core.v1.Event.user_login_cooldown_started:type_name -> chatto.core.v1.UserLoginCooldownStartedEvent + 71, // 70: chatto.core.v1.Event.user_key_shredded:type_name -> chatto.core.v1.UserKeyShreddedEvent + 72, // 71: chatto.core.v1.Event.user_dek_generated:type_name -> chatto.core.v1.UserDEKGeneratedEvent + 73, // 72: chatto.core.v1.Event.user_external_identity_linked:type_name -> chatto.core.v1.UserExternalIdentityLinkedEvent + 74, // 73: chatto.core.v1.Event.user_custom_status_set:type_name -> chatto.core.v1.UserCustomStatusSetEvent + 75, // 74: chatto.core.v1.Event.user_custom_status_cleared:type_name -> chatto.core.v1.UserCustomStatusClearedEvent + 76, // 75: chatto.core.v1.Event.user_external_identity_unlinked:type_name -> chatto.core.v1.UserExternalIdentityUnlinkedEvent + 77, // 76: chatto.core.v1.Event.user_key_shredding_requested:type_name -> chatto.core.v1.UserKeyShreddingRequestedEvent + 78, // 77: chatto.core.v1.Event.rbac_role_created:type_name -> chatto.core.v1.RbacRoleCreatedEvent + 79, // 78: chatto.core.v1.Event.rbac_role_display_name_changed:type_name -> chatto.core.v1.RbacRoleDisplayNameChangedEvent + 80, // 79: chatto.core.v1.Event.rbac_role_description_changed:type_name -> chatto.core.v1.RbacRoleDescriptionChangedEvent + 81, // 80: chatto.core.v1.Event.rbac_role_deleted:type_name -> chatto.core.v1.RbacRoleDeletedEvent + 82, // 81: chatto.core.v1.Event.rbac_roles_reordered:type_name -> chatto.core.v1.RbacRolesReorderedEvent + 83, // 82: chatto.core.v1.Event.rbac_role_assigned:type_name -> chatto.core.v1.RbacRoleAssignedEvent + 84, // 83: chatto.core.v1.Event.rbac_role_revoked:type_name -> chatto.core.v1.RbacRoleRevokedEvent + 85, // 84: chatto.core.v1.Event.rbac_permission_granted:type_name -> chatto.core.v1.RbacPermissionGrantedEvent + 86, // 85: chatto.core.v1.Event.rbac_permission_denied:type_name -> chatto.core.v1.RbacPermissionDeniedEvent + 87, // 86: chatto.core.v1.Event.rbac_permission_cleared:type_name -> chatto.core.v1.RbacPermissionClearedEvent + 88, // 87: chatto.core.v1.Event.rbac_role_pingable_changed:type_name -> chatto.core.v1.RbacRolePingableChangedEvent + 89, // 88: chatto.core.v1.Event.authorization_fence_advanced:type_name -> chatto.core.v1.AuthorizationFenceAdvancedEvent + 90, // 89: chatto.core.v1.Event.room_member_banned:type_name -> chatto.core.v1.RoomMemberBannedEvent + 91, // 90: chatto.core.v1.Event.room_member_unbanned:type_name -> chatto.core.v1.RoomMemberUnbannedEvent + 92, // 91: chatto.core.v1.Event.room_member_added:type_name -> chatto.core.v1.RoomMemberAddedEvent + 93, // 92: chatto.core.v1.Event.room_member_removed:type_name -> chatto.core.v1.RoomMemberRemovedEvent + 94, // 93: chatto.core.v1.Event.registration_verification_code_issued:type_name -> chatto.core.v1.RegistrationVerificationCodeIssuedEvent + 95, // 94: chatto.core.v1.Event.email_verification_code_issued:type_name -> chatto.core.v1.EmailVerificationCodeIssuedEvent + 96, // 95: chatto.core.v1.Event.password_reset_link_issued:type_name -> chatto.core.v1.PasswordResetLinkIssuedEvent + 97, // 96: chatto.core.v1.Event.account_deletion_confirmation_issued:type_name -> chatto.core.v1.AccountDeletionConfirmationIssuedEvent + 98, // 97: chatto.core.v1.Event.password_reset_completed:type_name -> chatto.core.v1.PasswordResetCompletedEvent + 99, // 98: chatto.core.v1.Event.login_succeeded:type_name -> chatto.core.v1.LoginSucceededEvent + 100, // 99: chatto.core.v1.Event.login_failed:type_name -> chatto.core.v1.LoginFailedEvent + 101, // 100: chatto.core.v1.Event.logout_succeeded:type_name -> chatto.core.v1.LogoutSucceededEvent + 102, // 101: chatto.core.v1.Event.auth_code_issued:type_name -> chatto.core.v1.AuthCodeIssuedEvent + 103, // 102: chatto.core.v1.Event.auth_code_exchange_succeeded:type_name -> chatto.core.v1.AuthCodeExchangeSucceededEvent + 104, // 103: chatto.core.v1.Event.auth_code_exchange_failed:type_name -> chatto.core.v1.AuthCodeExchangeFailedEvent + 105, // 104: chatto.core.v1.Event.bearer_token_issued:type_name -> chatto.core.v1.BearerTokenIssuedEvent + 106, // 105: chatto.core.v1.Event.bearer_token_revoked:type_name -> chatto.core.v1.BearerTokenRevokedEvent + 107, // 106: chatto.core.v1.Event.oauth_consent_granted:type_name -> chatto.core.v1.OAuthConsentGrantedEvent + 108, // 107: chatto.core.v1.Event.oauth_consent_denied:type_name -> chatto.core.v1.OAuthConsentDeniedEvent + 109, // 108: chatto.core.v1.Event.invitation_created:type_name -> chatto.core.v1.InvitationCreatedEvent + 110, // 109: chatto.core.v1.Event.invitation_redeemed:type_name -> chatto.core.v1.InvitationRedeemedEvent + 111, // 110: chatto.core.v1.Event.invitation_revoked:type_name -> chatto.core.v1.InvitationRevokedEvent + 112, // 111: chatto.core.v1.Event.reaction_added:type_name -> chatto.core.v1.ReactionAddedEvent + 113, // 112: chatto.core.v1.Event.reaction_removed:type_name -> chatto.core.v1.ReactionRemovedEvent + 113, // [113:113] is the sub-list for method output_type + 113, // [113:113] is the sub-list for method input_type + 113, // [113:113] is the sub-list for extension type_name + 113, // [113:113] is the sub-list for extension extendee + 0, // [0:113] is the sub-list for field type_name } func init() { file_chatto_core_v1_event_proto_init() } @@ -2325,6 +2355,7 @@ func file_chatto_core_v1_event_proto_init() { file_chatto_core_v1_asset_events_proto_init() file_chatto_core_v1_message_events_proto_init() file_chatto_core_v1_moderation_events_proto_init() + file_chatto_core_v1_notification_proto_init() file_chatto_core_v1_rbac_events_proto_init() file_chatto_core_v1_reaction_events_proto_init() file_chatto_core_v1_room_events_proto_init() @@ -2379,6 +2410,7 @@ func file_chatto_core_v1_event_proto_init() { (*Event_UserServerNotificationLevelCleared)(nil), (*Event_UserRoomNotificationLevelSet)(nil), (*Event_UserRoomNotificationLevelCleared)(nil), + (*Event_UserNotificationPreferenceChanged)(nil), (*Event_RoomGroupCreated)(nil), (*Event_RoomGroupUpdated)(nil), (*Event_RoomGroupDeleted)(nil), diff --git a/cli/internal/pb/chatto/core/v1/live_events.pb.go b/cli/internal/pb/chatto/core/v1/live_events.pb.go index 831c08e6b..fa0307629 100644 --- a/cli/internal/pb/chatto/core/v1/live_events.pb.go +++ b/cli/internal/pb/chatto/core/v1/live_events.pb.go @@ -55,6 +55,7 @@ type LiveEvent struct { // *LiveEvent_CallParticipantLeft // *LiveEvent_NotificationCreated // *LiveEvent_NotificationDismissed + // *LiveEvent_NotificationOccurrenceChanged // *LiveEvent_RoomMarkedAsRead // *LiveEvent_MentionStatusCleared // *LiveEvent_RoomGroupsUpdated @@ -159,6 +160,7 @@ func (x *LiveEvent) GetServerUserPreferencesUpdated() *ServerUserPreferencesUpda return nil } +// Deprecated: Marked as deprecated in chatto/core/v1/live_events.proto. func (x *LiveEvent) GetNotificationLevelChanged() *NotificationLevelChangedEvent { if x != nil { if x, ok := x.Event.(*LiveEvent_NotificationLevelChanged); ok { @@ -213,6 +215,7 @@ func (x *LiveEvent) GetPresenceChanged() *PresenceChangedEvent { return nil } +// Deprecated: Marked as deprecated in chatto/core/v1/live_events.proto. func (x *LiveEvent) GetMentionNotification() *MentionNotificationEvent { if x != nil { if x, ok := x.Event.(*LiveEvent_MentionNotification); ok { @@ -222,6 +225,7 @@ func (x *LiveEvent) GetMentionNotification() *MentionNotificationEvent { return nil } +// Deprecated: Marked as deprecated in chatto/core/v1/live_events.proto. func (x *LiveEvent) GetNewDirectMessageNotification() *NewDirectMessageNotificationEvent { if x != nil { if x, ok := x.Event.(*LiveEvent_NewDirectMessageNotification); ok { @@ -249,6 +253,7 @@ func (x *LiveEvent) GetCallParticipantLeft() *CallParticipantLeftEvent { return nil } +// Deprecated: Marked as deprecated in chatto/core/v1/live_events.proto. func (x *LiveEvent) GetNotificationCreated() *NotificationCreatedEvent { if x != nil { if x, ok := x.Event.(*LiveEvent_NotificationCreated); ok { @@ -258,6 +263,7 @@ func (x *LiveEvent) GetNotificationCreated() *NotificationCreatedEvent { return nil } +// Deprecated: Marked as deprecated in chatto/core/v1/live_events.proto. func (x *LiveEvent) GetNotificationDismissed() *NotificationDismissedEvent { if x != nil { if x, ok := x.Event.(*LiveEvent_NotificationDismissed); ok { @@ -267,6 +273,15 @@ func (x *LiveEvent) GetNotificationDismissed() *NotificationDismissedEvent { return nil } +func (x *LiveEvent) GetNotificationOccurrenceChanged() *NotificationOccurrenceChangedEvent { + if x != nil { + if x, ok := x.Event.(*LiveEvent_NotificationOccurrenceChanged); ok { + return x.NotificationOccurrenceChanged + } + } + return nil +} + func (x *LiveEvent) GetRoomMarkedAsRead() *RoomMarkedAsReadEvent { if x != nil { if x, ok := x.Event.(*LiveEvent_RoomMarkedAsRead); ok { @@ -330,6 +345,9 @@ type LiveEvent_ServerUserPreferencesUpdated struct { } type LiveEvent_NotificationLevelChanged struct { + // Notifications 1.0 schema tombstone. Current code never publishes or handles it. + // + // Deprecated: Marked as deprecated in chatto/core/v1/live_events.proto. NotificationLevelChanged *NotificationLevelChangedEvent `protobuf:"bytes,24,opt,name=notification_level_changed,json=notificationLevelChanged,proto3,oneof"` } @@ -357,11 +375,14 @@ type LiveEvent_PresenceChanged struct { } type LiveEvent_MentionNotification struct { - // ----- Notifications ----- + // Notifications 1.0 schema tombstones. Current code never publishes or handles them. + // + // Deprecated: Marked as deprecated in chatto/core/v1/live_events.proto. MentionNotification *MentionNotificationEvent `protobuf:"bytes,50,opt,name=mention_notification,json=mentionNotification,proto3,oneof"` } type LiveEvent_NewDirectMessageNotification struct { + // Deprecated: Marked as deprecated in chatto/core/v1/live_events.proto. NewDirectMessageNotification *NewDirectMessageNotificationEvent `protobuf:"bytes,51,opt,name=new_direct_message_notification,json=newDirectMessageNotification,proto3,oneof"` } @@ -376,13 +397,21 @@ type LiveEvent_CallParticipantLeft struct { type LiveEvent_NotificationCreated struct { // ----- Notification sync ----- + // Notifications 1.0 schema tombstones. Current code never publishes or handles them. + // + // Deprecated: Marked as deprecated in chatto/core/v1/live_events.proto. NotificationCreated *NotificationCreatedEvent `protobuf:"bytes,70,opt,name=notification_created,json=notificationCreated,proto3,oneof"` } type LiveEvent_NotificationDismissed struct { + // Deprecated: Marked as deprecated in chatto/core/v1/live_events.proto. NotificationDismissed *NotificationDismissedEvent `protobuf:"bytes,71,opt,name=notification_dismissed,json=notificationDismissed,proto3,oneof"` } +type LiveEvent_NotificationOccurrenceChanged struct { + NotificationOccurrenceChanged *NotificationOccurrenceChangedEvent `protobuf:"bytes,72,opt,name=notification_occurrence_changed,json=notificationOccurrenceChanged,proto3,oneof"` +} + type LiveEvent_RoomMarkedAsRead struct { // ----- Unread indicators ----- RoomMarkedAsRead *RoomMarkedAsReadEvent `protobuf:"bytes,80,opt,name=room_marked_as_read,json=roomMarkedAsRead,proto3,oneof"` @@ -434,6 +463,8 @@ func (*LiveEvent_NotificationCreated) isLiveEvent_Event() {} func (*LiveEvent_NotificationDismissed) isLiveEvent_Event() {} +func (*LiveEvent_NotificationOccurrenceChanged) isLiveEvent_Event() {} + func (*LiveEvent_RoomMarkedAsRead) isLiveEvent_Event() {} func (*LiveEvent_MentionStatusCleared) isLiveEvent_Event() {} @@ -482,17 +513,13 @@ func (*HeartbeatEvent) Descriptor() ([]byte, []int) { return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{1} } -// NotificationLevelChangedEvent is published when a user changes their notification -// level for the server or a specific room. User-scoped: only delivered to the user -// who changed it. +// Notifications 1.0 schema tombstone retained for internal wire compatibility. +// Current code never publishes or handles this message. type NotificationLevelChangedEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The room whose notification level was changed (empty for server-level changes). - RoomId string `protobuf:"bytes,2,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - // The explicitly set level. - Level NotificationLevel `protobuf:"varint,3,opt,name=level,proto3,enum=chatto.core.v1.NotificationLevel" json:"level,omitempty"` - // The effective level after inheritance resolution. - EffectiveLevel NotificationLevel `protobuf:"varint,4,opt,name=effective_level,json=effectiveLevel,proto3,enum=chatto.core.v1.NotificationLevel" json:"effective_level,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RoomId string `protobuf:"bytes,2,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + Level NotificationLevel `protobuf:"varint,3,opt,name=level,proto3,enum=chatto.core.v1.NotificationLevel" json:"level,omitempty"` + EffectiveLevel NotificationLevel `protobuf:"varint,4,opt,name=effective_level,json=effectiveLevel,proto3,enum=chatto.core.v1.NotificationLevel" json:"effective_level,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -733,15 +760,12 @@ func (x *PresenceChangedEvent) GetStatus() string { return "" } -// Notifies a user that they were mentioned in a message. -// Published as a server-scoped live event to the mentioned user. -// This is a transient notification — the KV bucket tracks persistent state. +// Notifications 1.0 schema tombstone retained for internal wire compatibility. +// Current code never publishes or handles this message. type MentionNotificationEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Room ID where the mention occurred - RoomId string `protobuf:"bytes,2,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - // User ID of the person who mentioned them (the message author) - MentionedByUserId string `protobuf:"bytes,3,opt,name=mentioned_by_user_id,json=mentionedByUserId,proto3" json:"mentioned_by_user_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RoomId string `protobuf:"bytes,2,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + MentionedByUserId string `protobuf:"bytes,3,opt,name=mentioned_by_user_id,json=mentionedByUserId,proto3" json:"mentioned_by_user_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -790,15 +814,12 @@ func (x *MentionNotificationEvent) GetMentionedByUserId() string { return "" } -// Notifies a user that a new message was posted in a DM conversation they're part of. -// Published as a server-scoped live event to all participants except the sender. -// Every DM message triggers this notification (unlike mentions which are opt-in). +// Notifications 1.0 schema tombstone retained for internal wire compatibility. +// Current code never publishes or handles this message. type NewDirectMessageNotificationEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Room ID of the DM conversation - RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - // User ID of the person who sent the message - SenderId string `protobuf:"bytes,2,opt,name=sender_id,json=senderId,proto3" json:"sender_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + SenderId string `protobuf:"bytes,2,opt,name=sender_id,json=senderId,proto3" json:"sender_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -847,21 +868,17 @@ func (x *NewDirectMessageNotificationEvent) GetSenderId() string { return "" } -// Published when a new notification is created for a user. -// Enables real-time notification updates in the UI. +// Notifications 1.0 schema tombstone retained for internal wire compatibility. +// Current code never publishes or handles this message. type NotificationCreatedEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The notification ID - NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` - // Navigation context (optional fields for routing) - RoomId string `protobuf:"bytes,3,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - EventId string `protobuf:"bytes,4,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` - InReplyToId string `protobuf:"bytes,5,opt,name=in_reply_to_id,json=inReplyToId,proto3" json:"in_reply_to_id,omitempty"` - // True when clients should update notification state without playing a local - // alert sound. Used for Do Not Disturb delivery. - Silent bool `protobuf:"varint,6,opt,name=silent,proto3" json:"silent,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` + RoomId string `protobuf:"bytes,3,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + EventId string `protobuf:"bytes,4,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + InReplyToId string `protobuf:"bytes,5,opt,name=in_reply_to_id,json=inReplyToId,proto3" json:"in_reply_to_id,omitempty"` + Silent bool `protobuf:"varint,6,opt,name=silent,proto3" json:"silent,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *NotificationCreatedEvent) Reset() { @@ -929,12 +946,11 @@ func (x *NotificationCreatedEvent) GetSilent() bool { return false } -// Published when a notification is dismissed by the user. -// Enables cross-device sync of dismissed notifications. +// Notifications 1.0 schema tombstone retained for internal wire compatibility. +// Current code never publishes or handles this message. type NotificationDismissedEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // The dismissed notification ID - NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -976,6 +992,96 @@ func (x *NotificationDismissedEvent) GetNotificationId() string { return "" } +// User-scoped invalidation for Notifications 2.0 authoritative replacement. +type NotificationOccurrenceChangedEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` + Created bool `protobuf:"varint,2,opt,name=created,proto3" json:"created,omitempty"` + Deleted bool `protobuf:"varint,3,opt,name=deleted,proto3" json:"deleted,omitempty"` + // True only when a newly created occurrence is currently allowed to trigger + // a one-shot local alert. + Alert bool `protobuf:"varint,4,opt,name=alert,proto3" json:"alert,omitempty"` + // Internal source identity used to fence the recipient's runtime-state + // watcher before an authoritative replacement is assembled. + SourceEventId string `protobuf:"bytes,5,opt,name=source_event_id,json=sourceEventId,proto3" json:"source_event_id,omitempty"` + // RUNTIME_STATE KV revision that must be visible before replacement. + RuntimeStateRevision uint64 `protobuf:"varint,6,opt,name=runtime_state_revision,json=runtimeStateRevision,proto3" json:"runtime_state_revision,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationOccurrenceChangedEvent) Reset() { + *x = NotificationOccurrenceChangedEvent{} + mi := &file_chatto_core_v1_live_events_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotificationOccurrenceChangedEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationOccurrenceChangedEvent) ProtoMessage() {} + +func (x *NotificationOccurrenceChangedEvent) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_live_events_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotificationOccurrenceChangedEvent.ProtoReflect.Descriptor instead. +func (*NotificationOccurrenceChangedEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{10} +} + +func (x *NotificationOccurrenceChangedEvent) GetNotificationId() string { + if x != nil { + return x.NotificationId + } + return "" +} + +func (x *NotificationOccurrenceChangedEvent) GetCreated() bool { + if x != nil { + return x.Created + } + return false +} + +func (x *NotificationOccurrenceChangedEvent) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +func (x *NotificationOccurrenceChangedEvent) GetAlert() bool { + if x != nil { + return x.Alert + } + return false +} + +func (x *NotificationOccurrenceChangedEvent) GetSourceEventId() string { + if x != nil { + return x.SourceEventId + } + return "" +} + +func (x *NotificationOccurrenceChangedEvent) GetRuntimeStateRevision() uint64 { + if x != nil { + return x.RuntimeStateRevision + } + return 0 +} + // ThreadFollowChangedEvent invalidates one user's viewer-specific thread state. // It is published for follow/unfollow and read-marker changes and is user-scoped // for multi-tab/multi-device projection convergence. @@ -993,7 +1099,7 @@ type ThreadFollowChangedEvent struct { func (x *ThreadFollowChangedEvent) Reset() { *x = ThreadFollowChangedEvent{} - mi := &file_chatto_core_v1_live_events_proto_msgTypes[10] + mi := &file_chatto_core_v1_live_events_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1005,7 +1111,7 @@ func (x *ThreadFollowChangedEvent) String() string { func (*ThreadFollowChangedEvent) ProtoMessage() {} func (x *ThreadFollowChangedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_live_events_proto_msgTypes[10] + mi := &file_chatto_core_v1_live_events_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1018,7 +1124,7 @@ func (x *ThreadFollowChangedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ThreadFollowChangedEvent.ProtoReflect.Descriptor instead. func (*ThreadFollowChangedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{10} + return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{11} } func (x *ThreadFollowChangedEvent) GetRoomId() string { @@ -1054,7 +1160,7 @@ type RoomMarkedAsReadEvent struct { func (x *RoomMarkedAsReadEvent) Reset() { *x = RoomMarkedAsReadEvent{} - mi := &file_chatto_core_v1_live_events_proto_msgTypes[11] + mi := &file_chatto_core_v1_live_events_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1066,7 +1172,7 @@ func (x *RoomMarkedAsReadEvent) String() string { func (*RoomMarkedAsReadEvent) ProtoMessage() {} func (x *RoomMarkedAsReadEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_live_events_proto_msgTypes[11] + mi := &file_chatto_core_v1_live_events_proto_msgTypes[12] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1079,7 +1185,7 @@ func (x *RoomMarkedAsReadEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use RoomMarkedAsReadEvent.ProtoReflect.Descriptor instead. func (*RoomMarkedAsReadEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{11} + return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{12} } func (x *RoomMarkedAsReadEvent) GetRoomId() string { @@ -1104,7 +1210,7 @@ type MentionStatusClearedEvent struct { func (x *MentionStatusClearedEvent) Reset() { *x = MentionStatusClearedEvent{} - mi := &file_chatto_core_v1_live_events_proto_msgTypes[12] + mi := &file_chatto_core_v1_live_events_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1116,7 +1222,7 @@ func (x *MentionStatusClearedEvent) String() string { func (*MentionStatusClearedEvent) ProtoMessage() {} func (x *MentionStatusClearedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_live_events_proto_msgTypes[12] + mi := &file_chatto_core_v1_live_events_proto_msgTypes[13] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1129,7 +1235,7 @@ func (x *MentionStatusClearedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use MentionStatusClearedEvent.ProtoReflect.Descriptor instead. func (*MentionStatusClearedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{12} + return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{13} } func (x *MentionStatusClearedEvent) GetRoomId() string { @@ -1150,7 +1256,7 @@ type RoomGroupsUpdatedEvent struct { func (x *RoomGroupsUpdatedEvent) Reset() { *x = RoomGroupsUpdatedEvent{} - mi := &file_chatto_core_v1_live_events_proto_msgTypes[13] + mi := &file_chatto_core_v1_live_events_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1162,7 +1268,7 @@ func (x *RoomGroupsUpdatedEvent) String() string { func (*RoomGroupsUpdatedEvent) ProtoMessage() {} func (x *RoomGroupsUpdatedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_live_events_proto_msgTypes[13] + mi := &file_chatto_core_v1_live_events_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1175,7 +1281,7 @@ func (x *RoomGroupsUpdatedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use RoomGroupsUpdatedEvent.ProtoReflect.Descriptor instead. func (*RoomGroupsUpdatedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{13} + return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{14} } // Notifies a user that their session has been terminated. @@ -1192,7 +1298,7 @@ type SessionTerminatedEvent struct { func (x *SessionTerminatedEvent) Reset() { *x = SessionTerminatedEvent{} - mi := &file_chatto_core_v1_live_events_proto_msgTypes[14] + mi := &file_chatto_core_v1_live_events_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1204,7 +1310,7 @@ func (x *SessionTerminatedEvent) String() string { func (*SessionTerminatedEvent) ProtoMessage() {} func (x *SessionTerminatedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_live_events_proto_msgTypes[14] + mi := &file_chatto_core_v1_live_events_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1217,7 +1323,7 @@ func (x *SessionTerminatedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use SessionTerminatedEvent.ProtoReflect.Descriptor instead. func (*SessionTerminatedEvent) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{14} + return file_chatto_core_v1_live_events_proto_rawDescGZIP(), []int{15} } func (x *SessionTerminatedEvent) GetReason() string { @@ -1231,7 +1337,7 @@ var File_chatto_core_v1_live_events_proto protoreflect.FileDescriptor const file_chatto_core_v1_live_events_proto_rawDesc = "" + "\n" + - " chatto/core/v1/live_events.proto\x12\x0echatto.core.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a chatto/core/v1/room_events.proto\x1a chatto/core/v1/user_events.proto\x1a%chatto/core/v1/user_preferences.proto\"\x83\x10\n" + + " chatto/core/v1/live_events.proto\x12\x0echatto.core.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a chatto/core/v1/room_events.proto\x1a chatto/core/v1/user_events.proto\x1a%chatto/core/v1/user_preferences.proto\"\x95\x11\n" + "\tLiveEvent\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x129\n" + "\n" + @@ -1240,20 +1346,21 @@ const file_chatto_core_v1_live_events_proto_rawDesc = "" + "\fuser_created\x18\x14 \x01(\v2 .chatto.core.v1.UserCreatedEventH\x00R\vuserCreated\x12I\n" + "\fuser_deleted\x18\x15 \x01(\v2 .chatto.core.v1.UserDeletedEventB\x02\x18\x01H\x00R\vuserDeleted\x12[\n" + "\x14user_profile_updated\x18\x16 \x01(\v2'.chatto.core.v1.UserProfileUpdatedEventH\x00R\x12userProfileUpdated\x12z\n" + - "\x1fserver_user_preferences_updated\x18\x17 \x01(\v21.chatto.core.v1.ServerUserPreferencesUpdatedEventH\x00R\x1cserverUserPreferencesUpdated\x12m\n" + - "\x1anotification_level_changed\x18\x18 \x01(\v2-.chatto.core.v1.NotificationLevelChangedEventH\x00R\x18notificationLevelChanged\x12^\n" + + "\x1fserver_user_preferences_updated\x18\x17 \x01(\v21.chatto.core.v1.ServerUserPreferencesUpdatedEventH\x00R\x1cserverUserPreferencesUpdated\x12q\n" + + "\x1anotification_level_changed\x18\x18 \x01(\v2-.chatto.core.v1.NotificationLevelChangedEventB\x02\x18\x01H\x00R\x18notificationLevelChanged\x12^\n" + "\x15thread_follow_changed\x18\x19 \x01(\v2(.chatto.core.v1.ThreadFollowChangedEventH\x00R\x13threadFollowChanged\x12^\n" + "\x15server_member_deleted\x18\x1e \x01(\v2(.chatto.core.v1.ServerMemberDeletedEventH\x00R\x13serverMemberDeleted\x12K\n" + "\x0eserver_updated\x18\x1f \x01(\v2\".chatto.core.v1.ServerUpdatedEventH\x00R\rserverUpdated\x12B\n" + "\vuser_typing\x18( \x01(\v2\x1f.chatto.core.v1.UserTypingEventH\x00R\n" + "userTyping\x12Q\n" + - "\x10presence_changed\x18- \x01(\v2$.chatto.core.v1.PresenceChangedEventH\x00R\x0fpresenceChanged\x12]\n" + - "\x14mention_notification\x182 \x01(\v2(.chatto.core.v1.MentionNotificationEventH\x00R\x13mentionNotification\x12z\n" + - "\x1fnew_direct_message_notification\x183 \x01(\v21.chatto.core.v1.NewDirectMessageNotificationEventH\x00R\x1cnewDirectMessageNotification\x12d\n" + + "\x10presence_changed\x18- \x01(\v2$.chatto.core.v1.PresenceChangedEventH\x00R\x0fpresenceChanged\x12a\n" + + "\x14mention_notification\x182 \x01(\v2(.chatto.core.v1.MentionNotificationEventB\x02\x18\x01H\x00R\x13mentionNotification\x12~\n" + + "\x1fnew_direct_message_notification\x183 \x01(\v21.chatto.core.v1.NewDirectMessageNotificationEventB\x02\x18\x01H\x00R\x1cnewDirectMessageNotification\x12d\n" + "\x17call_participant_joined\x18< \x01(\v2*.chatto.core.v1.CallParticipantJoinedEventH\x00R\x15callParticipantJoined\x12^\n" + - "\x15call_participant_left\x18= \x01(\v2(.chatto.core.v1.CallParticipantLeftEventH\x00R\x13callParticipantLeft\x12]\n" + - "\x14notification_created\x18F \x01(\v2(.chatto.core.v1.NotificationCreatedEventH\x00R\x13notificationCreated\x12c\n" + - "\x16notification_dismissed\x18G \x01(\v2*.chatto.core.v1.NotificationDismissedEventH\x00R\x15notificationDismissed\x12V\n" + + "\x15call_participant_left\x18= \x01(\v2(.chatto.core.v1.CallParticipantLeftEventH\x00R\x13callParticipantLeft\x12a\n" + + "\x14notification_created\x18F \x01(\v2(.chatto.core.v1.NotificationCreatedEventB\x02\x18\x01H\x00R\x13notificationCreated\x12g\n" + + "\x16notification_dismissed\x18G \x01(\v2*.chatto.core.v1.NotificationDismissedEventB\x02\x18\x01H\x00R\x15notificationDismissed\x12|\n" + + "\x1fnotification_occurrence_changed\x18H \x01(\v22.chatto.core.v1.NotificationOccurrenceChangedEventH\x00R\x1dnotificationOccurrenceChanged\x12V\n" + "\x13room_marked_as_read\x18P \x01(\v2%.chatto.core.v1.RoomMarkedAsReadEventH\x00R\x10roomMarkedAsRead\x12a\n" + "\x16mention_status_cleared\x18Q \x01(\v2).chatto.core.v1.MentionStatusClearedEventH\x00R\x14mentionStatusCleared\x12X\n" + "\x13room_groups_updated\x18Z \x01(\v2&.chatto.core.v1.RoomGroupsUpdatedEventH\x00R\x11roomGroupsUpdated\x12W\n" + @@ -1291,7 +1398,14 @@ const file_chatto_core_v1_live_events_proto_rawDesc = "" + "\x0ein_reply_to_id\x18\x05 \x01(\tR\vinReplyToId\x12\x16\n" + "\x06silent\x18\x06 \x01(\bR\x06silentJ\x04\b\x02\x10\x03R\bspace_id\"E\n" + "\x1aNotificationDismissedEvent\x12'\n" + - "\x0fnotification_id\x18\x01 \x01(\tR\x0enotificationId\"\x97\x01\n" + + "\x0fnotification_id\x18\x01 \x01(\tR\x0enotificationId\"\xf5\x01\n" + + "\"NotificationOccurrenceChangedEvent\x12'\n" + + "\x0fnotification_id\x18\x01 \x01(\tR\x0enotificationId\x12\x18\n" + + "\acreated\x18\x02 \x01(\bR\acreated\x12\x18\n" + + "\adeleted\x18\x03 \x01(\bR\adeleted\x12\x14\n" + + "\x05alert\x18\x04 \x01(\bR\x05alert\x12&\n" + + "\x0fsource_event_id\x18\x05 \x01(\tR\rsourceEventId\x124\n" + + "\x16runtime_state_revision\x18\x06 \x01(\x04R\x14runtimeStateRevision\"\x97\x01\n" + "\x18ThreadFollowChangedEvent\x12\x17\n" + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12/\n" + "\x14thread_root_event_id\x18\x03 \x01(\tR\x11threadRootEventId\x12!\n" + @@ -1317,62 +1431,64 @@ func file_chatto_core_v1_live_events_proto_rawDescGZIP() []byte { return file_chatto_core_v1_live_events_proto_rawDescData } -var file_chatto_core_v1_live_events_proto_msgTypes = make([]protoimpl.MessageInfo, 15) +var file_chatto_core_v1_live_events_proto_msgTypes = make([]protoimpl.MessageInfo, 16) var file_chatto_core_v1_live_events_proto_goTypes = []any{ - (*LiveEvent)(nil), // 0: chatto.core.v1.LiveEvent - (*HeartbeatEvent)(nil), // 1: chatto.core.v1.HeartbeatEvent - (*NotificationLevelChangedEvent)(nil), // 2: chatto.core.v1.NotificationLevelChangedEvent - (*ServerUpdatedEvent)(nil), // 3: chatto.core.v1.ServerUpdatedEvent - (*UserTypingEvent)(nil), // 4: chatto.core.v1.UserTypingEvent - (*PresenceChangedEvent)(nil), // 5: chatto.core.v1.PresenceChangedEvent - (*MentionNotificationEvent)(nil), // 6: chatto.core.v1.MentionNotificationEvent - (*NewDirectMessageNotificationEvent)(nil), // 7: chatto.core.v1.NewDirectMessageNotificationEvent - (*NotificationCreatedEvent)(nil), // 8: chatto.core.v1.NotificationCreatedEvent - (*NotificationDismissedEvent)(nil), // 9: chatto.core.v1.NotificationDismissedEvent - (*ThreadFollowChangedEvent)(nil), // 10: chatto.core.v1.ThreadFollowChangedEvent - (*RoomMarkedAsReadEvent)(nil), // 11: chatto.core.v1.RoomMarkedAsReadEvent - (*MentionStatusClearedEvent)(nil), // 12: chatto.core.v1.MentionStatusClearedEvent - (*RoomGroupsUpdatedEvent)(nil), // 13: chatto.core.v1.RoomGroupsUpdatedEvent - (*SessionTerminatedEvent)(nil), // 14: chatto.core.v1.SessionTerminatedEvent - (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp - (*UserCreatedEvent)(nil), // 16: chatto.core.v1.UserCreatedEvent - (*UserDeletedEvent)(nil), // 17: chatto.core.v1.UserDeletedEvent - (*UserProfileUpdatedEvent)(nil), // 18: chatto.core.v1.UserProfileUpdatedEvent - (*ServerUserPreferencesUpdatedEvent)(nil), // 19: chatto.core.v1.ServerUserPreferencesUpdatedEvent - (*ServerMemberDeletedEvent)(nil), // 20: chatto.core.v1.ServerMemberDeletedEvent - (*CallParticipantJoinedEvent)(nil), // 21: chatto.core.v1.CallParticipantJoinedEvent - (*CallParticipantLeftEvent)(nil), // 22: chatto.core.v1.CallParticipantLeftEvent - (NotificationLevel)(0), // 23: chatto.core.v1.NotificationLevel + (*LiveEvent)(nil), // 0: chatto.core.v1.LiveEvent + (*HeartbeatEvent)(nil), // 1: chatto.core.v1.HeartbeatEvent + (*NotificationLevelChangedEvent)(nil), // 2: chatto.core.v1.NotificationLevelChangedEvent + (*ServerUpdatedEvent)(nil), // 3: chatto.core.v1.ServerUpdatedEvent + (*UserTypingEvent)(nil), // 4: chatto.core.v1.UserTypingEvent + (*PresenceChangedEvent)(nil), // 5: chatto.core.v1.PresenceChangedEvent + (*MentionNotificationEvent)(nil), // 6: chatto.core.v1.MentionNotificationEvent + (*NewDirectMessageNotificationEvent)(nil), // 7: chatto.core.v1.NewDirectMessageNotificationEvent + (*NotificationCreatedEvent)(nil), // 8: chatto.core.v1.NotificationCreatedEvent + (*NotificationDismissedEvent)(nil), // 9: chatto.core.v1.NotificationDismissedEvent + (*NotificationOccurrenceChangedEvent)(nil), // 10: chatto.core.v1.NotificationOccurrenceChangedEvent + (*ThreadFollowChangedEvent)(nil), // 11: chatto.core.v1.ThreadFollowChangedEvent + (*RoomMarkedAsReadEvent)(nil), // 12: chatto.core.v1.RoomMarkedAsReadEvent + (*MentionStatusClearedEvent)(nil), // 13: chatto.core.v1.MentionStatusClearedEvent + (*RoomGroupsUpdatedEvent)(nil), // 14: chatto.core.v1.RoomGroupsUpdatedEvent + (*SessionTerminatedEvent)(nil), // 15: chatto.core.v1.SessionTerminatedEvent + (*timestamppb.Timestamp)(nil), // 16: google.protobuf.Timestamp + (*UserCreatedEvent)(nil), // 17: chatto.core.v1.UserCreatedEvent + (*UserDeletedEvent)(nil), // 18: chatto.core.v1.UserDeletedEvent + (*UserProfileUpdatedEvent)(nil), // 19: chatto.core.v1.UserProfileUpdatedEvent + (*ServerUserPreferencesUpdatedEvent)(nil), // 20: chatto.core.v1.ServerUserPreferencesUpdatedEvent + (*ServerMemberDeletedEvent)(nil), // 21: chatto.core.v1.ServerMemberDeletedEvent + (*CallParticipantJoinedEvent)(nil), // 22: chatto.core.v1.CallParticipantJoinedEvent + (*CallParticipantLeftEvent)(nil), // 23: chatto.core.v1.CallParticipantLeftEvent + (NotificationLevel)(0), // 24: chatto.core.v1.NotificationLevel } var file_chatto_core_v1_live_events_proto_depIdxs = []int32{ - 15, // 0: chatto.core.v1.LiveEvent.created_at:type_name -> google.protobuf.Timestamp - 16, // 1: chatto.core.v1.LiveEvent.user_created:type_name -> chatto.core.v1.UserCreatedEvent - 17, // 2: chatto.core.v1.LiveEvent.user_deleted:type_name -> chatto.core.v1.UserDeletedEvent - 18, // 3: chatto.core.v1.LiveEvent.user_profile_updated:type_name -> chatto.core.v1.UserProfileUpdatedEvent - 19, // 4: chatto.core.v1.LiveEvent.server_user_preferences_updated:type_name -> chatto.core.v1.ServerUserPreferencesUpdatedEvent + 16, // 0: chatto.core.v1.LiveEvent.created_at:type_name -> google.protobuf.Timestamp + 17, // 1: chatto.core.v1.LiveEvent.user_created:type_name -> chatto.core.v1.UserCreatedEvent + 18, // 2: chatto.core.v1.LiveEvent.user_deleted:type_name -> chatto.core.v1.UserDeletedEvent + 19, // 3: chatto.core.v1.LiveEvent.user_profile_updated:type_name -> chatto.core.v1.UserProfileUpdatedEvent + 20, // 4: chatto.core.v1.LiveEvent.server_user_preferences_updated:type_name -> chatto.core.v1.ServerUserPreferencesUpdatedEvent 2, // 5: chatto.core.v1.LiveEvent.notification_level_changed:type_name -> chatto.core.v1.NotificationLevelChangedEvent - 10, // 6: chatto.core.v1.LiveEvent.thread_follow_changed:type_name -> chatto.core.v1.ThreadFollowChangedEvent - 20, // 7: chatto.core.v1.LiveEvent.server_member_deleted:type_name -> chatto.core.v1.ServerMemberDeletedEvent + 11, // 6: chatto.core.v1.LiveEvent.thread_follow_changed:type_name -> chatto.core.v1.ThreadFollowChangedEvent + 21, // 7: chatto.core.v1.LiveEvent.server_member_deleted:type_name -> chatto.core.v1.ServerMemberDeletedEvent 3, // 8: chatto.core.v1.LiveEvent.server_updated:type_name -> chatto.core.v1.ServerUpdatedEvent 4, // 9: chatto.core.v1.LiveEvent.user_typing:type_name -> chatto.core.v1.UserTypingEvent 5, // 10: chatto.core.v1.LiveEvent.presence_changed:type_name -> chatto.core.v1.PresenceChangedEvent 6, // 11: chatto.core.v1.LiveEvent.mention_notification:type_name -> chatto.core.v1.MentionNotificationEvent 7, // 12: chatto.core.v1.LiveEvent.new_direct_message_notification:type_name -> chatto.core.v1.NewDirectMessageNotificationEvent - 21, // 13: chatto.core.v1.LiveEvent.call_participant_joined:type_name -> chatto.core.v1.CallParticipantJoinedEvent - 22, // 14: chatto.core.v1.LiveEvent.call_participant_left:type_name -> chatto.core.v1.CallParticipantLeftEvent + 22, // 13: chatto.core.v1.LiveEvent.call_participant_joined:type_name -> chatto.core.v1.CallParticipantJoinedEvent + 23, // 14: chatto.core.v1.LiveEvent.call_participant_left:type_name -> chatto.core.v1.CallParticipantLeftEvent 8, // 15: chatto.core.v1.LiveEvent.notification_created:type_name -> chatto.core.v1.NotificationCreatedEvent 9, // 16: chatto.core.v1.LiveEvent.notification_dismissed:type_name -> chatto.core.v1.NotificationDismissedEvent - 11, // 17: chatto.core.v1.LiveEvent.room_marked_as_read:type_name -> chatto.core.v1.RoomMarkedAsReadEvent - 12, // 18: chatto.core.v1.LiveEvent.mention_status_cleared:type_name -> chatto.core.v1.MentionStatusClearedEvent - 13, // 19: chatto.core.v1.LiveEvent.room_groups_updated:type_name -> chatto.core.v1.RoomGroupsUpdatedEvent - 14, // 20: chatto.core.v1.LiveEvent.session_terminated:type_name -> chatto.core.v1.SessionTerminatedEvent - 23, // 21: chatto.core.v1.NotificationLevelChangedEvent.level:type_name -> chatto.core.v1.NotificationLevel - 23, // 22: chatto.core.v1.NotificationLevelChangedEvent.effective_level:type_name -> chatto.core.v1.NotificationLevel - 23, // [23:23] is the sub-list for method output_type - 23, // [23:23] is the sub-list for method input_type - 23, // [23:23] is the sub-list for extension type_name - 23, // [23:23] is the sub-list for extension extendee - 0, // [0:23] is the sub-list for field type_name + 10, // 17: chatto.core.v1.LiveEvent.notification_occurrence_changed:type_name -> chatto.core.v1.NotificationOccurrenceChangedEvent + 12, // 18: chatto.core.v1.LiveEvent.room_marked_as_read:type_name -> chatto.core.v1.RoomMarkedAsReadEvent + 13, // 19: chatto.core.v1.LiveEvent.mention_status_cleared:type_name -> chatto.core.v1.MentionStatusClearedEvent + 14, // 20: chatto.core.v1.LiveEvent.room_groups_updated:type_name -> chatto.core.v1.RoomGroupsUpdatedEvent + 15, // 21: chatto.core.v1.LiveEvent.session_terminated:type_name -> chatto.core.v1.SessionTerminatedEvent + 24, // 22: chatto.core.v1.NotificationLevelChangedEvent.level:type_name -> chatto.core.v1.NotificationLevel + 24, // 23: chatto.core.v1.NotificationLevelChangedEvent.effective_level:type_name -> chatto.core.v1.NotificationLevel + 24, // [24:24] is the sub-list for method output_type + 24, // [24:24] is the sub-list for method input_type + 24, // [24:24] is the sub-list for extension type_name + 24, // [24:24] is the sub-list for extension extendee + 0, // [0:24] is the sub-list for field type_name } func init() { file_chatto_core_v1_live_events_proto_init() } @@ -1400,6 +1516,7 @@ func file_chatto_core_v1_live_events_proto_init() { (*LiveEvent_CallParticipantLeft)(nil), (*LiveEvent_NotificationCreated)(nil), (*LiveEvent_NotificationDismissed)(nil), + (*LiveEvent_NotificationOccurrenceChanged)(nil), (*LiveEvent_RoomMarkedAsRead)(nil), (*LiveEvent_MentionStatusCleared)(nil), (*LiveEvent_RoomGroupsUpdated)(nil), @@ -1412,7 +1529,7 @@ func file_chatto_core_v1_live_events_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chatto_core_v1_live_events_proto_rawDesc), len(file_chatto_core_v1_live_events_proto_rawDesc)), NumEnums: 0, - NumMessages: 15, + NumMessages: 16, NumExtensions: 0, NumServices: 0, }, diff --git a/cli/internal/pb/chatto/core/v1/notification.pb.go b/cli/internal/pb/chatto/core/v1/notification.pb.go index 4ab260ff3..d7bf3167a 100644 --- a/cli/internal/pb/chatto/core/v1/notification.pb.go +++ b/cli/internal/pb/chatto/core/v1/notification.pb.go @@ -22,6 +22,310 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// NotificationReason identifies why one source activity qualified for a +// recipient's notification list. One occurrence can retain several reasons. +type NotificationReason int32 + +const ( + NotificationReason_NOTIFICATION_REASON_UNSPECIFIED NotificationReason = 0 + NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE NotificationReason = 1 + NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION NotificationReason = 2 + NotificationReason_NOTIFICATION_REASON_REPLY NotificationReason = 3 + NotificationReason_NOTIFICATION_REASON_ROLE_MENTION NotificationReason = 4 + NotificationReason_NOTIFICATION_REASON_HERE NotificationReason = 5 + NotificationReason_NOTIFICATION_REASON_ALL NotificationReason = 6 + NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD NotificationReason = 7 + NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM NotificationReason = 8 + NotificationReason_NOTIFICATION_REASON_REACTION NotificationReason = 9 + NotificationReason_NOTIFICATION_REASON_ROOM_INVITATION NotificationReason = 10 +) + +// Enum value maps for NotificationReason. +var ( + NotificationReason_name = map[int32]string{ + 0: "NOTIFICATION_REASON_UNSPECIFIED", + 1: "NOTIFICATION_REASON_DIRECT_MESSAGE", + 2: "NOTIFICATION_REASON_DIRECT_MENTION", + 3: "NOTIFICATION_REASON_REPLY", + 4: "NOTIFICATION_REASON_ROLE_MENTION", + 5: "NOTIFICATION_REASON_HERE", + 6: "NOTIFICATION_REASON_ALL", + 7: "NOTIFICATION_REASON_FOLLOWED_THREAD", + 8: "NOTIFICATION_REASON_FOLLOWED_ROOM", + 9: "NOTIFICATION_REASON_REACTION", + 10: "NOTIFICATION_REASON_ROOM_INVITATION", + } + NotificationReason_value = map[string]int32{ + "NOTIFICATION_REASON_UNSPECIFIED": 0, + "NOTIFICATION_REASON_DIRECT_MESSAGE": 1, + "NOTIFICATION_REASON_DIRECT_MENTION": 2, + "NOTIFICATION_REASON_REPLY": 3, + "NOTIFICATION_REASON_ROLE_MENTION": 4, + "NOTIFICATION_REASON_HERE": 5, + "NOTIFICATION_REASON_ALL": 6, + "NOTIFICATION_REASON_FOLLOWED_THREAD": 7, + "NOTIFICATION_REASON_FOLLOWED_ROOM": 8, + "NOTIFICATION_REASON_REACTION": 9, + "NOTIFICATION_REASON_ROOM_INVITATION": 10, + } +) + +func (x NotificationReason) Enum() *NotificationReason { + p := new(NotificationReason) + *p = x + return p +} + +func (x NotificationReason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NotificationReason) Descriptor() protoreflect.EnumDescriptor { + return file_chatto_core_v1_notification_proto_enumTypes[0].Descriptor() +} + +func (NotificationReason) Type() protoreflect.EnumType { + return &file_chatto_core_v1_notification_proto_enumTypes[0] +} + +func (x NotificationReason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NotificationReason.Descriptor instead. +func (NotificationReason) EnumDescriptor() ([]byte, []int) { + return file_chatto_core_v1_notification_proto_rawDescGZIP(), []int{0} +} + +// NotificationDeliveryIntensity controls whether qualifying activity is +// omitted, recorded silently, or eligible for an interruptive alert. +type NotificationDeliveryIntensity int32 + +const ( + NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED NotificationDeliveryIntensity = 0 + NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF NotificationDeliveryIntensity = 1 + NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE NotificationDeliveryIntensity = 2 + NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT NotificationDeliveryIntensity = 3 +) + +// Enum value maps for NotificationDeliveryIntensity. +var ( + NotificationDeliveryIntensity_name = map[int32]string{ + 0: "NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED", + 1: "NOTIFICATION_DELIVERY_INTENSITY_OFF", + 2: "NOTIFICATION_DELIVERY_INTENSITY_BADGE", + 3: "NOTIFICATION_DELIVERY_INTENSITY_ALERT", + } + NotificationDeliveryIntensity_value = map[string]int32{ + "NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED": 0, + "NOTIFICATION_DELIVERY_INTENSITY_OFF": 1, + "NOTIFICATION_DELIVERY_INTENSITY_BADGE": 2, + "NOTIFICATION_DELIVERY_INTENSITY_ALERT": 3, + } +) + +func (x NotificationDeliveryIntensity) Enum() *NotificationDeliveryIntensity { + p := new(NotificationDeliveryIntensity) + *p = x + return p +} + +func (x NotificationDeliveryIntensity) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NotificationDeliveryIntensity) Descriptor() protoreflect.EnumDescriptor { + return file_chatto_core_v1_notification_proto_enumTypes[1].Descriptor() +} + +func (NotificationDeliveryIntensity) Type() protoreflect.EnumType { + return &file_chatto_core_v1_notification_proto_enumTypes[1] +} + +func (x NotificationDeliveryIntensity) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NotificationDeliveryIntensity.Descriptor instead. +func (NotificationDeliveryIntensity) EnumDescriptor() ([]byte, []int) { + return file_chatto_core_v1_notification_proto_rawDescGZIP(), []int{1} +} + +// NotificationInboxState is the persisted attention state. DONE is retained +// only for compatibility with Notifications 2.0 development records written +// before the single-list model; visible DONE records are treated as read. +type NotificationInboxState int32 + +const ( + NotificationInboxState_NOTIFICATION_INBOX_STATE_UNSPECIFIED NotificationInboxState = 0 + NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD NotificationInboxState = 1 + NotificationInboxState_NOTIFICATION_INBOX_STATE_READ NotificationInboxState = 2 + NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE NotificationInboxState = 3 +) + +// Enum value maps for NotificationInboxState. +var ( + NotificationInboxState_name = map[int32]string{ + 0: "NOTIFICATION_INBOX_STATE_UNSPECIFIED", + 1: "NOTIFICATION_INBOX_STATE_UNREAD", + 2: "NOTIFICATION_INBOX_STATE_READ", + 3: "NOTIFICATION_INBOX_STATE_DONE", + } + NotificationInboxState_value = map[string]int32{ + "NOTIFICATION_INBOX_STATE_UNSPECIFIED": 0, + "NOTIFICATION_INBOX_STATE_UNREAD": 1, + "NOTIFICATION_INBOX_STATE_READ": 2, + "NOTIFICATION_INBOX_STATE_DONE": 3, + } +) + +func (x NotificationInboxState) Enum() *NotificationInboxState { + p := new(NotificationInboxState) + *p = x + return p +} + +func (x NotificationInboxState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NotificationInboxState) Descriptor() protoreflect.EnumDescriptor { + return file_chatto_core_v1_notification_proto_enumTypes[2].Descriptor() +} + +func (NotificationInboxState) Type() protoreflect.EnumType { + return &file_chatto_core_v1_notification_proto_enumTypes[2] +} + +func (x NotificationInboxState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NotificationInboxState.Descriptor instead. +func (NotificationInboxState) EnumDescriptor() ([]byte, []int) { + return file_chatto_core_v1_notification_proto_rawDescGZIP(), []int{2} +} + +// NotificationRemovalReason explains why an occurrence is represented only by +// an anti-recreation tombstone. +type NotificationRemovalReason int32 + +const ( + NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED NotificationRemovalReason = 0 + NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_DELETED NotificationRemovalReason = 1 + NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_TARGET_RETRACTED NotificationRemovalReason = 2 + NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_REACTION_REMOVED NotificationRemovalReason = 3 + NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST NotificationRemovalReason = 4 + NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_ACCOUNT_DELETED NotificationRemovalReason = 5 +) + +// Enum value maps for NotificationRemovalReason. +var ( + NotificationRemovalReason_name = map[int32]string{ + 0: "NOTIFICATION_REMOVAL_REASON_UNSPECIFIED", + 1: "NOTIFICATION_REMOVAL_REASON_DELETED", + 2: "NOTIFICATION_REMOVAL_REASON_TARGET_RETRACTED", + 3: "NOTIFICATION_REMOVAL_REASON_REACTION_REMOVED", + 4: "NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST", + 5: "NOTIFICATION_REMOVAL_REASON_ACCOUNT_DELETED", + } + NotificationRemovalReason_value = map[string]int32{ + "NOTIFICATION_REMOVAL_REASON_UNSPECIFIED": 0, + "NOTIFICATION_REMOVAL_REASON_DELETED": 1, + "NOTIFICATION_REMOVAL_REASON_TARGET_RETRACTED": 2, + "NOTIFICATION_REMOVAL_REASON_REACTION_REMOVED": 3, + "NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST": 4, + "NOTIFICATION_REMOVAL_REASON_ACCOUNT_DELETED": 5, + } +) + +func (x NotificationRemovalReason) Enum() *NotificationRemovalReason { + p := new(NotificationRemovalReason) + *p = x + return p +} + +func (x NotificationRemovalReason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NotificationRemovalReason) Descriptor() protoreflect.EnumDescriptor { + return file_chatto_core_v1_notification_proto_enumTypes[3].Descriptor() +} + +func (NotificationRemovalReason) Type() protoreflect.EnumType { + return &file_chatto_core_v1_notification_proto_enumTypes[3] +} + +func (x NotificationRemovalReason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NotificationRemovalReason.Descriptor instead. +func (NotificationRemovalReason) EnumDescriptor() ([]byte, []int) { + return file_chatto_core_v1_notification_proto_rawDescGZIP(), []int{3} +} + +// NotificationAlertState tracks recoverable interruptive delivery for an +// occurrence. List and badge visibility do not depend on this state. +type NotificationAlertState int32 + +const ( + NotificationAlertState_NOTIFICATION_ALERT_STATE_UNSPECIFIED NotificationAlertState = 0 + NotificationAlertState_NOTIFICATION_ALERT_STATE_NOT_APPLICABLE NotificationAlertState = 1 + NotificationAlertState_NOTIFICATION_ALERT_STATE_PENDING NotificationAlertState = 2 + NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED NotificationAlertState = 3 + NotificationAlertState_NOTIFICATION_ALERT_STATE_DELIVERED NotificationAlertState = 4 + NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED NotificationAlertState = 5 +) + +// Enum value maps for NotificationAlertState. +var ( + NotificationAlertState_name = map[int32]string{ + 0: "NOTIFICATION_ALERT_STATE_UNSPECIFIED", + 1: "NOTIFICATION_ALERT_STATE_NOT_APPLICABLE", + 2: "NOTIFICATION_ALERT_STATE_PENDING", + 3: "NOTIFICATION_ALERT_STATE_CLAIMED", + 4: "NOTIFICATION_ALERT_STATE_DELIVERED", + 5: "NOTIFICATION_ALERT_STATE_SILENCED", + } + NotificationAlertState_value = map[string]int32{ + "NOTIFICATION_ALERT_STATE_UNSPECIFIED": 0, + "NOTIFICATION_ALERT_STATE_NOT_APPLICABLE": 1, + "NOTIFICATION_ALERT_STATE_PENDING": 2, + "NOTIFICATION_ALERT_STATE_CLAIMED": 3, + "NOTIFICATION_ALERT_STATE_DELIVERED": 4, + "NOTIFICATION_ALERT_STATE_SILENCED": 5, + } +) + +func (x NotificationAlertState) Enum() *NotificationAlertState { + p := new(NotificationAlertState) + *p = x + return p +} + +func (x NotificationAlertState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NotificationAlertState) Descriptor() protoreflect.EnumDescriptor { + return file_chatto_core_v1_notification_proto_enumTypes[4].Descriptor() +} + +func (NotificationAlertState) Type() protoreflect.EnumType { + return &file_chatto_core_v1_notification_proto_enumTypes[4] +} + +func (x NotificationAlertState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NotificationAlertState.Descriptor instead. +func (NotificationAlertState) EnumDescriptor() ([]byte, []int) { + return file_chatto_core_v1_notification_proto_rawDescGZIP(), []int{4} +} + // Notification is the unified wrapper for all notification types. // // Notifications are stored in the RUNTIME_STATE KV bucket with key format: @@ -435,6 +739,318 @@ func (x *RoomMessageNotification) GetEventId() string { return "" } +// NotificationReasonMatch records one matched cause and its effective policy +// result at the source activity's evaluation boundary. +type NotificationReasonMatch struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reason NotificationReason `protobuf:"varint,1,opt,name=reason,proto3,enum=chatto.core.v1.NotificationReason" json:"reason,omitempty"` + Intensity NotificationDeliveryIntensity `protobuf:"varint,2,opt,name=intensity,proto3,enum=chatto.core.v1.NotificationDeliveryIntensity" json:"intensity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationReasonMatch) Reset() { + *x = NotificationReasonMatch{} + mi := &file_chatto_core_v1_notification_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotificationReasonMatch) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationReasonMatch) ProtoMessage() {} + +func (x *NotificationReasonMatch) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_notification_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotificationReasonMatch.ProtoReflect.Descriptor instead. +func (*NotificationReasonMatch) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_notification_proto_rawDescGZIP(), []int{5} +} + +func (x *NotificationReasonMatch) GetReason() NotificationReason { + if x != nil { + return x.Reason + } + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED +} + +func (x *NotificationReasonMatch) GetIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.Intensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +// NotificationTarget identifies the exact activity destination without +// copying message, room, or user presentation data. +type NotificationTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + ThreadRootEventId *string `protobuf:"bytes,2,opt,name=thread_root_event_id,json=threadRootEventId,proto3,oneof" json:"thread_root_event_id,omitempty"` + EventId string `protobuf:"bytes,3,opt,name=event_id,json=eventId,proto3" json:"event_id,omitempty"` + ParentEventId *string `protobuf:"bytes,4,opt,name=parent_event_id,json=parentEventId,proto3,oneof" json:"parent_event_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationTarget) Reset() { + *x = NotificationTarget{} + mi := &file_chatto_core_v1_notification_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotificationTarget) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationTarget) ProtoMessage() {} + +func (x *NotificationTarget) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_notification_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotificationTarget.ProtoReflect.Descriptor instead. +func (*NotificationTarget) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_notification_proto_rawDescGZIP(), []int{6} +} + +func (x *NotificationTarget) GetRoomId() string { + if x != nil { + return x.RoomId + } + return "" +} + +func (x *NotificationTarget) GetThreadRootEventId() string { + if x != nil && x.ThreadRootEventId != nil { + return *x.ThreadRootEventId + } + return "" +} + +func (x *NotificationTarget) GetEventId() string { + if x != nil { + return x.EventId + } + return "" +} + +func (x *NotificationTarget) GetParentEventId() string { + if x != nil && x.ParentEventId != nil { + return *x.ParentEventId + } + return "" +} + +// NotificationOccurrence is recipient-specific bounded runtime state derived +// from one canonical source event. It is stored under a deterministic +// notification_v2 key with an absolute 90-day lifetime. +type NotificationOccurrence struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + RecipientId string `protobuf:"bytes,2,opt,name=recipient_id,json=recipientId,proto3" json:"recipient_id,omitempty"` + SourceEventId string `protobuf:"bytes,3,opt,name=source_event_id,json=sourceEventId,proto3" json:"source_event_id,omitempty"` + SourceCreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=source_created_at,json=sourceCreatedAt,proto3" json:"source_created_at,omitempty"` + ActorId string `protobuf:"bytes,5,opt,name=actor_id,json=actorId,proto3" json:"actor_id,omitempty"` + Target *NotificationTarget `protobuf:"bytes,6,opt,name=target,proto3" json:"target,omitempty"` + Reasons []*NotificationReasonMatch `protobuf:"bytes,7,rep,name=reasons,proto3" json:"reasons,omitempty"` + StrongestIntensity NotificationDeliveryIntensity `protobuf:"varint,8,opt,name=strongest_intensity,json=strongestIntensity,proto3,enum=chatto.core.v1.NotificationDeliveryIntensity" json:"strongest_intensity,omitempty"` + InboxState NotificationInboxState `protobuf:"varint,9,opt,name=inbox_state,json=inboxState,proto3,enum=chatto.core.v1.NotificationInboxState" json:"inbox_state,omitempty"` + // Exact reaction discriminator for reaction-caused occurrences. Empty for + // every other cause. + ReactionEmoji string `protobuf:"bytes,10,opt,name=reaction_emoji,json=reactionEmoji,proto3" json:"reaction_emoji,omitempty"` + EvaluatedAt *timestamppb.Timestamp `protobuf:"bytes,11,opt,name=evaluated_at,json=evaluatedAt,proto3" json:"evaluated_at,omitempty"` + UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,12,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + ExpiresAt *timestamppb.Timestamp `protobuf:"bytes,13,opt,name=expires_at,json=expiresAt,proto3" json:"expires_at,omitempty"` + RemovalReason NotificationRemovalReason `protobuf:"varint,14,opt,name=removal_reason,json=removalReason,proto3,enum=chatto.core.v1.NotificationRemovalReason" json:"removal_reason,omitempty"` + RemovedAt *timestamppb.Timestamp `protobuf:"bytes,15,opt,name=removed_at,json=removedAt,proto3" json:"removed_at,omitempty"` + AlertState NotificationAlertState `protobuf:"varint,16,opt,name=alert_state,json=alertState,proto3,enum=chatto.core.v1.NotificationAlertState" json:"alert_state,omitempty"` + AlertClaimedUntil *timestamppb.Timestamp `protobuf:"bytes,17,opt,name=alert_claimed_until,json=alertClaimedUntil,proto3" json:"alert_claimed_until,omitempty"` + // Internal EVT stream position of the source fact. Used for causal lifecycle + // cleanup and read-boundary reconciliation; never exposed through the public + // API. + SourceStreamSequence uint64 `protobuf:"varint,18,opt,name=source_stream_sequence,json=sourceStreamSequence,proto3" json:"source_stream_sequence,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationOccurrence) Reset() { + *x = NotificationOccurrence{} + mi := &file_chatto_core_v1_notification_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotificationOccurrence) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationOccurrence) ProtoMessage() {} + +func (x *NotificationOccurrence) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_notification_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotificationOccurrence.ProtoReflect.Descriptor instead. +func (*NotificationOccurrence) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_notification_proto_rawDescGZIP(), []int{7} +} + +func (x *NotificationOccurrence) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NotificationOccurrence) GetRecipientId() string { + if x != nil { + return x.RecipientId + } + return "" +} + +func (x *NotificationOccurrence) GetSourceEventId() string { + if x != nil { + return x.SourceEventId + } + return "" +} + +func (x *NotificationOccurrence) GetSourceCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.SourceCreatedAt + } + return nil +} + +func (x *NotificationOccurrence) GetActorId() string { + if x != nil { + return x.ActorId + } + return "" +} + +func (x *NotificationOccurrence) GetTarget() *NotificationTarget { + if x != nil { + return x.Target + } + return nil +} + +func (x *NotificationOccurrence) GetReasons() []*NotificationReasonMatch { + if x != nil { + return x.Reasons + } + return nil +} + +func (x *NotificationOccurrence) GetStrongestIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.StrongestIntensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +func (x *NotificationOccurrence) GetInboxState() NotificationInboxState { + if x != nil { + return x.InboxState + } + return NotificationInboxState_NOTIFICATION_INBOX_STATE_UNSPECIFIED +} + +func (x *NotificationOccurrence) GetReactionEmoji() string { + if x != nil { + return x.ReactionEmoji + } + return "" +} + +func (x *NotificationOccurrence) GetEvaluatedAt() *timestamppb.Timestamp { + if x != nil { + return x.EvaluatedAt + } + return nil +} + +func (x *NotificationOccurrence) GetUpdatedAt() *timestamppb.Timestamp { + if x != nil { + return x.UpdatedAt + } + return nil +} + +func (x *NotificationOccurrence) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +func (x *NotificationOccurrence) GetRemovalReason() NotificationRemovalReason { + if x != nil { + return x.RemovalReason + } + return NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED +} + +func (x *NotificationOccurrence) GetRemovedAt() *timestamppb.Timestamp { + if x != nil { + return x.RemovedAt + } + return nil +} + +func (x *NotificationOccurrence) GetAlertState() NotificationAlertState { + if x != nil { + return x.AlertState + } + return NotificationAlertState_NOTIFICATION_ALERT_STATE_UNSPECIFIED +} + +func (x *NotificationOccurrence) GetAlertClaimedUntil() *timestamppb.Timestamp { + if x != nil { + return x.AlertClaimedUntil + } + return nil +} + +func (x *NotificationOccurrence) GetSourceStreamSequence() uint64 { + if x != nil { + return x.SourceStreamSequence + } + return 0 +} + var File_chatto_core_v1_notification_proto protoreflect.FileDescriptor const file_chatto_core_v1_notification_proto_rawDesc = "" + @@ -466,7 +1082,79 @@ const file_chatto_core_v1_notification_proto_rawDesc = "" + "\tin_thread\x18\x05 \x01(\tR\binThreadJ\x04\b\x01\x10\x02R\bspace_id\"]\n" + "\x17RoomMessageNotification\x12\x17\n" + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12\x19\n" + - "\bevent_id\x18\x03 \x01(\tR\aeventIdJ\x04\b\x01\x10\x02R\bspace_idB\xb4\x01\n" + + "\bevent_id\x18\x03 \x01(\tR\aeventIdJ\x04\b\x01\x10\x02R\bspace_id\"\xa2\x01\n" + + "\x17NotificationReasonMatch\x12:\n" + + "\x06reason\x18\x01 \x01(\x0e2\".chatto.core.v1.NotificationReasonR\x06reason\x12K\n" + + "\tintensity\x18\x02 \x01(\x0e2-.chatto.core.v1.NotificationDeliveryIntensityR\tintensity\"\xd8\x01\n" + + "\x12NotificationTarget\x12\x17\n" + + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x124\n" + + "\x14thread_root_event_id\x18\x02 \x01(\tH\x00R\x11threadRootEventId\x88\x01\x01\x12\x19\n" + + "\bevent_id\x18\x03 \x01(\tR\aeventId\x12+\n" + + "\x0fparent_event_id\x18\x04 \x01(\tH\x01R\rparentEventId\x88\x01\x01B\x17\n" + + "\x15_thread_root_event_idB\x12\n" + + "\x10_parent_event_id\"\xb2\b\n" + + "\x16NotificationOccurrence\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12!\n" + + "\frecipient_id\x18\x02 \x01(\tR\vrecipientId\x12&\n" + + "\x0fsource_event_id\x18\x03 \x01(\tR\rsourceEventId\x12F\n" + + "\x11source_created_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x0fsourceCreatedAt\x12\x19\n" + + "\bactor_id\x18\x05 \x01(\tR\aactorId\x12:\n" + + "\x06target\x18\x06 \x01(\v2\".chatto.core.v1.NotificationTargetR\x06target\x12A\n" + + "\areasons\x18\a \x03(\v2'.chatto.core.v1.NotificationReasonMatchR\areasons\x12^\n" + + "\x13strongest_intensity\x18\b \x01(\x0e2-.chatto.core.v1.NotificationDeliveryIntensityR\x12strongestIntensity\x12G\n" + + "\vinbox_state\x18\t \x01(\x0e2&.chatto.core.v1.NotificationInboxStateR\n" + + "inboxState\x12%\n" + + "\x0ereaction_emoji\x18\n" + + " \x01(\tR\rreactionEmoji\x12=\n" + + "\fevaluated_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\vevaluatedAt\x129\n" + + "\n" + + "updated_at\x18\f \x01(\v2\x1a.google.protobuf.TimestampR\tupdatedAt\x129\n" + + "\n" + + "expires_at\x18\r \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\x12P\n" + + "\x0eremoval_reason\x18\x0e \x01(\x0e2).chatto.core.v1.NotificationRemovalReasonR\rremovalReason\x129\n" + + "\n" + + "removed_at\x18\x0f \x01(\v2\x1a.google.protobuf.TimestampR\tremovedAt\x12G\n" + + "\valert_state\x18\x10 \x01(\x0e2&.chatto.core.v1.NotificationAlertStateR\n" + + "alertState\x12J\n" + + "\x13alert_claimed_until\x18\x11 \x01(\v2\x1a.google.protobuf.TimestampR\x11alertClaimedUntil\x124\n" + + "\x16source_stream_sequence\x18\x12 \x01(\x04R\x14sourceStreamSequence*\xa4\x03\n" + + "\x12NotificationReason\x12#\n" + + "\x1fNOTIFICATION_REASON_UNSPECIFIED\x10\x00\x12&\n" + + "\"NOTIFICATION_REASON_DIRECT_MESSAGE\x10\x01\x12&\n" + + "\"NOTIFICATION_REASON_DIRECT_MENTION\x10\x02\x12\x1d\n" + + "\x19NOTIFICATION_REASON_REPLY\x10\x03\x12$\n" + + " NOTIFICATION_REASON_ROLE_MENTION\x10\x04\x12\x1c\n" + + "\x18NOTIFICATION_REASON_HERE\x10\x05\x12\x1b\n" + + "\x17NOTIFICATION_REASON_ALL\x10\x06\x12'\n" + + "#NOTIFICATION_REASON_FOLLOWED_THREAD\x10\a\x12%\n" + + "!NOTIFICATION_REASON_FOLLOWED_ROOM\x10\b\x12 \n" + + "\x1cNOTIFICATION_REASON_REACTION\x10\t\x12'\n" + + "#NOTIFICATION_REASON_ROOM_INVITATION\x10\n" + + "*\xcf\x01\n" + + "\x1dNotificationDeliveryIntensity\x12/\n" + + "+NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED\x10\x00\x12'\n" + + "#NOTIFICATION_DELIVERY_INTENSITY_OFF\x10\x01\x12)\n" + + "%NOTIFICATION_DELIVERY_INTENSITY_BADGE\x10\x02\x12)\n" + + "%NOTIFICATION_DELIVERY_INTENSITY_ALERT\x10\x03*\xad\x01\n" + + "\x16NotificationInboxState\x12(\n" + + "$NOTIFICATION_INBOX_STATE_UNSPECIFIED\x10\x00\x12#\n" + + "\x1fNOTIFICATION_INBOX_STATE_UNREAD\x10\x01\x12!\n" + + "\x1dNOTIFICATION_INBOX_STATE_READ\x10\x02\x12!\n" + + "\x1dNOTIFICATION_INBOX_STATE_DONE\x10\x03*\xb7\x02\n" + + "\x19NotificationRemovalReason\x12+\n" + + "'NOTIFICATION_REMOVAL_REASON_UNSPECIFIED\x10\x00\x12'\n" + + "#NOTIFICATION_REMOVAL_REASON_DELETED\x10\x01\x120\n" + + ",NOTIFICATION_REMOVAL_REASON_TARGET_RETRACTED\x10\x02\x120\n" + + ",NOTIFICATION_REMOVAL_REASON_REACTION_REMOVED\x10\x03\x12/\n" + + "+NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST\x10\x04\x12/\n" + + "+NOTIFICATION_REMOVAL_REASON_ACCOUNT_DELETED\x10\x05*\x8a\x02\n" + + "\x16NotificationAlertState\x12(\n" + + "$NOTIFICATION_ALERT_STATE_UNSPECIFIED\x10\x00\x12+\n" + + "'NOTIFICATION_ALERT_STATE_NOT_APPLICABLE\x10\x01\x12$\n" + + " NOTIFICATION_ALERT_STATE_PENDING\x10\x02\x12$\n" + + " NOTIFICATION_ALERT_STATE_CLAIMED\x10\x03\x12&\n" + + "\"NOTIFICATION_ALERT_STATE_DELIVERED\x10\x04\x12%\n" + + "!NOTIFICATION_ALERT_STATE_SILENCED\x10\x05B\xb4\x01\n" + "\x12com.chatto.core.v1B\x11NotificationProtoP\x01Z1hmans.de/chatto/internal/pb/chatto/core/v1;corev1\xa2\x02\x03CCX\xaa\x02\x0eChatto.Core.V1\xca\x02\x0eChatto\\Core\\V1\xe2\x02\x1aChatto\\Core\\V1\\GPBMetadata\xea\x02\x10Chatto::Core::V1b\x06proto3" var ( @@ -481,26 +1169,49 @@ func file_chatto_core_v1_notification_proto_rawDescGZIP() []byte { return file_chatto_core_v1_notification_proto_rawDescData } -var file_chatto_core_v1_notification_proto_msgTypes = make([]protoimpl.MessageInfo, 5) +var file_chatto_core_v1_notification_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_chatto_core_v1_notification_proto_msgTypes = make([]protoimpl.MessageInfo, 8) var file_chatto_core_v1_notification_proto_goTypes = []any{ - (*Notification)(nil), // 0: chatto.core.v1.Notification - (*DMMessageNotification)(nil), // 1: chatto.core.v1.DMMessageNotification - (*MentionNotification)(nil), // 2: chatto.core.v1.MentionNotification - (*ReplyNotification)(nil), // 3: chatto.core.v1.ReplyNotification - (*RoomMessageNotification)(nil), // 4: chatto.core.v1.RoomMessageNotification - (*timestamppb.Timestamp)(nil), // 5: google.protobuf.Timestamp + (NotificationReason)(0), // 0: chatto.core.v1.NotificationReason + (NotificationDeliveryIntensity)(0), // 1: chatto.core.v1.NotificationDeliveryIntensity + (NotificationInboxState)(0), // 2: chatto.core.v1.NotificationInboxState + (NotificationRemovalReason)(0), // 3: chatto.core.v1.NotificationRemovalReason + (NotificationAlertState)(0), // 4: chatto.core.v1.NotificationAlertState + (*Notification)(nil), // 5: chatto.core.v1.Notification + (*DMMessageNotification)(nil), // 6: chatto.core.v1.DMMessageNotification + (*MentionNotification)(nil), // 7: chatto.core.v1.MentionNotification + (*ReplyNotification)(nil), // 8: chatto.core.v1.ReplyNotification + (*RoomMessageNotification)(nil), // 9: chatto.core.v1.RoomMessageNotification + (*NotificationReasonMatch)(nil), // 10: chatto.core.v1.NotificationReasonMatch + (*NotificationTarget)(nil), // 11: chatto.core.v1.NotificationTarget + (*NotificationOccurrence)(nil), // 12: chatto.core.v1.NotificationOccurrence + (*timestamppb.Timestamp)(nil), // 13: google.protobuf.Timestamp } var file_chatto_core_v1_notification_proto_depIdxs = []int32{ - 5, // 0: chatto.core.v1.Notification.created_at:type_name -> google.protobuf.Timestamp - 1, // 1: chatto.core.v1.Notification.dm_message:type_name -> chatto.core.v1.DMMessageNotification - 2, // 2: chatto.core.v1.Notification.mention:type_name -> chatto.core.v1.MentionNotification - 3, // 3: chatto.core.v1.Notification.reply:type_name -> chatto.core.v1.ReplyNotification - 4, // 4: chatto.core.v1.Notification.room_message:type_name -> chatto.core.v1.RoomMessageNotification - 5, // [5:5] is the sub-list for method output_type - 5, // [5:5] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 13, // 0: chatto.core.v1.Notification.created_at:type_name -> google.protobuf.Timestamp + 6, // 1: chatto.core.v1.Notification.dm_message:type_name -> chatto.core.v1.DMMessageNotification + 7, // 2: chatto.core.v1.Notification.mention:type_name -> chatto.core.v1.MentionNotification + 8, // 3: chatto.core.v1.Notification.reply:type_name -> chatto.core.v1.ReplyNotification + 9, // 4: chatto.core.v1.Notification.room_message:type_name -> chatto.core.v1.RoomMessageNotification + 0, // 5: chatto.core.v1.NotificationReasonMatch.reason:type_name -> chatto.core.v1.NotificationReason + 1, // 6: chatto.core.v1.NotificationReasonMatch.intensity:type_name -> chatto.core.v1.NotificationDeliveryIntensity + 13, // 7: chatto.core.v1.NotificationOccurrence.source_created_at:type_name -> google.protobuf.Timestamp + 11, // 8: chatto.core.v1.NotificationOccurrence.target:type_name -> chatto.core.v1.NotificationTarget + 10, // 9: chatto.core.v1.NotificationOccurrence.reasons:type_name -> chatto.core.v1.NotificationReasonMatch + 1, // 10: chatto.core.v1.NotificationOccurrence.strongest_intensity:type_name -> chatto.core.v1.NotificationDeliveryIntensity + 2, // 11: chatto.core.v1.NotificationOccurrence.inbox_state:type_name -> chatto.core.v1.NotificationInboxState + 13, // 12: chatto.core.v1.NotificationOccurrence.evaluated_at:type_name -> google.protobuf.Timestamp + 13, // 13: chatto.core.v1.NotificationOccurrence.updated_at:type_name -> google.protobuf.Timestamp + 13, // 14: chatto.core.v1.NotificationOccurrence.expires_at:type_name -> google.protobuf.Timestamp + 3, // 15: chatto.core.v1.NotificationOccurrence.removal_reason:type_name -> chatto.core.v1.NotificationRemovalReason + 13, // 16: chatto.core.v1.NotificationOccurrence.removed_at:type_name -> google.protobuf.Timestamp + 4, // 17: chatto.core.v1.NotificationOccurrence.alert_state:type_name -> chatto.core.v1.NotificationAlertState + 13, // 18: chatto.core.v1.NotificationOccurrence.alert_claimed_until:type_name -> google.protobuf.Timestamp + 19, // [19:19] is the sub-list for method output_type + 19, // [19:19] is the sub-list for method input_type + 19, // [19:19] is the sub-list for extension type_name + 19, // [19:19] is the sub-list for extension extendee + 0, // [0:19] is the sub-list for field type_name } func init() { file_chatto_core_v1_notification_proto_init() } @@ -514,18 +1225,20 @@ func file_chatto_core_v1_notification_proto_init() { (*Notification_Reply)(nil), (*Notification_RoomMessage)(nil), } + file_chatto_core_v1_notification_proto_msgTypes[6].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chatto_core_v1_notification_proto_rawDesc), len(file_chatto_core_v1_notification_proto_rawDesc)), - NumEnums: 0, - NumMessages: 5, + NumEnums: 5, + NumMessages: 8, NumExtensions: 0, NumServices: 0, }, GoTypes: file_chatto_core_v1_notification_proto_goTypes, DependencyIndexes: file_chatto_core_v1_notification_proto_depIdxs, + EnumInfos: file_chatto_core_v1_notification_proto_enumTypes, MessageInfos: file_chatto_core_v1_notification_proto_msgTypes, }.Build() File_chatto_core_v1_notification_proto = out.File diff --git a/cli/internal/pb/chatto/core/v1/projection_snapshots.pb.go b/cli/internal/pb/chatto/core/v1/projection_snapshots.pb.go index e4c8639f2..686f2f094 100644 --- a/cli/internal/pb/chatto/core/v1/projection_snapshots.pb.go +++ b/cli/internal/pb/chatto/core/v1/projection_snapshots.pb.go @@ -983,6 +983,68 @@ func (x *RoomGroupStateSnapshot) GetGroup() *RoomGroup { return nil } +// NotificationVisibilityProjectionSnapshot combines the exact authorization +// state needed to enforce persistent notification visibility boundaries. +type NotificationVisibilityProjectionSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomDirectory *RoomDirectoryProjectionSnapshot `protobuf:"bytes,1,opt,name=room_directory,json=roomDirectory,proto3" json:"room_directory,omitempty"` + RoomGroupLayout *RoomGroupLayoutProjectionSnapshot `protobuf:"bytes,2,opt,name=room_group_layout,json=roomGroupLayout,proto3" json:"room_group_layout,omitempty"` + Rbac *RBACProjectionSnapshot `protobuf:"bytes,3,opt,name=rbac,proto3" json:"rbac,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationVisibilityProjectionSnapshot) Reset() { + *x = NotificationVisibilityProjectionSnapshot{} + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotificationVisibilityProjectionSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationVisibilityProjectionSnapshot) ProtoMessage() {} + +func (x *NotificationVisibilityProjectionSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use NotificationVisibilityProjectionSnapshot.ProtoReflect.Descriptor instead. +func (*NotificationVisibilityProjectionSnapshot) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{13} +} + +func (x *NotificationVisibilityProjectionSnapshot) GetRoomDirectory() *RoomDirectoryProjectionSnapshot { + if x != nil { + return x.RoomDirectory + } + return nil +} + +func (x *NotificationVisibilityProjectionSnapshot) GetRoomGroupLayout() *RoomGroupLayoutProjectionSnapshot { + if x != nil { + return x.RoomGroupLayout + } + return nil +} + +func (x *NotificationVisibilityProjectionSnapshot) GetRbac() *RBACProjectionSnapshot { + if x != nil { + return x.Rbac + } + return nil +} + type CallStateProjectionSnapshot struct { state protoimpl.MessageState `protogen:"open.v1"` Rooms []*CallRoomStateSnapshot `protobuf:"bytes,1,rep,name=rooms,proto3" json:"rooms,omitempty"` @@ -992,7 +1054,7 @@ type CallStateProjectionSnapshot struct { func (x *CallStateProjectionSnapshot) Reset() { *x = CallStateProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[13] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1004,7 +1066,7 @@ func (x *CallStateProjectionSnapshot) String() string { func (*CallStateProjectionSnapshot) ProtoMessage() {} func (x *CallStateProjectionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[13] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[14] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1017,7 +1079,7 @@ func (x *CallStateProjectionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use CallStateProjectionSnapshot.ProtoReflect.Descriptor instead. func (*CallStateProjectionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{13} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{14} } func (x *CallStateProjectionSnapshot) GetRooms() []*CallRoomStateSnapshot { @@ -1039,7 +1101,7 @@ type CallRoomStateSnapshot struct { func (x *CallRoomStateSnapshot) Reset() { *x = CallRoomStateSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[14] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1051,7 +1113,7 @@ func (x *CallRoomStateSnapshot) String() string { func (*CallRoomStateSnapshot) ProtoMessage() {} func (x *CallRoomStateSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[14] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[15] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1064,7 +1126,7 @@ func (x *CallRoomStateSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use CallRoomStateSnapshot.ProtoReflect.Descriptor instead. func (*CallRoomStateSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{14} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{15} } func (x *CallRoomStateSnapshot) GetRoomId() string { @@ -1107,7 +1169,7 @@ type CallSessionSnapshot struct { func (x *CallSessionSnapshot) Reset() { *x = CallSessionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[15] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1119,7 +1181,7 @@ func (x *CallSessionSnapshot) String() string { func (*CallSessionSnapshot) ProtoMessage() {} func (x *CallSessionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[15] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[16] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1132,7 +1194,7 @@ func (x *CallSessionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use CallSessionSnapshot.ProtoReflect.Descriptor instead. func (*CallSessionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{15} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{16} } func (x *CallSessionSnapshot) GetCallId() string { @@ -1175,7 +1237,7 @@ type CallParticipantSnapshot struct { func (x *CallParticipantSnapshot) Reset() { *x = CallParticipantSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[16] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1187,7 +1249,7 @@ func (x *CallParticipantSnapshot) String() string { func (*CallParticipantSnapshot) ProtoMessage() {} func (x *CallParticipantSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[16] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[17] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1200,7 +1262,7 @@ func (x *CallParticipantSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use CallParticipantSnapshot.ProtoReflect.Descriptor instead. func (*CallParticipantSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{16} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{17} } func (x *CallParticipantSnapshot) GetUserId() string { @@ -1242,7 +1304,7 @@ type ContentKeyProjectionSnapshot struct { func (x *ContentKeyProjectionSnapshot) Reset() { *x = ContentKeyProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[17] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1254,7 +1316,7 @@ func (x *ContentKeyProjectionSnapshot) String() string { func (*ContentKeyProjectionSnapshot) ProtoMessage() {} func (x *ContentKeyProjectionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[17] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[18] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1267,7 +1329,7 @@ func (x *ContentKeyProjectionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use ContentKeyProjectionSnapshot.ProtoReflect.Descriptor instead. func (*ContentKeyProjectionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{17} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{18} } func (x *ContentKeyProjectionSnapshot) GetKeys() []*UserDEKGeneratedEvent { @@ -1303,7 +1365,7 @@ type RBACProjectionSnapshot struct { func (x *RBACProjectionSnapshot) Reset() { *x = RBACProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[18] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1315,7 +1377,7 @@ func (x *RBACProjectionSnapshot) String() string { func (*RBACProjectionSnapshot) ProtoMessage() {} func (x *RBACProjectionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[18] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[19] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1328,7 +1390,7 @@ func (x *RBACProjectionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use RBACProjectionSnapshot.ProtoReflect.Descriptor instead. func (*RBACProjectionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{18} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{19} } func (x *RBACProjectionSnapshot) GetRoles() []*Role { @@ -1369,7 +1431,7 @@ type RBACAssignmentSnapshot struct { func (x *RBACAssignmentSnapshot) Reset() { *x = RBACAssignmentSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[19] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1381,7 +1443,7 @@ func (x *RBACAssignmentSnapshot) String() string { func (*RBACAssignmentSnapshot) ProtoMessage() {} func (x *RBACAssignmentSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[19] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1394,7 +1456,7 @@ func (x *RBACAssignmentSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use RBACAssignmentSnapshot.ProtoReflect.Descriptor instead. func (*RBACAssignmentSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{19} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{20} } func (x *RBACAssignmentSnapshot) GetUserId() string { @@ -1425,7 +1487,7 @@ type RBACDecisionSnapshot struct { func (x *RBACDecisionSnapshot) Reset() { *x = RBACDecisionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[20] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1437,7 +1499,7 @@ func (x *RBACDecisionSnapshot) String() string { func (*RBACDecisionSnapshot) ProtoMessage() {} func (x *RBACDecisionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[20] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1450,7 +1512,7 @@ func (x *RBACDecisionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use RBACDecisionSnapshot.ProtoReflect.Descriptor instead. func (*RBACDecisionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{20} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{21} } func (x *RBACDecisionSnapshot) GetScope() string { @@ -1511,7 +1573,7 @@ type ConfigProjectionSnapshot struct { func (x *ConfigProjectionSnapshot) Reset() { *x = ConfigProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[21] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1523,7 +1585,7 @@ func (x *ConfigProjectionSnapshot) String() string { func (*ConfigProjectionSnapshot) ProtoMessage() {} func (x *ConfigProjectionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[21] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1536,7 +1598,7 @@ func (x *ConfigProjectionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use ConfigProjectionSnapshot.ProtoReflect.Descriptor instead. func (*ConfigProjectionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{21} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{22} } func (x *ConfigProjectionSnapshot) GetServerName() string { @@ -1596,19 +1658,19 @@ func (x *ConfigProjectionSnapshot) GetUsers() []*UserConfigSnapshot { } type UserConfigSnapshot struct { - state protoimpl.MessageState `protogen:"open.v1"` - UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` - Timezone *string `protobuf:"bytes,2,opt,name=timezone,proto3,oneof" json:"timezone,omitempty"` - TimeFormat *TimeFormat `protobuf:"varint,3,opt,name=time_format,json=timeFormat,proto3,enum=chatto.core.v1.TimeFormat,oneof" json:"time_format,omitempty"` - ServerNotificationLevel *NotificationLevel `protobuf:"varint,4,opt,name=server_notification_level,json=serverNotificationLevel,proto3,enum=chatto.core.v1.NotificationLevel,oneof" json:"server_notification_level,omitempty"` - RoomNotificationLevels []*RoomNotificationLevelSnapshot `protobuf:"bytes,5,rep,name=room_notification_levels,json=roomNotificationLevels,proto3" json:"room_notification_levels,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + Timezone *string `protobuf:"bytes,2,opt,name=timezone,proto3,oneof" json:"timezone,omitempty"` + TimeFormat *TimeFormat `protobuf:"varint,3,opt,name=time_format,json=timeFormat,proto3,enum=chatto.core.v1.TimeFormat,oneof" json:"time_format,omitempty"` + ServerNotificationPreferences []*NotificationPreferenceSnapshot `protobuf:"bytes,6,rep,name=server_notification_preferences,json=serverNotificationPreferences,proto3" json:"server_notification_preferences,omitempty"` + RoomNotificationPreferences []*RoomNotificationPreferenceSnapshot `protobuf:"bytes,7,rep,name=room_notification_preferences,json=roomNotificationPreferences,proto3" json:"room_notification_preferences,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *UserConfigSnapshot) Reset() { *x = UserConfigSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[22] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1620,7 +1682,7 @@ func (x *UserConfigSnapshot) String() string { func (*UserConfigSnapshot) ProtoMessage() {} func (x *UserConfigSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[22] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1633,7 +1695,7 @@ func (x *UserConfigSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use UserConfigSnapshot.ProtoReflect.Descriptor instead. func (*UserConfigSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{22} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{23} } func (x *UserConfigSnapshot) GetUserId() string { @@ -1657,43 +1719,43 @@ func (x *UserConfigSnapshot) GetTimeFormat() TimeFormat { return TimeFormat_TIME_FORMAT_UNSPECIFIED } -func (x *UserConfigSnapshot) GetServerNotificationLevel() NotificationLevel { - if x != nil && x.ServerNotificationLevel != nil { - return *x.ServerNotificationLevel +func (x *UserConfigSnapshot) GetServerNotificationPreferences() []*NotificationPreferenceSnapshot { + if x != nil { + return x.ServerNotificationPreferences } - return NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED + return nil } -func (x *UserConfigSnapshot) GetRoomNotificationLevels() []*RoomNotificationLevelSnapshot { +func (x *UserConfigSnapshot) GetRoomNotificationPreferences() []*RoomNotificationPreferenceSnapshot { if x != nil { - return x.RoomNotificationLevels + return x.RoomNotificationPreferences } return nil } -type RoomNotificationLevelSnapshot struct { - state protoimpl.MessageState `protogen:"open.v1"` - RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - Level NotificationLevel `protobuf:"varint,2,opt,name=level,proto3,enum=chatto.core.v1.NotificationLevel" json:"level,omitempty"` +type NotificationPreferenceSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + Reason NotificationReason `protobuf:"varint,1,opt,name=reason,proto3,enum=chatto.core.v1.NotificationReason" json:"reason,omitempty"` + Intensity NotificationDeliveryIntensity `protobuf:"varint,2,opt,name=intensity,proto3,enum=chatto.core.v1.NotificationDeliveryIntensity" json:"intensity,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *RoomNotificationLevelSnapshot) Reset() { - *x = RoomNotificationLevelSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[23] +func (x *NotificationPreferenceSnapshot) Reset() { + *x = NotificationPreferenceSnapshot{} + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *RoomNotificationLevelSnapshot) String() string { +func (x *NotificationPreferenceSnapshot) String() string { return protoimpl.X.MessageStringOf(x) } -func (*RoomNotificationLevelSnapshot) ProtoMessage() {} +func (*NotificationPreferenceSnapshot) ProtoMessage() {} -func (x *RoomNotificationLevelSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[23] +func (x *NotificationPreferenceSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1704,23 +1766,75 @@ func (x *RoomNotificationLevelSnapshot) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use RoomNotificationLevelSnapshot.ProtoReflect.Descriptor instead. -func (*RoomNotificationLevelSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{23} +// Deprecated: Use NotificationPreferenceSnapshot.ProtoReflect.Descriptor instead. +func (*NotificationPreferenceSnapshot) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{24} +} + +func (x *NotificationPreferenceSnapshot) GetReason() NotificationReason { + if x != nil { + return x.Reason + } + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED } -func (x *RoomNotificationLevelSnapshot) GetRoomId() string { +func (x *NotificationPreferenceSnapshot) GetIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.Intensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +type RoomNotificationPreferenceSnapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + Preferences []*NotificationPreferenceSnapshot `protobuf:"bytes,2,rep,name=preferences,proto3" json:"preferences,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RoomNotificationPreferenceSnapshot) Reset() { + *x = RoomNotificationPreferenceSnapshot{} + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RoomNotificationPreferenceSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RoomNotificationPreferenceSnapshot) ProtoMessage() {} + +func (x *RoomNotificationPreferenceSnapshot) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RoomNotificationPreferenceSnapshot.ProtoReflect.Descriptor instead. +func (*RoomNotificationPreferenceSnapshot) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{25} +} + +func (x *RoomNotificationPreferenceSnapshot) GetRoomId() string { if x != nil { return x.RoomId } return "" } -func (x *RoomNotificationLevelSnapshot) GetLevel() NotificationLevel { +func (x *RoomNotificationPreferenceSnapshot) GetPreferences() []*NotificationPreferenceSnapshot { if x != nil { - return x.Level + return x.Preferences } - return NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED + return nil } type AssetProjectionSnapshot struct { @@ -1738,7 +1852,7 @@ type AssetProjectionSnapshot struct { func (x *AssetProjectionSnapshot) Reset() { *x = AssetProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[24] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1750,7 +1864,7 @@ func (x *AssetProjectionSnapshot) String() string { func (*AssetProjectionSnapshot) ProtoMessage() {} func (x *AssetProjectionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[24] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1763,7 +1877,7 @@ func (x *AssetProjectionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetProjectionSnapshot.ProtoReflect.Descriptor instead. func (*AssetProjectionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{24} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{26} } func (x *AssetProjectionSnapshot) GetCreations() []*AssetCreatedEvent { @@ -1825,7 +1939,7 @@ type AssetChildrenSnapshot struct { func (x *AssetChildrenSnapshot) Reset() { *x = AssetChildrenSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[25] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1837,7 +1951,7 @@ func (x *AssetChildrenSnapshot) String() string { func (*AssetChildrenSnapshot) ProtoMessage() {} func (x *AssetChildrenSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[25] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1850,7 +1964,7 @@ func (x *AssetChildrenSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetChildrenSnapshot.ProtoReflect.Descriptor instead. func (*AssetChildrenSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{25} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{27} } func (x *AssetChildrenSnapshot) GetParentAssetId() string { @@ -1879,7 +1993,7 @@ type AssetManifestSnapshot struct { func (x *AssetManifestSnapshot) Reset() { *x = AssetManifestSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[26] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1891,7 +2005,7 @@ func (x *AssetManifestSnapshot) String() string { func (*AssetManifestSnapshot) ProtoMessage() {} func (x *AssetManifestSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[26] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1904,7 +2018,7 @@ func (x *AssetManifestSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetManifestSnapshot.ProtoReflect.Descriptor instead. func (*AssetManifestSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{26} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{28} } func (x *AssetManifestSnapshot) GetAssetId() string { @@ -1945,7 +2059,7 @@ type DeletedAssetSnapshot struct { func (x *DeletedAssetSnapshot) Reset() { *x = DeletedAssetSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[27] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1957,7 +2071,7 @@ func (x *DeletedAssetSnapshot) String() string { func (*DeletedAssetSnapshot) ProtoMessage() {} func (x *DeletedAssetSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[27] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1970,7 +2084,7 @@ func (x *DeletedAssetSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use DeletedAssetSnapshot.ProtoReflect.Descriptor instead. func (*DeletedAssetSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{27} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{29} } func (x *DeletedAssetSnapshot) GetAssetId() string { @@ -2001,7 +2115,7 @@ type ReactionProjectionSnapshot struct { func (x *ReactionProjectionSnapshot) Reset() { *x = ReactionProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[28] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2013,7 +2127,7 @@ func (x *ReactionProjectionSnapshot) String() string { func (*ReactionProjectionSnapshot) ProtoMessage() {} func (x *ReactionProjectionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[28] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2026,7 +2140,7 @@ func (x *ReactionProjectionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use ReactionProjectionSnapshot.ProtoReflect.Descriptor instead. func (*ReactionProjectionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{28} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{30} } func (x *ReactionProjectionSnapshot) GetMessages() []*MessageReactionsSnapshot { @@ -2081,7 +2195,7 @@ type MessageReactionsSnapshot struct { func (x *MessageReactionsSnapshot) Reset() { *x = MessageReactionsSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[29] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2093,7 +2207,7 @@ func (x *MessageReactionsSnapshot) String() string { func (*MessageReactionsSnapshot) ProtoMessage() {} func (x *MessageReactionsSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[29] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2106,7 +2220,7 @@ func (x *MessageReactionsSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use MessageReactionsSnapshot.ProtoReflect.Descriptor instead. func (*MessageReactionsSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{29} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{31} } func (x *MessageReactionsSnapshot) GetMessageEventId() string { @@ -2133,7 +2247,7 @@ type EmojiReactionsSnapshot struct { func (x *EmojiReactionsSnapshot) Reset() { *x = EmojiReactionsSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[30] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2145,7 +2259,7 @@ func (x *EmojiReactionsSnapshot) String() string { func (*EmojiReactionsSnapshot) ProtoMessage() {} func (x *EmojiReactionsSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[30] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2158,7 +2272,7 @@ func (x *EmojiReactionsSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use EmojiReactionsSnapshot.ProtoReflect.Descriptor instead. func (*EmojiReactionsSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{30} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{32} } func (x *EmojiReactionsSnapshot) GetEmoji() string { @@ -2179,13 +2293,14 @@ type UserReactionSnapshot struct { state protoimpl.MessageState `protogen:"open.v1"` UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` AddedAtNanos int64 `protobuf:"varint,2,opt,name=added_at_nanos,json=addedAtNanos,proto3" json:"added_at_nanos,omitempty"` + SourceEventId string `protobuf:"bytes,3,opt,name=source_event_id,json=sourceEventId,proto3" json:"source_event_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *UserReactionSnapshot) Reset() { *x = UserReactionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[31] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2197,7 +2312,7 @@ func (x *UserReactionSnapshot) String() string { func (*UserReactionSnapshot) ProtoMessage() {} func (x *UserReactionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[31] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2210,7 +2325,7 @@ func (x *UserReactionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use UserReactionSnapshot.ProtoReflect.Descriptor instead. func (*UserReactionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{31} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{33} } func (x *UserReactionSnapshot) GetUserId() string { @@ -2227,6 +2342,13 @@ func (x *UserReactionSnapshot) GetAddedAtNanos() int64 { return 0 } +func (x *UserReactionSnapshot) GetSourceEventId() string { + if x != nil { + return x.SourceEventId + } + return "" +} + type StringUint64Snapshot struct { state protoimpl.MessageState `protogen:"open.v1"` Key string `protobuf:"bytes,1,opt,name=key,proto3" json:"key,omitempty"` @@ -2237,7 +2359,7 @@ type StringUint64Snapshot struct { func (x *StringUint64Snapshot) Reset() { *x = StringUint64Snapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[32] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2249,7 +2371,7 @@ func (x *StringUint64Snapshot) String() string { func (*StringUint64Snapshot) ProtoMessage() {} func (x *StringUint64Snapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[32] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2262,7 +2384,7 @@ func (x *StringUint64Snapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use StringUint64Snapshot.ProtoReflect.Descriptor instead. func (*StringUint64Snapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{32} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{34} } func (x *StringUint64Snapshot) GetKey() string { @@ -2289,7 +2411,7 @@ type StringStringSnapshot struct { func (x *StringStringSnapshot) Reset() { *x = StringStringSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[33] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2301,7 +2423,7 @@ func (x *StringStringSnapshot) String() string { func (*StringStringSnapshot) ProtoMessage() {} func (x *StringStringSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[33] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2314,7 +2436,7 @@ func (x *StringStringSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use StringStringSnapshot.ProtoReflect.Descriptor instead. func (*StringStringSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{33} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{35} } func (x *StringStringSnapshot) GetKey() string { @@ -2342,7 +2464,7 @@ type MentionablesProjectionSnapshot struct { func (x *MentionablesProjectionSnapshot) Reset() { *x = MentionablesProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[34] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2354,7 +2476,7 @@ func (x *MentionablesProjectionSnapshot) String() string { func (*MentionablesProjectionSnapshot) ProtoMessage() {} func (x *MentionablesProjectionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[34] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2367,7 +2489,7 @@ func (x *MentionablesProjectionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use MentionablesProjectionSnapshot.ProtoReflect.Descriptor instead. func (*MentionablesProjectionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{34} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{36} } func (x *MentionablesProjectionSnapshot) GetUserLoginSources() []*Event { @@ -2407,7 +2529,7 @@ type UserProfileProjectionSnapshot struct { func (x *UserProfileProjectionSnapshot) Reset() { *x = UserProfileProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[35] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2419,7 +2541,7 @@ func (x *UserProfileProjectionSnapshot) String() string { func (*UserProfileProjectionSnapshot) ProtoMessage() {} func (x *UserProfileProjectionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[35] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2432,7 +2554,7 @@ func (x *UserProfileProjectionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use UserProfileProjectionSnapshot.ProtoReflect.Descriptor instead. func (*UserProfileProjectionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{35} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{37} } func (x *UserProfileProjectionSnapshot) GetUsers() []*ProjectedUserProfileSnapshot { @@ -2489,7 +2611,7 @@ type ProjectedUserProfileSnapshot struct { func (x *ProjectedUserProfileSnapshot) Reset() { *x = ProjectedUserProfileSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[36] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2501,7 +2623,7 @@ func (x *ProjectedUserProfileSnapshot) String() string { func (*ProjectedUserProfileSnapshot) ProtoMessage() {} func (x *ProjectedUserProfileSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[36] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2514,7 +2636,7 @@ func (x *ProjectedUserProfileSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectedUserProfileSnapshot.ProtoReflect.Descriptor instead. func (*ProjectedUserProfileSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{36} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{38} } func (x *ProjectedUserProfileSnapshot) GetUserId() string { @@ -2606,7 +2728,7 @@ type ProjectedEncryptedUserStringSnapshot struct { func (x *ProjectedEncryptedUserStringSnapshot) Reset() { *x = ProjectedEncryptedUserStringSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[37] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2618,7 +2740,7 @@ func (x *ProjectedEncryptedUserStringSnapshot) String() string { func (*ProjectedEncryptedUserStringSnapshot) ProtoMessage() {} func (x *ProjectedEncryptedUserStringSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[37] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2631,7 +2753,7 @@ func (x *ProjectedEncryptedUserStringSnapshot) ProtoReflect() protoreflect.Messa // Deprecated: Use ProjectedEncryptedUserStringSnapshot.ProtoReflect.Descriptor instead. func (*ProjectedEncryptedUserStringSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{37} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{39} } func (x *ProjectedEncryptedUserStringSnapshot) GetEventId() string { @@ -2673,7 +2795,7 @@ type ProjectedVerifiedEmailSnapshot struct { func (x *ProjectedVerifiedEmailSnapshot) Reset() { *x = ProjectedVerifiedEmailSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[38] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2685,7 +2807,7 @@ func (x *ProjectedVerifiedEmailSnapshot) String() string { func (*ProjectedVerifiedEmailSnapshot) ProtoMessage() {} func (x *ProjectedVerifiedEmailSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[38] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2698,7 +2820,7 @@ func (x *ProjectedVerifiedEmailSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use ProjectedVerifiedEmailSnapshot.ProtoReflect.Descriptor instead. func (*ProjectedVerifiedEmailSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{38} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{40} } func (x *ProjectedVerifiedEmailSnapshot) GetDigest() string { @@ -2740,7 +2862,7 @@ type RoomTimelineProjectionSnapshot struct { func (x *RoomTimelineProjectionSnapshot) Reset() { *x = RoomTimelineProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[39] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2752,7 +2874,7 @@ func (x *RoomTimelineProjectionSnapshot) String() string { func (*RoomTimelineProjectionSnapshot) ProtoMessage() {} func (x *RoomTimelineProjectionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[39] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2765,7 +2887,7 @@ func (x *RoomTimelineProjectionSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use RoomTimelineProjectionSnapshot.ProtoReflect.Descriptor instead. func (*RoomTimelineProjectionSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{39} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{41} } func (x *RoomTimelineProjectionSnapshot) GetEntries() []*TimelineEntrySnapshot { @@ -2851,7 +2973,7 @@ type PinnedMessageSnapshot struct { func (x *PinnedMessageSnapshot) Reset() { *x = PinnedMessageSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[40] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2863,7 +2985,7 @@ func (x *PinnedMessageSnapshot) String() string { func (*PinnedMessageSnapshot) ProtoMessage() {} func (x *PinnedMessageSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[40] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2876,7 +2998,7 @@ func (x *PinnedMessageSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use PinnedMessageSnapshot.ProtoReflect.Descriptor instead. func (*PinnedMessageSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{40} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{42} } func (x *PinnedMessageSnapshot) GetPinEventId() string { @@ -2919,7 +3041,7 @@ type LatestRoomPinSnapshot struct { func (x *LatestRoomPinSnapshot) Reset() { *x = LatestRoomPinSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[41] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2931,7 +3053,7 @@ func (x *LatestRoomPinSnapshot) String() string { func (*LatestRoomPinSnapshot) ProtoMessage() {} func (x *LatestRoomPinSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[41] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2944,7 +3066,7 @@ func (x *LatestRoomPinSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use LatestRoomPinSnapshot.ProtoReflect.Descriptor instead. func (*LatestRoomPinSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{41} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{43} } func (x *LatestRoomPinSnapshot) GetRoomId() string { @@ -2978,7 +3100,7 @@ type TimelineEntrySnapshot struct { func (x *TimelineEntrySnapshot) Reset() { *x = TimelineEntrySnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[42] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2990,7 +3112,7 @@ func (x *TimelineEntrySnapshot) String() string { func (*TimelineEntrySnapshot) ProtoMessage() {} func (x *TimelineEntrySnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[42] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3003,7 +3125,7 @@ func (x *TimelineEntrySnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use TimelineEntrySnapshot.ProtoReflect.Descriptor instead. func (*TimelineEntrySnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{42} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{44} } func (x *TimelineEntrySnapshot) GetStreamSequence() uint64 { @@ -3032,7 +3154,7 @@ type TimelineBodySnapshot struct { func (x *TimelineBodySnapshot) Reset() { *x = TimelineBodySnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[43] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3044,7 +3166,7 @@ func (x *TimelineBodySnapshot) String() string { func (*TimelineBodySnapshot) ProtoMessage() {} func (x *TimelineBodySnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[43] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3057,7 +3179,7 @@ func (x *TimelineBodySnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use TimelineBodySnapshot.ProtoReflect.Descriptor instead. func (*TimelineBodySnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{43} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{45} } func (x *TimelineBodySnapshot) GetMessageEventId() string { @@ -3098,7 +3220,7 @@ type StringTimestampSnapshot struct { func (x *StringTimestampSnapshot) Reset() { *x = StringTimestampSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[44] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[46] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3110,7 +3232,7 @@ func (x *StringTimestampSnapshot) String() string { func (*StringTimestampSnapshot) ProtoMessage() {} func (x *StringTimestampSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[44] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[46] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3123,7 +3245,7 @@ func (x *StringTimestampSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use StringTimestampSnapshot.ProtoReflect.Descriptor instead. func (*StringTimestampSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{44} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{46} } func (x *StringTimestampSnapshot) GetKey() string { @@ -3152,7 +3274,7 @@ type AssetMessageOwnerSnapshot struct { func (x *AssetMessageOwnerSnapshot) Reset() { *x = AssetMessageOwnerSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[45] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[47] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3164,7 +3286,7 @@ func (x *AssetMessageOwnerSnapshot) String() string { func (*AssetMessageOwnerSnapshot) ProtoMessage() {} func (x *AssetMessageOwnerSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[45] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[47] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3177,7 +3299,7 @@ func (x *AssetMessageOwnerSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetMessageOwnerSnapshot.ProtoReflect.Descriptor instead. func (*AssetMessageOwnerSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{45} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{47} } func (x *AssetMessageOwnerSnapshot) GetAssetId() string { @@ -3212,7 +3334,7 @@ var File_chatto_core_v1_projection_snapshots_proto protoreflect.FileDescriptor const file_chatto_core_v1_projection_snapshots_proto_rawDesc = "" + "\n" + - ")chatto/core/v1/projection_snapshots.proto\x12\x0echatto.core.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a!chatto/core/v1/asset_events.proto\x1a\x1achatto/core/v1/event.proto\x1a\x1bchatto/core/v1/models.proto\x1a chatto/core/v1/rbac_events.proto\x1a chatto/core/v1/room_events.proto\x1a chatto/core/v1/user_events.proto\x1a%chatto/core/v1/user_preferences.proto\"\xd2\x03\n" + + ")chatto/core/v1/projection_snapshots.proto\x12\x0echatto.core.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a!chatto/core/v1/asset_events.proto\x1a\x1achatto/core/v1/event.proto\x1a\x1bchatto/core/v1/models.proto\x1a!chatto/core/v1/notification.proto\x1a chatto/core/v1/rbac_events.proto\x1a chatto/core/v1/room_events.proto\x1a chatto/core/v1/user_events.proto\x1a%chatto/core/v1/user_preferences.proto\"\xd2\x03\n" + "\x1cProjectionSnapshotGeneration\x12#\n" + "\rgeneration_id\x18\x01 \x01(\tR\fgenerationId\x12\x1f\n" + "\vstream_name\x18\x02 \x01(\tR\n" + @@ -3292,7 +3414,11 @@ const file_chatto_core_v1_projection_snapshots_proto_rawDesc = "" + "\tgroup_ids\x18\x02 \x03(\tR\bgroupIds\x12\x1a\n" + "\bsequence\x18\x03 \x01(\x04R\bsequence\"I\n" + "\x16RoomGroupStateSnapshot\x12/\n" + - "\x05group\x18\x01 \x01(\v2\x19.chatto.core.v1.RoomGroupR\x05group\"Z\n" + + "\x05group\x18\x01 \x01(\v2\x19.chatto.core.v1.RoomGroupR\x05group\"\x9d\x02\n" + + "(NotificationVisibilityProjectionSnapshot\x12V\n" + + "\x0eroom_directory\x18\x01 \x01(\v2/.chatto.core.v1.RoomDirectoryProjectionSnapshotR\rroomDirectory\x12]\n" + + "\x11room_group_layout\x18\x02 \x01(\v21.chatto.core.v1.RoomGroupLayoutProjectionSnapshotR\x0froomGroupLayout\x12:\n" + + "\x04rbac\x18\x03 \x01(\v2&.chatto.core.v1.RBACProjectionSnapshotR\x04rbac\"Z\n" + "\x1bCallStateProjectionSnapshot\x12;\n" + "\x05rooms\x18\x01 \x03(\v2%.chatto.core.v1.CallRoomStateSnapshotR\x05rooms\"\xd2\x01\n" + "\x15CallRoomStateSnapshot\x12\x17\n" + @@ -3344,20 +3470,22 @@ const file_chatto_core_v1_projection_snapshots_proto_rawDesc = "" + "\x04logo\x18\x06 \x01(\v2\x1b.chatto.core.v1.AssetRecordR\x04logo\x123\n" + "\x06banner\x18\a \x01(\v2\x1b.chatto.core.v1.AssetRecordR\x06banner\x128\n" + "\x05users\x18\b \x03(\v2\".chatto.core.v1.UserConfigSnapshotR\x05usersB\x14\n" + - "\x12_blocked_usernames\"\x98\x03\n" + + "\x12_blocked_usernames\"\xde\x03\n" + "\x12UserConfigSnapshot\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x1f\n" + "\btimezone\x18\x02 \x01(\tH\x00R\btimezone\x88\x01\x01\x12@\n" + "\vtime_format\x18\x03 \x01(\x0e2\x1a.chatto.core.v1.TimeFormatH\x01R\n" + - "timeFormat\x88\x01\x01\x12b\n" + - "\x19server_notification_level\x18\x04 \x01(\x0e2!.chatto.core.v1.NotificationLevelH\x02R\x17serverNotificationLevel\x88\x01\x01\x12g\n" + - "\x18room_notification_levels\x18\x05 \x03(\v2-.chatto.core.v1.RoomNotificationLevelSnapshotR\x16roomNotificationLevelsB\v\n" + + "timeFormat\x88\x01\x01\x12v\n" + + "\x1fserver_notification_preferences\x18\x06 \x03(\v2..chatto.core.v1.NotificationPreferenceSnapshotR\x1dserverNotificationPreferences\x12v\n" + + "\x1droom_notification_preferences\x18\a \x03(\v22.chatto.core.v1.RoomNotificationPreferenceSnapshotR\x1broomNotificationPreferencesB\v\n" + "\t_timezoneB\x0e\n" + - "\f_time_formatB\x1c\n" + - "\x1a_server_notification_level\"q\n" + - "\x1dRoomNotificationLevelSnapshot\x12\x17\n" + - "\aroom_id\x18\x01 \x01(\tR\x06roomId\x127\n" + - "\x05level\x18\x02 \x01(\x0e2!.chatto.core.v1.NotificationLevelR\x05level\"\x95\x04\n" + + "\f_time_formatJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06R\x19server_notification_levelR\x18room_notification_levels\"\xa9\x01\n" + + "\x1eNotificationPreferenceSnapshot\x12:\n" + + "\x06reason\x18\x01 \x01(\x0e2\".chatto.core.v1.NotificationReasonR\x06reason\x12K\n" + + "\tintensity\x18\x02 \x01(\x0e2-.chatto.core.v1.NotificationDeliveryIntensityR\tintensity\"\x8f\x01\n" + + "\"RoomNotificationPreferenceSnapshot\x12\x17\n" + + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12P\n" + + "\vpreferences\x18\x02 \x03(\v2..chatto.core.v1.NotificationPreferenceSnapshotR\vpreferences\"\x95\x04\n" + "\x17AssetProjectionSnapshot\x12?\n" + "\tcreations\x18\x01 \x03(\v2!.chatto.core.v1.AssetCreatedEventR\tcreations\x12A\n" + "\bchildren\x18\x02 \x03(\v2%.chatto.core.v1.AssetChildrenSnapshotR\bchildren\x12C\n" + @@ -3390,10 +3518,11 @@ const file_chatto_core_v1_projection_snapshots_proto_rawDesc = "" + "\x06emojis\x18\x02 \x03(\v2&.chatto.core.v1.EmojiReactionsSnapshotR\x06emojis\"j\n" + "\x16EmojiReactionsSnapshot\x12\x14\n" + "\x05emoji\x18\x01 \x01(\tR\x05emoji\x12:\n" + - "\x05users\x18\x02 \x03(\v2$.chatto.core.v1.UserReactionSnapshotR\x05users\"U\n" + + "\x05users\x18\x02 \x03(\v2$.chatto.core.v1.UserReactionSnapshotR\x05users\"}\n" + "\x14UserReactionSnapshot\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x12$\n" + - "\x0eadded_at_nanos\x18\x02 \x01(\x03R\faddedAtNanos\">\n" + + "\x0eadded_at_nanos\x18\x02 \x01(\x03R\faddedAtNanos\x12&\n" + + "\x0fsource_event_id\x18\x03 \x01(\tR\rsourceEventId\">\n" + "\x14StringUint64Snapshot\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\x04R\x05value\">\n" + @@ -3492,159 +3621,167 @@ func file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP() []byte { return file_chatto_core_v1_projection_snapshots_proto_rawDescData } -var file_chatto_core_v1_projection_snapshots_proto_msgTypes = make([]protoimpl.MessageInfo, 46) +var file_chatto_core_v1_projection_snapshots_proto_msgTypes = make([]protoimpl.MessageInfo, 48) var file_chatto_core_v1_projection_snapshots_proto_goTypes = []any{ - (*ProjectionSnapshotGeneration)(nil), // 0: chatto.core.v1.ProjectionSnapshotGeneration - (*ProjectionSnapshotPointer)(nil), // 1: chatto.core.v1.ProjectionSnapshotPointer - (*ThreadProjectionSnapshot)(nil), // 2: chatto.core.v1.ThreadProjectionSnapshot - (*ThreadSnapshot)(nil), // 3: chatto.core.v1.ThreadSnapshot - (*ThreadTimelineEntrySnapshot)(nil), // 4: chatto.core.v1.ThreadTimelineEntrySnapshot - (*ThreadReplySnapshot)(nil), // 5: chatto.core.v1.ThreadReplySnapshot - (*ThreadFollowSnapshot)(nil), // 6: chatto.core.v1.ThreadFollowSnapshot - (*ProjectionReplayGuardSnapshot)(nil), // 7: chatto.core.v1.ProjectionReplayGuardSnapshot - (*RoomDirectoryProjectionSnapshot)(nil), // 8: chatto.core.v1.RoomDirectoryProjectionSnapshot - (*RoomMembershipSnapshot)(nil), // 9: chatto.core.v1.RoomMembershipSnapshot - (*RoomBanSnapshot)(nil), // 10: chatto.core.v1.RoomBanSnapshot - (*RoomGroupLayoutProjectionSnapshot)(nil), // 11: chatto.core.v1.RoomGroupLayoutProjectionSnapshot - (*RoomGroupStateSnapshot)(nil), // 12: chatto.core.v1.RoomGroupStateSnapshot - (*CallStateProjectionSnapshot)(nil), // 13: chatto.core.v1.CallStateProjectionSnapshot - (*CallRoomStateSnapshot)(nil), // 14: chatto.core.v1.CallRoomStateSnapshot - (*CallSessionSnapshot)(nil), // 15: chatto.core.v1.CallSessionSnapshot - (*CallParticipantSnapshot)(nil), // 16: chatto.core.v1.CallParticipantSnapshot - (*ContentKeyProjectionSnapshot)(nil), // 17: chatto.core.v1.ContentKeyProjectionSnapshot - (*RBACProjectionSnapshot)(nil), // 18: chatto.core.v1.RBACProjectionSnapshot - (*RBACAssignmentSnapshot)(nil), // 19: chatto.core.v1.RBACAssignmentSnapshot - (*RBACDecisionSnapshot)(nil), // 20: chatto.core.v1.RBACDecisionSnapshot - (*ConfigProjectionSnapshot)(nil), // 21: chatto.core.v1.ConfigProjectionSnapshot - (*UserConfigSnapshot)(nil), // 22: chatto.core.v1.UserConfigSnapshot - (*RoomNotificationLevelSnapshot)(nil), // 23: chatto.core.v1.RoomNotificationLevelSnapshot - (*AssetProjectionSnapshot)(nil), // 24: chatto.core.v1.AssetProjectionSnapshot - (*AssetChildrenSnapshot)(nil), // 25: chatto.core.v1.AssetChildrenSnapshot - (*AssetManifestSnapshot)(nil), // 26: chatto.core.v1.AssetManifestSnapshot - (*DeletedAssetSnapshot)(nil), // 27: chatto.core.v1.DeletedAssetSnapshot - (*ReactionProjectionSnapshot)(nil), // 28: chatto.core.v1.ReactionProjectionSnapshot - (*MessageReactionsSnapshot)(nil), // 29: chatto.core.v1.MessageReactionsSnapshot - (*EmojiReactionsSnapshot)(nil), // 30: chatto.core.v1.EmojiReactionsSnapshot - (*UserReactionSnapshot)(nil), // 31: chatto.core.v1.UserReactionSnapshot - (*StringUint64Snapshot)(nil), // 32: chatto.core.v1.StringUint64Snapshot - (*StringStringSnapshot)(nil), // 33: chatto.core.v1.StringStringSnapshot - (*MentionablesProjectionSnapshot)(nil), // 34: chatto.core.v1.MentionablesProjectionSnapshot - (*UserProfileProjectionSnapshot)(nil), // 35: chatto.core.v1.UserProfileProjectionSnapshot - (*ProjectedUserProfileSnapshot)(nil), // 36: chatto.core.v1.ProjectedUserProfileSnapshot - (*ProjectedEncryptedUserStringSnapshot)(nil), // 37: chatto.core.v1.ProjectedEncryptedUserStringSnapshot - (*ProjectedVerifiedEmailSnapshot)(nil), // 38: chatto.core.v1.ProjectedVerifiedEmailSnapshot - (*RoomTimelineProjectionSnapshot)(nil), // 39: chatto.core.v1.RoomTimelineProjectionSnapshot - (*PinnedMessageSnapshot)(nil), // 40: chatto.core.v1.PinnedMessageSnapshot - (*LatestRoomPinSnapshot)(nil), // 41: chatto.core.v1.LatestRoomPinSnapshot - (*TimelineEntrySnapshot)(nil), // 42: chatto.core.v1.TimelineEntrySnapshot - (*TimelineBodySnapshot)(nil), // 43: chatto.core.v1.TimelineBodySnapshot - (*StringTimestampSnapshot)(nil), // 44: chatto.core.v1.StringTimestampSnapshot - (*AssetMessageOwnerSnapshot)(nil), // 45: chatto.core.v1.AssetMessageOwnerSnapshot - (*timestamppb.Timestamp)(nil), // 46: google.protobuf.Timestamp - (*Room)(nil), // 47: chatto.core.v1.Room - (*RoomGroup)(nil), // 48: chatto.core.v1.RoomGroup - (CallParticipantEventSource)(0), // 49: chatto.core.v1.CallParticipantEventSource - (*UserDEKGeneratedEvent)(nil), // 50: chatto.core.v1.UserDEKGeneratedEvent - (*Role)(nil), // 51: chatto.core.v1.Role - (RbacPermissionSubjectKind)(0), // 52: chatto.core.v1.RbacPermissionSubjectKind - (*AssetRecord)(nil), // 53: chatto.core.v1.AssetRecord - (TimeFormat)(0), // 54: chatto.core.v1.TimeFormat - (NotificationLevel)(0), // 55: chatto.core.v1.NotificationLevel - (*AssetCreatedEvent)(nil), // 56: chatto.core.v1.AssetCreatedEvent - (*AssetProcessingStartedEvent)(nil), // 57: chatto.core.v1.AssetProcessingStartedEvent - (*AssetProcessingSucceededEvent)(nil), // 58: chatto.core.v1.AssetProcessingSucceededEvent - (*AssetProcessingFailedEvent)(nil), // 59: chatto.core.v1.AssetProcessingFailedEvent - (*Event)(nil), // 60: chatto.core.v1.Event - (*User)(nil), // 61: chatto.core.v1.User - (*ServerUserPreferences)(nil), // 62: chatto.core.v1.ServerUserPreferences - (*EncryptedUserString)(nil), // 63: chatto.core.v1.EncryptedUserString - (*MessageBody)(nil), // 64: chatto.core.v1.MessageBody + (*ProjectionSnapshotGeneration)(nil), // 0: chatto.core.v1.ProjectionSnapshotGeneration + (*ProjectionSnapshotPointer)(nil), // 1: chatto.core.v1.ProjectionSnapshotPointer + (*ThreadProjectionSnapshot)(nil), // 2: chatto.core.v1.ThreadProjectionSnapshot + (*ThreadSnapshot)(nil), // 3: chatto.core.v1.ThreadSnapshot + (*ThreadTimelineEntrySnapshot)(nil), // 4: chatto.core.v1.ThreadTimelineEntrySnapshot + (*ThreadReplySnapshot)(nil), // 5: chatto.core.v1.ThreadReplySnapshot + (*ThreadFollowSnapshot)(nil), // 6: chatto.core.v1.ThreadFollowSnapshot + (*ProjectionReplayGuardSnapshot)(nil), // 7: chatto.core.v1.ProjectionReplayGuardSnapshot + (*RoomDirectoryProjectionSnapshot)(nil), // 8: chatto.core.v1.RoomDirectoryProjectionSnapshot + (*RoomMembershipSnapshot)(nil), // 9: chatto.core.v1.RoomMembershipSnapshot + (*RoomBanSnapshot)(nil), // 10: chatto.core.v1.RoomBanSnapshot + (*RoomGroupLayoutProjectionSnapshot)(nil), // 11: chatto.core.v1.RoomGroupLayoutProjectionSnapshot + (*RoomGroupStateSnapshot)(nil), // 12: chatto.core.v1.RoomGroupStateSnapshot + (*NotificationVisibilityProjectionSnapshot)(nil), // 13: chatto.core.v1.NotificationVisibilityProjectionSnapshot + (*CallStateProjectionSnapshot)(nil), // 14: chatto.core.v1.CallStateProjectionSnapshot + (*CallRoomStateSnapshot)(nil), // 15: chatto.core.v1.CallRoomStateSnapshot + (*CallSessionSnapshot)(nil), // 16: chatto.core.v1.CallSessionSnapshot + (*CallParticipantSnapshot)(nil), // 17: chatto.core.v1.CallParticipantSnapshot + (*ContentKeyProjectionSnapshot)(nil), // 18: chatto.core.v1.ContentKeyProjectionSnapshot + (*RBACProjectionSnapshot)(nil), // 19: chatto.core.v1.RBACProjectionSnapshot + (*RBACAssignmentSnapshot)(nil), // 20: chatto.core.v1.RBACAssignmentSnapshot + (*RBACDecisionSnapshot)(nil), // 21: chatto.core.v1.RBACDecisionSnapshot + (*ConfigProjectionSnapshot)(nil), // 22: chatto.core.v1.ConfigProjectionSnapshot + (*UserConfigSnapshot)(nil), // 23: chatto.core.v1.UserConfigSnapshot + (*NotificationPreferenceSnapshot)(nil), // 24: chatto.core.v1.NotificationPreferenceSnapshot + (*RoomNotificationPreferenceSnapshot)(nil), // 25: chatto.core.v1.RoomNotificationPreferenceSnapshot + (*AssetProjectionSnapshot)(nil), // 26: chatto.core.v1.AssetProjectionSnapshot + (*AssetChildrenSnapshot)(nil), // 27: chatto.core.v1.AssetChildrenSnapshot + (*AssetManifestSnapshot)(nil), // 28: chatto.core.v1.AssetManifestSnapshot + (*DeletedAssetSnapshot)(nil), // 29: chatto.core.v1.DeletedAssetSnapshot + (*ReactionProjectionSnapshot)(nil), // 30: chatto.core.v1.ReactionProjectionSnapshot + (*MessageReactionsSnapshot)(nil), // 31: chatto.core.v1.MessageReactionsSnapshot + (*EmojiReactionsSnapshot)(nil), // 32: chatto.core.v1.EmojiReactionsSnapshot + (*UserReactionSnapshot)(nil), // 33: chatto.core.v1.UserReactionSnapshot + (*StringUint64Snapshot)(nil), // 34: chatto.core.v1.StringUint64Snapshot + (*StringStringSnapshot)(nil), // 35: chatto.core.v1.StringStringSnapshot + (*MentionablesProjectionSnapshot)(nil), // 36: chatto.core.v1.MentionablesProjectionSnapshot + (*UserProfileProjectionSnapshot)(nil), // 37: chatto.core.v1.UserProfileProjectionSnapshot + (*ProjectedUserProfileSnapshot)(nil), // 38: chatto.core.v1.ProjectedUserProfileSnapshot + (*ProjectedEncryptedUserStringSnapshot)(nil), // 39: chatto.core.v1.ProjectedEncryptedUserStringSnapshot + (*ProjectedVerifiedEmailSnapshot)(nil), // 40: chatto.core.v1.ProjectedVerifiedEmailSnapshot + (*RoomTimelineProjectionSnapshot)(nil), // 41: chatto.core.v1.RoomTimelineProjectionSnapshot + (*PinnedMessageSnapshot)(nil), // 42: chatto.core.v1.PinnedMessageSnapshot + (*LatestRoomPinSnapshot)(nil), // 43: chatto.core.v1.LatestRoomPinSnapshot + (*TimelineEntrySnapshot)(nil), // 44: chatto.core.v1.TimelineEntrySnapshot + (*TimelineBodySnapshot)(nil), // 45: chatto.core.v1.TimelineBodySnapshot + (*StringTimestampSnapshot)(nil), // 46: chatto.core.v1.StringTimestampSnapshot + (*AssetMessageOwnerSnapshot)(nil), // 47: chatto.core.v1.AssetMessageOwnerSnapshot + (*timestamppb.Timestamp)(nil), // 48: google.protobuf.Timestamp + (*Room)(nil), // 49: chatto.core.v1.Room + (*RoomGroup)(nil), // 50: chatto.core.v1.RoomGroup + (CallParticipantEventSource)(0), // 51: chatto.core.v1.CallParticipantEventSource + (*UserDEKGeneratedEvent)(nil), // 52: chatto.core.v1.UserDEKGeneratedEvent + (*Role)(nil), // 53: chatto.core.v1.Role + (RbacPermissionSubjectKind)(0), // 54: chatto.core.v1.RbacPermissionSubjectKind + (*AssetRecord)(nil), // 55: chatto.core.v1.AssetRecord + (TimeFormat)(0), // 56: chatto.core.v1.TimeFormat + (NotificationReason)(0), // 57: chatto.core.v1.NotificationReason + (NotificationDeliveryIntensity)(0), // 58: chatto.core.v1.NotificationDeliveryIntensity + (*AssetCreatedEvent)(nil), // 59: chatto.core.v1.AssetCreatedEvent + (*AssetProcessingStartedEvent)(nil), // 60: chatto.core.v1.AssetProcessingStartedEvent + (*AssetProcessingSucceededEvent)(nil), // 61: chatto.core.v1.AssetProcessingSucceededEvent + (*AssetProcessingFailedEvent)(nil), // 62: chatto.core.v1.AssetProcessingFailedEvent + (*Event)(nil), // 63: chatto.core.v1.Event + (*User)(nil), // 64: chatto.core.v1.User + (*ServerUserPreferences)(nil), // 65: chatto.core.v1.ServerUserPreferences + (*EncryptedUserString)(nil), // 66: chatto.core.v1.EncryptedUserString + (*MessageBody)(nil), // 67: chatto.core.v1.MessageBody } var file_chatto_core_v1_projection_snapshots_proto_depIdxs = []int32{ - 46, // 0: chatto.core.v1.ProjectionSnapshotGeneration.created_at:type_name -> google.protobuf.Timestamp - 46, // 1: chatto.core.v1.ProjectionSnapshotPointer.current_created_at:type_name -> google.protobuf.Timestamp - 46, // 2: chatto.core.v1.ProjectionSnapshotPointer.previous_created_at:type_name -> google.protobuf.Timestamp + 48, // 0: chatto.core.v1.ProjectionSnapshotGeneration.created_at:type_name -> google.protobuf.Timestamp + 48, // 1: chatto.core.v1.ProjectionSnapshotPointer.current_created_at:type_name -> google.protobuf.Timestamp + 48, // 2: chatto.core.v1.ProjectionSnapshotPointer.previous_created_at:type_name -> google.protobuf.Timestamp 3, // 3: chatto.core.v1.ThreadProjectionSnapshot.threads:type_name -> chatto.core.v1.ThreadSnapshot 5, // 4: chatto.core.v1.ThreadProjectionSnapshot.replies:type_name -> chatto.core.v1.ThreadReplySnapshot 6, // 5: chatto.core.v1.ThreadProjectionSnapshot.follows:type_name -> chatto.core.v1.ThreadFollowSnapshot 7, // 6: chatto.core.v1.ThreadProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot 4, // 7: chatto.core.v1.ThreadSnapshot.entries:type_name -> chatto.core.v1.ThreadTimelineEntrySnapshot - 46, // 8: chatto.core.v1.ThreadReplySnapshot.created_at:type_name -> google.protobuf.Timestamp - 47, // 9: chatto.core.v1.RoomDirectoryProjectionSnapshot.rooms:type_name -> chatto.core.v1.Room + 48, // 8: chatto.core.v1.ThreadReplySnapshot.created_at:type_name -> google.protobuf.Timestamp + 49, // 9: chatto.core.v1.RoomDirectoryProjectionSnapshot.rooms:type_name -> chatto.core.v1.Room 9, // 10: chatto.core.v1.RoomDirectoryProjectionSnapshot.memberships:type_name -> chatto.core.v1.RoomMembershipSnapshot 10, // 11: chatto.core.v1.RoomDirectoryProjectionSnapshot.bans:type_name -> chatto.core.v1.RoomBanSnapshot - 46, // 12: chatto.core.v1.RoomBanSnapshot.created_at:type_name -> google.protobuf.Timestamp - 46, // 13: chatto.core.v1.RoomBanSnapshot.expires_at:type_name -> google.protobuf.Timestamp + 48, // 12: chatto.core.v1.RoomBanSnapshot.created_at:type_name -> google.protobuf.Timestamp + 48, // 13: chatto.core.v1.RoomBanSnapshot.expires_at:type_name -> google.protobuf.Timestamp 12, // 14: chatto.core.v1.RoomGroupLayoutProjectionSnapshot.groups:type_name -> chatto.core.v1.RoomGroupStateSnapshot - 48, // 15: chatto.core.v1.RoomGroupStateSnapshot.group:type_name -> chatto.core.v1.RoomGroup - 14, // 16: chatto.core.v1.CallStateProjectionSnapshot.rooms:type_name -> chatto.core.v1.CallRoomStateSnapshot - 15, // 17: chatto.core.v1.CallRoomStateSnapshot.call:type_name -> chatto.core.v1.CallSessionSnapshot - 16, // 18: chatto.core.v1.CallRoomStateSnapshot.participants:type_name -> chatto.core.v1.CallParticipantSnapshot - 49, // 19: chatto.core.v1.CallSessionSnapshot.source:type_name -> chatto.core.v1.CallParticipantEventSource - 49, // 20: chatto.core.v1.CallParticipantSnapshot.source:type_name -> chatto.core.v1.CallParticipantEventSource - 50, // 21: chatto.core.v1.ContentKeyProjectionSnapshot.keys:type_name -> chatto.core.v1.UserDEKGeneratedEvent - 7, // 22: chatto.core.v1.ContentKeyProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot - 51, // 23: chatto.core.v1.RBACProjectionSnapshot.roles:type_name -> chatto.core.v1.Role - 19, // 24: chatto.core.v1.RBACProjectionSnapshot.assignments:type_name -> chatto.core.v1.RBACAssignmentSnapshot - 20, // 25: chatto.core.v1.RBACProjectionSnapshot.decisions:type_name -> chatto.core.v1.RBACDecisionSnapshot - 7, // 26: chatto.core.v1.RBACProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot - 52, // 27: chatto.core.v1.RBACDecisionSnapshot.subject_kind:type_name -> chatto.core.v1.RbacPermissionSubjectKind - 53, // 28: chatto.core.v1.ConfigProjectionSnapshot.logo:type_name -> chatto.core.v1.AssetRecord - 53, // 29: chatto.core.v1.ConfigProjectionSnapshot.banner:type_name -> chatto.core.v1.AssetRecord - 22, // 30: chatto.core.v1.ConfigProjectionSnapshot.users:type_name -> chatto.core.v1.UserConfigSnapshot - 54, // 31: chatto.core.v1.UserConfigSnapshot.time_format:type_name -> chatto.core.v1.TimeFormat - 55, // 32: chatto.core.v1.UserConfigSnapshot.server_notification_level:type_name -> chatto.core.v1.NotificationLevel - 23, // 33: chatto.core.v1.UserConfigSnapshot.room_notification_levels:type_name -> chatto.core.v1.RoomNotificationLevelSnapshot - 55, // 34: chatto.core.v1.RoomNotificationLevelSnapshot.level:type_name -> chatto.core.v1.NotificationLevel - 56, // 35: chatto.core.v1.AssetProjectionSnapshot.creations:type_name -> chatto.core.v1.AssetCreatedEvent - 25, // 36: chatto.core.v1.AssetProjectionSnapshot.children:type_name -> chatto.core.v1.AssetChildrenSnapshot - 26, // 37: chatto.core.v1.AssetProjectionSnapshot.manifests:type_name -> chatto.core.v1.AssetManifestSnapshot - 27, // 38: chatto.core.v1.AssetProjectionSnapshot.deleted_assets:type_name -> chatto.core.v1.DeletedAssetSnapshot - 7, // 39: chatto.core.v1.AssetProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot - 45, // 40: chatto.core.v1.AssetProjectionSnapshot.message_owners:type_name -> chatto.core.v1.AssetMessageOwnerSnapshot - 57, // 41: chatto.core.v1.AssetManifestSnapshot.started:type_name -> chatto.core.v1.AssetProcessingStartedEvent - 58, // 42: chatto.core.v1.AssetManifestSnapshot.succeeded:type_name -> chatto.core.v1.AssetProcessingSucceededEvent - 59, // 43: chatto.core.v1.AssetManifestSnapshot.failed:type_name -> chatto.core.v1.AssetProcessingFailedEvent - 29, // 44: chatto.core.v1.ReactionProjectionSnapshot.messages:type_name -> chatto.core.v1.MessageReactionsSnapshot - 32, // 45: chatto.core.v1.ReactionProjectionSnapshot.room_sequences:type_name -> chatto.core.v1.StringUint64Snapshot - 33, // 46: chatto.core.v1.ReactionProjectionSnapshot.message_rooms:type_name -> chatto.core.v1.StringStringSnapshot - 33, // 47: chatto.core.v1.ReactionProjectionSnapshot.echo_originals:type_name -> chatto.core.v1.StringStringSnapshot - 33, // 48: chatto.core.v1.ReactionProjectionSnapshot.asset_rooms:type_name -> chatto.core.v1.StringStringSnapshot - 7, // 49: chatto.core.v1.ReactionProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot - 30, // 50: chatto.core.v1.MessageReactionsSnapshot.emojis:type_name -> chatto.core.v1.EmojiReactionsSnapshot - 31, // 51: chatto.core.v1.EmojiReactionsSnapshot.users:type_name -> chatto.core.v1.UserReactionSnapshot - 60, // 52: chatto.core.v1.MentionablesProjectionSnapshot.user_login_sources:type_name -> chatto.core.v1.Event - 50, // 53: chatto.core.v1.MentionablesProjectionSnapshot.keys:type_name -> chatto.core.v1.UserDEKGeneratedEvent - 36, // 54: chatto.core.v1.UserProfileProjectionSnapshot.users:type_name -> chatto.core.v1.ProjectedUserProfileSnapshot - 50, // 55: chatto.core.v1.UserProfileProjectionSnapshot.keys:type_name -> chatto.core.v1.UserDEKGeneratedEvent - 7, // 56: chatto.core.v1.UserProfileProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot - 33, // 57: chatto.core.v1.UserProfileProjectionSnapshot.login_index:type_name -> chatto.core.v1.StringStringSnapshot - 33, // 58: chatto.core.v1.UserProfileProjectionSnapshot.email_index:type_name -> chatto.core.v1.StringStringSnapshot - 61, // 59: chatto.core.v1.ProjectedUserProfileSnapshot.user:type_name -> chatto.core.v1.User - 37, // 60: chatto.core.v1.ProjectedUserProfileSnapshot.login:type_name -> chatto.core.v1.ProjectedEncryptedUserStringSnapshot - 37, // 61: chatto.core.v1.ProjectedUserProfileSnapshot.display_name:type_name -> chatto.core.v1.ProjectedEncryptedUserStringSnapshot - 53, // 62: chatto.core.v1.ProjectedUserProfileSnapshot.avatar:type_name -> chatto.core.v1.AssetRecord - 38, // 63: chatto.core.v1.ProjectedUserProfileSnapshot.verified_emails:type_name -> chatto.core.v1.ProjectedVerifiedEmailSnapshot - 62, // 64: chatto.core.v1.ProjectedUserProfileSnapshot.preferences:type_name -> chatto.core.v1.ServerUserPreferences - 46, // 65: chatto.core.v1.ProjectedUserProfileSnapshot.login_changed_at:type_name -> google.protobuf.Timestamp - 63, // 66: chatto.core.v1.ProjectedEncryptedUserStringSnapshot.encrypted:type_name -> chatto.core.v1.EncryptedUserString - 37, // 67: chatto.core.v1.ProjectedVerifiedEmailSnapshot.value:type_name -> chatto.core.v1.ProjectedEncryptedUserStringSnapshot - 46, // 68: chatto.core.v1.ProjectedVerifiedEmailSnapshot.verified_at:type_name -> google.protobuf.Timestamp - 42, // 69: chatto.core.v1.RoomTimelineProjectionSnapshot.entries:type_name -> chatto.core.v1.TimelineEntrySnapshot - 43, // 70: chatto.core.v1.RoomTimelineProjectionSnapshot.bodies:type_name -> chatto.core.v1.TimelineBodySnapshot - 44, // 71: chatto.core.v1.RoomTimelineProjectionSnapshot.tombstoned_at:type_name -> chatto.core.v1.StringTimestampSnapshot - 44, // 72: chatto.core.v1.RoomTimelineProjectionSnapshot.shredded_at:type_name -> chatto.core.v1.StringTimestampSnapshot - 7, // 73: chatto.core.v1.RoomTimelineProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot - 40, // 74: chatto.core.v1.RoomTimelineProjectionSnapshot.pinned_messages:type_name -> chatto.core.v1.PinnedMessageSnapshot - 41, // 75: chatto.core.v1.RoomTimelineProjectionSnapshot.latest_room_pins:type_name -> chatto.core.v1.LatestRoomPinSnapshot - 60, // 76: chatto.core.v1.TimelineEntrySnapshot.event:type_name -> chatto.core.v1.Event - 64, // 77: chatto.core.v1.TimelineBodySnapshot.body:type_name -> chatto.core.v1.MessageBody - 46, // 78: chatto.core.v1.StringTimestampSnapshot.value:type_name -> google.protobuf.Timestamp - 79, // [79:79] is the sub-list for method output_type - 79, // [79:79] is the sub-list for method input_type - 79, // [79:79] is the sub-list for extension type_name - 79, // [79:79] is the sub-list for extension extendee - 0, // [0:79] is the sub-list for field type_name + 50, // 15: chatto.core.v1.RoomGroupStateSnapshot.group:type_name -> chatto.core.v1.RoomGroup + 8, // 16: chatto.core.v1.NotificationVisibilityProjectionSnapshot.room_directory:type_name -> chatto.core.v1.RoomDirectoryProjectionSnapshot + 11, // 17: chatto.core.v1.NotificationVisibilityProjectionSnapshot.room_group_layout:type_name -> chatto.core.v1.RoomGroupLayoutProjectionSnapshot + 19, // 18: chatto.core.v1.NotificationVisibilityProjectionSnapshot.rbac:type_name -> chatto.core.v1.RBACProjectionSnapshot + 15, // 19: chatto.core.v1.CallStateProjectionSnapshot.rooms:type_name -> chatto.core.v1.CallRoomStateSnapshot + 16, // 20: chatto.core.v1.CallRoomStateSnapshot.call:type_name -> chatto.core.v1.CallSessionSnapshot + 17, // 21: chatto.core.v1.CallRoomStateSnapshot.participants:type_name -> chatto.core.v1.CallParticipantSnapshot + 51, // 22: chatto.core.v1.CallSessionSnapshot.source:type_name -> chatto.core.v1.CallParticipantEventSource + 51, // 23: chatto.core.v1.CallParticipantSnapshot.source:type_name -> chatto.core.v1.CallParticipantEventSource + 52, // 24: chatto.core.v1.ContentKeyProjectionSnapshot.keys:type_name -> chatto.core.v1.UserDEKGeneratedEvent + 7, // 25: chatto.core.v1.ContentKeyProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot + 53, // 26: chatto.core.v1.RBACProjectionSnapshot.roles:type_name -> chatto.core.v1.Role + 20, // 27: chatto.core.v1.RBACProjectionSnapshot.assignments:type_name -> chatto.core.v1.RBACAssignmentSnapshot + 21, // 28: chatto.core.v1.RBACProjectionSnapshot.decisions:type_name -> chatto.core.v1.RBACDecisionSnapshot + 7, // 29: chatto.core.v1.RBACProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot + 54, // 30: chatto.core.v1.RBACDecisionSnapshot.subject_kind:type_name -> chatto.core.v1.RbacPermissionSubjectKind + 55, // 31: chatto.core.v1.ConfigProjectionSnapshot.logo:type_name -> chatto.core.v1.AssetRecord + 55, // 32: chatto.core.v1.ConfigProjectionSnapshot.banner:type_name -> chatto.core.v1.AssetRecord + 23, // 33: chatto.core.v1.ConfigProjectionSnapshot.users:type_name -> chatto.core.v1.UserConfigSnapshot + 56, // 34: chatto.core.v1.UserConfigSnapshot.time_format:type_name -> chatto.core.v1.TimeFormat + 24, // 35: chatto.core.v1.UserConfigSnapshot.server_notification_preferences:type_name -> chatto.core.v1.NotificationPreferenceSnapshot + 25, // 36: chatto.core.v1.UserConfigSnapshot.room_notification_preferences:type_name -> chatto.core.v1.RoomNotificationPreferenceSnapshot + 57, // 37: chatto.core.v1.NotificationPreferenceSnapshot.reason:type_name -> chatto.core.v1.NotificationReason + 58, // 38: chatto.core.v1.NotificationPreferenceSnapshot.intensity:type_name -> chatto.core.v1.NotificationDeliveryIntensity + 24, // 39: chatto.core.v1.RoomNotificationPreferenceSnapshot.preferences:type_name -> chatto.core.v1.NotificationPreferenceSnapshot + 59, // 40: chatto.core.v1.AssetProjectionSnapshot.creations:type_name -> chatto.core.v1.AssetCreatedEvent + 27, // 41: chatto.core.v1.AssetProjectionSnapshot.children:type_name -> chatto.core.v1.AssetChildrenSnapshot + 28, // 42: chatto.core.v1.AssetProjectionSnapshot.manifests:type_name -> chatto.core.v1.AssetManifestSnapshot + 29, // 43: chatto.core.v1.AssetProjectionSnapshot.deleted_assets:type_name -> chatto.core.v1.DeletedAssetSnapshot + 7, // 44: chatto.core.v1.AssetProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot + 47, // 45: chatto.core.v1.AssetProjectionSnapshot.message_owners:type_name -> chatto.core.v1.AssetMessageOwnerSnapshot + 60, // 46: chatto.core.v1.AssetManifestSnapshot.started:type_name -> chatto.core.v1.AssetProcessingStartedEvent + 61, // 47: chatto.core.v1.AssetManifestSnapshot.succeeded:type_name -> chatto.core.v1.AssetProcessingSucceededEvent + 62, // 48: chatto.core.v1.AssetManifestSnapshot.failed:type_name -> chatto.core.v1.AssetProcessingFailedEvent + 31, // 49: chatto.core.v1.ReactionProjectionSnapshot.messages:type_name -> chatto.core.v1.MessageReactionsSnapshot + 34, // 50: chatto.core.v1.ReactionProjectionSnapshot.room_sequences:type_name -> chatto.core.v1.StringUint64Snapshot + 35, // 51: chatto.core.v1.ReactionProjectionSnapshot.message_rooms:type_name -> chatto.core.v1.StringStringSnapshot + 35, // 52: chatto.core.v1.ReactionProjectionSnapshot.echo_originals:type_name -> chatto.core.v1.StringStringSnapshot + 35, // 53: chatto.core.v1.ReactionProjectionSnapshot.asset_rooms:type_name -> chatto.core.v1.StringStringSnapshot + 7, // 54: chatto.core.v1.ReactionProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot + 32, // 55: chatto.core.v1.MessageReactionsSnapshot.emojis:type_name -> chatto.core.v1.EmojiReactionsSnapshot + 33, // 56: chatto.core.v1.EmojiReactionsSnapshot.users:type_name -> chatto.core.v1.UserReactionSnapshot + 63, // 57: chatto.core.v1.MentionablesProjectionSnapshot.user_login_sources:type_name -> chatto.core.v1.Event + 52, // 58: chatto.core.v1.MentionablesProjectionSnapshot.keys:type_name -> chatto.core.v1.UserDEKGeneratedEvent + 38, // 59: chatto.core.v1.UserProfileProjectionSnapshot.users:type_name -> chatto.core.v1.ProjectedUserProfileSnapshot + 52, // 60: chatto.core.v1.UserProfileProjectionSnapshot.keys:type_name -> chatto.core.v1.UserDEKGeneratedEvent + 7, // 61: chatto.core.v1.UserProfileProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot + 35, // 62: chatto.core.v1.UserProfileProjectionSnapshot.login_index:type_name -> chatto.core.v1.StringStringSnapshot + 35, // 63: chatto.core.v1.UserProfileProjectionSnapshot.email_index:type_name -> chatto.core.v1.StringStringSnapshot + 64, // 64: chatto.core.v1.ProjectedUserProfileSnapshot.user:type_name -> chatto.core.v1.User + 39, // 65: chatto.core.v1.ProjectedUserProfileSnapshot.login:type_name -> chatto.core.v1.ProjectedEncryptedUserStringSnapshot + 39, // 66: chatto.core.v1.ProjectedUserProfileSnapshot.display_name:type_name -> chatto.core.v1.ProjectedEncryptedUserStringSnapshot + 55, // 67: chatto.core.v1.ProjectedUserProfileSnapshot.avatar:type_name -> chatto.core.v1.AssetRecord + 40, // 68: chatto.core.v1.ProjectedUserProfileSnapshot.verified_emails:type_name -> chatto.core.v1.ProjectedVerifiedEmailSnapshot + 65, // 69: chatto.core.v1.ProjectedUserProfileSnapshot.preferences:type_name -> chatto.core.v1.ServerUserPreferences + 48, // 70: chatto.core.v1.ProjectedUserProfileSnapshot.login_changed_at:type_name -> google.protobuf.Timestamp + 66, // 71: chatto.core.v1.ProjectedEncryptedUserStringSnapshot.encrypted:type_name -> chatto.core.v1.EncryptedUserString + 39, // 72: chatto.core.v1.ProjectedVerifiedEmailSnapshot.value:type_name -> chatto.core.v1.ProjectedEncryptedUserStringSnapshot + 48, // 73: chatto.core.v1.ProjectedVerifiedEmailSnapshot.verified_at:type_name -> google.protobuf.Timestamp + 44, // 74: chatto.core.v1.RoomTimelineProjectionSnapshot.entries:type_name -> chatto.core.v1.TimelineEntrySnapshot + 45, // 75: chatto.core.v1.RoomTimelineProjectionSnapshot.bodies:type_name -> chatto.core.v1.TimelineBodySnapshot + 46, // 76: chatto.core.v1.RoomTimelineProjectionSnapshot.tombstoned_at:type_name -> chatto.core.v1.StringTimestampSnapshot + 46, // 77: chatto.core.v1.RoomTimelineProjectionSnapshot.shredded_at:type_name -> chatto.core.v1.StringTimestampSnapshot + 7, // 78: chatto.core.v1.RoomTimelineProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot + 42, // 79: chatto.core.v1.RoomTimelineProjectionSnapshot.pinned_messages:type_name -> chatto.core.v1.PinnedMessageSnapshot + 43, // 80: chatto.core.v1.RoomTimelineProjectionSnapshot.latest_room_pins:type_name -> chatto.core.v1.LatestRoomPinSnapshot + 63, // 81: chatto.core.v1.TimelineEntrySnapshot.event:type_name -> chatto.core.v1.Event + 67, // 82: chatto.core.v1.TimelineBodySnapshot.body:type_name -> chatto.core.v1.MessageBody + 48, // 83: chatto.core.v1.StringTimestampSnapshot.value:type_name -> google.protobuf.Timestamp + 84, // [84:84] is the sub-list for method output_type + 84, // [84:84] is the sub-list for method input_type + 84, // [84:84] is the sub-list for extension type_name + 84, // [84:84] is the sub-list for extension extendee + 0, // [0:84] is the sub-list for field type_name } func init() { file_chatto_core_v1_projection_snapshots_proto_init() } @@ -3655,19 +3792,20 @@ func file_chatto_core_v1_projection_snapshots_proto_init() { file_chatto_core_v1_asset_events_proto_init() file_chatto_core_v1_event_proto_init() file_chatto_core_v1_models_proto_init() + file_chatto_core_v1_notification_proto_init() file_chatto_core_v1_rbac_events_proto_init() file_chatto_core_v1_room_events_proto_init() file_chatto_core_v1_user_events_proto_init() file_chatto_core_v1_user_preferences_proto_init() - file_chatto_core_v1_projection_snapshots_proto_msgTypes[21].OneofWrappers = []any{} file_chatto_core_v1_projection_snapshots_proto_msgTypes[22].OneofWrappers = []any{} + file_chatto_core_v1_projection_snapshots_proto_msgTypes[23].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chatto_core_v1_projection_snapshots_proto_rawDesc), len(file_chatto_core_v1_projection_snapshots_proto_rawDesc)), NumEnums: 0, - NumMessages: 46, + NumMessages: 48, NumExtensions: 0, NumServices: 0, }, diff --git a/cli/internal/pb/chatto/realtime/v1/realtime.pb.go b/cli/internal/pb/chatto/realtime/v1/realtime.pb.go index 0eca57eef..49b0bc30a 100644 --- a/cli/internal/pb/chatto/realtime/v1/realtime.pb.go +++ b/cli/internal/pb/chatto/realtime/v1/realtime.pb.go @@ -78,7 +78,8 @@ type RealtimeProjectionNotificationAction int32 const ( RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_UNSPECIFIED RealtimeProjectionNotificationAction = 0 RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED RealtimeProjectionNotificationAction = 1 - RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED RealtimeProjectionNotificationAction = 2 + RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED RealtimeProjectionNotificationAction = 3 + RealtimeProjectionNotificationAction_REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED RealtimeProjectionNotificationAction = 4 ) // Enum value maps for RealtimeProjectionNotificationAction. @@ -86,12 +87,14 @@ var ( RealtimeProjectionNotificationAction_name = map[int32]string{ 0: "REALTIME_PROJECTION_NOTIFICATION_ACTION_UNSPECIFIED", 1: "REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED", - 2: "REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED", + 3: "REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED", + 4: "REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED", } RealtimeProjectionNotificationAction_value = map[string]int32{ "REALTIME_PROJECTION_NOTIFICATION_ACTION_UNSPECIFIED": 0, "REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED": 1, - "REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED": 2, + "REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED": 3, + "REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED": 4, } ) @@ -1482,8 +1485,6 @@ type RealtimeProjectionRoom struct { // membership is empty in a cold room summary and complete after that room's // timeline has been materialised. MemberUserIds []string `protobuf:"bytes,2,rep,name=member_user_ids,json=memberUserIds,proto3" json:"member_user_ids,omitempty"` - // Current pending notifications targeting this room for the viewer. - ViewerNotificationCount uint32 `protobuf:"varint,3,opt,name=viewer_notification_count,json=viewerNotificationCount,proto3" json:"viewer_notification_count,omitempty"` // Whether this DM room has ever received a root message. False means the // durable room exists only so its initiator can compose the first message. // Once true, message deletion does not reset it. Absent for channel rooms @@ -1537,13 +1538,6 @@ func (x *RealtimeProjectionRoom) GetMemberUserIds() []string { return nil } -func (x *RealtimeProjectionRoom) GetViewerNotificationCount() uint32 { - if x != nil { - return x.ViewerNotificationCount - } - return 0 -} - func (x *RealtimeProjectionRoom) GetHasMessageHistory() bool { if x != nil && x.HasMessageHistory != nil { return *x.HasMessageHistory @@ -2111,13 +2105,11 @@ func (x *RealtimeProjectionRoomTimelineEventRemove) GetEventId() string { // Finite current notification state emitted on bootstrap and every resume. type RealtimeProjectionNotificationsReplace struct { state protoimpl.MessageState `protogen:"open.v1"` - // Newest pending notifications and total pending count. - Page *v1.ListNotificationsResponse `protobuf:"bytes,1,opt,name=page,proto3" json:"page,omitempty"` - // Complete current counts for rooms with pending notifications. - RoomCounts []*v1.RoomNotificationCount `protobuf:"bytes,2,rep,name=room_counts,json=roomCounts,proto3" json:"room_counts,omitempty"` // Live transition that caused this replacement, when one exists. Bootstrap, // replay reconciliation, and compacted reset replacements omit this field. - Change *RealtimeProjectionNotificationChange `protobuf:"bytes,3,opt,name=change,proto3,oneof" json:"change,omitempty"` + Change *RealtimeProjectionNotificationChange `protobuf:"bytes,3,opt,name=change,proto3,oneof" json:"change,omitempty"` + // Authoritative Notifications 2.0 groups and unread group count. + Groups *v1.ListNotificationGroupsResponse `protobuf:"bytes,4,opt,name=groups,proto3" json:"groups,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2152,23 +2144,16 @@ func (*RealtimeProjectionNotificationsReplace) Descriptor() ([]byte, []int) { return file_chatto_realtime_v1_realtime_proto_rawDescGZIP(), []int{24} } -func (x *RealtimeProjectionNotificationsReplace) GetPage() *v1.ListNotificationsResponse { - if x != nil { - return x.Page - } - return nil -} - -func (x *RealtimeProjectionNotificationsReplace) GetRoomCounts() []*v1.RoomNotificationCount { +func (x *RealtimeProjectionNotificationsReplace) GetChange() *RealtimeProjectionNotificationChange { if x != nil { - return x.RoomCounts + return x.Change } return nil } -func (x *RealtimeProjectionNotificationsReplace) GetChange() *RealtimeProjectionNotificationChange { +func (x *RealtimeProjectionNotificationsReplace) GetGroups() *v1.ListNotificationGroupsResponse { if x != nil { - return x.Change + return x.Groups } return nil } @@ -2730,8 +2715,6 @@ type RealtimeEventEnvelope struct { // // *RealtimeEventEnvelope_UserTyping // *RealtimeEventEnvelope_PresenceChanged - // *RealtimeEventEnvelope_MentionNotification - // *RealtimeEventEnvelope_NewDirectMessageNotification // *RealtimeEventEnvelope_SessionTerminated Event isRealtimeEventEnvelope_Event `protobuf_oneof:"event"` unknownFields protoimpl.UnknownFields @@ -2814,24 +2797,6 @@ func (x *RealtimeEventEnvelope) GetPresenceChanged() *RealtimePresenceChangedEve return nil } -func (x *RealtimeEventEnvelope) GetMentionNotification() *RealtimeMentionNotificationEvent { - if x != nil { - if x, ok := x.Event.(*RealtimeEventEnvelope_MentionNotification); ok { - return x.MentionNotification - } - } - return nil -} - -func (x *RealtimeEventEnvelope) GetNewDirectMessageNotification() *RealtimeNewDirectMessageNotificationEvent { - if x != nil { - if x, ok := x.Event.(*RealtimeEventEnvelope_NewDirectMessageNotification); ok { - return x.NewDirectMessageNotification - } - } - return nil -} - func (x *RealtimeEventEnvelope) GetSessionTerminated() *RealtimeSessionTerminatedEvent { if x != nil { if x, ok := x.Event.(*RealtimeEventEnvelope_SessionTerminated); ok { @@ -2855,16 +2820,6 @@ type RealtimeEventEnvelope_PresenceChanged struct { PresenceChanged *RealtimePresenceChangedEvent `protobuf:"bytes,31,opt,name=presence_changed,json=presenceChanged,proto3,oneof"` } -type RealtimeEventEnvelope_MentionNotification struct { - // The current user was mentioned in a room. - MentionNotification *RealtimeMentionNotificationEvent `protobuf:"bytes,88,opt,name=mention_notification,json=mentionNotification,proto3,oneof"` -} - -type RealtimeEventEnvelope_NewDirectMessageNotification struct { - // The current user received a new direct message. - NewDirectMessageNotification *RealtimeNewDirectMessageNotificationEvent `protobuf:"bytes,89,opt,name=new_direct_message_notification,json=newDirectMessageNotification,proto3,oneof"` -} - type RealtimeEventEnvelope_SessionTerminated struct { // The current user's session was terminated. SessionTerminated *RealtimeSessionTerminatedEvent `protobuf:"bytes,90,opt,name=session_terminated,json=sessionTerminated,proto3,oneof"` @@ -2874,10 +2829,6 @@ func (*RealtimeEventEnvelope_UserTyping) isRealtimeEventEnvelope_Event() {} func (*RealtimeEventEnvelope_PresenceChanged) isRealtimeEventEnvelope_Event() {} -func (*RealtimeEventEnvelope_MentionNotification) isRealtimeEventEnvelope_Event() {} - -func (*RealtimeEventEnvelope_NewDirectMessageNotification) isRealtimeEventEnvelope_Event() {} - func (*RealtimeEventEnvelope_SessionTerminated) isRealtimeEventEnvelope_Event() {} // Typing signal. @@ -2996,170 +2947,6 @@ func (x *RealtimePresenceChangedEvent) GetStatus() v1.PresenceStatus { return v1.PresenceStatus(0) } -// Mention attention signal for the connected user. -// -// Inline names are display hints. Hydrate referenced rooms through -// `RoomDirectoryService.BatchGetRooms` and users through -// `UserService.BatchGetUsers` when local caches are missing or stale. -type RealtimeMentionNotificationEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Room where the mention occurred. - RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - // Author user ID. - ActorUserId string `protobuf:"bytes,2,opt,name=actor_user_id,json=actorUserId,proto3" json:"actor_user_id,omitempty"` - // Display name of the room where the mention occurred, when hydrated for the caller. - RoomName *string `protobuf:"bytes,3,opt,name=room_name,json=roomName,proto3,oneof" json:"room_name,omitempty"` - // Display name of the author who mentioned the connected user, when hydrated. - ActorDisplayName *string `protobuf:"bytes,4,opt,name=actor_display_name,json=actorDisplayName,proto3,oneof" json:"actor_display_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RealtimeMentionNotificationEvent) Reset() { - *x = RealtimeMentionNotificationEvent{} - mi := &file_chatto_realtime_v1_realtime_proto_msgTypes[37] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RealtimeMentionNotificationEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RealtimeMentionNotificationEvent) ProtoMessage() {} - -func (x *RealtimeMentionNotificationEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_realtime_v1_realtime_proto_msgTypes[37] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RealtimeMentionNotificationEvent.ProtoReflect.Descriptor instead. -func (*RealtimeMentionNotificationEvent) Descriptor() ([]byte, []int) { - return file_chatto_realtime_v1_realtime_proto_rawDescGZIP(), []int{37} -} - -func (x *RealtimeMentionNotificationEvent) GetRoomId() string { - if x != nil { - return x.RoomId - } - return "" -} - -func (x *RealtimeMentionNotificationEvent) GetActorUserId() string { - if x != nil { - return x.ActorUserId - } - return "" -} - -func (x *RealtimeMentionNotificationEvent) GetRoomName() string { - if x != nil && x.RoomName != nil { - return *x.RoomName - } - return "" -} - -func (x *RealtimeMentionNotificationEvent) GetActorDisplayName() string { - if x != nil && x.ActorDisplayName != nil { - return *x.ActorDisplayName - } - return "" -} - -// New-DM attention signal for the connected user. -// -// Inline names and avatar URLs are display hints. Hydrate the DM room through -// `RoomDirectoryService.GetRoom` or `RoomDirectoryService.BatchGetRooms`, and -// the sender through `UserService.GetUser` or `UserService.BatchGetUsers` when -// local caches are missing or stale. -type RealtimeNewDirectMessageNotificationEvent struct { - state protoimpl.MessageState `protogen:"open.v1"` - // DM room ID. - RoomId string `protobuf:"bytes,1,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` - // Sender user ID. - SenderId string `protobuf:"bytes,2,opt,name=sender_id,json=senderId,proto3" json:"sender_id,omitempty"` - // Display name of the sender, when hydrated. - SenderDisplayName *string `protobuf:"bytes,3,opt,name=sender_display_name,json=senderDisplayName,proto3,oneof" json:"sender_display_name,omitempty"` - // Avatar URL of the sender, when one is available. - SenderAvatarUrl *string `protobuf:"bytes,4,opt,name=sender_avatar_url,json=senderAvatarUrl,proto3,oneof" json:"sender_avatar_url,omitempty"` - // Display name for the DM conversation from the connected user's perspective, when hydrated. - ConversationName *string `protobuf:"bytes,5,opt,name=conversation_name,json=conversationName,proto3,oneof" json:"conversation_name,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *RealtimeNewDirectMessageNotificationEvent) Reset() { - *x = RealtimeNewDirectMessageNotificationEvent{} - mi := &file_chatto_realtime_v1_realtime_proto_msgTypes[38] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *RealtimeNewDirectMessageNotificationEvent) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*RealtimeNewDirectMessageNotificationEvent) ProtoMessage() {} - -func (x *RealtimeNewDirectMessageNotificationEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_realtime_v1_realtime_proto_msgTypes[38] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use RealtimeNewDirectMessageNotificationEvent.ProtoReflect.Descriptor instead. -func (*RealtimeNewDirectMessageNotificationEvent) Descriptor() ([]byte, []int) { - return file_chatto_realtime_v1_realtime_proto_rawDescGZIP(), []int{38} -} - -func (x *RealtimeNewDirectMessageNotificationEvent) GetRoomId() string { - if x != nil { - return x.RoomId - } - return "" -} - -func (x *RealtimeNewDirectMessageNotificationEvent) GetSenderId() string { - if x != nil { - return x.SenderId - } - return "" -} - -func (x *RealtimeNewDirectMessageNotificationEvent) GetSenderDisplayName() string { - if x != nil && x.SenderDisplayName != nil { - return *x.SenderDisplayName - } - return "" -} - -func (x *RealtimeNewDirectMessageNotificationEvent) GetSenderAvatarUrl() string { - if x != nil && x.SenderAvatarUrl != nil { - return *x.SenderAvatarUrl - } - return "" -} - -func (x *RealtimeNewDirectMessageNotificationEvent) GetConversationName() string { - if x != nil && x.ConversationName != nil { - return *x.ConversationName - } - return "" -} - // Session-terminated signal for the connected user's current session. type RealtimeSessionTerminatedEvent struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -3171,7 +2958,7 @@ type RealtimeSessionTerminatedEvent struct { func (x *RealtimeSessionTerminatedEvent) Reset() { *x = RealtimeSessionTerminatedEvent{} - mi := &file_chatto_realtime_v1_realtime_proto_msgTypes[39] + mi := &file_chatto_realtime_v1_realtime_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3183,7 +2970,7 @@ func (x *RealtimeSessionTerminatedEvent) String() string { func (*RealtimeSessionTerminatedEvent) ProtoMessage() {} func (x *RealtimeSessionTerminatedEvent) ProtoReflect() protoreflect.Message { - mi := &file_chatto_realtime_v1_realtime_proto_msgTypes[39] + mi := &file_chatto_realtime_v1_realtime_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3196,7 +2983,7 @@ func (x *RealtimeSessionTerminatedEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use RealtimeSessionTerminatedEvent.ProtoReflect.Descriptor instead. func (*RealtimeSessionTerminatedEvent) Descriptor() ([]byte, []int) { - return file_chatto_realtime_v1_realtime_proto_rawDescGZIP(), []int{39} + return file_chatto_realtime_v1_realtime_proto_rawDescGZIP(), []int{37} } func (x *RealtimeSessionTerminatedEvent) GetReason() string { @@ -3210,7 +2997,7 @@ var File_chatto_realtime_v1_realtime_proto protoreflect.FileDescriptor const file_chatto_realtime_v1_realtime_proto_rawDesc = "" + "\n" + - "!chatto/realtime/v1/realtime.proto\x12\x12chatto.realtime.v1\x1a,chatto/api/v1/notification_preferences.proto\x1a!chatto/api/v1/notifications.proto\x1a$chatto/api/v1/member_directory.proto\x1a!chatto/api/v1/message_types.proto\x1a\x1cchatto/api/v1/presence.proto\x1a\"chatto/api/v1/room_directory.proto\x1a!chatto/api/v1/room_timeline.proto\x1a\x1achatto/api/v1/server.proto\x1a chatto/api/v1/server_state.proto\x1a\x1achatto/api/v1/viewer.proto\x1a\x1fchatto/api/v1/voice_calls.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc4\x02\n" + + "!chatto/realtime/v1/realtime.proto\x12\x12chatto.realtime.v1\x1a!chatto/api/v1/notifications.proto\x1a$chatto/api/v1/member_directory.proto\x1a!chatto/api/v1/message_types.proto\x1a\x1cchatto/api/v1/presence.proto\x1a\"chatto/api/v1/room_directory.proto\x1a!chatto/api/v1/room_timeline.proto\x1a\x1achatto/api/v1/server.proto\x1a chatto/api/v1/server_state.proto\x1a\x1achatto/api/v1/viewer.proto\x1a\x1fchatto/api/v1/voice_calls.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc4\x02\n" + "\x13RealtimeClientFrame\x12?\n" + "\x05hello\x18\x01 \x01(\v2'.chatto.realtime.v1.RealtimeClientHelloH\x00R\x05hello\x12X\n" + "\x10subscribe_events\x18\x02 \x01(\v2+.chatto.realtime.v1.RealtimeSubscribeEventsH\x00R\x0fsubscribeEvents\x126\n" + @@ -3296,13 +3083,12 @@ const file_chatto_realtime_v1_realtime_proto_rawDesc = "" + "%RealtimeProjectionPinnedMessageChange\x12Q\n" + "\x06action\x18\x01 \x01(\x0e29.chatto.realtime.v1.RealtimeProjectionPinnedMessageActionR\x06action\x12\x17\n" + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12(\n" + - "\x10message_event_id\x18\x03 \x01(\tR\x0emessageEventId\"\x81\x02\n" + + "\x10message_event_id\x18\x03 \x01(\tR\x0emessageEventId\"\xe6\x01\n" + "\x16RealtimeProjectionRoom\x126\n" + "\x04room\x18\x01 \x01(\v2\".chatto.api.v1.RoomWithViewerStateR\x04room\x12&\n" + - "\x0fmember_user_ids\x18\x02 \x03(\tR\rmemberUserIds\x12:\n" + - "\x19viewer_notification_count\x18\x03 \x01(\rR\x17viewerNotificationCount\x123\n" + + "\x0fmember_user_ids\x18\x02 \x03(\tR\rmemberUserIds\x123\n" + "\x13has_message_history\x18\x04 \x01(\bH\x00R\x11hasMessageHistory\x88\x01\x01B\x16\n" + - "\x14_has_message_history\"9\n" + + "\x14_has_message_historyJ\x04\b\x03\x10\x04R\x19viewer_notification_count\"9\n" + "\x1eRealtimeProjectionRoomActivity\x12\x17\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\"7\n" + "\x1cRealtimeProjectionUserRemove\x12\x17\n" + @@ -3339,13 +3125,11 @@ const file_chatto_realtime_v1_realtime_proto_rawDesc = "" + "\x10_reaction_change\"_\n" + ")RealtimeProjectionRoomTimelineEventRemove\x12\x17\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\x19\n" + - "\bevent_id\x18\x02 \x01(\tR\aeventId\"\x8f\x02\n" + - "&RealtimeProjectionNotificationsReplace\x12<\n" + - "\x04page\x18\x01 \x01(\v2(.chatto.api.v1.ListNotificationsResponseR\x04page\x12E\n" + - "\vroom_counts\x18\x02 \x03(\v2$.chatto.api.v1.RoomNotificationCountR\n" + - "roomCounts\x12U\n" + - "\x06change\x18\x03 \x01(\v28.chatto.realtime.v1.RealtimeProjectionNotificationChangeH\x00R\x06change\x88\x01\x01B\t\n" + - "\a_change\"\xb9\x01\n" + + "\bevent_id\x18\x02 \x01(\tR\aeventId\"\xf0\x01\n" + + "&RealtimeProjectionNotificationsReplace\x12U\n" + + "\x06change\x18\x03 \x01(\v28.chatto.realtime.v1.RealtimeProjectionNotificationChangeH\x00R\x06change\x88\x01\x01\x12E\n" + + "\x06groups\x18\x04 \x01(\v2-.chatto.api.v1.ListNotificationGroupsResponseR\x06groupsB\t\n" + + "\a_changeJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x04pageR\vroom_counts\"\xb9\x01\n" + "$RealtimeProjectionNotificationChange\x12P\n" + "\x06action\x18\x01 \x01(\x0e28.chatto.realtime.v1.RealtimeProjectionNotificationActionR\x06action\x12'\n" + "\x0fnotification_id\x18\x02 \x01(\tR\x0enotificationId\x12\x16\n" + @@ -3381,7 +3165,7 @@ const file_chatto_realtime_v1_realtime_proto_rawDesc = "" + "\x04code\x18\x01 \x01(\tR\x04code\x12\x18\n" + "\amessage\x18\x02 \x01(\tR\amessage\x12\x1c\n" + "\treconnect\x18\x03 \x01(\bR\treconnect\x12$\n" + - "\x0eretry_after_ms\x18\x04 \x01(\rR\fretryAfterMs\"\x8b\v\n" + + "\x0eretry_after_ms\x18\x04 \x01(\rR\fretryAfterMs\"\xce\t\n" + "\x15RealtimeEventEnvelope\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x129\n" + "\n" + @@ -3389,48 +3173,30 @@ const file_chatto_realtime_v1_realtime_proto_rawDesc = "" + "\bactor_id\x18\x03 \x01(\tH\x01R\aactorId\x88\x01\x01\x12J\n" + "\vuser_typing\x18\x1e \x01(\v2'.chatto.realtime.v1.RealtimeTypingEventH\x00R\n" + "userTyping\x12]\n" + - "\x10presence_changed\x18\x1f \x01(\v20.chatto.realtime.v1.RealtimePresenceChangedEventH\x00R\x0fpresenceChanged\x12i\n" + - "\x14mention_notification\x18X \x01(\v24.chatto.realtime.v1.RealtimeMentionNotificationEventH\x00R\x13mentionNotification\x12\x86\x01\n" + - "\x1fnew_direct_message_notification\x18Y \x01(\v2=.chatto.realtime.v1.RealtimeNewDirectMessageNotificationEventH\x00R\x1cnewDirectMessageNotification\x12c\n" + + "\x10presence_changed\x18\x1f \x01(\v20.chatto.realtime.v1.RealtimePresenceChangedEventH\x00R\x0fpresenceChanged\x12c\n" + "\x12session_terminated\x18Z \x01(\v22.chatto.realtime.v1.RealtimeSessionTerminatedEventH\x00R\x11sessionTerminatedB\a\n" + "\x05eventB\v\n" + "\t_actor_idJ\x04\b\x04\x10\x05J\x04\b\x05\x10\x06J\x04\b\n" + - "\x10\rJ\x04\b\x14\x10\x16J\x04\b(\x100J\x04\b<\x10MJ\x04\bP\x10XR\x06cursorR\rresume_cursorR\x0emessage_postedR\x0emessage_editedR\x11message_retractedR\x0ereaction_addedR\x10reaction_removedR\froom_createdR\froom_updatedR\froom_deletedR\rroom_archivedR\x0froom_unarchivedR\x10user_joined_roomR\x0euser_left_roomR\x16room_universal_changedR\x0ethread_createdR\x16user_custom_status_setR\x1auser_custom_status_clearedR\x18asset_processing_startedR\x1aasset_processing_succeededR\x17asset_processing_failedR\rasset_deletedR\x14notification_createdR\x16notification_dismissedR\x1anotification_level_changedR\x15thread_follow_changedR\x13room_marked_as_readR\x0eserver_updatedR\x14user_profile_updatedR\x1fserver_user_preferences_updatedR\x13room_groups_updatedR\x15server_member_deletedR\fcall_startedR\x17call_participant_joinedR\x15call_participant_leftR\n" + - "call_ended\"}\n" + + "\x10\rJ\x04\b\x14\x10\x16J\x04\b(\x100J\x04\b<\x10MJ\x04\bP\x10ZR\x06cursorR\rresume_cursorR\x0emessage_postedR\x0emessage_editedR\x11message_retractedR\x0ereaction_addedR\x10reaction_removedR\froom_createdR\froom_updatedR\froom_deletedR\rroom_archivedR\x0froom_unarchivedR\x10user_joined_roomR\x0euser_left_roomR\x16room_universal_changedR\x0ethread_createdR\x16user_custom_status_setR\x1auser_custom_status_clearedR\x18asset_processing_startedR\x1aasset_processing_succeededR\x17asset_processing_failedR\rasset_deletedR\x14notification_createdR\x16notification_dismissedR\x1anotification_level_changedR\x15thread_follow_changedR\x13room_marked_as_readR\x0eserver_updatedR\x14user_profile_updatedR\x1fserver_user_preferences_updatedR\x13room_groups_updatedR\x15server_member_deletedR\fcall_startedR\x17call_participant_joinedR\x15call_participant_leftR\n" + + "call_endedR\x14mention_notificationR\x1fnew_direct_message_notification\"}\n" + "\x13RealtimeTypingEvent\x12\x17\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x124\n" + "\x14thread_root_event_id\x18\x02 \x01(\tH\x00R\x11threadRootEventId\x88\x01\x01B\x17\n" + "\x15_thread_root_event_id\"n\n" + "\x1cRealtimePresenceChangedEvent\x12\x17\n" + "\auser_id\x18\x01 \x01(\tR\x06userId\x125\n" + - "\x06status\x18\x02 \x01(\x0e2\x1d.chatto.api.v1.PresenceStatusR\x06status\"\xd9\x01\n" + - " RealtimeMentionNotificationEvent\x12\x17\n" + - "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\"\n" + - "\ractor_user_id\x18\x02 \x01(\tR\vactorUserId\x12 \n" + - "\troom_name\x18\x03 \x01(\tH\x00R\broomName\x88\x01\x01\x121\n" + - "\x12actor_display_name\x18\x04 \x01(\tH\x01R\x10actorDisplayName\x88\x01\x01B\f\n" + - "\n" + - "_room_nameB\x15\n" + - "\x13_actor_display_name\"\xbd\x02\n" + - ")RealtimeNewDirectMessageNotificationEvent\x12\x17\n" + - "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\x1b\n" + - "\tsender_id\x18\x02 \x01(\tR\bsenderId\x123\n" + - "\x13sender_display_name\x18\x03 \x01(\tH\x00R\x11senderDisplayName\x88\x01\x01\x12/\n" + - "\x11sender_avatar_url\x18\x04 \x01(\tH\x01R\x0fsenderAvatarUrl\x88\x01\x01\x120\n" + - "\x11conversation_name\x18\x05 \x01(\tH\x02R\x10conversationName\x88\x01\x01B\x16\n" + - "\x14_sender_display_nameB\x14\n" + - "\x12_sender_avatar_urlB\x14\n" + - "\x12_conversation_name\"8\n" + + "\x06status\x18\x02 \x01(\x0e2\x1d.chatto.api.v1.PresenceStatusR\x06status\"8\n" + "\x1eRealtimeSessionTerminatedEvent\x12\x16\n" + "\x06reason\x18\x01 \x01(\tR\x06reason*\xd0\x01\n" + "%RealtimeProjectionPinnedMessageAction\x129\n" + "5REALTIME_PROJECTION_PINNED_MESSAGE_ACTION_UNSPECIFIED\x10\x00\x125\n" + "1REALTIME_PROJECTION_PINNED_MESSAGE_ACTION_CREATED\x10\x01\x125\n" + - "1REALTIME_PROJECTION_PINNED_MESSAGE_ACTION_DELETED\x10\x02*\xcb\x01\n" + + "1REALTIME_PROJECTION_PINNED_MESSAGE_ACTION_DELETED\x10\x02*\xb7\x02\n" + "$RealtimeProjectionNotificationAction\x127\n" + "3REALTIME_PROJECTION_NOTIFICATION_ACTION_UNSPECIFIED\x10\x00\x123\n" + - "/REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED\x10\x01\x125\n" + - "1REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED\x10\x02*\xb7\x01\n" + + "/REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED\x10\x01\x123\n" + + "/REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED\x10\x03\x123\n" + + "/REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED\x10\x04\"\x04\b\x02\x10\x02*1REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED*\xb7\x01\n" + " RealtimeProjectionReactionAction\x123\n" + "/REALTIME_PROJECTION_REACTION_ACTION_UNSPECIFIED\x10\x00\x12-\n" + ")REALTIME_PROJECTION_REACTION_ACTION_ADDED\x10\x01\x12/\n" + @@ -3450,7 +3216,7 @@ func file_chatto_realtime_v1_realtime_proto_rawDescGZIP() []byte { } var file_chatto_realtime_v1_realtime_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_chatto_realtime_v1_realtime_proto_msgTypes = make([]protoimpl.MessageInfo, 42) +var file_chatto_realtime_v1_realtime_proto_msgTypes = make([]protoimpl.MessageInfo, 40) var file_chatto_realtime_v1_realtime_proto_goTypes = []any{ (RealtimeProjectionPinnedMessageAction)(0), // 0: chatto.realtime.v1.RealtimeProjectionPinnedMessageAction (RealtimeProjectionNotificationAction)(0), // 1: chatto.realtime.v1.RealtimeProjectionNotificationAction @@ -3492,27 +3258,24 @@ var file_chatto_realtime_v1_realtime_proto_goTypes = []any{ (*RealtimeEventEnvelope)(nil), // 37: chatto.realtime.v1.RealtimeEventEnvelope (*RealtimeTypingEvent)(nil), // 38: chatto.realtime.v1.RealtimeTypingEvent (*RealtimePresenceChangedEvent)(nil), // 39: chatto.realtime.v1.RealtimePresenceChangedEvent - (*RealtimeMentionNotificationEvent)(nil), // 40: chatto.realtime.v1.RealtimeMentionNotificationEvent - (*RealtimeNewDirectMessageNotificationEvent)(nil), // 41: chatto.realtime.v1.RealtimeNewDirectMessageNotificationEvent - (*RealtimeSessionTerminatedEvent)(nil), // 42: chatto.realtime.v1.RealtimeSessionTerminatedEvent - nil, // 43: chatto.realtime.v1.RealtimeProjectionPresencesReplace.StatusesEntry - nil, // 44: chatto.realtime.v1.RealtimeProjectionRoomTimelineReplace.EventCursorsEntry - (*timestamppb.Timestamp)(nil), // 45: google.protobuf.Timestamp - (*v1.ServerPublicProfile)(nil), // 46: chatto.api.v1.ServerPublicProfile - (*v1.GetViewerResponse)(nil), // 47: chatto.api.v1.GetViewerResponse - (*v1.DirectoryMember)(nil), // 48: chatto.api.v1.DirectoryMember - (*v1.ServerRuntimeConfig)(nil), // 49: chatto.api.v1.ServerRuntimeConfig - (*v1.RoomWithViewerState)(nil), // 50: chatto.api.v1.RoomWithViewerState - (*v1.ThreadViewerState)(nil), // 51: chatto.api.v1.ThreadViewerState - (*v1.RoomGroup)(nil), // 52: chatto.api.v1.RoomGroup - (*v1.RoomTimelinePage)(nil), // 53: chatto.api.v1.RoomTimelinePage - (*v1.RoomTimelineEvent)(nil), // 54: chatto.api.v1.RoomTimelineEvent - (*v1.RoomTimelineIncludes)(nil), // 55: chatto.api.v1.RoomTimelineIncludes - (*v1.ListNotificationsResponse)(nil), // 56: chatto.api.v1.ListNotificationsResponse - (*v1.RoomNotificationCount)(nil), // 57: chatto.api.v1.RoomNotificationCount - (*v1.RoomViewerState)(nil), // 58: chatto.api.v1.RoomViewerState - (*v1.ActiveCall)(nil), // 59: chatto.api.v1.ActiveCall - (v1.PresenceStatus)(0), // 60: chatto.api.v1.PresenceStatus + (*RealtimeSessionTerminatedEvent)(nil), // 40: chatto.realtime.v1.RealtimeSessionTerminatedEvent + nil, // 41: chatto.realtime.v1.RealtimeProjectionPresencesReplace.StatusesEntry + nil, // 42: chatto.realtime.v1.RealtimeProjectionRoomTimelineReplace.EventCursorsEntry + (*timestamppb.Timestamp)(nil), // 43: google.protobuf.Timestamp + (*v1.ServerPublicProfile)(nil), // 44: chatto.api.v1.ServerPublicProfile + (*v1.GetViewerResponse)(nil), // 45: chatto.api.v1.GetViewerResponse + (*v1.DirectoryMember)(nil), // 46: chatto.api.v1.DirectoryMember + (*v1.ServerRuntimeConfig)(nil), // 47: chatto.api.v1.ServerRuntimeConfig + (*v1.RoomWithViewerState)(nil), // 48: chatto.api.v1.RoomWithViewerState + (*v1.ThreadViewerState)(nil), // 49: chatto.api.v1.ThreadViewerState + (*v1.RoomGroup)(nil), // 50: chatto.api.v1.RoomGroup + (*v1.RoomTimelinePage)(nil), // 51: chatto.api.v1.RoomTimelinePage + (*v1.RoomTimelineEvent)(nil), // 52: chatto.api.v1.RoomTimelineEvent + (*v1.RoomTimelineIncludes)(nil), // 53: chatto.api.v1.RoomTimelineIncludes + (*v1.ListNotificationGroupsResponse)(nil), // 54: chatto.api.v1.ListNotificationGroupsResponse + (*v1.RoomViewerState)(nil), // 55: chatto.api.v1.RoomViewerState + (*v1.ActiveCall)(nil), // 56: chatto.api.v1.ActiveCall + (v1.PresenceStatus)(0), // 57: chatto.api.v1.PresenceStatus } var file_chatto_realtime_v1_realtime_proto_depIdxs = []int32{ 5, // 0: chatto.realtime.v1.RealtimeClientFrame.hello:type_name -> chatto.realtime.v1.RealtimeClientHello @@ -3528,12 +3291,12 @@ var file_chatto_realtime_v1_realtime_proto_depIdxs = []int32{ 33, // 10: chatto.realtime.v1.RealtimeServerFrame.pong:type_name -> chatto.realtime.v1.RealtimePong 10, // 11: chatto.realtime.v1.RealtimeServerFrame.caught_up:type_name -> chatto.realtime.v1.RealtimeCaughtUp 11, // 12: chatto.realtime.v1.RealtimeServerFrame.projection_event:type_name -> chatto.realtime.v1.RealtimeProjectionEvent - 45, // 13: chatto.realtime.v1.RealtimeProjectionEvent.created_at:type_name -> google.protobuf.Timestamp + 43, // 13: chatto.realtime.v1.RealtimeProjectionEvent.created_at:type_name -> google.protobuf.Timestamp 12, // 14: chatto.realtime.v1.RealtimeProjectionEvent.operations:type_name -> chatto.realtime.v1.RealtimeProjectionOperation 13, // 15: chatto.realtime.v1.RealtimeProjectionOperation.reset:type_name -> chatto.realtime.v1.RealtimeProjectionReset - 46, // 16: chatto.realtime.v1.RealtimeProjectionOperation.server_upsert:type_name -> chatto.api.v1.ServerPublicProfile - 47, // 17: chatto.realtime.v1.RealtimeProjectionOperation.viewer_upsert:type_name -> chatto.api.v1.GetViewerResponse - 48, // 18: chatto.realtime.v1.RealtimeProjectionOperation.user_upsert:type_name -> chatto.api.v1.DirectoryMember + 44, // 16: chatto.realtime.v1.RealtimeProjectionOperation.server_upsert:type_name -> chatto.api.v1.ServerPublicProfile + 45, // 17: chatto.realtime.v1.RealtimeProjectionOperation.viewer_upsert:type_name -> chatto.api.v1.GetViewerResponse + 46, // 18: chatto.realtime.v1.RealtimeProjectionOperation.user_upsert:type_name -> chatto.api.v1.DirectoryMember 18, // 19: chatto.realtime.v1.RealtimeProjectionOperation.user_remove:type_name -> chatto.realtime.v1.RealtimeProjectionUserRemove 16, // 20: chatto.realtime.v1.RealtimeProjectionOperation.room_upsert:type_name -> chatto.realtime.v1.RealtimeProjectionRoom 22, // 21: chatto.realtime.v1.RealtimeProjectionOperation.room_remove:type_name -> chatto.realtime.v1.RealtimeProjectionRoomRemove @@ -3548,40 +3311,37 @@ var file_chatto_realtime_v1_realtime_proto_depIdxs = []int32{ 19, // 30: chatto.realtime.v1.RealtimeProjectionOperation.presences_replace:type_name -> chatto.realtime.v1.RealtimeProjectionPresencesReplace 20, // 31: chatto.realtime.v1.RealtimeProjectionOperation.thread_viewer_states_replace:type_name -> chatto.realtime.v1.RealtimeProjectionThreadViewerStatesReplace 17, // 32: chatto.realtime.v1.RealtimeProjectionOperation.room_activity:type_name -> chatto.realtime.v1.RealtimeProjectionRoomActivity - 49, // 33: chatto.realtime.v1.RealtimeProjectionServerState.runtime:type_name -> chatto.api.v1.ServerRuntimeConfig + 47, // 33: chatto.realtime.v1.RealtimeProjectionServerState.runtime:type_name -> chatto.api.v1.ServerRuntimeConfig 15, // 34: chatto.realtime.v1.RealtimeProjectionServerState.pinned_message_change:type_name -> chatto.realtime.v1.RealtimeProjectionPinnedMessageChange 0, // 35: chatto.realtime.v1.RealtimeProjectionPinnedMessageChange.action:type_name -> chatto.realtime.v1.RealtimeProjectionPinnedMessageAction - 50, // 36: chatto.realtime.v1.RealtimeProjectionRoom.room:type_name -> chatto.api.v1.RoomWithViewerState - 43, // 37: chatto.realtime.v1.RealtimeProjectionPresencesReplace.statuses:type_name -> chatto.realtime.v1.RealtimeProjectionPresencesReplace.StatusesEntry + 48, // 36: chatto.realtime.v1.RealtimeProjectionRoom.room:type_name -> chatto.api.v1.RoomWithViewerState + 41, // 37: chatto.realtime.v1.RealtimeProjectionPresencesReplace.statuses:type_name -> chatto.realtime.v1.RealtimeProjectionPresencesReplace.StatusesEntry 21, // 38: chatto.realtime.v1.RealtimeProjectionThreadViewerStatesReplace.states:type_name -> chatto.realtime.v1.RealtimeProjectionThreadViewerState - 51, // 39: chatto.realtime.v1.RealtimeProjectionThreadViewerState.viewer_state:type_name -> chatto.api.v1.ThreadViewerState - 52, // 40: chatto.realtime.v1.RealtimeProjectionRoomGroupsReplace.groups:type_name -> chatto.api.v1.RoomGroup - 53, // 41: chatto.realtime.v1.RealtimeProjectionRoomTimelineReplace.page:type_name -> chatto.api.v1.RoomTimelinePage - 44, // 42: chatto.realtime.v1.RealtimeProjectionRoomTimelineReplace.event_cursors:type_name -> chatto.realtime.v1.RealtimeProjectionRoomTimelineReplace.EventCursorsEntry - 54, // 43: chatto.realtime.v1.RealtimeProjectionRoomTimelineEventUpsert.event:type_name -> chatto.api.v1.RoomTimelineEvent - 55, // 44: chatto.realtime.v1.RealtimeProjectionRoomTimelineEventUpsert.includes:type_name -> chatto.api.v1.RoomTimelineIncludes + 49, // 39: chatto.realtime.v1.RealtimeProjectionThreadViewerState.viewer_state:type_name -> chatto.api.v1.ThreadViewerState + 50, // 40: chatto.realtime.v1.RealtimeProjectionRoomGroupsReplace.groups:type_name -> chatto.api.v1.RoomGroup + 51, // 41: chatto.realtime.v1.RealtimeProjectionRoomTimelineReplace.page:type_name -> chatto.api.v1.RoomTimelinePage + 42, // 42: chatto.realtime.v1.RealtimeProjectionRoomTimelineReplace.event_cursors:type_name -> chatto.realtime.v1.RealtimeProjectionRoomTimelineReplace.EventCursorsEntry + 52, // 43: chatto.realtime.v1.RealtimeProjectionRoomTimelineEventUpsert.event:type_name -> chatto.api.v1.RoomTimelineEvent + 53, // 44: chatto.realtime.v1.RealtimeProjectionRoomTimelineEventUpsert.includes:type_name -> chatto.api.v1.RoomTimelineIncludes 31, // 45: chatto.realtime.v1.RealtimeProjectionRoomTimelineEventUpsert.reaction_change:type_name -> chatto.realtime.v1.RealtimeProjectionReactionChange - 56, // 46: chatto.realtime.v1.RealtimeProjectionNotificationsReplace.page:type_name -> chatto.api.v1.ListNotificationsResponse - 57, // 47: chatto.realtime.v1.RealtimeProjectionNotificationsReplace.room_counts:type_name -> chatto.api.v1.RoomNotificationCount - 28, // 48: chatto.realtime.v1.RealtimeProjectionNotificationsReplace.change:type_name -> chatto.realtime.v1.RealtimeProjectionNotificationChange - 1, // 49: chatto.realtime.v1.RealtimeProjectionNotificationChange.action:type_name -> chatto.realtime.v1.RealtimeProjectionNotificationAction - 58, // 50: chatto.realtime.v1.RealtimeProjectionRoomViewerStateReplace.viewer_state:type_name -> chatto.api.v1.RoomViewerState - 59, // 51: chatto.realtime.v1.RealtimeProjectionActiveCallsReplace.calls:type_name -> chatto.api.v1.ActiveCall - 2, // 52: chatto.realtime.v1.RealtimeProjectionReactionChange.action:type_name -> chatto.realtime.v1.RealtimeProjectionReactionAction - 45, // 53: chatto.realtime.v1.RealtimeHeartbeat.created_at:type_name -> google.protobuf.Timestamp - 45, // 54: chatto.realtime.v1.RealtimeEventEnvelope.created_at:type_name -> google.protobuf.Timestamp - 38, // 55: chatto.realtime.v1.RealtimeEventEnvelope.user_typing:type_name -> chatto.realtime.v1.RealtimeTypingEvent - 39, // 56: chatto.realtime.v1.RealtimeEventEnvelope.presence_changed:type_name -> chatto.realtime.v1.RealtimePresenceChangedEvent - 40, // 57: chatto.realtime.v1.RealtimeEventEnvelope.mention_notification:type_name -> chatto.realtime.v1.RealtimeMentionNotificationEvent - 41, // 58: chatto.realtime.v1.RealtimeEventEnvelope.new_direct_message_notification:type_name -> chatto.realtime.v1.RealtimeNewDirectMessageNotificationEvent - 42, // 59: chatto.realtime.v1.RealtimeEventEnvelope.session_terminated:type_name -> chatto.realtime.v1.RealtimeSessionTerminatedEvent - 60, // 60: chatto.realtime.v1.RealtimePresenceChangedEvent.status:type_name -> chatto.api.v1.PresenceStatus - 60, // 61: chatto.realtime.v1.RealtimeProjectionPresencesReplace.StatusesEntry.value:type_name -> chatto.api.v1.PresenceStatus - 62, // [62:62] is the sub-list for method output_type - 62, // [62:62] is the sub-list for method input_type - 62, // [62:62] is the sub-list for extension type_name - 62, // [62:62] is the sub-list for extension extendee - 0, // [0:62] is the sub-list for field type_name + 28, // 46: chatto.realtime.v1.RealtimeProjectionNotificationsReplace.change:type_name -> chatto.realtime.v1.RealtimeProjectionNotificationChange + 54, // 47: chatto.realtime.v1.RealtimeProjectionNotificationsReplace.groups:type_name -> chatto.api.v1.ListNotificationGroupsResponse + 1, // 48: chatto.realtime.v1.RealtimeProjectionNotificationChange.action:type_name -> chatto.realtime.v1.RealtimeProjectionNotificationAction + 55, // 49: chatto.realtime.v1.RealtimeProjectionRoomViewerStateReplace.viewer_state:type_name -> chatto.api.v1.RoomViewerState + 56, // 50: chatto.realtime.v1.RealtimeProjectionActiveCallsReplace.calls:type_name -> chatto.api.v1.ActiveCall + 2, // 51: chatto.realtime.v1.RealtimeProjectionReactionChange.action:type_name -> chatto.realtime.v1.RealtimeProjectionReactionAction + 43, // 52: chatto.realtime.v1.RealtimeHeartbeat.created_at:type_name -> google.protobuf.Timestamp + 43, // 53: chatto.realtime.v1.RealtimeEventEnvelope.created_at:type_name -> google.protobuf.Timestamp + 38, // 54: chatto.realtime.v1.RealtimeEventEnvelope.user_typing:type_name -> chatto.realtime.v1.RealtimeTypingEvent + 39, // 55: chatto.realtime.v1.RealtimeEventEnvelope.presence_changed:type_name -> chatto.realtime.v1.RealtimePresenceChangedEvent + 40, // 56: chatto.realtime.v1.RealtimeEventEnvelope.session_terminated:type_name -> chatto.realtime.v1.RealtimeSessionTerminatedEvent + 57, // 57: chatto.realtime.v1.RealtimePresenceChangedEvent.status:type_name -> chatto.api.v1.PresenceStatus + 57, // 58: chatto.realtime.v1.RealtimeProjectionPresencesReplace.StatusesEntry.value:type_name -> chatto.api.v1.PresenceStatus + 59, // [59:59] is the sub-list for method output_type + 59, // [59:59] is the sub-list for method input_type + 59, // [59:59] is the sub-list for extension type_name + 59, // [59:59] is the sub-list for extension extendee + 0, // [0:59] is the sub-list for field type_name } func init() { file_chatto_realtime_v1_realtime_proto_init() } @@ -3638,20 +3398,16 @@ func file_chatto_realtime_v1_realtime_proto_init() { file_chatto_realtime_v1_realtime_proto_msgTypes[34].OneofWrappers = []any{ (*RealtimeEventEnvelope_UserTyping)(nil), (*RealtimeEventEnvelope_PresenceChanged)(nil), - (*RealtimeEventEnvelope_MentionNotification)(nil), - (*RealtimeEventEnvelope_NewDirectMessageNotification)(nil), (*RealtimeEventEnvelope_SessionTerminated)(nil), } file_chatto_realtime_v1_realtime_proto_msgTypes[35].OneofWrappers = []any{} - file_chatto_realtime_v1_realtime_proto_msgTypes[37].OneofWrappers = []any{} - file_chatto_realtime_v1_realtime_proto_msgTypes[38].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_chatto_realtime_v1_realtime_proto_rawDesc), len(file_chatto_realtime_v1_realtime_proto_rawDesc)), NumEnums: 3, - NumMessages: 42, + NumMessages: 40, NumExtensions: 0, NumServices: 0, }, diff --git a/cli/internal/projectionsnapshot/repository.go b/cli/internal/projectionsnapshot/repository.go index ff5c76011..ab77a5154 100644 --- a/cli/internal/projectionsnapshot/repository.go +++ b/cli/internal/projectionsnapshot/repository.go @@ -36,18 +36,19 @@ const ( ) const ( - ProjectionThreadsKey = "threads" - ProjectionRoomDirectoryKey = "room_directory" - ProjectionServerConfigKey = "server_config" - ProjectionRoomGroupLayoutKey = "room_group_layout" - ProjectionRoomTimelineKey = "room_timeline" - ProjectionCallStateKey = "call_state" - ProjectionAssetsKey = "assets" - ProjectionReactionsKey = "reactions" - ProjectionContentKeysKey = "content_keys" - ProjectionRBACKey = "rbac" - ProjectionMentionablesKey = "mentionables" - ProjectionUsersKey = "users" + ProjectionThreadsKey = "threads" + ProjectionRoomDirectoryKey = "room_directory" + ProjectionNotificationVisibilityKey = "notification_visibility" + ProjectionServerConfigKey = "server_config" + ProjectionRoomGroupLayoutKey = "room_group_layout" + ProjectionRoomTimelineKey = "room_timeline" + ProjectionCallStateKey = "call_state" + ProjectionAssetsKey = "assets" + ProjectionReactionsKey = "reactions" + ProjectionContentKeysKey = "content_keys" + ProjectionRBACKey = "rbac" + ProjectionMentionablesKey = "mentionables" + ProjectionUsersKey = "users" ) var ( diff --git a/cli/internal/push/push.go b/cli/internal/push/push.go index 543accb99..8101a9ed0 100644 --- a/cli/internal/push/push.go +++ b/cli/internal/push/push.go @@ -63,9 +63,6 @@ type Payload struct { NotificationID string `json:"notificationId,omitempty"` URL string `json:"url,omitempty"` AppBadge string `json:"-"` - // Action is empty for regular user-visible notifications. Control pushes set - // it to a command such as "dismiss" and do not display a new notification. - Action string `json:"action,omitempty"` } type declarativeNotification struct { @@ -93,7 +90,6 @@ func (p Payload) MarshalJSON() ([]byte, error) { Tag string `json:"tag,omitempty"` NotificationID string `json:"notificationId,omitempty"` URL string `json:"url,omitempty"` - Action string `json:"action,omitempty"` WebPush int `json:"web_push,omitempty"` Mutable bool `json:"mutable,omitempty"` AppBadge string `json:"app_badge,omitempty"` @@ -108,7 +104,6 @@ func (p Payload) MarshalJSON() ([]byte, error) { Tag: p.Tag, NotificationID: p.NotificationID, URL: p.URL, - Action: p.Action, AppBadge: p.AppBadge, } if p.declarativeNotificationEligible() { @@ -132,20 +127,7 @@ func (p Payload) MarshalJSON() ([]byte, error) { } func (p Payload) declarativeNotificationEligible() bool { - return p.Action == "" && p.Title != "" && p.URL != "" -} - -func (p Payload) isUserVisibleNotification() bool { - return p.Action == "" -} - -// deliveryUrgency keeps visible notifications prompt on sleeping mobile -// devices without using high-priority delivery for silent control pushes. -func (p Payload) deliveryUrgency() webpush.Urgency { - if p.isUserVisibleNotification() { - return webpush.UrgencyHigh - } - return webpush.UrgencyNormal + return p.Title != "" && p.URL != "" } // PayloadContext provides optional context for building push payloads. @@ -225,7 +207,7 @@ func (s *Sender) Send(ctx context.Context, sub *corev1.PushSubscription, payload VAPIDPublicKey: s.config.VAPIDPublicKey, VAPIDPrivateKey: s.config.VAPIDPrivateKey, TTL: 86400, // 24 hours - Urgency: payload.deliveryUrgency(), + Urgency: webpush.UrgencyHigh, RecordSize: pushRecordSize, HTTPClient: s.httpClient, }) @@ -353,12 +335,12 @@ func buildNotificationURL(baseURL, roomID, threadRootID, highlightEventID string return buildAppURL(baseURL, segments, "highlight", highlightEventID) } -// BuildPayloadFromNotification creates a push payload from a notification. +// BuildPayloadFromOccurrence creates a push payload from a notification occurrence. // The baseURL is used to build navigation URLs (e.g., "https://chatto.example.com"). // The optional payloadCtx provides message preview and room name for richer notifications. -func BuildPayloadFromNotification(notif *corev1.Notification, actorDisplayName, baseURL string, payloadCtx *PayloadContext) *Payload { +func BuildPayloadFromOccurrence(occurrence *corev1.NotificationOccurrence, actorDisplayName, baseURL string, payloadCtx *PayloadContext) *Payload { payload := &Payload{ - NotificationID: notif.Id, + NotificationID: occurrence.GetId(), Icon: buildAppURL(baseURL, []string{"icons", "icon-192.png"}, "", ""), Badge: buildAppURL(baseURL, []string{"icons", "icon-192.png"}, "", ""), // Badge should be monochrome, but use same for now } @@ -371,65 +353,101 @@ func BuildPayloadFromNotification(notif *corev1.Notification, actorDisplayName, roomName = payloadCtx.RoomName } - switch n := notif.Notification.(type) { - case *corev1.Notification_DmMessage: + target := occurrence.GetTarget() + if target == nil { + payload.Title = "New notification" + payload.Body = "You have a new notification" + return payload + } + + switch { + case occurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE): payload.Title = fmt.Sprintf("@%s sent you a new DM", actorDisplayName) payload.Body = preview - payload.Tag = "dm-" + n.DmMessage.EventId - payload.URL = buildNotificationURL(baseURL, n.DmMessage.RoomId, "", "") + payload.Tag = OccurrenceTag(occurrence) + payload.URL = buildNotificationURL(baseURL, target.GetRoomId(), "", "") - case *corev1.Notification_Mention: + case occurrenceHasMentionReason(occurrence): if roomName != "" { payload.Title = fmt.Sprintf("@%s mentioned you in #%s", actorDisplayName, roomName) } else { payload.Title = fmt.Sprintf("@%s mentioned you", actorDisplayName) } payload.Body = preview - payload.Tag = "mention-" + n.Mention.EventId - payload.URL = buildNotificationURL(baseURL, n.Mention.RoomId, n.Mention.InThread, n.Mention.EventId) + payload.Tag = OccurrenceTag(occurrence) + payload.URL = buildNotificationURL(baseURL, target.GetRoomId(), target.GetThreadRootEventId(), target.GetEventId()) - case *corev1.Notification_Reply: + case occurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REACTION): + if roomName != "" { + payload.Title = fmt.Sprintf("@%s reacted to your message in #%s", actorDisplayName, roomName) + } else { + payload.Title = fmt.Sprintf("@%s reacted to your message", actorDisplayName) + } + payload.Body = preview + if emoji := occurrence.GetReactionEmoji(); emoji != "" { + payload.Body = ":" + emoji + ":" + if preview != "" { + payload.Body += " · " + preview + } + } + payload.Tag = OccurrenceTag(occurrence) + payload.URL = buildNotificationURL(baseURL, target.GetRoomId(), target.GetThreadRootEventId(), target.GetEventId()) + + case occurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REPLY): if roomName != "" { payload.Title = fmt.Sprintf("@%s replied to you in #%s", actorDisplayName, roomName) } else { payload.Title = fmt.Sprintf("@%s replied to you", actorDisplayName) } payload.Body = preview - payload.Tag = "reply-" + n.Reply.EventId - payload.URL = buildNotificationURL(baseURL, n.Reply.RoomId, n.Reply.InThread, n.Reply.EventId) + payload.Tag = OccurrenceTag(occurrence) + payload.URL = buildNotificationURL(baseURL, target.GetRoomId(), target.GetThreadRootEventId(), target.GetEventId()) - case *corev1.Notification_RoomMessage: + default: if roomName != "" { payload.Title = fmt.Sprintf("@%s posted in #%s", actorDisplayName, roomName) } else { payload.Title = fmt.Sprintf("@%s posted a message", actorDisplayName) } payload.Body = preview - payload.Tag = "room-message-" + n.RoomMessage.EventId - payload.URL = buildNotificationURL(baseURL, n.RoomMessage.RoomId, "", n.RoomMessage.EventId) - - default: - payload.Title = "New notification" - payload.Body = "You have a new notification" + payload.Tag = OccurrenceTag(occurrence) + payload.URL = buildNotificationURL(baseURL, target.GetRoomId(), target.GetThreadRootEventId(), target.GetEventId()) } return payload } -// NotificationTag returns the push notification tag for a notification. -// Used for dismissing notifications on other devices. -// Tags use event IDs to uniquely identify each notification. -func NotificationTag(notif *corev1.Notification) string { - switch n := notif.Notification.(type) { - case *corev1.Notification_DmMessage: - return "dm-" + n.DmMessage.EventId - case *corev1.Notification_Mention: - return "mention-" + n.Mention.EventId - case *corev1.Notification_Reply: - return "reply-" + n.Reply.EventId - case *corev1.Notification_RoomMessage: - return "room-message-" + n.RoomMessage.EventId +// OccurrenceTag returns the stable native-notification tag for an occurrence. +func OccurrenceTag(occurrence *corev1.NotificationOccurrence) string { + eventID := occurrence.GetTarget().GetEventId() + switch { + case occurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE): + return "dm-" + eventID + case occurrenceHasMentionReason(occurrence): + return "mention-" + eventID + case occurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REPLY): + return "reply-" + eventID + case occurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REACTION): + return "reaction-" + eventID + case occurrence.GetTarget() != nil: + return "room-message-" + eventID default: return "" } } + +func occurrenceHasReason(occurrence *corev1.NotificationOccurrence, reason corev1.NotificationReason) bool { + for _, match := range occurrence.GetReasons() { + if match.GetReason() == reason { + return true + } + } + return false +} + +func occurrenceHasMentionReason(occurrence *corev1.NotificationOccurrence) bool { + return occurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) || + occurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_ROLE_MENTION) || + occurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_HERE) || + occurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_ALL) +} diff --git a/cli/internal/push/push_test.go b/cli/internal/push/push_test.go index f8d2398fe..bbe42a0d1 100644 --- a/cli/internal/push/push_test.go +++ b/cli/internal/push/push_test.go @@ -18,7 +18,6 @@ import ( webpush "github.com/SherClockHolmes/webpush-go" "github.com/charmbracelet/log" - "google.golang.org/protobuf/types/known/timestamppb" "hmans.de/chatto/internal/config" corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" @@ -216,42 +215,6 @@ func TestPayloadMarshal(t *testing.T) { } }) - t.Run("dismiss payload stays imperative only", func(t *testing.T) { - payload := &Payload{ - Action: "dismiss", - Tag: "test-tag", - AppBadge: "2", - } - - data, err := json.Marshal(payload) - if err != nil { - t.Fatalf("Failed to marshal payload: %v", err) - } - - var result map[string]interface{} - if err := json.Unmarshal(data, &result); err != nil { - t.Fatalf("Failed to unmarshal: %v", err) - } - - if result["action"] != "dismiss" { - t.Errorf("Expected dismiss action, got %v", result["action"]) - } - if result["tag"] != "test-tag" { - t.Errorf("Expected tag 'test-tag', got %v", result["tag"]) - } - if result["app_badge"] != "2" { - t.Errorf("Expected dismiss app_badge '2', got %v", result["app_badge"]) - } - if _, ok := result["web_push"]; ok { - t.Error("Expected dismiss payload to omit web_push") - } - if _, ok := result["mutable"]; ok { - t.Error("Expected dismiss payload to omit mutable") - } - if _, ok := result["notification"]; ok { - t.Error("Expected dismiss payload to omit declarative notification") - } - }) } func TestNormalizeVAPIDSubject(t *testing.T) { @@ -286,24 +249,40 @@ func TestNormalizeVAPIDSubject(t *testing.T) { } } -func TestBuildPayloadFromNotification(t *testing.T) { +func notificationOccurrenceForTest(id, recipientID, actorID, roomID, eventID, threadRootID string, reasons ...corev1.NotificationReason) *corev1.NotificationOccurrence { + occurrence := &corev1.NotificationOccurrence{ + Id: id, + RecipientId: recipientID, + ActorId: actorID, + Target: &corev1.NotificationTarget{ + RoomId: roomID, + EventId: eventID, + ThreadRootEventId: optionalString(threadRootID), + }, + } + for _, reason := range reasons { + occurrence.Reasons = append(occurrence.Reasons, &corev1.NotificationReasonMatch{Reason: reason}) + } + if len(reasons) == 0 { + occurrence.Target = nil + } + return occurrence +} + +func optionalString(value string) *string { + if value == "" { + return nil + } + return &value +} + +func TestBuildPayloadFromOccurrence(t *testing.T) { baseURL := "https://chatto.example.com" t.Run("builds DM message payload without context", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-123", - RecipientId: "user-1", - ActorId: "user-2", - CreatedAt: timestamppb.Now(), - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{ - RoomId: "dm-room-456", - EventId: "event-789", - }, - }, - } + notif := notificationOccurrenceForTest("notif-123", "user-1", "user-2", "dm-room-456", "event-789", "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE) - payload := BuildPayloadFromNotification(notif, "Alice", baseURL, nil) + payload := BuildPayloadFromOccurrence(notif, "Alice", baseURL, nil) if payload.Title != "@Alice sent you a new DM" { t.Errorf("Expected '@Alice sent you a new DM', got %s", payload.Title) @@ -323,17 +302,10 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("builds DM message payload with preview", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-123", - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{ - RoomId: "dm-room-456", - }, - }, - } + notif := notificationOccurrenceForTest("notif-123", "", "", "dm-room-456", "", "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE) ctx := &PayloadContext{MessagePreview: "Hey, how are you?"} - payload := BuildPayloadFromNotification(notif, "Alice", baseURL, ctx) + payload := BuildPayloadFromOccurrence(notif, "Alice", baseURL, ctx) if payload.Title != "@Alice sent you a new DM" { t.Errorf("Expected '@Alice sent you a new DM', got %s", payload.Title) @@ -344,17 +316,9 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("builds mention payload without context", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-456", - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{ - RoomId: "room-2", - EventId: "event-3", - }, - }, - } + notif := notificationOccurrenceForTest("notif-456", "", "", "room-2", "event-3", "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) - payload := BuildPayloadFromNotification(notif, "Bob", baseURL, nil) + payload := BuildPayloadFromOccurrence(notif, "Bob", baseURL, nil) if payload.Title != "@Bob mentioned you" { t.Errorf("Expected '@Bob mentioned you', got %s", payload.Title) @@ -368,18 +332,10 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("builds mention payload with room name and preview", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-456", - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{ - RoomId: "room-2", - EventId: "event-3", - }, - }, - } + notif := notificationOccurrenceForTest("notif-456", "", "", "room-2", "event-3", "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) ctx := &PayloadContext{MessagePreview: "Hey @Bob check this out", RoomName: "general"} - payload := BuildPayloadFromNotification(notif, "Alice", baseURL, ctx) + payload := BuildPayloadFromOccurrence(notif, "Alice", baseURL, ctx) if payload.Title != "@Alice mentioned you in #general" { t.Errorf("Expected '@Alice mentioned you in #general', got %s", payload.Title) @@ -390,17 +346,9 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("builds mention payload without event ID", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-789", - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{ - RoomId: "room-2", - // No EventId - }, - }, - } + notif := notificationOccurrenceForTest("notif-789", "", "", "room-2", "", "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) - payload := BuildPayloadFromNotification(notif, "Charlie", baseURL, nil) + payload := BuildPayloadFromOccurrence(notif, "Charlie", baseURL, nil) if payload.URL != "https://chatto.example.com/chat/-/room-2" { t.Errorf("Expected URL without event param, got %s", payload.URL) @@ -408,18 +356,9 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("builds thread mention payload", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-thread-mention", - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{ - RoomId: "room-2", - EventId: "mention-event", - InThread: "thread-root", - }, - }, - } + notif := notificationOccurrenceForTest("notif-thread-mention", "", "", "room-2", "mention-event", "thread-root", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) - payload := BuildPayloadFromNotification(notif, "Bob", baseURL, nil) + payload := BuildPayloadFromOccurrence(notif, "Bob", baseURL, nil) expectedURL := "https://chatto.example.com/chat/-/room-2/thread-root?highlight=mention-event" if payload.URL != expectedURL { @@ -428,19 +367,9 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("builds room-level reply payload without context", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-abc", - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{ - RoomId: "room-y", - EventId: "reply-event", - InReplyToId: "root-event", - // InThread empty — room-level reply - }, - }, - } + notif := notificationOccurrenceForTest("notif-abc", "", "", "room-y", "reply-event", "", corev1.NotificationReason_NOTIFICATION_REASON_REPLY) - payload := BuildPayloadFromNotification(notif, "Diana", baseURL, nil) + payload := BuildPayloadFromOccurrence(notif, "Diana", baseURL, nil) if payload.Title != "@Diana replied to you" { t.Errorf("Expected '@Diana replied to you', got %s", payload.Title) @@ -458,19 +387,9 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("builds thread reply payload without context", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-abc", - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{ - RoomId: "room-y", - EventId: "reply-event", - InReplyToId: "mid-thread-msg", // The specific message replied to (not the root) - InThread: "thread-root", // The actual thread root - }, - }, - } + notif := notificationOccurrenceForTest("notif-abc", "", "", "room-y", "reply-event", "thread-root", corev1.NotificationReason_NOTIFICATION_REASON_REPLY) - payload := BuildPayloadFromNotification(notif, "Diana", baseURL, nil) + payload := BuildPayloadFromOccurrence(notif, "Diana", baseURL, nil) if payload.Title != "@Diana replied to you" { t.Errorf("Expected '@Diana replied to you', got %s", payload.Title) @@ -483,19 +402,10 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("builds reply payload with preview", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-abc", - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{ - RoomId: "room-y", - EventId: "reply-event", - InReplyToId: "root-event", - }, - }, - } + notif := notificationOccurrenceForTest("notif-abc", "", "", "room-y", "reply-event", "", corev1.NotificationReason_NOTIFICATION_REASON_REPLY) ctx := &PayloadContext{MessagePreview: "Thanks for the update!"} - payload := BuildPayloadFromNotification(notif, "Diana", baseURL, ctx) + payload := BuildPayloadFromOccurrence(notif, "Diana", baseURL, ctx) if payload.Title != "@Diana replied to you" { t.Errorf("Expected '@Diana replied to you', got %s", payload.Title) @@ -506,19 +416,10 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("builds reply payload with room name and preview", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-abc", - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{ - RoomId: "room-y", - EventId: "reply-event", - InReplyToId: "root-event", - }, - }, - } + notif := notificationOccurrenceForTest("notif-abc", "", "", "room-y", "reply-event", "", corev1.NotificationReason_NOTIFICATION_REASON_REPLY) ctx := &PayloadContext{MessagePreview: "Thanks for the update!", RoomName: "general"} - payload := BuildPayloadFromNotification(notif, "Diana", baseURL, ctx) + payload := BuildPayloadFromOccurrence(notif, "Diana", baseURL, ctx) if payload.Title != "@Diana replied to you in #general" { t.Errorf("Expected '@Diana replied to you in #general', got %s", payload.Title) @@ -528,19 +429,32 @@ func TestBuildPayloadFromNotification(t *testing.T) { } }) - t.Run("builds room message payload with room name and preview", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-room-message", - Notification: &corev1.Notification_RoomMessage{ - RoomMessage: &corev1.RoomMessageNotification{ - RoomId: "room-news", - EventId: "room-event", - }, - }, + t.Run("builds reaction payload from reaction occurrence", func(t *testing.T) { + notif := notificationOccurrenceForTest("notif-reaction", "user-author", "user-reactor", "room-y", "message-event", "", corev1.NotificationReason_NOTIFICATION_REASON_REACTION) + notif.ReactionEmoji = "thumbsup" + ctx := &PayloadContext{MessagePreview: "The message that was reacted to", RoomName: "general"} + + payload := BuildPayloadFromOccurrence(notif, "Diana", baseURL, ctx) + + if payload.Title != "@Diana reacted to your message in #general" { + t.Errorf("unexpected reaction title: %s", payload.Title) } + if payload.Body != ":thumbsup: · The message that was reacted to" { + t.Errorf("unexpected reaction body: %s", payload.Body) + } + if payload.Tag != "reaction-message-event" { + t.Errorf("unexpected reaction tag: %s", payload.Tag) + } + if payload.URL != "https://chatto.example.com/chat/-/room-y?highlight=message-event" { + t.Errorf("unexpected reaction URL: %s", payload.URL) + } + }) + + t.Run("builds room message payload with room name and preview", func(t *testing.T) { + notif := notificationOccurrenceForTest("notif-room-message", "", "", "room-news", "room-event", "", corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM) ctx := &PayloadContext{MessagePreview: "A watched room has a new message", RoomName: "news"} - payload := BuildPayloadFromNotification(notif, "Eve", baseURL, ctx) + payload := BuildPayloadFromOccurrence(notif, "Eve", baseURL, ctx) if payload.Title != "@Eve posted in #news" { t.Errorf("Expected '@Eve posted in #news', got %s", payload.Title) @@ -558,17 +472,9 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("escapes notification URL path segments and highlight query", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-escaped", - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{ - RoomId: "room with spaces", - EventId: "event+plus", - }, - }, - } + notif := notificationOccurrenceForTest("notif-escaped", "", "", "room with spaces", "event+plus", "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) - payload := BuildPayloadFromNotification(notif, "Bob", baseURL, nil) + payload := BuildPayloadFromOccurrence(notif, "Bob", baseURL, nil) expectedURL := "https://chatto.example.com/chat/-/room%20with%20spaces?highlight=event%2Bplus" if payload.URL != expectedURL { @@ -577,12 +483,9 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("builds default payload for unknown type", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-unknown", - // No notification type set - } + notif := notificationOccurrenceForTest("notif-unknown", "", "", "", "", "") - payload := BuildPayloadFromNotification(notif, "Unknown", baseURL, nil) + payload := BuildPayloadFromOccurrence(notif, "Unknown", baseURL, nil) if payload.Title != "New notification" { t.Errorf("Expected 'New notification', got %s", payload.Title) @@ -593,14 +496,9 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("sets icon and badge URLs", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-icons", - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "room"}, - }, - } + notif := notificationOccurrenceForTest("notif-icons", "", "", "room", "", "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE) - payload := BuildPayloadFromNotification(notif, "Test", baseURL, nil) + payload := BuildPayloadFromOccurrence(notif, "Test", baseURL, nil) expectedIcon := "https://chatto.example.com/icons/icon-192.png" if payload.Icon != expectedIcon { @@ -612,17 +510,12 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) t.Run("truncates long message preview", func(t *testing.T) { - notif := &corev1.Notification{ - Id: "notif-long", - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "room"}, - }, - } + notif := notificationOccurrenceForTest("notif-long", "", "", "room", "", "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE) // Create a preview longer than maxPreviewLength longPreview := "This is a very long message that exceeds the maximum preview length and should be truncated with an ellipsis at the end to fit within the allowed characters" ctx := &PayloadContext{MessagePreview: longPreview} - payload := BuildPayloadFromNotification(notif, "Test", baseURL, ctx) + payload := BuildPayloadFromOccurrence(notif, "Test", baseURL, ctx) // Body should be truncated (just the preview, no prefix) if len(payload.Body) > maxPreviewLength+3 { // +3 for ellipsis @@ -634,58 +527,50 @@ func TestBuildPayloadFromNotification(t *testing.T) { }) } -func TestNotificationTag(t *testing.T) { +func TestOccurrenceTag(t *testing.T) { t.Run("returns DM tag with event ID", func(t *testing.T) { - notif := &corev1.Notification{ - Notification: &corev1.Notification_DmMessage{ - DmMessage: &corev1.DMMessageNotification{RoomId: "room-123", EventId: "event-abc"}, - }, - } - tag := NotificationTag(notif) + notif := notificationOccurrenceForTest("", "", "", "room-123", "event-abc", "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE) + tag := OccurrenceTag(notif) if tag != "dm-event-abc" { t.Errorf("Expected 'dm-event-abc', got %s", tag) } }) t.Run("returns mention tag with event ID", func(t *testing.T) { - notif := &corev1.Notification{ - Notification: &corev1.Notification_Mention{ - Mention: &corev1.MentionNotification{RoomId: "room-456", EventId: "event-def"}, - }, - } - tag := NotificationTag(notif) + notif := notificationOccurrenceForTest("", "", "", "room-456", "event-def", "", corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) + tag := OccurrenceTag(notif) if tag != "mention-event-def" { t.Errorf("Expected 'mention-event-def', got %s", tag) } }) t.Run("returns reply tag with event ID", func(t *testing.T) { - notif := &corev1.Notification{ - Notification: &corev1.Notification_Reply{ - Reply: &corev1.ReplyNotification{RoomId: "room-789", EventId: "event-ghi"}, - }, - } - tag := NotificationTag(notif) + notif := notificationOccurrenceForTest("", "", "", "room-789", "event-ghi", "", corev1.NotificationReason_NOTIFICATION_REASON_REPLY) + tag := OccurrenceTag(notif) if tag != "reply-event-ghi" { t.Errorf("Expected 'reply-event-ghi', got %s", tag) } }) - t.Run("returns room message tag with event ID", func(t *testing.T) { - notif := &corev1.Notification{ - Notification: &corev1.Notification_RoomMessage{ - RoomMessage: &corev1.RoomMessageNotification{RoomId: "room-101", EventId: "event-room"}, - }, + t.Run("returns reaction tag with message event ID", func(t *testing.T) { + notif := notificationOccurrenceForTest("", "", "", "room-789", "event-reacted", "", corev1.NotificationReason_NOTIFICATION_REASON_REACTION) + tag := OccurrenceTag(notif) + if tag != "reaction-event-reacted" { + t.Errorf("Expected 'reaction-event-reacted', got %s", tag) } - tag := NotificationTag(notif) + }) + + t.Run("returns room message tag with event ID", func(t *testing.T) { + notif := notificationOccurrenceForTest("", "", "", "room-101", "event-room", "", corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM) + tag := OccurrenceTag(notif) if tag != "room-message-event-room" { t.Errorf("Expected 'room-message-event-room', got %s", tag) } }) t.Run("returns empty for unknown type", func(t *testing.T) { - notif := &corev1.Notification{} - tag := NotificationTag(notif) + notif := notificationOccurrenceForTest("", "", "", "", "", "") + tag := OccurrenceTag(notif) if tag != "" { t.Errorf("Expected empty string, got %s", tag) } @@ -818,28 +703,6 @@ func TestSend(t *testing.T) { } }) - t.Run("uses normal urgency for silent dismiss pushes", func(t *testing.T) { - var urgency string - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - urgency = r.Header.Get("Urgency") - w.WriteHeader(http.StatusCreated) - })) - defer server.Close() - - sender := newTestSender(t, server.Client()) - result := sender.Send(context.Background(), newTestPushSubscription(t, server.URL), &Payload{ - Action: "dismiss", - Tag: "notification-1", - }) - - if result.Error != nil { - t.Fatalf("Send error: %v", result.Error) - } - if urgency != "normal" { - t.Fatalf("Urgency = %q, want normal", urgency) - } - }) - t.Run("includes provider response body for non-gone failures", func(t *testing.T) { tests := []struct { name string diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index eaf82e7f6..ef98bfab0 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -70,6 +70,12 @@ User-facing concepts. If a user might say the word, it goes here. **Mention** — `@handle` syntax in a message that notifies referenced users, pingable roles, or virtual room groups such as `@all` and `@here`. See [FDR-006](fdr/FDR-006-mentions.md). +**Notification** — Persistent user-scoped attention created for activity such as a DM, reply, mention, followed conversation, or reaction. Notifications remain visible after being read and can be deleted independently of room read state. See [FDR-012](fdr/FDR-012-notifications.md). + +**Notification Group** — List row that combines related notification occurrences by conversation or target while retaining their exact underlying activity. See [ADR-072](adr/ADR-072-persistent-notification-list.md). + +**Delivery Intensity** — Per-cause notification preference with one of three effective values: Off, Badge, or Alert. See [FDR-012](fdr/FDR-012-notifications.md). + **Attachment** — File (image, document, video) uploaded alongside a message. See [FDR-008](fdr/FDR-008-file-attachments-and-video.md). **Link Preview** — Auto-generated preview card for URLs in messages. See [FDR-009](fdr/FDR-009-link-previews.md). @@ -140,6 +146,8 @@ Infrastructure jargon. If only contributors say the word, it goes here. **Projection** — Derived read model rebuilt from `EVT` and owned independently by each consuming process. Persistence is optional: a projection may cold-replay every time, use an encrypted snapshot, or checkpoint a disposable local index and EVT cutoff for tail replay. `EVT` remains the source of truth. See [ADR-033](adr/ADR-033-event-sourced-state-with-projections.md) and [ADR-054](adr/ADR-054-optional-projection-persistence.md). +**Notification Occurrence** — Recipient-specific runtime record derived from one canonical source event, retaining every matched notification reason and its exact destination. See [ADR-071](adr/ADR-071-deterministic-notification-occurrences.md). + **Auth generation** — Per-user authentication epoch derived from durable user events. Cookie sessions, bearer tokens, and OAuth authorization codes are valid only when their stored generation matches the user's current generation. See [FDR-023](fdr/FDR-023-authentication-and-sessions.md). **External identity** — Provider-issued account identity linked to a user, keyed by verified issuer/provider namespace plus provider subject rather than email. See [FDR-023](fdr/FDR-023-authentication-and-sessions.md). diff --git a/docs/adr/ADR-036-runtime-state-kv-boundary.md b/docs/adr/ADR-036-runtime-state-kv-boundary.md index 3e958abb1..0c66ef9cb 100644 --- a/docs/adr/ADR-036-runtime-state-kv-boundary.md +++ b/docs/adr/ADR-036-runtime-state-kv-boundary.md @@ -59,8 +59,10 @@ Current occupants include: - Room read cursors: `read.room.{userId}.{roomId}`. - Thread read cursors: `read.thread.{userId}.{roomId}.{threadRootEventId}`. -- Pending notifications: `notification.{userId}.{notificationId}`, with per-key - 90-day TTL. +- Notification occurrences and anti-recreation tombstones: + `notification_v2.{userId}.{sourceEventId}`, with an absolute per-key 90-day + TTL, plus bounded `notification_work.*` records used by the durable + materializer. - Web Push subscriptions: `push_subscription.{userId}.{endpointHash}`. - Runtime credential verifiers: `session.{hmac}`, with per-key `auth.token_ttl` sliding-window expiry. Values include credential kind @@ -112,9 +114,9 @@ The current video processor does not write new runtime progress or claim state; legacy `SERVER_RUNTIME video.*` records are historical pre-0.1 state and are not written by current code. -Mention flags are not a target runtime-state model. Orange-dot behavior derives -from pending notifications instead of preserving `room_mention_status.*` as -canonical state. +Mention flags are not a target runtime-state model. Attention indicators derive +from unread notification groups instead of preserving `room_mention_status.*` +as canonical state. ## Consequences diff --git a/docs/adr/ADR-038-room-owned-thread-state.md b/docs/adr/ADR-038-room-owned-thread-state.md index 291792898..b088658c5 100644 --- a/docs/adr/ADR-038-room-owned-thread-state.md +++ b/docs/adr/ADR-038-room-owned-thread-state.md @@ -86,7 +86,7 @@ Thread projections are responsible for deriving richer thread state from the room event history: root message, replies, participants, reply count, latest activity, labels, closed/open state, and future forum-mode ordering metadata. -Per-user thread read cursors and pending notifications stay in `RUNTIME_STATE`. +Per-user thread read cursors and notification occurrences stay in `RUNTIME_STATE`. Follow choices use room-owned `ThreadFollowedEvent` and `ThreadUnfollowedEvent` facts so replay reconstructs current follow state together with thread history. diff --git a/docs/adr/ADR-040-permission-only-rbac-with-owner-override.md b/docs/adr/ADR-040-permission-only-rbac-with-owner-override.md index 8f73442e6..e09c815d2 100644 --- a/docs/adr/ADR-040-permission-only-rbac-with-owner-override.md +++ b/docs/adr/ADR-040-permission-only-rbac-with-owner-override.md @@ -2,6 +2,10 @@ **Date:** 2026-06-15 +> **Amended 2026-08-11:** Configured owner emails now converge on the durable +> `owner` role instead of acting as a separate permission-time fallback. This +> keeps live authorization and event-time visibility on one representation. +> > **Partially superseded by [ADR-052](ADR-052-subject-specific-rbac-with-everyone-baseline.md).** > The effective-owner override, permission-only gates, and non-ranking role > positions remain active. ADR-052 replaces the literal all-subject, @@ -29,9 +33,11 @@ The main pressure points were: Use a permission-only RBAC model for everyone except effective owners. -- Effective owners are users with the durable `owner` role or a verified email - matching `owners.emails` in Chatto configuration. Owners are always granted - all permissions regardless of stored allow/deny state. +- Effective owners are users with the durable `owner` role. A verified email + matching `owners.emails` is materialized into that role at boot and through a + retryable durable worker after verification; verification waits for the + materialization before reporting success. Owners are always granted all + permissions regardless of stored allow/deny state. - Every other role, including `admin`, confers only its explicit permission decisions. Runtime code does not attach additional authority to role names. - For non-owners, permission resolution is deny-wins: any applicable user or @@ -68,9 +74,10 @@ This supersedes ADR-005. `message.post`. Because deny-wins is literal, that deny blocks every non-owner in the room. - Deny-wins enables future broad restriction roles such as a suspended role. -- Operators cannot lock out effective owners through RBAC state, but owner - access now depends on protecting `owners.emails` configuration and verified - email ownership. +- Operators cannot revoke the durable owner role while its verified email + remains in `owners.emails`. Existing matching users are repaired at boot and + new verifications are repaired by durable redelivery, so protecting the + configuration and verified-email ownership remains security-critical. - Existing role position fields and protobuf event fields remain for compatibility. Removing or reserving them can be considered separately if the persisted event contract is migrated. diff --git a/docs/adr/ADR-043-client-shell-internationalization.md b/docs/adr/ADR-043-client-shell-internationalization.md index 8a567c021..7e92f5c20 100644 --- a/docs/adr/ADR-043-client-shell-internationalization.md +++ b/docs/adr/ADR-043-client-shell-internationalization.md @@ -36,7 +36,7 @@ Locale payloads should be split into separate bundles and loaded lazily. Chatto Chatto will prefer static, typed message keys over runtime string lookups: - Component chrome, settings labels, dialogs, validation messages, empty states, toast text, and system-event labels use generated message functions. -- Stable enums, permission names, event types, and notification levels are mapped explicitly to message functions at the UI boundary. +- Stable enums, permission names, event types, and notification policy reasons/intensities are mapped explicitly to message functions at the UI boundary. - User-generated content, server names, room names, display names, message bodies, uploaded filenames, and other user-authored values are displayed as authored and are not translated. - Backend APIs should return structured data, stable codes, enum values, and parameters rather than pre-rendered localized product copy. - Persisted EVT events and projections must store language-neutral facts, not localized labels or sentences. diff --git a/docs/adr/ADR-051-server-scoped-resumable-client-projection.md b/docs/adr/ADR-051-server-scoped-resumable-client-projection.md index 924d49ffa..1976e6839 100644 --- a/docs/adr/ADR-051-server-scoped-resumable-client-projection.md +++ b/docs/adr/ADR-051-server-scoped-resumable-client-projection.md @@ -158,10 +158,11 @@ message tombstone. Canonical reply deletion marks the corresponding echo upsert as a retained deleted row so it remains a tombstone rather than taking the direct-echo deletion path. -Notification records and room/thread read markers include latest-value state +Notification occurrences and room/thread read markers include latest-value state outside EVT. Every subscription therefore re-emits the viewer resource, every visible room's viewer state, the complete followed-thread viewer-state set, -pending notification page/counts, and directory presence before `caught_up`. +the finite notification Inbox groups with complete unread and per-room group +counts, and directory presence before `caught_up`. Missing followed-thread entries clear retained follow/unread flags. Transient signals buffered during the handoff converge concurrent changes. Thread follow and read-marker mutations share a user-scoped viewer-state invalidation, which diff --git a/docs/adr/ADR-071-deterministic-notification-occurrences.md b/docs/adr/ADR-071-deterministic-notification-occurrences.md new file mode 100644 index 000000000..09fdb8aeb --- /dev/null +++ b/docs/adr/ADR-071-deterministic-notification-occurrences.md @@ -0,0 +1,336 @@ +# ADR-071: Derive Deterministic Notification Occurrences into Runtime State + +**Date:** 2026-08-10 + +## Context + +Chatto currently creates recipient-specific notification records directly from +message-posting request paths. The records use random IDs, several independent +fanout paths can match the same recipient, and creation is not recovered after +a crash. Read-cursor advancement deletes whatever records happen to exist at +that moment. A delayed creator can therefore add a notification after the user +has already read the message, producing a phantom badge. + +The records also mix several concerns. A notification's type stands in for why +it was created, its existence stands in for unread state, deletion stands in +for dismissal, and Web Push is launched from the same best-effort request +callback. This makes it difficult to add independent preferences for direct +mentions, role mentions, `@here`, `@all`, replies, followed conversations, and +reactions without introducing more fanout paths and more races. + +The source activity and notification preferences are durable domain facts, but +a recipient's notification list is bounded, mutable, user-runtime state. We +need a design that preserves that boundary while making derivation recoverable, +idempotent, and safe across replicas. + +## Decision + +### Authority boundaries + +Notification source activity and user notification preferences remain durable +facts in `EVT`. A recipient-specific **notification occurrence** is a bounded +latest-value record in `RUNTIME_STATE`; it is not appended to `EVT` and is not +a second copy of the source content. + +Per-cause policy changes use one `UserNotificationPreferenceChangedEvent` for +both server and room scope and for setting or clearing an override. The legacy +notification-level event variants remain unchanged only as replay-decodable +history: their tags and payloads are already persisted, but current projections +ignore them. New writes use only the per-cause event. Removing or reusing those +persisted oneof fields would corrupt EVT decoding. + +Each occurrence has one deterministic identity derived from the recipient ID +and the canonical source event ID. One source event can therefore create at +most one occurrence for a recipient, even when several notification reasons +match or several replicas attempt the work. Creation uses KV `Create`; later +state transitions use revision-based `Update` with conflict retries. + +The new key family is versioned separately from the legacy random-ID records. +Its concrete prefix is an implementation detail, but it must preserve efficient +recipient-scoped watching and must not place user-controlled text in keys. + +### Recoverable derivation from runtime work + +Each source-command OCC attempt evaluates notification policy against the +authoritative projections behind that attempt. It captures the current config +subject tail and waits for the local Config projection through that boundary +before preparing the exact recipient/reason/intensity occurrences in +`RUNTIME_STATE` and appending the existing message or reaction fact. A policy +write that completed before the attempt is therefore observed; a policy write +overlapping the source command may order on either side, and every OCC retry +captures the boundary again. Message attempts also recompute mention expansion +and the persisted `mentioned_user_ids`, so a retry ordered after a membership +change cannot commit the earlier recipient set. These +short-lived work keys use the triggering event ID and recipient ID; their value +is the prepared `NotificationOccurrence`, not a second domain-event schema. A +companion marker at the triggering event ID lets consumers reject events with +no prepared recipients using one direct KV lookup rather than a filtered scan. +Message and reaction payloads remain unchanged, and notification +materialization does not introduce an `EVT` aggregate or event type. + +Notification evaluation is part of committing the source command. If +recipient discovery, policy evaluation, or runtime-work preparation cannot +complete, the command fails before appending the source fact; it must not +commit an ambiguous "nobody" decision. A failed source append can leave an +untriggered work key, but its absolute source-time-plus-90-days TTL bounds that +orphan. Once the source fact commits, occurrence materialization and delivery +are recoverable effects and cannot roll back the source action. + +An OCC command retry replaces the complete prepared recipient set for its +trigger. It deletes recipients retained from an earlier failed attempt and +removes the marker when the new authoritative decision has no work. Prepared +work is therefore a latest exact decision, not an append-only union of attempts. + +All replicas share one durable JetStream pull consumer with one globally +in-flight delivery over the existing +`MessagePosted`, `ReactionAdded`, `ReactionRemoved`, retraction, membership, +room visibility, room-group placement, relevant RBAC, room-deletion, and +account-deletion facts. Verified-email facts are also included so configured +owner identities converge on the durable RBAC state used by notification +visibility. A delivery waits for the projections needed by that +fact, checks the marker, loads recipient work by the triggering event ID, +applies it idempotently, deletes completed work and its marker, and acknowledges +only after the effect succeeds. The consumer begins at its initial +creation boundary because older facts cannot have Notifications 2.0 work; +server boot readiness waits for that consumer to exist before commands may be +served. The worker also skips facts beyond the 90-day retention boundary +without touching KV. +Crashes, shutdown, and transient failures therefore cause redelivery through +the shared `events.DurableWorker` framework without replaying unrelated message +history at rollout. The single consumer lane preserves source-before-lifecycle +order across replicas. + +The durable consumer is the sole owner of occurrence creation and lifecycle +cleanup; request handlers do not run an overlapping prompt writer. A committing +request may wait for the consumer's acknowledgement when read-your-writes +matters, but all effects still pass through the shared causal lane. Delayed +creation checks current account existence, room membership, message retraction, +exact reaction-add state, and the recipient's latest room-visibility-loss +sequence before writing. A leave or removal records that 90-day runtime +boundary immediately after commit, and the durable worker repeats the write +during ordered recovery. + +Effective membership can also disappear without an explicit leave: a universal +room can be disabled, moved across group permission scopes, or made inaccessible +by a `room.join` RBAC or role change. The same ordered worker consumes those +existing domain facts after a dedicated Notification Visibility projection +captures the minimal room, membership, room-group, and RBAC state at the fact's +exact EVT sequence. The worker scans authoritative occurrences and tombstones +only recipient/room pairs that lacked effective membership at that boundary. A +projection that already observed a later regain therefore cannot erase an +intermediate visibility loss, and activity sourced after the regain is outside +the earlier cleanup boundary. Snapshot restore is capped at the shared worker's +acknowledged floor, so pending boundaries are replayed rather than skipped by a +newer projection snapshot. Pending facts share one full visibility checkpoint +plus a compact event-delta journal and an incrementally evaluated cursor; the +projection does not copy full membership/RBAC state for every boundary or +replay lifetime EVT history on the single notification lane. Boundary data is +released only after the shared consumer's acknowledged floor confirms the +delivery, so a failed acknowledgement can redeliver safely on the same replica. +Snapshot publication uses that full floor too: a capture beyond it is deferred +even when the pending notification-worker delivery is not itself a visibility +boundary. On restart, the safe full-EVT prefix is reconstructed immediately +before the earliest worker-filtered fact after the consumer's sparse AckFloor. +The repository therefore retains a generation that restart can accept and +needs to replay only its tail without another persisted watermark. + +Configured `owners.emails` identities are materialized as durable owner-role +assignments at boot and through the same retryable durable lane after email +verification. Verification waits for that source fact to complete when the +email is configured; live authorization recognizes only the durable role. +While a verified email remains configured, that role cannot be revoked. The +event-time RBAC projection and live owner authorization therefore cannot +disagree about room visibility after a transient assignment failure. + +Prepared work contains enough immutable provenance to reproduce the recipient +and reason decision without later policy evaluation. In particular, message +work distinguishes direct-user, role, `@here`, and `@all` matches instead of +relying on the message event's combined mentioned-user list. Eligibility that +depends on transient state, such as who counted as present for `@here`, is +resolved before source commit and retained in the prepared occurrence. +Reaction occurrences retain the exact emoji as internal provenance, allowing +an existing `ReactionRemoved` fact from an older replica to remove precisely +the corresponding v2 occurrence even though that writer prepared no v2 work. + +The evaluator gathers every matching reason once, evaluates each reason's +effective delivery intensity, stores the complete matched-reason set, and +selects the strongest intensity. `Off` creates no visible occurrence; `Badge` +and `Alert` create the same durable occurrence, while only `Alert` is eligible +for interruptive delivery. + +### Occurrence contents + +An occurrence retains the stable facts needed to explain, reconcile, and open +it: + +- recipient, canonical source event ID, actor ID, source time, and an internal + EVT stream sequence used for causal cleanup and read-boundary reconciliation; +- exact destination: room, optional thread root, and target event; +- all matched reasons and their evaluated intensities; +- strongest effective intensity and policy-evaluation time; +- attention state, alert-delivery state, lifecycle timestamps, and absolute expiry + time. + +It does not copy message bodies, room names, avatars, display names, or other +presentation data. Public assemblers hydrate current visible resources from +their authoritative projections. If the target is retracted or the recipient +loses visibility, the occurrence cannot preserve stale copied content. + +### Attention-state and lifecycle convergence + +Notification attention state is distinct from room and thread read cursors. A read action also +writes a bounded notification-read record containing the target timeline EVT +sequence and the reaction projection's applied EVT horizon. Occurrence creation +reads that boundary directly from KV after its initial write, while the read +action scans the recipient's authoritative occurrence keys after writing the +boundary. This two-sided handshake converges across replicas without depending +on either process's watcher timing. Coverage uses stream order, not protobuf +wall-clock timestamps. Ordinary activity is covered through the target +sequence; a reaction is covered only when both its reacted-to message and its +source sequence were visible to the later read. A reaction arriving after a +read therefore remains new until another read action. + +New occurrence rows are initially persisted in an unfinalized, non-claimable +state. Finalization applies the read boundary and only then makes an unread +Alert claimable. A durable redelivery finalizes an interrupted row, but never +reconciles an already-finalized row or turns a Read occurrence back to Unread. + +User read/delete mutations, read reconciliation, retraction, reaction removal, and +visibility changes all use KV OCC. Causal cleanup and read reconciliation scan +authoritative KV state rather than a potentially lagging replica-local index. +Retraction, lost visibility, explicit +deletion, and other conditions that must prevent rediscovery replace the +visible record with a minimal tombstone. The tombstone keeps recipient, source +identity, removal reason, and expiry only, so replay cannot recreate the +notification and inaccessible presentation references are removed. Account +deletion repeatedly purges the recipient's records through OCC races until no +keys remain, and replay skips work recipients whose account no longer exists. +A room-leave, member-removal, or implicit-visibility-loss fact removes only +occurrences whose source EVT sequence precedes that lifecycle fact. +Materialization requires the committed source sequence and rejects work at or +before the latest persisted visibility boundary, even if the recipient has +since rejoined. Replaying an old visibility loss cannot delete activity created +after a later rejoin, regardless of replica clock skew. + +Notification policy changes affect future source activity. They do not rewrite +or erase existing notification history; users delete retained items explicitly. + +### Absolute retention + +Every occurrence and tombstone has an absolute expiry 90 days after its source +activity. Every KV mutation applies only the remaining lifetime. Marking an +item read or rewriting it as a tombstone never restarts the 90-day clock. + +### Authoritative reads and delivery + +Each Chatto process owns one filtered `RUNTIME_STATE` watcher and an in-memory +notification index. The watcher's initial latest-value delivery is a startup +readiness barrier. KV remains authoritative; successful writes wait for their +revision to reach the local index when read-your-writes matters. Public list, +count, pending-Alert claims, and realtime replacement assembly use dedicated +index views instead of scanning all retained records or a KV prefix per request, +connection, or delivery poll. Index reads also prune records whose +absolute expiry has passed, so a delayed or missing KV expiry notification +cannot leave an occurrence visible in a long-running process. + +Realtime messages remain convergence accelerators. A transition signal carries +the source identity and written KV revision internally; the serving replica +waits until its local notification index has observed that revision before it +assembles the authoritative replacement. Initial connection, reconnect, and +authorization changes publish the same finite replacement. Missing a transient +signal cannot permanently corrupt counts, and a cursor cannot advance with a +replacement assembled from stale local notification state. + +Sound, Web Push, native notifications, and installed-app badges are downstream +presentations of a committed occurrence. Interruptive delivery begins only +after the occurrence exists and is still eligible. A revision-claimed delivery +state lets another worker recover an abandoned attempt without sending from two +replicas concurrently. Current transient conditions such as Do Not Disturb may +silence delivery without suppressing the occurrence. Effect delivery is +retryable and at least once; provider-level deduplication is used where +available, but a crash after provider acceptance may produce a duplicate alert. +Marking an occurrence Read silences any pending or claimed Alert. +Workers verify the exact claim immediately before delivery and revalidate the +current account, membership, unretracted target message, exact reaction, and +subscription ownership before sending. Before account, room, message, or +reaction absence is treated as authoritative, the serving replica captures the +current recipient, server-wide room-event, room-group-layout, and RBAC tails +and waits its relevant projections through those boundaries. +List and mutation APIs use the same causally fenced validation, so projection +lag cannot tombstone a valid occurrence or expose a removed target. Before a +mutation reads one occurrence or captures a derived group's members, it waits +the durable notification consumer through a fresh worker boundary and then +fences the process-local occurrence index through the resulting KV writes. +Before list or realtime responses derive +exhaustive totals and notification summaries, they capture the latest sequence for +every notification-worker EVT filter and wait for the sole durable writer to +acknowledge that boundary. The read then appends a `RUNTIME_STATE` fence marker +and waits its process-local occurrence watcher through that marker's KV +revision. Because the marker follows the acknowledged worker's occurrence +mutations in the same KV stream, a retrying lifecycle cleanup or lagging replica +index fails or delays the read instead of leaking stale counts. List validation +scans only the prefix needed to fill the requested offset page, validating each +bounded page-sized overfetch chunk once when stale groups are removed. The +worker renews the claim, then makes a final Do Not Disturb +check immediately before the provider call; newly active DND silences that +exact claim. Subscription storage and ownership read failures fail the attempt +instead of masquerading as an empty device set. Failed delivery remains +claimed until a bounded retry delay, avoiding a hot loop. +Before claiming a locally pending Alert, delivery waits the durable consumer +through a fresh worker-event boundary. An intermediate visibility loss is +therefore applied before an old occurrence can be claimed, even when current +authorization already reflects a later regain. +Delivery completes once any current device accepts the push; it retries only +when no device accepted and at least one current endpoint failed transiently. +This occurrence-level success rule avoids repeatedly alerting successful +devices because another endpoint is persistently broken. +A crash after provider acceptance but before claim completion can still cause +a duplicate alert, consistent with the at-least-once contract. + +### Compatibility and rollout + +Notifications 2.0 uses a new persisted occurrence protobuf rather than +changing the immutable `chatto.core.v1.Notification` storage message. Existing +legacy notification records are neither migrated nor read. The cutover starts +with an empty 2.0 list; old rows remain inert until their retention removes +them. This is an intentional pre-1.0 product reset, not a dual-store period. + +The public `chatto.api.v1` notification and coarse-preference RPCs are removed +at the same release boundary and replaced by the grouped list, occurrence, +group, and per-cause policy operations. The bundled client contains no fallback +to the old API or preference levels. Older clients are therefore incompatible +with an upgraded server for notifications, and the 0.5 client requires a 0.5 +server through the normal feature-version gate. This intentional breaking +change avoids maintaining a second API, compatibility projection, or preset +translation layer. + +Every upgraded replica writes and reads only the 2.0 key and work families. A rolling +deployment can therefore briefly contain an older replica that still writes +legacy records and a newer replica that writes 2.0 records, but neither family +is translated into the other. Once all replicas are upgraded, only 2.0 records +are produced. Rolling back restores the legacy implementation and its old +notification view; 2.0 occurrences and temporary work remain isolated and are not +interpreted by that binary. No notification-only `EVT` variants are added, and +message and reaction protobufs retain their existing wire shape. + +## Consequences + +- Source commands fail before commit when notification policy cannot be + evaluated or their temporary work cannot be prepared. Failed source appends + can leave bounded work orphans; after commit, durable delivery makes + notification processing recoverable without adding domain events. +- Recipient/source identity, KV OCC, and tombstones make retries and + multi-replica races idempotent. +- Notification creation and read advancement converge in either order, closing + the late-notification race. +- Exact targets and durable reason provenance support richer policy and reliable + navigation without copying mutable or private presentation data. +- A process-wide index makes list, count, and realtime replacement assembly + proportional to the user's result set rather than repeated KV scans. +- The notification materializer needs explicit consumer-lag, retry, and health + observability. Work older than the 90-day occurrence lifetime is deliberately + allowed to expire rather than create an already-expired notification item. +- Interruptive effects are recoverable but not exactly once; rare duplicate + provider delivery remains possible. +- The clean cutover deliberately discards legacy pending-notification history + from the new list and avoids a dual-read migration path. diff --git a/docs/adr/ADR-072-persistent-notification-list.md b/docs/adr/ADR-072-persistent-notification-list.md new file mode 100644 index 000000000..268b893e1 --- /dev/null +++ b/docs/adr/ADR-072-persistent-notification-list.md @@ -0,0 +1,154 @@ +# ADR-072: Present Notifications as One Persistent List with Derived Groups + +**Date:** 2026-08-10 + +## Context + +The legacy notification model equates a notification record with pending +unread attention. Reading covered activity or dismissing the notification +deletes the record, so users cannot keep a read notification for later. + +GitHub notifications provide useful inspiration for durable attention and +grouped activity, but their separate Inbox and Done lifecycle is more state +than Chatto needs. A notification should be new or already read. If a user no +longer wants it in the list, they can delete it without opening the source. +Chatto also needs to group related occurrences without allowing mutable group +records to become another source of truth. + +## Decision + +### Attention state and deletion + +Each visible notification occurrence has one attention state: + +- **Unread** — counted as new attention and highlighted with Chatto's semantic + notification orange; or +- **Read** — retained in the same chronological list without unread emphasis. + +There is no Done state, no separate notification view, and no operation that +marks a notification unread. Opening an unread notification marks it read only +after its target is displayed successfully. Reading a room or thread also +marks covered occurrences read. + +Delete is independent of reading. It removes an occurrence from the visible +list and leaves only the minimal anti-recreation tombstone until the original +expiry. The bundled client exposes group deletion as its sole row action, so a +user can discard a notification without opening or otherwise handling its +source activity. The public API also retains single-occurrence deletion for +precise integration and privacy workflows. + +The persisted core enum retains the numeric legacy-development `DONE` value +because `RUNTIME_STATE` protobufs are storage contracts. Current code never +writes that state; any retained value decodes as visible read history. + +### User actions + +The public behavior follows these rules: + +- Opening a notification navigates to its exact room, thread, and event. The + occurrence is marked read after that target is displayed. +- `MarkNotificationRead` is an idempotent one-way transition. There is no + general state-update RPC and no Mark Unread operation. +- Reading a room or thread marks covered notification occurrences read. +- `DeleteNotificationOccurrence` permanently dismisses one occurrence. +- `DeleteNotificationGroup` dismisses the occurrences that belong to the + derived group at the server's authoritative mutation boundary. +- `DeleteAllNotificationOccurrences` dismisses every occurrence current at the + server's authoritative mutation boundary. Later activity remains new. + +Read and delete mutations are server-owned and synchronize across sessions. +A later source event can create a new occurrence with the same derived group +ID, so clients must not automatically retry an ambiguous group or whole-list +deletion. + +Muting a notification's source may be added as a separate policy action in the +future. It is not part of this lifecycle or the initial bundled UI. + +### Groups are derived presentation resources + +A **notification group** is a read model over occurrences, not an authoritative +mutable record. Its stable grouping target is derived from the exact activity +destination: + +- a DM conversation groups by room; +- thread activity groups by room and thread root; +- reactions group by the reacted-to message; +- ambient channel activity groups by room; and +- an ungroupable future cause falls back to its source occurrence. + +A group exposes its current member occurrences, matched reasons, newest +activity, unread state, strongest intensity, and deterministic open target. It +opens the newest unread visible occurrence, or the newest visible occurrence +when every member is read. + +Group list responses contain a bounded newest-member preview, always including +the open occurrence, plus total count and aggregate state. Bounding the preview +keeps ConnectRPC pages and realtime replacement frames finite even when a busy +room, DM, or thread has thousands of retained occurrences. Clients render the +first page immediately and automatically append later pages. + +All unread and read groups are assembled into one chronological server view. +The bundled client separates that view into Today, Yesterday, This Week, and +month sections using each server account's preferred time zone. Rows describe +the activity in a full sentence rather than presenting a terse actor/reason +tuple. + +The public API therefore needs only one paginated list request. The bundled +multi-server client makes one request per authenticated server, preserves +fulfilled results when another server fails, and reports the failure as +partial. + +Single-group and whole-list dismissal update the bundled client optimistically. +Whole-list dismissal uses one authoritative request per authenticated server; +it never loops over only the groups currently loaded by pagination. A failed +server restores its rows without restoring successful servers' rows. + +The bell, server indicator, room indicators, and installed-app badge derive +from the same unread occurrences and grouping rules. The bell and server +indicator are active when at least one unread group exists; the notification +list uses the semantic `attention` orange for the same state. + +### Read state and policy remain separate + +Room and thread read cursors describe content consumption. Notification policy +decides whether new activity creates an occurrence and whether it is eligible +to interrupt. Notification attention state describes whether the resulting +occurrence is new. None of these concepts substitutes for another. + +Consequently, disabling a cause does not mark content read, marking a +notification read does not necessarily advance the room cursor, and changing a +room's policy does not erase existing notification history. The legacy +`MUTED` behavior that also hid ordinary room unread state is not carried into +Notifications 2.0. + +### Reconciliation and visibility + +Notification groups and counts are finite authoritative state in the +server-scoped client projection. Reconnect and reset replace them from the +current notification index. Realtime operations accelerate convergence but do +not define correctness. Responses and realtime replacements expose the next +expiry boundary, while each group exposes its earliest expiry, so continuously +connected clients refresh even when KV TTL removal produces no live watcher +transition. + +If a target is retracted, a reaction is removed, or authorization no longer +allows the recipient to open the target, the server removes the affected +occurrence. Assemblers never use a retained notification as authority to reveal +an otherwise inaccessible room, actor, or message. + +## Consequences + +- Read notifications remain available until the user deletes them or they + expire. +- Users can dismiss notifications without pretending they opened or handled + the source content. +- The one-way Unread-to-Read transition avoids a third triage state and removes + general occurrence and group-update API surface. +- Derived groups reduce noise without introducing mutable group records that + can drift from their member occurrences. +- Group deletion requires an authoritative boundary so concurrent later + occurrences are not accidentally included. +- Whole-list deletion has the same boundary semantics and is not safe for an + automatic retry after an ambiguous transport failure. +- Read cursors, delivery policy, and notification attention can evolve without + overloading one another's semantics. diff --git a/docs/adr/INDEX.md b/docs/adr/INDEX.md index 561390563..1dd63ad1e 100644 --- a/docs/adr/INDEX.md +++ b/docs/adr/INDEX.md @@ -82,3 +82,5 @@ replace part of their original design. | [ADR-068](ADR-068-selectable-event-mutation-consistency-boundaries.md) | Select Event Mutation Consistency Boundaries Explicitly | Accepted | 2026-08-10 | | [ADR-069](ADR-069-explicit-durable-consumer-lifecycle.md) | Manage Durable Consumer Lifecycles Explicitly | Accepted | 2026-08-11 | | [ADR-070](ADR-070-deterministic-invite-link-capabilities.md) | Derive Invite-Link Capabilities from Durable EVT Identity | Accepted | 2026-08-11 | +| [ADR-071](ADR-071-deterministic-notification-occurrences.md) | Derive Deterministic Notification Occurrences into Runtime State | Accepted | 2026-08-10 | +| [ADR-072](ADR-072-persistent-notification-list.md) | Present Notifications as One Persistent List with Derived Groups | Accepted | 2026-08-10 | diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 39c86ae52..f62d59aa8 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -1,10 +1,12 @@ # Durable Effect Inventory -Key files: [`pkg/events/durable_worker.go`](../../pkg/events/durable_worker.go), [`cli/internal/core/durable_delivery.go`](../../cli/internal/core/durable_delivery.go), [`cli/internal/core/user_key_shredding.go`](../../cli/internal/core/user_key_shredding.go), [`cli/internal/core/call_model.go`](../../cli/internal/core/call_model.go), [`cli/internal/core/asset_model.go`](../../cli/internal/core/asset_model.go), [`cli/internal/core/message_body_cleanup.go`](../../cli/internal/core/message_body_cleanup.go), [`cli/internal/video/unit.go`](../../cli/internal/video/unit.go), [`cli/internal/video/service.go`](../../cli/internal/video/service.go) +Key files: [`pkg/events/durable_worker.go`](../../pkg/events/durable_worker.go), [`cli/internal/core/durable_delivery.go`](../../cli/internal/core/durable_delivery.go), [`cli/internal/core/notification_materializer.go`](../../cli/internal/core/notification_materializer.go), [`cli/internal/core/notification_visibility_projection.go`](../../cli/internal/core/notification_visibility_projection.go), [`cli/internal/core/notification_occurrence_model.go`](../../cli/internal/core/notification_occurrence_model.go), [`cli/internal/core/user_key_shredding.go`](../../cli/internal/core/user_key_shredding.go), [`cli/internal/core/call_model.go`](../../cli/internal/core/call_model.go), [`cli/internal/core/asset_model.go`](../../cli/internal/core/asset_model.go), [`cli/internal/core/message_body_cleanup.go`](../../cli/internal/core/message_body_cleanup.go), [`cli/internal/video/unit.go`](../../cli/internal/video/unit.go), [`cli/internal/video/service.go`](../../cli/internal/video/service.go) Related decisions: [ADR-033](../adr/ADR-033-event-sourced-state-with-projections.md), [ADR-036](../adr/ADR-036-runtime-state-kv-boundary.md), and -[ADR-066](../adr/ADR-066-durable-asset-processing-runtime-unit.md). +[ADR-066](../adr/ADR-066-durable-asset-processing-runtime-unit.md), +[ADR-069](../adr/ADR-069-explicit-durable-consumer-lifecycle.md), and +[ADR-071](../adr/ADR-071-deterministic-notification-occurrences.md). Some durable facts require work in a different storage system or external service. The table records the current execution and recovery contract. A @@ -13,8 +15,11 @@ does not by itself guarantee that every implementation currently performs that recovery. `events.DurableWorker` provides application-neutral bounded pull-consumer -execution. Chatto owns each consumer's durable name, filters, ack -policy, event decoding, projection barrier, idempotency, and terminal facts. +execution with progress heartbeats, delayed retry, confirmed acknowledgements, +poison termination, and reconnect-safe fetching. Chatto owns each consumer's +durable name, filters, ack policy, event decoding, projection barrier, +idempotency, and terminal facts. Video processing and notification +materialization use application-owned durable consumers through this boundary. Transient fetch failures retry in place. A deleted consumer stops its worker so the owning core process or supervised runtime unit can recreate the declared consumer instead of polling a stale handle indefinitely. Retry failures are @@ -22,7 +27,6 @@ logged on the first and exponentially sparse later attempts; terminated poison deliveries are always logged. Shutdown cancels outstanding pulls before active handlers and schedules redelivery beyond the maximum pull lifetime, preventing an orphaned server-side pull from reclaiming its own handoff. - Owner-only admin diagnostics classify the four known Chatto durable queues from their JetStream consumer state without adding process-local health as a source of truth. Waiting pulls demonstrate availability. Ack-pending deliveries @@ -42,9 +46,22 @@ redelivery counts remain informational rather than a current failure flag. | Obsolete or retracted message-body erasure | `MessageEditedEvent`, `MessageRetractedEvent`, and hidden echo state make prior `MessageBodyEvent` payloads obsolete | The mutation calls JetStream `SecureDeleteMsg` for projected obsolete body sequences | After projections catch up at boot, every replica derives all obsolete body sequences and repeats idempotent secure deletion | Recoverable from EVT projection state; boot work is not lease-owned | | User content-key and KEK shredding | `UserKeyShreddingRequestedEvent` is committed under the exact user-aggregate OCC tail and is the logical tombstone boundary; immutable `UserDEKGeneratedEvent` facts plus surviving runtime DEK records identify the deletion set; `UserKeyShreddedEvent` records physical completion | Account deletion aborts unless the request is durable; the command waits for privacy-sensitive projections through it, shreds every discovered wrapping key before deleting any DEK record, and appends completion | Shared `chatto-user-key-shredding-v1` pull-consumer replicas reconstruct targets and redeliver the request until deletion and completion succeed; KEK-first ordering preserves discovery across partial attempts, and existing completion is an ack-only no-op | Crash-safe, recoverable, at-least-once effect with deterministic failure-window and concurrent-key-generation coverage | | Runtime credential cleanup after security changes | Password, account-deletion, and external-identity events advance durable user/auth state before stored sessions and tokens are deleted | The request scans and deletes matching `RUNTIME_STATE` credentials and publishes transient session termination | Credential generation prevents stale credentials from authenticating new requests or reconnects; stale records remain cleanup debt, and an already-open realtime connection depends on best-effort session termination | New authentication is durably revoked; physical cleanup and immediate live disconnect are best-effort | -| Notifications derived from messages | `MessagePostedEvent` contains the source message, actor, room, mentions, and thread relationships | The posting request derives recipient-specific notification records in `RUNTIME_STATE`, publishes live invalidations, and asynchronously invokes web push | Notification creation is not replayed from EVT after a crash; push retries are limited to the active callback and provider behavior | Best-effort derived user state; a crash can lose notification records or push delivery | +| Notification occurrence materialization and Alert delivery | Source-time policy evaluation prepares exact occurrence work plus a trigger marker in `RUNTIME_STATE` before the existing message/reaction fact commits. The source fact then wakes the shared durable consumer; retraction, reaction removal, visibility loss, and account deletion remain existing domain facts. No notification-only event is added to `EVT` | Every source-command OCC attempt captures and waits the current config-projection tail, then recomputes mention expansion, policy decisions, and its exact prepared recipient set, including clearing stale work when a retry now evaluates to Off. The shared `chatto-notification-materializer-v2` pull consumer is the sole occurrence/lifecycle writer; request paths may wait for its acknowledgement but do not perform overlapping prompt materialization. It begins at its creation boundary, permits one globally in-flight delivery, waits for source projections, applies work idempotently, deletes completed work, and acknowledges. The snapshot-capable Notification Visibility projection retains exact authorization state for pending implicit-loss facts and caps restore at the worker's acknowledged floor, avoiding lifetime EVT replay on this lane. Room visibility loss records a 90-day causal boundary immediately after commit and again in the worker. Read actions persist target and observed EVT boundaries, then reconcile through authoritative KV scans; occurrence creation performs the matching post-write boundary check before an Alert becomes claimable. Reading or deleting an occurrence cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current recipient, server-wide room-event, room-group-layout, and RBAC tails and wait local projections before exact target/reaction validation. Single-occurrence mutations and group deletion first wait the durable consumer through a fresh boundary and fence the occurrence index through its KV writes before reading membership. Alert claiming first waits the durable consumer through a fresh worker-event boundary. List and realtime reads also wait that consumer through a captured tail of every worker filter, append a marker to the occurrence KV stream, and wait the serving replica's watcher through the marker revision before deriving exhaustive summaries. Paged lists validate only the required prefix and each page-sized overfetch chunk once. Delivery also revalidates subscription ownership and DND state | Replicas share the ordered queue lane. Recipient/source KV identity, tombstones, exact prepared-work replacement, direct authoritative cleanup scans, read boundaries, visibility boundaries, durable-consumer/KV-watcher read fences, and exact-boundary projection snapshots make cross-replica ordering explicit rather than relying on local watcher timing. Delayed creation checks current account, membership, retraction, and exact reaction state. Account deletion retries occurrence purge and removes read/visibility boundaries. Failed source appends can leave untriggered keys, bounded by the same absolute 90-day TTL. Claims prevent concurrent replica delivery; any-device acceptance completes an unexpired claim, while a crash after provider acceptance can still cause duplicate delivery on retry | Occurrence creation/removal and Alert retry are recoverable and at least once. Consumer lag and retry state are exposed only through logs today | | Server branding replacement cleanup | Server logo/banner set or cleared events make the old asset unreachable from projected configuration | The request deletes the prior NATS/S3 object and cached transforms after the config event commits | No durable cleanup worker scans superseded branding assets | Durable pointer update with best-effort orphan cleanup | +The notification consumer also processes configured-owner verified-email facts. +It retries materializing the durable RBAC owner role, while live authorization +recognizes only that role. Pending notification-visibility facts share one full +checkpoint plus compact event deltas, and a boundary is released only after the +consumer's acknowledged floor confirms it. This keeps transient role-assignment +or acknowledgement failures from creating live/event-time divergence or +redelivery gaps without adding notification-only EVT facts. Snapshot publication +also defers whenever a capture crosses the full notification-consumer floor, +including while a non-boundary worker fact is pending, preserving the +repository's last generation that restart can safely accept. Idle-tail +advancement is reconstructed after restart as the full-EVT prefix immediately +before the earliest fact following the filtered consumer's sparse raw AckFloor. + Observability is currently domain-specific. Call reconciliation records its consecutive LiveKit listing failures in `MEMORY_CACHE`. Owner-only asset-cleanup diagnostics derive queue depth and delivery progress directly from the shared @@ -64,16 +81,17 @@ message-body cleanup covers immediate secure deletion after edits and retractions. User-key shredding covers request-append failure, logical fail-closed state before physical deletion, partial deletion, missing completion, idempotent retry, and shutdown handoff to another replica. -Notification derivation, branding cleanup, -and the message-body boot sweep do not have equivalent crash-and-recovery -coverage. The +Notification occurrence tests cover durable +work recovery, replay-safe identity, OCC, read/materialization ordering, +tombstones, and alert-claim retry. Branding cleanup and the message-body boot +sweep do not have equivalent crash-and-recovery coverage. The call-key, user-DEK, and asset-creation compensation paths likewise lack durable tests for cleanup failure followed by restart. Cross-domain follow-up work is tracked in [#1377](https://github.com/chattocorp/chatto/issues/1377), with separate issues for physical asset deletion, user-key shredding, video ownership, and the -notification durability decision. +notification follow-up observability. Transient `live.sync.>` publication is intentionally excluded from recovery: clients treat those messages as invalidations and recover authoritative state diff --git a/docs/architecture/interfaces.md b/docs/architecture/interfaces.md index 328a75297..ec45f15c0 100644 --- a/docs/architecture/interfaces.md +++ b/docs/architecture/interfaces.md @@ -52,7 +52,7 @@ socket. | ------- | --------------- | ----------- | | `chatto.auth.v1` | `ExternalIdentityAuthService` | Public capability-token flows | | `chatto.discovery.v1` | `ServerDiscoveryService` | Public discovery | -| `chatto.api.v1` | `AssetService`, `AssetUploadService`, `MessageSearchService`, `MessageService`, `MyAccountService`, `NotificationPreferencesService`, `NotificationService`, `PushNotificationService`, `RoleService`, `RoomDirectoryService`, `RoomService`, `ServerService`, `ThreadService`, `UserService`, `ViewerService`, `VoiceCallService` | Authenticated user | +| `chatto.api.v1` | `AssetService`, `AssetUploadService`, `MessageSearchService`, `MessageService`, `MyAccountService`, `NotificationService`, `PushNotificationService`, `RoleService`, `RoomDirectoryService`, `RoomService`, `ServerService`, `ThreadService`, `UserService`, `ViewerService`, `VoiceCallService` | Authenticated user | | `chatto.admin.v1` | `AdminDiagnosticsService`, `AdminEventLogService`, `AdminInviteLinkService`, `AdminPermissionService`, `AdminRoleService`, `AdminRoomLayoutService`, `AdminServerService`, `AdminUserService` | Authenticated user; methods enforce administrative permissions | `AdminInviteLinkService` requires `user.invite`. Its resource includes the diff --git a/docs/architecture/nats-resources.md b/docs/architecture/nats-resources.md index 760bb3003..85fcedf14 100644 --- a/docs/architecture/nats-resources.md +++ b/docs/architecture/nats-resources.md @@ -17,8 +17,8 @@ inventories. | Type | Name | Storage | Backup | Description | | ------------ | ------------------- | ------- | ------ | --------------------------------------------------------------------------- | | Stream | `EVT` | File | Yes | Event-sourcing log for durable `corev1.Event` facts on `evt.>` | -| KV bucket | `RUNTIME_STATE` | File | Yes | Persisted latest-value runtime state, auth/session tokens, notifications, wrapped app DEKs, encrypted snapshot pointers | -| KV bucket | `MEMORY_CACHE` | Memory | No | Volatile presence, worker leases and cooldowns, and reconciliation counters; recreated automatically after a full NATS restart | +| KV bucket | `RUNTIME_STATE` | File | Yes | Persisted latest-value runtime state, auth/session tokens, notification occurrences and tombstones, wrapped app DEKs, encrypted snapshot pointers | +| KV bucket | `MEMORY_CACHE` | Memory | No | Volatile presence, worker leases and cooldowns, reconciliation counters, and worker health heartbeats; recreated automatically after a full NATS restart | | KV bucket | `ENCRYPTION_KEYS` | File | No | KMS key-encryption keys and per-call LiveKit E2EE keys; excluded from backups | | Object store | `SERVER_ASSETS` | File | Yes | Default/legacy NATS-backed persisted asset binaries | | Object store | `PROJECTION_SNAPSHOTS` | File | Yes | Optional encrypted projection snapshot objects; configurable TTL defaults to seven days | diff --git a/docs/architecture/projections.md b/docs/architecture/projections.md index 27c4467f4..6d26c8f29 100644 --- a/docs/architecture/projections.md +++ b/docs/architecture/projections.md @@ -244,7 +244,7 @@ reconstruction. Legacy cohort paths remain outside application S3 expiry. | Projection | Contract | Payload store | Pointer store | Publication | | ---------- | -------- | ------------- | ------------- | ----------- | -| Room Directory, Server Config, Room Group Layout, Call State, Reactions, Content Keys, RBAC | `v1` per projection | `PROJECTION_SNAPSHOTS` or configured S3 | Encrypted per-projection `RUNTIME_STATE` pointer with KV revision OCC | Elected publisher checks hourly; cold/delta replay publishes immediately and unchanged state refreshes at 23 hours | +| Room Directory, Notification Visibility, Server Config, Room Group Layout, Call State, Reactions, Content Keys, RBAC | `v1` per projection | `PROJECTION_SNAPSHOTS` or configured S3 | Encrypted per-projection `RUNTIME_STATE` pointer with KV revision OCC | Elected publisher checks hourly; cold/delta replay publishes immediately and unchanged state refreshes at 23 hours. Notification Visibility caps restore at the notification worker's full acknowledged floor so pending exact boundaries replay into one full checkpoint plus compact deltas; publication beyond that same floor defers rather than rotating away the last safe generation. Startup reconstructs idle-tail advancement immediately before the earliest worker-filtered fact following the sparse AckFloor | | Threads, Mentionables | `v2` per projection | `PROJECTION_SNAPSHOTS` or configured S3 | Encrypted per-projection `RUNTIME_STATE` pointer with KV revision OCC | The key-shredding request boundary invalidates pre-request snapshot contracts | | Room Timeline | `v5` | `PROJECTION_SNAPSHOTS` or configured S3 | Encrypted per-projection `RUNTIME_STATE` pointer with KV revision OCC | Restores active pin associations and rebuilds Slow Mode's latest-original-post index; earlier schema fingerprints remain isolated | | Assets | `v2` | `PROJECTION_SNAPSHOTS` or configured S3 | Encrypted per-projection `RUNTIME_STATE` pointer with KV revision OCC | Same elected age-aware publisher; `v1` snapshots remain independently addressable during rollout and rollback | @@ -255,13 +255,14 @@ reconstruction. Legacy cohort paths remain outside application S3 expiry. | Runtime area | Registered projector | Consumes | Read models / primary readers | | ------------------ | -------------------- | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------- | | Room directory | Room Directory | `evt.room.>` | `RoomCatalogProjection`, `RoomMembershipProjection`, `RoomBanProjection`; room metadata including Slow Mode, room/member queries, room authorization, and Universal-room effective membership | +| Notification privacy | Notification Visibility | Focused room creation/universal/deletion/membership/ban facts, room-group lifecycle/placement facts, and `evt.rbac.>` | Exact room-membership and `room.join` authorization state retained for pending administrative boundaries as one checkpoint plus an event-delta journal; release follows the shared consumer's confirmed acknowledgement floor | | Room organization | Room Group Layout | `evt.group.>`, `evt.layout.>` | `RoomGroupProjection`, `RoomLayoutProjection`; sidebar groups, sidebar links, and mixed sidebar item ordering | | Room timeline | Room Timeline | `evt.room.>`, `evt.user.*.user_key_shredding_requested`, `evt.user.*.user_key_shredded` | Visible room timeline, latest message bodies, tombstone timestamps, hidden echoes, current attachment-bearing message index, direct message-post lookup, active canonical pinned-message associations, the latest pin-fact marker per room, and latest original post by room and author | | Assets | Assets | `evt.asset.>`, legacy `evt.room.*.asset_*`, `evt.room.*.message_body` | `AssetModel`; detached asset declaration/room/processing/deletion snapshots, derivative graph, message ownership/author references, public link-preview image references, and legacy room-asset compatibility | | Threads | Threads | `evt.room.*.thread_created`, `evt.room.*.thread_followed`, `evt.room.*.thread_unfollowed`, `evt.room.*.message_posted`, `evt.room.*.message_edited`, `evt.room.*.message_retracted`, `evt.user.*.user_key_shredding_requested`, `evt.user.*.user_key_shredded` | Per-thread existence, reply logs, summaries, participants, reply counts, and follow state | | Reactions | Reactions | `evt.room.>` | Current canonical per-message reaction sets, echo-to-original reaction aliases, and room-scoped snapshot OCC positions; intentionally broad so reaction writes can OCC against the room tail | | Voice calls | Call State | `evt.room.>` | Current LiveKit call session, participants, active room IDs, and room-scoped snapshot OCC positions | -| Server/user config | Server Config | `evt.config.>`, selected user cleanup/preference facts | `ConfigModel`; server config, branding refs, user preferences, notification levels, blocked usernames | +| Server/user config | Server Config | `evt.config.>`, selected user cleanup/preference facts | `ConfigModel`; server config, branding refs, user preferences, per-cause server/room notification intensities, blocked usernames; historical coarse notification-level facts decode but have no projected effect | | Users | Users | `evt.user.>` | `UserModel`; account/profile/custom-status state, verified emails, lookup digests, and encrypted user PII | | User authentication | User Auth | Focused account, password, external-identity, consent, deletion, and key-shredding user facts | `UserModel`; password verifiers, auth generations, external identity links, and OAuth consent; always cold-replayed | | Invitations | Invitations | `evt.invitation.>` | `InvitationModel`; immutable constraints, redemption count, revocation state, and administrator listings; always cold-replayed | diff --git a/docs/architecture/realtime-delivery.md b/docs/architecture/realtime-delivery.md index ce3031b2c..cac94c98a 100644 --- a/docs/architecture/realtime-delivery.md +++ b/docs/architecture/realtime-delivery.md @@ -71,7 +71,7 @@ idempotent operations: visible room-group layout; DM participant references remain eager; - complete channel membership and the latest 50 renderable timeline events only for rooms named as retained by the subscribing client; -- the newest finite pending-notification page and complete per-room counts; +- the newest finite Notifications 2.0 groups, unread-group count, and complete per-room unread-group counts; - every active call visible to the viewer; and - a complete latest-value presence map for the projected user directory. @@ -214,7 +214,7 @@ windows (3,200 recent rows), bounding decryption and transient response memory. Every subscription emits one finite latest-value reconciliation before `caught_up`. It replaces the viewer resource; the complete followed-thread -viewer-state set, including RUNTIME_STATE unread markers; pending notifications +viewer-state set, including RUNTIME_STATE unread markers; notification groups and room counts; and the server directory's current presence. Missing followed-thread entries authoritatively clear follow/unread state on retained thread roots. @@ -374,14 +374,22 @@ reloaded during reset. Typing, presence transitions, mention/new-DM attention hints, and session termination continue as `RealtimeEventEnvelope` frames on the same WebSocket. -Notification create/dismiss signals instead assemble an authoritative -`notifications_replace`; a live replacement may carry transition metadata for +Notification occurrence create/update/delete signals instead assemble an authoritative +`notifications_replace` containing groups and counts; a live replacement may carry transition metadata for one-shot presentation effects, while replay and finite reconciliation omit it. +The internal signal also carries its source identity and `RUNTIME_STATE` +revision. Before emitting the replacement at that live cursor, the serving +replica waits until its process-wide notification index has observed that +revision, preventing a cross-replica signal from advancing the cursor with a +stale replacement. Group replacements contain at most 20 occurrence previews +per group plus aggregate totals and the next list expiry boundary; +clients refresh at that boundary, and exact group members use a separately +paginated ConnectRPC read. Viewer preferences, thread follow/read state, profile changes, server layout, and member removal likewise mutate the client only through projection operations. Active calls converge through `active_calls_replace` in the compacted prefix and after every durable call transition. Transient frames have -no durable cursor; finite pending-notification and presence state are +no durable cursor; finite notification-list and presence state are reconciled explicitly on every subscription. The process-wide PresenceHub retains current presence and fans out later transitions. diff --git a/docs/architecture/runtime-components.md b/docs/architecture/runtime-components.md index f8d3d3664..670a6ed31 100644 --- a/docs/architecture/runtime-components.md +++ b/docs/architecture/runtime-components.md @@ -57,7 +57,7 @@ The core model inventory is a list of stable machine-readable keys such as `conf | `events.ProjectionHandle` / `events.Projector` | [`projector.go`](../../pkg/events/projector.go), [`projector.go`](../../cli/internal/evtstream/projector.go) | Envelope-neutral typed projection ownership plus ordered replay, readiness, failure, snapshot, and checkpoint lifecycle; `evtstream` supplies Chatto's unchanged `corev1.Event` decoder and typed constructors | | `events.DurableWorker` | [`durable_worker.go`](../../pkg/events/durable_worker.go) | Application-neutral bounded, at-least-once execution from an application-owned JetStream pull consumer; transient fetches retry, deleted consumers return control to application lifecycle, and callers own decoding, projection barriers, idempotency, retry classification, and terminal facts | | `ConfigModel` | [`config_model.go`](../../cli/internal/core/config_model.go), [`server_config_model.go`](../../cli/internal/core/server_config_model.go) | Sole core boundary for semantic server/user config reads and event writes, including `ConfigProjection` readiness | -| `NotificationPreferencesModel` | [`notification_level.go`](../../cli/internal/core/notification_level.go) | Operation-level notification preference API with authZ before config preference writes | +| `NotificationPolicyModel` / `NotificationOccurrenceModel` / `NotificationMaterializer` / `NotificationVisibilityProjection` | [`notification_policy.go`](../../cli/internal/core/notification_policy.go), [`notification_occurrence_model.go`](../../cli/internal/core/notification_occurrence_model.go), [`notification_occurrence_index.go`](../../cli/internal/core/notification_occurrence_index.go), [`notification_materializer.go`](../../cli/internal/core/notification_materializer.go), [`notification_visibility_projection.go`](../../cli/internal/core/notification_visibility_projection.go) | Per-cause server/room policy writes and evaluation; deterministic recipient/source occurrence ownership; one process-wide KV index; temporary pre-commit runtime work; a shared durable consumer of existing source/lifecycle, universal-room, room-group placement, relevant RBAC, and configured-owner verified-email facts; snapshot-capped exact-boundary visibility state retained as one checkpoint plus compact deltas until confirmed acknowledgement; authoritative effective-membership cleanup; lifecycle OCC; and leased Alert delivery | | `MessageModel` | [`message_model.go`](../../cli/internal/core/message_model.go), [`messages.go`](../../cli/internal/core/messages.go) | Operation-level message posting and mutation API with preflight validation, Slow Mode enforcement in preflight and room-OCC commit authorization, narrow authorization-fence plus room-OCC edits, room-scoped retractions, projection waits, atomic edit-driven echo reconciliation, read-marker side effects, and atomic author-created root-thread writes | | `MessageSearchReadModel` | [`message_search_read_model.go`](../../cli/internal/core/message_search_read_model.go) | Resolves provider queries to current member-room scopes and re-authorizes thin provider hits against current room membership and message state | | `ReactionModel` | [`reaction_model.go`](../../cli/internal/core/reaction_model.go), [`reactions.go`](../../cli/internal/core/reactions.go) | Sole reaction mutation boundary: actor membership and `message.react` authZ, room-aggregate OCC writes and retries, and reaction-projection readiness | diff --git a/docs/architecture/runtime-state.md b/docs/architecture/runtime-state.md index bfb3d4696..b39d2ae2f 100644 --- a/docs/architecture/runtime-state.md +++ b/docs/architecture/runtime-state.md @@ -2,6 +2,8 @@ Key files: [`cli/internal/core/storage.go`](../../cli/internal/core/storage.go), [`cli/internal/core/read_state_index.go`](../../cli/internal/core/read_state_index.go), +[`cli/internal/core/notification_materializer.go`](../../cli/internal/core/notification_materializer.go), +[`cli/internal/core/notification_occurrence_model.go`](../../cli/internal/core/notification_occurrence_model.go), [`cli/internal/core/runtime_token_keys.go`](../../cli/internal/core/runtime_token_keys.go), [`cli/internal/core/external_identities.go`](../../cli/internal/core/external_identities.go), [`cli/internal/core/asset_uploads.go`](../../cli/internal/core/asset_uploads.go), and @@ -13,8 +15,8 @@ Related decision: [ADR-036](../adr/ADR-036-runtime-state-kv-boundary.md). | Bucket | Storage | Backup | Description | | ----------------------------- | ------- | -------- | ----------------------------------------------- | -| `RUNTIME_STATE` | File | Yes | Persisted latest-value runtime/user state, including pending notifications, push subscriptions, auth/workflow tokens, wrapped app DEK records, and encrypted snapshot pointers | -| `MEMORY_CACHE` | Memory | No | Volatile cache state: presence, worker leases and cooldowns, and reconciliation counters | +| `RUNTIME_STATE` | File | Yes | Persisted latest-value runtime/user state, including notification occurrences, push subscriptions, auth/workflow tokens, wrapped app DEK records, and encrypted snapshot pointers | +| `MEMORY_CACHE` | Memory | No | Volatile cache state: presence, worker leases and cooldowns, reconciliation counters, and worker health heartbeats | | `ENCRYPTION_KEYS` | File | **No** | KMS KEKs and LiveKit per-call E2EE keys (excluded for security); app-owned wrapped DEKs live in `RUNTIME_STATE` | **ENCRYPTION_KEYS keys:** @@ -58,7 +60,12 @@ survives restart but is not content/domain history. See | -------------------------------------- | ----------------------------------------------------------------- | | `read.room.{userId}.{roomId}` | Last-read root message event ID (UTF-8 string, ~14 bytes). Empty value = "joined but no specific event read yet" (e.g. joined an empty room). Missing key triggers a one-time lazy init to the room's current last event. Membership and DM initialization create the key only when absent. | | `read.thread.{userId}.{roomId}.{threadRootEventId}` | Latest thread message event ID the user has seen. | -| `notification.{userId}.{notificationId}` | Pending notification record (protobuf `Notification`) for DM messages, @mentions, replies, and all-message subscriptions. Uses per-key 90-day TTL. Internal `NotificationCreatedEvent` / `NotificationDismissedEvent` signals on `live.sync.user.{userId}.*` trigger authoritative notification projection replacements; DND keeps the record but marks the live creation transition silent and skips push delivery. | +| `notification_v2.{userId}.{sourceEventId}` | Deterministic protobuf `NotificationOccurrence` or anti-recreation tombstone. Records exact target, matched/evaluated causes, reaction emoji provenance when applicable, an internal source EVT sequence for causal cleanup and read-boundary reconciliation, Unread/Read attention state, and leased Alert delivery. The persisted enum retains its earlier `DONE` number for storage compatibility, but current code treats that value as Read and never writes it. The sequence is never exposed through public APIs. `Create` plus revision OCC is safe across replicas; every rewrite preserves the absolute source-time-plus-90-days expiry. New rows remain non-claimable until their direct read-boundary check finalizes. One filtered watcher per process supplies indexed hot reads, pending-Alert candidates, and realtime replacements; cross-replica lifecycle and read handshakes use authoritative direct KV reads. The watcher prunes locally expired rows together with their revision fences, drops KV delete/purge entries, and uses live written revisions as realtime assembly fences. The legacy key family is not migrated or read by this model. | +| `notification_work.{triggerEventId}` | Temporary marker written after all recipient work is prepared and before the source fact commits. The durable materializer uses this exact lookup to avoid scanning recipient work for unrelated message/reaction facts, then deletes it after successful materialization. It shares the source-time-plus-90-days expiry of its recipient keys. | +| `notification_work.{triggerEventId}.{recipientId}` | Temporary protobuf `NotificationOccurrence` prepared before the existing message/reaction source fact commits. A shared durable EVT consumer loads it by triggering event ID, materializes or revokes the deterministic occurrence idempotently, and deletes the work key after success. Failed source appends may leave untriggered keys; the same absolute 90-day TTL bounds them. | +| `notification_read_boundary.{userId}.{roomId}[.{threadRootEventId}]` | Two big-endian EVT stream sequences: the latest room/thread timeline target read and the reaction projection horizon observed by that read action. Creation and read reconciliation use direct KV handshakes so cross-replica watcher lag cannot leave covered activity unread. The two coordinates keep reactions that arrive after a read new until the next read. The key expires 90 days after its latest update and account deletion removes it. | +| `notification_visibility_boundary.{userId}.{roomId}` | Big-endian EVT stream sequence of the latest explicit or derived room visibility loss relevant to notification materialization. Leave/removal request paths record it immediately after commit; the ordered worker records it for those facts and for universal-room, room-group placement, ban, or `room.join` RBAC/role changes that remove effective membership. Delayed source work at or before the boundary cannot reappear after a rejoin. The key expires after 90 days, matching the maximum lifetime of source work it can suppress, and account deletion removes it. | +| `notification_v2.read_fence` | Big-endian captured EVT stream sequence written by list and realtime summary paths after the sole durable notification worker acknowledges that boundary. Its KV revision fences the serving replica's process-local occurrence watcher through every earlier occurrence/lifecycle mutation in the same KV stream. | | `push_subscription.{userId}.{endpointHash}` | Web Push subscription record (protobuf `PushSubscription`) for a user's browser/device. The endpoint hash keeps multiple devices per user while deduplicating the same browser subscription. A record is deliverable only while its revision matches the endpoint's active owner claim. | | `push_endpoint_owner.{sha256(endpoint)}` | JSON Web Push endpoint owner claim containing the active user ID and exact `push_subscription` KV revision. Saves transfer the claim with KV OCC; revision-matched deletes prevent stale logout, expiry cleanup, and subscription rotation races from releasing a newer claim. Legacy subscription records without a claim remain inert until the browser re-registers. | | `asset_upload.{uploadId}` | JSON room-scoped attachment upload session with actor, declared size/SHA-256, committed offset, chunk keys, status, and expiry. Open sessions use a 15-minute TTL; completed sessions expire with the 24-hour pending-attachment claim window. | diff --git a/docs/architecture/subjects-and-events.md b/docs/architecture/subjects-and-events.md index c934a3415..5a73cf4e5 100644 --- a/docs/architecture/subjects-and-events.md +++ b/docs/architecture/subjects-and-events.md @@ -13,8 +13,9 @@ Related decisions: [ADR-033](../adr/ADR-033-event-sourced-state-with-projections [ADR-034](../adr/ADR-034-single-event-stream.md), [ADR-040](../adr/ADR-040-permission-only-rbac-with-owner-override.md), [ADR-049](../adr/ADR-049-process-wide-realtime-event-hub.md), -[ADR-053](../adr/ADR-053-versioned-nats-service-namespaces.md), and -[ADR-068](../adr/ADR-068-selectable-event-mutation-consistency-boundaries.md). +[ADR-053](../adr/ADR-053-versioned-nats-service-namespaces.md), +[ADR-068](../adr/ADR-068-selectable-event-mutation-consistency-boundaries.md), and +[ADR-071](../adr/ADR-071-deterministic-notification-occurrences.md). ## Event envelopes @@ -40,9 +41,9 @@ Both files share `package chatto.core.v1` and generate into the same Go package. | Category | Storage | Examples | Purpose | | --------------------------- | ---------- | ----------------------------------------------------------- | -------------------------------------------------------------- | -| JetStream-stored (room) | Stream | RoomCreated, RoomUniversalChanged, RoomSlowModeChanged, MessagePosted, MessageEdited, MessageRetracted, ReactionAdded, ReactionRemoved, UserJoinedRoom, CallStarted, CallParticipantJoined, CallParticipantLeft, CallEnded | Ordering guarantees, historical replay, projection source of truth | +| JetStream-stored (room) | Stream | RoomCreated, RoomUniversalChanged, RoomSlowModeChanged, MessagePosted, MessageEdited, MessageRetracted, ReactionAdded, ReactionRemoved, UserJoinedRoom, CallStarted, CallParticipantJoined, CallParticipantLeft, CallEnded | Ordering guarantees, historical replay, projection and recoverable-effect source of truth | | Room live-only | NATS Core | UserTyping | Ephemeral room notifications where another store/projection is source of truth | -| Deployment live (user/config) | NATS Core | UserCreated, ServerUpdated, MentionNotification, NotificationCreated, PresenceChanged | Cross-tab sync, notifications, server lifecycle | +| Deployment live (user/config) | NATS Core | UserCreated, ServerUpdated, NotificationOccurrenceChanged, PresenceChanged | Cross-tab sync, notification-state invalidation, server lifecycle | The distinction between stored and live-only events is explicit in the wire envelope: durable facts use `corev1.Event`, transient signals use `corev1.LiveEvent`. Room queries and server subscriptions are delivery contexts, not separate wrapper types. @@ -165,7 +166,7 @@ and [ADR-051](../adr/ADR-051-server-scoped-resumable-client-projection.md). | Stream | Wrapper | Scope | Description | | ---------------------------- | ---------------- | ---------- | ------------------------------------------------ | -| `EVT` | `corev1.Event` | Server | Event-sourcing log ([ADR-033](../adr/ADR-033-event-sourced-state-with-projections.md) / [ADR-034](../adr/ADR-034-single-event-stream.md)). Subjects `evt.{aggregateType}.{aggregateId}.{eventType}`; republishes onto `live.evt.>` as the raw committed-event feed. Stores room membership/metadata, groups/layout, server config, users, messages/threads, reactions, assets, RBAC, and auth workflow audit facts. | +| `EVT` | `corev1.Event` | Server | Event-sourcing log ([ADR-033](../adr/ADR-033-event-sourced-state-with-projections.md) / [ADR-034](../adr/ADR-034-single-event-stream.md)). Subjects `evt.{aggregateType}.{aggregateId}.{eventType}`; republishes onto `live.evt.>` as the raw committed-event feed. Stores room membership/metadata, groups/layout, server config, users, messages/threads, reactions, assets, RBAC, and auth workflow audit facts. Notification materialization consumes existing source/lifecycle facts and keeps prepared work in `RUNTIME_STATE`; it adds no notification-only EVT facts. | | Live Sync | `corev1.LiveEvent` | Transient | Direct NATS Core pubsub on `live.sync.>` for ephemeral activity and latest-value invalidation signals. `StreamMyEvents` authorizes them; genuinely transient activity becomes public realtime events, while invalidations trigger authoritative projection operations. | The republished `live.evt.{aggregateType}.{aggregateId}.{eventType}` subject is an internal server-side feed; `StreamMyEvents` waits for projections and authorization before delivering anything to clients. @@ -263,10 +264,11 @@ cursors are trusted integration coordinates and are not public API cursors. | `evt.config.{subject}.user_time_format_changed` | `UserTimeFormatChangedEvent` | | `evt.config.{subject}.user_time_format_cleared` | `UserTimeFormatClearedEvent` | -| `evt.config.{subject}.user_server_notification_level_set` | `UserServerNotificationLevelSetEvent` | -| `evt.config.{subject}.user_server_notification_level_cleared` | `UserServerNotificationLevelClearedEvent` | -| `evt.config.{subject}.user_room_notification_level_set` | `UserRoomNotificationLevelSetEvent` | -| `evt.config.{subject}.user_room_notification_level_cleared` | `UserRoomNotificationLevelClearedEvent` | +| `evt.config.{subject}.user_server_notification_level_set` | `UserServerNotificationLevelSetEvent` (historical decode only; ignored by current projections) | +| `evt.config.{subject}.user_server_notification_level_cleared` | `UserServerNotificationLevelClearedEvent` (historical decode only; ignored by current projections) | +| `evt.config.{subject}.user_room_notification_level_set` | `UserRoomNotificationLevelSetEvent` (historical decode only; ignored by current projections) | +| `evt.config.{subject}.user_room_notification_level_cleared` | `UserRoomNotificationLevelClearedEvent` (historical decode only; ignored by current projections) | +| `evt.config.{subject}.user_notification_preference_changed` | `UserNotificationPreferenceChangedEvent` (sole active notification-policy write) | | `evt.group.{groupId}.group_created` | `RoomGroupCreatedEvent` | | `evt.group.{groupId}.group_updated` | `RoomGroupUpdatedEvent` | | `evt.group.{groupId}.group_deleted` | `RoomGroupDeletedEvent` | @@ -360,11 +362,7 @@ Patterns: `live.sync.>` for transient `LiveEvent` pubsub and `live.evt.>` for ra | `live.sync.user.{userId}.profile_updated` | User profile changed (broadcast for login/display/avatar updates; custom status set/clear is delivered from `live.evt.>`) | | `live.sync.config.server_updated` | Public server profile/config changed (name/MOTD/welcome/logo/banner/description) | | `live.sync.config.room_groups_updated` | Admin reordered the room sidebar / room-group layout | -| `live.sync.user.{userId}.mentioned` | User was @mentioned (legacy attention signal; suppressed during DND) | -| `live.sync.user.{userId}.dm_message` | New DM message received (legacy attention signal; suppressed during DND) | -| `live.sync.user.{userId}.notification_created` | New notification created; may be marked silent for DND alert suppression | -| `live.sync.user.{userId}.notification_dismissed` | Notification dismissed | -| `live.sync.user.{userId}.notification_level_changed` | Viewer's server/room notification level changed | +| `live.sync.user.{userId}.notification_v2` | Notification occurrence created, triaged, removed, or alert-eligibility changed; triggers an authoritative group/count replacement | | `live.sync.user.{userId}.thread_follow_changed` | Viewer's thread follow/unfollow toggled | | `live.sync.user.{userId}.settings_updated` | User preferences changed | | `live.sync.user.{userId}.room_read` | Room marked as read | diff --git a/docs/fdr/FDR-001-roles-and-permissions.md b/docs/fdr/FDR-001-roles-and-permissions.md index 510105883..b5f16674b 100644 --- a/docs/fdr/FDR-001-roles-and-permissions.md +++ b/docs/fdr/FDR-001-roles-and-permissions.md @@ -1,7 +1,7 @@ # FDR-001: Roles & Permissions (RBAC) **Status:** Active -**Last reviewed:** 2026-08-10 +**Last reviewed:** 2026-08-11 ## Overview @@ -16,7 +16,7 @@ Chatto controls who can do what through role-based access control. Every authent - Permissions gate capabilities, not every form of visibility. For example, DM read access comes from room membership, while `message.post` gates starting DMs and sending root DM messages. - Server admins can drag-and-drop to reorder custom roles. System role positions are fixed for ordering consistency. - Custom role display names are limited to 80 bytes; descriptions are limited to 500 bytes. -- Owners are always granted all permissions. An effective owner is either assigned the durable `owner` role or has a verified email listed in `owners.emails` in `chatto.toml`. +- Owners are always granted all permissions. An effective owner has the durable `owner` role; verified users listed in `owners.emails` in `chatto.toml` are materialized into that role at boot or through retryable durable work after verification. - `admin` and every other non-owner role confer only their explicit permission decisions; they have no role-name-based authority. - Owner permissions are virtual rather than persisted defaults: fresh servers do not seed editable owner permission rows, and the admin UI shows owner permissions as read-only green checks. - RBAC editor and inspection APIs are exposed through ConnectRPC admin services. Admin entry is authenticated, and individual operations keep narrower gates such as `role.manage`, `role.assign`, `user.manage-accounts`, `user.manage-permissions`, or `room.manage`. @@ -51,11 +51,11 @@ Chatto controls who can do what through role-based access control. Every authent **Why:** Instance owners must not be able to lock themselves out through unusual role or per-user permission configuration. See ADR-040. **Tradeoff:** RBAC cannot be used to restrict owners, and owner permissions appear as virtual read-only allows rather than stored permission decisions. Restricting owner access requires changing ownership configuration or account state. -### 5. Config-designated owners remain effective even without a durable role +### 5. Config-designated owners converge on the durable role -**Decision:** `owners.emails` is checked at permission time for verified users and also materialized as an `owner` role assignment where possible. -**Why:** The config is the emergency recovery path. Even if the durable `owner` role is removed, a verified configured owner remains able to recover access. -**Tradeoff:** Removing an email from `owners.emails` now matters at the next permission check; durable owner role assignments may still need separate cleanup. +**Decision:** `owners.emails` is materialized as durable `owner` role assignments. Existing verified matches are repaired at boot; a new matching verification is processed by a retryable durable worker and waits for that source fact before returning. Permission checks use only the durable role, and the role cannot be revoked while the matching verified email remains configured. +**Why:** One durable representation keeps live authorization, event-time visibility, and recovery behavior consistent. A transient role append failure remains pending for redelivery instead of creating a live-only owner that notification cleanup cannot recognize. +**Tradeoff:** A transient materialization failure can delay completion of email verification. Removing an email from `owners.emails` does not automatically revoke an already materialized owner role, because the server cannot distinguish config-created assignments from manual ones; operators may revoke it after updating configuration. ### 6. Target-user mutations are permission-gated and role assignment is bounded diff --git a/docs/fdr/FDR-002-replies-and-threads.md b/docs/fdr/FDR-002-replies-and-threads.md index 8320b5b75..45e38a758 100644 --- a/docs/fdr/FDR-002-replies-and-threads.md +++ b/docs/fdr/FDR-002-replies-and-threads.md @@ -1,7 +1,7 @@ # FDR-002: Replies & Threads **Status:** Active -**Last reviewed:** 2026-08-08 +**Last reviewed:** 2026-08-10 ## Overview @@ -82,5 +82,5 @@ Chatto messages can link to one another via reply attribution, and channel-room ## Related -- **ADRs:** ADR-011 (message body/event split), ADR-026 (event identity via NanoID), ADR-038 (room-owned thread state), ADR-050 (ephemeral encrypted projection snapshots) -- **FDRs:** FDR-003 (Thread Reply Echo) +- **ADRs:** ADR-011 (message body/event split), ADR-026 (event identity via NanoID), ADR-038 (room-owned thread state), ADR-050 (ephemeral encrypted projection snapshots), ADR-071 (deterministic notification occurrences), ADR-072 (triageable notification inbox) +- **FDRs:** FDR-003 (Thread Reply Echo), FDR-012 (Notifications) diff --git a/docs/fdr/FDR-005-reactions.md b/docs/fdr/FDR-005-reactions.md index 64d2de466..8c6492fb4 100644 --- a/docs/fdr/FDR-005-reactions.md +++ b/docs/fdr/FDR-005-reactions.md @@ -96,5 +96,5 @@ into a narrow commit-time authorization fence instead. ## Related -- **ADRs:** ADR-026 (event identity via NanoID), ADR-033 (event-sourced state with projections), ADR-034 (single event stream), ADR-035 (per-aggregate migration), ADR-042 (protobuf-first public API), ADR-044 (ConnectRPC service conventions), ADR-048 (frontend optimistic UI), ADR-051 (server-scoped resumable client projection), ADR-068 (selectable event mutation consistency boundaries) -- **FDRs:** FDR-003 (Thread Reply Echo) +- **ADRs:** ADR-026 (event identity via NanoID), ADR-033 (event-sourced state with projections), ADR-034 (single event stream), ADR-035 (per-aggregate migration), ADR-042 (protobuf-first public API), ADR-044 (ConnectRPC service conventions), ADR-048 (frontend optimistic UI), ADR-051 (server-scoped resumable client projection), ADR-068 (selectable event mutation consistency boundaries), ADR-071 (deterministic notification occurrences), ADR-072 (triageable notification inbox) +- **FDRs:** FDR-003 (Thread Reply Echo), FDR-012 (Notifications) diff --git a/docs/fdr/FDR-006-mentions.md b/docs/fdr/FDR-006-mentions.md index 14dbbe5f3..6c0d4aa30 100644 --- a/docs/fdr/FDR-006-mentions.md +++ b/docs/fdr/FDR-006-mentions.md @@ -1,11 +1,14 @@ # FDR-006: @Mentions **Status:** Active -**Last reviewed:** 2026-07-04 +**Last reviewed:** 2026-08-11 ## Overview -Users can mention users, roles, and room-scoped virtual groups with `@handle` syntax. A delivered mention notifies the recipient users, contributes to the room's pending-notification indicator in the sidebar, and renders the mention as styled text in the message body. +Users can mention users, roles, and room-scoped virtual groups with `@handle` +syntax. An eligible delivered mention creates a notification occurrence, +contributes to the room's unread-group indicator, and renders as styled text in +the message body. ## Behavior @@ -60,17 +63,26 @@ Users can mention users, roles, and room-scoped virtual groups with `@handle` sy **Why:** The echo's mention rendering (highlight, link to profile) needs the field present, but the user shouldn't get notified twice. See FDR-003. **Tradeoff:** The frontend has to know that echo mentions don't trigger room-level mention indicators twice. The backend skips the notification on echo events. -### 6. Mute trumps mention +### 6. Direct-mention policy controls delivery -**Decision:** If the recipient has muted the room, the mention is rendered but does not produce a notification. -**Why:** Mute is the user's strongest signal that they don't want pings from this room. Honoring it for everything except mentions would create surprise notifications. -**Tradeoff:** Users in muted rooms might miss directed pings. The mute affordance is loud enough that this is a reasonable default; users who want differently shouldn't mute. +**Decision:** A rendered mention produces an occurrence only when its specific +mention cause resolves to Badge or Alert for the recipient; Off suppresses it. +**Why:** Direct, role, `@here`, and `@all` mentions need independent attention +policy instead of one coarse room mute. See FDR-012. +**Tradeoff:** Users can deliberately suppress directed mentions in a room and +may miss them. ### 7. Mention attention state is a notification -**Decision:** A delivered mention creates a pending notification. Sidebar mention dots derive from pending notifications, not from a separate room-level mention-status key. -**Why:** Mention attention state has the same lifecycle as other notifications: it is pending until the user views or dismisses it, syncs across devices, and expires with notification retention. Keeping it in the notification model avoids duplicated state. -**Tradeoff:** Mention dots follow notification dismissal semantics. Dismissing a mention notification clears the corresponding sidebar attention signal. +**Decision:** An eligible delivered mention creates a notification occurrence. +Sidebar mention indicators derive from unread groups, not from a separate +room-level mention-status key. +**Why:** Mention attention state has the same read, delete, cross-device, +and retention lifecycle as other notifications. Keeping it in the notification +model avoids duplicated state. +**Tradeoff:** Marking the occurrence read or reading through its target clears +the corresponding unread sidebar attention signal while retaining the notification +item. ### 8. Direct thread mentions can subscribe the recipient @@ -98,5 +110,5 @@ No dedicated mention permission. Anyone who can post in a room can mention any u ## Related -- **ADRs:** ADR-026 (event identity via NanoID) +- **ADRs:** ADR-026 (event identity via NanoID), ADR-071 (deterministic notification occurrences), ADR-072 (persistent notification list) - **FDRs:** FDR-002 (Replies & Threads), FDR-003 (Thread Reply Echo), FDR-012 (Notifications), FDR-013 (Web Push Notifications) diff --git a/docs/fdr/FDR-007-direct-messages.md b/docs/fdr/FDR-007-direct-messages.md index f808eacdd..60a0123af 100644 --- a/docs/fdr/FDR-007-direct-messages.md +++ b/docs/fdr/FDR-007-direct-messages.md @@ -1,7 +1,7 @@ # FDR-007: Direct Messages **Status:** Active -**Last reviewed:** 2026-07-22 +**Last reviewed:** 2026-08-10 ## Overview @@ -76,5 +76,5 @@ DMs have no `dm.*` permissions. Message and reaction permissions apply inside DM ## Related -- **ADRs:** ADR-033 (event-sourced state), ADR-034 (single event stream), ADR-037 (DM access via membership) +- **ADRs:** ADR-033 (event-sourced state), ADR-034 (single event stream), ADR-037 (DM access via membership), ADR-071 (deterministic notification occurrences), ADR-072 (triageable notification inbox) - **FDRs:** FDR-001 (Roles & Permissions), FDR-002 (Replies & Threads), FDR-012 (Notifications) diff --git a/docs/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index f9320d9c3..40d18498f 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -1,112 +1,286 @@ # FDR-012: Notifications -**Status:** Active -**Last reviewed:** 2026-07-20 +**Status:** Experimental +**Last reviewed:** 2026-08-11 + +> **Implementation status:** Implemented for the upcoming 0.5.0 release by +> [#1556](https://github.com/chattocorp/chatto/issues/1556), using the documented +> clean cutover from legacy notification records. ## Overview -Chatto has a persistent notification system surfaced through a bell icon and notification center. Notifications represent things the user should pay attention to: DMs, @mentions of users/roles/virtual groups, replies to their own messages, new posts in threads they follow, and (optionally) all messages in rooms they've subscribed to. Notification levels are configurable per space and per room. +Notifications are a persistent, user-scoped list of activity that deserves +attention. They cover direct messages, replies, mentions, followed +conversations, reactions, and future attention causes. The list takes +inspiration from GitHub notifications while using a simpler lifecycle: items +are unread or read, and users delete items they no longer want to retain. +Related activity is grouped without losing the exact events and reasons +underneath. ## Behavior -- A bell icon shows an unread count and opens the notification center listing recent notifications. -- A notification appears for: a DM message, a mention that resolves to the user, a reply to one of the user's messages, a new reply in a thread the user follows, or any root message in a room set to ALL_MESSAGES. -- Mention notifications may come from direct `@username`, role `@role`, `@all`, or `@here` mentions. The bundled composer asks for confirmation before sending role, `@all`, or `@here` mentions, while API callers can post authorized messages directly. -- Notifications auto-expire after 90 days. -- Dismissing a notification removes it everywhere — across all the user's open tabs and devices. -- A notification sound plays and the in-app and installed PWA notification badges update in real time as new notifications arrive. -- While the installed PWA is visible, its app-icon badge shows the exact pending DM count when known. Other pending notifications, or an incomplete notification page that cannot provide an exact DM count, show a non-numeric attention flag. Ordinary unread rooms stay in the in-app sidebar unless the user has configured them to create notifications. -- Users can choose and locally shape the notification sound on each browser with volume, tone, and effect controls. -- Sidebar orange dots for mentions, replies, DMs, and all-message subscriptions derive from pending notification records. -- A recipient's Do Not Disturb presence still stores new notifications and updates counts, but those creation events are silent: no notification sound and no web push while DND is active. - -## Notification Levels - -Per space and per room, the user picks one of four levels: - -- **DEFAULT** — inherit from the parent (room → space → system default of NORMAL). -- **MUTED** — suppress everything for this scope, including @mentions. The room doesn't even show as unread in the sidebar. -- **NORMAL** — notifications for mentions, DMs, and thread replies. Default behavior. -- **ALL_MESSAGES** — like NORMAL plus every root message in the room. - -## Thread Follow - -- Posting a reply in a thread automatically subscribes the user to that thread's reply notifications. -- A direct `@username` mention in a thread subscribes the mentioned user if they have never followed or explicitly unfollowed that thread before. Role mentions, `@all`, and `@here` notify according to mention rules but do not subscribe recipients. -- Thread followers can manually unfollow, and non-posters can manually follow. -- Followers receive a notification for new replies in the thread (skipping their own). -- Thread notifications respect room mute: a muted room produces no thread notifications even for followed threads. +- The notification page is one chronological list of Unread and Read groups. + Unread rows use Chatto's notification orange; Read rows remain visible + without unread emphasis. +- The list is divided into Today, Yesterday, This Week, and monthly sections + using the preferred time zone of the account on each server. Notification + titles are full localized sentences that identify the actor and activity. +- Opening a notification navigates to the exact room, thread, and event. It + marks that notification read; the room or thread becomes read only when the + target is actually displayed. +- Mark Read is a one-way, idempotent attention transition. Notifications cannot + be marked unread. +- Reading a room or thread marks covered notifications read. For a reaction, + coverage follows the reacted-to message rather than the later reaction time. + It does not remove notifications from the list. +- The trash action deletes a notification group without requiring the user to + open or otherwise handle its source activity. Dismiss All deletes every + occurrence current at an authoritative boundary on each authenticated + server. Both actions update the list optimistically; failures restore only + the affected server's rows. +- Notifications expire 90 days after their source activity. Read and Delete + mutations never extend that absolute lifetime. +- Related occurrences are grouped by conversation or target: DM room, thread, + reacted-to message, or channel room. Later activity makes the grouping target + appear as unread again even when older occurrences in the group are read. +- A group opens its newest unread occurrence, or its newest occurrence when all + members are read. Individual occurrences retain exact destinations. +- The bell, current-server indicator, and installed-app badge are active when + at least one unread group exists. The public API still exposes exact unread + group counts for clients that need them; the bundled list does not render a + single-occurrence counter. +- The combined multi-server list preserves results from healthy servers and + servers when another source fails, and presents the failure as partial with + a retry instead of replacing the whole list with an error. +- Retraction, reaction removal, lost room visibility, and account deletion + remove notifications that the user can no longer act on or view. List and + mutation requests validate the exact current target after waiting local + projections through freshly captured recipient, server-wide room-event, + room-group-layout, and RBAC boundaries. List validation scans only far enough + to fill the requested offset page and validates each page-sized overfetch + chunk once when stale groups are removed. The ordered writer also reconciles effective membership + after universal-room, room-group placement, and relevant RBAC/role changes, + including visibility loss without an explicit leave. A snapshot-capable + Notification Visibility projection retains effective membership at each + pending change's exact EVT boundary, so a later regain that reaches ordinary + projections first cannot preserve pre-loss history or remove post-regain + activity. Snapshot restore stops at the worker's acknowledged floor and + replays only its pending tail. Pending facts share one full checkpoint plus a + compact event-delta journal, and exact boundary data remains available until + the consumer's acknowledgement is confirmed; an administrative fact never + copies the full visibility graph or replays lifetime membership/RBAC history + on the notification lane. Snapshot publication pauses whenever a captured + generation would cross the worker's full acknowledged floor, including when + a non-boundary notification fact is pending. On restart, Chatto reconstructs + that full floor immediately before the earliest worker-filtered fact after + the consumer's sparse AckFloor. This preserves the last safe restore point + instead of rotating it away. + Before exhaustive totals and badge summaries are read, + Chatto waits that writer through a captured tail of every relevant EVT filter, + appends a read fence to `RUNTIME_STATE`, then waits the serving replica's + occurrence index through that fence's KV revision. Temporary projection, + worker, or replica-watcher lag is therefore never interpreted as permanent + visibility loss or an authoritative stale count. +- Source commands capture the current notification-policy config tail on every + OCC attempt and wait the local Config projection through it before preparing + notification work. A preference change that completed before the source + attempt therefore applies to that activity; a retry recaptures policy as well + as recipients. +- Before single-occurrence and group deletion read target membership, and + before whole-list deletion captures the current occurrence set, Chatto waits + the durable notification worker through a fresh boundary and + fences the serving replica's occurrence index through the worker's KV writes. + Pending visibility cleanup cannot be bypassed by a stale triage request. +- Attention state, groups, counts, sounds, Web Push, and installed-app badges + reconcile from authoritative server state after reconnect. Missing one live + update cannot leave the client permanently wrong. + +## Notification Policy + +Every supported cause has an independent delivery intensity: + +- **Off** — do not create a notification occurrence for this cause. +- **Badge** — create an occurrence and update the list and badges without an + interruptive sound, Web Push, or native notification. +- **Alert** — create the same occurrence and allow configured interruptive + delivery. + +Preferences inherit independently for each cause from the Chatto product +default, through the user's server-level preference, to an optional room-level +override. A user can return any override to Inherit. Effective values are +computed by the server; clients do not reproduce policy evaluation. + +The initial product defaults are: + +| Cause | Default intensity | +| --- | --- | +| Direct message | Alert | +| Direct username mention | Alert | +| Reply to the user's message | Alert | +| Mention of a role the user belongs to | Alert | +| `@here` | Alert | +| `@all` | Alert | +| New activity in a followed thread | Badge | +| New activity in a followed room | Off | +| Reaction to the user's message | Badge | + +Room invitations are not an implemented notification cause and therefore do +not appear in policy responses or settings. The protobuf reason value is kept +for future additive support, but cannot be persisted as a preference today. + +Direct username mentions, role mentions, `@here`, and `@all` remain separate +causes. A message can match several causes for one recipient, but it produces +one occurrence containing every matched reason and uses the strongest effective +intensity. The user's own activity does not notify them. + +Notification policy affects future activity. Changing a preference does not +mark content read, rewrite existing notification intensity, or erase retained +history. Do Not Disturb and other temporary delivery conditions may silence an +Alert while preserving its occurrence for later review. + +## Conversation Subscriptions + +- Posting in a thread follows it. A delivered direct username mention follows + the thread unless the recipient previously opted out; role, `@here`, and + `@all` mentions do not implicitly follow it. +- Following a thread or room establishes an ambient activity source whose + delivery intensity is still controlled by notification policy. +- Conversation subscriptions are changed through their owning room or thread + controls, not through the initial notification API. ## Design Decisions -### 1. Persistent notification model with live-event sync - -**Decision:** Notifications are persistent objects stored per user in `RUNTIME_STATE` (`notification.{userId}.{notificationId}`), with a 90-day per-key TTL. Live events fire on create and dismiss to keep all the user's connected sessions in sync. -**Why:** Notifications need to survive a tab close (so the badge count is right when you come back tomorrow), and they need to be the same across devices. They are pending user-runtime state, not reconstructable content history, so `RUNTIME_STATE` is the right home. See ADR-012, ADR-028, and ADR-036. -**Tradeoff:** A notification dismissal anywhere clears it everywhere, even if the user wanted to dismiss only locally. The simpler model wins here — "I've seen it" is not device-specific. - -### 2. Mute suppresses notifications AND unread - -**Decision:** MUTED is stronger than "no pings": a muted room doesn't appear unread in the sidebar either. -**Why:** "Quiet" in chat apps often means "ignore this room completely". A user who mutes a room wants it out of their face, not just out of their alerts. -**Tradeoff:** Users who want "quiet but I still want to see if there's new stuff" don't have a third state. The two main modes (engage / ignore) cover the dominant use cases. - -### 3. Mute trumps mentions - -**Decision:** Mentioning a user in a muted room produces no notification. The mention text still highlights in the body if the user opens the room. -**Why:** Mute is the strongest "I don't want pings" signal. Allowing mentions through would defeat the muscle-memory of "mute the room to stop the spam". -**Tradeoff:** Coordinators can't reliably ping someone in a muted room. The mention still renders, so eventual visibility is preserved. - -### 4. Thread auto-follow on post and direct mention - -**Decision:** Posting in a thread automatically follows it, even if the poster previously unfollowed. A delivered direct `@username` mention inside a thread also follows the thread for that recipient, unless they explicitly unfollowed it before. Follow and unfollow state is represented by durable room-aggregate `ThreadFollowedEvent` and `ThreadUnfollowedEvent` facts, with a projection used for notification fanout and My Threads. -**Why:** People who participate in a thread almost always want to see the replies, and a direct mention makes the thread relevant to the recipient. Manual unfollow handles both the "I posted once and don't care any more" case and the "do not put this mentioned thread back in My Threads" case. -**Tradeoff:** A user who posts in many threads or is directly mentioned in many threads accumulates followed-thread subscriptions over time. The 90-day TTL on notifications limits the blast radius; the thread follow state itself is cheap to store. - -### 5. Broadcast mentions are sender-controlled with bundled-client friction - -**Decision:** `@all`, `@here`, and role mentions are allowed. The bundled -composer asks for confirmation before sending them, and muted recipients still -do not receive notifications. The server does not require a confirmation token -from API callers. -**Why:** Chatto needs explicit operational pings for small teams and rooms, but broad pings should be deliberate in the main client. Keeping the safeguard in the client avoids making the integration API carry a client-shaped confirmation token that does not provide meaningful abuse protection. -**Tradeoff:** Operators and integrations can force attention in a room unless recipients have muted it. This is acceptable because mute remains authoritative and integrations can add their own policy or UX friction where appropriate. - -### 6. ALL_MESSAGES is a per-room subscription, not a per-message setting - -**Decision:** "Notify me for every message" is configured per room by the user, not per message by the poster. -**Why:** Receiver-controlled subscription puts the ongoing ambient-notification choice with the person who has to live with the noise. Sender-controlled broadcasts are reserved for explicit mentions; the bundled client adds confirmation friction for role and room-wide mentions. -**Tradeoff:** Users who want every message still need to opt into ALL_MESSAGES; senders should use mentions only for attention events. - -### 7. Push notifications piggyback on persistent notifications - -**Decision:** A push notification fires when a persistent notification is created. If no persistent notification is created (because the room is muted, etc.), no push is sent either. -**Why:** Pushes and in-app notifications are the same logical event presented in two surfaces. Sharing the gating logic ensures they can't diverge. See FDR-013. -**Tradeoff:** No way to receive a push without also generating a persistent notification. Considered desirable: a push you can't find later in the app would be annoying. - -### 8. No parallel mention-status flag - -**Decision:** @mention orange dots are derived from pending mention notifications. Chatto does not maintain a separate `room_mention_status.*` flag. -**Why:** The separate flag duplicated notification state and had to be cleared in lockstep with notification dismissals and room reads. A single pending-notification model gives one source of truth for mention, reply, DM, and all-message attention indicators. -**Tradeoff:** Pending mention dots now have the same retention and dismissal semantics as notifications. This is deliberate: a mention that is no longer a pending notification is no longer pending attention. - -### 9. Notification sound choice and shaping are local - -**Decision:** Notification sound selection and sound-shaping controls are stored in browser-local preferences. -**Why:** They are playback-device preferences, not server behavior. Keeping them local matches the existing sound picker and avoids adding durable compatibility surface for an annoyance/subtlety control. -**Tradeoff:** A user who signs in on a new browser reconfigures sound taste there. Server-synced display settings remain separate. - -### 10. Do Not Disturb silences alert delivery - -**Decision:** Do Not Disturb is checked at notification creation time. While the recipient has live DND presence, Chatto still creates the persistent notification and publishes a silent live sync event, but it suppresses legacy attention live events, notification sounds, and web push delivery. -**Why:** DND means "do not interrupt me now", not "discard things I should review later". Storing the notification preserves missed activity in the notification center and sidebar counts, while the silent marker lets clients update state without making noise. -**Tradeoff:** A user may see badge/sidebar changes while actively viewing Chatto in DND. That is less disruptive than sound or push, and it avoids losing important mentions or DMs. +### 1. A persistent list replaces delete-on-read pending alerts + +**Decision:** Notification occurrences are Unread or Read; Delete is explicit. +There is no Done state and no Mark Unread operation. +**Why:** Users need to review a read notification later and discard noise +without pretending they opened it. A one-way attention transition keeps the +useful persistence of GitHub-style notifications without its additional +Inbox/Done organization. See ADR-072. +**Tradeoff:** Read notifications consume bounded storage until deletion or +expiry, and deleted notifications cannot be restored. + +### 2. Content read state and notification attention are separate + +**Decision:** Room/thread read cursors can mark covered occurrences read, but +notification actions do not advance content read state until the target is +actually displayed. +**Why:** “I cleared this alert” and “I read this conversation through here” are +different claims. Keeping them separate closes accidental read receipts and +allows direct list cleanup. See ADR-028 and ADR-072. +**Tradeoff:** A user can intentionally mark a notification read while its room +still has unread messages. + +### 3. One occurrence records every reason + +**Decision:** One source event produces at most one occurrence per recipient. +It records all matched reasons and uses their strongest effective intensity. +**Why:** A followed-thread reply that also mentions the recipient is one piece +of activity, not two alerts. Retaining all reasons makes the decision +explainable and prevents independent fanout paths from disagreeing. See +ADR-071. +**Tradeoff:** Policy evaluation must gather every cause before committing the +occurrence instead of stopping after the first match. + +### 4. Delivery intensity is independent per cause + +**Decision:** Each cause inherits an Off, Badge, or Alert value through server +and room scopes. +**Why:** The legacy Muted/Normal/All Messages level combines too many choices. +A user may want direct mentions to alert, reactions to appear silently, and +ambient room activity off in the same room. The bundled settings matrix has a +server/room scope selector; Inherit clears the override at the selected scope. +**Tradeoff:** The settings UI becomes a matrix. Presets and inheritance cues +must keep the common case understandable. + +### 5. Notification policy no longer hides ordinary unread rooms + +**Decision:** Turning notification causes Off suppresses new notification +occurrences but does not suppress ordinary room unread state. +**Why:** Read state describes unseen content; notification policy describes how +strongly to surface selected activity. Coupling them makes “quiet but still +unread” impossible. See ADR-072. +**Tradeoff:** This deliberately retires the legacy behavior where Muted also +removed the room's unread indicator. + +### 6. Groups are derived from exact occurrences + +**Decision:** DM, thread, reaction, and room groups are presentation resources +derived from member occurrences rather than independently mutable canonical +records. The only group mutation is deletion. +**Why:** Grouping should reduce notification noise without creating a second +lifecycle that can drift from exact targets. A group deletion removes the +members present at its authoritative boundary; later activity remains new. See +ADR-072. +**Tradeoff:** Group assembly and group deletion require an indexed membership +view and explicit concurrency semantics. Because later activity can reuse a +derived group ID, group mutations are not safe for automatic retries after an +ambiguous transport failure; its response is a bounded affected-count +acknowledgements and realtime delivery is coalesced to one invalidation. + +### 7. Notifications retain references, not presentation copies + +**Decision:** Occurrences retain stable source, reason, actor, and destination +IDs. Names, avatars, message text, and room presentation are hydrated from +current visible resources. +**Why:** Copied presentation becomes stale and can outlive authorization or +content deletion. Exact references are sufficient to navigate and reconcile. +See ADR-071. +**Tradeoff:** Rendering a notification depends on current projection hydration; +an inaccessible or deleted target removes the occurrence rather than showing a +stale preview. + +### 8. Ninety days is an absolute lifetime + +**Decision:** Every occurrence expires 90 days +after the source activity. Mutations do not reset the clock. +**Why:** The notification list is bounded attention state, not a permanent activity archive. +The limit gives predictable storage and privacy behavior while retaining three +months of useful history. See ADR-071. +**Tradeoff:** Notifications cannot be retained as a permanent personal archive. + +### 9. Persistent state precedes interruptive delivery + +**Decision:** Sounds, Web Push, native notifications, and installed-app badges +are driven from a committed occurrence evaluated as Alert. Badge occurrences +stay silent, and Off creates nothing. +**Why:** Every interrupt should correspond to something the user can find in +the app, and every surface should reflect the same policy decision. See +ADR-071 and FDR-013. +**Tradeoff:** Delivery waits for durable occurrence creation and may be delayed +while the notification worker catches up. + +### 10. Policy and subscription changes affect future activity only + +**Decision:** Changing a cause intensity or conversation subscription does not +retroactively rewrite existing occurrences. +**Why:** Existing notifications explain decisions made when their activity +occurred. Silent retroactive cleanup would make notification history unpredictable. +**Tradeoff:** After turning a cause Off, users may still need to triage older +items from that cause. + +### 11. Notifications 2.0 is a clean replacement + +**Decision:** The grouped notification list and per-cause policy replace the legacy pending +notification list and coarse Muted/Normal/All Messages preferences at one +release boundary. Existing pending rows and coarse preferences are not migrated +or interpreted. +**Why:** Maintaining two APIs, stores, and policy systems would make it unclear +which state is authoritative and would preserve the limitations this redesign +exists to remove. Historical persisted event variants remain decode-only so EVT +replay stays valid. See ADR-071. +**Tradeoff:** The 2.0 list starts empty after upgrade, prior preference choices +must be set again, and older clients cannot use notifications on the upgraded +server. ## Permissions -Notification preferences are user-scoped and don't require special permissions to manage. There's no permission gating the ability to mute or change levels. +Notification policy and notification mutations are user-scoped and require no RBAC +permission. Visibility of the source room, message, thread, actor, or reaction +still governs whether the notification can be listed and opened. ## Related -- **ADRs:** ADR-012 (two-tier real-time events), ADR-028 (event-ID-keyed read state), ADR-036 (runtime state in `RUNTIME_STATE`), ADR-038 (room-owned thread state) -- **FDRs:** FDR-006 (@Mentions), FDR-007 (Direct Messages), FDR-013 (Web Push Notifications) +- **ADRs:** ADR-012 (two-tier real-time events), ADR-028 (event-ID-keyed read state), ADR-036 (runtime state in `RUNTIME_STATE`), ADR-038 (room-owned thread state), ADR-051 (server-scoped resumable client projection), ADR-071 (deterministic notification occurrences), ADR-072 (persistent notification list) +- **FDRs:** FDR-002 (Replies & Threads), FDR-005 (Reactions), FDR-006 (@Mentions), FDR-007 (Direct Messages), FDR-013 (Web Push Notifications) diff --git a/docs/fdr/FDR-013-web-push-notifications.md b/docs/fdr/FDR-013-web-push-notifications.md index 7e4375c1f..cd3f66411 100644 --- a/docs/fdr/FDR-013-web-push-notifications.md +++ b/docs/fdr/FDR-013-web-push-notifications.md @@ -1,7 +1,7 @@ # FDR-013: Web Push Notifications **Status:** Active -**Last reviewed:** 2026-08-08 +**Last reviewed:** 2026-08-11 ## Overview @@ -17,14 +17,16 @@ Users can opt in to receive notifications through the browser's W3C Web Push sys - In multi-server mode, native Web Push controls are shown only for the server that served the installed app. Remote servers can still update in-app notification badges and sounds while Chatto is open, but they do not offer direct browser push registration from another server's app origin. - On iOS/iPadOS, Web Push is available only for Home Screen web apps on supported versions. Chatto treats Web Push as a notification trigger rather than authoritative app state. - Stored subscription fields are bounded: endpoint 4,096 bytes, public key 256 bytes, auth secret 128 bytes, and user agent 512 bytes. -- A user can have multiple devices subscribed simultaneously — every device receives every push. +- A user can have multiple devices subscribed simultaneously — every current + device is attempted for each push. Once any device accepts an occurrence, + Chatto does not retry the whole device set merely because another endpoint + failed, avoiding duplicate alerts on healthy devices. - Push payloads include a mutable declarative-compatible notification envelope with a title, a truncated message preview (max 100 chars, broken at word boundaries), a navigation URL, and the pending app badge count when available. The legacy root fields remain present so older Chatto service workers can display the same notification during upgrades. -- User-visible notification pushes request high-urgency delivery so mobile push services can wake sleeping devices promptly. Silent cross-device dismissal pushes use normal urgency. +- User-visible notification pushes request high-urgency delivery so mobile push services can wake sleeping devices promptly. - Clicking a push notification navigates to the relevant room, thread, or DM. -- Dismissing a notification in one place sends a "dismiss" action push to other devices, closing the system notification there too. -- Immediately before a regular push is sent, Chatto confirms that the notification is still pending and the exact prepared subscription is still active. This prevents slower asynchronous creation delivery from overtaking a dismissal or subscription rotation. -- While Chatto is visible, its pending-notification stores are authoritative for the app-icon badge: an exact DM count is numeric, while other or incompletely loaded notifications use a non-numeric flag. After a regular push, the visible app reapplies that aggregate in case the browser used the push's origin-server count. Declarative Web Push supplies a numeric origin-server notification total while the app is closed or suspended. A dismiss push updates that total through the service worker when no visible app window can own the badge, because declarative push cannot remove a notification without displaying a replacement. -- Clicking or manually dismissing a native notification does not itself dismiss the pending notification inside Chatto, so it does not clear the app-icon badge; the badge clears when the pending notification is dismissed in Chatto. +- Immediately before a regular push is sent, Chatto waits the sending replica's user and room projections through freshly captured recipient and server-wide room-event boundaries, then confirms that the occurrence is still unread and Alert-eligible, its account and membership remain active, its target message and exact reaction still exist, every prepared subscription is still owned by the recipient, and Do Not Disturb is still off. Transient projection or subscription reads fail the attempt for retry instead of being treated as absence or an empty device set. This prevents replica lag or slower asynchronous delivery from overtaking notification mutations, target removal, visibility loss, subscription rotation, or a newly enabled DND state. +- While Chatto is visible, its notification stores are authoritative for the app-icon badge. Declarative Web Push supplies the origin server's unread-group count while the app is closed or suspended. +- Clicking or manually dismissing a native notification does not change the occurrence inside Chatto. Attention state changes only through Chatto's read and delete actions or through covered room/thread read state. - Expired or invalid subscriptions (browsers report 404/410 on push delivery) are cleaned up automatically. - Deleting the user account removes all push subscriptions. - If the server isn't configured with VAPID keys, the push UI is hidden entirely — no opt-in prompt, no settings toggle. @@ -33,8 +35,8 @@ Users can opt in to receive notifications through the browser's W3C Web Push sys ### 1. Piggyback on persistent notifications -**Decision:** A push fires only when a persistent notification is created. The two share the same gating logic (mute, level, thread follow). -**Why:** Two parallel decision trees would inevitably diverge — a user who muted a room would still get pushed, or vice versa. One source of truth eliminates that bug class. See FDR-012. +**Decision:** A push fires only for a committed notification occurrence whose strongest per-cause policy intensity is Alert. +**Why:** Two parallel decision trees would inevitably diverge. One persisted policy decision and occurrence eliminate that bug class. See FDR-012. **Tradeoff:** No way to push without also creating an in-app notification. Considered a feature, not a limitation: a push you can't find later in the app would be confusing. ### 2. Per-device subscriptions with exclusive endpoint ownership @@ -55,11 +57,15 @@ Users can opt in to receive notifications through the browser's W3C Web Push sys **Why:** Browsers expire subscriptions over time (uninstalled PWA, revoked permission, expired keys). Without cleanup, the subscription store would grow forever with dead entries, wasting send attempts. **Tradeoff:** A transient 410 from a flaky push provider would prematurely delete an active subscription. The provider's contract is that 410 means "gone for good", so we trust it. -### 5. Dismissal-via-push for cross-device close +### 5. Native notification state is presentation-only -**Decision:** Dismissing a notification anywhere sends a special "dismiss" payload to the user's other devices, which use it to programmatically close the system notification. -**Why:** Otherwise a notification dismissed on the laptop would linger on the phone until the user manually swiped it away. Cross-device dismiss is what users expect from modern chat apps. -**Tradeoff:** Slightly more push traffic. Bounded by user actions, so it's small. +**Decision:** Clicking or dismissing an OS notification does not mutate the +Chatto notification list, and in-app actions do not claim that every push service can retract +an already delivered OS notification. +**Why:** The persistent occurrence is authoritative and must not depend on +browser-specific dismissal callbacks or unordered control pushes. +**Tradeoff:** A delivered native notification can remain visible on another +device after the occurrence is triaged until the person dismisses it there. ### 6. Startup subscription reconciliation @@ -87,15 +93,19 @@ Users can opt in to receive notifications through the browser's W3C Web Push sys ### 10. Late delivery and badge ownership -**Decision:** Regular push delivery revalidates both the pending notification and exact active subscription immediately before sending. While the app is visible, direct synchronization uses a numeric badge only for an exact aggregate DM count and a flag for other or incompletely loaded notifications. After a regular push, the worker sends visible pages a value-free refresh signal so they replay that intent. Declarative push handles regular closed or suspended-app updates with the origin server's numeric total; dismiss pushes carry the remaining origin-server total and the worker applies it whenever no visible app window owns the badge. -**Why:** Notification creation and dismissal callbacks run asynchronously, so a slower creation path can otherwise finish after dismissal and restore a stale native notification. The open page must remain authoritative for its aggregate multi-server intent, including when a browser applies a later origin-only declarative badge without changing page state. The narrow refresh signal does not duplicate badge values or ownership state, while the dismiss fallback prevents cross-device dismissals from leaving a closed installed app stale. This needs no persisted badge state. -**Tradeoff:** The server check cannot revoke a request after the final validation has already passed and the push provider has accepted it. Web Push does not provide strict cross-message ordering, so concurrent badge-bearing pushes remain last-delivery-wins until another push or the visible app refreshes the aggregate. A closed app may temporarily show an all-notification number instead of the visible app's DM-count-or-flag meaning; that count also covers only the app's origin server. Browsers without declarative or worker Badging API support restore the authoritative aggregate when the app next opens. +**Decision:** Regular push delivery revalidates the exact unread Alert occurrence, target visibility, and active subscription immediately before sending. The visible app owns its aggregate multi-server badge; Declarative Web Push carries the origin server's unread-group count while the app is closed. +**Why:** Occurrence materialization and push delivery are asynchronous, so a slower delivery can otherwise overtake read/delete state, target removal, or subscription rotation. Revalidation keeps the push tied to current authoritative state without persisting a separate badge record. +**Tradeoff:** The server cannot revoke a request after final validation and provider acceptance. Concurrent badge-bearing pushes remain last-delivery-wins until another push or the visible app refreshes the aggregate, and a closed-app count covers only the app's origin server. ### 11. High urgency only for user-visible pushes -**Decision:** Regular notification pushes request high-urgency delivery, while silent dismissal pushes use normal urgency. -**Why:** Mobile operating systems may defer normal-urgency Web Push while a device is sleeping. Messages, mentions, and replies are user-visible and time-sensitive, so they should wake the device promptly. Dismissal pushes only reconcile an existing notification and do not justify waking a sleeping device. -**Tradeoff:** Prompt delivery uses more battery than batched delivery. Restricting high urgency to pushes that display a notification keeps that cost aligned with visible user attention and avoids training push services to downgrade silent traffic. +**Decision:** Notification pushes request high-urgency delivery. +**Why:** Mobile operating systems may defer normal-urgency Web Push while a +device is sleeping. Alert activity is user-visible and time-sensitive, so it +should wake the device promptly. Chatto does not send separate dismissal +pushes; read and delete actions synchronize through normal app state when the client is +connected or next opens. +**Tradeoff:** Prompt delivery uses more battery than batched delivery. Restricting push to Alert occurrences keeps that cost aligned with explicit user attention policy, while an already displayed OS notification may remain until the user dismisses it. ## Permissions @@ -103,4 +113,5 @@ No Chatto-side permission gates push. The OS and browser permissions are the onl ## Related +- **ADRs:** ADR-071 (deterministic notification occurrences), ADR-072 (persistent notification list) - **FDRs:** FDR-006 (@Mentions), FDR-012 (Notifications) diff --git a/docs/fdr/INDEX.md b/docs/fdr/INDEX.md index 41c0291d9..d2396dc89 100644 --- a/docs/fdr/INDEX.md +++ b/docs/fdr/INDEX.md @@ -11,18 +11,18 @@ See [`.agents/skills/fdr/SKILL.md`](../../.agents/skills/fdr/SKILL.md) for the F | # | Feature | Status | Last reviewed | |---|---------|--------|---------------| | [FDR-001](FDR-001-roles-and-permissions.md) | Roles & Permissions (RBAC) | Active | 2026-08-10 | -| [FDR-002](FDR-002-replies-and-threads.md) | Replies & Threads | Active | 2026-08-08 | +| [FDR-002](FDR-002-replies-and-threads.md) | Replies & Threads | Active | 2026-08-10 | | [FDR-003](FDR-003-thread-reply-echo.md) | Thread Reply Echo | Active | 2026-06-01 | | [FDR-004](FDR-004-message-editing-and-deletion.md) | Message Editing & Deletion | Active | 2026-08-10 | -| [FDR-005](FDR-005-reactions.md) | Reactions | Active | 2026-08-05 | -| [FDR-006](FDR-006-mentions.md) | @Mentions | Active | 2026-06-15 | -| [FDR-007](FDR-007-direct-messages.md) | Direct Messages | Active | 2026-07-22 | +| [FDR-005](FDR-005-reactions.md) | Reactions | Active | 2026-08-10 | +| [FDR-006](FDR-006-mentions.md) | @Mentions | Active | 2026-08-11 | +| [FDR-007](FDR-007-direct-messages.md) | Direct Messages | Active | 2026-08-10 | | [FDR-008](FDR-008-file-attachments-and-video.md) | File Attachments & Video Processing | Active | 2026-08-11 | | [FDR-009](FDR-009-link-previews.md) | Link Previews | Active | 2026-07-15 | | [FDR-010](FDR-010-typing-indicators.md) | Typing Indicators | Active | 2026-05-19 | | [FDR-011](FDR-011-user-presence.md) | User Presence | Active | 2026-08-03 | -| [FDR-012](FDR-012-notifications.md) | Notifications | Active | 2026-07-20 | -| [FDR-013](FDR-013-web-push-notifications.md) | Web Push Notifications | Active | 2026-08-08 | +| [FDR-012](FDR-012-notifications.md) | Notifications | Experimental | 2026-08-11 | +| [FDR-013](FDR-013-web-push-notifications.md) | Web Push Notifications | Active | 2026-08-11 | | [FDR-014](FDR-014-jump-to-present.md) | Jump to Present | Active | 2026-05-19 | | [FDR-015](FDR-015-quick-switcher.md) | Quick Switcher (Cmd-K) | Active | 2026-05-31 | | [FDR-016](FDR-016-voice-calls.md) | Voice Calls | Active | 2026-08-11 | diff --git a/packages/api-types/src/chatto/api/v1/notification_preferences_connect.ts b/packages/api-types/src/chatto/api/v1/notification_preferences_connect.ts deleted file mode 100644 index 8f1906760..000000000 --- a/packages/api-types/src/chatto/api/v1/notification_preferences_connect.ts +++ /dev/null @@ -1,68 +0,0 @@ -// @generated by protoc-gen-connect-es v1.7.0 with parameter "target=ts" -// @generated from file chatto/api/v1/notification_preferences.proto (package chatto.api.v1, syntax proto3) -/* eslint-disable */ -// @ts-nocheck - -import { GetNotificationPreferenceResponse, GetRoomNotificationPreferenceRequest, GetServerNotificationPreferenceRequest, UpdateNotificationPreferenceResponse, UpdateRoomNotificationPreferenceRequest, UpdateServerNotificationPreferenceRequest } from "./notification_preferences_pb.js"; -import { MethodKind } from "@bufbuild/protobuf"; - -/** - * Reads and updates per-room notification preferences for the current user. - * - * @generated from service chatto.api.v1.NotificationPreferencesService - */ -export const NotificationPreferencesService = { - typeName: "chatto.api.v1.NotificationPreferencesService", - methods: { - /** - * Returns the current user's explicit and effective server notification level. - * - * @generated from rpc chatto.api.v1.NotificationPreferencesService.GetServerNotificationPreference - */ - getServerNotificationPreference: { - name: "GetServerNotificationPreference", - I: GetServerNotificationPreferenceRequest, - O: GetNotificationPreferenceResponse, - kind: MethodKind.Unary, - }, - /** - * Updates the current user's explicit server notification level and returns the - * resulting stored and effective levels. - * - * @generated from rpc chatto.api.v1.NotificationPreferencesService.UpdateServerNotificationPreference - */ - updateServerNotificationPreference: { - name: "UpdateServerNotificationPreference", - I: UpdateServerNotificationPreferenceRequest, - O: UpdateNotificationPreferenceResponse, - kind: MethodKind.Unary, - }, - /** - * Returns the current user's explicit and effective notification level for a - * room. Use this before rendering room notification controls so the UI can - * distinguish inherited defaults from an explicit room override. Returns - * NOT_FOUND when the room does not exist and PERMISSION_DENIED when the room - * exists but is inaccessible to the caller. - * - * @generated from rpc chatto.api.v1.NotificationPreferencesService.GetRoomNotificationPreference - */ - getRoomNotificationPreference: { - name: "GetRoomNotificationPreference", - I: GetRoomNotificationPreferenceRequest, - O: GetNotificationPreferenceResponse, - kind: MethodKind.Unary, - }, - /** - * Updates the current user's explicit notification level for a room and returns - * the resulting stored and effective levels. - * - * @generated from rpc chatto.api.v1.NotificationPreferencesService.UpdateRoomNotificationPreference - */ - updateRoomNotificationPreference: { - name: "UpdateRoomNotificationPreference", - I: UpdateRoomNotificationPreferenceRequest, - O: UpdateNotificationPreferenceResponse, - kind: MethodKind.Unary, - }, - } -} as const; diff --git a/packages/api-types/src/chatto/api/v1/notification_preferences_pb.ts b/packages/api-types/src/chatto/api/v1/notification_preferences_pb.ts deleted file mode 100644 index 68e3abcea..000000000 --- a/packages/api-types/src/chatto/api/v1/notification_preferences_pb.ts +++ /dev/null @@ -1,354 +0,0 @@ -// @generated by protoc-gen-es v1.10.1 with parameter "target=ts" -// @generated from file chatto/api/v1/notification_preferences.proto (package chatto.api.v1, syntax proto3) -/* eslint-disable */ -// @ts-nocheck - -import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialMessage, PlainMessage } from "@bufbuild/protobuf"; -import { Message, proto3 } from "@bufbuild/protobuf"; - -/** - * Notification delivery level for a room. - * - * @generated from enum chatto.api.v1.NotificationLevel - */ -export enum NotificationLevel { - /** - * The level was not specified. - * - * @generated from enum value: NOTIFICATION_LEVEL_UNSPECIFIED = 0; - */ - UNSPECIFIED = 0, - - /** - * Use the inherited default for the room. - * - * @generated from enum value: NOTIFICATION_LEVEL_DEFAULT = 1; - */ - DEFAULT = 1, - - /** - * Do not notify for this room. - * - * @generated from enum value: NOTIFICATION_LEVEL_MUTED = 2; - */ - MUTED = 2, - - /** - * Notify according to the normal room rules. - * - * @generated from enum value: NOTIFICATION_LEVEL_NORMAL = 3; - */ - NORMAL = 3, - - /** - * Notify for every message in the room. - * - * @generated from enum value: NOTIFICATION_LEVEL_ALL_MESSAGES = 4; - */ - ALL_MESSAGES = 4, -} -// Retrieve enum metadata with: proto3.getEnumType(NotificationLevel) -proto3.util.setEnumType(NotificationLevel, "chatto.api.v1.NotificationLevel", [ - { no: 0, name: "NOTIFICATION_LEVEL_UNSPECIFIED" }, - { no: 1, name: "NOTIFICATION_LEVEL_DEFAULT" }, - { no: 2, name: "NOTIFICATION_LEVEL_MUTED" }, - { no: 3, name: "NOTIFICATION_LEVEL_NORMAL" }, - { no: 4, name: "NOTIFICATION_LEVEL_ALL_MESSAGES" }, -]); - -/** - * Stored and effective notification preference. - * - * @generated from message chatto.api.v1.NotificationPreference - */ -export class NotificationPreference extends Message { - /** - * Explicit level stored for the current user. - * - * @generated from field: chatto.api.v1.NotificationLevel level = 1; - */ - level = NotificationLevel.UNSPECIFIED; - - /** - * Level after applying defaults and inheritance. - * - * @generated from field: chatto.api.v1.NotificationLevel effective_level = 2; - */ - effectiveLevel = NotificationLevel.UNSPECIFIED; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.NotificationPreference"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "level", kind: "enum", T: proto3.getEnumType(NotificationLevel) }, - { no: 2, name: "effective_level", kind: "enum", T: proto3.getEnumType(NotificationLevel) }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): NotificationPreference { - return new NotificationPreference().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): NotificationPreference { - return new NotificationPreference().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): NotificationPreference { - return new NotificationPreference().fromJsonString(jsonString, options); - } - - static equals(a: NotificationPreference | PlainMessage | undefined, b: NotificationPreference | PlainMessage | undefined): boolean { - return proto3.util.equals(NotificationPreference, a, b); - } -} - -/** - * Current notification preference. - * - * @generated from message chatto.api.v1.GetNotificationPreferenceResponse - */ -export class GetNotificationPreferenceResponse extends Message { - /** - * Current stored and effective notification preference. - * - * @generated from field: chatto.api.v1.NotificationPreference preference = 3; - */ - preference?: NotificationPreference; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.GetNotificationPreferenceResponse"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 3, name: "preference", kind: "message", T: NotificationPreference }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): GetNotificationPreferenceResponse { - return new GetNotificationPreferenceResponse().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): GetNotificationPreferenceResponse { - return new GetNotificationPreferenceResponse().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): GetNotificationPreferenceResponse { - return new GetNotificationPreferenceResponse().fromJsonString(jsonString, options); - } - - static equals(a: GetNotificationPreferenceResponse | PlainMessage | undefined, b: GetNotificationPreferenceResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(GetNotificationPreferenceResponse, a, b); - } -} - -/** - * Updated notification preference. - * - * @generated from message chatto.api.v1.UpdateNotificationPreferenceResponse - */ -export class UpdateNotificationPreferenceResponse extends Message { - /** - * Stored and effective notification preference after the update. - * - * @generated from field: chatto.api.v1.NotificationPreference preference = 3; - */ - preference?: NotificationPreference; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.UpdateNotificationPreferenceResponse"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 3, name: "preference", kind: "message", T: NotificationPreference }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): UpdateNotificationPreferenceResponse { - return new UpdateNotificationPreferenceResponse().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): UpdateNotificationPreferenceResponse { - return new UpdateNotificationPreferenceResponse().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): UpdateNotificationPreferenceResponse { - return new UpdateNotificationPreferenceResponse().fromJsonString(jsonString, options); - } - - static equals(a: UpdateNotificationPreferenceResponse | PlainMessage | undefined, b: UpdateNotificationPreferenceResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(UpdateNotificationPreferenceResponse, a, b); - } -} - -/** - * Request for the current user's server-level notification preference. - * - * @generated from message chatto.api.v1.GetServerNotificationPreferenceRequest - */ -export class GetServerNotificationPreferenceRequest extends Message { - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.GetServerNotificationPreferenceRequest"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): GetServerNotificationPreferenceRequest { - return new GetServerNotificationPreferenceRequest().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): GetServerNotificationPreferenceRequest { - return new GetServerNotificationPreferenceRequest().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): GetServerNotificationPreferenceRequest { - return new GetServerNotificationPreferenceRequest().fromJsonString(jsonString, options); - } - - static equals(a: GetServerNotificationPreferenceRequest | PlainMessage | undefined, b: GetServerNotificationPreferenceRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(GetServerNotificationPreferenceRequest, a, b); - } -} - -/** - * Request to update the current user's server-level notification level. - * - * @generated from message chatto.api.v1.UpdateServerNotificationPreferenceRequest - */ -export class UpdateServerNotificationPreferenceRequest extends Message { - /** - * Required. New explicit notification level. Use NOTIFICATION_LEVEL_DEFAULT - * to return the server to default behavior. - * - * @generated from field: chatto.api.v1.NotificationLevel level = 1; - */ - level = NotificationLevel.UNSPECIFIED; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.UpdateServerNotificationPreferenceRequest"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "level", kind: "enum", T: proto3.getEnumType(NotificationLevel) }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): UpdateServerNotificationPreferenceRequest { - return new UpdateServerNotificationPreferenceRequest().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): UpdateServerNotificationPreferenceRequest { - return new UpdateServerNotificationPreferenceRequest().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): UpdateServerNotificationPreferenceRequest { - return new UpdateServerNotificationPreferenceRequest().fromJsonString(jsonString, options); - } - - static equals(a: UpdateServerNotificationPreferenceRequest | PlainMessage | undefined, b: UpdateServerNotificationPreferenceRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(UpdateServerNotificationPreferenceRequest, a, b); - } -} - -/** - * Request for the current user's notification preference in one room. - * - * @generated from message chatto.api.v1.GetRoomNotificationPreferenceRequest - */ -export class GetRoomNotificationPreferenceRequest extends Message { - /** - * Required. Room whose notification preference should be loaded for the current user. - * - * @generated from field: string room_id = 1; - */ - roomId = ""; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.GetRoomNotificationPreferenceRequest"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): GetRoomNotificationPreferenceRequest { - return new GetRoomNotificationPreferenceRequest().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): GetRoomNotificationPreferenceRequest { - return new GetRoomNotificationPreferenceRequest().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): GetRoomNotificationPreferenceRequest { - return new GetRoomNotificationPreferenceRequest().fromJsonString(jsonString, options); - } - - static equals(a: GetRoomNotificationPreferenceRequest | PlainMessage | undefined, b: GetRoomNotificationPreferenceRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(GetRoomNotificationPreferenceRequest, a, b); - } -} - -/** - * Request to update the current user's notification level in one room. - * - * @generated from message chatto.api.v1.UpdateRoomNotificationPreferenceRequest - */ -export class UpdateRoomNotificationPreferenceRequest extends Message { - /** - * Required. Room whose notification level should be changed for the current user. - * - * @generated from field: string room_id = 1; - */ - roomId = ""; - - /** - * Required. New explicit notification level. Use NOTIFICATION_LEVEL_DEFAULT - * to return the room to inherited/default behavior. - * - * @generated from field: chatto.api.v1.NotificationLevel level = 2; - */ - level = NotificationLevel.UNSPECIFIED; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.UpdateRoomNotificationPreferenceRequest"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "level", kind: "enum", T: proto3.getEnumType(NotificationLevel) }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): UpdateRoomNotificationPreferenceRequest { - return new UpdateRoomNotificationPreferenceRequest().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): UpdateRoomNotificationPreferenceRequest { - return new UpdateRoomNotificationPreferenceRequest().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): UpdateRoomNotificationPreferenceRequest { - return new UpdateRoomNotificationPreferenceRequest().fromJsonString(jsonString, options); - } - - static equals(a: UpdateRoomNotificationPreferenceRequest | PlainMessage | undefined, b: UpdateRoomNotificationPreferenceRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(UpdateRoomNotificationPreferenceRequest, a, b); - } -} diff --git a/packages/api-types/src/chatto/api/v1/notifications_connect.ts b/packages/api-types/src/chatto/api/v1/notifications_connect.ts index cce34c768..355995078 100644 --- a/packages/api-types/src/chatto/api/v1/notifications_connect.ts +++ b/packages/api-types/src/chatto/api/v1/notifications_connect.ts @@ -3,11 +3,11 @@ /* eslint-disable */ // @ts-nocheck -import { BatchGetNotificationsRequest, BatchGetNotificationsResponse, DismissAllNotificationsRequest, DismissAllNotificationsResponse, DismissNotificationRequest, DismissNotificationResponse, GetNotificationRequest, GetNotificationResponse, HasNotificationsRequest, HasNotificationsResponse, ListNotificationsRequest, ListNotificationsResponse, ListRoomNotificationCountsRequest, ListRoomNotificationCountsResponse, ListRoomNotificationsRequest, ListRoomNotificationsResponse } from "./notifications_pb.js"; +import { DeleteAllNotificationOccurrencesRequest, DeleteAllNotificationOccurrencesResponse, DeleteNotificationGroupRequest, DeleteNotificationGroupResponse, DeleteNotificationOccurrenceRequest, DeleteNotificationOccurrenceResponse, GetNotificationPolicyRequest, GetNotificationPolicyResponse, ListNotificationGroupsRequest, ListNotificationGroupsResponse, MarkNotificationReadRequest, MarkNotificationReadResponse, SetNotificationPolicyPreferenceRequest, SetNotificationPolicyPreferenceResponse } from "./notifications_pb.js"; import { MethodIdempotency, MethodKind } from "@bufbuild/protobuf"; /** - * Reads and dismisses pending notifications for the authenticated viewer. + * Reads, marks read, and deletes notifications for the authenticated viewer. * * @generated from service chatto.api.v1.NotificationService */ @@ -15,95 +15,90 @@ export const NotificationService = { typeName: "chatto.api.v1.NotificationService", methods: { /** - * Lists the authenticated viewer's pending notifications. + * Lists Notifications 2.0 groups. * - * @generated from rpc chatto.api.v1.NotificationService.ListNotifications + * @generated from rpc chatto.api.v1.NotificationService.ListNotificationGroups */ - listNotifications: { - name: "ListNotifications", - I: ListNotificationsRequest, - O: ListNotificationsResponse, + listNotificationGroups: { + name: "ListNotificationGroups", + I: ListNotificationGroupsRequest, + O: ListNotificationGroupsResponse, kind: MethodKind.Unary, }, /** - * Gets one pending notification. Returns NOT_FOUND when the notification is - * unknown or has been dismissed. + * Marks one occurrence read. Repeating the call is safe. * - * @generated from rpc chatto.api.v1.NotificationService.GetNotification + * @generated from rpc chatto.api.v1.NotificationService.MarkNotificationRead */ - getNotification: { - name: "GetNotification", - I: GetNotificationRequest, - O: GetNotificationResponse, + markNotificationRead: { + name: "MarkNotificationRead", + I: MarkNotificationReadRequest, + O: MarkNotificationReadResponse, kind: MethodKind.Unary, + idempotency: MethodIdempotency.Idempotent, }, /** - * Gets pending notifications by ID. + * Permanently deletes one occurrence while retaining its anti-recreation + * tombstone through the original expiry. Repeating the call is safe. * - * @generated from rpc chatto.api.v1.NotificationService.BatchGetNotifications + * @generated from rpc chatto.api.v1.NotificationService.DeleteNotificationOccurrence */ - batchGetNotifications: { - name: "BatchGetNotifications", - I: BatchGetNotificationsRequest, - O: BatchGetNotificationsResponse, + deleteNotificationOccurrence: { + name: "DeleteNotificationOccurrence", + I: DeleteNotificationOccurrenceRequest, + O: DeleteNotificationOccurrenceResponse, kind: MethodKind.Unary, + idempotency: MethodIdempotency.Idempotent, }, /** - * Lists pending notifications for one room. Non-members receive an empty page. + * Permanently deletes occurrences currently belonging to one derived group. + * Group membership is captured when the request is handled. Callers must not + * retry this mutation automatically because later activity may reuse the + * same derived group ID. * - * @generated from rpc chatto.api.v1.NotificationService.ListRoomNotifications + * @generated from rpc chatto.api.v1.NotificationService.DeleteNotificationGroup */ - listRoomNotifications: { - name: "ListRoomNotifications", - I: ListRoomNotificationsRequest, - O: ListRoomNotificationsResponse, + deleteNotificationGroup: { + name: "DeleteNotificationGroup", + I: DeleteNotificationGroupRequest, + O: DeleteNotificationGroupResponse, kind: MethodKind.Unary, }, /** - * Lists pending notification counts grouped by room as a finite snapshot. + * Permanently deletes every occurrence current at the server's authoritative + * mutation boundary. Callers must not retry this mutation automatically + * because later activity may arrive after that boundary. * - * @generated from rpc chatto.api.v1.NotificationService.ListRoomNotificationCounts + * @generated from rpc chatto.api.v1.NotificationService.DeleteAllNotificationOccurrences */ - listRoomNotificationCounts: { - name: "ListRoomNotificationCounts", - I: ListRoomNotificationCountsRequest, - O: ListRoomNotificationCountsResponse, + deleteAllNotificationOccurrences: { + name: "DeleteAllNotificationOccurrences", + I: DeleteAllNotificationOccurrencesRequest, + O: DeleteAllNotificationOccurrencesResponse, kind: MethodKind.Unary, }, /** - * Checks whether the authenticated viewer has any pending notifications. + * Gets every supported cause and its effective inherited delivery intensity. * - * @generated from rpc chatto.api.v1.NotificationService.HasNotifications + * @generated from rpc chatto.api.v1.NotificationService.GetNotificationPolicy */ - hasNotifications: { - name: "HasNotifications", - I: HasNotificationsRequest, - O: HasNotificationsResponse, + getNotificationPolicy: { + name: "GetNotificationPolicy", + I: GetNotificationPolicyRequest, + O: GetNotificationPolicyResponse, kind: MethodKind.Unary, }, /** - * Dismisses one pending notification. Already-dismissed notifications are - * treated as idempotent success. + * Sets or clears one server- or room-scoped cause override. * - * @generated from rpc chatto.api.v1.NotificationService.DismissNotification + * @generated from rpc chatto.api.v1.NotificationService.SetNotificationPolicyPreference */ - dismissNotification: { - name: "DismissNotification", - I: DismissNotificationRequest, - O: DismissNotificationResponse, + setNotificationPolicyPreference: { + name: "SetNotificationPolicyPreference", + I: SetNotificationPolicyPreferenceRequest, + O: SetNotificationPolicyPreferenceResponse, kind: MethodKind.Unary, idempotency: MethodIdempotency.Idempotent, }, - /** - * Dismisses all pending notifications. - * - * @generated from rpc chatto.api.v1.NotificationService.DismissAllNotifications - */ - dismissAllNotifications: { - name: "DismissAllNotifications", - I: DismissAllNotificationsRequest, - O: DismissAllNotificationsResponse, - kind: MethodKind.Unary, - }, } } as const; diff --git a/packages/api-types/src/chatto/api/v1/notifications_pb.ts b/packages/api-types/src/chatto/api/v1/notifications_pb.ts index 06286cae6..043a45168 100644 --- a/packages/api-types/src/chatto/api/v1/notifications_pb.ts +++ b/packages/api-types/src/chatto/api/v1/notifications_pb.ts @@ -10,1026 +10,1224 @@ import { User } from "./users_pb.js"; import { PageInfo, PageRequest } from "./pagination_pb.js"; /** - * Direct-message notification payload. + * Why source activity matched the authenticated viewer's notification policy. * - * @generated from message chatto.api.v1.DirectMessageNotification + * @generated from enum chatto.api.v1.NotificationReason */ -export class DirectMessageNotification extends Message { +export enum NotificationReason { /** - * Message event ID. + * No cause was specified. This value is not valid in preference writes. * - * @generated from field: string event_id = 2; + * @generated from enum value: NOTIFICATION_REASON_UNSPECIFIED = 0; */ - eventId = ""; + UNSPECIFIED = 0, /** - * DM room where the message was posted. + * A message was posted in a direct-message conversation. * - * @generated from field: chatto.api.v1.RoomSummary room = 3; + * @generated from enum value: NOTIFICATION_REASON_DIRECT_MESSAGE = 1; */ - room?: RoomSummary; + DIRECT_MESSAGE = 1, - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } + /** + * The viewer's username was mentioned directly. + * + * @generated from enum value: NOTIFICATION_REASON_DIRECT_MENTION = 2; + */ + DIRECT_MENTION = 2, - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.DirectMessageNotification"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 2, name: "event_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "room", kind: "message", T: RoomSummary }, - ]); + /** + * Activity replied directly to the viewer's message. + * + * @generated from enum value: NOTIFICATION_REASON_REPLY = 3; + */ + REPLY = 3, - static fromBinary(bytes: Uint8Array, options?: Partial): DirectMessageNotification { - return new DirectMessageNotification().fromBinary(bytes, options); - } + /** + * A role held by the viewer was mentioned. + * + * @generated from enum value: NOTIFICATION_REASON_ROLE_MENTION = 4; + */ + ROLE_MENTION = 4, - static fromJson(jsonValue: JsonValue, options?: Partial): DirectMessageNotification { - return new DirectMessageNotification().fromJson(jsonValue, options); - } + /** + * An `@here` mention included the viewer. + * + * @generated from enum value: NOTIFICATION_REASON_HERE = 5; + */ + HERE = 5, - static fromJsonString(jsonString: string, options?: Partial): DirectMessageNotification { - return new DirectMessageNotification().fromJsonString(jsonString, options); - } + /** + * An `@all` mention included the viewer. + * + * @generated from enum value: NOTIFICATION_REASON_ALL = 6; + */ + ALL = 6, - static equals(a: DirectMessageNotification | PlainMessage | undefined, b: DirectMessageNotification | PlainMessage | undefined): boolean { - return proto3.util.equals(DirectMessageNotification, a, b); - } + /** + * New activity appeared in a thread followed by the viewer. + * + * @generated from enum value: NOTIFICATION_REASON_FOLLOWED_THREAD = 7; + */ + FOLLOWED_THREAD = 7, + + /** + * New activity appeared in a room followed by the viewer. + * + * @generated from enum value: NOTIFICATION_REASON_FOLLOWED_ROOM = 8; + */ + FOLLOWED_ROOM = 8, + + /** + * Someone reacted to the viewer's message. + * + * @generated from enum value: NOTIFICATION_REASON_REACTION = 9; + */ + REACTION = 9, + + /** + * The viewer was invited to a room. + * + * @generated from enum value: NOTIFICATION_REASON_ROOM_INVITATION = 10; + */ + ROOM_INVITATION = 10, } +// Retrieve enum metadata with: proto3.getEnumType(NotificationReason) +proto3.util.setEnumType(NotificationReason, "chatto.api.v1.NotificationReason", [ + { no: 0, name: "NOTIFICATION_REASON_UNSPECIFIED" }, + { no: 1, name: "NOTIFICATION_REASON_DIRECT_MESSAGE" }, + { no: 2, name: "NOTIFICATION_REASON_DIRECT_MENTION" }, + { no: 3, name: "NOTIFICATION_REASON_REPLY" }, + { no: 4, name: "NOTIFICATION_REASON_ROLE_MENTION" }, + { no: 5, name: "NOTIFICATION_REASON_HERE" }, + { no: 6, name: "NOTIFICATION_REASON_ALL" }, + { no: 7, name: "NOTIFICATION_REASON_FOLLOWED_THREAD" }, + { no: 8, name: "NOTIFICATION_REASON_FOLLOWED_ROOM" }, + { no: 9, name: "NOTIFICATION_REASON_REACTION" }, + { no: 10, name: "NOTIFICATION_REASON_ROOM_INVITATION" }, +]); /** - * Mention notification payload. + * Delivery strength for one notification cause. * - * @generated from message chatto.api.v1.MentionNotification + * @generated from enum chatto.api.v1.NotificationDeliveryIntensity */ -export class MentionNotification extends Message { +export enum NotificationDeliveryIntensity { /** - * Room where the mention occurred. + * In preference writes, unspecified clears the override (Inherit). * - * @generated from field: chatto.api.v1.RoomSummary room = 1; + * @generated from enum value: NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED = 0; */ - room?: RoomSummary; + UNSPECIFIED = 0, /** - * Message event ID. + * Matching activity does not create a notification occurrence. * - * @generated from field: string event_id = 2; + * @generated from enum value: NOTIFICATION_DELIVERY_INTENSITY_OFF = 1; */ - eventId = ""; + OFF = 1, /** - * Thread root event ID when the mention happened inside a thread. + * Matching activity appears in the notification list without interruptive delivery. * - * @generated from field: optional string thread_root_event_id = 3; + * @generated from enum value: NOTIFICATION_DELIVERY_INTENSITY_BADGE = 2; */ - threadRootEventId?: string; + BADGE = 2, - constructor(data?: PartialMessage) { + /** + * Matching activity appears in the notification list and may trigger sound or push. + * + * @generated from enum value: NOTIFICATION_DELIVERY_INTENSITY_ALERT = 3; + */ + ALERT = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(NotificationDeliveryIntensity) +proto3.util.setEnumType(NotificationDeliveryIntensity, "chatto.api.v1.NotificationDeliveryIntensity", [ + { no: 0, name: "NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED" }, + { no: 1, name: "NOTIFICATION_DELIVERY_INTENSITY_OFF" }, + { no: 2, name: "NOTIFICATION_DELIVERY_INTENSITY_BADGE" }, + { no: 3, name: "NOTIFICATION_DELIVERY_INTENSITY_ALERT" }, +]); + +/** + * One cause that matched an occurrence and its evaluated delivery intensity. + * + * @generated from message chatto.api.v1.NotificationReasonMatch + */ +export class NotificationReasonMatch extends Message { + /** + * Cause that matched the viewer. + * + * @generated from field: chatto.api.v1.NotificationReason reason = 1; + */ + reason = NotificationReason.UNSPECIFIED; + + /** + * Effective intensity when the source activity occurred. + * + * @generated from field: chatto.api.v1.NotificationDeliveryIntensity intensity = 2; + */ + intensity = NotificationDeliveryIntensity.UNSPECIFIED; + + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.MentionNotification"; + static readonly typeName = "chatto.api.v1.NotificationReasonMatch"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "room", kind: "message", T: RoomSummary }, - { no: 2, name: "event_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "thread_root_event_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + { no: 1, name: "reason", kind: "enum", T: proto3.getEnumType(NotificationReason) }, + { no: 2, name: "intensity", kind: "enum", T: proto3.getEnumType(NotificationDeliveryIntensity) }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): MentionNotification { - return new MentionNotification().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): NotificationReasonMatch { + return new NotificationReasonMatch().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): MentionNotification { - return new MentionNotification().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationReasonMatch { + return new NotificationReasonMatch().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): MentionNotification { - return new MentionNotification().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): NotificationReasonMatch { + return new NotificationReasonMatch().fromJsonString(jsonString, options); } - static equals(a: MentionNotification | PlainMessage | undefined, b: MentionNotification | PlainMessage | undefined): boolean { - return proto3.util.equals(MentionNotification, a, b); + static equals(a: NotificationReasonMatch | PlainMessage | undefined, b: NotificationReasonMatch | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationReasonMatch, a, b); } } /** - * Reply notification payload. + * Exact visible destination of one notification occurrence. * - * @generated from message chatto.api.v1.ReplyNotification + * @generated from message chatto.api.v1.NotificationTarget */ -export class ReplyNotification extends Message { +export class NotificationTarget extends Message { /** - * Room where the reply occurred. + * Room containing the source activity. * * @generated from field: chatto.api.v1.RoomSummary room = 1; */ room?: RoomSummary; /** - * Reply event ID. + * Exact source or reacted-to message event to reveal. * * @generated from field: string event_id = 2; */ eventId = ""; /** - * Event ID of the message being replied to. + * Thread root when the target is inside a thread. * - * @generated from field: string in_reply_to_id = 3; + * @generated from field: optional string thread_root_event_id = 3; */ - inReplyToId = ""; + threadRootEventId?: string; /** - * Thread root event ID when the reply happened inside a thread. + * Direct reply target when the occurrence was caused by a reply. * - * @generated from field: optional string thread_root_event_id = 4; + * @generated from field: optional string parent_event_id = 4; */ - threadRootEventId?: string; + parentEventId?: string; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.ReplyNotification"; + static readonly typeName = "chatto.api.v1.NotificationTarget"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "room", kind: "message", T: RoomSummary }, { no: 2, name: "event_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "in_reply_to_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 4, name: "thread_root_event_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + { no: 3, name: "thread_root_event_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + { no: 4, name: "parent_event_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): ReplyNotification { - return new ReplyNotification().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): NotificationTarget { + return new NotificationTarget().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): ReplyNotification { - return new ReplyNotification().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationTarget { + return new NotificationTarget().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): ReplyNotification { - return new ReplyNotification().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): NotificationTarget { + return new NotificationTarget().fromJsonString(jsonString, options); } - static equals(a: ReplyNotification | PlainMessage | undefined, b: ReplyNotification | PlainMessage | undefined): boolean { - return proto3.util.equals(ReplyNotification, a, b); + static equals(a: NotificationTarget | PlainMessage | undefined, b: NotificationTarget | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationTarget, a, b); } } /** - * All-messages room notification payload. + * One exact Notifications 2.0 source occurrence. * - * @generated from message chatto.api.v1.RoomMessageNotification + * @generated from message chatto.api.v1.NotificationOccurrence */ -export class RoomMessageNotification extends Message { +export class NotificationOccurrence extends Message { /** - * Room where the message was posted. + * Stable occurrence ID. * - * @generated from field: chatto.api.v1.RoomSummary room = 1; + * @generated from field: string id = 1; */ - room?: RoomSummary; + id = ""; /** - * Message event ID. + * Durable source event from which this occurrence was derived. * - * @generated from field: string event_id = 2; + * @generated from field: string source_event_id = 2; */ - eventId = ""; + sourceEventId = ""; - constructor(data?: PartialMessage) { + /** + * Time of the source activity. + * + * @generated from field: google.protobuf.Timestamp created_at = 3; + */ + createdAt?: Timestamp; + + /** + * User who caused the source activity, when still visible. + * + * @generated from field: chatto.api.v1.User actor = 4; + */ + actor?: User; + + /** + * Exact current destination for navigation. + * + * @generated from field: chatto.api.v1.NotificationTarget target = 5; + */ + target?: NotificationTarget; + + /** + * Every cause that matched when the source activity occurred. + * + * @generated from field: repeated chatto.api.v1.NotificationReasonMatch reasons = 6; + */ + reasons: NotificationReasonMatch[] = []; + + /** + * Strongest evaluated intensity across all matching causes. + * + * @generated from field: chatto.api.v1.NotificationDeliveryIntensity strongest_intensity = 7; + */ + strongestIntensity = NotificationDeliveryIntensity.UNSPECIFIED; + + /** + * Whether this occurrence still contributes unread attention. + * + * @generated from field: bool unread = 8; + */ + unread = false; + + /** + * Absolute expiry, 90 days after the source activity. + * + * @generated from field: google.protobuf.Timestamp expires_at = 10; + */ + expiresAt?: Timestamp; + + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.RoomMessageNotification"; + static readonly typeName = "chatto.api.v1.NotificationOccurrence"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "room", kind: "message", T: RoomSummary }, - { no: 2, name: "event_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "source_event_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 3, name: "created_at", kind: "message", T: Timestamp }, + { no: 4, name: "actor", kind: "message", T: User }, + { no: 5, name: "target", kind: "message", T: NotificationTarget }, + { no: 6, name: "reasons", kind: "message", T: NotificationReasonMatch, repeated: true }, + { no: 7, name: "strongest_intensity", kind: "enum", T: proto3.getEnumType(NotificationDeliveryIntensity) }, + { no: 8, name: "unread", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 10, name: "expires_at", kind: "message", T: Timestamp }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): RoomMessageNotification { - return new RoomMessageNotification().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): NotificationOccurrence { + return new NotificationOccurrence().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): RoomMessageNotification { - return new RoomMessageNotification().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationOccurrence { + return new NotificationOccurrence().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): RoomMessageNotification { - return new RoomMessageNotification().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): NotificationOccurrence { + return new NotificationOccurrence().fromJsonString(jsonString, options); } - static equals(a: RoomMessageNotification | PlainMessage | undefined, b: RoomMessageNotification | PlainMessage | undefined): boolean { - return proto3.util.equals(RoomMessageNotification, a, b); + static equals(a: NotificationOccurrence | PlainMessage | undefined, b: NotificationOccurrence | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationOccurrence, a, b); } } /** - * One pending notification for the authenticated viewer. + * A presentation group derived from related notification occurrences. * - * @generated from message chatto.api.v1.NotificationItem + * @generated from message chatto.api.v1.NotificationGroup */ -export class NotificationItem extends Message { +export class NotificationGroup extends Message { /** - * Stable notification ID. + * Stable ID derived from the viewer and grouping target. * * @generated from field: string id = 1; */ id = ""; /** - * Creation time. + * Bounded newest-occurrence preview. It also includes the open target when + * that target falls outside the newest preview window. * - * @generated from field: google.protobuf.Timestamp created_at = 2; + * @generated from field: repeated chatto.api.v1.NotificationOccurrence occurrences = 2; */ - createdAt?: Timestamp; + occurrences: NotificationOccurrence[] = []; /** - * User who triggered the notification, when still resolvable. + * Target to open: newest unread, or newest when all are read. * - * @generated from field: chatto.api.v1.User actor = 3; + * @generated from field: chatto.api.v1.NotificationTarget open_target = 3; */ - actor?: User; + openTarget?: NotificationTarget; + + /** + * True when at least one member occurrence is unread. + * + * @generated from field: bool unread = 4; + */ + unread = false; + + /** + * Total number of occurrences in this group, including those not in the + * bounded preview. + * + * @generated from field: int32 occurrence_count = 5; + */ + occurrenceCount = 0; + + /** + * Time of the newest occurrence. + * + * @generated from field: google.protobuf.Timestamp latest_at = 6; + */ + latestAt?: Timestamp; + + /** + * Strongest intensity among member occurrences. + * + * @generated from field: chatto.api.v1.NotificationDeliveryIntensity strongest_intensity = 7; + */ + strongestIntensity = NotificationDeliveryIntensity.UNSPECIFIED; + + /** + * Distinct causes represented by member occurrences. + * + * @generated from field: repeated chatto.api.v1.NotificationReason reasons = 8; + */ + reasons: NotificationReason[] = []; /** - * @generated from oneof chatto.api.v1.NotificationItem.kind - */ - kind: { - /** - * Direct-message notification. - * - * @generated from field: chatto.api.v1.DirectMessageNotification direct_message = 10; - */ - value: DirectMessageNotification; - case: "directMessage"; - } | { - /** - * Mention notification. - * - * @generated from field: chatto.api.v1.MentionNotification mention = 11; - */ - value: MentionNotification; - case: "mention"; - } | { - /** - * Reply notification. - * - * @generated from field: chatto.api.v1.ReplyNotification reply = 12; - */ - value: ReplyNotification; - case: "reply"; - } | { - /** - * All-messages room notification. - * - * @generated from field: chatto.api.v1.RoomMessageNotification room_message = 13; - */ - value: RoomMessageNotification; - case: "roomMessage"; - } | { case: undefined; value?: undefined } = { case: undefined }; - - constructor(data?: PartialMessage) { + * Earliest member expiry. Clients refresh the group at this boundary. + * + * @generated from field: google.protobuf.Timestamp next_expiry_at = 11; + */ + nextExpiryAt?: Timestamp; + + /** + * Occurrence ID corresponding to open_target, including when several + * occurrences share the same message target. + * + * @generated from field: string open_notification_id = 12; + */ + openNotificationId = ""; + + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.NotificationItem"; + static readonly typeName = "chatto.api.v1.NotificationGroup"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "created_at", kind: "message", T: Timestamp }, - { no: 3, name: "actor", kind: "message", T: User }, - { no: 10, name: "direct_message", kind: "message", T: DirectMessageNotification, oneof: "kind" }, - { no: 11, name: "mention", kind: "message", T: MentionNotification, oneof: "kind" }, - { no: 12, name: "reply", kind: "message", T: ReplyNotification, oneof: "kind" }, - { no: 13, name: "room_message", kind: "message", T: RoomMessageNotification, oneof: "kind" }, + { no: 2, name: "occurrences", kind: "message", T: NotificationOccurrence, repeated: true }, + { no: 3, name: "open_target", kind: "message", T: NotificationTarget }, + { no: 4, name: "unread", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 5, name: "occurrence_count", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 6, name: "latest_at", kind: "message", T: Timestamp }, + { no: 7, name: "strongest_intensity", kind: "enum", T: proto3.getEnumType(NotificationDeliveryIntensity) }, + { no: 8, name: "reasons", kind: "enum", T: proto3.getEnumType(NotificationReason), repeated: true }, + { no: 11, name: "next_expiry_at", kind: "message", T: Timestamp }, + { no: 12, name: "open_notification_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): NotificationItem { - return new NotificationItem().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): NotificationGroup { + return new NotificationGroup().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): NotificationItem { - return new NotificationItem().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationGroup { + return new NotificationGroup().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): NotificationItem { - return new NotificationItem().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): NotificationGroup { + return new NotificationGroup().fromJsonString(jsonString, options); } - static equals(a: NotificationItem | PlainMessage | undefined, b: NotificationItem | PlainMessage | undefined): boolean { - return proto3.util.equals(NotificationItem, a, b); + static equals(a: NotificationGroup | PlainMessage | undefined, b: NotificationGroup | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationGroup, a, b); } } /** - * Request for the authenticated viewer's pending notifications. + * Request for one page of grouped notification occurrences. * - * @generated from message chatto.api.v1.ListNotificationsRequest + * @generated from message chatto.api.v1.ListNotificationGroupsRequest */ -export class ListNotificationsRequest extends Message { +export class ListNotificationGroupsRequest extends Message { /** * Page request. Defaults to 50 results when absent or limit is zero. * - * @generated from field: chatto.api.v1.PageRequest page = 3; + * @generated from field: chatto.api.v1.PageRequest page = 1; */ page?: PageRequest; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.ListNotificationsRequest"; + static readonly typeName = "chatto.api.v1.ListNotificationGroupsRequest"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 3, name: "page", kind: "message", T: PageRequest }, + { no: 1, name: "page", kind: "message", T: PageRequest }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): ListNotificationsRequest { - return new ListNotificationsRequest().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): ListNotificationGroupsRequest { + return new ListNotificationGroupsRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): ListNotificationsRequest { - return new ListNotificationsRequest().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): ListNotificationGroupsRequest { + return new ListNotificationGroupsRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): ListNotificationsRequest { - return new ListNotificationsRequest().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): ListNotificationGroupsRequest { + return new ListNotificationGroupsRequest().fromJsonString(jsonString, options); } - static equals(a: ListNotificationsRequest | PlainMessage | undefined, b: ListNotificationsRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(ListNotificationsRequest, a, b); + static equals(a: ListNotificationGroupsRequest | PlainMessage | undefined, b: ListNotificationGroupsRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ListNotificationGroupsRequest, a, b); } } /** - * Request for pending notifications scoped to one room. + * One page of derived notification groups. * - * @generated from message chatto.api.v1.ListRoomNotificationsRequest + * @generated from message chatto.api.v1.ListNotificationGroupsResponse */ -export class ListRoomNotificationsRequest extends Message { +export class ListNotificationGroupsResponse extends Message { /** - * Required. Room whose notifications should be listed. + * Groups newest activity first. * - * @generated from field: string room_id = 1; + * @generated from field: repeated chatto.api.v1.NotificationGroup groups = 1; */ - roomId = ""; + groups: NotificationGroup[] = []; /** - * Page request. Defaults to 50 results when absent or limit is zero. + * Page metadata. * - * @generated from field: chatto.api.v1.PageRequest page = 4; + * @generated from field: chatto.api.v1.PageInfo page = 2; */ - page?: PageRequest; + page?: PageInfo; + + /** + * Total unread group count. + * + * @generated from field: int32 unread_group_count = 3; + */ + unreadGroupCount = 0; + + /** + * Earliest expiry in the complete list, including groups outside this page. + * Clients refresh authoritative notification state at this boundary. + * + * @generated from field: google.protobuf.Timestamp next_expiry_at = 4; + */ + nextExpiryAt?: Timestamp; - constructor(data?: PartialMessage) { + /** + * Complete unread-group counts by target room. This is independent of the + * bounded group page so room indicators remain authoritative. + * + * @generated from field: repeated chatto.api.v1.NotificationRoomUnreadGroupCount room_unread_group_counts = 5; + */ + roomUnreadGroupCounts: NotificationRoomUnreadGroupCount[] = []; + + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.ListRoomNotificationsRequest"; + static readonly typeName = "chatto.api.v1.ListNotificationGroupsResponse"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 4, name: "page", kind: "message", T: PageRequest }, + { no: 1, name: "groups", kind: "message", T: NotificationGroup, repeated: true }, + { no: 2, name: "page", kind: "message", T: PageInfo }, + { no: 3, name: "unread_group_count", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 4, name: "next_expiry_at", kind: "message", T: Timestamp }, + { no: 5, name: "room_unread_group_counts", kind: "message", T: NotificationRoomUnreadGroupCount, repeated: true }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): ListRoomNotificationsRequest { - return new ListRoomNotificationsRequest().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): ListNotificationGroupsResponse { + return new ListNotificationGroupsResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): ListRoomNotificationsRequest { - return new ListRoomNotificationsRequest().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): ListNotificationGroupsResponse { + return new ListNotificationGroupsResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): ListRoomNotificationsRequest { - return new ListRoomNotificationsRequest().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): ListNotificationGroupsResponse { + return new ListNotificationGroupsResponse().fromJsonString(jsonString, options); } - static equals(a: ListRoomNotificationsRequest | PlainMessage | undefined, b: ListRoomNotificationsRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(ListRoomNotificationsRequest, a, b); + static equals(a: ListNotificationGroupsResponse | PlainMessage | undefined, b: ListNotificationGroupsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListNotificationGroupsResponse, a, b); } } /** - * Pending notification page. + * Unread notification groups currently targeting one room. * - * @generated from message chatto.api.v1.ListNotificationsResponse + * @generated from message chatto.api.v1.NotificationRoomUnreadGroupCount */ -export class ListNotificationsResponse extends Message { +export class NotificationRoomUnreadGroupCount extends Message { /** - * Page notifications, newest first. + * Target room ID. * - * @generated from field: repeated chatto.api.v1.NotificationItem notifications = 1; + * @generated from field: string room_id = 1; */ - notifications: NotificationItem[] = []; + roomId = ""; /** - * Page metadata. + * Number of unread groups targeting this room. * - * @generated from field: chatto.api.v1.PageInfo page = 5; + * @generated from field: int32 unread_group_count = 2; */ - page?: PageInfo; + unreadGroupCount = 0; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.ListNotificationsResponse"; + static readonly typeName = "chatto.api.v1.NotificationRoomUnreadGroupCount"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "notifications", kind: "message", T: NotificationItem, repeated: true }, - { no: 5, name: "page", kind: "message", T: PageInfo }, + { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "unread_group_count", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): ListNotificationsResponse { - return new ListNotificationsResponse().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): NotificationRoomUnreadGroupCount { + return new NotificationRoomUnreadGroupCount().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): ListNotificationsResponse { - return new ListNotificationsResponse().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationRoomUnreadGroupCount { + return new NotificationRoomUnreadGroupCount().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): ListNotificationsResponse { - return new ListNotificationsResponse().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): NotificationRoomUnreadGroupCount { + return new NotificationRoomUnreadGroupCount().fromJsonString(jsonString, options); } - static equals(a: ListNotificationsResponse | PlainMessage | undefined, b: ListNotificationsResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(ListNotificationsResponse, a, b); + static equals(a: NotificationRoomUnreadGroupCount | PlainMessage | undefined, b: NotificationRoomUnreadGroupCount | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationRoomUnreadGroupCount, a, b); } } /** - * Request one pending notification for the authenticated viewer. + * Request to mark one notification occurrence read. * - * @generated from message chatto.api.v1.GetNotificationRequest + * @generated from message chatto.api.v1.MarkNotificationReadRequest */ -export class GetNotificationRequest extends Message { +export class MarkNotificationReadRequest extends Message { /** - * Required notification ID. + * Required stable occurrence ID. * * @generated from field: string notification_id = 1; */ notificationId = ""; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.GetNotificationRequest"; + static readonly typeName = "chatto.api.v1.MarkNotificationReadRequest"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "notification_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): GetNotificationRequest { - return new GetNotificationRequest().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): MarkNotificationReadRequest { + return new MarkNotificationReadRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): GetNotificationRequest { - return new GetNotificationRequest().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): MarkNotificationReadRequest { + return new MarkNotificationReadRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): GetNotificationRequest { - return new GetNotificationRequest().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): MarkNotificationReadRequest { + return new MarkNotificationReadRequest().fromJsonString(jsonString, options); } - static equals(a: GetNotificationRequest | PlainMessage | undefined, b: GetNotificationRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(GetNotificationRequest, a, b); + static equals(a: MarkNotificationReadRequest | PlainMessage | undefined, b: MarkNotificationReadRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(MarkNotificationReadRequest, a, b); } } /** - * Pending notification response. + * Notification occurrence after marking it read. * - * @generated from message chatto.api.v1.GetNotificationResponse + * @generated from message chatto.api.v1.MarkNotificationReadResponse */ -export class GetNotificationResponse extends Message { +export class MarkNotificationReadResponse extends Message { /** - * Requested notification. + * Updated occurrence. * - * @generated from field: chatto.api.v1.NotificationItem notification = 1; + * @generated from field: chatto.api.v1.NotificationOccurrence notification = 1; */ - notification?: NotificationItem; + notification?: NotificationOccurrence; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.GetNotificationResponse"; + static readonly typeName = "chatto.api.v1.MarkNotificationReadResponse"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "notification", kind: "message", T: NotificationItem }, + { no: 1, name: "notification", kind: "message", T: NotificationOccurrence }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): GetNotificationResponse { - return new GetNotificationResponse().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): MarkNotificationReadResponse { + return new MarkNotificationReadResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): GetNotificationResponse { - return new GetNotificationResponse().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): MarkNotificationReadResponse { + return new MarkNotificationReadResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): GetNotificationResponse { - return new GetNotificationResponse().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): MarkNotificationReadResponse { + return new MarkNotificationReadResponse().fromJsonString(jsonString, options); } - static equals(a: GetNotificationResponse | PlainMessage | undefined, b: GetNotificationResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(GetNotificationResponse, a, b); + static equals(a: MarkNotificationReadResponse | PlainMessage | undefined, b: MarkNotificationReadResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(MarkNotificationReadResponse, a, b); } } /** - * Request pending notifications for a set of stable notification IDs. + * Request permanent deletion of one notification occurrence. * - * @generated from message chatto.api.v1.BatchGetNotificationsRequest + * @generated from message chatto.api.v1.DeleteNotificationOccurrenceRequest */ -export class BatchGetNotificationsRequest extends Message { +export class DeleteNotificationOccurrenceRequest extends Message { /** - * Required notification IDs. Unknown or dismissed IDs are omitted from the - * response. + * Required stable occurrence ID. * - * @generated from field: repeated string notification_ids = 1; + * @generated from field: string notification_id = 1; */ - notificationIds: string[] = []; + notificationId = ""; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.BatchGetNotificationsRequest"; + static readonly typeName = "chatto.api.v1.DeleteNotificationOccurrenceRequest"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "notification_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, + { no: 1, name: "notification_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): BatchGetNotificationsRequest { - return new BatchGetNotificationsRequest().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): DeleteNotificationOccurrenceRequest { + return new DeleteNotificationOccurrenceRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): BatchGetNotificationsRequest { - return new BatchGetNotificationsRequest().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteNotificationOccurrenceRequest { + return new DeleteNotificationOccurrenceRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): BatchGetNotificationsRequest { - return new BatchGetNotificationsRequest().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): DeleteNotificationOccurrenceRequest { + return new DeleteNotificationOccurrenceRequest().fromJsonString(jsonString, options); } - static equals(a: BatchGetNotificationsRequest | PlainMessage | undefined, b: BatchGetNotificationsRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(BatchGetNotificationsRequest, a, b); + static equals(a: DeleteNotificationOccurrenceRequest | PlainMessage | undefined, b: DeleteNotificationOccurrenceRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(DeleteNotificationOccurrenceRequest, a, b); } } /** - * Batch pending notification response. + * Result of deleting one notification occurrence. * - * @generated from message chatto.api.v1.BatchGetNotificationsResponse + * @generated from message chatto.api.v1.DeleteNotificationOccurrenceResponse */ -export class BatchGetNotificationsResponse extends Message { +export class DeleteNotificationOccurrenceResponse extends Message { /** - * Found notifications. The server preserves first-seen request order and - * de-duplicates repeated IDs. + * True when this call replaced a visible occurrence with a deletion tombstone. + * False when the occurrence was already absent or deleted. * - * @generated from field: repeated chatto.api.v1.NotificationItem notifications = 1; + * @generated from field: bool deleted = 1; */ - notifications: NotificationItem[] = []; + deleted = false; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.BatchGetNotificationsResponse"; + static readonly typeName = "chatto.api.v1.DeleteNotificationOccurrenceResponse"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "notifications", kind: "message", T: NotificationItem, repeated: true }, + { no: 1, name: "deleted", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): BatchGetNotificationsResponse { - return new BatchGetNotificationsResponse().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): DeleteNotificationOccurrenceResponse { + return new DeleteNotificationOccurrenceResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): BatchGetNotificationsResponse { - return new BatchGetNotificationsResponse().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteNotificationOccurrenceResponse { + return new DeleteNotificationOccurrenceResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): BatchGetNotificationsResponse { - return new BatchGetNotificationsResponse().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): DeleteNotificationOccurrenceResponse { + return new DeleteNotificationOccurrenceResponse().fromJsonString(jsonString, options); } - static equals(a: BatchGetNotificationsResponse | PlainMessage | undefined, b: BatchGetNotificationsResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(BatchGetNotificationsResponse, a, b); + static equals(a: DeleteNotificationOccurrenceResponse | PlainMessage | undefined, b: DeleteNotificationOccurrenceResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(DeleteNotificationOccurrenceResponse, a, b); } } /** - * Pending notification page scoped to one room. + * Request permanent deletion of one derived notification group. * - * @generated from message chatto.api.v1.ListRoomNotificationsResponse + * @generated from message chatto.api.v1.DeleteNotificationGroupRequest */ -export class ListRoomNotificationsResponse extends Message { +export class DeleteNotificationGroupRequest extends Message { /** - * Page notifications, newest first. + * Required stable group ID. * - * @generated from field: repeated chatto.api.v1.NotificationItem notifications = 1; + * @generated from field: string group_id = 1; */ - notifications: NotificationItem[] = []; + groupId = ""; - /** - * Page metadata. - * - * @generated from field: chatto.api.v1.PageInfo page = 5; - */ - page?: PageInfo; - - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.ListRoomNotificationsResponse"; + static readonly typeName = "chatto.api.v1.DeleteNotificationGroupRequest"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "notifications", kind: "message", T: NotificationItem, repeated: true }, - { no: 5, name: "page", kind: "message", T: PageInfo }, + { no: 1, name: "group_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): ListRoomNotificationsResponse { - return new ListRoomNotificationsResponse().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): DeleteNotificationGroupRequest { + return new DeleteNotificationGroupRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): ListRoomNotificationsResponse { - return new ListRoomNotificationsResponse().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteNotificationGroupRequest { + return new DeleteNotificationGroupRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): ListRoomNotificationsResponse { - return new ListRoomNotificationsResponse().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): DeleteNotificationGroupRequest { + return new DeleteNotificationGroupRequest().fromJsonString(jsonString, options); } - static equals(a: ListRoomNotificationsResponse | PlainMessage | undefined, b: ListRoomNotificationsResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(ListRoomNotificationsResponse, a, b); + static equals(a: DeleteNotificationGroupRequest | PlainMessage | undefined, b: DeleteNotificationGroupRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(DeleteNotificationGroupRequest, a, b); } } /** - * Request for a lightweight pending notification check. + * Result of deleting one derived notification group. * - * @generated from message chatto.api.v1.HasNotificationsRequest + * @generated from message chatto.api.v1.DeleteNotificationGroupResponse */ -export class HasNotificationsRequest extends Message { - constructor(data?: PartialMessage) { +export class DeleteNotificationGroupResponse extends Message { + /** + * Number of visible occurrences replaced by deletion tombstones. + * + * @generated from field: int32 deleted_count = 1; + */ + deletedCount = 0; + + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.HasNotificationsRequest"; + static readonly typeName = "chatto.api.v1.DeleteNotificationGroupResponse"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "deleted_count", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): HasNotificationsRequest { - return new HasNotificationsRequest().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): DeleteNotificationGroupResponse { + return new DeleteNotificationGroupResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): HasNotificationsRequest { - return new HasNotificationsRequest().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteNotificationGroupResponse { + return new DeleteNotificationGroupResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): HasNotificationsRequest { - return new HasNotificationsRequest().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): DeleteNotificationGroupResponse { + return new DeleteNotificationGroupResponse().fromJsonString(jsonString, options); } - static equals(a: HasNotificationsRequest | PlainMessage | undefined, b: HasNotificationsRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(HasNotificationsRequest, a, b); + static equals(a: DeleteNotificationGroupResponse | PlainMessage | undefined, b: DeleteNotificationGroupResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(DeleteNotificationGroupResponse, a, b); } } /** - * Lightweight pending notification check. + * Request permanent deletion of every current notification occurrence. * - * @generated from message chatto.api.v1.HasNotificationsResponse + * @generated from message chatto.api.v1.DeleteAllNotificationOccurrencesRequest */ -export class HasNotificationsResponse extends Message { - /** - * True when the authenticated viewer has at least one pending notification. - * - * @generated from field: bool has_notifications = 1; - */ - hasNotifications = false; - - constructor(data?: PartialMessage) { +export class DeleteAllNotificationOccurrencesRequest extends Message { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.HasNotificationsResponse"; + static readonly typeName = "chatto.api.v1.DeleteAllNotificationOccurrencesRequest"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "has_notifications", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): HasNotificationsResponse { - return new HasNotificationsResponse().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): DeleteAllNotificationOccurrencesRequest { + return new DeleteAllNotificationOccurrencesRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): HasNotificationsResponse { - return new HasNotificationsResponse().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteAllNotificationOccurrencesRequest { + return new DeleteAllNotificationOccurrencesRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): HasNotificationsResponse { - return new HasNotificationsResponse().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): DeleteAllNotificationOccurrencesRequest { + return new DeleteAllNotificationOccurrencesRequest().fromJsonString(jsonString, options); } - static equals(a: HasNotificationsResponse | PlainMessage | undefined, b: HasNotificationsResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(HasNotificationsResponse, a, b); + static equals(a: DeleteAllNotificationOccurrencesRequest | PlainMessage | undefined, b: DeleteAllNotificationOccurrencesRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(DeleteAllNotificationOccurrencesRequest, a, b); } } /** - * Pending notification count for one room. + * Result of deleting every current notification occurrence. * - * @generated from message chatto.api.v1.RoomNotificationCount + * @generated from message chatto.api.v1.DeleteAllNotificationOccurrencesResponse */ -export class RoomNotificationCount extends Message { +export class DeleteAllNotificationOccurrencesResponse extends Message { /** - * Room ID. + * Number of current occurrences replaced by deletion tombstones. * - * @generated from field: string room_id = 1; + * @generated from field: int32 deleted_count = 1; */ - roomId = ""; + deletedCount = 0; - /** - * Pending notification count for the authenticated viewer in this room. - * - * @generated from field: int32 total_count = 2; - */ - totalCount = 0; - - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.RoomNotificationCount"; + static readonly typeName = "chatto.api.v1.DeleteAllNotificationOccurrencesResponse"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "total_count", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 1, name: "deleted_count", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): RoomNotificationCount { - return new RoomNotificationCount().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): DeleteAllNotificationOccurrencesResponse { + return new DeleteAllNotificationOccurrencesResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): RoomNotificationCount { - return new RoomNotificationCount().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteAllNotificationOccurrencesResponse { + return new DeleteAllNotificationOccurrencesResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): RoomNotificationCount { - return new RoomNotificationCount().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): DeleteAllNotificationOccurrencesResponse { + return new DeleteAllNotificationOccurrencesResponse().fromJsonString(jsonString, options); } - static equals(a: RoomNotificationCount | PlainMessage | undefined, b: RoomNotificationCount | PlainMessage | undefined): boolean { - return proto3.util.equals(RoomNotificationCount, a, b); + static equals(a: DeleteAllNotificationOccurrencesResponse | PlainMessage | undefined, b: DeleteAllNotificationOccurrencesResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(DeleteAllNotificationOccurrencesResponse, a, b); } } /** - * Request for pending notification counts grouped by room. + * Explicit and effective delivery policy for one notification cause. * - * @generated from message chatto.api.v1.ListRoomNotificationCountsRequest + * @generated from message chatto.api.v1.NotificationPolicyPreference */ -export class ListRoomNotificationCountsRequest extends Message { - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.ListRoomNotificationCountsRequest"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): ListRoomNotificationCountsRequest { - return new ListRoomNotificationCountsRequest().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): ListRoomNotificationCountsRequest { - return new ListRoomNotificationCountsRequest().fromJson(jsonValue, options); - } +export class NotificationPolicyPreference extends Message { + /** + * Notification cause controlled by this row. + * + * @generated from field: chatto.api.v1.NotificationReason reason = 1; + */ + reason = NotificationReason.UNSPECIFIED; - static fromJsonString(jsonString: string, options?: Partial): ListRoomNotificationCountsRequest { - return new ListRoomNotificationCountsRequest().fromJsonString(jsonString, options); - } + /** + * Explicit server override, or unspecified when inherited from product defaults. + * + * @generated from field: chatto.api.v1.NotificationDeliveryIntensity server_intensity = 2; + */ + serverIntensity = NotificationDeliveryIntensity.UNSPECIFIED; - static equals(a: ListRoomNotificationCountsRequest | PlainMessage | undefined, b: ListRoomNotificationCountsRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(ListRoomNotificationCountsRequest, a, b); - } -} + /** + * Explicit room override, or unspecified when inherited from server scope. + * + * @generated from field: chatto.api.v1.NotificationDeliveryIntensity room_intensity = 3; + */ + roomIntensity = NotificationDeliveryIntensity.UNSPECIFIED; -/** - * Finite snapshot of pending notification counts grouped by room. - * - * @generated from message chatto.api.v1.ListRoomNotificationCountsResponse - */ -export class ListRoomNotificationCountsResponse extends Message { /** - * Counts for rooms with at least one pending notification. + * Effective intensity after applying product, server, and room inheritance. * - * @generated from field: repeated chatto.api.v1.RoomNotificationCount room_counts = 1; + * @generated from field: chatto.api.v1.NotificationDeliveryIntensity effective_intensity = 4; */ - roomCounts: RoomNotificationCount[] = []; + effectiveIntensity = NotificationDeliveryIntensity.UNSPECIFIED; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.ListRoomNotificationCountsResponse"; + static readonly typeName = "chatto.api.v1.NotificationPolicyPreference"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "room_counts", kind: "message", T: RoomNotificationCount, repeated: true }, + { no: 1, name: "reason", kind: "enum", T: proto3.getEnumType(NotificationReason) }, + { no: 2, name: "server_intensity", kind: "enum", T: proto3.getEnumType(NotificationDeliveryIntensity) }, + { no: 3, name: "room_intensity", kind: "enum", T: proto3.getEnumType(NotificationDeliveryIntensity) }, + { no: 4, name: "effective_intensity", kind: "enum", T: proto3.getEnumType(NotificationDeliveryIntensity) }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): ListRoomNotificationCountsResponse { - return new ListRoomNotificationCountsResponse().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): NotificationPolicyPreference { + return new NotificationPolicyPreference().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): ListRoomNotificationCountsResponse { - return new ListRoomNotificationCountsResponse().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationPolicyPreference { + return new NotificationPolicyPreference().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): ListRoomNotificationCountsResponse { - return new ListRoomNotificationCountsResponse().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): NotificationPolicyPreference { + return new NotificationPolicyPreference().fromJsonString(jsonString, options); } - static equals(a: ListRoomNotificationCountsResponse | PlainMessage | undefined, b: ListRoomNotificationCountsResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(ListRoomNotificationCountsResponse, a, b); + static equals(a: NotificationPolicyPreference | PlainMessage | undefined, b: NotificationPolicyPreference | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationPolicyPreference, a, b); } } /** - * Request to dismiss one pending notification. + * Request the authenticated viewer's notification policy. * - * @generated from message chatto.api.v1.DismissNotificationRequest + * @generated from message chatto.api.v1.GetNotificationPolicyRequest */ -export class DismissNotificationRequest extends Message { +export class GetNotificationPolicyRequest extends Message { /** - * Required. Notification to dismiss. + * Empty returns server-scoped preferences. A room ID returns the inherited + * effective policy for that room and requires current membership. * - * @generated from field: string notification_id = 1; + * @generated from field: optional string room_id = 1; */ - notificationId = ""; + roomId?: string; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.DismissNotificationRequest"; + static readonly typeName = "chatto.api.v1.GetNotificationPolicyRequest"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "notification_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): DismissNotificationRequest { - return new DismissNotificationRequest().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): GetNotificationPolicyRequest { + return new GetNotificationPolicyRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): DismissNotificationRequest { - return new DismissNotificationRequest().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): GetNotificationPolicyRequest { + return new GetNotificationPolicyRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): DismissNotificationRequest { - return new DismissNotificationRequest().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): GetNotificationPolicyRequest { + return new GetNotificationPolicyRequest().fromJsonString(jsonString, options); } - static equals(a: DismissNotificationRequest | PlainMessage | undefined, b: DismissNotificationRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(DismissNotificationRequest, a, b); + static equals(a: GetNotificationPolicyRequest | PlainMessage | undefined, b: GetNotificationPolicyRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(GetNotificationPolicyRequest, a, b); } } /** - * Result of dismissing one pending notification. + * Complete supported notification policy for one scope. * - * @generated from message chatto.api.v1.DismissNotificationResponse + * @generated from message chatto.api.v1.GetNotificationPolicyResponse */ -export class DismissNotificationResponse extends Message { +export class GetNotificationPolicyResponse extends Message { /** - * True when the notification is no longer pending. + * Room scope when requested; absent for server scope. * - * @generated from field: bool dismissed = 1; + * @generated from field: optional string room_id = 1; */ - dismissed = false; + roomId?: string; - constructor(data?: PartialMessage) { + /** + * One row for every supported notification cause. + * + * @generated from field: repeated chatto.api.v1.NotificationPolicyPreference preferences = 2; + */ + preferences: NotificationPolicyPreference[] = []; + + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.DismissNotificationResponse"; + static readonly typeName = "chatto.api.v1.GetNotificationPolicyResponse"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "dismissed", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + { no: 2, name: "preferences", kind: "message", T: NotificationPolicyPreference, repeated: true }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): DismissNotificationResponse { - return new DismissNotificationResponse().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): GetNotificationPolicyResponse { + return new GetNotificationPolicyResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): DismissNotificationResponse { - return new DismissNotificationResponse().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): GetNotificationPolicyResponse { + return new GetNotificationPolicyResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): DismissNotificationResponse { - return new DismissNotificationResponse().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): GetNotificationPolicyResponse { + return new GetNotificationPolicyResponse().fromJsonString(jsonString, options); } - static equals(a: DismissNotificationResponse | PlainMessage | undefined, b: DismissNotificationResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(DismissNotificationResponse, a, b); + static equals(a: GetNotificationPolicyResponse | PlainMessage | undefined, b: GetNotificationPolicyResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(GetNotificationPolicyResponse, a, b); } } /** - * Request to dismiss all pending notifications. + * Set or clear one notification preference override. * - * @generated from message chatto.api.v1.DismissAllNotificationsRequest + * @generated from message chatto.api.v1.SetNotificationPolicyPreferenceRequest */ -export class DismissAllNotificationsRequest extends Message { - constructor(data?: PartialMessage) { +export class SetNotificationPolicyPreferenceRequest extends Message { + /** + * Room scope to change; absent changes the server scope. + * + * @generated from field: optional string room_id = 1; + */ + roomId?: string; + + /** + * Required notification cause. + * + * @generated from field: chatto.api.v1.NotificationReason reason = 2; + */ + reason = NotificationReason.UNSPECIFIED; + + /** + * Unspecified clears the selected server or room override. + * + * @generated from field: chatto.api.v1.NotificationDeliveryIntensity intensity = 3; + */ + intensity = NotificationDeliveryIntensity.UNSPECIFIED; + + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.DismissAllNotificationsRequest"; + static readonly typeName = "chatto.api.v1.SetNotificationPolicyPreferenceRequest"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + { no: 2, name: "reason", kind: "enum", T: proto3.getEnumType(NotificationReason) }, + { no: 3, name: "intensity", kind: "enum", T: proto3.getEnumType(NotificationDeliveryIntensity) }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): DismissAllNotificationsRequest { - return new DismissAllNotificationsRequest().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): SetNotificationPolicyPreferenceRequest { + return new SetNotificationPolicyPreferenceRequest().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): DismissAllNotificationsRequest { - return new DismissAllNotificationsRequest().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): SetNotificationPolicyPreferenceRequest { + return new SetNotificationPolicyPreferenceRequest().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): DismissAllNotificationsRequest { - return new DismissAllNotificationsRequest().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): SetNotificationPolicyPreferenceRequest { + return new SetNotificationPolicyPreferenceRequest().fromJsonString(jsonString, options); } - static equals(a: DismissAllNotificationsRequest | PlainMessage | undefined, b: DismissAllNotificationsRequest | PlainMessage | undefined): boolean { - return proto3.util.equals(DismissAllNotificationsRequest, a, b); + static equals(a: SetNotificationPolicyPreferenceRequest | PlainMessage | undefined, b: SetNotificationPolicyPreferenceRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(SetNotificationPolicyPreferenceRequest, a, b); } } /** - * Result of dismissing all pending notifications. + * Complete supported notification policy after one preference change. * - * @generated from message chatto.api.v1.DismissAllNotificationsResponse + * @generated from message chatto.api.v1.SetNotificationPolicyPreferenceResponse */ -export class DismissAllNotificationsResponse extends Message { +export class SetNotificationPolicyPreferenceResponse extends Message { + /** + * Room scope when changed; absent for server scope. + * + * @generated from field: optional string room_id = 1; + */ + roomId?: string; + /** - * Number of notifications dismissed. + * One row for every supported notification cause. * - * @generated from field: int32 dismissed_count = 1; + * @generated from field: repeated chatto.api.v1.NotificationPolicyPreference preferences = 2; */ - dismissedCount = 0; + preferences: NotificationPolicyPreference[] = []; - constructor(data?: PartialMessage) { + constructor(data?: PartialMessage) { super(); proto3.util.initPartial(data, this); } static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.DismissAllNotificationsResponse"; + static readonly typeName = "chatto.api.v1.SetNotificationPolicyPreferenceResponse"; static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "dismissed_count", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + { no: 2, name: "preferences", kind: "message", T: NotificationPolicyPreference, repeated: true }, ]); - static fromBinary(bytes: Uint8Array, options?: Partial): DismissAllNotificationsResponse { - return new DismissAllNotificationsResponse().fromBinary(bytes, options); + static fromBinary(bytes: Uint8Array, options?: Partial): SetNotificationPolicyPreferenceResponse { + return new SetNotificationPolicyPreferenceResponse().fromBinary(bytes, options); } - static fromJson(jsonValue: JsonValue, options?: Partial): DismissAllNotificationsResponse { - return new DismissAllNotificationsResponse().fromJson(jsonValue, options); + static fromJson(jsonValue: JsonValue, options?: Partial): SetNotificationPolicyPreferenceResponse { + return new SetNotificationPolicyPreferenceResponse().fromJson(jsonValue, options); } - static fromJsonString(jsonString: string, options?: Partial): DismissAllNotificationsResponse { - return new DismissAllNotificationsResponse().fromJsonString(jsonString, options); + static fromJsonString(jsonString: string, options?: Partial): SetNotificationPolicyPreferenceResponse { + return new SetNotificationPolicyPreferenceResponse().fromJsonString(jsonString, options); } - static equals(a: DismissAllNotificationsResponse | PlainMessage | undefined, b: DismissAllNotificationsResponse | PlainMessage | undefined): boolean { - return proto3.util.equals(DismissAllNotificationsResponse, a, b); + static equals(a: SetNotificationPolicyPreferenceResponse | PlainMessage | undefined, b: SetNotificationPolicyPreferenceResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(SetNotificationPolicyPreferenceResponse, a, b); } } diff --git a/packages/api-types/src/chatto/api/v1/viewer_pb.ts b/packages/api-types/src/chatto/api/v1/viewer_pb.ts index 8e6ec3044..472832820 100644 --- a/packages/api-types/src/chatto/api/v1/viewer_pb.ts +++ b/packages/api-types/src/chatto/api/v1/viewer_pb.ts @@ -7,7 +7,6 @@ import type { BinaryReadOptions, FieldList, JsonReadOptions, JsonValue, PartialM import { Message, proto3, Timestamp } from "@bufbuild/protobuf"; import { User } from "./users_pb.js"; import { CapabilityGrant, PermissionGrant } from "./permissions_pb.js"; -import { NotificationPreference } from "./notification_preferences_pb.js"; /** * User preference for rendering times in clients. @@ -313,55 +312,6 @@ export class ServerViewerState extends Message { } } -/** - * Room notification preference for one joined room. - * - * @generated from message chatto.api.v1.RoomNotificationPreference - */ -export class RoomNotificationPreference extends Message { - /** - * Room whose preference is represented. - * - * @generated from field: string room_id = 1; - */ - roomId = ""; - - /** - * Stored and effective notification preference. - * - * @generated from field: chatto.api.v1.NotificationPreference preference = 4; - */ - preference?: NotificationPreference; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.api.v1.RoomNotificationPreference"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 4, name: "preference", kind: "message", T: NotificationPreference }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): RoomNotificationPreference { - return new RoomNotificationPreference().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): RoomNotificationPreference { - return new RoomNotificationPreference().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): RoomNotificationPreference { - return new RoomNotificationPreference().fromJsonString(jsonString, options); - } - - static equals(a: RoomNotificationPreference | PlainMessage | undefined, b: RoomNotificationPreference | PlainMessage | undefined): boolean { - return proto3.util.equals(RoomNotificationPreference, a, b); - } -} - /** * Request for the authenticated viewer snapshot. * @@ -415,20 +365,6 @@ export class GetViewerResponse extends Message { */ capabilities?: ViewerCapabilities; - /** - * Server-wide notification preference. - * - * @generated from field: chatto.api.v1.NotificationPreference server_notification_preference = 3; - */ - serverNotificationPreference?: NotificationPreference; - - /** - * Notification preferences for rooms the user participates in. - * - * @generated from field: repeated chatto.api.v1.RoomNotificationPreference room_notification_preferences = 4; - */ - roomNotificationPreferences: RoomNotificationPreference[] = []; - /** * Effective server/channel permission decisions for the authenticated user. * @@ -453,8 +389,6 @@ export class GetViewerResponse extends Message { static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "user", kind: "message", T: ViewerUser }, { no: 2, name: "capabilities", kind: "message", T: ViewerCapabilities }, - { no: 3, name: "server_notification_preference", kind: "message", T: NotificationPreference }, - { no: 4, name: "room_notification_preferences", kind: "message", T: RoomNotificationPreference, repeated: true }, { no: 5, name: "viewer_permissions", kind: "message", T: ServerViewerPermissions }, { no: 6, name: "viewer_state", kind: "message", T: ServerViewerState }, ]); diff --git a/packages/api-types/src/chatto/realtime/v1/realtime_pb.ts b/packages/api-types/src/chatto/realtime/v1/realtime_pb.ts index 439e37528..36a933b59 100644 --- a/packages/api-types/src/chatto/realtime/v1/realtime_pb.ts +++ b/packages/api-types/src/chatto/realtime/v1/realtime_pb.ts @@ -13,7 +13,7 @@ import { RoomGroup, RoomViewerState, RoomWithViewerState } from "../../api/v1/ro import { PresenceStatus } from "../../api/v1/presence_pb.js"; import { ThreadViewerState } from "../../api/v1/message_types_pb.js"; import { RoomTimelineEvent, RoomTimelineIncludes, RoomTimelinePage } from "../../api/v1/room_timeline_pb.js"; -import { ListNotificationsResponse, RoomNotificationCount } from "../../api/v1/notifications_pb.js"; +import { ListNotificationGroupsResponse } from "../../api/v1/notifications_pb.js"; import { ActiveCall } from "../../api/v1/voice_calls_pb.js"; /** @@ -59,15 +59,21 @@ export enum RealtimeProjectionNotificationAction { CREATED = 1, /** - * @generated from enum value: REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED = 2; + * @generated from enum value: REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED = 3; */ - DISMISSED = 2, + UPDATED = 3, + + /** + * @generated from enum value: REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED = 4; + */ + DELETED = 4, } // Retrieve enum metadata with: proto3.getEnumType(RealtimeProjectionNotificationAction) proto3.util.setEnumType(RealtimeProjectionNotificationAction, "chatto.realtime.v1.RealtimeProjectionNotificationAction", [ { no: 0, name: "REALTIME_PROJECTION_NOTIFICATION_ACTION_UNSPECIFIED" }, { no: 1, name: "REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED" }, - { no: 2, name: "REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED" }, + { no: 3, name: "REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED" }, + { no: 4, name: "REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED" }, ]); /** @@ -1068,13 +1074,6 @@ export class RealtimeProjectionRoom extends Message { */ memberUserIds: string[] = []; - /** - * Current pending notifications targeting this room for the viewer. - * - * @generated from field: uint32 viewer_notification_count = 3; - */ - viewerNotificationCount = 0; - /** * Whether this DM room has ever received a root message. False means the * durable room exists only so its initiator can compose the first message. @@ -1095,7 +1094,6 @@ export class RealtimeProjectionRoom extends Message { static readonly fields: FieldList = proto3.util.newFieldList(() => [ { no: 1, name: "room", kind: "message", T: RoomWithViewerState }, { no: 2, name: "member_user_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, - { no: 3, name: "viewer_notification_count", kind: "scalar", T: 13 /* ScalarType.UINT32 */ }, { no: 4, name: "has_message_history", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, ]); @@ -1613,20 +1611,6 @@ export class RealtimeProjectionRoomTimelineEventRemove extends Message { - /** - * Newest pending notifications and total pending count. - * - * @generated from field: chatto.api.v1.ListNotificationsResponse page = 1; - */ - page?: ListNotificationsResponse; - - /** - * Complete current counts for rooms with pending notifications. - * - * @generated from field: repeated chatto.api.v1.RoomNotificationCount room_counts = 2; - */ - roomCounts: RoomNotificationCount[] = []; - /** * Live transition that caused this replacement, when one exists. Bootstrap, * replay reconciliation, and compacted reset replacements omit this field. @@ -1635,6 +1619,13 @@ export class RealtimeProjectionNotificationsReplace extends Message) { super(); proto3.util.initPartial(data, this); @@ -1643,9 +1634,8 @@ export class RealtimeProjectionNotificationsReplace extends Message [ - { no: 1, name: "page", kind: "message", T: ListNotificationsResponse }, - { no: 2, name: "room_counts", kind: "message", T: RoomNotificationCount, repeated: true }, { no: 3, name: "change", kind: "message", T: RealtimeProjectionNotificationChange, opt: true }, + { no: 4, name: "groups", kind: "message", T: ListNotificationGroupsResponse }, ]); static fromBinary(bytes: Uint8Array, options?: Partial): RealtimeProjectionNotificationsReplace { @@ -2191,22 +2181,6 @@ export class RealtimeEventEnvelope extends Message { */ value: RealtimePresenceChangedEvent; case: "presenceChanged"; - } | { - /** - * The current user was mentioned in a room. - * - * @generated from field: chatto.realtime.v1.RealtimeMentionNotificationEvent mention_notification = 88; - */ - value: RealtimeMentionNotificationEvent; - case: "mentionNotification"; - } | { - /** - * The current user received a new direct message. - * - * @generated from field: chatto.realtime.v1.RealtimeNewDirectMessageNotificationEvent new_direct_message_notification = 89; - */ - value: RealtimeNewDirectMessageNotificationEvent; - case: "newDirectMessageNotification"; } | { /** * The current user's session was terminated. @@ -2230,8 +2204,6 @@ export class RealtimeEventEnvelope extends Message { { no: 3, name: "actor_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, { no: 30, name: "user_typing", kind: "message", T: RealtimeTypingEvent, oneof: "event" }, { no: 31, name: "presence_changed", kind: "message", T: RealtimePresenceChangedEvent, oneof: "event" }, - { no: 88, name: "mention_notification", kind: "message", T: RealtimeMentionNotificationEvent, oneof: "event" }, - { no: 89, name: "new_direct_message_notification", kind: "message", T: RealtimeNewDirectMessageNotificationEvent, oneof: "event" }, { no: 90, name: "session_terminated", kind: "message", T: RealtimeSessionTerminatedEvent, oneof: "event" }, ]); @@ -2356,153 +2328,6 @@ export class RealtimePresenceChangedEvent extends Message { - /** - * Room where the mention occurred. - * - * @generated from field: string room_id = 1; - */ - roomId = ""; - - /** - * Author user ID. - * - * @generated from field: string actor_user_id = 2; - */ - actorUserId = ""; - - /** - * Display name of the room where the mention occurred, when hydrated for the caller. - * - * @generated from field: optional string room_name = 3; - */ - roomName?: string; - - /** - * Display name of the author who mentioned the connected user, when hydrated. - * - * @generated from field: optional string actor_display_name = 4; - */ - actorDisplayName?: string; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.realtime.v1.RealtimeMentionNotificationEvent"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "actor_user_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "room_name", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, - { no: 4, name: "actor_display_name", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): RealtimeMentionNotificationEvent { - return new RealtimeMentionNotificationEvent().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): RealtimeMentionNotificationEvent { - return new RealtimeMentionNotificationEvent().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): RealtimeMentionNotificationEvent { - return new RealtimeMentionNotificationEvent().fromJsonString(jsonString, options); - } - - static equals(a: RealtimeMentionNotificationEvent | PlainMessage | undefined, b: RealtimeMentionNotificationEvent | PlainMessage | undefined): boolean { - return proto3.util.equals(RealtimeMentionNotificationEvent, a, b); - } -} - -/** - * New-DM attention signal for the connected user. - * - * Inline names and avatar URLs are display hints. Hydrate the DM room through - * `RoomDirectoryService.GetRoom` or `RoomDirectoryService.BatchGetRooms`, and - * the sender through `UserService.GetUser` or `UserService.BatchGetUsers` when - * local caches are missing or stale. - * - * @generated from message chatto.realtime.v1.RealtimeNewDirectMessageNotificationEvent - */ -export class RealtimeNewDirectMessageNotificationEvent extends Message { - /** - * DM room ID. - * - * @generated from field: string room_id = 1; - */ - roomId = ""; - - /** - * Sender user ID. - * - * @generated from field: string sender_id = 2; - */ - senderId = ""; - - /** - * Display name of the sender, when hydrated. - * - * @generated from field: optional string sender_display_name = 3; - */ - senderDisplayName?: string; - - /** - * Avatar URL of the sender, when one is available. - * - * @generated from field: optional string sender_avatar_url = 4; - */ - senderAvatarUrl?: string; - - /** - * Display name for the DM conversation from the connected user's perspective, when hydrated. - * - * @generated from field: optional string conversation_name = 5; - */ - conversationName?: string; - - constructor(data?: PartialMessage) { - super(); - proto3.util.initPartial(data, this); - } - - static readonly runtime: typeof proto3 = proto3; - static readonly typeName = "chatto.realtime.v1.RealtimeNewDirectMessageNotificationEvent"; - static readonly fields: FieldList = proto3.util.newFieldList(() => [ - { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 2, name: "sender_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, - { no: 3, name: "sender_display_name", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, - { no: 4, name: "sender_avatar_url", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, - { no: 5, name: "conversation_name", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, - ]); - - static fromBinary(bytes: Uint8Array, options?: Partial): RealtimeNewDirectMessageNotificationEvent { - return new RealtimeNewDirectMessageNotificationEvent().fromBinary(bytes, options); - } - - static fromJson(jsonValue: JsonValue, options?: Partial): RealtimeNewDirectMessageNotificationEvent { - return new RealtimeNewDirectMessageNotificationEvent().fromJson(jsonValue, options); - } - - static fromJsonString(jsonString: string, options?: Partial): RealtimeNewDirectMessageNotificationEvent { - return new RealtimeNewDirectMessageNotificationEvent().fromJsonString(jsonString, options); - } - - static equals(a: RealtimeNewDirectMessageNotificationEvent | PlainMessage | undefined, b: RealtimeNewDirectMessageNotificationEvent | PlainMessage | undefined): boolean { - return proto3.util.equals(RealtimeNewDirectMessageNotificationEvent, a, b); - } -} - /** * Session-terminated signal for the connected user's current session. * diff --git a/proto/chatto/api/v1/notification_preferences.proto b/proto/chatto/api/v1/notification_preferences.proto deleted file mode 100644 index aa27f4604..000000000 --- a/proto/chatto/api/v1/notification_preferences.proto +++ /dev/null @@ -1,98 +0,0 @@ -syntax = "proto3"; - -package chatto.api.v1; - -import "buf/validate/validate.proto"; - -// Notification delivery level for a room. -enum NotificationLevel { - // The level was not specified. - NOTIFICATION_LEVEL_UNSPECIFIED = 0; - // Use the inherited default for the room. - NOTIFICATION_LEVEL_DEFAULT = 1; - // Do not notify for this room. - NOTIFICATION_LEVEL_MUTED = 2; - // Notify according to the normal room rules. - NOTIFICATION_LEVEL_NORMAL = 3; - // Notify for every message in the room. - NOTIFICATION_LEVEL_ALL_MESSAGES = 4; -} - -// Stored and effective notification preference. -message NotificationPreference { - // Explicit level stored for the current user. - NotificationLevel level = 1; - // Level after applying defaults and inheritance. - NotificationLevel effective_level = 2; -} - -// Current notification preference. -message GetNotificationPreferenceResponse { - // Field 1 was `level` and field 2 was `effective_level` before the shared - // NotificationPreference shape. - reserved 1, 2; - reserved "level", "effective_level"; - - // Current stored and effective notification preference. - NotificationPreference preference = 3; -} - -// Updated notification preference. -message UpdateNotificationPreferenceResponse { - // Field 1 was `level` and field 2 was `effective_level` before the shared - // NotificationPreference shape. - reserved 1, 2; - reserved "level", "effective_level"; - - // Stored and effective notification preference after the update. - NotificationPreference preference = 3; -} - -// Request for the current user's server-level notification preference. -message GetServerNotificationPreferenceRequest {} - -// Request to update the current user's server-level notification level. -message UpdateServerNotificationPreferenceRequest { - // Required. New explicit notification level. Use NOTIFICATION_LEVEL_DEFAULT - // to return the server to default behavior. - NotificationLevel level = 1 [(buf.validate.field).enum = { - defined_only: true - not_in: [0] - }]; -} - -// Request for the current user's notification preference in one room. -message GetRoomNotificationPreferenceRequest { - // Required. Room whose notification preference should be loaded for the current user. - string room_id = 1 [(buf.validate.field).string.min_len = 1]; -} - -// Request to update the current user's notification level in one room. -message UpdateRoomNotificationPreferenceRequest { - // Required. Room whose notification level should be changed for the current user. - string room_id = 1 [(buf.validate.field).string.min_len = 1]; - // Required. New explicit notification level. Use NOTIFICATION_LEVEL_DEFAULT - // to return the room to inherited/default behavior. - NotificationLevel level = 2 [(buf.validate.field).enum = { - defined_only: true - not_in: [0] - }]; -} - -// Reads and updates per-room notification preferences for the current user. -service NotificationPreferencesService { - // Returns the current user's explicit and effective server notification level. - rpc GetServerNotificationPreference(GetServerNotificationPreferenceRequest) returns (GetNotificationPreferenceResponse); - // Updates the current user's explicit server notification level and returns the - // resulting stored and effective levels. - rpc UpdateServerNotificationPreference(UpdateServerNotificationPreferenceRequest) returns (UpdateNotificationPreferenceResponse); - // Returns the current user's explicit and effective notification level for a - // room. Use this before rendering room notification controls so the UI can - // distinguish inherited defaults from an explicit room override. Returns - // NOT_FOUND when the room does not exist and PERMISSION_DENIED when the room - // exists but is inaccessible to the caller. - rpc GetRoomNotificationPreference(GetRoomNotificationPreferenceRequest) returns (GetNotificationPreferenceResponse); - // Updates the current user's explicit notification level for a room and returns - // the resulting stored and effective levels. - rpc UpdateRoomNotificationPreference(UpdateRoomNotificationPreferenceRequest) returns (UpdateNotificationPreferenceResponse); -} diff --git a/proto/chatto/api/v1/notifications.proto b/proto/chatto/api/v1/notifications.proto index 94145680f..76915d5b6 100644 --- a/proto/chatto/api/v1/notifications.proto +++ b/proto/chatto/api/v1/notifications.proto @@ -9,218 +9,264 @@ import "chatto/api/v1/users.proto"; import "google/protobuf/descriptor.proto"; import "google/protobuf/timestamp.proto"; -// Direct-message notification payload. -message DirectMessageNotification { - reserved 1; - reserved "room_id"; - - // Message event ID. - string event_id = 2; - // DM room where the message was posted. - RoomSummary room = 3; +// Why source activity matched the authenticated viewer's notification policy. +enum NotificationReason { + // No cause was specified. This value is not valid in preference writes. + NOTIFICATION_REASON_UNSPECIFIED = 0; + // A message was posted in a direct-message conversation. + NOTIFICATION_REASON_DIRECT_MESSAGE = 1; + // The viewer's username was mentioned directly. + NOTIFICATION_REASON_DIRECT_MENTION = 2; + // Activity replied directly to the viewer's message. + NOTIFICATION_REASON_REPLY = 3; + // A role held by the viewer was mentioned. + NOTIFICATION_REASON_ROLE_MENTION = 4; + // An `@here` mention included the viewer. + NOTIFICATION_REASON_HERE = 5; + // An `@all` mention included the viewer. + NOTIFICATION_REASON_ALL = 6; + // New activity appeared in a thread followed by the viewer. + NOTIFICATION_REASON_FOLLOWED_THREAD = 7; + // New activity appeared in a room followed by the viewer. + NOTIFICATION_REASON_FOLLOWED_ROOM = 8; + // Someone reacted to the viewer's message. + NOTIFICATION_REASON_REACTION = 9; + // The viewer was invited to a room. + NOTIFICATION_REASON_ROOM_INVITATION = 10; } -// Mention notification payload. -message MentionNotification { - // Room where the mention occurred. - RoomSummary room = 1; - // Message event ID. - string event_id = 2; - // Thread root event ID when the mention happened inside a thread. - optional string thread_root_event_id = 3; +// Delivery strength for one notification cause. +enum NotificationDeliveryIntensity { + // In preference writes, unspecified clears the override (Inherit). + NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED = 0; + // Matching activity does not create a notification occurrence. + NOTIFICATION_DELIVERY_INTENSITY_OFF = 1; + // Matching activity appears in the notification list without interruptive delivery. + NOTIFICATION_DELIVERY_INTENSITY_BADGE = 2; + // Matching activity appears in the notification list and may trigger sound or push. + NOTIFICATION_DELIVERY_INTENSITY_ALERT = 3; } -// Reply notification payload. -message ReplyNotification { - // Room where the reply occurred. - RoomSummary room = 1; - // Reply event ID. - string event_id = 2; - // Event ID of the message being replied to. - string in_reply_to_id = 3; - // Thread root event ID when the reply happened inside a thread. - optional string thread_root_event_id = 4; +// One cause that matched an occurrence and its evaluated delivery intensity. +message NotificationReasonMatch { + // Cause that matched the viewer. + NotificationReason reason = 1; + // Effective intensity when the source activity occurred. + NotificationDeliveryIntensity intensity = 2; } -// All-messages room notification payload. -message RoomMessageNotification { - // Room where the message was posted. +// Exact visible destination of one notification occurrence. +message NotificationTarget { + // Room containing the source activity. RoomSummary room = 1; - // Message event ID. + // Exact source or reacted-to message event to reveal. string event_id = 2; + // Thread root when the target is inside a thread. + optional string thread_root_event_id = 3; + // Direct reply target when the occurrence was caused by a reply. + optional string parent_event_id = 4; } -// One pending notification for the authenticated viewer. -message NotificationItem { - reserved 4; - reserved "summary"; - - // Stable notification ID. +// One exact Notifications 2.0 source occurrence. +message NotificationOccurrence { + // Stable occurrence ID. string id = 1; - // Creation time. - google.protobuf.Timestamp created_at = 2; - // User who triggered the notification, when still resolvable. - User actor = 3; - - oneof kind { - // Direct-message notification. - DirectMessageNotification direct_message = 10; - // Mention notification. - MentionNotification mention = 11; - // Reply notification. - ReplyNotification reply = 12; - // All-messages room notification. - RoomMessageNotification room_message = 13; - } + // Durable source event from which this occurrence was derived. + string source_event_id = 2; + // Time of the source activity. + google.protobuf.Timestamp created_at = 3; + // User who caused the source activity, when still visible. + User actor = 4; + // Exact current destination for navigation. + NotificationTarget target = 5; + // Every cause that matched when the source activity occurred. + repeated NotificationReasonMatch reasons = 6; + // Strongest evaluated intensity across all matching causes. + NotificationDeliveryIntensity strongest_intensity = 7; + // Whether this occurrence still contributes unread attention. + bool unread = 8; + // Absolute expiry, 90 days after the source activity. + google.protobuf.Timestamp expires_at = 10; } -// Request for the authenticated viewer's pending notifications. -message ListNotificationsRequest { - reserved 1, 2; - reserved "limit", "offset"; - - // Page request. Defaults to 50 results when absent or limit is zero. - PageRequest page = 3; +// A presentation group derived from related notification occurrences. +message NotificationGroup { + // Stable ID derived from the viewer and grouping target. + string id = 1; + // Bounded newest-occurrence preview. It also includes the open target when + // that target falls outside the newest preview window. + repeated NotificationOccurrence occurrences = 2; + // Target to open: newest unread, or newest when all are read. + NotificationTarget open_target = 3; + // True when at least one member occurrence is unread. + bool unread = 4; + // Total number of occurrences in this group, including those not in the + // bounded preview. + int32 occurrence_count = 5; + // Time of the newest occurrence. + google.protobuf.Timestamp latest_at = 6; + // Strongest intensity among member occurrences. + NotificationDeliveryIntensity strongest_intensity = 7; + // Distinct causes represented by member occurrences. + repeated NotificationReason reasons = 8; + // Earliest member expiry. Clients refresh the group at this boundary. + google.protobuf.Timestamp next_expiry_at = 11; + // Occurrence ID corresponding to open_target, including when several + // occurrences share the same message target. + string open_notification_id = 12; } -// Request for pending notifications scoped to one room. -message ListRoomNotificationsRequest { - reserved 2, 3; - reserved "limit", "offset"; - - // Required. Room whose notifications should be listed. - string room_id = 1 [(buf.validate.field).string.min_len = 1]; +// Request for one page of grouped notification occurrences. +message ListNotificationGroupsRequest { // Page request. Defaults to 50 results when absent or limit is zero. - PageRequest page = 4; + PageRequest page = 1; } -// Pending notification page. -message ListNotificationsResponse { - reserved 2, 3, 4; - reserved "total_count", "has_more", "server_name"; - - // Page notifications, newest first. - repeated NotificationItem notifications = 1; +// One page of derived notification groups. +message ListNotificationGroupsResponse { + // Groups newest activity first. + repeated NotificationGroup groups = 1; // Page metadata. - PageInfo page = 5; + PageInfo page = 2; + // Total unread group count. + int32 unread_group_count = 3; + // Earliest expiry in the complete list, including groups outside this page. + // Clients refresh authoritative notification state at this boundary. + google.protobuf.Timestamp next_expiry_at = 4; + // Complete unread-group counts by target room. This is independent of the + // bounded group page so room indicators remain authoritative. + repeated NotificationRoomUnreadGroupCount room_unread_group_counts = 5; } -// Request one pending notification for the authenticated viewer. -message GetNotificationRequest { - // Required notification ID. - string notification_id = 1 [(buf.validate.field).string.min_len = 1]; +// Unread notification groups currently targeting one room. +message NotificationRoomUnreadGroupCount { + // Target room ID. + string room_id = 1; + // Number of unread groups targeting this room. + int32 unread_group_count = 2; } -// Pending notification response. -message GetNotificationResponse { - reserved 2; - reserved "server_name"; - - // Requested notification. - NotificationItem notification = 1; +// Request to mark one notification occurrence read. +message MarkNotificationReadRequest { + // Required stable occurrence ID. + string notification_id = 1 [(buf.validate.field).string.min_len = 1]; } -// Request pending notifications for a set of stable notification IDs. -message BatchGetNotificationsRequest { - // Required notification IDs. Unknown or dismissed IDs are omitted from the - // response. - repeated string notification_ids = 1 [(buf.validate.field).repeated = { - min_items: 1 - max_items: 100 - items: { - string: {min_len: 1} - } - }]; +// Notification occurrence after marking it read. +message MarkNotificationReadResponse { + // Updated occurrence. + NotificationOccurrence notification = 1; } -// Batch pending notification response. -message BatchGetNotificationsResponse { - reserved 2; - reserved "server_name"; +// Request permanent deletion of one notification occurrence. +message DeleteNotificationOccurrenceRequest { + // Required stable occurrence ID. + string notification_id = 1 [(buf.validate.field).string.min_len = 1]; +} - // Found notifications. The server preserves first-seen request order and - // de-duplicates repeated IDs. - repeated NotificationItem notifications = 1; +// Result of deleting one notification occurrence. +message DeleteNotificationOccurrenceResponse { + // True when this call replaced a visible occurrence with a deletion tombstone. + // False when the occurrence was already absent or deleted. + bool deleted = 1; } -// Pending notification page scoped to one room. -message ListRoomNotificationsResponse { - reserved 2, 3, 4; - reserved "total_count", "has_more", "server_name"; +// Request permanent deletion of one derived notification group. +message DeleteNotificationGroupRequest { + // Required stable group ID. + string group_id = 1 [(buf.validate.field).string.min_len = 1]; +} - // Page notifications, newest first. - repeated NotificationItem notifications = 1; - // Page metadata. - PageInfo page = 5; +// Result of deleting one derived notification group. +message DeleteNotificationGroupResponse { + // Number of visible occurrences replaced by deletion tombstones. + int32 deleted_count = 1; } -// Request for a lightweight pending notification check. -message HasNotificationsRequest {} +// Request permanent deletion of every current notification occurrence. +message DeleteAllNotificationOccurrencesRequest {} -// Lightweight pending notification check. -message HasNotificationsResponse { - // True when the authenticated viewer has at least one pending notification. - bool has_notifications = 1; +// Result of deleting every current notification occurrence. +message DeleteAllNotificationOccurrencesResponse { + // Number of current occurrences replaced by deletion tombstones. + int32 deleted_count = 1; } -// Pending notification count for one room. -message RoomNotificationCount { - // Room ID. - string room_id = 1; - // Pending notification count for the authenticated viewer in this room. - int32 total_count = 2; +// Explicit and effective delivery policy for one notification cause. +message NotificationPolicyPreference { + // Notification cause controlled by this row. + NotificationReason reason = 1; + // Explicit server override, or unspecified when inherited from product defaults. + NotificationDeliveryIntensity server_intensity = 2; + // Explicit room override, or unspecified when inherited from server scope. + NotificationDeliveryIntensity room_intensity = 3; + // Effective intensity after applying product, server, and room inheritance. + NotificationDeliveryIntensity effective_intensity = 4; } -// Request for pending notification counts grouped by room. -message ListRoomNotificationCountsRequest {} - -// Finite snapshot of pending notification counts grouped by room. -message ListRoomNotificationCountsResponse { - // Counts for rooms with at least one pending notification. - repeated RoomNotificationCount room_counts = 1; +// Request the authenticated viewer's notification policy. +message GetNotificationPolicyRequest { + // Empty returns server-scoped preferences. A room ID returns the inherited + // effective policy for that room and requires current membership. + optional string room_id = 1 [(buf.validate.field).string.min_len = 1]; } -// Request to dismiss one pending notification. -message DismissNotificationRequest { - // Required. Notification to dismiss. - string notification_id = 1 [(buf.validate.field).string.min_len = 1]; +// Complete supported notification policy for one scope. +message GetNotificationPolicyResponse { + // Room scope when requested; absent for server scope. + optional string room_id = 1; + // One row for every supported notification cause. + repeated NotificationPolicyPreference preferences = 2; } -// Result of dismissing one pending notification. -message DismissNotificationResponse { - // True when the notification is no longer pending. - bool dismissed = 1; +// Set or clear one notification preference override. +message SetNotificationPolicyPreferenceRequest { + // Room scope to change; absent changes the server scope. + optional string room_id = 1 [(buf.validate.field).string.min_len = 1]; + // Required notification cause. + NotificationReason reason = 2 [(buf.validate.field).enum = { + defined_only: true + not_in: [0] + }]; + // Unspecified clears the selected server or room override. + NotificationDeliveryIntensity intensity = 3 [(buf.validate.field).enum.defined_only = true]; } -// Request to dismiss all pending notifications. -message DismissAllNotificationsRequest {} - -// Result of dismissing all pending notifications. -message DismissAllNotificationsResponse { - // Number of notifications dismissed. - int32 dismissed_count = 1; +// Complete supported notification policy after one preference change. +message SetNotificationPolicyPreferenceResponse { + // Room scope when changed; absent for server scope. + optional string room_id = 1; + // One row for every supported notification cause. + repeated NotificationPolicyPreference preferences = 2; } -// Reads and dismisses pending notifications for the authenticated viewer. +// Reads, marks read, and deletes notifications for the authenticated viewer. service NotificationService { - // Lists the authenticated viewer's pending notifications. - rpc ListNotifications(ListNotificationsRequest) returns (ListNotificationsResponse); - // Gets one pending notification. Returns NOT_FOUND when the notification is - // unknown or has been dismissed. - rpc GetNotification(GetNotificationRequest) returns (GetNotificationResponse); - // Gets pending notifications by ID. - rpc BatchGetNotifications(BatchGetNotificationsRequest) returns (BatchGetNotificationsResponse); - // Lists pending notifications for one room. Non-members receive an empty page. - rpc ListRoomNotifications(ListRoomNotificationsRequest) returns (ListRoomNotificationsResponse); - // Lists pending notification counts grouped by room as a finite snapshot. - rpc ListRoomNotificationCounts(ListRoomNotificationCountsRequest) returns (ListRoomNotificationCountsResponse); - // Checks whether the authenticated viewer has any pending notifications. - rpc HasNotifications(HasNotificationsRequest) returns (HasNotificationsResponse); - // Dismisses one pending notification. Already-dismissed notifications are - // treated as idempotent success. - rpc DismissNotification(DismissNotificationRequest) returns (DismissNotificationResponse) { + // Lists Notifications 2.0 groups. + rpc ListNotificationGroups(ListNotificationGroupsRequest) returns (ListNotificationGroupsResponse); + // Marks one occurrence read. Repeating the call is safe. + rpc MarkNotificationRead(MarkNotificationReadRequest) returns (MarkNotificationReadResponse) { + option idempotency_level = IDEMPOTENT; + } + // Permanently deletes one occurrence while retaining its anti-recreation + // tombstone through the original expiry. Repeating the call is safe. + rpc DeleteNotificationOccurrence(DeleteNotificationOccurrenceRequest) returns (DeleteNotificationOccurrenceResponse) { + option idempotency_level = IDEMPOTENT; + } + // Permanently deletes occurrences currently belonging to one derived group. + // Group membership is captured when the request is handled. Callers must not + // retry this mutation automatically because later activity may reuse the + // same derived group ID. + rpc DeleteNotificationGroup(DeleteNotificationGroupRequest) returns (DeleteNotificationGroupResponse); + // Permanently deletes every occurrence current at the server's authoritative + // mutation boundary. Callers must not retry this mutation automatically + // because later activity may arrive after that boundary. + rpc DeleteAllNotificationOccurrences(DeleteAllNotificationOccurrencesRequest) returns (DeleteAllNotificationOccurrencesResponse); + // Gets every supported cause and its effective inherited delivery intensity. + rpc GetNotificationPolicy(GetNotificationPolicyRequest) returns (GetNotificationPolicyResponse); + // Sets or clears one server- or room-scoped cause override. + rpc SetNotificationPolicyPreference(SetNotificationPolicyPreferenceRequest) returns (SetNotificationPolicyPreferenceResponse) { option idempotency_level = IDEMPOTENT; } - // Dismisses all pending notifications. - rpc DismissAllNotifications(DismissAllNotificationsRequest) returns (DismissAllNotificationsResponse); + } diff --git a/proto/chatto/api/v1/viewer.proto b/proto/chatto/api/v1/viewer.proto index e85557d47..2632c9c85 100644 --- a/proto/chatto/api/v1/viewer.proto +++ b/proto/chatto/api/v1/viewer.proto @@ -2,7 +2,6 @@ syntax = "proto3"; package chatto.api.v1; -import "chatto/api/v1/notification_preferences.proto"; import "chatto/api/v1/permissions.proto"; import "chatto/api/v1/users.proto"; import "google/protobuf/timestamp.proto"; @@ -67,30 +66,18 @@ message ServerViewerState { bool has_unread_rooms = 1; } -// Room notification preference for one joined room. -message RoomNotificationPreference { - reserved 2, 3; - reserved "level", "effective_level"; - - // Room whose preference is represented. - string room_id = 1; - // Stored and effective notification preference. - NotificationPreference preference = 4; -} - // Request for the authenticated viewer snapshot. message GetViewerRequest {} // Authenticated viewer snapshot needed by application shells. message GetViewerResponse { + reserved 3, 4; + reserved "server_notification_preference", "room_notification_preferences"; + // Current authenticated user. ViewerUser user = 1; // Permission-derived capabilities. ViewerCapabilities capabilities = 2; - // Server-wide notification preference. - NotificationPreference server_notification_preference = 3; - // Notification preferences for rooms the user participates in. - repeated RoomNotificationPreference room_notification_preferences = 4; // Effective server/channel permission decisions for the authenticated user. ServerViewerPermissions viewer_permissions = 5; // Non-permission server state for the authenticated user. diff --git a/proto/chatto/core/v1/config_events.proto b/proto/chatto/core/v1/config_events.proto index 05a453784..6ef6ccaea 100644 --- a/proto/chatto/core/v1/config_events.proto +++ b/proto/chatto/core/v1/config_events.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package chatto.core.v1; import "chatto/core/v1/models.proto"; +import "chatto/core/v1/notification.proto"; import "chatto/core/v1/user_preferences.proto"; option go_package = "hmans.de/chatto/internal/pb/chatto/core/v1;corev1"; @@ -12,7 +13,7 @@ option go_package = "hmans.de/chatto/internal/pb/chatto/core/v1;corev1"; // // Top-level Event oneof tags owned by this file: // - 500: reserved tombstone for the abandoned server_config_changed snapshot -// - 501-517: durable server/user configuration events on evt.config.* +// - 501-518: durable server/user configuration events on evt.config.* // // Keep these tags coordinated with event.proto. Message-internal field numbers // are local to each message and do not consume top-level Event tag space. @@ -68,25 +69,39 @@ message UserTimeFormatClearedEvent { string user_id = 1; } +// Legacy Notifications 1.0 fact retained only so existing EVT histories remain +// decodable. Current writers emit UserNotificationPreferenceChangedEvent. message UserServerNotificationLevelSetEvent { string user_id = 1; NotificationLevel level = 2; } +// Legacy Notifications 1.0 fact retained only for historical replay decoding. message UserServerNotificationLevelClearedEvent { string user_id = 1; } +// Legacy Notifications 1.0 fact retained only for historical replay decoding. message UserRoomNotificationLevelSetEvent { string user_id = 1; string room_id = 2; NotificationLevel level = 3; } +// Legacy Notifications 1.0 fact retained only for historical replay decoding. message UserRoomNotificationLevelClearedEvent { string user_id = 1; string room_id = 2; } +// Records one server- or room-scoped notification policy override. An absent +// room_id selects server scope; an unspecified intensity clears the override. +message UserNotificationPreferenceChangedEvent { + string user_id = 1; + NotificationReason reason = 2; + NotificationDeliveryIntensity intensity = 3; + optional string room_id = 4; +} + // Notifies clients that server configuration was updated. // Clients should refetch server info to get the new values. diff --git a/proto/chatto/core/v1/event.proto b/proto/chatto/core/v1/event.proto index 75f969b5c..593d01f80 100644 --- a/proto/chatto/core/v1/event.proto +++ b/proto/chatto/core/v1/event.proto @@ -8,6 +8,7 @@ import "chatto/core/v1/authorization_events.proto"; import "chatto/core/v1/asset_events.proto"; import "chatto/core/v1/message_events.proto"; import "chatto/core/v1/moderation_events.proto"; +import "chatto/core/v1/notification.proto"; import "chatto/core/v1/rbac_events.proto"; import "chatto/core/v1/reaction_events.proto"; import "chatto/core/v1/room_events.proto"; @@ -133,10 +134,13 @@ message Event { UserTimezoneClearedEvent user_timezone_cleared = 511; UserTimeFormatChangedEvent user_time_format_changed = 512; UserTimeFormatClearedEvent user_time_format_cleared = 513; - UserServerNotificationLevelSetEvent user_server_notification_level_set = 514; - UserServerNotificationLevelClearedEvent user_server_notification_level_cleared = 515; - UserRoomNotificationLevelSetEvent user_room_notification_level_set = 516; - UserRoomNotificationLevelClearedEvent user_room_notification_level_cleared = 517; + // Legacy Notifications 1.0 variants retained only to decode EVT history. + // Current code never writes them. + UserServerNotificationLevelSetEvent user_server_notification_level_set = 514 [deprecated = true]; + UserServerNotificationLevelClearedEvent user_server_notification_level_cleared = 515 [deprecated = true]; + UserRoomNotificationLevelSetEvent user_room_notification_level_set = 516 [deprecated = true]; + UserRoomNotificationLevelClearedEvent user_room_notification_level_cleared = 517 [deprecated = true]; + UserNotificationPreferenceChangedEvent user_notification_preference_changed = 518; // ----- Room groups (600-609, durable, evt.group.{groupId}) ----- // The group aggregate owns its room-membership AND room-ordering. diff --git a/proto/chatto/core/v1/live_events.proto b/proto/chatto/core/v1/live_events.proto index 18ca5b83b..85fc82269 100644 --- a/proto/chatto/core/v1/live_events.proto +++ b/proto/chatto/core/v1/live_events.proto @@ -50,7 +50,8 @@ message LiveEvent { UserDeletedEvent user_deleted = 21 [deprecated = true]; UserProfileUpdatedEvent user_profile_updated = 22; ServerUserPreferencesUpdatedEvent server_user_preferences_updated = 23; - NotificationLevelChangedEvent notification_level_changed = 24; + // Notifications 1.0 schema tombstone. Current code never publishes or handles it. + NotificationLevelChangedEvent notification_level_changed = 24 [deprecated = true]; ThreadFollowChangedEvent thread_follow_changed = 25; // ----- Server membership / lifecycle ----- @@ -63,17 +64,19 @@ message LiveEvent { // ----- Presence ----- PresenceChangedEvent presence_changed = 45; - // ----- Notifications ----- - MentionNotificationEvent mention_notification = 50; - NewDirectMessageNotificationEvent new_direct_message_notification = 51; + // Notifications 1.0 schema tombstones. Current code never publishes or handles them. + MentionNotificationEvent mention_notification = 50 [deprecated = true]; + NewDirectMessageNotificationEvent new_direct_message_notification = 51 [deprecated = true]; // ----- Voice calls ----- CallParticipantJoinedEvent call_participant_joined = 60; CallParticipantLeftEvent call_participant_left = 61; // ----- Notification sync ----- - NotificationCreatedEvent notification_created = 70; - NotificationDismissedEvent notification_dismissed = 71; + // Notifications 1.0 schema tombstones. Current code never publishes or handles them. + NotificationCreatedEvent notification_created = 70 [deprecated = true]; + NotificationDismissedEvent notification_dismissed = 71 [deprecated = true]; + NotificationOccurrenceChangedEvent notification_occurrence_changed = 72; // ----- Unread indicators ----- RoomMarkedAsReadEvent room_marked_as_read = 80; @@ -96,20 +99,16 @@ message LiveEvent { // on an otherwise-healthy WebSocket. It is never persisted and never // travels over NATS. message HeartbeatEvent {} -// NotificationLevelChangedEvent is published when a user changes their notification -// level for the server or a specific room. User-scoped: only delivered to the user -// who changed it. + +// Notifications 1.0 schema tombstone retained for internal wire compatibility. +// Current code never publishes or handles this message. message NotificationLevelChangedEvent { reserved 1; reserved "space_id"; - // The room whose notification level was changed (empty for server-level changes). string room_id = 2; - // The explicitly set level. NotificationLevel level = 3; - // The effective level after inheritance resolution. NotificationLevel effective_level = 4; } - // ============================================================================ // SERVER LIFECYCLE EVENTS (deployment-scoped) // ============================================================================ @@ -156,32 +155,19 @@ message PresenceChangedEvent { string status = 1; } -// ============================================================================ -// NOTIFICATION EVENTS (server-scoped — KV tracks persistent state) -// ============================================================================ - -// Notifies a user that they were mentioned in a message. -// Published as a server-scoped live event to the mentioned user. -// This is a transient notification — the KV bucket tracks persistent state. +// Notifications 1.0 schema tombstone retained for internal wire compatibility. +// Current code never publishes or handles this message. message MentionNotificationEvent { reserved 1; reserved "space_id"; - - // Room ID where the mention occurred string room_id = 2; - - // User ID of the person who mentioned them (the message author) string mentioned_by_user_id = 3; } -// Notifies a user that a new message was posted in a DM conversation they're part of. -// Published as a server-scoped live event to all participants except the sender. -// Every DM message triggers this notification (unlike mentions which are opt-in). +// Notifications 1.0 schema tombstone retained for internal wire compatibility. +// Current code never publishes or handles this message. message NewDirectMessageNotificationEvent { - // Room ID of the DM conversation string room_id = 1; - - // User ID of the person who sent the message string sender_id = 2; } @@ -189,32 +175,39 @@ message NewDirectMessageNotificationEvent { // NOTIFICATION SYNC EVENTS (server-scoped) // ============================================================================ -// Published when a new notification is created for a user. -// Enables real-time notification updates in the UI. +// Notifications 1.0 schema tombstone retained for internal wire compatibility. +// Current code never publishes or handles this message. message NotificationCreatedEvent { - // The notification ID string notification_id = 1; - reserved 2; reserved "space_id"; - - // Navigation context (optional fields for routing) string room_id = 3; string event_id = 4; string in_reply_to_id = 5; - - // True when clients should update notification state without playing a local - // alert sound. Used for Do Not Disturb delivery. bool silent = 6; } -// Published when a notification is dismissed by the user. -// Enables cross-device sync of dismissed notifications. +// Notifications 1.0 schema tombstone retained for internal wire compatibility. +// Current code never publishes or handles this message. message NotificationDismissedEvent { - // The dismissed notification ID string notification_id = 1; } +// User-scoped invalidation for Notifications 2.0 authoritative replacement. +message NotificationOccurrenceChangedEvent { + string notification_id = 1; + bool created = 2; + bool deleted = 3; + // True only when a newly created occurrence is currently allowed to trigger + // a one-shot local alert. + bool alert = 4; + // Internal source identity used to fence the recipient's runtime-state + // watcher before an authoritative replacement is assembled. + string source_event_id = 5; + // RUNTIME_STATE KV revision that must be visible before replacement. + uint64 runtime_state_revision = 6; +} + // ============================================================================ // THREAD FOLLOW EVENTS (user-scoped) // ============================================================================ diff --git a/proto/chatto/core/v1/notification.proto b/proto/chatto/core/v1/notification.proto index 127a153b2..362f47764 100644 --- a/proto/chatto/core/v1/notification.proto +++ b/proto/chatto/core/v1/notification.proto @@ -101,3 +101,105 @@ message RoomMessageNotification { // Event ID of the message string event_id = 3; } + +// NotificationReason identifies why one source activity qualified for a +// recipient's notification list. One occurrence can retain several reasons. +enum NotificationReason { + NOTIFICATION_REASON_UNSPECIFIED = 0; + NOTIFICATION_REASON_DIRECT_MESSAGE = 1; + NOTIFICATION_REASON_DIRECT_MENTION = 2; + NOTIFICATION_REASON_REPLY = 3; + NOTIFICATION_REASON_ROLE_MENTION = 4; + NOTIFICATION_REASON_HERE = 5; + NOTIFICATION_REASON_ALL = 6; + NOTIFICATION_REASON_FOLLOWED_THREAD = 7; + NOTIFICATION_REASON_FOLLOWED_ROOM = 8; + NOTIFICATION_REASON_REACTION = 9; + NOTIFICATION_REASON_ROOM_INVITATION = 10; +} + +// NotificationDeliveryIntensity controls whether qualifying activity is +// omitted, recorded silently, or eligible for an interruptive alert. +enum NotificationDeliveryIntensity { + NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED = 0; + NOTIFICATION_DELIVERY_INTENSITY_OFF = 1; + NOTIFICATION_DELIVERY_INTENSITY_BADGE = 2; + NOTIFICATION_DELIVERY_INTENSITY_ALERT = 3; +} + +// NotificationInboxState is the persisted attention state. DONE is retained +// only for compatibility with Notifications 2.0 development records written +// before the single-list model; visible DONE records are treated as read. +enum NotificationInboxState { + NOTIFICATION_INBOX_STATE_UNSPECIFIED = 0; + NOTIFICATION_INBOX_STATE_UNREAD = 1; + NOTIFICATION_INBOX_STATE_READ = 2; + NOTIFICATION_INBOX_STATE_DONE = 3; +} + +// NotificationRemovalReason explains why an occurrence is represented only by +// an anti-recreation tombstone. +enum NotificationRemovalReason { + NOTIFICATION_REMOVAL_REASON_UNSPECIFIED = 0; + NOTIFICATION_REMOVAL_REASON_DELETED = 1; + NOTIFICATION_REMOVAL_REASON_TARGET_RETRACTED = 2; + NOTIFICATION_REMOVAL_REASON_REACTION_REMOVED = 3; + NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST = 4; + NOTIFICATION_REMOVAL_REASON_ACCOUNT_DELETED = 5; +} + +// NotificationAlertState tracks recoverable interruptive delivery for an +// occurrence. List and badge visibility do not depend on this state. +enum NotificationAlertState { + NOTIFICATION_ALERT_STATE_UNSPECIFIED = 0; + NOTIFICATION_ALERT_STATE_NOT_APPLICABLE = 1; + NOTIFICATION_ALERT_STATE_PENDING = 2; + NOTIFICATION_ALERT_STATE_CLAIMED = 3; + NOTIFICATION_ALERT_STATE_DELIVERED = 4; + NOTIFICATION_ALERT_STATE_SILENCED = 5; +} + +// NotificationReasonMatch records one matched cause and its effective policy +// result at the source activity's evaluation boundary. +message NotificationReasonMatch { + NotificationReason reason = 1; + NotificationDeliveryIntensity intensity = 2; +} + +// NotificationTarget identifies the exact activity destination without +// copying message, room, or user presentation data. +message NotificationTarget { + string room_id = 1; + optional string thread_root_event_id = 2; + string event_id = 3; + optional string parent_event_id = 4; +} + +// NotificationOccurrence is recipient-specific bounded runtime state derived +// from one canonical source event. It is stored under a deterministic +// notification_v2 key with an absolute 90-day lifetime. +message NotificationOccurrence { + string id = 1; + string recipient_id = 2; + string source_event_id = 3; + google.protobuf.Timestamp source_created_at = 4; + string actor_id = 5; + NotificationTarget target = 6; + repeated NotificationReasonMatch reasons = 7; + NotificationDeliveryIntensity strongest_intensity = 8; + NotificationInboxState inbox_state = 9; + // Exact reaction discriminator for reaction-caused occurrences. Empty for + // every other cause. + string reaction_emoji = 10; + google.protobuf.Timestamp evaluated_at = 11; + google.protobuf.Timestamp updated_at = 12; + google.protobuf.Timestamp expires_at = 13; + NotificationRemovalReason removal_reason = 14; + google.protobuf.Timestamp removed_at = 15; + NotificationAlertState alert_state = 16; + google.protobuf.Timestamp alert_claimed_until = 17; + // Internal EVT stream position of the source fact. Used for causal lifecycle + // cleanup and read-boundary reconciliation; never exposed through the public + // API. + uint64 source_stream_sequence = 18; +} diff --git a/proto/chatto/core/v1/projection_snapshots.proto b/proto/chatto/core/v1/projection_snapshots.proto index 712d6d5f1..43b7824c8 100644 --- a/proto/chatto/core/v1/projection_snapshots.proto +++ b/proto/chatto/core/v1/projection_snapshots.proto @@ -6,6 +6,7 @@ import "google/protobuf/timestamp.proto"; import "chatto/core/v1/asset_events.proto"; import "chatto/core/v1/event.proto"; import "chatto/core/v1/models.proto"; +import "chatto/core/v1/notification.proto"; import "chatto/core/v1/rbac_events.proto"; import "chatto/core/v1/room_events.proto"; import "chatto/core/v1/user_events.proto"; @@ -123,6 +124,14 @@ message RoomGroupStateSnapshot { RoomGroup group = 1; } +// NotificationVisibilityProjectionSnapshot combines the exact authorization +// state needed to enforce persistent notification visibility boundaries. +message NotificationVisibilityProjectionSnapshot { + RoomDirectoryProjectionSnapshot room_directory = 1; + RoomGroupLayoutProjectionSnapshot room_group_layout = 2; + RBACProjectionSnapshot rbac = 3; +} + message CallStateProjectionSnapshot { repeated CallRoomStateSnapshot rooms = 1; } @@ -187,16 +196,24 @@ message ConfigProjectionSnapshot { } message UserConfigSnapshot { + reserved 4, 5; + reserved "server_notification_level", "room_notification_levels"; + string user_id = 1; optional string timezone = 2; optional TimeFormat time_format = 3; - optional NotificationLevel server_notification_level = 4; - repeated RoomNotificationLevelSnapshot room_notification_levels = 5; + repeated NotificationPreferenceSnapshot server_notification_preferences = 6; + repeated RoomNotificationPreferenceSnapshot room_notification_preferences = 7; +} + +message NotificationPreferenceSnapshot { + NotificationReason reason = 1; + NotificationDeliveryIntensity intensity = 2; } -message RoomNotificationLevelSnapshot { +message RoomNotificationPreferenceSnapshot { string room_id = 1; - NotificationLevel level = 2; + repeated NotificationPreferenceSnapshot preferences = 2; } message AssetProjectionSnapshot { @@ -248,6 +265,7 @@ message EmojiReactionsSnapshot { message UserReactionSnapshot { string user_id = 1; int64 added_at_nanos = 2; + string source_event_id = 3; } message StringUint64Snapshot { string key = 1; uint64 value = 2; } diff --git a/proto/chatto/realtime/v1/realtime.proto b/proto/chatto/realtime/v1/realtime.proto index 595d73490..c357fd700 100644 --- a/proto/chatto/realtime/v1/realtime.proto +++ b/proto/chatto/realtime/v1/realtime.proto @@ -2,7 +2,6 @@ syntax = "proto3"; package chatto.realtime.v1; -import "chatto/api/v1/notification_preferences.proto"; import "chatto/api/v1/notifications.proto"; import "chatto/api/v1/member_directory.proto"; import "chatto/api/v1/message_types.proto"; @@ -249,8 +248,8 @@ message RealtimeProjectionRoom { // membership is empty in a cold room summary and complete after that room's // timeline has been materialised. repeated string member_user_ids = 2; - // Current pending notifications targeting this room for the viewer. - uint32 viewer_notification_count = 3; + reserved 3; + reserved "viewer_notification_count"; // Whether this DM room has ever received a root message. False means the // durable room exists only so its initiator can compose the first message. // Once true, message deletion does not reset it. Absent for channel rooms @@ -347,13 +346,13 @@ message RealtimeProjectionRoomTimelineEventRemove { // Finite current notification state emitted on bootstrap and every resume. message RealtimeProjectionNotificationsReplace { - // Newest pending notifications and total pending count. - chatto.api.v1.ListNotificationsResponse page = 1; - // Complete current counts for rooms with pending notifications. - repeated chatto.api.v1.RoomNotificationCount room_counts = 2; + reserved 1, 2; + reserved "page", "room_counts"; // Live transition that caused this replacement, when one exists. Bootstrap, // replay reconciliation, and compacted reset replacements omit this field. optional RealtimeProjectionNotificationChange change = 3; + // Authoritative Notifications 2.0 groups and unread group count. + chatto.api.v1.ListNotificationGroupsResponse groups = 4; } // One live notification transition accompanying authoritative current state. @@ -369,9 +368,13 @@ message RealtimeProjectionNotificationChange { // Kind of live notification transition. enum RealtimeProjectionNotificationAction { + reserved 2; + reserved "REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED"; + REALTIME_PROJECTION_NOTIFICATION_ACTION_UNSPECIFIED = 0; REALTIME_PROJECTION_NOTIFICATION_ACTION_CREATED = 1; - REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED = 2; + REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED = 3; + REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED = 4; } // Lightweight current viewer state for one projected room. @@ -461,8 +464,8 @@ message RealtimeClose { // These events have no resume cursor and are never replayed. Durable state is // delivered only as RealtimeProjectionEvent operations. message RealtimeEventEnvelope { - reserved 4, 5, 10 to 12, 20 to 21, 40 to 47, 60 to 76, 80 to 87; - reserved "cursor", "resume_cursor", "message_posted", "message_edited", "message_retracted", "reaction_added", "reaction_removed", "room_created", "room_updated", "room_deleted", "room_archived", "room_unarchived", "user_joined_room", "user_left_room", "room_universal_changed", "thread_created", "user_custom_status_set", "user_custom_status_cleared", "asset_processing_started", "asset_processing_succeeded", "asset_processing_failed", "asset_deleted", "notification_created", "notification_dismissed", "notification_level_changed", "thread_follow_changed", "room_marked_as_read", "server_updated", "user_profile_updated", "server_user_preferences_updated", "room_groups_updated", "server_member_deleted", "call_started", "call_participant_joined", "call_participant_left", "call_ended"; + reserved 4, 5, 10 to 12, 20 to 21, 40 to 47, 60 to 76, 80 to 89; + reserved "cursor", "resume_cursor", "message_posted", "message_edited", "message_retracted", "reaction_added", "reaction_removed", "room_created", "room_updated", "room_deleted", "room_archived", "room_unarchived", "user_joined_room", "user_left_room", "room_universal_changed", "thread_created", "user_custom_status_set", "user_custom_status_cleared", "asset_processing_started", "asset_processing_succeeded", "asset_processing_failed", "asset_deleted", "notification_created", "notification_dismissed", "notification_level_changed", "thread_follow_changed", "room_marked_as_read", "server_updated", "user_profile_updated", "server_user_preferences_updated", "room_groups_updated", "server_member_deleted", "call_started", "call_participant_joined", "call_participant_left", "call_ended", "mention_notification", "new_direct_message_notification"; // Stable event ID. string id = 1; @@ -475,10 +478,6 @@ message RealtimeEventEnvelope { RealtimeTypingEvent user_typing = 30; // A user's live presence changed. RealtimePresenceChangedEvent presence_changed = 31; - // The current user was mentioned in a room. - RealtimeMentionNotificationEvent mention_notification = 88; - // The current user received a new direct message. - RealtimeNewDirectMessageNotificationEvent new_direct_message_notification = 89; // The current user's session was terminated. RealtimeSessionTerminatedEvent session_terminated = 90; } @@ -506,41 +505,6 @@ message RealtimePresenceChangedEvent { chatto.api.v1.PresenceStatus status = 2; } -// Mention attention signal for the connected user. -// -// Inline names are display hints. Hydrate referenced rooms through -// `RoomDirectoryService.BatchGetRooms` and users through -// `UserService.BatchGetUsers` when local caches are missing or stale. -message RealtimeMentionNotificationEvent { - // Room where the mention occurred. - string room_id = 1; - // Author user ID. - string actor_user_id = 2; - // Display name of the room where the mention occurred, when hydrated for the caller. - optional string room_name = 3; - // Display name of the author who mentioned the connected user, when hydrated. - optional string actor_display_name = 4; -} - -// New-DM attention signal for the connected user. -// -// Inline names and avatar URLs are display hints. Hydrate the DM room through -// `RoomDirectoryService.GetRoom` or `RoomDirectoryService.BatchGetRooms`, and -// the sender through `UserService.GetUser` or `UserService.BatchGetUsers` when -// local caches are missing or stale. -message RealtimeNewDirectMessageNotificationEvent { - // DM room ID. - string room_id = 1; - // Sender user ID. - string sender_id = 2; - // Display name of the sender, when hydrated. - optional string sender_display_name = 3; - // Avatar URL of the sender, when one is available. - optional string sender_avatar_url = 4; - // Display name for the DM conversation from the connected user's perspective, when hydrated. - optional string conversation_name = 5; -} - // Session-terminated signal for the connected user's current session. message RealtimeSessionTerminatedEvent { // Termination reason. diff --git a/tools/split-connectrpc-docs.mjs b/tools/split-connectrpc-docs.mjs index 3f38975a5..a9a7b22fa 100644 --- a/tools/split-connectrpc-docs.mjs +++ b/tools/split-connectrpc-docs.mjs @@ -79,17 +79,11 @@ const categories = [ title: 'My Account', description: 'Self-service account, profile, avatar, presence, status, external identity, and settings RPCs for the authenticated user.' }, - { - name: 'NotificationPreferencesService', - slug: 'notification-preferences', - title: 'Notification Preferences', - description: 'Server and room notification preference RPCs.' - }, { name: 'NotificationService', slug: 'notifications', title: 'Notifications', - description: 'Notification listing, counts, checks, and dismissal RPCs.' + description: 'Grouped notification listing, read, deletion, and per-cause policy RPCs.' }, { name: 'PushNotificationService',