From d86799374ff06eb683342f779febeefcf75c21d3 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Mon, 10 Aug 2026 20:56:09 +0200 Subject: [PATCH 01/30] feat(notifications): add triageable notification inbox --- .../operations/notifications-web-push.mdx | 36 +- .../connectrpc-api/notifications.mdx | 349 +++ .../reference/connectrpc-api/realtime.mdx | 3 + .../docs/reference/connectrpc-api/types.mdx | 136 + .../src/generated/connectrpc-api/api.raw.mdx | 499 ++++ .../generated/connectrpc-api/realtime.raw.mdx | 3 + apps/frontend/e2e/cross-server-dots.test.ts | 6 +- apps/frontend/e2e/notifications.test.ts | 33 +- apps/frontend/e2e/pages/NotificationsPage.ts | 36 +- apps/frontend/e2e/realtime-protobuf.test.ts | 79 +- apps/frontend/messages/ar/chat.json | 12 +- apps/frontend/messages/ar/settings.json | 25 + apps/frontend/messages/cs-CZ/chat.json | 12 +- apps/frontend/messages/cs-CZ/settings.json | 25 + apps/frontend/messages/de-AT/chat.json | 12 +- apps/frontend/messages/de-AT/settings.json | 25 + apps/frontend/messages/de-CH/chat.json | 12 +- apps/frontend/messages/de-CH/settings.json | 25 + apps/frontend/messages/de-DE/chat.json | 12 +- apps/frontend/messages/de-DE/settings.json | 25 + apps/frontend/messages/en-GB/chat.json | 12 +- apps/frontend/messages/en-GB/settings.json | 25 + apps/frontend/messages/eo/chat.json | 12 +- apps/frontend/messages/eo/settings.json | 25 + apps/frontend/messages/es-419/chat.json | 12 +- apps/frontend/messages/es-419/settings.json | 25 + apps/frontend/messages/es-ES/chat.json | 12 +- apps/frontend/messages/es-ES/settings.json | 25 + apps/frontend/messages/et-EE/chat.json | 12 +- apps/frontend/messages/et-EE/settings.json | 25 + apps/frontend/messages/fr-CA/chat.json | 12 +- apps/frontend/messages/fr-CA/settings.json | 25 + apps/frontend/messages/fr-FR/chat.json | 12 +- apps/frontend/messages/fr-FR/settings.json | 25 + apps/frontend/messages/he-IL/chat.json | 12 +- apps/frontend/messages/he-IL/settings.json | 25 + apps/frontend/messages/it-IT/chat.json | 12 +- apps/frontend/messages/it-IT/settings.json | 25 + apps/frontend/messages/ja-JP/chat.json | 12 +- apps/frontend/messages/ja-JP/settings.json | 25 + apps/frontend/messages/lv-LV/chat.json | 12 +- apps/frontend/messages/lv-LV/settings.json | 25 + apps/frontend/messages/nb-NO/chat.json | 12 +- apps/frontend/messages/nb-NO/settings.json | 25 + apps/frontend/messages/nl-BE/chat.json | 12 +- apps/frontend/messages/nl-BE/settings.json | 25 + apps/frontend/messages/nl-NL/chat.json | 12 +- apps/frontend/messages/nl-NL/settings.json | 25 + apps/frontend/messages/pl-PL/chat.json | 12 +- apps/frontend/messages/pl-PL/settings.json | 25 + apps/frontend/messages/pt-BR/chat.json | 12 +- apps/frontend/messages/pt-BR/settings.json | 25 + apps/frontend/messages/pt-PT/chat.json | 12 +- apps/frontend/messages/pt-PT/settings.json | 25 + apps/frontend/messages/ru-RU/chat.json | 12 +- apps/frontend/messages/ru-RU/settings.json | 25 + apps/frontend/messages/sv-SE/chat.json | 12 +- apps/frontend/messages/sv-SE/settings.json | 25 + apps/frontend/messages/tr-TR/chat.json | 12 +- apps/frontend/messages/tr-TR/settings.json | 25 + apps/frontend/messages/uk-UA/chat.json | 12 +- apps/frontend/messages/uk-UA/settings.json | 25 + apps/frontend/messages/zh-CN/chat.json | 12 +- apps/frontend/messages/zh-CN/settings.json | 25 + apps/frontend/messages/zh-TW/chat.json | 12 +- apps/frontend/messages/zh-TW/settings.json | 25 + .../api-client-tests/notifications.spec.ts | 99 +- .../src/lib/api-client/notifications.ts | 314 +++ .../lib/components/NotificationSync.svelte | 36 +- .../NotificationSync.svelte.spec.ts | 65 +- .../NotificationPolicySettings.svelte | 150 ++ .../src/lib/hooks/usePageTitle.svelte.spec.ts | 8 +- .../src/lib/hooks/usePageTitle.svelte.ts | 2 +- .../lib/state/server/notifications.spec.ts | 146 +- .../lib/state/server/notifications.svelte.ts | 238 +- .../src/lib/state/server/projection.svelte.ts | 11 +- .../src/lib/state/server/store.svelte.spec.ts | 48 +- .../src/lib/state/server/store.svelte.ts | 7 +- .../settings/notifications/+page.svelte | 2 + .../routes/chat/notifications/+page.svelte | 458 +++- .../notifications.page.svelte.spec.ts | 113 +- cli/cmd/run.go | 118 + .../notification_occurrence_assembler.go | 135 + .../connectapi/notification_occurrences.go | 413 +++ .../connectapi/realtime_projection.go | 54 +- cli/internal/connectapi/room_services_test.go | 225 ++ cli/internal/core/config_projection.go | 38 +- .../core/config_projection_snapshot.go | 54 +- cli/internal/core/core.go | 12 + cli/internal/core/core_services.go | 6 + cli/internal/core/dm_test.go | 28 +- cli/internal/core/mentions.go | 100 +- cli/internal/core/mentions_test.go | 39 +- cli/internal/core/messages.go | 128 +- cli/internal/core/nats_recovery.go | 3 + cli/internal/core/notification_candidates.go | 142 + .../core/notification_materializer.go | 215 ++ .../core/notification_materializer_test.go | 381 +++ .../core/notification_occurrence_index.go | 394 +++ .../core/notification_occurrence_live.go | 38 + .../core/notification_occurrence_model.go | 833 ++++++ .../notification_occurrence_model_test.go | 282 ++ cli/internal/core/notification_policy.go | 229 ++ cli/internal/core/notification_policy_test.go | 97 + .../core/notification_test_helpers_test.go | 35 + .../core/projection_snapshots_test.go | 2 +- cli/internal/core/reaction_projection.go | 32 +- .../core/reaction_projection_snapshot.go | 11 +- cli/internal/core/reactions.go | 40 + cli/internal/core/reactions_test.go | 46 + cli/internal/core/read_state_model.go | 20 +- cli/internal/core/threads_test.go | 254 +- .../evtstream/events_integration_test.go | 38 + .../evtstream/incremental_effect_consumer.go | 21 +- cli/internal/evtstream/subjects.go | 46 +- .../http_server/realtime_projection.go | 79 + cli/internal/http_server/realtime_test.go | 135 +- .../v1/apiv1connect/notifications.connect.go | 354 ++- .../pb/chatto/api/v1/notifications.pb.go | 2357 ++++++++++++++++- .../pb/chatto/core/v1/config_events.pb.go | 337 ++- cli/internal/pb/chatto/core/v1/event.pb.go | 430 +-- .../pb/chatto/core/v1/live_events.pb.go | 245 +- .../pb/chatto/core/v1/message_events.pb.go | 38 +- .../pb/chatto/core/v1/notification.pb.go | 797 +++++- .../chatto/core/v1/projection_snapshots.pb.go | 507 ++-- .../pb/chatto/core/v1/reaction_events.pb.go | 66 +- .../pb/chatto/realtime/v1/realtime.pb.go | 72 +- docs/GLOSSARY.md | 8 + ...-deterministic-notification-occurrences.md | 226 ++ .../ADR-070-triageable-notification-inbox.md | 162 ++ docs/adr/INDEX.md | 2 + docs/architecture/durable-effects.md | 7 +- docs/architecture/nats-resources.md | 2 +- docs/architecture/projections.md | 2 +- docs/architecture/realtime-delivery.md | 18 +- docs/architecture/runtime-components.md | 1 + docs/architecture/runtime-state.md | 3 +- docs/architecture/subjects-and-events.md | 5 + docs/fdr/FDR-002-replies-and-threads.md | 6 +- docs/fdr/FDR-005-reactions.md | 4 +- docs/fdr/FDR-006-mentions.md | 4 +- docs/fdr/FDR-007-direct-messages.md | 4 +- docs/fdr/FDR-012-notifications.md | 300 ++- docs/fdr/FDR-013-web-push-notifications.md | 8 +- docs/fdr/INDEX.md | 12 +- .../chatto/api/v1/notifications_connect.ts | 124 +- .../src/chatto/api/v1/notifications_pb.ts | 1617 +++++++++++ .../src/chatto/realtime/v1/realtime_pb.ts | 22 +- proto/chatto/api/v1/notifications.proto | 355 +++ proto/chatto/core/v1/config_events.proto | 27 +- proto/chatto/core/v1/event.proto | 4 + proto/chatto/core/v1/live_events.proto | 16 + proto/chatto/core/v1/message_events.proto | 6 + proto/chatto/core/v1/notification.proto | 102 + .../chatto/core/v1/projection_snapshots.proto | 14 + proto/chatto/core/v1/reaction_events.proto | 11 + proto/chatto/realtime/v1/realtime.proto | 4 + 157 files changed, 15343 insertions(+), 1572 deletions(-) create mode 100644 apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte create mode 100644 cli/internal/connectapi/notification_occurrence_assembler.go create mode 100644 cli/internal/connectapi/notification_occurrences.go create mode 100644 cli/internal/core/notification_candidates.go create mode 100644 cli/internal/core/notification_materializer.go create mode 100644 cli/internal/core/notification_materializer_test.go create mode 100644 cli/internal/core/notification_occurrence_index.go create mode 100644 cli/internal/core/notification_occurrence_live.go create mode 100644 cli/internal/core/notification_occurrence_model.go create mode 100644 cli/internal/core/notification_occurrence_model_test.go create mode 100644 cli/internal/core/notification_policy.go create mode 100644 cli/internal/core/notification_policy_test.go create mode 100644 cli/internal/core/notification_test_helpers_test.go create mode 100644 docs/adr/ADR-069-deterministic-notification-occurrences.md create mode 100644 docs/adr/ADR-070-triageable-notification-inbox.md 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..98f272f10 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,34 @@ 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, Done, Saved, and Delete changes synchronize everywhere for that user. -## Notification Levels +## Inbox, Done, and Saved -Members can choose notification levels per room: +The notification centre follows a triage model: -| 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. | +- **Inbox** keeps both unread and read notifications until you move or delete them. +- **Done** holds notifications dismissed from Inbox without requiring you to open them. +- **Saved** contains bookmarked notifications from either Inbox or Done. +- **Delete** permanently removes a notification from every view. -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, but does not move them to Done. Saved and Done notifications still 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 inbox and badge counts without sound or Web Push. | +| Alert | Add it to the inbox 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,7 +97,7 @@ 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. 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..4b9fe4768 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 @@ -20,6 +20,355 @@ Shared message and enum definitions are documented in [Shared Types And Enums](/ Reads and dismisses pending notifications for the authenticated viewer. + + +### ListNotificationGroups + +Lists the Notifications 2.0 Inbox, Done, or Saved groups. + +```http +POST /api/connect/chatto.api.v1.NotificationService/ListNotificationGroups +``` + + + +#### Input: ListNotificationGroupsRequest + +Request for one page of grouped notification occurrences. + +| Field | Type | Description | +| --- | --- | --- | +| `view` | [`NotificationView`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationView) | View to list. Unspecified selects Inbox. | +| `page` | [`PageRequest`](/reference/connectrpc-api/types/#chatto-api-v1-PageRequest) | Page request. Defaults to 50 results when absent or limit is zero. | + + + + +#### Result: ListNotificationGroupsResponse + +One page of derived notification groups. + +| Field | Type | Description | +| --- | --- | --- | +| `groups` | repeated [`NotificationGroup`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationGroup) | Groups in the selected view, newest activity first. | +| `page` | [`PageInfo`](/reference/connectrpc-api/types/#chatto-api-v1-PageInfo) | Page metadata. | +| `unread_group_count` | `int32` | Total unread group count in Inbox, independent of the selected view. | +| `next_inbox_expiry_at` | `google.protobuf.Timestamp` | Earliest expiry in the complete Inbox, including groups outside this page. Clients refresh authoritative notification state at this boundary. | + + + + +### ListNotificationOccurrences + +Lists exact members of one derived notification group. + +```http +POST /api/connect/chatto.api.v1.NotificationService/ListNotificationOccurrences +``` + + + +#### Input: ListNotificationOccurrencesRequest + +Request one page of exact occurrences belonging to a derived group. + +| Field | Type | Description | +| --- | --- | --- | +| `group_id` | `string` | Required stable group ID from the selected view. | +| `view` | [`NotificationView`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationView) | View containing the group. Unspecified selects Inbox. | +| `page` | [`PageRequest`](/reference/connectrpc-api/types/#chatto-api-v1-PageRequest) | Page request. Defaults to 50 results when absent or limit is zero. | + + + + +#### Result: ListNotificationOccurrencesResponse + +One bounded page of exact notification occurrences. + +| Field | Type | Description | +| --- | --- | --- | +| `notifications` | repeated [`NotificationOccurrence`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationOccurrence) | Occurrences in newest-first order. | +| `page` | [`PageInfo`](/reference/connectrpc-api/types/#chatto-api-v1-PageInfo) | Page metadata for all occurrences in the group and selected view. | + + + + +### GetNotificationOccurrence + +Gets one visible occurrence. Returns NOT_FOUND when absent or inaccessible. + +```http +POST /api/connect/chatto.api.v1.NotificationService/GetNotificationOccurrence +``` + + + +#### Input: GetNotificationOccurrenceRequest + +Request one notification occurrence owned by the authenticated viewer. + +| Field | Type | Description | +| --- | --- | --- | +| `notification_id` | `string` | Required stable occurrence ID. | + + + + +#### Result: GetNotificationOccurrenceResponse + +One visible notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `notification` | [`NotificationOccurrence`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationOccurrence) | Requested occurrence. | + + + + +### UpdateNotificationOccurrence + +Patches one occurrence's inbox and Saved state. + +```http +POST /api/connect/chatto.api.v1.NotificationService/UpdateNotificationOccurrence +``` + + + +#### Input: UpdateNotificationOccurrenceRequest + +Patch one notification occurrence's triage state. + +| Field | Type | Description | +| --- | --- | --- | +| `notification_id` | `string` | Required stable occurrence ID. | +| `inbox_state` | optional [`NotificationInboxState`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationInboxState) | New inbox state. Omit to leave unchanged. | +| `saved` | `optional bool` | New Saved value. Omit to leave unchanged. | + + + + +#### Result: UpdateNotificationOccurrenceResponse + +Updated notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `notification` | [`NotificationOccurrence`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationOccurrence) | Occurrence after applying the patch. | + + + + +### DeleteNotificationOccurrence + +Permanently deletes one occurrence from every notification view. + +```http +POST /api/connect/chatto.api.v1.NotificationService/DeleteNotificationOccurrence +``` + + + +#### Input: DeleteNotificationOccurrenceRequest + +Request permanent deletion of one notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `notification_id` | `string` | Required stable occurrence ID. | + + + + +#### Result: DeleteNotificationOccurrenceResponse + +Result of deleting one notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `deleted` | `bool` | True when a visible occurrence was replaced by a deletion tombstone. | + + + + +### UpdateNotificationGroup + +Patches 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/UpdateNotificationGroup +``` + + + +#### Input: UpdateNotificationGroupRequest + +Patch all current members of one derived notification group. + +| Field | Type | Description | +| --- | --- | --- | +| `group_id` | `string` | Required stable group ID from the selected view. | +| `view` | [`NotificationView`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationView) | View whose current group members are updated. Unspecified selects Inbox. | +| `inbox_state` | optional [`NotificationInboxState`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationInboxState) | New inbox state. Omit to leave unchanged. | +| `saved` | `optional bool` | New Saved value. Omit to leave unchanged. | + + + + +#### Result: UpdateNotificationGroupResponse + +Bounded acknowledgement for a group patch. + +| Field | Type | Description | +| --- | --- | --- | +| `updated_count` | `int32` | Number of occurrences updated at the mutation boundary. | + + + + +### DeleteNotificationGroup + +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/DeleteNotificationGroup +``` + + + +#### Input: DeleteNotificationGroupRequest + +Request permanent deletion of one derived notification group. + +| Field | Type | Description | +| --- | --- | --- | +| `group_id` | `string` | Required stable group ID from the selected view. | +| `view` | [`NotificationView`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationView) | View whose current group members are deleted. Unspecified selects Inbox. | + + + + +#### Result: DeleteNotificationGroupResponse + +Result of deleting one derived notification group. + +| Field | Type | Description | +| --- | --- | --- | +| `deleted_count` | `int32` | Number of visible occurrences replaced by deletion tombstones. | + + + + +### UnsubscribeNotificationGroup + +Disables an ambient thread/room source and moves its current group to Done. +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/UnsubscribeNotificationGroup +``` + + + +#### Input: UnsubscribeNotificationGroupRequest + +Request to disable a group's ambient source and move it to Done. + +| Field | Type | Description | +| --- | --- | --- | +| `group_id` | `string` | Required stable group ID from the selected view. | +| `view` | [`NotificationView`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationView) | View containing the group. Unspecified selects Inbox. | + + + + +#### Result: UnsubscribeNotificationGroupResponse + +Result of disabling the group's ambient source and moving its current +occurrences to Done. Direct mentions and replies remain independently +eligible under their own policy. + +| Field | Type | Description | +| --- | --- | --- | +| `updated_count` | `int32` | Number of occurrences moved to Done by the unsubscribe action. | + + + + +### GetNotificationPolicy + +Gets every supported cause and its effective inherited delivery intensity. + +```http +POST /api/connect/chatto.api.v1.NotificationService/GetNotificationPolicy +``` + + + +#### Input: GetNotificationPolicyRequest + +Request the authenticated viewer's notification policy. + +| Field | Type | Description | +| --- | --- | --- | +| `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: GetNotificationPolicyResponse + +Complete supported notification policy for one scope. + +| Field | Type | Description | +| --- | --- | --- | +| `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. | + + + + +### SetNotificationPolicyPreference + +Sets or clears one server- or room-scoped cause override. + +```http +POST /api/connect/chatto.api.v1.NotificationService/SetNotificationPolicyPreference +``` + + + +#### Input: SetNotificationPolicyPreferenceRequest + +Set or clear one notification preference override. + +| 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: SetNotificationPolicyPreferenceResponse + +Complete supported notification policy after one preference change. + +| Field | Type | Description | +| --- | --- | --- | +| `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. | + + ### ListNotifications 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 142ed1f9f..155ab045c 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 @@ -262,6 +262,7 @@ Finite current notification state emitted on bootstrap and every resume. | `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 Inbox groups and unread group count. | @@ -558,6 +559,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 23a791a54..b22e0bd49 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 @@ -719,6 +719,27 @@ Mention notification payload. | `event_id` | `string` | Message event ID. | | `thread_root_event_id` | `optional string` | Thread root event ID when the mention happened inside a thread. | + + +### NotificationGroup + +A presentation group derived from occurrences in one view. + +| Field | Type | Description | +| --- | --- | --- | +| `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 and view, 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. | +| `all_saved` | `bool` | True when every occurrence in this group and view is saved. | +| `can_unsubscribe` | `bool` | True when the group contains an active ambient subscription that can be disabled through UnsubscribeNotificationGroup. | +| `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. | + ### NotificationItem @@ -735,6 +756,62 @@ One pending notification for the authenticated viewer. | `kind.reply` | [`ReplyNotification`](#chatto-api-v1-ReplyNotification) | Reply notification. | | `kind.room_message` | [`RoomMessageNotification`](#chatto-api-v1-RoomMessageNotification) | All-messages room notification. | + + +### NotificationOccurrence + +One exact Notifications 2.0 source occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `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. | +| `inbox_state` | [`NotificationInboxState`](#chatto-api-v1-NotificationInboxState) | Current user-controlled inbox state. | +| `saved` | `bool` | Whether the occurrence also appears in Saved. | +| `expires_at` | `google.protobuf.Timestamp` | Absolute expiry, 90 days after the source activity. | + + + +### NotificationPolicyPreference + +Explicit and effective delivery policy for one notification cause. + +| Field | Type | Description | +| --- | --- | --- | +| `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. | + + + +### NotificationReasonMatch + +One cause that matched an occurrence and its evaluated delivery intensity. + +| Field | Type | Description | +| --- | --- | --- | +| `reason` | [`NotificationReason`](#chatto-api-v1-NotificationReason) | Cause that matched the viewer. | +| `intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Effective intensity when the source activity occurred. | + + + +### NotificationTarget + +Exact visible destination of one notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `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. | + ### ReplyNotification @@ -1525,6 +1602,65 @@ 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 inbox without interruptive delivery. | +| `NOTIFICATION_DELIVERY_INTENSITY_ALERT` | `3` | Matching activity appears in the inbox and may trigger sound or push. | + + + +### NotificationInboxState + +User-controlled triage state for one notification occurrence. + +| Name | Number | Description | +| --- | --- | --- | +| `NOTIFICATION_INBOX_STATE_UNSPECIFIED` | `0` | No inbox state was specified. | +| `NOTIFICATION_INBOX_STATE_UNREAD` | `1` | The occurrence is in Inbox and contributes unread attention. | +| `NOTIFICATION_INBOX_STATE_READ` | `2` | The occurrence remains in Inbox without contributing unread attention. | +| `NOTIFICATION_INBOX_STATE_DONE` | `3` | The occurrence is removed from Inbox and retained in Done. | + + + +### 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. | + + + +### NotificationView + +Selects one derived notification-inbox view. + +| Name | Number | Description | +| --- | --- | --- | +| `NOTIFICATION_VIEW_UNSPECIFIED` | `0` | Defaults to Inbox on reads and mutations. | +| `NOTIFICATION_VIEW_INBOX` | `1` | Unread and read occurrences that have not been moved to Done. | +| `NOTIFICATION_VIEW_DONE` | `2` | Occurrences moved out of Inbox. | +| `NOTIFICATION_VIEW_SAVED` | `3` | Saved occurrences from either Inbox or Done. | + ### RoomDirectoryScope 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 1d141b356..305622428 100644 --- a/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx +++ b/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx @@ -2196,6 +2196,355 @@ Result of removing a room ban. Reads and dismisses pending notifications for the authenticated viewer. + + +### ListNotificationGroups + +Lists the Notifications 2.0 Inbox, Done, or Saved groups. + +```http +POST /api/connect/chatto.api.v1.NotificationService/ListNotificationGroups +``` + + + +#### Input: ListNotificationGroupsRequest + +Request for one page of grouped notification occurrences. + +| Field | Type | Description | +| --- | --- | --- | +| `view` | [`NotificationView`](#chatto-api-v1-NotificationView) | View to list. Unspecified selects Inbox. | +| `page` | [`PageRequest`](#chatto-api-v1-PageRequest) | Page request. Defaults to 50 results when absent or limit is zero. | + + + + +#### Result: ListNotificationGroupsResponse + +One page of derived notification groups. + +| Field | Type | Description | +| --- | --- | --- | +| `groups` | repeated [`NotificationGroup`](#chatto-api-v1-NotificationGroup) | Groups in the selected view, newest activity first. | +| `page` | [`PageInfo`](#chatto-api-v1-PageInfo) | Page metadata. | +| `unread_group_count` | `int32` | Total unread group count in Inbox, independent of the selected view. | +| `next_inbox_expiry_at` | `google.protobuf.Timestamp` | Earliest expiry in the complete Inbox, including groups outside this page. Clients refresh authoritative notification state at this boundary. | + + + + +### ListNotificationOccurrences + +Lists exact members of one derived notification group. + +```http +POST /api/connect/chatto.api.v1.NotificationService/ListNotificationOccurrences +``` + + + +#### Input: ListNotificationOccurrencesRequest + +Request one page of exact occurrences belonging to a derived group. + +| Field | Type | Description | +| --- | --- | --- | +| `group_id` | `string` | Required stable group ID from the selected view. | +| `view` | [`NotificationView`](#chatto-api-v1-NotificationView) | View containing the group. Unspecified selects Inbox. | +| `page` | [`PageRequest`](#chatto-api-v1-PageRequest) | Page request. Defaults to 50 results when absent or limit is zero. | + + + + +#### Result: ListNotificationOccurrencesResponse + +One bounded page of exact notification occurrences. + +| Field | Type | Description | +| --- | --- | --- | +| `notifications` | repeated [`NotificationOccurrence`](#chatto-api-v1-NotificationOccurrence) | Occurrences in newest-first order. | +| `page` | [`PageInfo`](#chatto-api-v1-PageInfo) | Page metadata for all occurrences in the group and selected view. | + + + + +### GetNotificationOccurrence + +Gets one visible occurrence. Returns NOT_FOUND when absent or inaccessible. + +```http +POST /api/connect/chatto.api.v1.NotificationService/GetNotificationOccurrence +``` + + + +#### Input: GetNotificationOccurrenceRequest + +Request one notification occurrence owned by the authenticated viewer. + +| Field | Type | Description | +| --- | --- | --- | +| `notification_id` | `string` | Required stable occurrence ID. | + + + + +#### Result: GetNotificationOccurrenceResponse + +One visible notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `notification` | [`NotificationOccurrence`](#chatto-api-v1-NotificationOccurrence) | Requested occurrence. | + + + + +### UpdateNotificationOccurrence + +Patches one occurrence's inbox and Saved state. + +```http +POST /api/connect/chatto.api.v1.NotificationService/UpdateNotificationOccurrence +``` + + + +#### Input: UpdateNotificationOccurrenceRequest + +Patch one notification occurrence's triage state. + +| Field | Type | Description | +| --- | --- | --- | +| `notification_id` | `string` | Required stable occurrence ID. | +| `inbox_state` | optional [`NotificationInboxState`](#chatto-api-v1-NotificationInboxState) | New inbox state. Omit to leave unchanged. | +| `saved` | `optional bool` | New Saved value. Omit to leave unchanged. | + + + + +#### Result: UpdateNotificationOccurrenceResponse + +Updated notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `notification` | [`NotificationOccurrence`](#chatto-api-v1-NotificationOccurrence) | Occurrence after applying the patch. | + + + + +### DeleteNotificationOccurrence + +Permanently deletes one occurrence from every notification view. + +```http +POST /api/connect/chatto.api.v1.NotificationService/DeleteNotificationOccurrence +``` + + + +#### Input: DeleteNotificationOccurrenceRequest + +Request permanent deletion of one notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `notification_id` | `string` | Required stable occurrence ID. | + + + + +#### Result: DeleteNotificationOccurrenceResponse + +Result of deleting one notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `deleted` | `bool` | True when a visible occurrence was replaced by a deletion tombstone. | + + + + +### UpdateNotificationGroup + +Patches 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/UpdateNotificationGroup +``` + + + +#### Input: UpdateNotificationGroupRequest + +Patch all current members of one derived notification group. + +| Field | Type | Description | +| --- | --- | --- | +| `group_id` | `string` | Required stable group ID from the selected view. | +| `view` | [`NotificationView`](#chatto-api-v1-NotificationView) | View whose current group members are updated. Unspecified selects Inbox. | +| `inbox_state` | optional [`NotificationInboxState`](#chatto-api-v1-NotificationInboxState) | New inbox state. Omit to leave unchanged. | +| `saved` | `optional bool` | New Saved value. Omit to leave unchanged. | + + + + +#### Result: UpdateNotificationGroupResponse + +Bounded acknowledgement for a group patch. + +| Field | Type | Description | +| --- | --- | --- | +| `updated_count` | `int32` | Number of occurrences updated at the mutation boundary. | + + + + +### DeleteNotificationGroup + +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/DeleteNotificationGroup +``` + + + +#### Input: DeleteNotificationGroupRequest + +Request permanent deletion of one derived notification group. + +| Field | Type | Description | +| --- | --- | --- | +| `group_id` | `string` | Required stable group ID from the selected view. | +| `view` | [`NotificationView`](#chatto-api-v1-NotificationView) | View whose current group members are deleted. Unspecified selects Inbox. | + + + + +#### Result: DeleteNotificationGroupResponse + +Result of deleting one derived notification group. + +| Field | Type | Description | +| --- | --- | --- | +| `deleted_count` | `int32` | Number of visible occurrences replaced by deletion tombstones. | + + + + +### UnsubscribeNotificationGroup + +Disables an ambient thread/room source and moves its current group to Done. +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/UnsubscribeNotificationGroup +``` + + + +#### Input: UnsubscribeNotificationGroupRequest + +Request to disable a group's ambient source and move it to Done. + +| Field | Type | Description | +| --- | --- | --- | +| `group_id` | `string` | Required stable group ID from the selected view. | +| `view` | [`NotificationView`](#chatto-api-v1-NotificationView) | View containing the group. Unspecified selects Inbox. | + + + + +#### Result: UnsubscribeNotificationGroupResponse + +Result of disabling the group's ambient source and moving its current +occurrences to Done. Direct mentions and replies remain independently +eligible under their own policy. + +| Field | Type | Description | +| --- | --- | --- | +| `updated_count` | `int32` | Number of occurrences moved to Done by the unsubscribe action. | + + + + +### GetNotificationPolicy + +Gets every supported cause and its effective inherited delivery intensity. + +```http +POST /api/connect/chatto.api.v1.NotificationService/GetNotificationPolicy +``` + + + +#### Input: GetNotificationPolicyRequest + +Request the authenticated viewer's notification policy. + +| Field | Type | Description | +| --- | --- | --- | +| `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: GetNotificationPolicyResponse + +Complete supported notification policy for one scope. + +| Field | Type | Description | +| --- | --- | --- | +| `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. | + + + + +### SetNotificationPolicyPreference + +Sets or clears one server- or room-scoped cause override. + +```http +POST /api/connect/chatto.api.v1.NotificationService/SetNotificationPolicyPreference +``` + + + +#### Input: SetNotificationPolicyPreferenceRequest + +Set or clear one notification preference override. + +| 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: SetNotificationPolicyPreferenceResponse + +Complete supported notification policy after one preference change. + +| Field | Type | Description | +| --- | --- | --- | +| `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. | + + ### ListNotifications @@ -4190,6 +4539,29 @@ Mention notification payload. + + +### NotificationGroup + +A presentation group derived from occurrences in one view. + +| Field | Type | Description | +| --- | --- | --- | +| `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 and view, 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. | +| `all_saved` | `bool` | True when every occurrence in this group and view is saved. | +| `can_unsubscribe` | `bool` | True when the group contains an active ambient subscription that can be disabled through UnsubscribeNotificationGroup. | +| `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. | + + + ### NotificationItem @@ -4208,6 +4580,70 @@ One pending notification for the authenticated viewer. + + +### NotificationOccurrence + +One exact Notifications 2.0 source occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `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. | +| `inbox_state` | [`NotificationInboxState`](#chatto-api-v1-NotificationInboxState) | Current user-controlled inbox state. | +| `saved` | `bool` | Whether the occurrence also appears in Saved. | +| `expires_at` | `google.protobuf.Timestamp` | Absolute expiry, 90 days after the source activity. | + + + + + +### NotificationPolicyPreference + +Explicit and effective delivery policy for one notification cause. + +| Field | Type | Description | +| --- | --- | --- | +| `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. | + + + + + +### NotificationReasonMatch + +One cause that matched an occurrence and its evaluated delivery intensity. + +| Field | Type | Description | +| --- | --- | --- | +| `reason` | [`NotificationReason`](#chatto-api-v1-NotificationReason) | Cause that matched the viewer. | +| `intensity` | [`NotificationDeliveryIntensity`](#chatto-api-v1-NotificationDeliveryIntensity) | Effective intensity when the source activity occurred. | + + + + + +### NotificationTarget + +Exact visible destination of one notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `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. | + + + ### ReplyNotification @@ -4589,6 +5025,69 @@ 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 inbox without interruptive delivery. | +| `NOTIFICATION_DELIVERY_INTENSITY_ALERT` | `3` | Matching activity appears in the inbox and may trigger sound or push. | + + + + +### NotificationInboxState + +User-controlled triage state for one notification occurrence. + +| Name | Number | Description | +| --- | --- | --- | +| `NOTIFICATION_INBOX_STATE_UNSPECIFIED` | `0` | No inbox state was specified. | +| `NOTIFICATION_INBOX_STATE_UNREAD` | `1` | The occurrence is in Inbox and contributes unread attention. | +| `NOTIFICATION_INBOX_STATE_READ` | `2` | The occurrence remains in Inbox without contributing unread attention. | +| `NOTIFICATION_INBOX_STATE_DONE` | `3` | The occurrence is removed from Inbox and retained in Done. | + + + + +### 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. | + + + + +### NotificationView + +Selects one derived notification-inbox view. + +| Name | Number | Description | +| --- | --- | --- | +| `NOTIFICATION_VIEW_UNSPECIFIED` | `0` | Defaults to Inbox on reads and mutations. | +| `NOTIFICATION_VIEW_INBOX` | `1` | Unread and read occurrences that have not been moved to Done. | +| `NOTIFICATION_VIEW_DONE` | `2` | Occurrences moved out of Inbox. | +| `NOTIFICATION_VIEW_SAVED` | `3` | Saved occurrences from either Inbox or Done. | + + ### 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 1d4daa834..c2b9b3db9 100644 --- a/apps/docs-website/src/generated/connectrpc-api/realtime.raw.mdx +++ b/apps/docs-website/src/generated/connectrpc-api/realtime.raw.mdx @@ -293,6 +293,7 @@ Finite current notification state emitted on bootstrap and every resume. | `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 Inbox groups and unread group count. | @@ -632,6 +633,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/notifications.test.ts b/apps/frontend/e2e/notifications.test.ts index cb3aab04f..211a8bec0 100644 --- a/apps/frontend/e2e/notifications.test.ts +++ b/apps/frontend/e2e/notifications.test.ts @@ -370,7 +370,7 @@ 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('posted in a thread you follow'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); // Verify location is shown (room and server name) @@ -574,7 +574,7 @@ test.describe('Navigation from Notifications', () => { // User A: Click notification await notificationsPage.goto(); - const notification = notificationsPage.getNotificationBySummary('replied to your message'); + const notification = notificationsPage.getNotificationBySummary('posted in a thread you follow'); await expect(notification).toBeVisible({ timeout: TIMEOUTS.REALTIME_EVENT }); await notificationsPage.clickNotification(notification); @@ -587,7 +587,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 Inbox', async ({ page, chatPage, notificationsPage, @@ -609,9 +609,11 @@ test.describe('Navigation from Notifications', () => { 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(); }); }); @@ -691,7 +693,7 @@ test.describe('Cross-Tab Sync', () => { }); }); - 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, @@ -718,16 +720,19 @@ test.describe('Cross-Tab Sync', () => { 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 Inbox 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 +758,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(); @@ -1051,7 +1056,9 @@ test.describe('Page Title Notification Count', () => { await expect(page).toHaveTitle(/^\(1\) /); // Dismiss the remaining notification - const replyNotification = notificationsPage.getNotificationBySummary('replied to your message'); + const replyNotification = notificationsPage.getNotificationBySummary( + 'posted in a thread you follow' + ); await notificationsPage.dismissNotification(replyNotification); // Title should have no count prefix diff --git a/apps/frontend/e2e/pages/NotificationsPage.ts b/apps/frontend/e2e/pages/NotificationsPage.ts index a32e55703..ae4306f55 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, grouped inbox, and triage actions. */ 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,7 @@ 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"]'); } /** @@ -81,10 +76,10 @@ export class NotificationsPage { } /** - * Get the dismiss button (X) for a specific notification. + * Get the Mark done button for a specific Inbox group. */ getDismissButton(notification: Locator): Locator { - return notification.locator('button[title="Dismiss"]'); + return notification.locator('button[title="Mark done"]'); } /** @@ -102,10 +97,17 @@ export class NotificationsPage { } /** - * Dismiss all notifications. + * Move every currently visible Inbox group to Done. */ 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.getDismissButton(this.notificationItems.first()).click(); + await expect(this.notificationItems).toHaveCount(count - 1); + } } // --- Assertions --- @@ -146,8 +148,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(); } /** @@ -160,16 +162,16 @@ export class NotificationsPage { } /** - * Assert that Clear all button is visible (only when there are notifications). + * Assert that bulk triage has visible Inbox work. */ async expectClearAllVisible(): Promise { - await expect(this.clearAllButton).toBeVisible(); + await expect(this.notificationItems.first()).toBeVisible(); } /** - * Assert that Clear all button is NOT visible (empty state). + * Assert that there is no visible Inbox work. */ async expectClearAllNotVisible(): Promise { - await expect(this.clearAllButton).not.toBeVisible(); + await expect(this.notificationItems).toHaveCount(0); } } 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..6b0785644 100644 --- a/apps/frontend/messages/ar/chat.json +++ b/apps/frontend/messages/ar/chat.json @@ -9,7 +9,17 @@ "time_now": "الآن", "time_minutes": "{count}m منذ", "time_hours": "{count}h منذ", - "time_days": "{count}d منذ" + "time_days": "{count}d منذ", + "inbox": "الوارد", + "done": "تم", + "saved": "المحفوظة", + "unread": "غير مقروء", + "activity": "نشاط جديد", + "save": "حفظ", + "unsave": "إزالة من المحفوظات", + "mark_done": "وضع علامة تم", + "restore": "نقل إلى الوارد", + "unsubscribe": "إلغاء الاشتراك ووضع علامة تم" }, "sign_out": { "title": "تسجيل الخروج", diff --git a/apps/frontend/messages/ar/settings.json b/apps/frontend/messages/ar/settings.json index b9808c343..ba651ac37 100644 --- a/apps/frontend/messages/ar/settings.json +++ b/apps/frontend/messages/ar/settings.json @@ -273,6 +273,31 @@ "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": "النشاط" + } } }, "account": { diff --git a/apps/frontend/messages/cs-CZ/chat.json b/apps/frontend/messages/cs-CZ/chat.json index 372b78710..27f021ff3 100644 --- a/apps/frontend/messages/cs-CZ/chat.json +++ b/apps/frontend/messages/cs-CZ/chat.json @@ -9,7 +9,17 @@ "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", + "inbox": "Doručené", + "done": "Hotovo", + "saved": "Uložené", + "unread": "Nepřečtené", + "activity": "Nová aktivita", + "save": "Uložit", + "unsave": "Odebrat z uložených", + "mark_done": "Označit jako hotové", + "restore": "Přesunout do doručených", + "unsubscribe": "Odhlásit odběr a označit jako hotové" }, "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..343baec11 100644 --- a/apps/frontend/messages/cs-CZ/settings.json +++ b/apps/frontend/messages/cs-CZ/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/de-AT/chat.json b/apps/frontend/messages/de-AT/chat.json index 018e07620..00e72e714 100644 --- a/apps/frontend/messages/de-AT/chat.json +++ b/apps/frontend/messages/de-AT/chat.json @@ -9,7 +9,17 @@ "time_now": "Gerade eben", "time_minutes": "vor {count} Min.", "time_hours": "vor {count} Std.", - "time_days": "vor {count} Tg." + "time_days": "vor {count} Tg.", + "inbox": "Posteingang", + "done": "Erledigt", + "saved": "Gespeichert", + "unread": "Ungelesen", + "activity": "Neue Aktivität", + "save": "Speichern", + "unsave": "Nicht mehr speichern", + "mark_done": "Als erledigt markieren", + "restore": "In den Posteingang verschieben", + "unsubscribe": "Abbestellen und als erledigt markieren" }, "sign_out": { "title": "Abmelden", diff --git a/apps/frontend/messages/de-AT/settings.json b/apps/frontend/messages/de-AT/settings.json index 86763a6db..501273c51 100644 --- a/apps/frontend/messages/de-AT/settings.json +++ b/apps/frontend/messages/de-AT/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/de-CH/chat.json b/apps/frontend/messages/de-CH/chat.json index 018e07620..00e72e714 100644 --- a/apps/frontend/messages/de-CH/chat.json +++ b/apps/frontend/messages/de-CH/chat.json @@ -9,7 +9,17 @@ "time_now": "Gerade eben", "time_minutes": "vor {count} Min.", "time_hours": "vor {count} Std.", - "time_days": "vor {count} Tg." + "time_days": "vor {count} Tg.", + "inbox": "Posteingang", + "done": "Erledigt", + "saved": "Gespeichert", + "unread": "Ungelesen", + "activity": "Neue Aktivität", + "save": "Speichern", + "unsave": "Nicht mehr speichern", + "mark_done": "Als erledigt markieren", + "restore": "In den Posteingang verschieben", + "unsubscribe": "Abbestellen und als erledigt markieren" }, "sign_out": { "title": "Abmelden", diff --git a/apps/frontend/messages/de-CH/settings.json b/apps/frontend/messages/de-CH/settings.json index 2060e92f7..f6e2434e9 100644 --- a/apps/frontend/messages/de-CH/settings.json +++ b/apps/frontend/messages/de-CH/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/de-DE/chat.json b/apps/frontend/messages/de-DE/chat.json index 018e07620..00e72e714 100644 --- a/apps/frontend/messages/de-DE/chat.json +++ b/apps/frontend/messages/de-DE/chat.json @@ -9,7 +9,17 @@ "time_now": "Gerade eben", "time_minutes": "vor {count} Min.", "time_hours": "vor {count} Std.", - "time_days": "vor {count} Tg." + "time_days": "vor {count} Tg.", + "inbox": "Posteingang", + "done": "Erledigt", + "saved": "Gespeichert", + "unread": "Ungelesen", + "activity": "Neue Aktivität", + "save": "Speichern", + "unsave": "Nicht mehr speichern", + "mark_done": "Als erledigt markieren", + "restore": "In den Posteingang verschieben", + "unsubscribe": "Abbestellen und als erledigt markieren" }, "sign_out": { "title": "Abmelden", diff --git a/apps/frontend/messages/de-DE/settings.json b/apps/frontend/messages/de-DE/settings.json index cf71e708a..ce6484346 100644 --- a/apps/frontend/messages/de-DE/settings.json +++ b/apps/frontend/messages/de-DE/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/en-GB/chat.json b/apps/frontend/messages/en-GB/chat.json index 99b984073..245fa97f2 100644 --- a/apps/frontend/messages/en-GB/chat.json +++ b/apps/frontend/messages/en-GB/chat.json @@ -9,7 +9,17 @@ "time_now": "Just now", "time_minutes": "{count}m ago", "time_hours": "{count}h ago", - "time_days": "{count}d ago" + "time_days": "{count}d ago", + "inbox": "Inbox", + "done": "Done", + "saved": "Saved", + "unread": "Unread", + "activity": "New activity", + "save": "Save", + "unsave": "Remove from saved", + "mark_done": "Mark done", + "restore": "Move to inbox", + "unsubscribe": "Unsubscribe and mark done" }, "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..3fc9a849d 100644 --- a/apps/frontend/messages/en-GB/settings.json +++ b/apps/frontend/messages/en-GB/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/eo/chat.json b/apps/frontend/messages/eo/chat.json index e10bd0818..4a8f8f02c 100644 --- a/apps/frontend/messages/eo/chat.json +++ b/apps/frontend/messages/eo/chat.json @@ -9,7 +9,17 @@ "time_now": "Ĝuste nun", "time_minutes": "{count}m antaŭ", "time_hours": "{count}h antaŭ", - "time_days": "{count}d antaŭ" + "time_days": "{count}d antaŭ", + "inbox": "Ricevujo", + "done": "Farite", + "saved": "Konservite", + "unread": "Nelegite", + "activity": "Nova aktiveco", + "save": "Konservi", + "unsave": "Forigi el konservitaj", + "mark_done": "Marki kiel farita", + "restore": "Movi al ricevujo", + "unsubscribe": "Malaboni kaj marki kiel farita" }, "sign_out": { "title": "Eliru", diff --git a/apps/frontend/messages/eo/settings.json b/apps/frontend/messages/eo/settings.json index 0de5195ee..e8de3c619 100644 --- a/apps/frontend/messages/eo/settings.json +++ b/apps/frontend/messages/eo/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/es-419/chat.json b/apps/frontend/messages/es-419/chat.json index cef9be177..ede4aa671 100644 --- a/apps/frontend/messages/es-419/chat.json +++ b/apps/frontend/messages/es-419/chat.json @@ -9,7 +9,17 @@ "time_now": "Justo ahora", "time_minutes": "Hace {count}m", "time_hours": "Hace {count}h", - "time_days": "Hace {count}d" + "time_days": "Hace {count}d", + "inbox": "Bandeja de entrada", + "done": "Listo", + "saved": "Guardado", + "unread": "No leído", + "activity": "Actividad nueva", + "save": "Guardar", + "unsave": "Quitar de guardados", + "mark_done": "Marcar como listo", + "restore": "Mover a la bandeja de entrada", + "unsubscribe": "Cancelar suscripción y marcar como listo" }, "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..7a040c17b 100644 --- a/apps/frontend/messages/es-419/settings.json +++ b/apps/frontend/messages/es-419/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/es-ES/chat.json b/apps/frontend/messages/es-ES/chat.json index cef9be177..27a64a569 100644 --- a/apps/frontend/messages/es-ES/chat.json +++ b/apps/frontend/messages/es-ES/chat.json @@ -9,7 +9,17 @@ "time_now": "Justo ahora", "time_minutes": "Hace {count}m", "time_hours": "Hace {count}h", - "time_days": "Hace {count}d" + "time_days": "Hace {count}d", + "inbox": "Bandeja de entrada", + "done": "Hecho", + "saved": "Guardado", + "unread": "No leído", + "activity": "Actividad nueva", + "save": "Guardar", + "unsave": "Quitar de guardados", + "mark_done": "Marcar como hecho", + "restore": "Mover a la bandeja de entrada", + "unsubscribe": "Cancelar suscripción y marcar como hecho" }, "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..ac5c1e657 100644 --- a/apps/frontend/messages/es-ES/settings.json +++ b/apps/frontend/messages/es-ES/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/et-EE/chat.json b/apps/frontend/messages/et-EE/chat.json index 296aef19d..a2b08bb95 100644 --- a/apps/frontend/messages/et-EE/chat.json +++ b/apps/frontend/messages/et-EE/chat.json @@ -9,7 +9,17 @@ "time_now": "Just praegu", "time_minutes": "{count}min tagasi", "time_hours": "{count}h tagasi", - "time_days": "{count}p tagasi" + "time_days": "{count}p tagasi", + "inbox": "Postkast", + "done": "Tehtud", + "saved": "Salvestatud", + "unread": "Lugemata", + "activity": "Uus tegevus", + "save": "Salvesta", + "unsave": "Eemalda salvestatutest", + "mark_done": "Märgi tehtuks", + "restore": "Teisalda postkasti", + "unsubscribe": "Loobu tellimusest ja märgi tehtuks" }, "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..e0f47b98b 100644 --- a/apps/frontend/messages/et-EE/settings.json +++ b/apps/frontend/messages/et-EE/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/fr-CA/chat.json b/apps/frontend/messages/fr-CA/chat.json index 9ad355cb0..80600e8d2 100644 --- a/apps/frontend/messages/fr-CA/chat.json +++ b/apps/frontend/messages/fr-CA/chat.json @@ -9,7 +9,17 @@ "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", + "inbox": "Boîte de réception", + "done": "Terminé", + "saved": "Enregistré", + "unread": "Non lu", + "activity": "Nouvelle activité", + "save": "Enregistrer", + "unsave": "Retirer des éléments enregistrés", + "mark_done": "Marquer comme terminé", + "restore": "Déplacer vers la boîte de réception", + "unsubscribe": "Se désabonner et marquer comme terminé" }, "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..67c442abd 100644 --- a/apps/frontend/messages/fr-CA/settings.json +++ b/apps/frontend/messages/fr-CA/settings.json @@ -273,6 +273,31 @@ "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é" + } } }, "account": { diff --git a/apps/frontend/messages/fr-FR/chat.json b/apps/frontend/messages/fr-FR/chat.json index 54776e37e..f5b9feb70 100644 --- a/apps/frontend/messages/fr-FR/chat.json +++ b/apps/frontend/messages/fr-FR/chat.json @@ -9,7 +9,17 @@ "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", + "inbox": "Boîte de réception", + "done": "Terminé", + "saved": "Enregistré", + "unread": "Non lu", + "activity": "Nouvelle activité", + "save": "Enregistrer", + "unsave": "Retirer des éléments enregistrés", + "mark_done": "Marquer comme terminé", + "restore": "Déplacer vers la boîte de réception", + "unsubscribe": "Se désabonner et marquer comme terminé" }, "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..56b19a2f9 100644 --- a/apps/frontend/messages/fr-FR/settings.json +++ b/apps/frontend/messages/fr-FR/settings.json @@ -273,6 +273,31 @@ "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é" + } } }, "account": { diff --git a/apps/frontend/messages/he-IL/chat.json b/apps/frontend/messages/he-IL/chat.json index 5fb54dc0a..55f900dd3 100644 --- a/apps/frontend/messages/he-IL/chat.json +++ b/apps/frontend/messages/he-IL/chat.json @@ -9,7 +9,17 @@ "time_now": "ממש עכשיו", "time_minutes": "{count}m לפני", "time_hours": "{count}h לפני", - "time_days": "{count}d לפני" + "time_days": "{count}d לפני", + "inbox": "תיבת דואר נכנס", + "done": "הושלם", + "saved": "נשמר", + "unread": "לא נקרא", + "activity": "פעילות חדשה", + "save": "שמירה", + "unsave": "הסרה מהשמורים", + "mark_done": "סימון כהושלם", + "restore": "העברה לתיבת הדואר הנכנס", + "unsubscribe": "ביטול הרשמה וסימון כהושלם" }, "sign_out": { "title": "צא", diff --git a/apps/frontend/messages/he-IL/settings.json b/apps/frontend/messages/he-IL/settings.json index e3bfde82c..2f31a7e96 100644 --- a/apps/frontend/messages/he-IL/settings.json +++ b/apps/frontend/messages/he-IL/settings.json @@ -273,6 +273,31 @@ "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": "פעילות" + } } }, "account": { diff --git a/apps/frontend/messages/it-IT/chat.json b/apps/frontend/messages/it-IT/chat.json index 9ba60a7b2..f388a0ada 100644 --- a/apps/frontend/messages/it-IT/chat.json +++ b/apps/frontend/messages/it-IT/chat.json @@ -9,7 +9,17 @@ "time_now": "Proprio adesso", "time_minutes": "{count}m fa", "time_hours": "{count} ore fa", - "time_days": "{count}d fa" + "time_days": "{count}d fa", + "inbox": "Posta in arrivo", + "done": "Completate", + "saved": "Salvate", + "unread": "Non letta", + "activity": "Nuova attività", + "save": "Salva", + "unsave": "Rimuovi dalle salvate", + "mark_done": "Segna come completata", + "restore": "Sposta nella posta in arrivo", + "unsubscribe": "Annulla iscrizione e segna come completata" }, "sign_out": { "title": "Esci", diff --git a/apps/frontend/messages/it-IT/settings.json b/apps/frontend/messages/it-IT/settings.json index f8177fb29..2b1ce4909 100644 --- a/apps/frontend/messages/it-IT/settings.json +++ b/apps/frontend/messages/it-IT/settings.json @@ -273,6 +273,31 @@ "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à" + } } }, "account": { diff --git a/apps/frontend/messages/ja-JP/chat.json b/apps/frontend/messages/ja-JP/chat.json index 7007ec772..1da02894f 100644 --- a/apps/frontend/messages/ja-JP/chat.json +++ b/apps/frontend/messages/ja-JP/chat.json @@ -9,7 +9,17 @@ "time_now": "ただいま", "time_minutes": "{count}分前", "time_hours": "{count}時間前", - "time_days": "{count}d 前" + "time_days": "{count}d 前", + "inbox": "受信トレイ", + "done": "完了", + "saved": "保存済み", + "unread": "未読", + "activity": "新しいアクティビティ", + "save": "保存", + "unsave": "保存済みから削除", + "mark_done": "完了にする", + "restore": "受信トレイに戻す", + "unsubscribe": "購読を解除して完了にする" }, "sign_out": { "title": "サインアウト", diff --git a/apps/frontend/messages/ja-JP/settings.json b/apps/frontend/messages/ja-JP/settings.json index d7ecb9075..7a4c6b398 100644 --- a/apps/frontend/messages/ja-JP/settings.json +++ b/apps/frontend/messages/ja-JP/settings.json @@ -273,6 +273,31 @@ "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": "アクティビティ" + } } }, "account": { diff --git a/apps/frontend/messages/lv-LV/chat.json b/apps/frontend/messages/lv-LV/chat.json index 34bf84e26..29787867b 100644 --- a/apps/frontend/messages/lv-LV/chat.json +++ b/apps/frontend/messages/lv-LV/chat.json @@ -9,7 +9,17 @@ "time_now": "Tikai tagad", "time_minutes": "Pirms {count}min", "time_hours": "Pirms {count}h", - "time_days": "Pirms {count}d" + "time_days": "Pirms {count}d", + "inbox": "Iesūtne", + "done": "Pabeigts", + "saved": "Saglabāts", + "unread": "Nelasīts", + "activity": "Jauna aktivitāte", + "save": "Saglabāt", + "unsave": "Noņemt no saglabātajiem", + "mark_done": "Atzīmēt kā pabeigtu", + "restore": "Pārvietot uz iesūtni", + "unsubscribe": "Atteikties un atzīmēt kā pabeigtu" }, "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..6be7c6c9d 100644 --- a/apps/frontend/messages/lv-LV/settings.json +++ b/apps/frontend/messages/lv-LV/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/nb-NO/chat.json b/apps/frontend/messages/nb-NO/chat.json index 7edd065d2..8c4a4141e 100644 --- a/apps/frontend/messages/nb-NO/chat.json +++ b/apps/frontend/messages/nb-NO/chat.json @@ -9,7 +9,17 @@ "time_now": "Akkurat nå", "time_minutes": "{count}m siden", "time_hours": "{count}h siden", - "time_days": "{count}d siden" + "time_days": "{count}d siden", + "inbox": "Innboks", + "done": "Ferdig", + "saved": "Lagret", + "unread": "Ulest", + "activity": "Ny aktivitet", + "save": "Lagre", + "unsave": "Fjern fra lagret", + "mark_done": "Merk som ferdig", + "restore": "Flytt til innboksen", + "unsubscribe": "Avslutt abonnementet og merk som ferdig" }, "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..373f95fe3 100644 --- a/apps/frontend/messages/nb-NO/settings.json +++ b/apps/frontend/messages/nb-NO/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/nl-BE/chat.json b/apps/frontend/messages/nl-BE/chat.json index 32c38d100..41ffcab3f 100644 --- a/apps/frontend/messages/nl-BE/chat.json +++ b/apps/frontend/messages/nl-BE/chat.json @@ -9,7 +9,17 @@ "time_now": "Zojuist", "time_minutes": "{count}m geleden", "time_hours": "{count}h geleden", - "time_days": "{count}d geleden" + "time_days": "{count}d geleden", + "inbox": "Postvak IN", + "done": "Afgehandeld", + "saved": "Opgeslagen", + "unread": "Ongelezen", + "activity": "Nieuwe activiteit", + "save": "Opslaan", + "unsave": "Verwijderen uit opgeslagen", + "mark_done": "Markeren als afgehandeld", + "restore": "Naar Postvak IN verplaatsen", + "unsubscribe": "Afmelden en markeren als afgehandeld" }, "sign_out": { "title": "Uitloggen", diff --git a/apps/frontend/messages/nl-BE/settings.json b/apps/frontend/messages/nl-BE/settings.json index 9fb15122b..b8353d612 100644 --- a/apps/frontend/messages/nl-BE/settings.json +++ b/apps/frontend/messages/nl-BE/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/nl-NL/chat.json b/apps/frontend/messages/nl-NL/chat.json index 32c38d100..41ffcab3f 100644 --- a/apps/frontend/messages/nl-NL/chat.json +++ b/apps/frontend/messages/nl-NL/chat.json @@ -9,7 +9,17 @@ "time_now": "Zojuist", "time_minutes": "{count}m geleden", "time_hours": "{count}h geleden", - "time_days": "{count}d geleden" + "time_days": "{count}d geleden", + "inbox": "Postvak IN", + "done": "Afgehandeld", + "saved": "Opgeslagen", + "unread": "Ongelezen", + "activity": "Nieuwe activiteit", + "save": "Opslaan", + "unsave": "Verwijderen uit opgeslagen", + "mark_done": "Markeren als afgehandeld", + "restore": "Naar Postvak IN verplaatsen", + "unsubscribe": "Afmelden en markeren als afgehandeld" }, "sign_out": { "title": "Uitloggen", diff --git a/apps/frontend/messages/nl-NL/settings.json b/apps/frontend/messages/nl-NL/settings.json index 9fb15122b..b8353d612 100644 --- a/apps/frontend/messages/nl-NL/settings.json +++ b/apps/frontend/messages/nl-NL/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/pl-PL/chat.json b/apps/frontend/messages/pl-PL/chat.json index c875eccb9..b481e3fe0 100644 --- a/apps/frontend/messages/pl-PL/chat.json +++ b/apps/frontend/messages/pl-PL/chat.json @@ -9,7 +9,17 @@ "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", + "inbox": "Odebrane", + "done": "Gotowe", + "saved": "Zapisane", + "unread": "Nieprzeczytane", + "activity": "Nowa aktywność", + "save": "Zapisz", + "unsave": "Usuń z zapisanych", + "mark_done": "Oznacz jako gotowe", + "restore": "Przenieś do odebranych", + "unsubscribe": "Anuluj subskrypcję i oznacz jako gotowe" }, "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..2e7f50691 100644 --- a/apps/frontend/messages/pl-PL/settings.json +++ b/apps/frontend/messages/pl-PL/settings.json @@ -273,6 +273,31 @@ "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ść" + } } }, "account": { diff --git a/apps/frontend/messages/pt-BR/chat.json b/apps/frontend/messages/pt-BR/chat.json index 349eb20a7..84f3cd8d3 100644 --- a/apps/frontend/messages/pt-BR/chat.json +++ b/apps/frontend/messages/pt-BR/chat.json @@ -9,7 +9,17 @@ "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", + "inbox": "Caixa de entrada", + "done": "Concluído", + "saved": "Salvo", + "unread": "Não lido", + "activity": "Nova atividade", + "save": "Salvar", + "unsave": "Remover dos salvos", + "mark_done": "Marcar como concluído", + "restore": "Mover para a caixa de entrada", + "unsubscribe": "Cancelar inscrição e marcar como concluído" }, "sign_out": { "title": "Sair", diff --git a/apps/frontend/messages/pt-BR/settings.json b/apps/frontend/messages/pt-BR/settings.json index 007884e0e..1e3e80bc6 100644 --- a/apps/frontend/messages/pt-BR/settings.json +++ b/apps/frontend/messages/pt-BR/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/pt-PT/chat.json b/apps/frontend/messages/pt-PT/chat.json index 75bf163b9..05c8db0ac 100644 --- a/apps/frontend/messages/pt-PT/chat.json +++ b/apps/frontend/messages/pt-PT/chat.json @@ -9,7 +9,17 @@ "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", + "inbox": "Caixa de entrada", + "done": "Concluído", + "saved": "Guardado", + "unread": "Não lido", + "activity": "Nova atividade", + "save": "Guardar", + "unsave": "Remover dos guardados", + "mark_done": "Marcar como concluído", + "restore": "Mover para a caixa de entrada", + "unsubscribe": "Cancelar subscrição e marcar como concluído" }, "sign_out": { "title": "Sair", diff --git a/apps/frontend/messages/pt-PT/settings.json b/apps/frontend/messages/pt-PT/settings.json index f2c398d23..cf69f2f20 100644 --- a/apps/frontend/messages/pt-PT/settings.json +++ b/apps/frontend/messages/pt-PT/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/ru-RU/chat.json b/apps/frontend/messages/ru-RU/chat.json index 6ec35e587..0b7b4675f 100644 --- a/apps/frontend/messages/ru-RU/chat.json +++ b/apps/frontend/messages/ru-RU/chat.json @@ -9,7 +9,17 @@ "time_now": "только что", "time_minutes": "{count} мин назад", "time_hours": "{count}ч назад", - "time_days": "{count}дн назад" + "time_days": "{count}дн назад", + "inbox": "Входящие", + "done": "Готово", + "saved": "Сохранённые", + "unread": "Непрочитанное", + "activity": "Новое событие", + "save": "Сохранить", + "unsave": "Убрать из сохранённых", + "mark_done": "Отметить как готовое", + "restore": "Вернуть во входящие", + "unsubscribe": "Отписаться и отметить как готовое" }, "sign_out": { "title": "Выйти", diff --git a/apps/frontend/messages/ru-RU/settings.json b/apps/frontend/messages/ru-RU/settings.json index fd27ed981..f5bf9626c 100644 --- a/apps/frontend/messages/ru-RU/settings.json +++ b/apps/frontend/messages/ru-RU/settings.json @@ -273,6 +273,31 @@ "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": "Активность" + } } }, "account": { diff --git a/apps/frontend/messages/sv-SE/chat.json b/apps/frontend/messages/sv-SE/chat.json index 9c99b78b6..b859b8080 100644 --- a/apps/frontend/messages/sv-SE/chat.json +++ b/apps/frontend/messages/sv-SE/chat.json @@ -9,7 +9,17 @@ "time_now": "Just nu", "time_minutes": "{count}m sedan", "time_hours": "{count}h sedan", - "time_days": "{count}d sedan" + "time_days": "{count}d sedan", + "inbox": "Inkorg", + "done": "Klart", + "saved": "Sparat", + "unread": "Oläst", + "activity": "Ny aktivitet", + "save": "Spara", + "unsave": "Ta bort från sparat", + "mark_done": "Markera som klar", + "restore": "Flytta till inkorgen", + "unsubscribe": "Avsluta prenumeration och markera som klar" }, "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..0d94a7dc7 100644 --- a/apps/frontend/messages/sv-SE/settings.json +++ b/apps/frontend/messages/sv-SE/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/tr-TR/chat.json b/apps/frontend/messages/tr-TR/chat.json index 8dee45ff7..f610b3cae 100644 --- a/apps/frontend/messages/tr-TR/chat.json +++ b/apps/frontend/messages/tr-TR/chat.json @@ -9,7 +9,17 @@ "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", + "inbox": "Gelen kutusu", + "done": "Tamamlandı", + "saved": "Kaydedilenler", + "unread": "Okunmadı", + "activity": "Yeni etkinlik", + "save": "Kaydet", + "unsave": "Kaydedilenlerden kaldır", + "mark_done": "Tamamlandı olarak işaretle", + "restore": "Gelen kutusuna taşı", + "unsubscribe": "Abonelikten çık ve tamamlandı olarak işaretle" }, "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..dc173154b 100644 --- a/apps/frontend/messages/tr-TR/settings.json +++ b/apps/frontend/messages/tr-TR/settings.json @@ -273,6 +273,31 @@ "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" + } } }, "account": { diff --git a/apps/frontend/messages/uk-UA/chat.json b/apps/frontend/messages/uk-UA/chat.json index 058ef0d74..7d275aff2 100644 --- a/apps/frontend/messages/uk-UA/chat.json +++ b/apps/frontend/messages/uk-UA/chat.json @@ -9,7 +9,17 @@ "time_now": "Щойно", "time_minutes": "{count}м тому", "time_hours": "{count}h тому", - "time_days": "{count}d тому" + "time_days": "{count}d тому", + "inbox": "Вхідні", + "done": "Готово", + "saved": "Збережені", + "unread": "Непрочитане", + "activity": "Нова активність", + "save": "Зберегти", + "unsave": "Вилучити зі збережених", + "mark_done": "Позначити як готове", + "restore": "Повернути до вхідних", + "unsubscribe": "Відписатися й позначити як готове" }, "sign_out": { "title": "Вийти", diff --git a/apps/frontend/messages/uk-UA/settings.json b/apps/frontend/messages/uk-UA/settings.json index fb7c4569c..48bdc8233 100644 --- a/apps/frontend/messages/uk-UA/settings.json +++ b/apps/frontend/messages/uk-UA/settings.json @@ -273,6 +273,31 @@ "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": "Активність" + } } }, "account": { diff --git a/apps/frontend/messages/zh-CN/chat.json b/apps/frontend/messages/zh-CN/chat.json index 1a1751d0f..8f3e99c74 100644 --- a/apps/frontend/messages/zh-CN/chat.json +++ b/apps/frontend/messages/zh-CN/chat.json @@ -9,7 +9,17 @@ "time_now": "刚刚", "time_minutes": "{count} 分钟前", "time_hours": "{count} 小时前", - "time_days": "{count} 天前" + "time_days": "{count} 天前", + "inbox": "收件箱", + "done": "已完成", + "saved": "已保存", + "unread": "未读", + "activity": "新动态", + "save": "保存", + "unsave": "取消保存", + "mark_done": "标记为已完成", + "restore": "移至收件箱", + "unsubscribe": "取消订阅并标记为已完成" }, "sign_out": { "title": "退出登录", diff --git a/apps/frontend/messages/zh-CN/settings.json b/apps/frontend/messages/zh-CN/settings.json index e1b9e1cb6..afff23d95 100644 --- a/apps/frontend/messages/zh-CN/settings.json +++ b/apps/frontend/messages/zh-CN/settings.json @@ -273,6 +273,31 @@ "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": "动态" + } } }, "account": { diff --git a/apps/frontend/messages/zh-TW/chat.json b/apps/frontend/messages/zh-TW/chat.json index afd04d95c..22385118c 100644 --- a/apps/frontend/messages/zh-TW/chat.json +++ b/apps/frontend/messages/zh-TW/chat.json @@ -9,7 +9,17 @@ "time_now": "剛剛", "time_minutes": "{count} 分鐘前", "time_hours": "{count} 小時前", - "time_days": "{count} 天前" + "time_days": "{count} 天前", + "inbox": "收件匣", + "done": "已完成", + "saved": "已儲存", + "unread": "未讀", + "activity": "新動態", + "save": "儲存", + "unsave": "取消儲存", + "mark_done": "標記為已完成", + "restore": "移至收件匣", + "unsubscribe": "取消訂閱並標記為已完成" }, "sign_out": { "title": "登出", diff --git a/apps/frontend/messages/zh-TW/settings.json b/apps/frontend/messages/zh-TW/settings.json index a7e826ad3..382873267 100644 --- a/apps/frontend/messages/zh-TW/settings.json +++ b/apps/frontend/messages/zh-TW/settings.json @@ -273,6 +273,31 @@ "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": "動態" + } } }, "account": { 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..2427a4dab 100644 --- a/apps/frontend/src/lib/api-client-tests/notifications.spec.ts +++ b/apps/frontend/src/lib/api-client-tests/notifications.spec.ts @@ -2,8 +2,17 @@ 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 { NotificationOccurrence } from '@chatto/api-types/api/v1/notifications_pb'; -import { createNotificationAPI, NotificationItemKind } from '$lib/api-client/notifications'; +import { + createNotificationAPI, + notificationOccurrence, + occurrenceAsNotificationItem, + NotificationDeliveryIntensity, + NotificationInboxState, + NotificationItemKind, + NotificationReason +} from '$lib/api-client/notifications'; const mocks = vi.hoisted(() => ({ createClient: vi.fn(), @@ -159,3 +168,91 @@ describe('createNotificationAPI', () => { ); }); }); + +describe('notification occurrence compatibility 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 + } + ], + inboxState: NotificationInboxState.UNREAD + }) + ); + + expect(occurrence.summary).toBe('Alice posted in a thread you follow'); + expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ + kind: NotificationItemKind.Reply, + replyEventId: 'reply-1', + replyInThread: 'root-1' + }); + }); + + 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 + }, + { + reason: NotificationReason.FOLLOWED_THREAD, + intensity: NotificationDeliveryIntensity.BADGE + } + ], + inboxState: NotificationInboxState.UNREAD + }) + ); + + expect(occurrence.summary).toBe('Alice mentioned you'); + expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ + kind: NotificationItemKind.Mention, + mentionEventId: 'reply-2', + mentionInThread: 'root-1' + }); + }); + + 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 + } + ], + inboxState: NotificationInboxState.UNREAD + }) + ); + + expect(occurrence.summary).toBe('Alice posted a message'); + expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ + kind: NotificationItemKind.RoomMessage, + roomMsgEventId: 'message-1' + }); + }); +}); diff --git a/apps/frontend/src/lib/api-client/notifications.ts b/apps/frontend/src/lib/api-client/notifications.ts index eb9293700..fe87549d9 100644 --- a/apps/frontend/src/lib/api-client/notifications.ts +++ b/apps/frontend/src/lib/api-client/notifications.ts @@ -2,10 +2,20 @@ 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 { + ListNotificationGroupsResponse, + ListNotificationOccurrencesResponse, ListRoomNotificationsResponse, ListNotificationsResponse, + NotificationGroup as APINotificationGroup, + NotificationOccurrence as APINotificationOccurrence, NotificationItem as APINotificationItem } from '@chatto/api-types/api/v1/notifications_pb'; +import { + NotificationDeliveryIntensity, + NotificationInboxState, + NotificationReason, + NotificationView +} 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'; export type NotificationAPIConfig = { @@ -44,6 +54,7 @@ export type DirectMessageNotificationItem = { actor?: NotificationActor | null; summary: string; room: { id: string }; + eventId?: string | null; }; export type MentionNotificationItem = { @@ -91,6 +102,66 @@ export type NotificationPage = { hasMore: boolean; }; +export type NotificationOccurrenceItem = { + id: string; + sourceEventId: string; + createdAt: string; + actor: NotificationActor | null; + summary: string; + room: { id: string; name: string } | null; + eventId: string; + threadRootId: string | null; + parentEventId: string | null; + reasons: NotificationReason[]; + reasonMatches: Array<{ + reason: NotificationReason; + intensity: NotificationDeliveryIntensity; + }>; + inboxState: NotificationInboxState; + saved: boolean; + expiresAt?: string; +}; + +export type NotificationGroupItem = { + id: string; + occurrences: NotificationOccurrenceItem[]; + openTarget: NotificationOccurrenceItem | null; + unread: boolean; + occurrenceCount: number; + latestAt: string; + reasons: NotificationReason[]; + allSaved?: boolean; + canUnsubscribe?: boolean; + nextExpiryAt?: string | null; +}; + +export type NotificationGroupPage = { + groups: NotificationGroupItem[]; + unreadGroupCount: number; + totalCount: number; + hasMore: boolean; + nextInboxExpiryAt?: string | null; +}; + +export type NotificationOccurrencePage = { + notifications: NotificationOccurrenceItem[]; + totalCount: number; + hasMore: boolean; +}; + +export { + NotificationDeliveryIntensity, + NotificationInboxState, + NotificationReason, + NotificationView +}; +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); @@ -102,6 +173,100 @@ export function createNotificationAPI(config: NotificationAPIConfig) { }; return { + async listNotificationGroups( + view = NotificationView.INBOX, + limit = 50, + offset = 0 + ): Promise { + return mapNotificationGroupPage( + await client.listNotificationGroups( + { view, page: { limit, offset } }, + { headers: headers() } + ) + ); + }, + + async listNotificationOccurrences( + groupId: string, + view = NotificationView.INBOX, + limit = 50, + offset = 0 + ): Promise { + return mapNotificationOccurrencePage( + await client.listNotificationOccurrences( + { groupId, view, page: { limit, offset } }, + { headers: headers() } + ) + ); + }, + + async updateNotificationGroup( + groupId: string, + view: NotificationView, + update: { inboxState?: NotificationInboxState; saved?: boolean } + ): Promise { + await client.updateNotificationGroup({ groupId, view, ...update }, { headers: headers() }); + }, + + async getNotificationOccurrence(notificationId: string): Promise { + const response = await client.getNotificationOccurrence( + { notificationId }, + { headers: headers() } + ); + if (!response.notification) throw new Error('Notification occurrence was not returned'); + return notificationOccurrence(response.notification); + }, + + async updateNotificationOccurrence( + notificationId: string, + update: { inboxState?: NotificationInboxState; saved?: boolean } + ): Promise { + const response = await client.updateNotificationOccurrence( + { notificationId, ...update }, + { headers: headers() } + ); + if (!response.notification) throw new Error('Updated notification was not returned'); + return notificationOccurrence(response.notification); + }, + + async deleteNotificationGroup(groupId: string, view: NotificationView): Promise { + return Number( + (await client.deleteNotificationGroup({ groupId, view }, { headers: headers() })) + .deletedCount + ); + }, + + async unsubscribeNotificationGroup(groupId: string, view: NotificationView): Promise { + await client.unsubscribeNotificationGroup({ groupId, view }, { headers: headers() }); + }, + + 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 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 + })); + }, + async listNotifications(limit = 50, offset = 0): Promise { return mapNotificationPage( await client.listNotifications({ page: { limit, offset } }, { headers: headers() }) @@ -147,6 +312,155 @@ export function mapNotificationPage( }; } +export function mapNotificationGroupPage( + response: ListNotificationGroupsResponse +): NotificationGroupPage { + return { + groups: response.groups.map(notificationGroup), + unreadGroupCount: Number(response.unreadGroupCount), + totalCount: Number(response.page?.totalCount ?? 0), + hasMore: response.page?.hasMore ?? false, + nextInboxExpiryAt: response.nextInboxExpiryAt?.toDate().toISOString() ?? null + }; +} + +export function mapNotificationOccurrencePage( + response: ListNotificationOccurrencesResponse +): NotificationOccurrencePage { + return { + notifications: response.notifications.map(notificationOccurrence), + totalCount: Number(response.page?.totalCount ?? 0), + hasMore: response.page?.hasMore ?? false + }; +} + +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], + allSaved: group.allSaved, + canUnsubscribe: group.canUnsubscribe, + nextExpiryAt: group.nextExpiryAt?.toDate().toISOString() ?? null + }; +} + +export function notificationOccurrence( + item: APINotificationOccurrence +): NotificationOccurrenceItem { + const actor = notificationActor(item.actor); + 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, + summary: occurrenceSummary(actor, reasons), + 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, + inboxState: item.inboxState, + saved: item.saved, + expiresAt: item.expiresAt?.toDate().toISOString() ?? new Date(0).toISOString() + }; +} + +export function occurrenceAsNotificationItem(item: NotificationOccurrenceItem): NotificationItem { + const base = { + id: item.id, + createdAt: item.createdAt, + actor: item.actor, + summary: item.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 + }; +} + +function occurrenceSummary(actor: NotificationActor | null, reasons: NotificationReason[]): string { + const actorName = actor?.displayName || 'Someone'; + if (reasons.includes(NotificationReason.DIRECT_MESSAGE)) return `${actorName} sent you a message`; + if (reasons.includes(NotificationReason.REACTION)) return `${actorName} reacted to your message`; + if (reasons.includes(NotificationReason.REPLY)) return `${actorName} replied to your message`; + if ( + reasons.includes(NotificationReason.DIRECT_MENTION) || + reasons.includes(NotificationReason.ROLE_MENTION) || + reasons.includes(NotificationReason.HERE) || + reasons.includes(NotificationReason.ALL) + ) { + return `${actorName} mentioned you`; + } + if (reasons.includes(NotificationReason.FOLLOWED_THREAD)) { + return `${actorName} posted in a thread you follow`; + } + if (reasons.includes(NotificationReason.FOLLOWED_ROOM)) { + return `${actorName} posted a message`; + } + return `${actorName} posted new activity`; +} + function notificationItem(item: APINotificationItem): NotificationItem | null { const actor = notificationActor(item.actor); const base = { diff --git a/apps/frontend/src/lib/components/NotificationSync.svelte b/apps/frontend/src/lib/components/NotificationSync.svelte index cf85bd8a7..a093f4dca 100644 --- a/apps/frontend/src/lib/components/NotificationSync.svelte +++ b/apps/frontend/src/lib/components/NotificationSync.svelte @@ -21,7 +21,7 @@ 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 { NotificationReason } from '$lib/api-client/notifications'; import type { ProjectionHandler } from '$lib/eventBus.svelte'; import { RealtimeProjectionNotificationAction } from '@chatto/api-types/realtime/v1/realtime_pb'; @@ -69,16 +69,16 @@ Include this component once in the application root so signed-out pages also cle const stores = serverRegistry.getStore(instance.id); if (!stores.isAuthenticated) continue; - const notifications = stores.notifications.notifications; + const unreadGroups = stores.notifications.groups.filter((group) => group.unread); const notificationTotal = stores.notifications.unreadNotificationCount; - dmCount += notifications.filter( - (notification) => notification.kind === NotificationItemKind.DirectMessage + dmCount += unreadGroups.filter((group) => + group.reasons.includes(NotificationReason.DIRECT_MESSAGE) ).length; - if (notificationTotal > 0 || notifications.length > 0) hasNotification = true; + if (notificationTotal > 0) hasNotification = true; if (!stores.notifications.hasLoaded) { allStoresLoaded = false; hasCompleteNotificationPages = false; - } else if (notificationTotal !== notifications.length) { + } else if (notificationTotal !== unreadGroups.length) { hasCompleteNotificationPages = false; } } @@ -103,4 +103,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.nextInboxExpiryAt) continue; + const boundary = new Date(stores.notifications.nextInboxExpiryAt).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..1718b7772 100644 --- a/apps/frontend/src/lib/components/NotificationSync.svelte.spec.ts +++ b/apps/frontend/src/lib/components/NotificationSync.svelte.spec.ts @@ -9,6 +9,7 @@ import { RealtimeProjectionNotificationsReplace, RealtimeProjectionOperation } from '@chatto/api-types/realtime/v1/realtime_pb'; +import { NotificationReason } from '$lib/api-client/notifications'; const { mocks } = vi.hoisted(() => { const bus = { @@ -18,9 +19,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, + nextInboxExpiryAt: null as string | null, + fetch: vi.fn(async () => {}) } }); const stores = { @@ -120,9 +124,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.nextInboxExpiryAt = null; + store.notifications.fetch.mockClear(); } }); @@ -168,10 +175,17 @@ describe('NotificationSync', () => { expect(mocks.playNotificationSound).not.toHaveBeenCalled(); }); + it('refreshes authoritative notification state at the next expiry boundary', async () => { + mocks.stores.origin.notifications.nextInboxExpiryAt = new Date().toISOString(); + await renderAndWaitForSubscription(); + + await vi.waitFor(() => expect(mocks.stores.origin.notifications.fetch).toHaveBeenCalledOnce()); + }); + it('uses an exact numeric badge for loaded DM notifications', async () => { - mocks.stores.origin.notifications.notifications = [ - { kind: 'directMessage' }, - { kind: 'directMessage' } + mocks.stores.origin.notifications.groups = [ + { unread: true, reasons: [NotificationReason.DIRECT_MESSAGE] }, + { unread: true, reasons: [NotificationReason.DIRECT_MESSAGE] } ]; mocks.stores.origin.notifications.unreadNotificationCount = 2; @@ -183,7 +197,9 @@ describe('NotificationSync', () => { }); it('uses a flag for non-DM notifications', async () => { - mocks.stores.origin.notifications.notifications = [{ kind: 'mention' }]; + mocks.stores.origin.notifications.groups = [ + { unread: true, reasons: [NotificationReason.DIRECT_MENTION] } + ]; mocks.stores.origin.notifications.unreadNotificationCount = 1; await renderAndWaitForSubscription(); @@ -194,10 +210,10 @@ describe('NotificationSync', () => { }); it('counts only DMs when a complete page also contains other notifications', async () => { - mocks.stores.origin.notifications.notifications = [ - { kind: 'mention' }, - { kind: 'directMessage' }, - { kind: 'reply' } + mocks.stores.origin.notifications.groups = [ + { unread: true, reasons: [NotificationReason.DIRECT_MENTION] }, + { unread: true, reasons: [NotificationReason.DIRECT_MESSAGE] }, + { unread: true, reasons: [NotificationReason.REPLY] } ]; mocks.stores.origin.notifications.unreadNotificationCount = 3; @@ -210,11 +226,13 @@ describe('NotificationSync', () => { it('aggregates exact DM counts across authenticated servers', async () => { mocks.servers.push({ id: 'remote' }); - mocks.stores.origin.notifications.notifications = [{ kind: 'directMessage' }]; + mocks.stores.origin.notifications.groups = [ + { unread: true, reasons: [NotificationReason.DIRECT_MESSAGE] } + ]; mocks.stores.origin.notifications.unreadNotificationCount = 1; - mocks.stores.remote.notifications.notifications = [ - { kind: 'directMessage' }, - { kind: 'mention' } + mocks.stores.remote.notifications.groups = [ + { unread: true, reasons: [NotificationReason.DIRECT_MESSAGE] }, + { unread: true, reasons: [NotificationReason.DIRECT_MENTION] } ]; mocks.stores.remote.notifications.unreadNotificationCount = 2; @@ -226,7 +244,9 @@ describe('NotificationSync', () => { }); it('uses a flag when a notification page is truncated', async () => { - mocks.stores.origin.notifications.notifications = [{ kind: 'directMessage' }]; + mocks.stores.origin.notifications.groups = [ + { unread: true, reasons: [NotificationReason.DIRECT_MESSAGE] } + ]; mocks.stores.origin.notifications.unreadNotificationCount = 3; await renderAndWaitForSubscription(); @@ -237,7 +257,9 @@ describe('NotificationSync', () => { }); it('reasserts the unchanged aggregate badge after a regular push', async () => { - mocks.stores.origin.notifications.notifications = [{ kind: 'directMessage' }]; + mocks.stores.origin.notifications.groups = [ + { unread: true, reasons: [NotificationReason.DIRECT_MESSAGE] } + ]; mocks.stores.origin.notifications.unreadNotificationCount = 1; await renderAndWaitForSubscription(); await vi.waitFor(() => @@ -260,6 +282,19 @@ describe('NotificationSync', () => { ); }); + it('clears the app badge when Inbox contains only read notifications', async () => { + mocks.stores.origin.notifications.notifications = [{ kind: 'directMessage' }]; + mocks.stores.origin.notifications.groups = [ + { unread: false, reasons: [NotificationReason.DIRECT_MESSAGE] } + ]; + + 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); 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..40e15ac28 --- /dev/null +++ b/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte @@ -0,0 +1,150 @@ + + + +

{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/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/state/server/notifications.spec.ts b/apps/frontend/src/lib/state/server/notifications.spec.ts index 57cb6dbae..d9f3b2b71 100644 --- a/apps/frontend/src/lib/state/server/notifications.spec.ts +++ b/apps/frontend/src/lib/state/server/notifications.spec.ts @@ -6,11 +6,25 @@ import { } from './notifications.svelte'; import { NotificationItemKind, + NotificationDeliveryIntensity, + NotificationInboxState, + NotificationReason, + NotificationView, type NotificationAPI, + type NotificationGroupPage, type NotificationPage } from '$lib/api-client/notifications'; type MockNotificationAPI = NotificationAPI & { + listNotificationGroups: ReturnType; + listNotificationOccurrences: ReturnType; + getNotificationOccurrence: ReturnType; + updateNotificationOccurrence: ReturnType; + updateNotificationGroup: ReturnType; + deleteNotificationGroup: ReturnType; + unsubscribeNotificationGroup: ReturnType; + getNotificationPolicy: ReturnType; + setNotificationPolicyPreference: ReturnType; listNotifications: ReturnType; listRoomNotifications: ReturnType; listRoomNotificationCounts: ReturnType; @@ -26,6 +40,47 @@ function page(items: NotificationItem[], totalCount = items.length): Notificatio }; } +function groupPage(source: NotificationPage): 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, + summary: item.summary, + 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 + } + ], + inboxState: NotificationInboxState.UNREAD, + saved: false + }; + 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, + totalCount: source.totalCount, + hasMore: source.hasMore + }; +} + function deferred() { let resolve!: (value: T | PromiseLike) => void; const promise = new Promise((res) => { @@ -45,6 +100,22 @@ function makeAPI( } = {} ): MockNotificationAPI { return { + listNotificationGroups: vi.fn().mockImplementation(async () => { + if (options.notificationsError) throw options.notificationsError; + return groupPage(options.notifications ?? page([])); + }), + listNotificationOccurrences: vi.fn().mockResolvedValue({ + notifications: [], + totalCount: 0, + hasMore: false + }), + getNotificationOccurrence: vi.fn(), + updateNotificationOccurrence: vi.fn().mockResolvedValue(undefined), + updateNotificationGroup: vi.fn().mockResolvedValue(undefined), + deleteNotificationGroup: vi.fn().mockResolvedValue(0), + unsubscribeNotificationGroup: vi.fn().mockResolvedValue(undefined), + getNotificationPolicy: vi.fn().mockResolvedValue([]), + setNotificationPolicyPreference: vi.fn().mockResolvedValue([]), listNotifications: vi.fn().mockImplementation(async () => { if (options.notificationsError) throw options.notificationsError; return options.notifications ?? page([]); @@ -106,22 +177,29 @@ 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]?.occurrences[0]?.summary).not.toContain('Tester'); + 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 +213,74 @@ describe('NotificationStore', () => { expect(store.hasLoaded).toBe(true); }); + it('keeps read Inbox 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]!.inboxState = NotificationInboxState.READ; + 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(); + 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')])); + 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 +294,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 +305,21 @@ describe('NotificationStore', () => { totalCount: null, notification: cached }); - expect(api.listRoomNotifications).not.toHaveBeenCalled(); + expect(api.listNotificationGroups).not.toHaveBeenCalled(); + }); + + it('returns one selected-view 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.fetchView(NotificationView.DONE, 50); + + expect(result.groups.map(({ id }) => id)).toEqual(['group-first']); + expect(result.hasMore).toBe(true); + expect(api.listNotificationGroups).toHaveBeenCalledWith(NotificationView.DONE, 50, 50); }); it('normalizes the room, thread, and event used by push payloads', () => { diff --git a/apps/frontend/src/lib/state/server/notifications.svelte.ts b/apps/frontend/src/lib/state/server/notifications.svelte.ts index 1de4949ce..52fabbc85 100644 --- a/apps/frontend/src/lib/state/server/notifications.svelte.ts +++ b/apps/frontend/src/lib/state/server/notifications.svelte.ts @@ -3,9 +3,17 @@ import { resolve } from '$app/paths'; import { serverIdToSegment } from '$lib/navigation'; import { NotificationItemKind, + NotificationInboxState, + NotificationView, + NotificationDeliveryIntensity, + occurrenceAsNotificationItem, type DirectMessageNotificationItem, type MentionNotificationItem, type NotificationAPI, + type NotificationGroupItem, + type NotificationGroupPage, + type NotificationPolicyItem, + type NotificationReason, type NotificationItem, type ReplyNotificationItem, type RoomMessageNotificationItem @@ -71,7 +79,7 @@ export function notificationTarget(n: NotificationItem): NotificationTarget { isDM: true, roomId: n.room.id, roomName: null, - eventId: null, + eventId: n.eventId ?? null, threadRootId: null }; } @@ -120,7 +128,11 @@ export class NotificationStore { #locallyDismissedNotificationIds = new SvelteSet(); #fetchGeneration = 0; notifications = $state([]); + groups = $state.raw([]); unreadNotificationCount = $state(0); + nextInboxExpiryAt = $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); @@ -153,11 +165,37 @@ export class NotificationStore { this.error = null; } + /** Replace Notifications 2.0 Inbox state from the realtime projection. */ + replaceGroupProjection(page: NotificationGroupPage): void { + this.#fetchGeneration++; + this.groups = page.groups; + this.notifications = page.groups + .flatMap((group) => + group.occurrences.filter( + (occurrence) => occurrence.inboxState === NotificationInboxState.UNREAD && group.unread + ) + ) + .map(occurrenceAsNotificationItem) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + .slice(0, 50); + this.unreadNotificationCount = page.unreadGroupCount; + this.nextInboxExpiryAt = page.nextInboxExpiryAt ?? 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.nextInboxExpiryAt = 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 +217,59 @@ 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, + summary: redactedNotificationSummary(occurrenceAsNotificationItem(occurrence).kind) + }; + }); + 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 + ); + } else if (originalGroups.length === 0 && removed > 0) { + // Compatibility with the legacy flat notification projection. + this.unreadNotificationCount = Math.max(0, this.unreadNotificationCount - removed); + } } /** @@ -199,9 +279,8 @@ 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; } @@ -210,9 +289,7 @@ export class NotificationStore { * Check if a specific thread has pending notifications. */ hasThreadNotification(threadRootId: string): boolean { - return this.notifications.some( - (n) => isReplyNotification(n) && n.replyInThread === threadRootId - ); + return this.notifications.some((n) => notificationTarget(n).threadRootId === threadRootId); } /** @@ -302,16 +379,10 @@ export class NotificationStore { this.error = null; try { - const page = await this.#api.listNotifications(50); + const page = await this.#api.listNotificationGroups(NotificationView.INBOX, 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,6 +394,55 @@ export class NotificationStore { } } + async fetchView(view: NotificationView, offset = 0): Promise { + const page = await this.#api.listNotificationGroups(view, 50, offset); + if (view === NotificationView.INBOX && offset === 0) this.replaceGroupProjection(page); + return page; + } + + async updateGroup( + groupId: string, + view: NotificationView, + update: { inboxState?: NotificationInboxState; saved?: boolean } + ): Promise { + await this.#api.updateNotificationGroup(groupId, view, update); + await this.fetch(); + } + + async moveGroupToDone(groupId: string, view: NotificationView): Promise { + await this.updateGroup(groupId, view, { inboxState: NotificationInboxState.DONE }); + } + + async restoreGroupToInbox(groupId: string, view: NotificationView): Promise { + await this.updateGroup(groupId, view, { inboxState: NotificationInboxState.READ }); + } + + async setGroupSaved(groupId: string, view: NotificationView, saved: boolean): Promise { + await this.updateGroup(groupId, view, { saved }); + } + + async deleteGroup(groupId: string, view: NotificationView): Promise { + await this.#api.deleteNotificationGroup(groupId, view); + await this.fetch(); + } + + async unsubscribeGroup(groupId: string, view: NotificationView): Promise { + await this.#api.unsubscribeNotificationGroup(groupId, view); + await this.fetch(); + } + + 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. * @@ -330,17 +450,39 @@ export class NotificationStore { * 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(NotificationView.INBOX, 50, offset); + const matches = page.groups + .flatMap((group) => group.occurrences) + .filter( + (occurrence) => + occurrence.inboxState === NotificationInboxState.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,7 +500,7 @@ export class NotificationStore { if (cached) { return { ok: true, totalCount: null, notification: cached }; } - return this.fetchRoomNotification(roomId); + return this.fetchRoomNotification(roomId, options); } /** @@ -369,24 +511,49 @@ export class NotificationStore { 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, inboxState: NotificationInboxState.READ } + : occurrence + ); + const unread = occurrences.some( + (occurrence) => occurrence.inboxState === NotificationInboxState.UNREAD + ); + if (group.unread && !unread) resolvedUnreadGroups++; + return { + ...group, + occurrences, + openTarget: + occurrences.find( + (occurrence) => occurrence.inboxState === NotificationInboxState.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.updateNotificationOccurrence(notificationId, { + inboxState: NotificationInboxState.READ + }); + 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; + this.unreadNotificationCount = originalCount; return false; } } @@ -511,7 +678,6 @@ export class NotificationStore { roomId: t.roomId }); } - } function redactedNotificationSummary(kind: NotificationItemKind): string { diff --git a/apps/frontend/src/lib/state/server/projection.svelte.ts b/apps/frontend/src/lib/state/server/projection.svelte.ts index 28e6ba1ce..a0156132e 100644 --- a/apps/frontend/src/lib/state/server/projection.svelte.ts +++ b/apps/frontend/src/lib/state/server/projection.svelte.ts @@ -6,7 +6,10 @@ 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, + ListNotificationsResponse +} 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 { @@ -23,6 +26,7 @@ export class ServerProjectionStore { 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 +96,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; } @@ -132,6 +137,7 @@ export class ServerProjectionStore { case 'notificationsReplace': { const replacement = operation.operation.value; this.notifications = replacement.page ?? null; + this.notificationGroups = replacement.groups ?? null; const counts = Object.fromEntries( replacement.roomCounts.map((count) => [count.roomId, count.totalCount]) ); @@ -266,6 +272,7 @@ export class ServerProjectionStore { this.rooms.clear(); this.roomGroups = []; this.notifications = null; + this.notificationGroups = null; this.activeCalls = []; this.threadViewerStates.clear(); this.timelines.clear(); 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 9771e252c..ced7a82a4 100644 --- a/apps/frontend/src/lib/state/server/store.svelte.spec.ts +++ b/apps/frontend/src/lib/state/server/store.svelte.spec.ts @@ -216,26 +216,34 @@ 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, + mapNotificationPage: vi.fn((response) => ({ + items: [], + totalCount: Number(response.page?.totalCount ?? 0), + hasMore: response.page?.hasMore ?? false + })), + createNotificationAPI: vi.fn(() => ({ + listNotifications: apiMocks.listNotifications, + listNotificationGroups: vi.fn(() => + Promise.resolve({ groups: [], totalCount: 0, hasMore: false, unreadGroupCount: 0 }) + ), + listRoomNotifications: vi.fn(), + listRoomNotificationCounts: apiMocks.listRoomNotificationCounts, + dismissNotification: vi.fn(), + dismissAllNotifications: vi.fn(), + updateNotificationOccurrence: vi.fn(), + deleteNotificationOccurrence: vi.fn(), + updateNotificationGroup: vi.fn(), + deleteNotificationGroup: vi.fn(), + unsubscribeNotificationGroup: vi.fn(), + getNotificationPolicy: vi.fn(() => Promise.resolve([])), + setNotificationPolicyPreference: vi.fn(() => Promise.resolve([])) + })) + }; +}); vi.mock('$lib/api-client/roles', () => ({ createRoleAPI: vi.fn(() => ({ diff --git a/apps/frontend/src/lib/state/server/store.svelte.ts b/apps/frontend/src/lib/state/server/store.svelte.ts index ea839eb5a..04eb4fe0e 100644 --- a/apps/frontend/src/lib/state/server/store.svelte.ts +++ b/apps/frontend/src/lib/state/server/store.svelte.ts @@ -43,7 +43,7 @@ import { removeUserSummaryCacheEntry } from '$lib/state/userSummaries.svelte'; import { avatarUserFromDirectoryMember } from './rooms.svelte'; -import { mapNotificationPage } from '$lib/api-client/notifications'; +import { mapNotificationGroupPage, mapNotificationPage } 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'; @@ -510,7 +510,10 @@ export class ServerStateStore { } case 'notificationsReplace': { const replacement = operation.operation.value; - if (replacement.page) { + if (replacement.groups) { + this.notifications.replaceGroupProjection(mapNotificationGroupPage(replacement.groups)); + this.notifications.invalidateViews(); + } else if (replacement.page) { this.notifications.replaceProjection(mapNotificationPage(replacement.page)); } break; diff --git a/apps/frontend/src/routes/chat/[serverId]/settings/notifications/+page.svelte b/apps/frontend/src/routes/chat/[serverId]/settings/notifications/+page.svelte index 05caa0ca3..f18912703 100644 --- a/apps/frontend/src/routes/chat/[serverId]/settings/notifications/+page.svelte +++ b/apps/frontend/src/routes/chat/[serverId]/settings/notifications/+page.svelte @@ -2,6 +2,7 @@ import { useServerScope } from '$lib/state/server/scope.svelte'; import { ChoiceRow, PaneHeader, Hint, FormSection } from '$lib/ui'; import { Button, RangeField } from '$lib/ui/form'; + import NotificationPolicySettings from '$lib/components/settings/NotificationPolicySettings.svelte'; import NotificationLevelSettings from '$lib/components/settings/NotificationLevelSettings.svelte'; import { userPreferences } from '$lib/state/userPreferences.svelte'; import { @@ -234,6 +235,7 @@
+ {#if showRemotePushNotice} diff --git a/apps/frontend/src/routes/chat/notifications/+page.svelte b/apps/frontend/src/routes/chat/notifications/+page.svelte index d6cd010c6..05342c897 100644 --- a/apps/frontend/src/routes/chat/notifications/+page.svelte +++ b/apps/frontend/src/routes/chat/notifications/+page.svelte @@ -1,14 +1,21 @@ @@ -138,63 +272,137 @@ title={m('chat.notifications.title')} subtitle={m('chat.notifications.subtitle')} showMobileNav - > - {#snippet actions()} - {#if allNotifications.length > 0} - - {/if} - {/snippet} - + /> + +
+ + + +
- {#if loading && allNotifications.length === 0} + {#if loading && groups.length === 0}
{m('common.loading')}
- {:else if allNotifications.length === 0} + {:else if groups.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)} + {#each groups as item (`${item.serverId}:${item.group.id}`)} + {@const occurrence = item.group.openTarget} + {@const actor = occurrence?.actor ?? null} + {@const allSaved = + item.group.allSaved ?? item.group.occurrences.every((member) => member.saved)} + {@const canUnsubscribe = + item.group.canUnsubscribe ?? + item.group.occurrences.some((member) => + member.reasonMatches.some( + (match) => + match.intensity > NotificationDeliveryIntensity.OFF && + (match.reason === NotificationReason.FOLLOWED_THREAD || + match.reason === NotificationReason.FOLLOWED_ROOM) + ) + )}
handleClick(item)} - onkeydown={(e) => e.key === 'Enter' && handleClick(item)} + class={[ + 'group flex w-full items-center gap-3 border-b border-border px-4 py-3 transition-colors hover:bg-surface', + item.group.unread && view === NotificationView.INBOX && 'bg-action/5' + ]} + data-testid="notification-group" > - {#if actor} - - {/if} - -
-

{item.notification.summary}

-

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

-
- + class="flex min-w-0 flex-1 items-center gap-3 text-left" + onclick={() => openGroup(item)} + > + {#if actor}{/if} + {#if item.group.unread && view === NotificationView.INBOX} + + {/if} + + {occurrence?.summary ?? m('chat.notifications.activity')} + + {item.serverHostname} + {#if occurrence?.room?.name} + · #{occurrence.room.name}{/if} + · {item.group.occurrenceCount} · {formatTime( + item.group.latestAt, + item.timeFormatSettings + )} + + + +
+ + {#if view === NotificationView.INBOX} + {#if canUnsubscribe} + + {/if} + + {:else if view === NotificationView.DONE} + + {/if} + +
{/each} + {#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..688c1a2f8 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 @@ -4,6 +4,7 @@ import { q } from '$lib/test-utils'; import { loadLocaleMessages } from '$lib/i18n/messages'; import { setReactiveLocale } from '$lib/i18n/state.svelte'; import { TimeFormat } from '@chatto/api-types/api/v1/viewer_pb'; +import { NotificationInboxState } from '@chatto/api-types/api/v1/notifications_pb'; const { mocks } = vi.hoisted(() => ({ mocks: { @@ -13,15 +14,20 @@ const { mocks } = vi.hoisted(() => ({ appUi: { disableRoomCallWideFor: vi.fn() }, - notification: { + 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 }], + inboxState: 1, + saved: false }, store: { isAuthenticated: true, @@ -37,13 +43,13 @@ const { mocks } = vi.hoisted(() => ({ 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') + fetchView: vi.fn(), + updateGroup: vi.fn().mockResolvedValue(undefined), + moveGroupToDone: vi.fn().mockResolvedValue(undefined), + restoreGroupToInbox: vi.fn().mockResolvedValue(undefined), + setGroupSaved: vi.fn().mockResolvedValue(undefined), + deleteGroup: vi.fn().mockResolvedValue(undefined), + unsubscribeGroup: vi.fn().mockResolvedValue(undefined) }, pendingHighlights: { set: vi.fn() @@ -61,7 +67,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)) } })); @@ -76,11 +84,21 @@ describe('notifications page', () => { vi.clearAllMocks(); 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'); + const group = { + id: 'group-1', + occurrences: [mocks.occurrence], + openTarget: mocks.occurrence, + unread: true, + occurrenceCount: 1, + latestAt: mocks.occurrence.createdAt, + reasons: [2] + }; + mocks.store.notifications.fetchView.mockResolvedValue({ + groups: [group], + unreadGroupCount: 1, + totalCount: 1, + hasMore: false + }); mocks.servers.splice(0, mocks.servers.length, { id: 'origin', url: 'https://chat.example.test' @@ -92,8 +110,10 @@ describe('notifications page', () => { it('reveals the target room before navigating from a notification row', async () => { const { container } = render(NotificationsPage); - const item = q(container, '[data-testid="notification-item"]') as HTMLElement; - await expect.element(item).toBeInTheDocument(); + await vi.waitFor(() => { + expect(q(container, '[data-testid="notification-group"] button')).not.toBeNull(); + }); + const item = q(container, '[data-testid="notification-group"] button') as HTMLElement; item.click(); await vi.waitFor(() => { @@ -106,7 +126,9 @@ describe('notifications page', () => { 'thread-1', 'event-1' ); - expect(mocks.store.notifications.dismiss).toHaveBeenCalledWith('mention-1'); + expect(mocks.store.notifications.updateGroup).toHaveBeenCalledWith('group-1', 1, { + inboxState: NotificationInboxState.READ + }); expect(mocks.goto).toHaveBeenCalledWith('/chat/-/room-1/thread-1'); }); }); @@ -117,7 +139,23 @@ describe('notifications page', () => { timezone: 'UTC', timeFormat: TimeFormat.TIME_FORMAT_24_HOUR }; - mocks.store.notifications.notifications = [{ ...mocks.notification, createdAt }]; + const localOccurrence = { ...mocks.occurrence, createdAt }; + mocks.store.notifications.fetchView.mockResolvedValue({ + groups: [ + { + id: 'local', + occurrences: [localOccurrence], + openTarget: localOccurrence, + unread: true, + occurrenceCount: 1, + latestAt: createdAt, + reasons: [2] + } + ], + unreadGroupCount: 1, + totalCount: 1, + hasMore: false + }); const remoteStore = { ...mocks.store, @@ -132,14 +170,29 @@ describe('notifications page', () => { serverInfo: { name: 'Remote Server' }, notifications: { ...mocks.store.notifications, - notifications: [ - { - ...mocks.notification, - id: 'mention-remote', - createdAt, - summary: 'Remote mention' - } - ] + fetchView: vi.fn().mockResolvedValue({ + groups: [ + { + id: 'remote', + occurrences: [ + { ...mocks.occurrence, id: 'mention-remote', createdAt, summary: 'Remote mention' } + ], + openTarget: { + ...mocks.occurrence, + id: 'mention-remote', + createdAt, + summary: 'Remote mention' + }, + unread: true, + occurrenceCount: 1, + latestAt: createdAt, + reasons: [2] + } + ], + unreadGroupCount: 1, + totalCount: 1, + hasMore: false + }) } }; mocks.servers.push({ id: 'remote', url: 'https://remote.example.test' }); diff --git a/cli/cmd/run.go b/cli/cmd/run.go index c1a0620d9..4e6871009 100644 --- a/cli/cmd/run.go +++ b/cli/cmd/run.go @@ -494,6 +494,89 @@ func setupPushNotifications(chattoCore *core.ChattoCore, cfg config.ChattoConfig } } + chattoCore.OnNotificationOccurrenceCreated = func(ctx context.Context, occurrence *corev1.NotificationOccurrence) error { + visible, err := chattoCore.NotificationOccurrences().TargetVisible(ctx, occurrence.GetRecipientId(), occurrence) + if err != nil { + return fmt.Errorf("revalidate notification target visibility: %w", err) + } + if !visible { + _, _ = chattoCore.NotificationOccurrences().Delete(ctx, occurrence.GetRecipientId(), occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return nil + } + notification := legacyNotificationForOccurrence(occurrence) + if notification == 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 nil + } + actorName := "Someone" + if occurrence.GetActorId() != "" { + if actor, actorErr := chattoCore.GetUser(ctx, occurrence.GetActorId()); actorErr == nil && actor != nil { + actorName = actor.DisplayName + if actorName == "" { + actorName = actor.Login + } + } + } + payload := push.BuildPayloadFromNotification(notification, actorName, cfg.Webserver.URL, fetchPayloadContext(ctx, chattoCore, notification, logger)) + if count, countErr := chattoCore.NotificationOccurrences().UnreadGroupCount(ctx, occurrence.GetRecipientId()); countErr == nil { + payload.AppBadge = strconv.Itoa(count) + } + + // 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 + } + visible, err = chattoCore.NotificationOccurrences().TargetVisible(ctx, occurrence.GetRecipientId(), occurrence) + if err != nil { + return fmt.Errorf("revalidate notification target visibility before delivery: %w", err) + } + if !visible { + _, _ = chattoCore.NotificationOccurrences().Delete(ctx, occurrence.GetRecipientId(), occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return nil + } + subscriptions = filterOwnedPushSubscriptions(ctx, chattoCore, occurrence.GetRecipientId(), subscriptions, logger) + if len(subscriptions) == 0 { + return nil + } + renewed, renewedClaim, err := chattoCore.NotificationOccurrences().RenewAlertClaim(ctx, occurrence) + if err != nil || !renewedClaim { + return err + } + // CompleteAlertClaim fences on this exact renewed timestamp. + occurrence.AlertClaimedUntil = renewed.GetAlertClaimedUntil() + results := sender.SendToMany(ctx, subscriptions, payload) + var sendErr error + accepted := false + for _, result := range results { + if result.Gone { + _ = 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 + } + // 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 @@ -554,6 +637,41 @@ func setupPushNotifications(chattoCore *core.ChattoCore, cfg config.ChattoConfig } } +func legacyNotificationForOccurrence(occurrence *corev1.NotificationOccurrence) *corev1.Notification { + if occurrence == nil || occurrence.GetTarget() == nil { + return nil + } + target := occurrence.GetTarget() + notification := &corev1.Notification{ + Id: occurrence.GetId(), + RecipientId: occurrence.GetRecipientId(), + CreatedAt: occurrence.GetSourceCreatedAt(), + ActorId: occurrence.GetActorId(), + } + hasReason := func(reason corev1.NotificationReason) bool { + for _, match := range occurrence.GetReasons() { + if match.GetReason() == reason { + return true + } + } + return false + } + switch { + case hasReason(corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MESSAGE): + notification.Notification = &corev1.Notification_DmMessage{DmMessage: &corev1.DMMessageNotification{RoomId: target.GetRoomId(), EventId: target.GetEventId()}} + case hasReason(corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION), + hasReason(corev1.NotificationReason_NOTIFICATION_REASON_ROLE_MENTION), + hasReason(corev1.NotificationReason_NOTIFICATION_REASON_HERE), + hasReason(corev1.NotificationReason_NOTIFICATION_REASON_ALL): + notification.Notification = &corev1.Notification_Mention{Mention: &corev1.MentionNotification{RoomId: target.GetRoomId(), EventId: target.GetEventId(), InThread: target.GetThreadRootEventId()}} + case hasReason(corev1.NotificationReason_NOTIFICATION_REASON_REPLY): + notification.Notification = &corev1.Notification_Reply{Reply: &corev1.ReplyNotification{RoomId: target.GetRoomId(), EventId: target.GetEventId(), InReplyToId: target.GetParentEventId(), InThread: target.GetThreadRootEventId()}} + default: + notification.Notification = &corev1.Notification_RoomMessage{RoomMessage: &corev1.RoomMessageNotification{RoomId: target.GetRoomId(), EventId: target.GetEventId()}} + } + return notification +} + func filterOwnedPushSubscriptions( ctx context.Context, chattoCore *core.ChattoCore, diff --git a/cli/internal/connectapi/notification_occurrence_assembler.go b/cli/internal/connectapi/notification_occurrence_assembler.go new file mode 100644 index 000000000..cb2528bb6 --- /dev/null +++ b/cli/internal/connectapi/notification_occurrence_assembler.go @@ -0,0 +1,135 @@ +package connectapi + +import ( + "context" + "sort" + + "hmans.de/chatto/internal/core" + apiv1 "hmans.de/chatto/internal/pb/chatto/api/v1" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +const notificationGroupOccurrencePreviewLimit = 20 + +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 + } + 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()), + InboxState: apiv1.NotificationInboxState(occurrence.GetInboxState()), + Saved: occurrence.GetSaved(), + ExpiresAt: occurrence.GetExpiresAt(), + }, nil +} + +func (a *notificationAssembler) group(ctx context.Context, group core.NotificationOccurrenceGroup) (*apiv1.NotificationGroup, error) { + if len(group.Occurrences) == 0 { + return nil, nil + } + unread := false + allSaved := true + canUnsubscribe := 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 + } + } + allSaved = allSaved && occurrence.GetSaved() + 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{}{} + active := match.GetIntensity() > corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF + canUnsubscribe = canUnsubscribe || active && (match.GetReason() == corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD || match.GetReason() == corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM) + } + } + 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 := make([]*apiv1.NotificationOccurrence, 0, len(preview)) + openPreviewIndex := 0 + for _, occurrence := range preview { + item, err := a.occurrence(ctx, occurrence) + if err != nil { + return nil, err + } + items = append(items, item) + if occurrence.GetId() == openOccurrence.GetId() { + openPreviewIndex = len(items) - 1 + } + } + 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, + AllSaved: allSaved, + CanUnsubscribe: canUnsubscribe, + 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..e88b89392 --- /dev/null +++ b/cli/internal/connectapi/notification_occurrences.go @@ -0,0 +1,413 @@ +package connectapi + +import ( + "context" + "errors" + + "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" +) + +func notificationOccurrenceView(view apiv1.NotificationView) (core.NotificationOccurrenceView, error) { + switch view { + case apiv1.NotificationView_NOTIFICATION_VIEW_UNSPECIFIED, + apiv1.NotificationView_NOTIFICATION_VIEW_INBOX: + return core.NotificationOccurrenceViewInbox, nil + case apiv1.NotificationView_NOTIFICATION_VIEW_DONE: + return core.NotificationOccurrenceViewDone, nil + case apiv1.NotificationView_NOTIFICATION_VIEW_SAVED: + return core.NotificationOccurrenceViewSaved, nil + default: + return 0, core.ErrInvalidArgument + } +} + +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 + } + view, err := notificationOccurrenceView(req.Msg.GetView()) + if err != nil { + return nil, connectError(err) + } + groups, err := s.api.core.NotificationOccurrences().Groups(ctx, caller.UserID, view) + if err != nil { + return nil, connectError(err) + } + groups, err = s.visibleNotificationGroups(ctx, caller.UserID, groups) + if err != nil { + return nil, connectError(err) + } + limit, offset := apiPagination(req.Msg.GetPage(), defaultNotificationLimit, maxNotificationLimit) + page, total, hasMore := apiSlicePage(groups, limit, offset) + assembler := newNotificationAssembler(s.api) + hydrated := make([]*apiv1.NotificationGroup, 0, len(page)) + for _, group := range page { + item, err := assembler.group(ctx, group) + if err != nil { + return nil, connectError(err) + } + if item != nil { + hydrated = append(hydrated, item) + } + } + inboxGroups := groups + if view != core.NotificationOccurrenceViewInbox { + inboxGroups, err = s.api.core.NotificationOccurrences().Groups(ctx, caller.UserID, core.NotificationOccurrenceViewInbox) + if err == nil { + inboxGroups, err = s.visibleNotificationGroups(ctx, caller.UserID, inboxGroups) + } + if err != nil { + return nil, connectError(err) + } + } + unreadGroupCount := 0 + var nextInboxExpiryAt *timestamppb.Timestamp + for _, group := range inboxGroups { + groupUnread := false + for _, occurrence := range group.Occurrences { + if nextInboxExpiryAt == nil || occurrence.GetExpiresAt().AsTime().Before(nextInboxExpiryAt.AsTime()) { + nextInboxExpiryAt = occurrence.GetExpiresAt() + } + groupUnread = groupUnread || occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD + } + if groupUnread { + unreadGroupCount++ + } + } + return connect.NewResponse(&apiv1.ListNotificationGroupsResponse{ + Groups: hydrated, + Page: apiPageInfo(total, hasMore), + UnreadGroupCount: int32(unreadGroupCount), + NextInboxExpiryAt: nextInboxExpiryAt, + }), nil +} + +func (s *notificationService) ListNotificationOccurrences(ctx context.Context, req *connect.Request[apiv1.ListNotificationOccurrencesRequest]) (*connect.Response[apiv1.ListNotificationOccurrencesResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + view, err := notificationOccurrenceView(req.Msg.GetView()) + if err != nil { + return nil, connectError(err) + } + groups, err := s.api.core.NotificationOccurrences().Groups(ctx, caller.UserID, view) + if err != nil { + return nil, connectError(err) + } + groups, err = s.visibleNotificationGroups(ctx, caller.UserID, groups) + if err != nil { + return nil, connectError(err) + } + var occurrences []*corev1.NotificationOccurrence + for _, group := range groups { + if group.ID == req.Msg.GetGroupId() { + occurrences = group.Occurrences + break + } + } + if occurrences == nil { + return nil, connectError(core.ErrNotFound) + } + limit, offset := apiPagination(req.Msg.GetPage(), defaultNotificationLimit, maxNotificationLimit) + page, total, hasMore := apiSlicePage(occurrences, limit, offset) + assembler := newNotificationAssembler(s.api) + hydrated := make([]*apiv1.NotificationOccurrence, 0, len(page)) + for _, occurrence := range page { + item, err := assembler.occurrence(ctx, occurrence) + if err != nil { + return nil, connectError(err) + } + hydrated = append(hydrated, item) + } + return connect.NewResponse(&apiv1.ListNotificationOccurrencesResponse{ + Notifications: hydrated, + Page: apiPageInfo(total, hasMore), + }), nil +} + +func (s *notificationService) GetNotificationOccurrence(ctx context.Context, req *connect.Request[apiv1.GetNotificationOccurrenceRequest]) (*connect.Response[apiv1.GetNotificationOccurrenceResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + occurrence, 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, occurrence) + if err != nil { + return nil, connectError(err) + } + if !visible { + _, _ = s.api.core.NotificationOccurrences().Delete(ctx, caller.UserID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return nil, connectError(core.ErrNotFound) + } + item, err := newNotificationAssembler(s.api).occurrence(ctx, occurrence) + if err != nil { + return nil, connectError(err) + } + return connect.NewResponse(&apiv1.GetNotificationOccurrenceResponse{Notification: item}), nil +} + +func (s *notificationService) visibleNotificationGroups(ctx context.Context, userID string, groups []core.NotificationOccurrenceGroup) ([]core.NotificationOccurrenceGroup, error) { + visible := make([]core.NotificationOccurrenceGroup, 0, len(groups)) + for _, group := range groups { + if len(group.Occurrences) == 0 { + continue + } + allowed, err := s.notificationOccurrenceVisible(ctx, userID, group.Occurrences[0]) + if err != nil { + return nil, err + } + if !allowed { + for _, occurrence := range group.Occurrences { + _, _ = s.api.core.NotificationOccurrences().Delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + } + continue + } + visible = append(visible, group) + } + return visible, nil +} + +func (s *notificationService) notificationOccurrenceVisible(ctx context.Context, userID string, occurrence *corev1.NotificationOccurrence) (bool, error) { + room, err := s.api.core.FindRoomByID(ctx, occurrence.GetTarget().GetRoomId()) + if errors.Is(err, core.ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + member, err := s.api.core.RoomMembershipExists(ctx, core.KindOfRoom(room), userID, room.GetId()) + return member, err +} + +func occurrenceUpdate(inboxState *apiv1.NotificationInboxState, saved *bool) core.UpdateNotificationOccurrenceInput { + input := core.UpdateNotificationOccurrenceInput{Saved: saved} + if inboxState != nil { + value := corev1.NotificationInboxState(*inboxState) + input.InboxState = &value + } + return input +} + +func (s *notificationService) UpdateNotificationOccurrence(ctx context.Context, req *connect.Request[apiv1.UpdateNotificationOccurrenceRequest]) (*connect.Response[apiv1.UpdateNotificationOccurrenceResponse], error) { + caller, err := requireCaller(ctx) + if 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 || !visible { + if !visible { + _, _ = s.api.core.NotificationOccurrences().Delete(ctx, caller.UserID, existing.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + err = core.ErrNotFound + } + return nil, connectError(err) + } + occurrence, err := s.api.core.NotificationOccurrences().Update(ctx, caller.UserID, req.Msg.GetNotificationId(), occurrenceUpdate(req.Msg.InboxState, req.Msg.Saved)) + 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.UpdateNotificationOccurrenceResponse{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 + } + 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) UpdateNotificationGroup(ctx context.Context, req *connect.Request[apiv1.UpdateNotificationGroupRequest]) (*connect.Response[apiv1.UpdateNotificationGroupResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + view, err := notificationOccurrenceView(req.Msg.GetView()) + if err != nil { + return nil, connectError(err) + } + if err := s.requireVisibleNotificationGroup(ctx, caller.UserID, req.Msg.GetGroupId(), view); err != nil { + return nil, connectError(err) + } + updated, err := s.api.core.NotificationOccurrences().UpdateGroup(ctx, caller.UserID, req.Msg.GetGroupId(), view, occurrenceUpdate(req.Msg.InboxState, req.Msg.Saved)) + if err != nil { + return nil, connectError(err) + } + return connect.NewResponse(&apiv1.UpdateNotificationGroupResponse{UpdatedCount: int32(len(updated))}), nil +} + +func (s *notificationService) requireVisibleNotificationGroup(ctx context.Context, userID, groupID string, view core.NotificationOccurrenceView) error { + groups, err := s.api.core.NotificationOccurrences().Groups(ctx, userID, view) + if err != nil { + return err + } + for _, group := range groups { + if group.ID != groupID || len(group.Occurrences) == 0 { + continue + } + visible, err := s.notificationOccurrenceVisible(ctx, userID, group.Occurrences[0]) + if err != nil { + return err + } + if visible { + return nil + } + for _, occurrence := range group.Occurrences { + _, _ = s.api.core.NotificationOccurrences().Delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + } + 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 + } + view, err := notificationOccurrenceView(req.Msg.GetView()) + if err != nil { + return nil, connectError(err) + } + count, err := s.api.core.NotificationOccurrences().DeleteGroup(ctx, caller.UserID, req.Msg.GetGroupId(), view) + if err != nil { + return nil, connectError(err) + } + return connect.NewResponse(&apiv1.DeleteNotificationGroupResponse{DeletedCount: int32(count)}), nil +} + +func (s *notificationService) UnsubscribeNotificationGroup(ctx context.Context, req *connect.Request[apiv1.UnsubscribeNotificationGroupRequest]) (*connect.Response[apiv1.UnsubscribeNotificationGroupResponse], error) { + caller, err := requireCaller(ctx) + if err != nil { + return nil, err + } + view, err := notificationOccurrenceView(req.Msg.GetView()) + if err != nil { + return nil, connectError(err) + } + if err := s.requireVisibleNotificationGroup(ctx, caller.UserID, req.Msg.GetGroupId(), view); err != nil { + return nil, connectError(err) + } + groups, err := s.api.core.NotificationOccurrences().Groups(ctx, caller.UserID, view) + if err != nil { + return nil, connectError(err) + } + var selected *core.NotificationOccurrenceGroup + for index := range groups { + if groups[index].ID == req.Msg.GetGroupId() { + selected = &groups[index] + break + } + } + if selected == nil || len(selected.Occurrences) == 0 { + return nil, connectError(core.ErrNotFound) + } + target := selected.Occurrences[0].GetTarget() + followedThread := false + followedRoom := false + for _, occurrence := range selected.Occurrences { + for _, match := range occurrence.GetReasons() { + active := match.GetIntensity() > corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF + followedThread = followedThread || active && match.GetReason() == corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD + followedRoom = followedRoom || active && match.GetReason() == corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM + } + } + switch { + case followedThread && target.GetThreadRootEventId() != "": + if err := s.api.core.ThreadFollows().UnfollowThread(ctx, caller.UserID, target.GetRoomId(), target.GetThreadRootEventId()); err != nil { + return nil, connectError(err) + } + case followedRoom: + if _, err := s.api.core.NotificationPreferences().SetRoomNotificationIntensity( + ctx, + caller.UserID, + target.GetRoomId(), + corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF, + ); err != nil { + return nil, connectError(err) + } + default: + return nil, connectError(core.ErrInvalidArgument) + } + done := corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE + updated, err := s.api.core.NotificationOccurrences().UpdateGroup(ctx, caller.UserID, req.Msg.GetGroupId(), view, core.UpdateNotificationOccurrenceInput{InboxState: &done}) + if err != nil { + return nil, connectError(err) + } + return connect.NewResponse(&apiv1.UnsubscribeNotificationGroupResponse{UpdatedCount: int32(len(updated))}), 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.NotificationPreferences().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.NotificationPreferences().SetServerNotificationIntensity(ctx, caller.UserID, reason, intensity) + } else { + policy, err = s.api.core.NotificationPreferences().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/realtime_projection.go b/cli/internal/connectapi/realtime_projection.go index a0c1e2b76..22c05fcf8 100644 --- a/cli/internal/connectapi/realtime_projection.go +++ b/cli/internal/connectapi/realtime_projection.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" + "google.golang.org/protobuf/types/known/timestamppb" + "hmans.de/chatto/internal/core" "hmans.de/chatto/internal/parallel" apiv1 "hmans.de/chatto/internal/pb/chatto/api/v1" @@ -65,6 +67,7 @@ type RealtimeProjectionRoomTimeline struct { type RealtimeProjectionNotifications struct { Page *apiv1.ListNotificationsResponse RoomCounts []*apiv1.RoomNotificationCount + Groups *apiv1.ListNotificationGroupsResponse } // RealtimeProjectionRoomViewerState is one latest-value room read/permission @@ -458,17 +461,60 @@ func (a *API) BuildRealtimeProjectionNotifications(ctx context.Context, userID s if err != nil { return nil, err } + groups, err := a.core.NotificationOccurrences().Groups(ctx, userID, core.NotificationOccurrenceViewInbox) + if err != nil { + return nil, err + } + groups, err = (¬ificationService{api: a}).visibleNotificationGroups(ctx, userID, groups) + if err != nil { + return nil, err + } + assembler := newNotificationAssembler(a) + hydratedGroups := make([]*apiv1.NotificationGroup, 0, min(len(groups), defaultNotificationLimit)) + unreadGroups := int32(0) + var nextInboxExpiryAt *timestamppb.Timestamp counts := make(map[string]int32) - for _, notification := range notifications { - if roomID := notificationTargetRoomID(notification); roomID != "" { - counts[roomID]++ + for index, group := range groups { + groupUnread := false + for _, occurrence := range group.Occurrences { + if nextInboxExpiryAt == nil || occurrence.GetExpiresAt().AsTime().Before(nextInboxExpiryAt.AsTime()) { + nextInboxExpiryAt = occurrence.GetExpiresAt() + } + if occurrence.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { + continue + } + groupUnread = true + if roomID := occurrence.GetTarget().GetRoomId(); roomID != "" { + counts[roomID]++ + } + } + if groupUnread { + unreadGroups++ + } + if index < defaultNotificationLimit { + hydrated, err := assembler.group(ctx, group) + if err != nil { + return nil, err + } + if hydrated != nil { + hydratedGroups = append(hydratedGroups, hydrated) + } } } roomCounts := make([]*apiv1.RoomNotificationCount, 0, len(counts)) for roomID, count := range counts { roomCounts = append(roomCounts, &apiv1.RoomNotificationCount{RoomId: roomID, TotalCount: count}) } - return &RealtimeProjectionNotifications{Page: page, RoomCounts: roomCounts}, nil + return &RealtimeProjectionNotifications{ + Page: page, + RoomCounts: roomCounts, + Groups: &apiv1.ListNotificationGroupsResponse{ + Groups: hydratedGroups, + Page: apiPageInfo(len(groups), len(groups) > defaultNotificationLimit), + UnreadGroupCount: unreadGroups, + NextInboxExpiryAt: nextInboxExpiryAt, + }, + }, nil } // BuildRealtimeProjectionUser returns the current public directory row. PII is diff --git a/cli/internal/connectapi/room_services_test.go b/cli/internal/connectapi/room_services_test.go index 1818f9d06..0ec9da8b7 100644 --- a/cli/internal/connectapi/room_services_test.go +++ b/cli/internal/connectapi/room_services_test.go @@ -12,6 +12,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" @@ -1534,6 +1535,230 @@ func TestNotificationServiceListsAndDismissesNotifications(t *testing.T) { } } +func TestNotificationServiceOccurrenceInboxLifecycle(t *testing.T) { + env := newConnectAPITestEnv(t) + ctx := withCaller(env.ctx, env.viewer) + 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) + } + dm, _, err := env.core.FindOrCreateDM(env.ctx, env.viewer.Id, []string{actor.Id}) + if err != nil { + t.Fatalf("FindOrCreateDM: %v", err) + } + posted, err := env.core.PostMessage(env.ctx, core.KindDM, dm.Id, actor.Id, "hello from v2", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage: %v", err) + } + + inbox, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{ + View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX, + })) + if err != nil { + t.Fatalf("ListNotificationGroups Inbox: %v", err) + } + if len(inbox.Msg.GetGroups()) != 1 || inbox.Msg.GetUnreadGroupCount() != 1 { + t.Fatalf("Inbox groups = %+v, unread = %d, want one unread group", inbox.Msg.GetGroups(), inbox.Msg.GetUnreadGroupCount()) + } + group := inbox.Msg.GetGroups()[0] + occurrence := group.GetOccurrences()[0] + if occurrence.GetTarget().GetRoom().GetId() != dm.Id || occurrence.GetTarget().GetEventId() != posted.Id || + occurrence.GetInboxState() != apiv1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { + t.Fatalf("occurrence = %+v, want exact unread DM target", occurrence) + } + + done := apiv1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE + saved := true + if _, err := env.notifications.UpdateNotificationGroup(ctx, connect.NewRequest(&apiv1.UpdateNotificationGroupRequest{ + GroupId: group.GetId(), + View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX, + InboxState: &done, + Saved: &saved, + })); err != nil { + t.Fatalf("UpdateNotificationGroup Done+Saved: %v", err) + } + for _, view := range []apiv1.NotificationView{ + apiv1.NotificationView_NOTIFICATION_VIEW_DONE, + apiv1.NotificationView_NOTIFICATION_VIEW_SAVED, + } { + response, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{View: view})) + if err != nil || len(response.Msg.GetGroups()) != 1 { + t.Fatalf("ListNotificationGroups %s = %+v, %v, want one group", view, response, err) + } + } + + if _, err := env.notifications.DeleteNotificationGroup(ctx, connect.NewRequest(&apiv1.DeleteNotificationGroupRequest{ + GroupId: group.GetId(), + View: apiv1.NotificationView_NOTIFICATION_VIEW_SAVED, + })); err != nil { + t.Fatalf("DeleteNotificationGroup: %v", err) + } + afterDelete, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{View: apiv1.NotificationView_NOTIFICATION_VIEW_SAVED})) + if err != nil || len(afterDelete.Msg.GetGroups()) != 0 { + t.Fatalf("Saved after delete = %+v, %v, want empty", afterDelete, err) + } + + 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("SetNotificationPolicyPreference: %v", err) + } + 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 + } + } + if !found { + t.Fatalf("notification policy = %+v, want direct mention Badge", policy.Msg.GetPreferences()) + } + + channel := env.createJoinedRoom("notification-v2-unsubscribe") + ambient, _, err := env.core.NotificationOccurrences().Create(env.ctx, core.CreateNotificationOccurrenceInput{ + RecipientID: env.viewer.Id, + SourceEventID: "notification-v2-ambient-source", + SourceCreated: time.Now().UTC(), + ActorID: actor.Id, + Target: &corev1.NotificationTarget{RoomId: channel.Id, EventId: "notification-v2-ambient-source"}, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + SkipReadLookup: true, + }) + if err != nil { + t.Fatalf("Create ambient occurrence: %v", err) + } + ambientGroups, err := env.core.NotificationOccurrences().Groups(env.ctx, env.viewer.Id, core.NotificationOccurrenceViewInbox) + if err != nil { + t.Fatalf("ambient Groups: %v", err) + } + var ambientGroupID string + for _, candidate := range ambientGroups { + for _, member := range candidate.Occurrences { + if member.GetId() == ambient.GetId() { + ambientGroupID = candidate.ID + } + } + } + if ambientGroupID == "" { + t.Fatal("ambient notification group missing") + } + if _, err := env.notifications.UnsubscribeNotificationGroup(ctx, connect.NewRequest(&apiv1.UnsubscribeNotificationGroupRequest{ + GroupId: ambientGroupID, + View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX, + })); err != nil { + t.Fatalf("UnsubscribeNotificationGroup: %v", err) + } + preference := env.core.GetEffectiveNotificationIntensity(env.viewer.Id, channel.Id, corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM) + if preference != corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF { + t.Fatalf("followed-room intensity = %v, want Off", preference) + } + updatedAmbient, err := env.core.NotificationOccurrences().Get(env.ctx, env.viewer.Id, ambient.GetId()) + if err != nil || updatedAmbient.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE { + t.Fatalf("ambient occurrence after unsubscribe = %+v, %v, want Done", updatedAmbient, err) + } + + mention, _, err := env.core.NotificationOccurrences().Create(env.ctx, core.CreateNotificationOccurrenceInput{ + RecipientID: env.viewer.Id, + SourceEventID: "notification-v2-mention-source", + SourceCreated: time.Now().UTC(), + ActorID: actor.Id, + Target: &corev1.NotificationTarget{RoomId: channel.Id, EventId: "notification-v2-mention-source"}, + Reasons: []*corev1.NotificationReasonMatch{ + {Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT}, + {Reason: corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF}, + }, + SkipReadLookup: true, + }) + if err != nil { + t.Fatalf("Create mention occurrence: %v", err) + } + mentionGroups, err := env.core.NotificationOccurrences().Groups(env.ctx, env.viewer.Id, core.NotificationOccurrenceViewInbox) + if err != nil || len(mentionGroups) != 1 || len(mentionGroups[0].Occurrences) != 1 || mentionGroups[0].Occurrences[0].GetId() != mention.GetId() { + t.Fatalf("mention Groups = %+v, %v, want one mention group", mentionGroups, err) + } + if _, err := env.notifications.UnsubscribeNotificationGroup(ctx, connect.NewRequest(&apiv1.UnsubscribeNotificationGroupRequest{ + GroupId: mentionGroups[0].ID, + View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX, + })); connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("UnsubscribeNotificationGroup inactive ambient cause code = %v, want invalid_argument", connect.CodeOf(err)) + } +} + +func TestNotificationServiceBoundsGroupPreviewAndPaginatesOccurrences(t *testing.T) { + env := newConnectAPITestEnv(t) + ctx := withCaller(env.ctx, env.viewer) + 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) + } + dm, _, err := env.core.FindOrCreateDM(env.ctx, env.viewer.Id, []string{actor.Id}) + if err != nil { + t.Fatalf("FindOrCreateDM: %v", err) + } + 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) + } + } + + inbox, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX})) + 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.GetNextInboxExpiryAt() == nil { + t.Fatalf("bounded group metadata = %+v", group) + } + projection, err := env.api.BuildRealtimeProjectionNotifications(env.ctx, env.viewer.Id) + if err != nil || projection.Groups.GetNextInboxExpiryAt() == nil { + t.Fatalf("realtime Inbox expiry boundary = %+v, %v", projection, err) + } + + page, err := env.notifications.ListNotificationOccurrences(ctx, connect.NewRequest(&apiv1.ListNotificationOccurrencesRequest{ + GroupId: group.GetId(), + View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX, + Page: &apiv1.PageRequest{Limit: 10, Offset: 20}, + })) + if err != nil { + t.Fatalf("ListNotificationOccurrences: %v", err) + } + if got := len(page.Msg.GetNotifications()); got != 5 || page.Msg.GetPage().GetTotalCount() != int64(notificationGroupOccurrencePreviewLimit+5) || page.Msg.GetPage().GetHasMore() { + t.Fatalf("occurrence page = %+v, want final five of 25", page.Msg) + } + + live, err := env.nc.SubscribeSync(subjects.LiveSyncUserEvent(env.viewer.Id, "notification_v2")) + if err != nil { + 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) + } + done := apiv1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE + updated, err := env.notifications.UpdateNotificationGroup(ctx, connect.NewRequest(&apiv1.UpdateNotificationGroupRequest{ + GroupId: group.GetId(), + View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX, + InboxState: &done, + })) + if err != nil || updated.Msg.GetUpdatedCount() != int32(notificationGroupOccurrencePreviewLimit+5) { + t.Fatalf("UpdateNotificationGroup = %+v, %v", updated, err) + } + if _, err := live.NextMsg(2 * time.Second); err != nil { + t.Fatalf("wait for coalesced notification invalidation: %v", err) + } + if _, err := live.NextMsg(200 * time.Millisecond); err == nil { + t.Fatal("group update published more than one notification invalidation") + } +} + func TestNotificationPreferencesServiceServerLevelPreference(t *testing.T) { env := newConnectAPITestEnv(t) ctx := withCaller(env.ctx, env.viewer) diff --git a/cli/internal/core/config_projection.go b/cli/internal/core/config_projection.go index 817e98fcc..ad6c2e6ce 100644 --- a/cli/internal/core/config_projection.go +++ b/cli/internal/core/config_projection.go @@ -29,10 +29,12 @@ type serverConfigState struct { } type userConfigState struct { - timezone *string - timeFormat *corev1.TimeFormat - serverLevel *corev1.NotificationLevel - roomLevelByRoom map[string]corev1.NotificationLevel + timezone *string + timeFormat *corev1.TimeFormat + serverLevel *corev1.NotificationLevel + roomLevelByRoom map[string]corev1.NotificationLevel + serverIntensityByReason map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity + roomIntensityByRoomAndCause map[string]map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity } func NewConfigProjection() *ConfigProjection { @@ -102,6 +104,34 @@ func (p *ConfigProjection) Apply(event *corev1.Event, _ uint64) error { if u := p.users[e.UserRoomNotificationLevelCleared.GetUserId()]; u != nil { delete(u.roomLevelByRoom, e.UserRoomNotificationLevelCleared.GetRoomId()) } + case *corev1.Event_UserServerNotificationPreferenceSet: + u := p.ensureUserLocked(e.UserServerNotificationPreferenceSet.GetUserId()) + if u.serverIntensityByReason == nil { + u.serverIntensityByReason = make(map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity) + } + u.serverIntensityByReason[e.UserServerNotificationPreferenceSet.GetReason()] = e.UserServerNotificationPreferenceSet.GetIntensity() + case *corev1.Event_UserServerNotificationPreferenceCleared: + if u := p.users[e.UserServerNotificationPreferenceCleared.GetUserId()]; u != nil { + delete(u.serverIntensityByReason, e.UserServerNotificationPreferenceCleared.GetReason()) + } + case *corev1.Event_UserRoomNotificationPreferenceSet: + u := p.ensureUserLocked(e.UserRoomNotificationPreferenceSet.GetUserId()) + if u.roomIntensityByRoomAndCause == nil { + u.roomIntensityByRoomAndCause = make(map[string]map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity) + } + roomID := e.UserRoomNotificationPreferenceSet.GetRoomId() + if u.roomIntensityByRoomAndCause[roomID] == nil { + u.roomIntensityByRoomAndCause[roomID] = make(map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity) + } + u.roomIntensityByRoomAndCause[roomID][e.UserRoomNotificationPreferenceSet.GetReason()] = e.UserRoomNotificationPreferenceSet.GetIntensity() + case *corev1.Event_UserRoomNotificationPreferenceCleared: + if u := p.users[e.UserRoomNotificationPreferenceCleared.GetUserId()]; u != nil { + roomID := e.UserRoomNotificationPreferenceCleared.GetRoomId() + delete(u.roomIntensityByRoomAndCause[roomID], e.UserRoomNotificationPreferenceCleared.GetReason()) + if len(u.roomIntensityByRoomAndCause[roomID]) == 0 { + delete(u.roomIntensityByRoomAndCause, roomID) + } + } 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..73bab4d07 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" @@ -38,6 +39,22 @@ func (p *ConfigProjection) Snapshot() ([]byte, error) { for _, roomID := range sortedMapKeys(user.roomLevelByRoom) { row.RoomNotificationLevels = append(row.RoomNotificationLevels, &corev1.RoomNotificationLevelSnapshot{RoomId: roomID, Level: user.roomLevelByRoom[roomID]}) } + for _, reason := range sortedNotificationReasons(user.serverIntensityByReason) { + row.ServerNotificationPreferences = append(row.ServerNotificationPreferences, &corev1.NotificationPreferenceSnapshot{ + Reason: reason, + Intensity: user.serverIntensityByReason[reason], + }) + } + 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) } return proto.MarshalOptions{Deterministic: true}.Marshal(snapshot) @@ -63,7 +80,11 @@ 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{ + roomLevelByRoom: make(map[string]corev1.NotificationLevel), + 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 @@ -85,6 +106,28 @@ func (p *ConfigProjection) Restore(data []byte) error { } user.roomLevelByRoom[level.GetRoomId()] = level.GetLevel() } + 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 _, 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") + } + 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.roomIntensityByRoomAndCause[room.GetRoomId()] = preferences + } users[row.GetUserId()] = user } p.Lock() @@ -92,3 +135,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 085b21a84..1df5c8521 100644 --- a/cli/internal/core/core.go +++ b/cli/internal/core/core.go @@ -42,6 +42,8 @@ type ChattoCore struct { notificationPrefs *NotificationPreferencesModel roomTimelineReads *RoomTimelineReadModel readStateModel *ReadStateModel + notificationOccurrences *NotificationOccurrenceModel + notificationMaterializer *NotificationMaterializer threadFollows *ThreadFollowModel reactionModel *ReactionModel userModel *UserModel @@ -81,6 +83,11 @@ type ChattoCore struct { // 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 @@ -162,6 +169,9 @@ 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) + } c.secureDeleteObsoleteProjectedMessageBodyEvents(gctx) // Apply config-designated owners to already-verified users on every // boot. Changing owners.emails requires a process restart, so this @@ -186,6 +196,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 ba729a93b..90ae1cb5a 100644 --- a/cli/internal/core/core_services.go +++ b/cli/internal/core/core_services.go @@ -120,6 +120,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) core.threadFollows = &ThreadFollowModel{core: core} core.reactionModel = &ReactionModel{core: core, mutations: core.EventPublisher} diff --git a/cli/internal/core/dm_test.go b/cli/internal/core/dm_test.go index 88e24dbe1..1bf5756e4 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,18 +819,19 @@ 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) } @@ -857,21 +858,18 @@ func TestDMNotifications(t *testing.T) { 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 event.Alert { + t.Fatal("NotificationOccurrenceChangedEvent.Alert = true during DND, want false") } 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) - } + 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/mentions.go b/cli/internal/core/mentions.go index 227aa5749..31144f5c2 100644 --- a/cli/internal/core/mentions.go +++ b/cli/internal/core/mentions.go @@ -225,8 +225,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 +244,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 +261,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 +291,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 @@ -295,7 +309,7 @@ func (c *ChattoCore) ResolveRoomMentions(ctx context.Context, kind RoomKind, roo continue } if status != PresenceStatusOffline { - add(member.UserId) + add(member.UserId, corev1.NotificationReason_NOTIFICATION_REASON_HERE) } } continue @@ -319,7 +333,7 @@ func (c *ChattoCore) ResolveRoomMentions(ctx context.Context, kind RoomKind, roo } continue } - addMembers(roleUsers) + addMembers(roleUsers, corev1.NotificationReason_NOTIFICATION_REASON_ROLE_MENTION) continue } @@ -327,53 +341,37 @@ func (c *ChattoCore) ResolveRoomMentions(ctx context.Context, kind RoomKind, roo if err != nil { continue } - add(user.Id) + add(user.Id, corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION) } - return userIDs, nil + return result, nil +} + +// 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 + } + return resolved.RecipientIDs, 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) + 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 + 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 + } } - seen[user.Id] = struct{}{} - userIDs = append(userIDs, user.Id) } return userIDs, nil diff --git a/cli/internal/core/mentions_test.go b/cli/internal/core/mentions_test.go index f1d2752c2..508133bcb 100644 --- a/cli/internal/core/mentions_test.go +++ b/cli/internal/core/mentions_test.go @@ -547,18 +547,15 @@ 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) } @@ -583,20 +580,17 @@ func TestChattoCore_MentionCreatesNotificationWithoutMentionStatus(t *testing.T) 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 event.Alert { + t.Fatal("NotificationOccurrenceChangedEvent.Alert = true during DND, want false") } 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) - } + notifications = testNotificationOccurrences(t, core, mentioned.Id) if len(notifications) != 2 { t.Fatalf("notifications after DND mention = %d, want 2", len(notifications)) } @@ -635,10 +629,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 +666,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 +707,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..3716ecb8e 100644 --- a/cli/internal/core/messages.go +++ b/cli/internal/core/messages.go @@ -917,30 +917,25 @@ func (c *ChattoCore) PostMessage(ctx context.Context, kind RoomKind, room_id, us // Extract and resolve @mentions from message body var mentionedUserIDs []string - var directMentionedUserIDs []string + var mentionResolution *RoomMentionResolution if hasBody { usernames := ExtractMentionUsernames(body) if len(usernames) > 0 { - resolved, err := c.ResolveRoomMentions(ctx, kind, room_id, usernames) + resolved, err := c.ResolveRoomMentionReasons(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 - } + return nil, fmt.Errorf("resolve notification mention recipients: %w", err) } + mentionResolution = resolved + mentionedUserIDs = resolved.RecipientIDs } } eventID := NewEventID() bodyEventID := NewEventID() + notificationCandidates, candidateErr := c.buildMessageNotificationCandidates(ctx, kind, room_id, user_id, inThread, inReplyTo, mentionResolution) + if candidateErr != nil { + return nil, fmt.Errorf("evaluate notification candidates: %w", candidateErr) + } messageBody := &corev1.MessageBody{ CreatedAt: timestamppb.New(now), AssetIds: resolvedAssetIDs, @@ -967,10 +962,11 @@ 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, + MentionedUserIds: mentionedUserIDs, + NotificationCandidates: notificationCandidates, }, }, }) @@ -1120,60 +1116,29 @@ 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) + for _, mentionedUserID := range directMentionRecipients(notificationCandidates) { + 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.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 - } - } - } - c.notifyAllMessageSubscribers(ctx, kind, room_id, user_id, event.Id, alreadyNotified) + // Materialize promptly for low latency. The background EVT consumer + // replays the same source after crashes; deterministic KV Create makes both + // paths safe and keeps posting successful if notification state is down. + if err := c.notificationMaterializer.MaterializeEvent(ctx, event); err != nil { + c.logger.Warn("Failed to materialize message notifications; background replay will retry", + "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 +1150,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) } } @@ -1481,7 +1439,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 +1462,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 +1537,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 +1591,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.MaterializeEvent(ctx, event); err != nil { + c.logger.Warn("Failed to remove notifications for retracted message; background replay will retry", + "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 058052d4c..70bc04119 100644 --- a/cli/internal/core/nats_recovery.go +++ b/cli/internal/core/nats_recovery.go @@ -185,6 +185,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_candidates.go b/cli/internal/core/notification_candidates.go new file mode 100644 index 000000000..85933f654 --- /dev/null +++ b/cli/internal/core/notification_candidates.go @@ -0,0 +1,142 @@ +package core + +import ( + "context" + "sort" + + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +// buildMessageNotificationCandidates evaluates every matching cause once and +// returns one deterministic candidate per recipient. The caller embeds the +// result in MessagePostedEvent before committing the source fact. +func (c *ChattoCore) buildMessageNotificationCandidates( + ctx context.Context, + kind RoomKind, + roomID, authorID, inThread, inReplyTo string, + mentions *RoomMentionResolution, +) ([]*corev1.NotificationCandidate, 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 only creates attention 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 here + // while evaluating the source event rather than 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) + + candidates := make([]*corev1.NotificationCandidate, 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 + } + candidates = append(candidates, &corev1.NotificationCandidate{RecipientId: userID, Reasons: matches}) + } + return candidates, nil +} + +func directMentionRecipients(candidates []*corev1.NotificationCandidate) []string { + result := make([]string, 0) + for _, candidate := range candidates { + for _, match := range candidate.GetReasons() { + if match.GetReason() == corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION && + match.GetIntensity() > corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF { + result = append(result, candidate.GetRecipientId()) + break + } + } + } + return result +} diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go new file mode 100644 index 000000000..265b31a85 --- /dev/null +++ b/cli/internal/core/notification_materializer.go @@ -0,0 +1,215 @@ +package core + +import ( + "context" + "errors" + "fmt" + "time" + + "hmans.de/chatto/internal/evtstream" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +const notificationMaterializerPollEvery = 250 * time.Millisecond + +// NotificationMaterializer recovers occurrence creation and lifecycle effects +// from durable source facts. Every replica may run it: recipient/source KV +// identity makes overlapping replay safe. +type NotificationMaterializer struct { + core *ChattoCore + consumer *evtstream.IncrementalEffectConsumer + pollEvery time.Duration +} + +func NewNotificationMaterializer(core *ChattoCore) *NotificationMaterializer { + materializer := &NotificationMaterializer{core: core, pollEvery: notificationMaterializerPollEvery} + materializer.consumer = evtstream.NewOrderedIncrementalEffectConsumer( + core.EventPublisher, + evtstream.EventSubjectFilter(), + materializer.MaterializeEvent, + ) + return materializer +} + +func (m *NotificationMaterializer) Run(ctx context.Context) error { + // Full replay waits for current room/account projections so notification + // recovery can enforce current visibility alongside source-event ordering. + if err := m.core.WaitForProjectionsCurrent(ctx); err != nil { + return fmt.Errorf("wait for projections before notification replay: %w", err) + } + if err := m.core.notificationOccurrences.WaitReady(ctx); err != nil { + return fmt.Errorf("wait for notification index before replay: %w", err) + } + ticker := time.NewTicker(m.pollEvery) + defer ticker.Stop() + for { + if err := m.consumer.Consume(ctx); err != nil && !errors.Is(err, context.Canceled) { + m.core.logger.Warn("Notification materialization pass incomplete", "error", err) + } + m.deliverPendingAlerts(ctx) + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func (m *NotificationMaterializer) deliverPendingAlerts(ctx context.Context) { + if m.core.OnNotificationOccurrenceCreated == nil { + return + } + for { + 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) error { + if event == nil { + return nil + } + switch payload := event.GetEvent().(type) { + case *corev1.Event_MessagePosted: + message := payload.MessagePosted + if message.GetEchoOfEventId() != "" || len(message.GetNotificationCandidates()) == 0 { + return nil + } + // Independent effect retries may run after a later retraction. Consult + // current monotonic target state so delayed creation cannot resurrect it. + if _, retracted, known := m.core.roomModel.latestBody(event.GetId()); known && retracted { + return nil + } + // Projection catch-up is complete before replay starts. A missing room is + // therefore a terminal historical condition (normally a later RoomDeleted + // fact), not a retryable materialization failure that should poison the + // globally ordered effect queue. + if _, err := m.core.FindRoomByID(ctx, message.GetRoomId()); errors.Is(err, ErrNotFound) { + return nil + } else if err != nil { + return fmt.Errorf("verify notification room: %w", err) + } + target := &corev1.NotificationTarget{RoomId: message.GetRoomId(), EventId: event.GetId()} + if message.GetInThread() != "" { + value := message.GetInThread() + target.ThreadRootEventId = &value + } + if message.GetInReplyTo() != "" { + value := message.GetInReplyTo() + target.ParentEventId = &value + } + for _, candidate := range message.GetNotificationCandidates() { + active, err := m.activeRecipient(ctx, candidate.GetRecipientId()) + if err != nil { + return fmt.Errorf("verify notification recipient %s: %w", candidate.GetRecipientId(), err) + } + if !active { + continue + } + _, _, err = m.core.notificationOccurrences.Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: candidate.GetRecipientId(), + SourceEventID: event.GetId(), + SourceCreated: event.GetCreatedAt().AsTime(), + ActorID: event.GetActorId(), + Target: target, + Reasons: candidate.GetReasons(), + EvaluatedAt: event.GetCreatedAt().AsTime(), + }) + if err != nil { + return fmt.Errorf("create occurrence for recipient %s: %w", candidate.GetRecipientId(), err) + } + } + 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_ReactionAdded: + reaction := payload.ReactionAdded + candidate := reaction.GetNotificationCandidate() + if candidate == nil { + return nil + } + // A removed and later re-added reaction has a different source event. + // Only materialize while this exact add is still projected as active. + snapshot := m.core.roomModel.reactionMutationSnapshot(reaction.GetRoomId(), reaction.GetMessageEventId(), reaction.GetEmoji(), event.GetActorId()) + if !snapshot.Exists || snapshot.SourceEventID != event.GetId() { + return nil + } + if _, err := m.core.FindRoomByID(ctx, reaction.GetRoomId()); errors.Is(err, ErrNotFound) { + return nil + } else if err != nil { + return fmt.Errorf("verify notification room: %w", err) + } + active, err := m.activeRecipient(ctx, candidate.GetRecipientId()) + if err != nil { + return fmt.Errorf("verify notification recipient %s: %w", candidate.GetRecipientId(), err) + } + if !active { + return nil + } + target := &corev1.NotificationTarget{RoomId: reaction.GetRoomId(), EventId: reaction.GetMessageEventId()} + if room, roomErr := m.core.FindRoomByID(ctx, reaction.GetRoomId()); roomErr == nil { + if message, err := m.core.GetRoomEventByEventID(ctx, KindOfRoom(room), reaction.GetRoomId(), reaction.GetMessageEventId()); err == nil && message != nil { + posted := message.GetMessagePosted() + if posted.GetInThread() != "" { + value := posted.GetInThread() + target.ThreadRootEventId = &value + } + } + } + _, _, err = m.core.notificationOccurrences.Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: candidate.GetRecipientId(), + SourceEventID: event.GetId(), + SourceCreated: event.GetCreatedAt().AsTime(), + ActorID: event.GetActorId(), + Target: target, + Reasons: candidate.GetReasons(), + EvaluatedAt: event.GetCreatedAt().AsTime(), + }) + if err != nil { + return err + } + case *corev1.Event_ReactionRemoved: + reaction := payload.ReactionRemoved + if reaction.GetNotificationRecipientId() == "" || reaction.GetNotificationSourceEventId() == "" { + return nil + } + _, err := m.core.notificationOccurrences.RemoveSource(ctx, reaction.GetNotificationRecipientId(), reaction.GetNotificationSourceEventId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_REACTION_REMOVED) + return err + case *corev1.Event_UserLeftRoom: + _, err := m.core.notificationOccurrences.RemoveRoomForUser(ctx, event.GetActorId(), payload.UserLeftRoom.GetRoomId(), event.GetCreatedAt().AsTime(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return err + case *corev1.Event_RoomMemberRemoved: + _, err := m.core.notificationOccurrences.RemoveRoomForUser(ctx, payload.RoomMemberRemoved.GetUserId(), payload.RoomMemberRemoved.GetRoomId(), event.GetCreatedAt().AsTime(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + return err + 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: + _, err := m.core.notificationOccurrences.PurgeUser(ctx, payload.UserAccountDeleted.GetUserId()) + return err + } + return 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..9838ba86a --- /dev/null +++ b/cli/internal/core/notification_materializer_test.go @@ -0,0 +1,381 @@ +package core + +import ( + "errors" + "testing" + "time" + + "google.golang.org/protobuf/types/known/timestamppb" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +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) + } + + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, bob.Id, NotificationOccurrenceViewInbox) + 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, NotificationOccurrenceViewInbox); err != nil || len(occurrences) != 0 { + t.Fatalf("Inbox after retraction = (%v, %v), want empty", occurrences, err) + } +} + +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) + } + + occurrence, created, err := chattoCore.NotificationOccurrences().Create(ctx, CreateNotificationOccurrenceInput{ + RecipientID: reader.Id, + SourceEventID: "E-late-materialization", + SourceCreated: posted.GetCreatedAt().AsTime(), + 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()) + } +} + +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, + 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, + 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, + }}, + }) + 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) + } + + err = chattoCore.notificationMaterializer.MaterializeEvent(ctx, &corev1.Event{ + Id: "E-before-account-deletion", + ActorId: actor.Id, + CreatedAt: timestamppb.Now(), + Event: &corev1.Event_MessagePosted{MessagePosted: &corev1.MessagePostedEvent{ + RoomId: "R-deleted-recipient", + NotificationCandidates: []*corev1.NotificationCandidate{{ + RecipientId: recipient.Id, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + }}, + }}, + }) + if err != nil { + t.Fatalf("replay notification source after account deletion: %v", err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id, NotificationOccurrenceViewInbox) + 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) + } + + err = chattoCore.notificationMaterializer.MaterializeEvent(ctx, &corev1.Event{ + Id: "E-in-deleted-room", + ActorId: actor.Id, + CreatedAt: timestamppb.Now(), + Event: &corev1.Event_MessagePosted{MessagePosted: &corev1.MessagePostedEvent{ + RoomId: "R-already-deleted", + NotificationCandidates: []*corev1.NotificationCandidate{{ + RecipientId: recipient.Id, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, + }}, + }}, + }}, + }) + if err != nil { + t.Fatalf("replay notification source after room deletion: %v", err) + } + if occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id, NotificationOccurrenceViewInbox); 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) + } + if err := chattoCore.notificationMaterializer.MaterializeEvent(ctx, posted); err != nil { + t.Fatalf("retry message materialization: %v", err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id, NotificationOccurrenceViewInbox) + 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) + } + if _, err := chattoCore.JoinRoom(ctx, reactor.Id, KindChannel, reactor.Id, room.Id); err != nil { + t.Fatalf("JoinRoom: %v", 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 _, 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() + addEvent.GetReactionAdded().NotificationCandidate = &corev1.NotificationCandidate{ + RecipientId: author.Id, + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_REACTION, + Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, + }}, + } + if err := chattoCore.notificationMaterializer.MaterializeEvent(ctx, addEvent); err != nil { + t.Fatalf("retry reaction materialization: %v", err) + } + occurrences, err := chattoCore.NotificationOccurrences().List(ctx, author.Id, NotificationOccurrenceViewInbox) + 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..1359a67e3 --- /dev/null +++ b/cli/internal/core/notification_occurrence_index.go @@ -0,0 +1,394 @@ +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 + keyRevisions map[string]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), + 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.keyRevisions = make(map[string]uint64) + close(i.changed) + i.changed = make(chan struct{}) +} + +func (i *NotificationOccurrenceIndex) apply(entry jetstream.KeyValueEntry) { + userID, _, ok := parseNotificationOccurrenceKey(entry.Key()) + if !ok { + 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()] { + return + } + if indexed.deleted || indexed.occurrence == nil { + delete(i.keyRevisions, 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 + 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.pruneExpiredLocked(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) 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.pruneExpiredLocked(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, entries := range i.entriesByUser { + 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) + changed = true + } + if len(entries) == 0 { + delete(i.entriesByUser, userID) + } + } + if changed { + close(i.changed) + i.changed = make(chan struct{}) + } +} + +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) 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..dd8bcb39e --- /dev/null +++ b/cli/internal/core/notification_occurrence_live.go @@ -0,0 +1,38 @@ +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 && + !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..18dad21d4 --- /dev/null +++ b/cli/internal/core/notification_occurrence_model.go @@ -0,0 +1,833 @@ +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/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 + EvaluatedAt time.Time + InitialState corev1.NotificationInboxState + SkipReadLookup bool +} + +type UpdateNotificationOccurrenceInput struct { + InboxState *corev1.NotificationInboxState + Saved *bool +} + +type NotificationOccurrenceView int + +const ( + NotificationOccurrenceViewInbox NotificationOccurrenceView = iota + NotificationOccurrenceViewDone + NotificationOccurrenceViewSaved +) + +type NotificationOccurrenceGroup struct { + ID string + Key string + Occurrences []*corev1.NotificationOccurrence +} + +// 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 + if !input.SkipReadLookup { + covered, err := m.targetCoveredByReadState(ctx, input.RecipientID, input.Target, input.SourceCreated) + if err != nil { + return nil, false, fmt.Errorf("resolve initial notification read state: %w", err) + } + if covered { + state = corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ + } + } + } + alertState := corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_NOT_APPLICABLE + if strongest == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT { + alertState = corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_PENDING + } + 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, + StrongestIntensity: strongest, + InboxState: state, + EvaluatedAt: timestamppb.New(evaluatedAt), + UpdatedAt: timestamppb.New(now), + 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.index.occurrenceBySource(ctx, input.RecipientID, input.SourceEventID) + if readErr != nil { + return nil, false, readErr + } + if exists { + if existing.occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { + return nil, false, nil + } + return existing.occurrence, false, nil + } + entry, getErr := m.kv.Get(ctx, key) + if getErr != nil { + return nil, false, fmt.Errorf("read concurrently created notification occurrence: %w", getErr) + } + if waitErr := m.index.waitForRevision(ctx, key, entry.Revision()); waitErr != nil { + return nil, false, waitErr + } + existing, exists, readErr = m.index.occurrenceBySource(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 + } + return existing.occurrence, false, nil + } + 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) + } + 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 +} + +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, view NotificationOccurrenceView) ([]*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 + } + include := false + switch view { + case NotificationOccurrenceViewInbox: + include = occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD || + occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ + case NotificationOccurrenceViewDone: + include = occurrence.GetInboxState() == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE + case NotificationOccurrenceViewSaved: + include = occurrence.GetSaved() + } + if include { + 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, view NotificationOccurrenceView) ([]NotificationOccurrenceGroup, error) { + occurrences, err := m.List(ctx, userID, view) + 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, NotificationOccurrenceViewInbox) + 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) Update(ctx context.Context, userID, occurrenceID string, input UpdateNotificationOccurrenceInput) (*corev1.NotificationOccurrence, error) { + return m.update(ctx, userID, occurrenceID, input, true) +} + +func (m *NotificationOccurrenceModel) update(ctx context.Context, userID, occurrenceID string, input UpdateNotificationOccurrenceInput, publish bool) (*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) + if input.InboxState != nil { + switch *input.InboxState { + case corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD, + corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ, + corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE: + updated.InboxState = *input.InboxState + default: + return nil, invalidArgument("inbox_state must be UNREAD, READ, or DONE") + } + if *input.InboxState != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD && + (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 input.Saved != nil { + updated.Saved = *input.Saved + } + 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 && publish { + 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_DONE, + 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) UpdateGroup(ctx context.Context, userID, groupID string, view NotificationOccurrenceView, input UpdateNotificationOccurrenceInput) ([]*corev1.NotificationOccurrence, error) { + groups, err := m.Groups(ctx, userID, view) + if err != nil { + return nil, err + } + for _, group := range groups { + if group.ID != groupID { + continue + } + updated := make([]*corev1.NotificationOccurrence, 0, len(group.Occurrences)) + for _, occurrence := range group.Occurrences { + item, err := m.update(ctx, userID, occurrence.GetId(), input, false) + if err != nil { + if len(updated) > 0 { + m.core.publishNotificationOccurrenceChanged(ctx, updated[len(updated)-1], false, false) + } + return updated, err + } + updated = append(updated, item) + } + if len(updated) > 0 { + // The last KV revision fences every earlier write in this ordered + // mutation, so one live invalidation is sufficient on every replica. + m.core.publishNotificationOccurrenceChanged(ctx, updated[len(updated)-1], false, false) + } + return updated, nil + } + return nil, ErrNotFound +} + +func (m *NotificationOccurrenceModel) DeleteGroup(ctx context.Context, userID, groupID string, view NotificationOccurrenceView) (int, error) { + groups, err := m.Groups(ctx, userID, view) + 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 +} + +func (m *NotificationOccurrenceModel) MarkCoveredRead(ctx context.Context, userID, roomID, threadRootEventID string, readThrough time.Time) (int, error) { + entries, err := m.index.userEntries(ctx, userID) + if err != nil { + return 0, err + } + updated := 0 + var lastUpdated *corev1.NotificationOccurrence + read := corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ + 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 || + occurrence.GetSourceCreatedAt().AsTime().After(readThrough) { + continue + } + item, err := m.update(ctx, userID, occurrence.GetId(), UpdateNotificationOccurrenceInput{InboxState: &read}, false) + if err != nil { + if errors.Is(err, ErrNotFound) { + continue + } + if lastUpdated != nil { + m.core.publishNotificationOccurrenceChanged(ctx, lastUpdated, false, false) + } + return updated, err + } + 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.allEntries(ctx) + if err != nil { + return nil, false, err + } + now := m.now().UTC() + for _, entry := range entries { + occurrence := entry.occurrence + claimExpired := occurrence.GetAlertState() == corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED && + occurrence.GetAlertClaimedUntil() != nil && occurrence.GetAlertClaimedUntil().AsTime().Before(now) + if occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED || + occurrence.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD || + occurrence.GetStrongestIntensity() != corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT || + (occurrence.GetAlertState() != corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_PENDING && !claimExpired) { + 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 +} + +// 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 + } + state := corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_CLAIMED + claimedUntil := m.now().UTC().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 +} + +// TargetVisible revalidates the recipient's current room membership before an +// occurrence is hydrated or delivered outside Chatto. +func (m *NotificationOccurrenceModel) TargetVisible(ctx context.Context, recipientID string, occurrence *corev1.NotificationOccurrence) (bool, error) { + if occurrence == nil || occurrence.GetRecipientId() != recipientID || occurrence.GetTarget().GetRoomId() == "" { + return false, nil + } + room, err := m.core.FindRoomByID(ctx, occurrence.GetTarget().GetRoomId()) + if errors.Is(err, ErrNotFound) { + return false, nil + } + if err != nil { + return false, err + } + return m.core.RoomMembershipExists(ctx, KindOfRoom(room), recipientID, room.GetId()) +} + +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.index.allEntries(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 + } + ok, err := m.Delete(ctx, entry.occurrence.GetRecipientId(), entry.occurrence.GetId(), reason) + if err != nil { + return removed, err + } + if ok { + removed++ + } + } + return removed, nil +} + +func (m *NotificationOccurrenceModel) RemoveSource(ctx context.Context, userID, sourceEventID string, reason corev1.NotificationRemovalReason) (bool, error) { + entry, exists, err := m.index.occurrenceBySource(ctx, userID, sourceEventID) + if err != nil || !exists { + return false, err + } + return m.Delete(ctx, userID, entry.occurrence.GetId(), reason) +} + +func (m *NotificationOccurrenceModel) RemoveRoomForUser(ctx context.Context, userID, roomID string, removedThrough time.Time, reason corev1.NotificationRemovalReason) (int, error) { + entries, err := m.index.userEntries(ctx, userID) + if err != nil { + return 0, err + } + removed := 0 + for _, entry := range entries { + if entry.occurrence.GetTarget().GetRoomId() != roomID { + continue + } + if !removedThrough.IsZero() && entry.occurrence.GetSourceCreatedAt().AsTime().After(removedThrough) { + continue + } + ok, err := m.Delete(ctx, userID, entry.occurrence.GetId(), reason) + if err != nil { + return removed, err + } + if ok { + removed++ + } + } + return removed, nil +} + +func (m *NotificationOccurrenceModel) RemoveRoom(ctx context.Context, roomID string, reason corev1.NotificationRemovalReason) (int, error) { + entries, err := m.index.allEntries(ctx) + if err != nil { + return 0, err + } + removed := 0 + for _, entry := range entries { + if entry.occurrence.GetTarget().GetRoomId() != roomID { + continue + } + ok, err := m.Delete(ctx, entry.occurrence.GetRecipientId(), entry.occurrence.GetId(), reason) + if err != nil { + return removed, err + } + if ok { + removed++ + } + } + return removed, nil +} + +func (m *NotificationOccurrenceModel) PurgeUser(ctx context.Context, userID string) (int, error) { + entries, err := m.index.userEntries(ctx, userID) + if err != nil { + return 0, err + } + purged := 0 + for _, entry := range entries { + if err := m.kv.Purge(ctx, entry.key, jetstream.LastRevision(entry.revision)); err != nil { + if jetstreamutil.IsSequenceConflict(err) { + continue + } + return purged, err + } + purged++ + } + return purged, nil +} + +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 (m *NotificationOccurrenceModel) targetCoveredByReadState(ctx context.Context, userID string, target *corev1.NotificationTarget, sourceCreated time.Time) (bool, error) { + room, err := m.core.FindRoomByID(ctx, target.GetRoomId()) + if err != nil { + return false, err + } + kind := KindOfRoom(room) + if target.GetThreadRootEventId() != "" { + readAt, err := m.core.GetThreadLastOpened(ctx, kind, userID, target.GetRoomId(), target.GetThreadRootEventId()) + return !readAt.IsZero() && !readAt.Before(sourceCreated), err + } + markerID, exists, err := m.core.PeekLastReadEventID(ctx, userID, target.GetRoomId()) + if err != nil || !exists || markerID == "" { + return false, err + } + readAt, err := m.core.GetEventTimestamp(ctx, kind, target.GetRoomId(), markerID) + return !readAt.IsZero() && !readAt.Before(sourceCreated), err +} + +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..008aa5f60 --- /dev/null +++ b/cli/internal/core/notification_occurrence_model_test.go @@ -0,0 +1,282 @@ +package core + +import ( + "context" + "errors" + "testing" + "time" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" + + 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 !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) + } + + read := corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ + saved := true + updated, err := model.Update(ctx, input.RecipientID, created.GetId(), UpdateNotificationOccurrenceInput{ + InboxState: &read, + Saved: &saved, + }) + if err != nil { + t.Fatalf("Update: %v", err) + } + if updated.GetInboxState() != read || !updated.GetSaved() { + t.Fatalf("Update = %+v, want read and saved", updated) + } + if !updated.GetExpiresAt().AsTime().Equal(originalExpiry) { + t.Fatalf("expiry changed from %v to %v", originalExpiry, updated.GetExpiresAt().AsTime()) + } + + inboxGroups, err := model.Groups(ctx, input.RecipientID, NotificationOccurrenceViewInbox) + if err != nil || len(inboxGroups) != 1 { + t.Fatalf("Inbox groups = (%v, %v), want one", inboxGroups, err) + } + done := corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE + if _, err := model.UpdateGroup(ctx, input.RecipientID, inboxGroups[0].ID, NotificationOccurrenceViewInbox, UpdateNotificationOccurrenceInput{InboxState: &done}); err != nil { + t.Fatalf("UpdateGroup to Done: %v", err) + } + if groups, err := model.Groups(ctx, input.RecipientID, NotificationOccurrenceViewInbox); err != nil || len(groups) != 0 { + t.Fatalf("Inbox groups after Done = (%v, %v), want empty", groups, err) + } + if groups, err := model.Groups(ctx, input.RecipientID, NotificationOccurrenceViewDone); err != nil || len(groups) != 1 { + t.Fatalf("Done groups = (%v, %v), want one", groups, err) + } + if groups, err := model.Groups(ctx, input.RecipientID, NotificationOccurrenceViewSaved); err != nil || len(groups) != 1 { + t.Fatalf("Saved groups = (%v, %v), want one", groups, err) + } + + deleted, err := model.Delete(ctx, input.RecipientID, created.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_DELETED) + if err != nil || !deleted { + t.Fatalf("Delete = (%v, %v), want true", deleted, 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 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) + } + read := corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ + updated, err := model.Update(ctx, created.GetRecipientId(), created.GetId(), UpdateNotificationOccurrenceInput{InboxState: &read}) + 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 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 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(), NotificationOccurrenceViewInbox); 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_policy.go b/cli/internal/core/notification_policy.go new file mode 100644 index 000000000..169609062 --- /dev/null +++ b/cli/internal/core/notification_policy.go @@ -0,0 +1,229 @@ +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, + corev1.NotificationReason_NOTIFICATION_REASON_ROOM_INVITATION, +} + +// 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 +} + +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, + corev1.NotificationReason_NOTIFICATION_REASON_ROOM_INVITATION: + 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, set := legacyNotificationLevelIntensity(c.configModel.notificationRoomLevel(userID, roomID), reason); set { + return intensity + } + } + if intensity := c.configModel.notificationServerIntensity(userID, reason); intensity != corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED { + return intensity + } + if intensity, set := legacyNotificationLevelIntensity(c.configModel.notificationServerLevel(userID), reason); set { + return intensity + } + return defaultNotificationIntensity(reason) +} + +// legacyNotificationLevelIntensity keeps the old coarse preference UI as a +// deprecated preset over the 2.0 matrix without translating notification +// records. Explicit per-cause values at the same scope always win. +func legacyNotificationLevelIntensity(level corev1.NotificationLevel, reason corev1.NotificationReason) (corev1.NotificationDeliveryIntensity, bool) { + switch level { + case corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED: + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF, true + case corev1.NotificationLevel_NOTIFICATION_LEVEL_NORMAL: + return defaultNotificationIntensity(reason), true + case corev1.NotificationLevel_NOTIFICATION_LEVEL_ALL_MESSAGES: + if reason == corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM { + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT, true + } + return defaultNotificationIntensity(reason), true + default: + return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED, false + } +} + +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 + } + if intensity == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED { + return []*corev1.Event{newEvent(userID, &corev1.Event{Event: &corev1.Event_UserServerNotificationPreferenceCleared{ + UserServerNotificationPreferenceCleared: &corev1.UserServerNotificationPreferenceClearedEvent{UserId: userID, Reason: reason}, + }})}, nil + } + return []*corev1.Event{newEvent(userID, &corev1.Event{Event: &corev1.Event_UserServerNotificationPreferenceSet{ + UserServerNotificationPreferenceSet: &corev1.UserServerNotificationPreferenceSetEvent{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 + } + if intensity == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED { + return []*corev1.Event{newEvent(userID, &corev1.Event{Event: &corev1.Event_UserRoomNotificationPreferenceCleared{ + UserRoomNotificationPreferenceCleared: &corev1.UserRoomNotificationPreferenceClearedEvent{UserId: userID, RoomId: roomID, Reason: reason}, + }})}, nil + } + return []*corev1.Event{newEvent(userID, &corev1.Event{Event: &corev1.Event_UserRoomNotificationPreferenceSet{ + UserRoomNotificationPreferenceSet: &corev1.UserRoomNotificationPreferenceSetEvent{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 *NotificationPreferencesModel) GetNotificationPolicy(ctx context.Context, actorID, roomID string) ([]NotificationPolicyPreference, error) { + if err := s.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 *NotificationPreferencesModel) SetServerNotificationIntensity(ctx context.Context, actorID string, reason corev1.NotificationReason, intensity corev1.NotificationDeliveryIntensity) ([]NotificationPolicyPreference, error) { + if err := s.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 *NotificationPreferencesModel) SetRoomNotificationIntensity(ctx context.Context, actorID, roomID string, reason corev1.NotificationReason, intensity corev1.NotificationDeliveryIntensity) ([]NotificationPolicyPreference, error) { + if err := s.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 *NotificationPreferencesModel) 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..c1ba70ac5 --- /dev/null +++ b/cli/internal/core/notification_policy_test.go @@ -0,0 +1,97 @@ +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.NotificationPreferences() + + policy, err := preferences.GetNotificationPolicy(ctx, user.Id, room.Id) + if err != nil { + t.Fatalf("GetNotificationPolicy: %v", err) + } + 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_test_helpers_test.go b/cli/internal/core/notification_test_helpers_test.go new file mode 100644 index 000000000..d141cc86f --- /dev/null +++ b/cli/internal/core/notification_test_helpers_test.go @@ -0,0 +1,35 @@ +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, NotificationOccurrenceViewInbox) + 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 testMoveAllNotificationOccurrencesDone(t *testing.T, chattoCore *ChattoCore, userID string) { + t.Helper() + done := corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE + for _, occurrence := range testNotificationOccurrences(t, chattoCore, userID) { + if _, err := chattoCore.NotificationOccurrences().Update(testContext(t), userID, occurrence.GetId(), UpdateNotificationOccurrenceInput{InboxState: &done}); err != nil { + t.Fatalf("move notification occurrence Done: %v", err) + } + } +} diff --git a/cli/internal/core/projection_snapshots_test.go b/cli/internal/core/projection_snapshots_test.go index 0e0490e7b..abb748709 100644 --- a/cli/internal/core/projection_snapshots_test.go +++ b/cli/internal/core/projection_snapshots_test.go @@ -210,7 +210,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" 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..48dccf238 100644 --- a/cli/internal/core/reactions.go +++ b/cli/internal/core/reactions.go @@ -65,7 +65,26 @@ 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) + if target.GetActorId() != "" && target.GetActorId() != userID { + intensity := s.core.GetEffectiveNotificationIntensity(target.GetActorId(), roomID, corev1.NotificationReason_NOTIFICATION_REASON_REACTION) + if intensity > corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_OFF { + event.GetReactionAdded().NotificationCandidate = &corev1.NotificationCandidate{ + RecipientId: target.GetActorId(), + Reasons: []*corev1.NotificationReasonMatch{{ + Reason: corev1.NotificationReason_NOTIFICATION_REASON_REACTION, + Intensity: intensity, + }}, + } + } + } added, err := s.publishReactionMutation(ctx, kind, roomID, messageEventID, emojiName, userID, event) if err != nil { return false, fmt.Errorf("failed to add reaction: %w", err) @@ -73,6 +92,10 @@ func (s *ReactionModel) addReaction(ctx context.Context, kind RoomKind, roomID, if !added { return false, nil } + if err := s.core.notificationMaterializer.MaterializeEvent(ctx, event); err != nil { + s.core.logger.Warn("Failed to materialize reaction notification; background replay will retry", + "room_id", roomID, "message_event_id", messageEventID, "error", err) + } s.core.logger.Debug("Reaction added", "kind", kind, @@ -99,7 +122,17 @@ 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) + if target.GetActorId() != userID { + event.GetReactionRemoved().NotificationRecipientId = target.GetActorId() + } removed, err := s.publishReactionMutation(ctx, kind, roomID, messageEventID, emojiName, userID, event) if err != nil { return false, fmt.Errorf("failed to remove reaction: %w", err) @@ -107,6 +140,10 @@ func (s *ReactionModel) removeReaction(ctx context.Context, kind RoomKind, roomI if !removed { return false, nil } + if err := s.core.notificationMaterializer.MaterializeEvent(ctx, event); err != nil { + s.core.logger.Warn("Failed to remove reaction notification; background replay will retry", + "room_id", roomID, "message_event_id", messageEventID, "error", err) + } s.core.logger.Debug("Reaction removed", "kind", kind, @@ -438,6 +475,9 @@ func (s *ReactionModel) publishReactionMutation(ctx context.Context, kind RoomKi } else if !snapshot.Exists { return nil, nil } + if remove { + event.GetReactionRemoved().NotificationSourceEventId = snapshot.SourceEventID + } return []evtstream.MutationEntry{{Subject: publishSubject, Event: event}}, nil }) diff --git a/cli/internal/core/reactions_test.go b/cli/internal/core/reactions_test.go index a8ece8e68..7c915f152 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) diff --git a/cli/internal/core/read_state_model.go b/cli/internal/core/read_state_model.go index 0875c952d..0b33ab2e5 100644 --- a/cli/internal/core/read_state_model.go +++ b/cli/internal/core/read_state_model.go @@ -111,11 +111,21 @@ func (s *ReadStateModel) MarkRoomAsRead(ctx context.Context, actorID, roomID, up } } - dismissedNotifications := 0 + readNotifications := 0 + dismissedLegacyNotifications := 0 if hasLast && !lastTime.IsZero() { - dismissedNotifications = s.core.DismissRoomReadNotifications(ctx, kind, actorID, room.Id, lastTime) + readNotifications, err = s.core.notificationOccurrences.MarkCoveredRead(ctx, actorID, room.Id, "", lastTime) + if err != nil { + s.core.logger.Warn("Failed to reconcile room read state with notification inbox", + "user_id", actorID, "room_id", room.Id, "error", err) + readNotifications = 0 + } + // Legacy records are not migrated into Notifications 2.0, but retaining + // their old read cleanup keeps a rollback or mixed rolling deployment + // from accumulating stale pending rows. + dismissedLegacyNotifications = s.core.DismissRoomReadNotifications(ctx, kind, actorID, room.Id, lastTime) } - if markerUpdated || dismissedNotifications > 0 { + if markerUpdated || readNotifications > 0 || dismissedLegacyNotifications > 0 { s.core.NotifyRoomMarkedAsRead(ctx, actorID, kind, room.Id) } @@ -166,6 +176,10 @@ func (s *ReadStateModel) MarkThreadAsRead(ctx context.Context, actorID, roomID, } if markerEventID != "" { if markerTime, err := s.core.GetEventTimestamp(ctx, kind, room.Id, markerEventID); err == nil && !markerTime.IsZero() { + if _, err := s.core.notificationOccurrences.MarkCoveredRead(ctx, actorID, room.Id, threadRootEventID, markerTime); err != nil { + s.core.logger.Warn("Failed to reconcile thread read state with notification inbox", + "user_id", actorID, "room_id", room.Id, "thread_root_event_id", threadRootEventID, "error", err) + } s.core.DismissThreadReadNotifications(ctx, kind, actorID, room.Id, threadRootEventID, markerTime) } } diff --git a/cli/internal/core/threads_test.go b/cli/internal/core/threads_test.go index 7a3da16a9..387be3c62 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) } @@ -1260,10 +1254,7 @@ func TestChattoCore_PostMessage_MutedDirectMentionDoesNotAutoFollowThread(t *tes 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) - } + notifications := testNotificationOccurrences(t, core, mentioned.Id) if len(notifications) != 0 { t.Fatalf("expected no notifications for muted 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) + testMoveAllNotificationOccurrencesDone(t, core, userA.Id) + testMoveAllNotificationOccurrencesDone(t, core, userB.Id) + testMoveAllNotificationOccurrencesDone(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) + testMoveAllNotificationOccurrencesDone(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,20 +1600,16 @@ 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) { // Clear existing notifications - core.DismissAllNotifications(ctx, alice.Id) + testMoveAllNotificationOccurrencesDone(t, core, alice.Id) // Alice mutes the room core.SetRoomNotificationLevel(ctx, alice.Id, room.Id, corev1.NotificationLevel_NOTIFICATION_LEVEL_MUTED) @@ -1667,20 +1628,16 @@ func TestChattoCore_PostMessage_InReplyToNotification(t *testing.T) { } // 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") + for _, occurrence := range testNotificationOccurrences(t, core, alice.Id) { + if testOccurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REPLY) { + t.Error("expected no reply occurrence when room is muted") } } }) - 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) + testMoveAllNotificationOccurrencesDone(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) + occurrences := testNotificationOccurrences(t, core, alice.Id) + if len(occurrences) != 1 { + t.Fatalf("expected one merged occurrence, got %d", len(occurrences)) } - - 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) - } - 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) + testMoveAllNotificationOccurrencesDone(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) - } - - 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 - } + 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) } - 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) + testMoveAllNotificationOccurrencesDone(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) - } - - 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) + occurrences := testNotificationOccurrences(t, core, alice.Id) + if len(occurrences) != 1 { + t.Fatalf("expected one merged occurrence, got %d", len(occurrences)) } - 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) + testMoveAllNotificationOccurrencesDone(t, core, alice.Id) + testMoveAllNotificationOccurrencesDone(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) + testMoveAllNotificationOccurrencesDone(t, core, alice.Id) + testMoveAllNotificationOccurrencesDone(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) + testMoveAllNotificationOccurrencesDone(t, core, alice.Id) + testMoveAllNotificationOccurrencesDone(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) + testMoveAllNotificationOccurrencesDone(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/evtstream/events_integration_test.go b/cli/internal/evtstream/events_integration_test.go index c6ff13aa0..6cf63a901 100644 --- a/cli/internal/evtstream/events_integration_test.go +++ b/cli/internal/evtstream/events_integration_test.go @@ -238,6 +238,44 @@ func TestIncrementalEffectConsumer_PermanentFailureDoesNotBlockLaterEffects(t *t } } +func TestOrderedIncrementalEffectConsumer_BlocksLaterEffectsUntilRetrySucceeds(t *testing.T) { + js, stream := setupTestStream(t) + pub := NewPublisher(js, stream, testLogger()) + ctx := testContext(t) + subject := RoomAggregate("R-ordered").Subject(EventUserJoinedRoom) + for _, userID := range []string{"U1", "U2"} { + if _, err := pub.AppendEventually(ctx, subject, makeEvent("R-ordered", userID)); err != nil { + t.Fatalf("AppendEventually %s: %v", userID, err) + } + } + + fail := true + var handled []string + consumer := NewOrderedIncrementalEffectConsumer(pub, subject, func(_ context.Context, event *corev1.Event) error { + handled = append(handled, event.GetActorId()) + if fail && event.GetActorId() == "U1" { + return errors.New("ordered effect unavailable") + } + return nil + }) + if err := consumer.Consume(ctx); err == nil { + t.Fatal("Consume returned nil for ordered failure") + } + if want := []string{"U1"}; !slices.Equal(handled, want) { + t.Fatalf("first pass handled actors = %v, want %v", handled, want) + } + if status := consumer.Status(); status.PendingCount != 2 { + t.Fatalf("pending after ordered failure = %d, want 2", status.PendingCount) + } + fail = false + if err := consumer.Consume(ctx); err != nil { + t.Fatalf("Consume retry: %v", err) + } + if want := []string{"U1", "U1", "U2"}; !slices.Equal(handled, want) { + t.Fatalf("handled actors = %v, want %v", handled, want) + } +} + func TestIncrementalEffectConsumer_SerializesConcurrentConsume(t *testing.T) { js, stream := setupTestStream(t) pub := NewPublisher(js, stream, testLogger()) diff --git a/cli/internal/evtstream/incremental_effect_consumer.go b/cli/internal/evtstream/incremental_effect_consumer.go index c74b875e0..8b7a970b9 100644 --- a/cli/internal/evtstream/incremental_effect_consumer.go +++ b/cli/internal/evtstream/incremental_effect_consumer.go @@ -24,10 +24,25 @@ type IncrementalEffectConsumer struct { afterSeq uint64 pending []*SubjectEvent initialized bool + ordered bool statusMu sync.RWMutex status IncrementalEffectConsumerStatus } +// NewOrderedIncrementalEffectConsumer constructs a consumer that preserves +// global EVT order: one failed effect remains at the head of the local queue +// and later effects wait. Use it when later lifecycle facts can invalidate or +// supersede earlier work. +func NewOrderedIncrementalEffectConsumer( + publisher *Publisher, + subject string, + handle func(context.Context, *corev1.Event) error, +) *IncrementalEffectConsumer { + consumer := NewIncrementalEffectConsumer(publisher, subject, handle) + consumer.ordered = true + return consumer +} + // IncrementalEffectConsumerStatus is a point-in-time view of process-local // discovery and retry state. Domain owners decide how or whether to publish it. type IncrementalEffectConsumerStatus struct { @@ -91,10 +106,14 @@ func (c *IncrementalEffectConsumer) Consume(ctx context.Context) error { remaining := c.pending[:0] var handleErr error - for _, event := range c.pending { + for index, event := range c.pending { if err := c.handle(ctx, event); err != nil { remaining = append(remaining, event) handleErr = errors.Join(handleErr, fmt.Errorf("handle incremental effect %s for %s: %w", event.Event.GetId(), c.subject, err)) + if c.ordered { + remaining = append(remaining, c.pending[index+1:]...) + break + } } } c.pending = remaining diff --git a/cli/internal/evtstream/subjects.go b/cli/internal/evtstream/subjects.go index d9fa48121..9eee1a2a3 100644 --- a/cli/internal/evtstream/subjects.go +++ b/cli/internal/evtstream/subjects.go @@ -122,23 +122,27 @@ const ( EventRoomGroupsReordered = "groups_reordered" // Config aggregate (singleton) - EventServerNameChanged = "server_name_changed" - EventServerDescriptionChanged = "server_description_changed" - EventServerWelcomeMessageChanged = "server_welcome_message_changed" - EventServerMotdChanged = "server_motd_changed" - EventServerBlockedUsernamesChanged = "server_blocked_usernames_changed" - EventServerLogoSet = "server_logo_set" - EventServerLogoCleared = "server_logo_cleared" - EventServerBannerSet = "server_banner_set" - EventServerBannerCleared = "server_banner_cleared" - EventUserTimezoneChanged = "user_timezone_changed" - EventUserTimezoneCleared = "user_timezone_cleared" - EventUserTimeFormatChanged = "user_time_format_changed" - EventUserTimeFormatCleared = "user_time_format_cleared" - EventUserServerNotificationLevelSet = "user_server_notification_level_set" - EventUserServerNotificationLevelCleared = "user_server_notification_level_cleared" - EventUserRoomNotificationLevelSet = "user_room_notification_level_set" - EventUserRoomNotificationLevelCleared = "user_room_notification_level_cleared" + EventServerNameChanged = "server_name_changed" + EventServerDescriptionChanged = "server_description_changed" + EventServerWelcomeMessageChanged = "server_welcome_message_changed" + EventServerMotdChanged = "server_motd_changed" + EventServerBlockedUsernamesChanged = "server_blocked_usernames_changed" + EventServerLogoSet = "server_logo_set" + EventServerLogoCleared = "server_logo_cleared" + EventServerBannerSet = "server_banner_set" + EventServerBannerCleared = "server_banner_cleared" + EventUserTimezoneChanged = "user_timezone_changed" + EventUserTimezoneCleared = "user_timezone_cleared" + EventUserTimeFormatChanged = "user_time_format_changed" + EventUserTimeFormatCleared = "user_time_format_cleared" + EventUserServerNotificationLevelSet = "user_server_notification_level_set" + EventUserServerNotificationLevelCleared = "user_server_notification_level_cleared" + EventUserRoomNotificationLevelSet = "user_room_notification_level_set" + EventUserRoomNotificationLevelCleared = "user_room_notification_level_cleared" + EventUserServerNotificationPreferenceSet = "user_server_notification_preference_set" + EventUserServerNotificationPreferenceCleared = "user_server_notification_preference_cleared" + EventUserRoomNotificationPreferenceSet = "user_room_notification_preference_set" + EventUserRoomNotificationPreferenceCleared = "user_room_notification_preference_cleared" // User aggregate EventUserAccountCreated = "account_created" @@ -326,6 +330,14 @@ func EventTypeOf(e *corev1.Event) string { return EventUserRoomNotificationLevelSet case *corev1.Event_UserRoomNotificationLevelCleared: return EventUserRoomNotificationLevelCleared + case *corev1.Event_UserServerNotificationPreferenceSet: + return EventUserServerNotificationPreferenceSet + case *corev1.Event_UserServerNotificationPreferenceCleared: + return EventUserServerNotificationPreferenceCleared + case *corev1.Event_UserRoomNotificationPreferenceSet: + return EventUserRoomNotificationPreferenceSet + case *corev1.Event_UserRoomNotificationPreferenceCleared: + return EventUserRoomNotificationPreferenceCleared case *corev1.Event_UserAccountCreated: return EventUserAccountCreated diff --git a/cli/internal/http_server/realtime_projection.go b/cli/internal/http_server/realtime_projection.go index b85809ccd..d01369269 100644 --- a/cli/internal/http_server/realtime_projection.go +++ b/cli/internal/http_server/realtime_projection.go @@ -310,6 +310,35 @@ func (s *HTTPServer) realtimeProjectionFrameForEventWithRooms(ctx context.Contex appendOperation(&realtimev1.RealtimeProjectionOperation{Operation: &realtimev1.RealtimeProjectionOperation_NotificationsReplace{ NotificationsReplace: replacement, }}) + 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 + } + 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: action, + NotificationId: change.GetNotificationId(), + Silent: !change.GetAlert(), + } + appendOperation(&realtimev1.RealtimeProjectionOperation{Operation: &realtimev1.RealtimeProjectionOperation_NotificationsReplace{ + NotificationsReplace: replacement, + }}) case *corev1.LiveEvent_RoomMarkedAsRead: roomID := payload.RoomMarkedAsRead.GetRoomId() viewerState, err := s.connectAPI.BuildRealtimeProjectionRoomViewerState(ctx, viewerID, roomID) @@ -534,6 +563,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() @@ -827,6 +905,7 @@ func realtimeProjectionNotifications(notifications *connectapi.RealtimeProjectio 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 1d4d6a248..67c3f27c2 100644 --- a/cli/internal/http_server/realtime_test.go +++ b/cli/internal/http_server/realtime_test.go @@ -404,6 +404,7 @@ func TestRealtimeTransientMapperRejectsProjectionOwnedLiveEvents(t *testing.T) { }{ {"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 occurrence changed", &corev1.LiveEvent{Event: &corev1.LiveEvent_NotificationOccurrenceChanged{NotificationOccurrenceChanged: &corev1.NotificationOccurrenceChangedEvent{NotificationId: "N2"}}}}, {"notification level", &corev1.LiveEvent{Event: &corev1.LiveEvent_NotificationLevelChanged{NotificationLevelChanged: &corev1.NotificationLevelChangedEvent{RoomId: "R1"}}}}, {"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"}}}}, @@ -1557,6 +1558,84 @@ func TestRealtimeProjectionNotificationChangesReplaceStateAndCarryLiveTransition } } +func TestRealtimeProjectionNotificationOccurrenceChangesReplaceGroups(t *testing.T) { + env := setupWebSocketTestServer(t) + 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-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-v2-room", "") + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + for _, userID := range []string{viewer.Id, author.Id} { + if _, err := env.core.JoinRoom(env.ctx, viewer.Id, core.KindChannel, userID, room.Id); err != nil { + t.Fatalf("JoinRoom %q: %v", userID, err) + } + } + root, err := env.core.PostMessage(env.ctx, core.KindChannel, room.Id, viewer.Id, "thread root", nil, "", "", nil, false) + if err != nil { + t.Fatalf("PostMessage root: %v", err) + } + _, 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("PostMessage: %v", err) + } + occurrences, err := env.core.NotificationOccurrences().List(env.ctx, viewer.Id, core.NotificationOccurrenceViewInbox) + 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-v2-created", + ActorId: author.Id, + Event: &corev1.LiveEvent_NotificationOccurrenceChanged{NotificationOccurrenceChanged: &corev1.NotificationOccurrenceChangedEvent{ + NotificationId: occurrence.GetId(), Created: true, Alert: true, + }}, + })) + 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.GetGroups().GetGroups()) != 1 || replacement.GetGroups().GetUnreadGroupCount() != 1 { + t.Fatalf("created replacement = %+v, want one unread group", replacement) + } + 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) + } + operations := frame.GetProjectionEvent().GetOperations() + if len(operations) != 1 { + t.Fatalf("created notification operations = %+v, want one notification replacement", operations) + } + + done := corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE + if _, err := env.core.NotificationOccurrences().Update(env.ctx, viewer.Id, occurrence.GetId(), core.UpdateNotificationOccurrenceInput{InboxState: &done}); err != nil { + t.Fatalf("move occurrence Done: %v", err) + } + 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 || !handled { + t.Fatalf("updated projection frame = %+v, handled=%v, err=%v", frame, handled, err) + } + replacement = frame.GetProjectionEvent().GetOperations()[0].GetNotificationsReplace() + if replacement == nil || len(replacement.GetGroups().GetGroups()) != 0 || replacement.GetGroups().GetUnreadGroupCount() != 0 { + t.Fatalf("updated replacement = %+v, want empty Inbox groups", replacement) + } + 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) + } +} + 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") @@ -1982,6 +2061,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) @@ -1989,10 +2072,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.NotificationPreferences().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) @@ -2000,20 +2102,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, core.NotificationOccurrenceViewInbox) + if err != nil || len(occurrences) != 0 { + t.Fatalf("off-policy notification occurrences = %+v, %v, want none", occurrences, err) + } } func TestRealtimeWebSocketMessageRetractionUpsertsDeletedRow(t *testing.T) { @@ -2733,8 +2856,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/notifications.connect.go b/cli/internal/pb/chatto/api/v1/apiv1connect/notifications.connect.go index 76f03966d..df62fccf2 100644 --- a/cli/internal/pb/chatto/api/v1/apiv1connect/notifications.connect.go +++ b/cli/internal/pb/chatto/api/v1/apiv1connect/notifications.connect.go @@ -33,6 +33,36 @@ const ( // reflection-formatted method names, remove the leading slash and convert the remaining slash to a // period. const ( + // NotificationServiceListNotificationGroupsProcedure is the fully-qualified name of the + // NotificationService's ListNotificationGroups RPC. + NotificationServiceListNotificationGroupsProcedure = "/chatto.api.v1.NotificationService/ListNotificationGroups" + // NotificationServiceListNotificationOccurrencesProcedure is the fully-qualified name of the + // NotificationService's ListNotificationOccurrences RPC. + NotificationServiceListNotificationOccurrencesProcedure = "/chatto.api.v1.NotificationService/ListNotificationOccurrences" + // NotificationServiceGetNotificationOccurrenceProcedure is the fully-qualified name of the + // NotificationService's GetNotificationOccurrence RPC. + NotificationServiceGetNotificationOccurrenceProcedure = "/chatto.api.v1.NotificationService/GetNotificationOccurrence" + // NotificationServiceUpdateNotificationOccurrenceProcedure is the fully-qualified name of the + // NotificationService's UpdateNotificationOccurrence RPC. + NotificationServiceUpdateNotificationOccurrenceProcedure = "/chatto.api.v1.NotificationService/UpdateNotificationOccurrence" + // NotificationServiceDeleteNotificationOccurrenceProcedure is the fully-qualified name of the + // NotificationService's DeleteNotificationOccurrence RPC. + NotificationServiceDeleteNotificationOccurrenceProcedure = "/chatto.api.v1.NotificationService/DeleteNotificationOccurrence" + // NotificationServiceUpdateNotificationGroupProcedure is the fully-qualified name of the + // NotificationService's UpdateNotificationGroup RPC. + NotificationServiceUpdateNotificationGroupProcedure = "/chatto.api.v1.NotificationService/UpdateNotificationGroup" + // NotificationServiceDeleteNotificationGroupProcedure is the fully-qualified name of the + // NotificationService's DeleteNotificationGroup RPC. + NotificationServiceDeleteNotificationGroupProcedure = "/chatto.api.v1.NotificationService/DeleteNotificationGroup" + // NotificationServiceUnsubscribeNotificationGroupProcedure is the fully-qualified name of the + // NotificationService's UnsubscribeNotificationGroup RPC. + NotificationServiceUnsubscribeNotificationGroupProcedure = "/chatto.api.v1.NotificationService/UnsubscribeNotificationGroup" + // 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" // NotificationServiceListNotificationsProcedure is the fully-qualified name of the // NotificationService's ListNotifications RPC. NotificationServiceListNotificationsProcedure = "/chatto.api.v1.NotificationService/ListNotifications" @@ -61,6 +91,35 @@ const ( // NotificationServiceClient is a client for the chatto.api.v1.NotificationService service. type NotificationServiceClient interface { + // Lists the Notifications 2.0 Inbox, Done, or Saved groups. + ListNotificationGroups(context.Context, *connect.Request[v1.ListNotificationGroupsRequest]) (*connect.Response[v1.ListNotificationGroupsResponse], error) + // Lists exact members of one derived notification group. + ListNotificationOccurrences(context.Context, *connect.Request[v1.ListNotificationOccurrencesRequest]) (*connect.Response[v1.ListNotificationOccurrencesResponse], error) + // Gets one visible occurrence. Returns NOT_FOUND when absent or inaccessible. + GetNotificationOccurrence(context.Context, *connect.Request[v1.GetNotificationOccurrenceRequest]) (*connect.Response[v1.GetNotificationOccurrenceResponse], error) + // Patches one occurrence's inbox and Saved state. + UpdateNotificationOccurrence(context.Context, *connect.Request[v1.UpdateNotificationOccurrenceRequest]) (*connect.Response[v1.UpdateNotificationOccurrenceResponse], error) + // Permanently deletes one occurrence from every notification view. + DeleteNotificationOccurrence(context.Context, *connect.Request[v1.DeleteNotificationOccurrenceRequest]) (*connect.Response[v1.DeleteNotificationOccurrenceResponse], error) + // Patches 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. + UpdateNotificationGroup(context.Context, *connect.Request[v1.UpdateNotificationGroupRequest]) (*connect.Response[v1.UpdateNotificationGroupResponse], 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) + // Disables an ambient thread/room source and moves its current group to Done. + // 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. + UnsubscribeNotificationGroup(context.Context, *connect.Request[v1.UnsubscribeNotificationGroupRequest]) (*connect.Response[v1.UnsubscribeNotificationGroupResponse], 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) // 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 @@ -92,6 +151,69 @@ 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{ + listNotificationGroups: connect.NewClient[v1.ListNotificationGroupsRequest, v1.ListNotificationGroupsResponse]( + httpClient, + baseURL+NotificationServiceListNotificationGroupsProcedure, + connect.WithSchema(notificationServiceMethods.ByName("ListNotificationGroups")), + connect.WithClientOptions(opts...), + ), + listNotificationOccurrences: connect.NewClient[v1.ListNotificationOccurrencesRequest, v1.ListNotificationOccurrencesResponse]( + httpClient, + baseURL+NotificationServiceListNotificationOccurrencesProcedure, + connect.WithSchema(notificationServiceMethods.ByName("ListNotificationOccurrences")), + connect.WithClientOptions(opts...), + ), + getNotificationOccurrence: connect.NewClient[v1.GetNotificationOccurrenceRequest, v1.GetNotificationOccurrenceResponse]( + httpClient, + baseURL+NotificationServiceGetNotificationOccurrenceProcedure, + connect.WithSchema(notificationServiceMethods.ByName("GetNotificationOccurrence")), + connect.WithClientOptions(opts...), + ), + updateNotificationOccurrence: connect.NewClient[v1.UpdateNotificationOccurrenceRequest, v1.UpdateNotificationOccurrenceResponse]( + httpClient, + baseURL+NotificationServiceUpdateNotificationOccurrenceProcedure, + connect.WithSchema(notificationServiceMethods.ByName("UpdateNotificationOccurrence")), + connect.WithIdempotency(connect.IdempotencyIdempotent), + connect.WithClientOptions(opts...), + ), + deleteNotificationOccurrence: connect.NewClient[v1.DeleteNotificationOccurrenceRequest, v1.DeleteNotificationOccurrenceResponse]( + httpClient, + baseURL+NotificationServiceDeleteNotificationOccurrenceProcedure, + connect.WithSchema(notificationServiceMethods.ByName("DeleteNotificationOccurrence")), + connect.WithIdempotency(connect.IdempotencyIdempotent), + connect.WithClientOptions(opts...), + ), + updateNotificationGroup: connect.NewClient[v1.UpdateNotificationGroupRequest, v1.UpdateNotificationGroupResponse]( + httpClient, + baseURL+NotificationServiceUpdateNotificationGroupProcedure, + connect.WithSchema(notificationServiceMethods.ByName("UpdateNotificationGroup")), + connect.WithClientOptions(opts...), + ), + deleteNotificationGroup: connect.NewClient[v1.DeleteNotificationGroupRequest, v1.DeleteNotificationGroupResponse]( + httpClient, + baseURL+NotificationServiceDeleteNotificationGroupProcedure, + connect.WithSchema(notificationServiceMethods.ByName("DeleteNotificationGroup")), + connect.WithClientOptions(opts...), + ), + unsubscribeNotificationGroup: connect.NewClient[v1.UnsubscribeNotificationGroupRequest, v1.UnsubscribeNotificationGroupResponse]( + httpClient, + baseURL+NotificationServiceUnsubscribeNotificationGroupProcedure, + connect.WithSchema(notificationServiceMethods.ByName("UnsubscribeNotificationGroup")), + connect.WithClientOptions(opts...), + ), + getNotificationPolicy: connect.NewClient[v1.GetNotificationPolicyRequest, v1.GetNotificationPolicyResponse]( + httpClient, + baseURL+NotificationServiceGetNotificationPolicyProcedure, + connect.WithSchema(notificationServiceMethods.ByName("GetNotificationPolicy")), + connect.WithClientOptions(opts...), + ), + setNotificationPolicyPreference: connect.NewClient[v1.SetNotificationPolicyPreferenceRequest, v1.SetNotificationPolicyPreferenceResponse]( + httpClient, + baseURL+NotificationServiceSetNotificationPolicyPreferenceProcedure, + connect.WithSchema(notificationServiceMethods.ByName("SetNotificationPolicyPreference")), + connect.WithIdempotency(connect.IdempotencyIdempotent), + connect.WithClientOptions(opts...), + ), listNotifications: connect.NewClient[v1.ListNotificationsRequest, v1.ListNotificationsResponse]( httpClient, baseURL+NotificationServiceListNotificationsProcedure, @@ -146,14 +268,78 @@ func NewNotificationServiceClient(httpClient connect.HTTPClient, baseURL string, // 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] + listNotificationGroups *connect.Client[v1.ListNotificationGroupsRequest, v1.ListNotificationGroupsResponse] + listNotificationOccurrences *connect.Client[v1.ListNotificationOccurrencesRequest, v1.ListNotificationOccurrencesResponse] + getNotificationOccurrence *connect.Client[v1.GetNotificationOccurrenceRequest, v1.GetNotificationOccurrenceResponse] + updateNotificationOccurrence *connect.Client[v1.UpdateNotificationOccurrenceRequest, v1.UpdateNotificationOccurrenceResponse] + deleteNotificationOccurrence *connect.Client[v1.DeleteNotificationOccurrenceRequest, v1.DeleteNotificationOccurrenceResponse] + updateNotificationGroup *connect.Client[v1.UpdateNotificationGroupRequest, v1.UpdateNotificationGroupResponse] + deleteNotificationGroup *connect.Client[v1.DeleteNotificationGroupRequest, v1.DeleteNotificationGroupResponse] + unsubscribeNotificationGroup *connect.Client[v1.UnsubscribeNotificationGroupRequest, v1.UnsubscribeNotificationGroupResponse] + getNotificationPolicy *connect.Client[v1.GetNotificationPolicyRequest, v1.GetNotificationPolicyResponse] + setNotificationPolicyPreference *connect.Client[v1.SetNotificationPolicyPreferenceRequest, v1.SetNotificationPolicyPreferenceResponse] + 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] +} + +// 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) +} + +// ListNotificationOccurrences calls chatto.api.v1.NotificationService.ListNotificationOccurrences. +func (c *notificationServiceClient) ListNotificationOccurrences(ctx context.Context, req *connect.Request[v1.ListNotificationOccurrencesRequest]) (*connect.Response[v1.ListNotificationOccurrencesResponse], error) { + return c.listNotificationOccurrences.CallUnary(ctx, req) +} + +// GetNotificationOccurrence calls chatto.api.v1.NotificationService.GetNotificationOccurrence. +func (c *notificationServiceClient) GetNotificationOccurrence(ctx context.Context, req *connect.Request[v1.GetNotificationOccurrenceRequest]) (*connect.Response[v1.GetNotificationOccurrenceResponse], error) { + return c.getNotificationOccurrence.CallUnary(ctx, req) +} + +// UpdateNotificationOccurrence calls +// chatto.api.v1.NotificationService.UpdateNotificationOccurrence. +func (c *notificationServiceClient) UpdateNotificationOccurrence(ctx context.Context, req *connect.Request[v1.UpdateNotificationOccurrenceRequest]) (*connect.Response[v1.UpdateNotificationOccurrenceResponse], error) { + return c.updateNotificationOccurrence.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) +} + +// UpdateNotificationGroup calls chatto.api.v1.NotificationService.UpdateNotificationGroup. +func (c *notificationServiceClient) UpdateNotificationGroup(ctx context.Context, req *connect.Request[v1.UpdateNotificationGroupRequest]) (*connect.Response[v1.UpdateNotificationGroupResponse], error) { + return c.updateNotificationGroup.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) +} + +// UnsubscribeNotificationGroup calls +// chatto.api.v1.NotificationService.UnsubscribeNotificationGroup. +func (c *notificationServiceClient) UnsubscribeNotificationGroup(ctx context.Context, req *connect.Request[v1.UnsubscribeNotificationGroupRequest]) (*connect.Response[v1.UnsubscribeNotificationGroupResponse], error) { + return c.unsubscribeNotificationGroup.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) +} + +// 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) } // ListNotifications calls chatto.api.v1.NotificationService.ListNotifications. @@ -198,6 +384,35 @@ func (c *notificationServiceClient) DismissAllNotifications(ctx context.Context, // NotificationServiceHandler is an implementation of the chatto.api.v1.NotificationService service. type NotificationServiceHandler interface { + // Lists the Notifications 2.0 Inbox, Done, or Saved groups. + ListNotificationGroups(context.Context, *connect.Request[v1.ListNotificationGroupsRequest]) (*connect.Response[v1.ListNotificationGroupsResponse], error) + // Lists exact members of one derived notification group. + ListNotificationOccurrences(context.Context, *connect.Request[v1.ListNotificationOccurrencesRequest]) (*connect.Response[v1.ListNotificationOccurrencesResponse], error) + // Gets one visible occurrence. Returns NOT_FOUND when absent or inaccessible. + GetNotificationOccurrence(context.Context, *connect.Request[v1.GetNotificationOccurrenceRequest]) (*connect.Response[v1.GetNotificationOccurrenceResponse], error) + // Patches one occurrence's inbox and Saved state. + UpdateNotificationOccurrence(context.Context, *connect.Request[v1.UpdateNotificationOccurrenceRequest]) (*connect.Response[v1.UpdateNotificationOccurrenceResponse], error) + // Permanently deletes one occurrence from every notification view. + DeleteNotificationOccurrence(context.Context, *connect.Request[v1.DeleteNotificationOccurrenceRequest]) (*connect.Response[v1.DeleteNotificationOccurrenceResponse], error) + // Patches 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. + UpdateNotificationGroup(context.Context, *connect.Request[v1.UpdateNotificationGroupRequest]) (*connect.Response[v1.UpdateNotificationGroupResponse], 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) + // Disables an ambient thread/room source and moves its current group to Done. + // 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. + UnsubscribeNotificationGroup(context.Context, *connect.Request[v1.UnsubscribeNotificationGroupRequest]) (*connect.Response[v1.UnsubscribeNotificationGroupResponse], 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) // 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 @@ -225,6 +440,69 @@ 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() + notificationServiceListNotificationGroupsHandler := connect.NewUnaryHandler( + NotificationServiceListNotificationGroupsProcedure, + svc.ListNotificationGroups, + connect.WithSchema(notificationServiceMethods.ByName("ListNotificationGroups")), + connect.WithHandlerOptions(opts...), + ) + notificationServiceListNotificationOccurrencesHandler := connect.NewUnaryHandler( + NotificationServiceListNotificationOccurrencesProcedure, + svc.ListNotificationOccurrences, + connect.WithSchema(notificationServiceMethods.ByName("ListNotificationOccurrences")), + connect.WithHandlerOptions(opts...), + ) + notificationServiceGetNotificationOccurrenceHandler := connect.NewUnaryHandler( + NotificationServiceGetNotificationOccurrenceProcedure, + svc.GetNotificationOccurrence, + connect.WithSchema(notificationServiceMethods.ByName("GetNotificationOccurrence")), + connect.WithHandlerOptions(opts...), + ) + notificationServiceUpdateNotificationOccurrenceHandler := connect.NewUnaryHandler( + NotificationServiceUpdateNotificationOccurrenceProcedure, + svc.UpdateNotificationOccurrence, + connect.WithSchema(notificationServiceMethods.ByName("UpdateNotificationOccurrence")), + connect.WithIdempotency(connect.IdempotencyIdempotent), + connect.WithHandlerOptions(opts...), + ) + notificationServiceDeleteNotificationOccurrenceHandler := connect.NewUnaryHandler( + NotificationServiceDeleteNotificationOccurrenceProcedure, + svc.DeleteNotificationOccurrence, + connect.WithSchema(notificationServiceMethods.ByName("DeleteNotificationOccurrence")), + connect.WithIdempotency(connect.IdempotencyIdempotent), + connect.WithHandlerOptions(opts...), + ) + notificationServiceUpdateNotificationGroupHandler := connect.NewUnaryHandler( + NotificationServiceUpdateNotificationGroupProcedure, + svc.UpdateNotificationGroup, + connect.WithSchema(notificationServiceMethods.ByName("UpdateNotificationGroup")), + connect.WithHandlerOptions(opts...), + ) + notificationServiceDeleteNotificationGroupHandler := connect.NewUnaryHandler( + NotificationServiceDeleteNotificationGroupProcedure, + svc.DeleteNotificationGroup, + connect.WithSchema(notificationServiceMethods.ByName("DeleteNotificationGroup")), + connect.WithHandlerOptions(opts...), + ) + notificationServiceUnsubscribeNotificationGroupHandler := connect.NewUnaryHandler( + NotificationServiceUnsubscribeNotificationGroupProcedure, + svc.UnsubscribeNotificationGroup, + connect.WithSchema(notificationServiceMethods.ByName("UnsubscribeNotificationGroup")), + connect.WithHandlerOptions(opts...), + ) + notificationServiceGetNotificationPolicyHandler := connect.NewUnaryHandler( + NotificationServiceGetNotificationPolicyProcedure, + svc.GetNotificationPolicy, + connect.WithSchema(notificationServiceMethods.ByName("GetNotificationPolicy")), + connect.WithHandlerOptions(opts...), + ) + notificationServiceSetNotificationPolicyPreferenceHandler := connect.NewUnaryHandler( + NotificationServiceSetNotificationPolicyPreferenceProcedure, + svc.SetNotificationPolicyPreference, + connect.WithSchema(notificationServiceMethods.ByName("SetNotificationPolicyPreference")), + connect.WithIdempotency(connect.IdempotencyIdempotent), + connect.WithHandlerOptions(opts...), + ) notificationServiceListNotificationsHandler := connect.NewUnaryHandler( NotificationServiceListNotificationsProcedure, svc.ListNotifications, @@ -276,6 +554,26 @@ func NewNotificationServiceHandler(svc NotificationServiceHandler, opts ...conne ) return "/chatto.api.v1.NotificationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { + case NotificationServiceListNotificationGroupsProcedure: + notificationServiceListNotificationGroupsHandler.ServeHTTP(w, r) + case NotificationServiceListNotificationOccurrencesProcedure: + notificationServiceListNotificationOccurrencesHandler.ServeHTTP(w, r) + case NotificationServiceGetNotificationOccurrenceProcedure: + notificationServiceGetNotificationOccurrenceHandler.ServeHTTP(w, r) + case NotificationServiceUpdateNotificationOccurrenceProcedure: + notificationServiceUpdateNotificationOccurrenceHandler.ServeHTTP(w, r) + case NotificationServiceDeleteNotificationOccurrenceProcedure: + notificationServiceDeleteNotificationOccurrenceHandler.ServeHTTP(w, r) + case NotificationServiceUpdateNotificationGroupProcedure: + notificationServiceUpdateNotificationGroupHandler.ServeHTTP(w, r) + case NotificationServiceDeleteNotificationGroupProcedure: + notificationServiceDeleteNotificationGroupHandler.ServeHTTP(w, r) + case NotificationServiceUnsubscribeNotificationGroupProcedure: + notificationServiceUnsubscribeNotificationGroupHandler.ServeHTTP(w, r) + case NotificationServiceGetNotificationPolicyProcedure: + notificationServiceGetNotificationPolicyHandler.ServeHTTP(w, r) + case NotificationServiceSetNotificationPolicyPreferenceProcedure: + notificationServiceSetNotificationPolicyPreferenceHandler.ServeHTTP(w, r) case NotificationServiceListNotificationsProcedure: notificationServiceListNotificationsHandler.ServeHTTP(w, r) case NotificationServiceGetNotificationProcedure: @@ -301,6 +599,46 @@ func NewNotificationServiceHandler(svc NotificationServiceHandler, opts ...conne // UnimplementedNotificationServiceHandler returns CodeUnimplemented from all methods. type UnimplementedNotificationServiceHandler struct{} +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) ListNotificationOccurrences(context.Context, *connect.Request[v1.ListNotificationOccurrencesRequest]) (*connect.Response[v1.ListNotificationOccurrencesResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.ListNotificationOccurrences is not implemented")) +} + +func (UnimplementedNotificationServiceHandler) GetNotificationOccurrence(context.Context, *connect.Request[v1.GetNotificationOccurrenceRequest]) (*connect.Response[v1.GetNotificationOccurrenceResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.GetNotificationOccurrence is not implemented")) +} + +func (UnimplementedNotificationServiceHandler) UpdateNotificationOccurrence(context.Context, *connect.Request[v1.UpdateNotificationOccurrenceRequest]) (*connect.Response[v1.UpdateNotificationOccurrenceResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.UpdateNotificationOccurrence 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) UpdateNotificationGroup(context.Context, *connect.Request[v1.UpdateNotificationGroupRequest]) (*connect.Response[v1.UpdateNotificationGroupResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.UpdateNotificationGroup 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) UnsubscribeNotificationGroup(context.Context, *connect.Request[v1.UnsubscribeNotificationGroupRequest]) (*connect.Response[v1.UnsubscribeNotificationGroupResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("chatto.api.v1.NotificationService.UnsubscribeNotificationGroup 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) 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")) +} + 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")) } diff --git a/cli/internal/pb/chatto/api/v1/notifications.pb.go b/cli/internal/pb/chatto/api/v1/notifications.pb.go index ba79fea5c..37d81c563 100644 --- a/cli/internal/pb/chatto/api/v1/notifications.pb.go +++ b/cli/internal/pb/chatto/api/v1/notifications.pb.go @@ -24,6 +24,262 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// 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 inbox without interruptive delivery. + NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE NotificationDeliveryIntensity = 2 + // Matching activity appears in the inbox 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} +} + +// User-controlled triage state for one notification occurrence. +type NotificationInboxState int32 + +const ( + // No inbox state was specified. + NotificationInboxState_NOTIFICATION_INBOX_STATE_UNSPECIFIED NotificationInboxState = 0 + // The occurrence is in Inbox and contributes unread attention. + NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD NotificationInboxState = 1 + // The occurrence remains in Inbox without contributing unread attention. + NotificationInboxState_NOTIFICATION_INBOX_STATE_READ NotificationInboxState = 2 + // The occurrence is removed from Inbox and retained in Done. + 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_api_v1_notifications_proto_enumTypes[2].Descriptor() +} + +func (NotificationInboxState) Type() protoreflect.EnumType { + return &file_chatto_api_v1_notifications_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_api_v1_notifications_proto_rawDescGZIP(), []int{2} +} + +// Selects one derived notification-inbox view. +type NotificationView int32 + +const ( + // Defaults to Inbox on reads and mutations. + NotificationView_NOTIFICATION_VIEW_UNSPECIFIED NotificationView = 0 + // Unread and read occurrences that have not been moved to Done. + NotificationView_NOTIFICATION_VIEW_INBOX NotificationView = 1 + // Occurrences moved out of Inbox. + NotificationView_NOTIFICATION_VIEW_DONE NotificationView = 2 + // Saved occurrences from either Inbox or Done. + NotificationView_NOTIFICATION_VIEW_SAVED NotificationView = 3 +) + +// Enum value maps for NotificationView. +var ( + NotificationView_name = map[int32]string{ + 0: "NOTIFICATION_VIEW_UNSPECIFIED", + 1: "NOTIFICATION_VIEW_INBOX", + 2: "NOTIFICATION_VIEW_DONE", + 3: "NOTIFICATION_VIEW_SAVED", + } + NotificationView_value = map[string]int32{ + "NOTIFICATION_VIEW_UNSPECIFIED": 0, + "NOTIFICATION_VIEW_INBOX": 1, + "NOTIFICATION_VIEW_DONE": 2, + "NOTIFICATION_VIEW_SAVED": 3, + } +) + +func (x NotificationView) Enum() *NotificationView { + p := new(NotificationView) + *p = x + return p +} + +func (x NotificationView) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (NotificationView) Descriptor() protoreflect.EnumDescriptor { + return file_chatto_api_v1_notifications_proto_enumTypes[3].Descriptor() +} + +func (NotificationView) Type() protoreflect.EnumType { + return &file_chatto_api_v1_notifications_proto_enumTypes[3] +} + +func (x NotificationView) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use NotificationView.Descriptor instead. +func (NotificationView) EnumDescriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{3} +} + // Direct-message notification payload. type DirectMessageNotification struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -1210,78 +1466,1799 @@ func (x *DismissAllNotificationsResponse) GetDismissedCount() int32 { return 0 } -var File_chatto_api_v1_notifications_proto protoreflect.FileDescriptor +// One cause that matched an occurrence and its evaluated delivery intensity. +type NotificationReasonMatch struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 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 +} -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" + - "\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" + - "\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" + +func (x *NotificationReasonMatch) Reset() { + *x = NotificationReasonMatch{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[22] + 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_api_v1_notifications_proto_msgTypes[22] + 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_api_v1_notifications_proto_rawDescGZIP(), []int{22} +} + +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 +} + +// Exact visible destination of one notification occurrence. +type NotificationTarget struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Room containing the source activity. + Room *RoomSummary `protobuf:"bytes,1,opt,name=room,proto3" json:"room,omitempty"` + // 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 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"` + // 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 *NotificationTarget) Reset() { + *x = NotificationTarget{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[23] + 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_api_v1_notifications_proto_msgTypes[23] + 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_api_v1_notifications_proto_rawDescGZIP(), []int{23} +} + +func (x *NotificationTarget) GetRoom() *RoomSummary { + if x != nil { + return x.Room + } + return nil +} + +func (x *NotificationTarget) GetEventId() string { + if x != nil { + return x.EventId + } + return "" +} + +func (x *NotificationTarget) GetThreadRootEventId() string { + if x != nil && x.ThreadRootEventId != nil { + return *x.ThreadRootEventId + } + return "" +} + +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"` + // 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"` + // Current user-controlled inbox state. + InboxState NotificationInboxState `protobuf:"varint,8,opt,name=inbox_state,json=inboxState,proto3,enum=chatto.api.v1.NotificationInboxState" json:"inbox_state,omitempty"` + // Whether the occurrence also appears in Saved. + Saved bool `protobuf:"varint,9,opt,name=saved,proto3" json:"saved,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 *NotificationOccurrence) Reset() { + *x = NotificationOccurrence{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[24] + 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_api_v1_notifications_proto_msgTypes[24] + 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_api_v1_notifications_proto_rawDescGZIP(), []int{24} +} + +func (x *NotificationOccurrence) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NotificationOccurrence) GetSourceEventId() string { + if x != nil { + return x.SourceEventId + } + return "" +} + +func (x *NotificationOccurrence) GetCreatedAt() *timestamppb.Timestamp { + if x != nil { + return x.CreatedAt + } + return nil +} + +func (x *NotificationOccurrence) GetActor() *User { + if x != nil { + return x.Actor + } + return nil +} + +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) GetSaved() bool { + if x != nil { + return x.Saved + } + return false +} + +func (x *NotificationOccurrence) GetExpiresAt() *timestamppb.Timestamp { + if x != nil { + return x.ExpiresAt + } + return nil +} + +// A presentation group derived from occurrences in one view. +type NotificationGroup struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Stable ID derived from the viewer and grouping target. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // 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 and view, 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"` + // True when every occurrence in this group and view is saved. + AllSaved bool `protobuf:"varint,9,opt,name=all_saved,json=allSaved,proto3" json:"all_saved,omitempty"` + // True when the group contains an active ambient subscription that can be + // disabled through UnsubscribeNotificationGroup. + CanUnsubscribe bool `protobuf:"varint,10,opt,name=can_unsubscribe,json=canUnsubscribe,proto3" json:"can_unsubscribe,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[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotificationGroup) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationGroup) ProtoMessage() {} + +func (x *NotificationGroup) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_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 NotificationGroup.ProtoReflect.Descriptor instead. +func (*NotificationGroup) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{25} +} + +func (x *NotificationGroup) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *NotificationGroup) GetOccurrences() []*NotificationOccurrence { + if x != nil { + return x.Occurrences + } + return nil +} + +func (x *NotificationGroup) GetOpenTarget() *NotificationTarget { + if x != nil { + return x.OpenTarget + } + return nil +} + +func (x *NotificationGroup) GetUnread() bool { + if x != nil { + return x.Unread + } + return false +} + +func (x *NotificationGroup) GetOccurrenceCount() int32 { + if x != nil { + return x.OccurrenceCount + } + return 0 +} + +func (x *NotificationGroup) GetLatestAt() *timestamppb.Timestamp { + if x != nil { + return x.LatestAt + } + return nil +} + +func (x *NotificationGroup) GetStrongestIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.StrongestIntensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +func (x *NotificationGroup) GetReasons() []NotificationReason { + if x != nil { + return x.Reasons + } + return nil +} + +func (x *NotificationGroup) GetAllSaved() bool { + if x != nil { + return x.AllSaved + } + return false +} + +func (x *NotificationGroup) GetCanUnsubscribe() bool { + if x != nil { + return x.CanUnsubscribe + } + return false +} + +func (x *NotificationGroup) GetNextExpiryAt() *timestamppb.Timestamp { + if x != nil { + return x.NextExpiryAt + } + return nil +} + +func (x *NotificationGroup) GetOpenNotificationId() string { + if x != nil { + return x.OpenNotificationId + } + return "" +} + +// Request for one page of grouped notification occurrences. +type ListNotificationGroupsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // View to list. Unspecified selects Inbox. + View NotificationView `protobuf:"varint,1,opt,name=view,proto3,enum=chatto.api.v1.NotificationView" json:"view,omitempty"` + // Page request. Defaults to 50 results when absent or limit is zero. + Page *PageRequest `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListNotificationGroupsRequest) Reset() { + *x = ListNotificationGroupsRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListNotificationGroupsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNotificationGroupsRequest) ProtoMessage() {} + +func (x *ListNotificationGroupsRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[26] + 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 ListNotificationGroupsRequest.ProtoReflect.Descriptor instead. +func (*ListNotificationGroupsRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{26} +} + +func (x *ListNotificationGroupsRequest) GetView() NotificationView { + if x != nil { + return x.View + } + return NotificationView_NOTIFICATION_VIEW_UNSPECIFIED +} + +func (x *ListNotificationGroupsRequest) GetPage() *PageRequest { + if x != nil { + return x.Page + } + return nil +} + +// One page of derived notification groups. +type ListNotificationGroupsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Groups in the selected view, 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 in Inbox, independent of the selected view. + UnreadGroupCount int32 `protobuf:"varint,3,opt,name=unread_group_count,json=unreadGroupCount,proto3" json:"unread_group_count,omitempty"` + // Earliest expiry in the complete Inbox, including groups outside this page. + // Clients refresh authoritative notification state at this boundary. + NextInboxExpiryAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=next_inbox_expiry_at,json=nextInboxExpiryAt,proto3" json:"next_inbox_expiry_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListNotificationGroupsResponse) Reset() { + *x = ListNotificationGroupsResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListNotificationGroupsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNotificationGroupsResponse) ProtoMessage() {} + +func (x *ListNotificationGroupsResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[27] + 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 ListNotificationGroupsResponse.ProtoReflect.Descriptor instead. +func (*ListNotificationGroupsResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{27} +} + +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 +} + +func (x *ListNotificationGroupsResponse) GetUnreadGroupCount() int32 { + if x != nil { + return x.UnreadGroupCount + } + return 0 +} + +func (x *ListNotificationGroupsResponse) GetNextInboxExpiryAt() *timestamppb.Timestamp { + if x != nil { + return x.NextInboxExpiryAt + } + return nil +} + +// Request one page of exact occurrences belonging to a derived group. +type ListNotificationOccurrencesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required stable group ID from the selected view. + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + // View containing the group. Unspecified selects Inbox. + View NotificationView `protobuf:"varint,2,opt,name=view,proto3,enum=chatto.api.v1.NotificationView" json:"view,omitempty"` + // 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 *ListNotificationOccurrencesRequest) Reset() { + *x = ListNotificationOccurrencesRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListNotificationOccurrencesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNotificationOccurrencesRequest) ProtoMessage() {} + +func (x *ListNotificationOccurrencesRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[28] + 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 ListNotificationOccurrencesRequest.ProtoReflect.Descriptor instead. +func (*ListNotificationOccurrencesRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{28} +} + +func (x *ListNotificationOccurrencesRequest) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *ListNotificationOccurrencesRequest) GetView() NotificationView { + if x != nil { + return x.View + } + return NotificationView_NOTIFICATION_VIEW_UNSPECIFIED +} + +func (x *ListNotificationOccurrencesRequest) GetPage() *PageRequest { + if x != nil { + return x.Page + } + return nil +} + +// One bounded page of exact notification occurrences. +type ListNotificationOccurrencesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Occurrences in newest-first order. + Notifications []*NotificationOccurrence `protobuf:"bytes,1,rep,name=notifications,proto3" json:"notifications,omitempty"` + // Page metadata for all occurrences in the group and selected view. + Page *PageInfo `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListNotificationOccurrencesResponse) Reset() { + *x = ListNotificationOccurrencesResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListNotificationOccurrencesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListNotificationOccurrencesResponse) ProtoMessage() {} + +func (x *ListNotificationOccurrencesResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[29] + 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 ListNotificationOccurrencesResponse.ProtoReflect.Descriptor instead. +func (*ListNotificationOccurrencesResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{29} +} + +func (x *ListNotificationOccurrencesResponse) GetNotifications() []*NotificationOccurrence { + if x != nil { + return x.Notifications + } + return nil +} + +func (x *ListNotificationOccurrencesResponse) GetPage() *PageInfo { + if x != nil { + return x.Page + } + return nil +} + +// Request one notification occurrence owned by the authenticated viewer. +type GetNotificationOccurrenceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 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 *GetNotificationOccurrenceRequest) Reset() { + *x = GetNotificationOccurrenceRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetNotificationOccurrenceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetNotificationOccurrenceRequest) ProtoMessage() {} + +func (x *GetNotificationOccurrenceRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[30] + 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 GetNotificationOccurrenceRequest.ProtoReflect.Descriptor instead. +func (*GetNotificationOccurrenceRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{30} +} + +func (x *GetNotificationOccurrenceRequest) GetNotificationId() string { + if x != nil { + return x.NotificationId + } + return "" +} + +// One visible notification occurrence. +type GetNotificationOccurrenceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Requested occurrence. + Notification *NotificationOccurrence `protobuf:"bytes,1,opt,name=notification,proto3" json:"notification,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetNotificationOccurrenceResponse) Reset() { + *x = GetNotificationOccurrenceResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetNotificationOccurrenceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetNotificationOccurrenceResponse) ProtoMessage() {} + +func (x *GetNotificationOccurrenceResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[31] + 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 GetNotificationOccurrenceResponse.ProtoReflect.Descriptor instead. +func (*GetNotificationOccurrenceResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{31} +} + +func (x *GetNotificationOccurrenceResponse) GetNotification() *NotificationOccurrence { + if x != nil { + return x.Notification + } + return nil +} + +// Patch one notification occurrence's triage state. +type UpdateNotificationOccurrenceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required stable occurrence ID. + NotificationId string `protobuf:"bytes,1,opt,name=notification_id,json=notificationId,proto3" json:"notification_id,omitempty"` + // New inbox state. Omit to leave unchanged. + InboxState *NotificationInboxState `protobuf:"varint,2,opt,name=inbox_state,json=inboxState,proto3,enum=chatto.api.v1.NotificationInboxState,oneof" json:"inbox_state,omitempty"` + // New Saved value. Omit to leave unchanged. + Saved *bool `protobuf:"varint,3,opt,name=saved,proto3,oneof" json:"saved,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateNotificationOccurrenceRequest) Reset() { + *x = UpdateNotificationOccurrenceRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateNotificationOccurrenceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateNotificationOccurrenceRequest) ProtoMessage() {} + +func (x *UpdateNotificationOccurrenceRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[32] + 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 UpdateNotificationOccurrenceRequest.ProtoReflect.Descriptor instead. +func (*UpdateNotificationOccurrenceRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{32} +} + +func (x *UpdateNotificationOccurrenceRequest) GetNotificationId() string { + if x != nil { + return x.NotificationId + } + return "" +} + +func (x *UpdateNotificationOccurrenceRequest) GetInboxState() NotificationInboxState { + if x != nil && x.InboxState != nil { + return *x.InboxState + } + return NotificationInboxState_NOTIFICATION_INBOX_STATE_UNSPECIFIED +} + +func (x *UpdateNotificationOccurrenceRequest) GetSaved() bool { + if x != nil && x.Saved != nil { + return *x.Saved + } + return false +} + +// Updated notification occurrence. +type UpdateNotificationOccurrenceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Occurrence after applying the patch. + Notification *NotificationOccurrence `protobuf:"bytes,1,opt,name=notification,proto3" json:"notification,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateNotificationOccurrenceResponse) Reset() { + *x = UpdateNotificationOccurrenceResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateNotificationOccurrenceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateNotificationOccurrenceResponse) ProtoMessage() {} + +func (x *UpdateNotificationOccurrenceResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[33] + 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 UpdateNotificationOccurrenceResponse.ProtoReflect.Descriptor instead. +func (*UpdateNotificationOccurrenceResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{33} +} + +func (x *UpdateNotificationOccurrenceResponse) GetNotification() *NotificationOccurrence { + if x != nil { + return x.Notification + } + return nil +} + +// Request permanent deletion of one notification occurrence. +type DeleteNotificationOccurrenceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 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 *DeleteNotificationOccurrenceRequest) Reset() { + *x = DeleteNotificationOccurrenceRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteNotificationOccurrenceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteNotificationOccurrenceRequest) ProtoMessage() {} + +func (x *DeleteNotificationOccurrenceRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[34] + 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 DeleteNotificationOccurrenceRequest.ProtoReflect.Descriptor instead. +func (*DeleteNotificationOccurrenceRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{34} +} + +func (x *DeleteNotificationOccurrenceRequest) GetNotificationId() string { + if x != nil { + return x.NotificationId + } + return "" +} + +// Result of deleting one notification occurrence. +type DeleteNotificationOccurrenceResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // True when a visible occurrence was replaced by a deletion tombstone. + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteNotificationOccurrenceResponse) Reset() { + *x = DeleteNotificationOccurrenceResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteNotificationOccurrenceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteNotificationOccurrenceResponse) ProtoMessage() {} + +func (x *DeleteNotificationOccurrenceResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[35] + 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 DeleteNotificationOccurrenceResponse.ProtoReflect.Descriptor instead. +func (*DeleteNotificationOccurrenceResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{35} +} + +func (x *DeleteNotificationOccurrenceResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +// Patch all current members of one derived notification group. +type UpdateNotificationGroupRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required stable group ID from the selected view. + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + // View whose current group members are updated. Unspecified selects Inbox. + View NotificationView `protobuf:"varint,2,opt,name=view,proto3,enum=chatto.api.v1.NotificationView" json:"view,omitempty"` + // New inbox state. Omit to leave unchanged. + InboxState *NotificationInboxState `protobuf:"varint,3,opt,name=inbox_state,json=inboxState,proto3,enum=chatto.api.v1.NotificationInboxState,oneof" json:"inbox_state,omitempty"` + // New Saved value. Omit to leave unchanged. + Saved *bool `protobuf:"varint,4,opt,name=saved,proto3,oneof" json:"saved,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateNotificationGroupRequest) Reset() { + *x = UpdateNotificationGroupRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateNotificationGroupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateNotificationGroupRequest) ProtoMessage() {} + +func (x *UpdateNotificationGroupRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[36] + 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 UpdateNotificationGroupRequest.ProtoReflect.Descriptor instead. +func (*UpdateNotificationGroupRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{36} +} + +func (x *UpdateNotificationGroupRequest) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *UpdateNotificationGroupRequest) GetView() NotificationView { + if x != nil { + return x.View + } + return NotificationView_NOTIFICATION_VIEW_UNSPECIFIED +} + +func (x *UpdateNotificationGroupRequest) GetInboxState() NotificationInboxState { + if x != nil && x.InboxState != nil { + return *x.InboxState + } + return NotificationInboxState_NOTIFICATION_INBOX_STATE_UNSPECIFIED +} + +func (x *UpdateNotificationGroupRequest) GetSaved() bool { + if x != nil && x.Saved != nil { + return *x.Saved + } + return false +} + +// Bounded acknowledgement for a group patch. +type UpdateNotificationGroupResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Number of occurrences updated at the mutation boundary. + UpdatedCount int32 `protobuf:"varint,1,opt,name=updated_count,json=updatedCount,proto3" json:"updated_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateNotificationGroupResponse) Reset() { + *x = UpdateNotificationGroupResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateNotificationGroupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateNotificationGroupResponse) ProtoMessage() {} + +func (x *UpdateNotificationGroupResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_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 UpdateNotificationGroupResponse.ProtoReflect.Descriptor instead. +func (*UpdateNotificationGroupResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{37} +} + +func (x *UpdateNotificationGroupResponse) GetUpdatedCount() int32 { + if x != nil { + return x.UpdatedCount + } + return 0 +} + +// Request permanent deletion of one derived notification group. +type DeleteNotificationGroupRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required stable group ID from the selected view. + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + // View whose current group members are deleted. Unspecified selects Inbox. + View NotificationView `protobuf:"varint,2,opt,name=view,proto3,enum=chatto.api.v1.NotificationView" json:"view,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteNotificationGroupRequest) Reset() { + *x = DeleteNotificationGroupRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteNotificationGroupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteNotificationGroupRequest) ProtoMessage() {} + +func (x *DeleteNotificationGroupRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_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 DeleteNotificationGroupRequest.ProtoReflect.Descriptor instead. +func (*DeleteNotificationGroupRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{38} +} + +func (x *DeleteNotificationGroupRequest) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *DeleteNotificationGroupRequest) GetView() NotificationView { + if x != nil { + return x.View + } + return NotificationView_NOTIFICATION_VIEW_UNSPECIFIED +} + +// Result of deleting one derived notification group. +type DeleteNotificationGroupResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 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 *DeleteNotificationGroupResponse) Reset() { + *x = DeleteNotificationGroupResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteNotificationGroupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteNotificationGroupResponse) ProtoMessage() {} + +func (x *DeleteNotificationGroupResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[39] + 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 DeleteNotificationGroupResponse.ProtoReflect.Descriptor instead. +func (*DeleteNotificationGroupResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{39} +} + +func (x *DeleteNotificationGroupResponse) GetDeletedCount() int32 { + if x != nil { + return x.DeletedCount + } + return 0 +} + +// Request to disable a group's ambient source and move it to Done. +type UnsubscribeNotificationGroupRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Required stable group ID from the selected view. + GroupId string `protobuf:"bytes,1,opt,name=group_id,json=groupId,proto3" json:"group_id,omitempty"` + // View containing the group. Unspecified selects Inbox. + View NotificationView `protobuf:"varint,2,opt,name=view,proto3,enum=chatto.api.v1.NotificationView" json:"view,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnsubscribeNotificationGroupRequest) Reset() { + *x = UnsubscribeNotificationGroupRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnsubscribeNotificationGroupRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnsubscribeNotificationGroupRequest) ProtoMessage() {} + +func (x *UnsubscribeNotificationGroupRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[40] + 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 UnsubscribeNotificationGroupRequest.ProtoReflect.Descriptor instead. +func (*UnsubscribeNotificationGroupRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{40} +} + +func (x *UnsubscribeNotificationGroupRequest) GetGroupId() string { + if x != nil { + return x.GroupId + } + return "" +} + +func (x *UnsubscribeNotificationGroupRequest) GetView() NotificationView { + if x != nil { + return x.View + } + return NotificationView_NOTIFICATION_VIEW_UNSPECIFIED +} + +// Result of disabling the group's ambient source and moving its current +// occurrences to Done. Direct mentions and replies remain independently +// eligible under their own policy. +type UnsubscribeNotificationGroupResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Number of occurrences moved to Done by the unsubscribe action. + UpdatedCount int32 `protobuf:"varint,1,opt,name=updated_count,json=updatedCount,proto3" json:"updated_count,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnsubscribeNotificationGroupResponse) Reset() { + *x = UnsubscribeNotificationGroupResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnsubscribeNotificationGroupResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnsubscribeNotificationGroupResponse) ProtoMessage() {} + +func (x *UnsubscribeNotificationGroupResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[41] + 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 UnsubscribeNotificationGroupResponse.ProtoReflect.Descriptor instead. +func (*UnsubscribeNotificationGroupResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{41} +} + +func (x *UnsubscribeNotificationGroupResponse) GetUpdatedCount() int32 { + if x != nil { + return x.UpdatedCount + } + return 0 +} + +// Explicit and effective delivery policy for one notification cause. +type NotificationPolicyPreference struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 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[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotificationPolicyPreference) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationPolicyPreference) ProtoMessage() {} + +func (x *NotificationPolicyPreference) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[42] + 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 NotificationPolicyPreference.ProtoReflect.Descriptor instead. +func (*NotificationPolicyPreference) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{42} +} + +func (x *NotificationPolicyPreference) GetReason() NotificationReason { + if x != nil { + return x.Reason + } + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED +} + +func (x *NotificationPolicyPreference) GetServerIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.ServerIntensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +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 *GetNotificationPolicyRequest) Reset() { + *x = GetNotificationPolicyRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetNotificationPolicyRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetNotificationPolicyRequest) ProtoMessage() {} + +func (x *GetNotificationPolicyRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[43] + 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 GetNotificationPolicyRequest.ProtoReflect.Descriptor instead. +func (*GetNotificationPolicyRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{43} +} + +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"` + // 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 *GetNotificationPolicyResponse) Reset() { + *x = GetNotificationPolicyResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetNotificationPolicyResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetNotificationPolicyResponse) ProtoMessage() {} + +func (x *GetNotificationPolicyResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[44] + 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 GetNotificationPolicyResponse.ProtoReflect.Descriptor instead. +func (*GetNotificationPolicyResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{44} +} + +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.Preferences + } + return nil +} + +// Set or clear one notification preference override. +type SetNotificationPolicyPreferenceRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // 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 *SetNotificationPolicyPreferenceRequest) Reset() { + *x = SetNotificationPolicyPreferenceRequest{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetNotificationPolicyPreferenceRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetNotificationPolicyPreferenceRequest) ProtoMessage() {} + +func (x *SetNotificationPolicyPreferenceRequest) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[45] + 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 SetNotificationPolicyPreferenceRequest.ProtoReflect.Descriptor instead. +func (*SetNotificationPolicyPreferenceRequest) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{45} +} + +func (x *SetNotificationPolicyPreferenceRequest) GetRoomId() string { + if x != nil && x.RoomId != nil { + return *x.RoomId + } + return "" +} + +func (x *SetNotificationPolicyPreferenceRequest) GetReason() NotificationReason { + if x != nil { + return x.Reason + } + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED +} + +func (x *SetNotificationPolicyPreferenceRequest) GetIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.Intensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +// 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 *SetNotificationPolicyPreferenceResponse) Reset() { + *x = SetNotificationPolicyPreferenceResponse{} + mi := &file_chatto_api_v1_notifications_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetNotificationPolicyPreferenceResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetNotificationPolicyPreferenceResponse) ProtoMessage() {} + +func (x *SetNotificationPolicyPreferenceResponse) ProtoReflect() protoreflect.Message { + mi := &file_chatto_api_v1_notifications_proto_msgTypes[46] + 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 SetNotificationPolicyPreferenceResponse.ProtoReflect.Descriptor instead. +func (*SetNotificationPolicyPreferenceResponse) Descriptor() ([]byte, []int) { + return file_chatto_api_v1_notifications_proto_rawDescGZIP(), []int{46} +} + +func (x *SetNotificationPolicyPreferenceResponse) GetRoomId() string { + if x != nil && x.RoomId != nil { + return *x.RoomId + } + return "" +} + +func (x *SetNotificationPolicyPreferenceResponse) GetPreferences() []*NotificationPolicyPreference { + if x != nil { + return x.Preferences + } + 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" + + "\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" + + "\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\x0edismissedCount\"\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\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\"\xab\x04\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\x12F\n" + + "\vinbox_state\x18\b \x01(\x0e2%.chatto.api.v1.NotificationInboxStateR\n" + + "inboxState\x12\x14\n" + + "\x05saved\x18\t \x01(\bR\x05saved\x129\n" + + "\n" + + "expires_at\x18\n" + + " \x01(\v2\x1a.google.protobuf.TimestampR\texpiresAt\"\x82\x05\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\x1b\n" + + "\tall_saved\x18\t \x01(\bR\ballSaved\x12'\n" + + "\x0fcan_unsubscribe\x18\n" + + " \x01(\bR\x0ecanUnsubscribe\x12@\n" + + "\x0enext_expiry_at\x18\v \x01(\v2\x1a.google.protobuf.TimestampR\fnextExpiryAt\x120\n" + + "\x14open_notification_id\x18\f \x01(\tR\x12openNotificationId\"\x8e\x01\n" + + "\x1dListNotificationGroupsRequest\x12=\n" + + "\x04view\x18\x01 \x01(\x0e2\x1f.chatto.api.v1.NotificationViewB\b\xbaH\x05\x82\x01\x02\x10\x01R\x04view\x12.\n" + + "\x04page\x18\x02 \x01(\v2\x1a.chatto.api.v1.PageRequestR\x04page\"\x82\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\x12K\n" + + "\x14next_inbox_expiry_at\x18\x04 \x01(\v2\x1a.google.protobuf.TimestampR\x11nextInboxExpiryAt\"\xb7\x01\n" + + "\"ListNotificationOccurrencesRequest\x12\"\n" + + "\bgroup_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\agroupId\x12=\n" + + "\x04view\x18\x02 \x01(\x0e2\x1f.chatto.api.v1.NotificationViewB\b\xbaH\x05\x82\x01\x02\x10\x01R\x04view\x12.\n" + + "\x04page\x18\x03 \x01(\v2\x1a.chatto.api.v1.PageRequestR\x04page\"\x9f\x01\n" + + "#ListNotificationOccurrencesResponse\x12K\n" + + "\rnotifications\x18\x01 \x03(\v2%.chatto.api.v1.NotificationOccurrenceR\rnotifications\x12+\n" + + "\x04page\x18\x02 \x01(\v2\x17.chatto.api.v1.PageInfoR\x04page\"T\n" + + " GetNotificationOccurrenceRequest\x120\n" + + "\x0fnotification_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0enotificationId\"n\n" + + "!GetNotificationOccurrenceResponse\x12I\n" + + "\fnotification\x18\x01 \x01(\v2%.chatto.api.v1.NotificationOccurrenceR\fnotification\"\xe5\x01\n" + + "#UpdateNotificationOccurrenceRequest\x120\n" + + "\x0fnotification_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\x0enotificationId\x12W\n" + + "\vinbox_state\x18\x02 \x01(\x0e2%.chatto.api.v1.NotificationInboxStateB\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00H\x00R\n" + + "inboxState\x88\x01\x01\x12\x19\n" + + "\x05saved\x18\x03 \x01(\bH\x01R\x05saved\x88\x01\x01B\x0e\n" + + "\f_inbox_stateB\b\n" + + "\x06_saved\"q\n" + + "$UpdateNotificationOccurrenceResponse\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\"\x91\x02\n" + + "\x1eUpdateNotificationGroupRequest\x12\"\n" + + "\bgroup_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\agroupId\x12=\n" + + "\x04view\x18\x02 \x01(\x0e2\x1f.chatto.api.v1.NotificationViewB\b\xbaH\x05\x82\x01\x02\x10\x01R\x04view\x12W\n" + + "\vinbox_state\x18\x03 \x01(\x0e2%.chatto.api.v1.NotificationInboxStateB\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00H\x00R\n" + + "inboxState\x88\x01\x01\x12\x19\n" + + "\x05saved\x18\x04 \x01(\bH\x01R\x05saved\x88\x01\x01B\x0e\n" + + "\f_inbox_stateB\b\n" + + "\x06_saved\"F\n" + + "\x1fUpdateNotificationGroupResponse\x12#\n" + + "\rupdated_count\x18\x01 \x01(\x05R\fupdatedCount\"\x83\x01\n" + + "\x1eDeleteNotificationGroupRequest\x12\"\n" + + "\bgroup_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\agroupId\x12=\n" + + "\x04view\x18\x02 \x01(\x0e2\x1f.chatto.api.v1.NotificationViewB\b\xbaH\x05\x82\x01\x02\x10\x01R\x04view\"F\n" + + "\x1fDeleteNotificationGroupResponse\x12#\n" + + "\rdeleted_count\x18\x01 \x01(\x05R\fdeletedCount\"\x88\x01\n" + + "#UnsubscribeNotificationGroupRequest\x12\"\n" + + "\bgroup_id\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\agroupId\x12=\n" + + "\x04view\x18\x02 \x01(\x0e2\x1f.chatto.api.v1.NotificationViewB\b\xbaH\x05\x82\x01\x02\x10\x01R\x04view\"K\n" + + "$UnsubscribeNotificationGroupResponse\x12#\n" + + "\rupdated_count\x18\x01 \x01(\x05R\fupdatedCount\"\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\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*\x8b\x01\n" + + "\x10NotificationView\x12!\n" + + "\x1dNOTIFICATION_VIEW_UNSPECIFIED\x10\x00\x12\x1b\n" + + "\x17NOTIFICATION_VIEW_INBOX\x10\x01\x12\x1a\n" + + "\x16NOTIFICATION_VIEW_DONE\x10\x02\x12\x1b\n" + + "\x17NOTIFICATION_VIEW_SAVED\x10\x032\xc3\x11\n" + + "\x13NotificationService\x12u\n" + + "\x16ListNotificationGroups\x12,.chatto.api.v1.ListNotificationGroupsRequest\x1a-.chatto.api.v1.ListNotificationGroupsResponse\x12\x84\x01\n" + + "\x1bListNotificationOccurrences\x121.chatto.api.v1.ListNotificationOccurrencesRequest\x1a2.chatto.api.v1.ListNotificationOccurrencesResponse\x12~\n" + + "\x19GetNotificationOccurrence\x12/.chatto.api.v1.GetNotificationOccurrenceRequest\x1a0.chatto.api.v1.GetNotificationOccurrenceResponse\x12\x8c\x01\n" + + "\x1cUpdateNotificationOccurrence\x122.chatto.api.v1.UpdateNotificationOccurrenceRequest\x1a3.chatto.api.v1.UpdateNotificationOccurrenceResponse\"\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" + + "\x17UpdateNotificationGroup\x12-.chatto.api.v1.UpdateNotificationGroupRequest\x1a..chatto.api.v1.UpdateNotificationGroupResponse\x12x\n" + + "\x17DeleteNotificationGroup\x12-.chatto.api.v1.DeleteNotificationGroupRequest\x1a..chatto.api.v1.DeleteNotificationGroupResponse\x12\x87\x01\n" + + "\x1cUnsubscribeNotificationGroup\x122.chatto.api.v1.UnsubscribeNotificationGroupRequest\x1a3.chatto.api.v1.UnsubscribeNotificationGroupResponse\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\x02\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" + @@ -1304,77 +3281,167 @@ 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, 4) +var file_chatto_api_v1_notifications_proto_msgTypes = make([]protoimpl.MessageInfo, 47) 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 + (NotificationInboxState)(0), // 2: chatto.api.v1.NotificationInboxState + (NotificationView)(0), // 3: chatto.api.v1.NotificationView + (*DirectMessageNotification)(nil), // 4: chatto.api.v1.DirectMessageNotification + (*MentionNotification)(nil), // 5: chatto.api.v1.MentionNotification + (*ReplyNotification)(nil), // 6: chatto.api.v1.ReplyNotification + (*RoomMessageNotification)(nil), // 7: chatto.api.v1.RoomMessageNotification + (*NotificationItem)(nil), // 8: chatto.api.v1.NotificationItem + (*ListNotificationsRequest)(nil), // 9: chatto.api.v1.ListNotificationsRequest + (*ListRoomNotificationsRequest)(nil), // 10: chatto.api.v1.ListRoomNotificationsRequest + (*ListNotificationsResponse)(nil), // 11: chatto.api.v1.ListNotificationsResponse + (*GetNotificationRequest)(nil), // 12: chatto.api.v1.GetNotificationRequest + (*GetNotificationResponse)(nil), // 13: chatto.api.v1.GetNotificationResponse + (*BatchGetNotificationsRequest)(nil), // 14: chatto.api.v1.BatchGetNotificationsRequest + (*BatchGetNotificationsResponse)(nil), // 15: chatto.api.v1.BatchGetNotificationsResponse + (*ListRoomNotificationsResponse)(nil), // 16: chatto.api.v1.ListRoomNotificationsResponse + (*HasNotificationsRequest)(nil), // 17: chatto.api.v1.HasNotificationsRequest + (*HasNotificationsResponse)(nil), // 18: chatto.api.v1.HasNotificationsResponse + (*RoomNotificationCount)(nil), // 19: chatto.api.v1.RoomNotificationCount + (*ListRoomNotificationCountsRequest)(nil), // 20: chatto.api.v1.ListRoomNotificationCountsRequest + (*ListRoomNotificationCountsResponse)(nil), // 21: chatto.api.v1.ListRoomNotificationCountsResponse + (*DismissNotificationRequest)(nil), // 22: chatto.api.v1.DismissNotificationRequest + (*DismissNotificationResponse)(nil), // 23: chatto.api.v1.DismissNotificationResponse + (*DismissAllNotificationsRequest)(nil), // 24: chatto.api.v1.DismissAllNotificationsRequest + (*DismissAllNotificationsResponse)(nil), // 25: chatto.api.v1.DismissAllNotificationsResponse + (*NotificationReasonMatch)(nil), // 26: chatto.api.v1.NotificationReasonMatch + (*NotificationTarget)(nil), // 27: chatto.api.v1.NotificationTarget + (*NotificationOccurrence)(nil), // 28: chatto.api.v1.NotificationOccurrence + (*NotificationGroup)(nil), // 29: chatto.api.v1.NotificationGroup + (*ListNotificationGroupsRequest)(nil), // 30: chatto.api.v1.ListNotificationGroupsRequest + (*ListNotificationGroupsResponse)(nil), // 31: chatto.api.v1.ListNotificationGroupsResponse + (*ListNotificationOccurrencesRequest)(nil), // 32: chatto.api.v1.ListNotificationOccurrencesRequest + (*ListNotificationOccurrencesResponse)(nil), // 33: chatto.api.v1.ListNotificationOccurrencesResponse + (*GetNotificationOccurrenceRequest)(nil), // 34: chatto.api.v1.GetNotificationOccurrenceRequest + (*GetNotificationOccurrenceResponse)(nil), // 35: chatto.api.v1.GetNotificationOccurrenceResponse + (*UpdateNotificationOccurrenceRequest)(nil), // 36: chatto.api.v1.UpdateNotificationOccurrenceRequest + (*UpdateNotificationOccurrenceResponse)(nil), // 37: chatto.api.v1.UpdateNotificationOccurrenceResponse + (*DeleteNotificationOccurrenceRequest)(nil), // 38: chatto.api.v1.DeleteNotificationOccurrenceRequest + (*DeleteNotificationOccurrenceResponse)(nil), // 39: chatto.api.v1.DeleteNotificationOccurrenceResponse + (*UpdateNotificationGroupRequest)(nil), // 40: chatto.api.v1.UpdateNotificationGroupRequest + (*UpdateNotificationGroupResponse)(nil), // 41: chatto.api.v1.UpdateNotificationGroupResponse + (*DeleteNotificationGroupRequest)(nil), // 42: chatto.api.v1.DeleteNotificationGroupRequest + (*DeleteNotificationGroupResponse)(nil), // 43: chatto.api.v1.DeleteNotificationGroupResponse + (*UnsubscribeNotificationGroupRequest)(nil), // 44: chatto.api.v1.UnsubscribeNotificationGroupRequest + (*UnsubscribeNotificationGroupResponse)(nil), // 45: chatto.api.v1.UnsubscribeNotificationGroupResponse + (*NotificationPolicyPreference)(nil), // 46: chatto.api.v1.NotificationPolicyPreference + (*GetNotificationPolicyRequest)(nil), // 47: chatto.api.v1.GetNotificationPolicyRequest + (*GetNotificationPolicyResponse)(nil), // 48: chatto.api.v1.GetNotificationPolicyResponse + (*SetNotificationPolicyPreferenceRequest)(nil), // 49: chatto.api.v1.SetNotificationPolicyPreferenceRequest + (*SetNotificationPolicyPreferenceResponse)(nil), // 50: chatto.api.v1.SetNotificationPolicyPreferenceResponse + (*RoomSummary)(nil), // 51: chatto.api.v1.RoomSummary + (*timestamppb.Timestamp)(nil), // 52: google.protobuf.Timestamp + (*User)(nil), // 53: chatto.api.v1.User + (*PageRequest)(nil), // 54: chatto.api.v1.PageRequest + (*PageInfo)(nil), // 55: 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 + 51, // 0: chatto.api.v1.DirectMessageNotification.room:type_name -> chatto.api.v1.RoomSummary + 51, // 1: chatto.api.v1.MentionNotification.room:type_name -> chatto.api.v1.RoomSummary + 51, // 2: chatto.api.v1.ReplyNotification.room:type_name -> chatto.api.v1.RoomSummary + 51, // 3: chatto.api.v1.RoomMessageNotification.room:type_name -> chatto.api.v1.RoomSummary + 52, // 4: chatto.api.v1.NotificationItem.created_at:type_name -> google.protobuf.Timestamp + 53, // 5: chatto.api.v1.NotificationItem.actor:type_name -> chatto.api.v1.User + 4, // 6: chatto.api.v1.NotificationItem.direct_message:type_name -> chatto.api.v1.DirectMessageNotification + 5, // 7: chatto.api.v1.NotificationItem.mention:type_name -> chatto.api.v1.MentionNotification + 6, // 8: chatto.api.v1.NotificationItem.reply:type_name -> chatto.api.v1.ReplyNotification + 7, // 9: chatto.api.v1.NotificationItem.room_message:type_name -> chatto.api.v1.RoomMessageNotification + 54, // 10: chatto.api.v1.ListNotificationsRequest.page:type_name -> chatto.api.v1.PageRequest + 54, // 11: chatto.api.v1.ListRoomNotificationsRequest.page:type_name -> chatto.api.v1.PageRequest + 8, // 12: chatto.api.v1.ListNotificationsResponse.notifications:type_name -> chatto.api.v1.NotificationItem + 55, // 13: chatto.api.v1.ListNotificationsResponse.page:type_name -> chatto.api.v1.PageInfo + 8, // 14: chatto.api.v1.GetNotificationResponse.notification:type_name -> chatto.api.v1.NotificationItem + 8, // 15: chatto.api.v1.BatchGetNotificationsResponse.notifications:type_name -> chatto.api.v1.NotificationItem + 8, // 16: chatto.api.v1.ListRoomNotificationsResponse.notifications:type_name -> chatto.api.v1.NotificationItem + 55, // 17: chatto.api.v1.ListRoomNotificationsResponse.page:type_name -> chatto.api.v1.PageInfo + 19, // 18: chatto.api.v1.ListRoomNotificationCountsResponse.room_counts:type_name -> chatto.api.v1.RoomNotificationCount + 0, // 19: chatto.api.v1.NotificationReasonMatch.reason:type_name -> chatto.api.v1.NotificationReason + 1, // 20: chatto.api.v1.NotificationReasonMatch.intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 51, // 21: chatto.api.v1.NotificationTarget.room:type_name -> chatto.api.v1.RoomSummary + 52, // 22: chatto.api.v1.NotificationOccurrence.created_at:type_name -> google.protobuf.Timestamp + 53, // 23: chatto.api.v1.NotificationOccurrence.actor:type_name -> chatto.api.v1.User + 27, // 24: chatto.api.v1.NotificationOccurrence.target:type_name -> chatto.api.v1.NotificationTarget + 26, // 25: chatto.api.v1.NotificationOccurrence.reasons:type_name -> chatto.api.v1.NotificationReasonMatch + 1, // 26: chatto.api.v1.NotificationOccurrence.strongest_intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 2, // 27: chatto.api.v1.NotificationOccurrence.inbox_state:type_name -> chatto.api.v1.NotificationInboxState + 52, // 28: chatto.api.v1.NotificationOccurrence.expires_at:type_name -> google.protobuf.Timestamp + 28, // 29: chatto.api.v1.NotificationGroup.occurrences:type_name -> chatto.api.v1.NotificationOccurrence + 27, // 30: chatto.api.v1.NotificationGroup.open_target:type_name -> chatto.api.v1.NotificationTarget + 52, // 31: chatto.api.v1.NotificationGroup.latest_at:type_name -> google.protobuf.Timestamp + 1, // 32: chatto.api.v1.NotificationGroup.strongest_intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 0, // 33: chatto.api.v1.NotificationGroup.reasons:type_name -> chatto.api.v1.NotificationReason + 52, // 34: chatto.api.v1.NotificationGroup.next_expiry_at:type_name -> google.protobuf.Timestamp + 3, // 35: chatto.api.v1.ListNotificationGroupsRequest.view:type_name -> chatto.api.v1.NotificationView + 54, // 36: chatto.api.v1.ListNotificationGroupsRequest.page:type_name -> chatto.api.v1.PageRequest + 29, // 37: chatto.api.v1.ListNotificationGroupsResponse.groups:type_name -> chatto.api.v1.NotificationGroup + 55, // 38: chatto.api.v1.ListNotificationGroupsResponse.page:type_name -> chatto.api.v1.PageInfo + 52, // 39: chatto.api.v1.ListNotificationGroupsResponse.next_inbox_expiry_at:type_name -> google.protobuf.Timestamp + 3, // 40: chatto.api.v1.ListNotificationOccurrencesRequest.view:type_name -> chatto.api.v1.NotificationView + 54, // 41: chatto.api.v1.ListNotificationOccurrencesRequest.page:type_name -> chatto.api.v1.PageRequest + 28, // 42: chatto.api.v1.ListNotificationOccurrencesResponse.notifications:type_name -> chatto.api.v1.NotificationOccurrence + 55, // 43: chatto.api.v1.ListNotificationOccurrencesResponse.page:type_name -> chatto.api.v1.PageInfo + 28, // 44: chatto.api.v1.GetNotificationOccurrenceResponse.notification:type_name -> chatto.api.v1.NotificationOccurrence + 2, // 45: chatto.api.v1.UpdateNotificationOccurrenceRequest.inbox_state:type_name -> chatto.api.v1.NotificationInboxState + 28, // 46: chatto.api.v1.UpdateNotificationOccurrenceResponse.notification:type_name -> chatto.api.v1.NotificationOccurrence + 3, // 47: chatto.api.v1.UpdateNotificationGroupRequest.view:type_name -> chatto.api.v1.NotificationView + 2, // 48: chatto.api.v1.UpdateNotificationGroupRequest.inbox_state:type_name -> chatto.api.v1.NotificationInboxState + 3, // 49: chatto.api.v1.DeleteNotificationGroupRequest.view:type_name -> chatto.api.v1.NotificationView + 3, // 50: chatto.api.v1.UnsubscribeNotificationGroupRequest.view:type_name -> chatto.api.v1.NotificationView + 0, // 51: chatto.api.v1.NotificationPolicyPreference.reason:type_name -> chatto.api.v1.NotificationReason + 1, // 52: chatto.api.v1.NotificationPolicyPreference.server_intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 1, // 53: chatto.api.v1.NotificationPolicyPreference.room_intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 1, // 54: chatto.api.v1.NotificationPolicyPreference.effective_intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 46, // 55: chatto.api.v1.GetNotificationPolicyResponse.preferences:type_name -> chatto.api.v1.NotificationPolicyPreference + 0, // 56: chatto.api.v1.SetNotificationPolicyPreferenceRequest.reason:type_name -> chatto.api.v1.NotificationReason + 1, // 57: chatto.api.v1.SetNotificationPolicyPreferenceRequest.intensity:type_name -> chatto.api.v1.NotificationDeliveryIntensity + 46, // 58: chatto.api.v1.SetNotificationPolicyPreferenceResponse.preferences:type_name -> chatto.api.v1.NotificationPolicyPreference + 30, // 59: chatto.api.v1.NotificationService.ListNotificationGroups:input_type -> chatto.api.v1.ListNotificationGroupsRequest + 32, // 60: chatto.api.v1.NotificationService.ListNotificationOccurrences:input_type -> chatto.api.v1.ListNotificationOccurrencesRequest + 34, // 61: chatto.api.v1.NotificationService.GetNotificationOccurrence:input_type -> chatto.api.v1.GetNotificationOccurrenceRequest + 36, // 62: chatto.api.v1.NotificationService.UpdateNotificationOccurrence:input_type -> chatto.api.v1.UpdateNotificationOccurrenceRequest + 38, // 63: chatto.api.v1.NotificationService.DeleteNotificationOccurrence:input_type -> chatto.api.v1.DeleteNotificationOccurrenceRequest + 40, // 64: chatto.api.v1.NotificationService.UpdateNotificationGroup:input_type -> chatto.api.v1.UpdateNotificationGroupRequest + 42, // 65: chatto.api.v1.NotificationService.DeleteNotificationGroup:input_type -> chatto.api.v1.DeleteNotificationGroupRequest + 44, // 66: chatto.api.v1.NotificationService.UnsubscribeNotificationGroup:input_type -> chatto.api.v1.UnsubscribeNotificationGroupRequest + 47, // 67: chatto.api.v1.NotificationService.GetNotificationPolicy:input_type -> chatto.api.v1.GetNotificationPolicyRequest + 49, // 68: chatto.api.v1.NotificationService.SetNotificationPolicyPreference:input_type -> chatto.api.v1.SetNotificationPolicyPreferenceRequest + 9, // 69: chatto.api.v1.NotificationService.ListNotifications:input_type -> chatto.api.v1.ListNotificationsRequest + 12, // 70: chatto.api.v1.NotificationService.GetNotification:input_type -> chatto.api.v1.GetNotificationRequest + 14, // 71: chatto.api.v1.NotificationService.BatchGetNotifications:input_type -> chatto.api.v1.BatchGetNotificationsRequest + 10, // 72: chatto.api.v1.NotificationService.ListRoomNotifications:input_type -> chatto.api.v1.ListRoomNotificationsRequest + 20, // 73: chatto.api.v1.NotificationService.ListRoomNotificationCounts:input_type -> chatto.api.v1.ListRoomNotificationCountsRequest + 17, // 74: chatto.api.v1.NotificationService.HasNotifications:input_type -> chatto.api.v1.HasNotificationsRequest + 22, // 75: chatto.api.v1.NotificationService.DismissNotification:input_type -> chatto.api.v1.DismissNotificationRequest + 24, // 76: chatto.api.v1.NotificationService.DismissAllNotifications:input_type -> chatto.api.v1.DismissAllNotificationsRequest + 31, // 77: chatto.api.v1.NotificationService.ListNotificationGroups:output_type -> chatto.api.v1.ListNotificationGroupsResponse + 33, // 78: chatto.api.v1.NotificationService.ListNotificationOccurrences:output_type -> chatto.api.v1.ListNotificationOccurrencesResponse + 35, // 79: chatto.api.v1.NotificationService.GetNotificationOccurrence:output_type -> chatto.api.v1.GetNotificationOccurrenceResponse + 37, // 80: chatto.api.v1.NotificationService.UpdateNotificationOccurrence:output_type -> chatto.api.v1.UpdateNotificationOccurrenceResponse + 39, // 81: chatto.api.v1.NotificationService.DeleteNotificationOccurrence:output_type -> chatto.api.v1.DeleteNotificationOccurrenceResponse + 41, // 82: chatto.api.v1.NotificationService.UpdateNotificationGroup:output_type -> chatto.api.v1.UpdateNotificationGroupResponse + 43, // 83: chatto.api.v1.NotificationService.DeleteNotificationGroup:output_type -> chatto.api.v1.DeleteNotificationGroupResponse + 45, // 84: chatto.api.v1.NotificationService.UnsubscribeNotificationGroup:output_type -> chatto.api.v1.UnsubscribeNotificationGroupResponse + 48, // 85: chatto.api.v1.NotificationService.GetNotificationPolicy:output_type -> chatto.api.v1.GetNotificationPolicyResponse + 50, // 86: chatto.api.v1.NotificationService.SetNotificationPolicyPreference:output_type -> chatto.api.v1.SetNotificationPolicyPreferenceResponse + 11, // 87: chatto.api.v1.NotificationService.ListNotifications:output_type -> chatto.api.v1.ListNotificationsResponse + 13, // 88: chatto.api.v1.NotificationService.GetNotification:output_type -> chatto.api.v1.GetNotificationResponse + 15, // 89: chatto.api.v1.NotificationService.BatchGetNotifications:output_type -> chatto.api.v1.BatchGetNotificationsResponse + 16, // 90: chatto.api.v1.NotificationService.ListRoomNotifications:output_type -> chatto.api.v1.ListRoomNotificationsResponse + 21, // 91: chatto.api.v1.NotificationService.ListRoomNotificationCounts:output_type -> chatto.api.v1.ListRoomNotificationCountsResponse + 18, // 92: chatto.api.v1.NotificationService.HasNotifications:output_type -> chatto.api.v1.HasNotificationsResponse + 23, // 93: chatto.api.v1.NotificationService.DismissNotification:output_type -> chatto.api.v1.DismissNotificationResponse + 25, // 94: chatto.api.v1.NotificationService.DismissAllNotifications:output_type -> chatto.api.v1.DismissAllNotificationsResponse + 77, // [77:95] is the sub-list for method output_type + 59, // [59:77] 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_api_v1_notifications_proto_init() } @@ -1393,18 +3460,26 @@ func file_chatto_api_v1_notifications_proto_init() { (*NotificationItem_Reply)(nil), (*NotificationItem_RoomMessage)(nil), } + file_chatto_api_v1_notifications_proto_msgTypes[23].OneofWrappers = []any{} + file_chatto_api_v1_notifications_proto_msgTypes[32].OneofWrappers = []any{} + file_chatto_api_v1_notifications_proto_msgTypes[36].OneofWrappers = []any{} + file_chatto_api_v1_notifications_proto_msgTypes[43].OneofWrappers = []any{} + file_chatto_api_v1_notifications_proto_msgTypes[44].OneofWrappers = []any{} + file_chatto_api_v1_notifications_proto_msgTypes[45].OneofWrappers = []any{} + file_chatto_api_v1_notifications_proto_msgTypes[46].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: 4, + NumMessages: 47, 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/core/v1/config_events.pb.go b/cli/internal/pb/chatto/core/v1/config_events.pb.go index 11ccfc654..1fff3938a 100644 --- a/cli/internal/pb/chatto/core/v1/config_events.pb.go +++ b/cli/internal/pb/chatto/core/v1/config_events.pb.go @@ -801,11 +801,251 @@ func (x *UserRoomNotificationLevelClearedEvent) GetRoomId() string { return "" } +type UserServerNotificationPreferenceSetEvent 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserServerNotificationPreferenceSetEvent) Reset() { + *x = UserServerNotificationPreferenceSetEvent{} + mi := &file_chatto_core_v1_config_events_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserServerNotificationPreferenceSetEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserServerNotificationPreferenceSetEvent) ProtoMessage() {} + +func (x *UserServerNotificationPreferenceSetEvent) 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 UserServerNotificationPreferenceSetEvent.ProtoReflect.Descriptor instead. +func (*UserServerNotificationPreferenceSetEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_config_events_proto_rawDescGZIP(), []int{17} +} + +func (x *UserServerNotificationPreferenceSetEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *UserServerNotificationPreferenceSetEvent) GetReason() NotificationReason { + if x != nil { + return x.Reason + } + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED +} + +func (x *UserServerNotificationPreferenceSetEvent) GetIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.Intensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +type UserServerNotificationPreferenceClearedEvent 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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserServerNotificationPreferenceClearedEvent) Reset() { + *x = UserServerNotificationPreferenceClearedEvent{} + mi := &file_chatto_core_v1_config_events_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserServerNotificationPreferenceClearedEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserServerNotificationPreferenceClearedEvent) ProtoMessage() {} + +func (x *UserServerNotificationPreferenceClearedEvent) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_config_events_proto_msgTypes[18] + 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 UserServerNotificationPreferenceClearedEvent.ProtoReflect.Descriptor instead. +func (*UserServerNotificationPreferenceClearedEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_config_events_proto_rawDescGZIP(), []int{18} +} + +func (x *UserServerNotificationPreferenceClearedEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *UserServerNotificationPreferenceClearedEvent) GetReason() NotificationReason { + if x != nil { + return x.Reason + } + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED +} + +type UserRoomNotificationPreferenceSetEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + RoomId string `protobuf:"bytes,2,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + Reason NotificationReason `protobuf:"varint,3,opt,name=reason,proto3,enum=chatto.core.v1.NotificationReason" json:"reason,omitempty"` + Intensity NotificationDeliveryIntensity `protobuf:"varint,4,opt,name=intensity,proto3,enum=chatto.core.v1.NotificationDeliveryIntensity" json:"intensity,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserRoomNotificationPreferenceSetEvent) Reset() { + *x = UserRoomNotificationPreferenceSetEvent{} + mi := &file_chatto_core_v1_config_events_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserRoomNotificationPreferenceSetEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserRoomNotificationPreferenceSetEvent) ProtoMessage() {} + +func (x *UserRoomNotificationPreferenceSetEvent) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_config_events_proto_msgTypes[19] + 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 UserRoomNotificationPreferenceSetEvent.ProtoReflect.Descriptor instead. +func (*UserRoomNotificationPreferenceSetEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_config_events_proto_rawDescGZIP(), []int{19} +} + +func (x *UserRoomNotificationPreferenceSetEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *UserRoomNotificationPreferenceSetEvent) GetRoomId() string { + if x != nil { + return x.RoomId + } + return "" +} + +func (x *UserRoomNotificationPreferenceSetEvent) GetReason() NotificationReason { + if x != nil { + return x.Reason + } + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED +} + +func (x *UserRoomNotificationPreferenceSetEvent) GetIntensity() NotificationDeliveryIntensity { + if x != nil { + return x.Intensity + } + return NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED +} + +type UserRoomNotificationPreferenceClearedEvent struct { + state protoimpl.MessageState `protogen:"open.v1"` + UserId string `protobuf:"bytes,1,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + RoomId string `protobuf:"bytes,2,opt,name=room_id,json=roomId,proto3" json:"room_id,omitempty"` + Reason NotificationReason `protobuf:"varint,3,opt,name=reason,proto3,enum=chatto.core.v1.NotificationReason" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserRoomNotificationPreferenceClearedEvent) Reset() { + *x = UserRoomNotificationPreferenceClearedEvent{} + mi := &file_chatto_core_v1_config_events_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserRoomNotificationPreferenceClearedEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserRoomNotificationPreferenceClearedEvent) ProtoMessage() {} + +func (x *UserRoomNotificationPreferenceClearedEvent) ProtoReflect() protoreflect.Message { + mi := &file_chatto_core_v1_config_events_proto_msgTypes[20] + 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 UserRoomNotificationPreferenceClearedEvent.ProtoReflect.Descriptor instead. +func (*UserRoomNotificationPreferenceClearedEvent) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_config_events_proto_rawDescGZIP(), []int{20} +} + +func (x *UserRoomNotificationPreferenceClearedEvent) GetUserId() string { + if x != nil { + return x.UserId + } + return "" +} + +func (x *UserRoomNotificationPreferenceClearedEvent) GetRoomId() string { + if x != nil { + return x.RoomId + } + return "" +} + +func (x *UserRoomNotificationPreferenceClearedEvent) GetReason() NotificationReason { + if x != nil { + return x.Reason + } + return NotificationReason_NOTIFICATION_REASON_UNSPECIFIED +} + 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 +1084,23 @@ 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\"\xcc\x01\n" + + "(UserServerNotificationPreferenceSetEvent\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\"\x83\x01\n" + + ",UserServerNotificationPreferenceClearedEvent\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12:\n" + + "\x06reason\x18\x02 \x01(\x0e2\".chatto.core.v1.NotificationReasonR\x06reason\"\xe3\x01\n" + + "&UserRoomNotificationPreferenceSetEvent\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x17\n" + + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12:\n" + + "\x06reason\x18\x03 \x01(\x0e2\".chatto.core.v1.NotificationReasonR\x06reason\x12K\n" + + "\tintensity\x18\x04 \x01(\x0e2-.chatto.core.v1.NotificationDeliveryIntensityR\tintensity\"\x9a\x01\n" + + "*UserRoomNotificationPreferenceClearedEvent\x12\x17\n" + + "\auser_id\x18\x01 \x01(\tR\x06userId\x12\x17\n" + + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12:\n" + + "\x06reason\x18\x03 \x01(\x0e2\".chatto.core.v1.NotificationReasonR\x06reasonB\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,40 +1115,52 @@ 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, 21) var file_chatto_core_v1_config_events_proto_goTypes = []any{ - (*ServerNameChangedEvent)(nil), // 0: chatto.core.v1.ServerNameChangedEvent - (*ServerDescriptionChangedEvent)(nil), // 1: chatto.core.v1.ServerDescriptionChangedEvent - (*ServerWelcomeMessageChangedEvent)(nil), // 2: chatto.core.v1.ServerWelcomeMessageChangedEvent - (*ServerMotdChangedEvent)(nil), // 3: chatto.core.v1.ServerMotdChangedEvent - (*ServerBlockedUsernamesChangedEvent)(nil), // 4: chatto.core.v1.ServerBlockedUsernamesChangedEvent - (*ServerLogoSetEvent)(nil), // 5: chatto.core.v1.ServerLogoSetEvent - (*ServerLogoClearedEvent)(nil), // 6: chatto.core.v1.ServerLogoClearedEvent - (*ServerBannerSetEvent)(nil), // 7: chatto.core.v1.ServerBannerSetEvent - (*ServerBannerClearedEvent)(nil), // 8: chatto.core.v1.ServerBannerClearedEvent - (*UserTimezoneChangedEvent)(nil), // 9: chatto.core.v1.UserTimezoneChangedEvent - (*UserTimezoneClearedEvent)(nil), // 10: chatto.core.v1.UserTimezoneClearedEvent - (*UserTimeFormatChangedEvent)(nil), // 11: chatto.core.v1.UserTimeFormatChangedEvent - (*UserTimeFormatClearedEvent)(nil), // 12: chatto.core.v1.UserTimeFormatClearedEvent - (*UserServerNotificationLevelSetEvent)(nil), // 13: chatto.core.v1.UserServerNotificationLevelSetEvent - (*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 + (*ServerNameChangedEvent)(nil), // 0: chatto.core.v1.ServerNameChangedEvent + (*ServerDescriptionChangedEvent)(nil), // 1: chatto.core.v1.ServerDescriptionChangedEvent + (*ServerWelcomeMessageChangedEvent)(nil), // 2: chatto.core.v1.ServerWelcomeMessageChangedEvent + (*ServerMotdChangedEvent)(nil), // 3: chatto.core.v1.ServerMotdChangedEvent + (*ServerBlockedUsernamesChangedEvent)(nil), // 4: chatto.core.v1.ServerBlockedUsernamesChangedEvent + (*ServerLogoSetEvent)(nil), // 5: chatto.core.v1.ServerLogoSetEvent + (*ServerLogoClearedEvent)(nil), // 6: chatto.core.v1.ServerLogoClearedEvent + (*ServerBannerSetEvent)(nil), // 7: chatto.core.v1.ServerBannerSetEvent + (*ServerBannerClearedEvent)(nil), // 8: chatto.core.v1.ServerBannerClearedEvent + (*UserTimezoneChangedEvent)(nil), // 9: chatto.core.v1.UserTimezoneChangedEvent + (*UserTimezoneClearedEvent)(nil), // 10: chatto.core.v1.UserTimezoneClearedEvent + (*UserTimeFormatChangedEvent)(nil), // 11: chatto.core.v1.UserTimeFormatChangedEvent + (*UserTimeFormatClearedEvent)(nil), // 12: chatto.core.v1.UserTimeFormatClearedEvent + (*UserServerNotificationLevelSetEvent)(nil), // 13: chatto.core.v1.UserServerNotificationLevelSetEvent + (*UserServerNotificationLevelClearedEvent)(nil), // 14: chatto.core.v1.UserServerNotificationLevelClearedEvent + (*UserRoomNotificationLevelSetEvent)(nil), // 15: chatto.core.v1.UserRoomNotificationLevelSetEvent + (*UserRoomNotificationLevelClearedEvent)(nil), // 16: chatto.core.v1.UserRoomNotificationLevelClearedEvent + (*UserServerNotificationPreferenceSetEvent)(nil), // 17: chatto.core.v1.UserServerNotificationPreferenceSetEvent + (*UserServerNotificationPreferenceClearedEvent)(nil), // 18: chatto.core.v1.UserServerNotificationPreferenceClearedEvent + (*UserRoomNotificationPreferenceSetEvent)(nil), // 19: chatto.core.v1.UserRoomNotificationPreferenceSetEvent + (*UserRoomNotificationPreferenceClearedEvent)(nil), // 20: chatto.core.v1.UserRoomNotificationPreferenceClearedEvent + (*AssetRecord)(nil), // 21: chatto.core.v1.AssetRecord + (TimeFormat)(0), // 22: chatto.core.v1.TimeFormat + (NotificationLevel)(0), // 23: chatto.core.v1.NotificationLevel + (NotificationReason)(0), // 24: chatto.core.v1.NotificationReason + (NotificationDeliveryIntensity)(0), // 25: 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 + 21, // 0: chatto.core.v1.ServerLogoSetEvent.asset:type_name -> chatto.core.v1.AssetRecord + 21, // 1: chatto.core.v1.ServerBannerSetEvent.asset:type_name -> chatto.core.v1.AssetRecord + 22, // 2: chatto.core.v1.UserTimeFormatChangedEvent.time_format:type_name -> chatto.core.v1.TimeFormat + 23, // 3: chatto.core.v1.UserServerNotificationLevelSetEvent.level:type_name -> chatto.core.v1.NotificationLevel + 23, // 4: chatto.core.v1.UserRoomNotificationLevelSetEvent.level:type_name -> chatto.core.v1.NotificationLevel + 24, // 5: chatto.core.v1.UserServerNotificationPreferenceSetEvent.reason:type_name -> chatto.core.v1.NotificationReason + 25, // 6: chatto.core.v1.UserServerNotificationPreferenceSetEvent.intensity:type_name -> chatto.core.v1.NotificationDeliveryIntensity + 24, // 7: chatto.core.v1.UserServerNotificationPreferenceClearedEvent.reason:type_name -> chatto.core.v1.NotificationReason + 24, // 8: chatto.core.v1.UserRoomNotificationPreferenceSetEvent.reason:type_name -> chatto.core.v1.NotificationReason + 25, // 9: chatto.core.v1.UserRoomNotificationPreferenceSetEvent.intensity:type_name -> chatto.core.v1.NotificationDeliveryIntensity + 24, // 10: chatto.core.v1.UserRoomNotificationPreferenceClearedEvent.reason:type_name -> chatto.core.v1.NotificationReason + 11, // [11:11] is the sub-list for method output_type + 11, // [11:11] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name } func init() { file_chatto_core_v1_config_events_proto_init() } @@ -901,6 +1169,7 @@ 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() type x struct{} out := protoimpl.TypeBuilder{ @@ -908,7 +1177,7 @@ func file_chatto_core_v1_config_events_proto_init() { 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: 21, 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 f54b6dc4e..93b4123cc 100644 --- a/cli/internal/pb/chatto/core/v1/event.pb.go +++ b/cli/internal/pb/chatto/core/v1/event.pb.go @@ -98,6 +98,10 @@ type Event struct { // *Event_UserServerNotificationLevelCleared // *Event_UserRoomNotificationLevelSet // *Event_UserRoomNotificationLevelCleared + // *Event_UserServerNotificationPreferenceSet + // *Event_UserServerNotificationPreferenceCleared + // *Event_UserRoomNotificationPreferenceSet + // *Event_UserRoomNotificationPreferenceCleared // *Event_RoomGroupCreated // *Event_RoomGroupUpdated // *Event_RoomGroupDeleted @@ -601,6 +605,42 @@ func (x *Event) GetUserRoomNotificationLevelCleared() *UserRoomNotificationLevel return nil } +func (x *Event) GetUserServerNotificationPreferenceSet() *UserServerNotificationPreferenceSetEvent { + if x != nil { + if x, ok := x.Event.(*Event_UserServerNotificationPreferenceSet); ok { + return x.UserServerNotificationPreferenceSet + } + } + return nil +} + +func (x *Event) GetUserServerNotificationPreferenceCleared() *UserServerNotificationPreferenceClearedEvent { + if x != nil { + if x, ok := x.Event.(*Event_UserServerNotificationPreferenceCleared); ok { + return x.UserServerNotificationPreferenceCleared + } + } + return nil +} + +func (x *Event) GetUserRoomNotificationPreferenceSet() *UserRoomNotificationPreferenceSetEvent { + if x != nil { + if x, ok := x.Event.(*Event_UserRoomNotificationPreferenceSet); ok { + return x.UserRoomNotificationPreferenceSet + } + } + return nil +} + +func (x *Event) GetUserRoomNotificationPreferenceCleared() *UserRoomNotificationPreferenceClearedEvent { + if x != nil { + if x, ok := x.Event.(*Event_UserRoomNotificationPreferenceCleared); ok { + return x.UserRoomNotificationPreferenceCleared + } + } + return nil +} + func (x *Event) GetRoomGroupCreated() *RoomGroupCreatedEvent { if x != nil { if x, ok := x.Event.(*Event_RoomGroupCreated); ok { @@ -1348,6 +1388,22 @@ type Event_UserRoomNotificationLevelCleared struct { UserRoomNotificationLevelCleared *UserRoomNotificationLevelClearedEvent `protobuf:"bytes,517,opt,name=user_room_notification_level_cleared,json=userRoomNotificationLevelCleared,proto3,oneof"` } +type Event_UserServerNotificationPreferenceSet struct { + UserServerNotificationPreferenceSet *UserServerNotificationPreferenceSetEvent `protobuf:"bytes,518,opt,name=user_server_notification_preference_set,json=userServerNotificationPreferenceSet,proto3,oneof"` +} + +type Event_UserServerNotificationPreferenceCleared struct { + UserServerNotificationPreferenceCleared *UserServerNotificationPreferenceClearedEvent `protobuf:"bytes,519,opt,name=user_server_notification_preference_cleared,json=userServerNotificationPreferenceCleared,proto3,oneof"` +} + +type Event_UserRoomNotificationPreferenceSet struct { + UserRoomNotificationPreferenceSet *UserRoomNotificationPreferenceSetEvent `protobuf:"bytes,520,opt,name=user_room_notification_preference_set,json=userRoomNotificationPreferenceSet,proto3,oneof"` +} + +type Event_UserRoomNotificationPreferenceCleared struct { + UserRoomNotificationPreferenceCleared *UserRoomNotificationPreferenceClearedEvent `protobuf:"bytes,521,opt,name=user_room_notification_preference_cleared,json=userRoomNotificationPreferenceCleared,proto3,oneof"` +} + type Event_RoomGroupCreated struct { // ----- Room groups (600-609, durable, evt.group.{groupId}) ----- // The group aggregate owns its room-membership AND room-ordering. @@ -1703,6 +1759,14 @@ func (*Event_UserRoomNotificationLevelSet) isEvent_Event() {} func (*Event_UserRoomNotificationLevelCleared) isEvent_Event() {} +func (*Event_UserServerNotificationPreferenceSet) isEvent_Event() {} + +func (*Event_UserServerNotificationPreferenceCleared) isEvent_Event() {} + +func (*Event_UserRoomNotificationPreferenceSet) isEvent_Event() {} + +func (*Event_UserRoomNotificationPreferenceCleared) isEvent_Event() {} + func (*Event_RoomGroupCreated) isEvent_Event() {} func (*Event_RoomGroupUpdated) isEvent_Event() {} @@ -1831,7 +1895,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\"\x96V\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\"\xf2Z\n" + "\x05Event\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x129\n" + "\n" + @@ -1878,7 +1942,11 @@ const file_chatto_core_v1_event_proto_rawDesc = "" + "\"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" + + "$user_room_notification_level_cleared\x18\x85\x04 \x01(\v25.chatto.core.v1.UserRoomNotificationLevelClearedEventH\x00R userRoomNotificationLevelCleared\x12\x91\x01\n" + + "'user_server_notification_preference_set\x18\x86\x04 \x01(\v28.chatto.core.v1.UserServerNotificationPreferenceSetEventH\x00R#userServerNotificationPreferenceSet\x12\x9d\x01\n" + + "+user_server_notification_preference_cleared\x18\x87\x04 \x01(\v2<.chatto.core.v1.UserServerNotificationPreferenceClearedEventH\x00R'userServerNotificationPreferenceCleared\x12\x8b\x01\n" + + "%user_room_notification_preference_set\x18\x88\x04 \x01(\v26.chatto.core.v1.UserRoomNotificationPreferenceSetEventH\x00R!userRoomNotificationPreferenceSet\x12\x97\x01\n" + + ")user_room_notification_preference_cleared\x18\x89\x04 \x01(\v2:.chatto.core.v1.UserRoomNotificationPreferenceClearedEventH\x00R%userRoomNotificationPreferenceCleared\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" + @@ -1959,112 +2027,116 @@ func file_chatto_core_v1_event_proto_rawDescGZIP() []byte { var file_chatto_core_v1_event_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_chatto_core_v1_event_proto_goTypes = []any{ - (*Event)(nil), // 0: chatto.core.v1.Event - (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp - (*RoomCreatedEvent)(nil), // 2: chatto.core.v1.RoomCreatedEvent - (*RoomUpdatedEvent)(nil), // 3: chatto.core.v1.RoomUpdatedEvent - (*RoomDeletedEvent)(nil), // 4: chatto.core.v1.RoomDeletedEvent - (*RoomArchivedEvent)(nil), // 5: chatto.core.v1.RoomArchivedEvent - (*RoomUnarchivedEvent)(nil), // 6: chatto.core.v1.RoomUnarchivedEvent - (*RoomUniversalChangedEvent)(nil), // 7: chatto.core.v1.RoomUniversalChangedEvent - (*UserJoinedRoomEvent)(nil), // 8: chatto.core.v1.UserJoinedRoomEvent - (*UserLeftRoomEvent)(nil), // 9: chatto.core.v1.UserLeftRoomEvent - (*ServerMemberDeletedEvent)(nil), // 10: chatto.core.v1.ServerMemberDeletedEvent - (*CallParticipantJoinedEvent)(nil), // 11: chatto.core.v1.CallParticipantJoinedEvent - (*CallParticipantLeftEvent)(nil), // 12: chatto.core.v1.CallParticipantLeftEvent - (*CallStartedEvent)(nil), // 13: chatto.core.v1.CallStartedEvent - (*CallEndedEvent)(nil), // 14: chatto.core.v1.CallEndedEvent - (*MessagePostedEvent)(nil), // 15: chatto.core.v1.MessagePostedEvent - (*MessageEditedEvent)(nil), // 16: chatto.core.v1.MessageEditedEvent - (*MessageRetractedEvent)(nil), // 17: chatto.core.v1.MessageRetractedEvent - (*MessageBodyEvent)(nil), // 18: chatto.core.v1.MessageBodyEvent - (*ThreadCreatedEvent)(nil), // 19: chatto.core.v1.ThreadCreatedEvent - (*ThreadFollowedEvent)(nil), // 20: chatto.core.v1.ThreadFollowedEvent - (*ThreadUnfollowedEvent)(nil), // 21: chatto.core.v1.ThreadUnfollowedEvent - (*AssetCreatedEvent)(nil), // 22: chatto.core.v1.AssetCreatedEvent - (*AssetProcessingStartedEvent)(nil), // 23: chatto.core.v1.AssetProcessingStartedEvent - (*AssetProcessingSucceededEvent)(nil), // 24: chatto.core.v1.AssetProcessingSucceededEvent - (*AssetProcessingFailedEvent)(nil), // 25: chatto.core.v1.AssetProcessingFailedEvent - (*AssetDeletedEvent)(nil), // 26: chatto.core.v1.AssetDeletedEvent - (*ServerNameChangedEvent)(nil), // 27: chatto.core.v1.ServerNameChangedEvent - (*ServerDescriptionChangedEvent)(nil), // 28: chatto.core.v1.ServerDescriptionChangedEvent - (*ServerWelcomeMessageChangedEvent)(nil), // 29: chatto.core.v1.ServerWelcomeMessageChangedEvent - (*ServerMotdChangedEvent)(nil), // 30: chatto.core.v1.ServerMotdChangedEvent - (*ServerBlockedUsernamesChangedEvent)(nil), // 31: chatto.core.v1.ServerBlockedUsernamesChangedEvent - (*ServerLogoSetEvent)(nil), // 32: chatto.core.v1.ServerLogoSetEvent - (*ServerLogoClearedEvent)(nil), // 33: chatto.core.v1.ServerLogoClearedEvent - (*ServerBannerSetEvent)(nil), // 34: chatto.core.v1.ServerBannerSetEvent - (*ServerBannerClearedEvent)(nil), // 35: chatto.core.v1.ServerBannerClearedEvent - (*UserTimezoneChangedEvent)(nil), // 36: chatto.core.v1.UserTimezoneChangedEvent - (*UserTimezoneClearedEvent)(nil), // 37: chatto.core.v1.UserTimezoneClearedEvent - (*UserTimeFormatChangedEvent)(nil), // 38: chatto.core.v1.UserTimeFormatChangedEvent - (*UserTimeFormatClearedEvent)(nil), // 39: chatto.core.v1.UserTimeFormatClearedEvent - (*UserServerNotificationLevelSetEvent)(nil), // 40: chatto.core.v1.UserServerNotificationLevelSetEvent - (*UserServerNotificationLevelClearedEvent)(nil), // 41: chatto.core.v1.UserServerNotificationLevelClearedEvent - (*UserRoomNotificationLevelSetEvent)(nil), // 42: chatto.core.v1.UserRoomNotificationLevelSetEvent - (*UserRoomNotificationLevelClearedEvent)(nil), // 43: chatto.core.v1.UserRoomNotificationLevelClearedEvent - (*RoomGroupCreatedEvent)(nil), // 44: chatto.core.v1.RoomGroupCreatedEvent - (*RoomGroupUpdatedEvent)(nil), // 45: chatto.core.v1.RoomGroupUpdatedEvent - (*RoomGroupDeletedEvent)(nil), // 46: chatto.core.v1.RoomGroupDeletedEvent - (*RoomAddedToGroupEvent)(nil), // 47: chatto.core.v1.RoomAddedToGroupEvent - (*RoomRemovedFromGroupEvent)(nil), // 48: chatto.core.v1.RoomRemovedFromGroupEvent - (*RoomsInGroupReorderedEvent)(nil), // 49: chatto.core.v1.RoomsInGroupReorderedEvent - (*SidebarLinkAddedToGroupEvent)(nil), // 50: chatto.core.v1.SidebarLinkAddedToGroupEvent - (*SidebarLinkUpdatedEvent)(nil), // 51: chatto.core.v1.SidebarLinkUpdatedEvent - (*SidebarLinkRemovedFromGroupEvent)(nil), // 52: chatto.core.v1.SidebarLinkRemovedFromGroupEvent - (*SidebarGroupEntriesReorderedEvent)(nil), // 53: chatto.core.v1.SidebarGroupEntriesReorderedEvent - (*RoomGroupsReorderedEvent)(nil), // 54: chatto.core.v1.RoomGroupsReorderedEvent - (*UserAccountCreatedEvent)(nil), // 55: chatto.core.v1.UserAccountCreatedEvent - (*UserLoginChangedEvent)(nil), // 56: chatto.core.v1.UserLoginChangedEvent - (*UserDisplayNameChangedEvent)(nil), // 57: chatto.core.v1.UserDisplayNameChangedEvent - (*UserAvatarSetEvent)(nil), // 58: chatto.core.v1.UserAvatarSetEvent - (*UserAvatarClearedEvent)(nil), // 59: chatto.core.v1.UserAvatarClearedEvent - (*UserVerifiedEmailAddedEvent)(nil), // 60: chatto.core.v1.UserVerifiedEmailAddedEvent - (*UserPasswordHashChangedEvent)(nil), // 61: chatto.core.v1.UserPasswordHashChangedEvent - (*UserOIDCSubjectLinkedEvent)(nil), // 62: chatto.core.v1.UserOIDCSubjectLinkedEvent - (*UserServerPreferencesChangedEvent)(nil), // 63: chatto.core.v1.UserServerPreferencesChangedEvent - (*UserLoginCooldownClearedEvent)(nil), // 64: chatto.core.v1.UserLoginCooldownClearedEvent - (*UserAccountDeletedEvent)(nil), // 65: chatto.core.v1.UserAccountDeletedEvent - (*UserLoginCooldownStartedEvent)(nil), // 66: chatto.core.v1.UserLoginCooldownStartedEvent - (*UserKeyShreddedEvent)(nil), // 67: chatto.core.v1.UserKeyShreddedEvent - (*UserDEKGeneratedEvent)(nil), // 68: chatto.core.v1.UserDEKGeneratedEvent - (*UserExternalIdentityLinkedEvent)(nil), // 69: chatto.core.v1.UserExternalIdentityLinkedEvent - (*UserCustomStatusSetEvent)(nil), // 70: chatto.core.v1.UserCustomStatusSetEvent - (*UserCustomStatusClearedEvent)(nil), // 71: chatto.core.v1.UserCustomStatusClearedEvent - (*UserExternalIdentityUnlinkedEvent)(nil), // 72: chatto.core.v1.UserExternalIdentityUnlinkedEvent - (*RbacRoleCreatedEvent)(nil), // 73: chatto.core.v1.RbacRoleCreatedEvent - (*RbacRoleDisplayNameChangedEvent)(nil), // 74: chatto.core.v1.RbacRoleDisplayNameChangedEvent - (*RbacRoleDescriptionChangedEvent)(nil), // 75: chatto.core.v1.RbacRoleDescriptionChangedEvent - (*RbacRoleDeletedEvent)(nil), // 76: chatto.core.v1.RbacRoleDeletedEvent - (*RbacRolesReorderedEvent)(nil), // 77: chatto.core.v1.RbacRolesReorderedEvent - (*RbacRoleAssignedEvent)(nil), // 78: chatto.core.v1.RbacRoleAssignedEvent - (*RbacRoleRevokedEvent)(nil), // 79: chatto.core.v1.RbacRoleRevokedEvent - (*RbacPermissionGrantedEvent)(nil), // 80: chatto.core.v1.RbacPermissionGrantedEvent - (*RbacPermissionDeniedEvent)(nil), // 81: chatto.core.v1.RbacPermissionDeniedEvent - (*RbacPermissionClearedEvent)(nil), // 82: chatto.core.v1.RbacPermissionClearedEvent - (*RbacRolePingableChangedEvent)(nil), // 83: chatto.core.v1.RbacRolePingableChangedEvent - (*AuthorizationFenceAdvancedEvent)(nil), // 84: chatto.core.v1.AuthorizationFenceAdvancedEvent - (*RoomMemberBannedEvent)(nil), // 85: chatto.core.v1.RoomMemberBannedEvent - (*RoomMemberUnbannedEvent)(nil), // 86: chatto.core.v1.RoomMemberUnbannedEvent - (*RoomMemberAddedEvent)(nil), // 87: chatto.core.v1.RoomMemberAddedEvent - (*RoomMemberRemovedEvent)(nil), // 88: chatto.core.v1.RoomMemberRemovedEvent - (*RegistrationVerificationCodeIssuedEvent)(nil), // 89: chatto.core.v1.RegistrationVerificationCodeIssuedEvent - (*EmailVerificationCodeIssuedEvent)(nil), // 90: chatto.core.v1.EmailVerificationCodeIssuedEvent - (*PasswordResetLinkIssuedEvent)(nil), // 91: chatto.core.v1.PasswordResetLinkIssuedEvent - (*AccountDeletionConfirmationIssuedEvent)(nil), // 92: chatto.core.v1.AccountDeletionConfirmationIssuedEvent - (*PasswordResetCompletedEvent)(nil), // 93: chatto.core.v1.PasswordResetCompletedEvent - (*LoginSucceededEvent)(nil), // 94: chatto.core.v1.LoginSucceededEvent - (*LoginFailedEvent)(nil), // 95: chatto.core.v1.LoginFailedEvent - (*LogoutSucceededEvent)(nil), // 96: chatto.core.v1.LogoutSucceededEvent - (*AuthCodeIssuedEvent)(nil), // 97: chatto.core.v1.AuthCodeIssuedEvent - (*AuthCodeExchangeSucceededEvent)(nil), // 98: chatto.core.v1.AuthCodeExchangeSucceededEvent - (*AuthCodeExchangeFailedEvent)(nil), // 99: chatto.core.v1.AuthCodeExchangeFailedEvent - (*BearerTokenIssuedEvent)(nil), // 100: chatto.core.v1.BearerTokenIssuedEvent - (*BearerTokenRevokedEvent)(nil), // 101: chatto.core.v1.BearerTokenRevokedEvent - (*OAuthConsentGrantedEvent)(nil), // 102: chatto.core.v1.OAuthConsentGrantedEvent - (*OAuthConsentDeniedEvent)(nil), // 103: chatto.core.v1.OAuthConsentDeniedEvent - (*ReactionAddedEvent)(nil), // 104: chatto.core.v1.ReactionAddedEvent - (*ReactionRemovedEvent)(nil), // 105: chatto.core.v1.ReactionRemovedEvent + (*Event)(nil), // 0: chatto.core.v1.Event + (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp + (*RoomCreatedEvent)(nil), // 2: chatto.core.v1.RoomCreatedEvent + (*RoomUpdatedEvent)(nil), // 3: chatto.core.v1.RoomUpdatedEvent + (*RoomDeletedEvent)(nil), // 4: chatto.core.v1.RoomDeletedEvent + (*RoomArchivedEvent)(nil), // 5: chatto.core.v1.RoomArchivedEvent + (*RoomUnarchivedEvent)(nil), // 6: chatto.core.v1.RoomUnarchivedEvent + (*RoomUniversalChangedEvent)(nil), // 7: chatto.core.v1.RoomUniversalChangedEvent + (*UserJoinedRoomEvent)(nil), // 8: chatto.core.v1.UserJoinedRoomEvent + (*UserLeftRoomEvent)(nil), // 9: chatto.core.v1.UserLeftRoomEvent + (*ServerMemberDeletedEvent)(nil), // 10: chatto.core.v1.ServerMemberDeletedEvent + (*CallParticipantJoinedEvent)(nil), // 11: chatto.core.v1.CallParticipantJoinedEvent + (*CallParticipantLeftEvent)(nil), // 12: chatto.core.v1.CallParticipantLeftEvent + (*CallStartedEvent)(nil), // 13: chatto.core.v1.CallStartedEvent + (*CallEndedEvent)(nil), // 14: chatto.core.v1.CallEndedEvent + (*MessagePostedEvent)(nil), // 15: chatto.core.v1.MessagePostedEvent + (*MessageEditedEvent)(nil), // 16: chatto.core.v1.MessageEditedEvent + (*MessageRetractedEvent)(nil), // 17: chatto.core.v1.MessageRetractedEvent + (*MessageBodyEvent)(nil), // 18: chatto.core.v1.MessageBodyEvent + (*ThreadCreatedEvent)(nil), // 19: chatto.core.v1.ThreadCreatedEvent + (*ThreadFollowedEvent)(nil), // 20: chatto.core.v1.ThreadFollowedEvent + (*ThreadUnfollowedEvent)(nil), // 21: chatto.core.v1.ThreadUnfollowedEvent + (*AssetCreatedEvent)(nil), // 22: chatto.core.v1.AssetCreatedEvent + (*AssetProcessingStartedEvent)(nil), // 23: chatto.core.v1.AssetProcessingStartedEvent + (*AssetProcessingSucceededEvent)(nil), // 24: chatto.core.v1.AssetProcessingSucceededEvent + (*AssetProcessingFailedEvent)(nil), // 25: chatto.core.v1.AssetProcessingFailedEvent + (*AssetDeletedEvent)(nil), // 26: chatto.core.v1.AssetDeletedEvent + (*ServerNameChangedEvent)(nil), // 27: chatto.core.v1.ServerNameChangedEvent + (*ServerDescriptionChangedEvent)(nil), // 28: chatto.core.v1.ServerDescriptionChangedEvent + (*ServerWelcomeMessageChangedEvent)(nil), // 29: chatto.core.v1.ServerWelcomeMessageChangedEvent + (*ServerMotdChangedEvent)(nil), // 30: chatto.core.v1.ServerMotdChangedEvent + (*ServerBlockedUsernamesChangedEvent)(nil), // 31: chatto.core.v1.ServerBlockedUsernamesChangedEvent + (*ServerLogoSetEvent)(nil), // 32: chatto.core.v1.ServerLogoSetEvent + (*ServerLogoClearedEvent)(nil), // 33: chatto.core.v1.ServerLogoClearedEvent + (*ServerBannerSetEvent)(nil), // 34: chatto.core.v1.ServerBannerSetEvent + (*ServerBannerClearedEvent)(nil), // 35: chatto.core.v1.ServerBannerClearedEvent + (*UserTimezoneChangedEvent)(nil), // 36: chatto.core.v1.UserTimezoneChangedEvent + (*UserTimezoneClearedEvent)(nil), // 37: chatto.core.v1.UserTimezoneClearedEvent + (*UserTimeFormatChangedEvent)(nil), // 38: chatto.core.v1.UserTimeFormatChangedEvent + (*UserTimeFormatClearedEvent)(nil), // 39: chatto.core.v1.UserTimeFormatClearedEvent + (*UserServerNotificationLevelSetEvent)(nil), // 40: chatto.core.v1.UserServerNotificationLevelSetEvent + (*UserServerNotificationLevelClearedEvent)(nil), // 41: chatto.core.v1.UserServerNotificationLevelClearedEvent + (*UserRoomNotificationLevelSetEvent)(nil), // 42: chatto.core.v1.UserRoomNotificationLevelSetEvent + (*UserRoomNotificationLevelClearedEvent)(nil), // 43: chatto.core.v1.UserRoomNotificationLevelClearedEvent + (*UserServerNotificationPreferenceSetEvent)(nil), // 44: chatto.core.v1.UserServerNotificationPreferenceSetEvent + (*UserServerNotificationPreferenceClearedEvent)(nil), // 45: chatto.core.v1.UserServerNotificationPreferenceClearedEvent + (*UserRoomNotificationPreferenceSetEvent)(nil), // 46: chatto.core.v1.UserRoomNotificationPreferenceSetEvent + (*UserRoomNotificationPreferenceClearedEvent)(nil), // 47: chatto.core.v1.UserRoomNotificationPreferenceClearedEvent + (*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 + (*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 + (*ReactionAddedEvent)(nil), // 108: chatto.core.v1.ReactionAddedEvent + (*ReactionRemovedEvent)(nil), // 109: 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 @@ -2110,73 +2182,77 @@ var file_chatto_core_v1_event_proto_depIdxs = []int32{ 41, // 40: chatto.core.v1.Event.user_server_notification_level_cleared:type_name -> chatto.core.v1.UserServerNotificationLevelClearedEvent 42, // 41: chatto.core.v1.Event.user_room_notification_level_set:type_name -> chatto.core.v1.UserRoomNotificationLevelSetEvent 43, // 42: chatto.core.v1.Event.user_room_notification_level_cleared:type_name -> chatto.core.v1.UserRoomNotificationLevelClearedEvent - 44, // 43: chatto.core.v1.Event.room_group_created:type_name -> chatto.core.v1.RoomGroupCreatedEvent - 45, // 44: chatto.core.v1.Event.room_group_updated:type_name -> chatto.core.v1.RoomGroupUpdatedEvent - 46, // 45: chatto.core.v1.Event.room_group_deleted:type_name -> chatto.core.v1.RoomGroupDeletedEvent - 47, // 46: chatto.core.v1.Event.room_added_to_group:type_name -> chatto.core.v1.RoomAddedToGroupEvent - 48, // 47: chatto.core.v1.Event.room_removed_from_group:type_name -> chatto.core.v1.RoomRemovedFromGroupEvent - 49, // 48: chatto.core.v1.Event.rooms_in_group_reordered:type_name -> chatto.core.v1.RoomsInGroupReorderedEvent - 50, // 49: chatto.core.v1.Event.sidebar_link_added_to_group:type_name -> chatto.core.v1.SidebarLinkAddedToGroupEvent - 51, // 50: chatto.core.v1.Event.sidebar_link_updated:type_name -> chatto.core.v1.SidebarLinkUpdatedEvent - 52, // 51: chatto.core.v1.Event.sidebar_link_removed_from_group:type_name -> chatto.core.v1.SidebarLinkRemovedFromGroupEvent - 53, // 52: chatto.core.v1.Event.sidebar_group_entries_reordered:type_name -> chatto.core.v1.SidebarGroupEntriesReorderedEvent - 54, // 53: chatto.core.v1.Event.room_groups_reordered:type_name -> chatto.core.v1.RoomGroupsReorderedEvent - 55, // 54: chatto.core.v1.Event.user_account_created:type_name -> chatto.core.v1.UserAccountCreatedEvent - 56, // 55: chatto.core.v1.Event.user_login_changed:type_name -> chatto.core.v1.UserLoginChangedEvent - 57, // 56: chatto.core.v1.Event.user_display_name_changed:type_name -> chatto.core.v1.UserDisplayNameChangedEvent - 58, // 57: chatto.core.v1.Event.user_avatar_set:type_name -> chatto.core.v1.UserAvatarSetEvent - 59, // 58: chatto.core.v1.Event.user_avatar_cleared:type_name -> chatto.core.v1.UserAvatarClearedEvent - 60, // 59: chatto.core.v1.Event.user_verified_email_added:type_name -> chatto.core.v1.UserVerifiedEmailAddedEvent - 61, // 60: chatto.core.v1.Event.user_password_hash_changed:type_name -> chatto.core.v1.UserPasswordHashChangedEvent - 62, // 61: chatto.core.v1.Event.user_oidc_subject_linked:type_name -> chatto.core.v1.UserOIDCSubjectLinkedEvent - 63, // 62: chatto.core.v1.Event.user_server_preferences_changed:type_name -> chatto.core.v1.UserServerPreferencesChangedEvent - 64, // 63: chatto.core.v1.Event.user_login_cooldown_cleared:type_name -> chatto.core.v1.UserLoginCooldownClearedEvent - 65, // 64: chatto.core.v1.Event.user_account_deleted:type_name -> chatto.core.v1.UserAccountDeletedEvent - 66, // 65: chatto.core.v1.Event.user_login_cooldown_started:type_name -> chatto.core.v1.UserLoginCooldownStartedEvent - 67, // 66: chatto.core.v1.Event.user_key_shredded:type_name -> chatto.core.v1.UserKeyShreddedEvent - 68, // 67: chatto.core.v1.Event.user_dek_generated:type_name -> chatto.core.v1.UserDEKGeneratedEvent - 69, // 68: chatto.core.v1.Event.user_external_identity_linked:type_name -> chatto.core.v1.UserExternalIdentityLinkedEvent - 70, // 69: chatto.core.v1.Event.user_custom_status_set:type_name -> chatto.core.v1.UserCustomStatusSetEvent - 71, // 70: chatto.core.v1.Event.user_custom_status_cleared:type_name -> chatto.core.v1.UserCustomStatusClearedEvent - 72, // 71: chatto.core.v1.Event.user_external_identity_unlinked:type_name -> chatto.core.v1.UserExternalIdentityUnlinkedEvent - 73, // 72: chatto.core.v1.Event.rbac_role_created:type_name -> chatto.core.v1.RbacRoleCreatedEvent - 74, // 73: chatto.core.v1.Event.rbac_role_display_name_changed:type_name -> chatto.core.v1.RbacRoleDisplayNameChangedEvent - 75, // 74: chatto.core.v1.Event.rbac_role_description_changed:type_name -> chatto.core.v1.RbacRoleDescriptionChangedEvent - 76, // 75: chatto.core.v1.Event.rbac_role_deleted:type_name -> chatto.core.v1.RbacRoleDeletedEvent - 77, // 76: chatto.core.v1.Event.rbac_roles_reordered:type_name -> chatto.core.v1.RbacRolesReorderedEvent - 78, // 77: chatto.core.v1.Event.rbac_role_assigned:type_name -> chatto.core.v1.RbacRoleAssignedEvent - 79, // 78: chatto.core.v1.Event.rbac_role_revoked:type_name -> chatto.core.v1.RbacRoleRevokedEvent - 80, // 79: chatto.core.v1.Event.rbac_permission_granted:type_name -> chatto.core.v1.RbacPermissionGrantedEvent - 81, // 80: chatto.core.v1.Event.rbac_permission_denied:type_name -> chatto.core.v1.RbacPermissionDeniedEvent - 82, // 81: chatto.core.v1.Event.rbac_permission_cleared:type_name -> chatto.core.v1.RbacPermissionClearedEvent - 83, // 82: chatto.core.v1.Event.rbac_role_pingable_changed:type_name -> chatto.core.v1.RbacRolePingableChangedEvent - 84, // 83: chatto.core.v1.Event.authorization_fence_advanced:type_name -> chatto.core.v1.AuthorizationFenceAdvancedEvent - 85, // 84: chatto.core.v1.Event.room_member_banned:type_name -> chatto.core.v1.RoomMemberBannedEvent - 86, // 85: chatto.core.v1.Event.room_member_unbanned:type_name -> chatto.core.v1.RoomMemberUnbannedEvent - 87, // 86: chatto.core.v1.Event.room_member_added:type_name -> chatto.core.v1.RoomMemberAddedEvent - 88, // 87: chatto.core.v1.Event.room_member_removed:type_name -> chatto.core.v1.RoomMemberRemovedEvent - 89, // 88: chatto.core.v1.Event.registration_verification_code_issued:type_name -> chatto.core.v1.RegistrationVerificationCodeIssuedEvent - 90, // 89: chatto.core.v1.Event.email_verification_code_issued:type_name -> chatto.core.v1.EmailVerificationCodeIssuedEvent - 91, // 90: chatto.core.v1.Event.password_reset_link_issued:type_name -> chatto.core.v1.PasswordResetLinkIssuedEvent - 92, // 91: chatto.core.v1.Event.account_deletion_confirmation_issued:type_name -> chatto.core.v1.AccountDeletionConfirmationIssuedEvent - 93, // 92: chatto.core.v1.Event.password_reset_completed:type_name -> chatto.core.v1.PasswordResetCompletedEvent - 94, // 93: chatto.core.v1.Event.login_succeeded:type_name -> chatto.core.v1.LoginSucceededEvent - 95, // 94: chatto.core.v1.Event.login_failed:type_name -> chatto.core.v1.LoginFailedEvent - 96, // 95: chatto.core.v1.Event.logout_succeeded:type_name -> chatto.core.v1.LogoutSucceededEvent - 97, // 96: chatto.core.v1.Event.auth_code_issued:type_name -> chatto.core.v1.AuthCodeIssuedEvent - 98, // 97: chatto.core.v1.Event.auth_code_exchange_succeeded:type_name -> chatto.core.v1.AuthCodeExchangeSucceededEvent - 99, // 98: chatto.core.v1.Event.auth_code_exchange_failed:type_name -> chatto.core.v1.AuthCodeExchangeFailedEvent - 100, // 99: chatto.core.v1.Event.bearer_token_issued:type_name -> chatto.core.v1.BearerTokenIssuedEvent - 101, // 100: chatto.core.v1.Event.bearer_token_revoked:type_name -> chatto.core.v1.BearerTokenRevokedEvent - 102, // 101: chatto.core.v1.Event.oauth_consent_granted:type_name -> chatto.core.v1.OAuthConsentGrantedEvent - 103, // 102: chatto.core.v1.Event.oauth_consent_denied:type_name -> chatto.core.v1.OAuthConsentDeniedEvent - 104, // 103: chatto.core.v1.Event.reaction_added:type_name -> chatto.core.v1.ReactionAddedEvent - 105, // 104: chatto.core.v1.Event.reaction_removed:type_name -> chatto.core.v1.ReactionRemovedEvent - 105, // [105:105] is the sub-list for method output_type - 105, // [105:105] is the sub-list for method input_type - 105, // [105:105] is the sub-list for extension type_name - 105, // [105:105] is the sub-list for extension extendee - 0, // [0:105] is the sub-list for field type_name + 44, // 43: chatto.core.v1.Event.user_server_notification_preference_set:type_name -> chatto.core.v1.UserServerNotificationPreferenceSetEvent + 45, // 44: chatto.core.v1.Event.user_server_notification_preference_cleared:type_name -> chatto.core.v1.UserServerNotificationPreferenceClearedEvent + 46, // 45: chatto.core.v1.Event.user_room_notification_preference_set:type_name -> chatto.core.v1.UserRoomNotificationPreferenceSetEvent + 47, // 46: chatto.core.v1.Event.user_room_notification_preference_cleared:type_name -> chatto.core.v1.UserRoomNotificationPreferenceClearedEvent + 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.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.reaction_added:type_name -> chatto.core.v1.ReactionAddedEvent + 109, // 108: chatto.core.v1.Event.reaction_removed:type_name -> chatto.core.v1.ReactionRemovedEvent + 109, // [109:109] is the sub-list for method output_type + 109, // [109:109] is the sub-list for method input_type + 109, // [109:109] is the sub-list for extension type_name + 109, // [109:109] is the sub-list for extension extendee + 0, // [0:109] is the sub-list for field type_name } func init() { file_chatto_core_v1_event_proto_init() } @@ -2239,6 +2315,10 @@ func file_chatto_core_v1_event_proto_init() { (*Event_UserServerNotificationLevelCleared)(nil), (*Event_UserRoomNotificationLevelSet)(nil), (*Event_UserRoomNotificationLevelCleared)(nil), + (*Event_UserServerNotificationPreferenceSet)(nil), + (*Event_UserServerNotificationPreferenceCleared)(nil), + (*Event_UserRoomNotificationPreferenceSet)(nil), + (*Event_UserRoomNotificationPreferenceCleared)(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..fcd25210d 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 @@ -267,6 +268,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 { @@ -383,6 +393,10 @@ type LiveEvent_NotificationDismissed struct { 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 +448,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() {} @@ -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\"\x81\x11\n" + "\tLiveEvent\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x129\n" + "\n" + @@ -1253,7 +1359,8 @@ const file_chatto_core_v1_live_events_proto_rawDesc = "" + "\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" + + "\x16notification_dismissed\x18G \x01(\v2*.chatto.core.v1.NotificationDismissedEventH\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/message_events.pb.go b/cli/internal/pb/chatto/core/v1/message_events.pb.go index 9d6e4922d..fc3178c9a 100644 --- a/cli/internal/pb/chatto/core/v1/message_events.pb.go +++ b/cli/internal/pb/chatto/core/v1/message_events.pb.go @@ -38,8 +38,12 @@ type MessagePostedEvent struct { EchoOfEventId string `protobuf:"bytes,7,opt,name=echo_of_event_id,json=echoOfEventId,proto3" json:"echo_of_event_id,omitempty"` // Thread root event ID — the thread this echo originates from (empty = not an echo) EchoFromThreadRootEventId string `protobuf:"bytes,8,opt,name=echo_from_thread_root_event_id,json=echoFromThreadRootEventId,proto3" json:"echo_from_thread_root_event_id,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Recipient-specific notification decisions evaluated before this source + // fact was committed. The notification materializer uses these candidates + // for recoverable, idempotent occurrence creation. + NotificationCandidates []*NotificationCandidate `protobuf:"bytes,10,rep,name=notification_candidates,json=notificationCandidates,proto3" json:"notification_candidates,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *MessagePostedEvent) Reset() { @@ -114,6 +118,13 @@ func (x *MessagePostedEvent) GetEchoFromThreadRootEventId() string { return "" } +func (x *MessagePostedEvent) GetNotificationCandidates() []*NotificationCandidate { + if x != nil { + return x.NotificationCandidates + } + return nil +} + // MessageBodyEvent carries the encrypted body payload for a message post or // body update. It is durable, room-scoped projection input but is not delivered // as a public live event. The target message event remains MessagePostedEvent; @@ -473,14 +484,16 @@ var File_chatto_core_v1_message_events_proto protoreflect.FileDescriptor const file_chatto_core_v1_message_events_proto_rawDesc = "" + "\n" + - "#chatto/core/v1/message_events.proto\x12\x0echatto.core.v1\x1a\x1bchatto/core/v1/models.proto\"\xb7\x02\n" + + "#chatto/core/v1/message_events.proto\x12\x0echatto.core.v1\x1a\x1bchatto/core/v1/models.proto\x1a!chatto/core/v1/notification.proto\"\x97\x03\n" + "\x12MessagePostedEvent\x12\x17\n" + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12\x1e\n" + "\vin_reply_to\x18\x04 \x01(\tR\tinReplyTo\x12\x1b\n" + "\tin_thread\x18\x05 \x01(\tR\binThread\x12,\n" + "\x12mentioned_user_ids\x18\x06 \x03(\tR\x10mentionedUserIds\x12'\n" + "\x10echo_of_event_id\x18\a \x01(\tR\rechoOfEventId\x12A\n" + - "\x1eecho_from_thread_root_event_id\x18\b \x01(\tR\x19echoFromThreadRootEventIdJ\x04\b\x01\x10\x02J\x04\b\x03\x10\x04J\x04\b\t\x10\n" + + "\x1eecho_from_thread_root_event_id\x18\b \x01(\tR\x19echoFromThreadRootEventId\x12^\n" + + "\x17notification_candidates\x18\n" + + " \x03(\v2%.chatto.core.v1.NotificationCandidateR\x16notificationCandidatesJ\x04\b\x01\x10\x02J\x04\b\x03\x10\x04J\x04\b\t\x10\n" + "R\bspace_idR\x0fmessage_body_idR\x04body\"w\n" + "\x10MessageBodyEvent\x12\x17\n" + "\aroom_id\x18\x01 \x01(\tR\x06roomId\x12\x19\n" + @@ -526,15 +539,17 @@ var file_chatto_core_v1_message_events_proto_goTypes = []any{ (*MessageRetractedEvent)(nil), // 3: chatto.core.v1.MessageRetractedEvent (*MessageUpdatedEvent)(nil), // 4: chatto.core.v1.MessageUpdatedEvent (*MessageDeletedEvent)(nil), // 5: chatto.core.v1.MessageDeletedEvent - (*MessageBody)(nil), // 6: chatto.core.v1.MessageBody + (*NotificationCandidate)(nil), // 6: chatto.core.v1.NotificationCandidate + (*MessageBody)(nil), // 7: chatto.core.v1.MessageBody } var file_chatto_core_v1_message_events_proto_depIdxs = []int32{ - 6, // 0: chatto.core.v1.MessageBodyEvent.body:type_name -> chatto.core.v1.MessageBody - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 6, // 0: chatto.core.v1.MessagePostedEvent.notification_candidates:type_name -> chatto.core.v1.NotificationCandidate + 7, // 1: chatto.core.v1.MessageBodyEvent.body:type_name -> chatto.core.v1.MessageBody + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_chatto_core_v1_message_events_proto_init() } @@ -543,6 +558,7 @@ func file_chatto_core_v1_message_events_proto_init() { return } file_chatto_core_v1_models_proto_init() + file_chatto_core_v1_notification_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/cli/internal/pb/chatto/core/v1/notification.pb.go b/cli/internal/pb/chatto/core/v1/notification.pb.go index 4ab260ff3..37003858f 100644 --- a/cli/internal/pb/chatto/core/v1/notification.pb.go +++ b/cli/internal/pb/chatto/core/v1/notification.pb.go @@ -22,6 +22,308 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +// NotificationReason identifies why one source activity qualified for a +// recipient's notification inbox. 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 recipient-controlled inbox triage state. +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. Inbox 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 +737,360 @@ 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 +} + +// NotificationCandidate is durable source-event provenance for one recipient. +// It lets notification materialization recover without re-evaluating later +// preferences or transient @here presence. +type NotificationCandidate struct { + state protoimpl.MessageState `protogen:"open.v1"` + RecipientId string `protobuf:"bytes,1,opt,name=recipient_id,json=recipientId,proto3" json:"recipient_id,omitempty"` + Reasons []*NotificationReasonMatch `protobuf:"bytes,2,rep,name=reasons,proto3" json:"reasons,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationCandidate) Reset() { + *x = NotificationCandidate{} + mi := &file_chatto_core_v1_notification_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *NotificationCandidate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationCandidate) ProtoMessage() {} + +func (x *NotificationCandidate) 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 NotificationCandidate.ProtoReflect.Descriptor instead. +func (*NotificationCandidate) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_notification_proto_rawDescGZIP(), []int{6} +} + +func (x *NotificationCandidate) GetRecipientId() string { + if x != nil { + return x.RecipientId + } + return "" +} + +func (x *NotificationCandidate) GetReasons() []*NotificationReasonMatch { + if x != nil { + return x.Reasons + } + return nil +} + +// 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[7] + 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[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 NotificationTarget.ProtoReflect.Descriptor instead. +func (*NotificationTarget) Descriptor() ([]byte, []int) { + return file_chatto_core_v1_notification_proto_rawDescGZIP(), []int{7} +} + +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"` + Saved bool `protobuf:"varint,10,opt,name=saved,proto3" json:"saved,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"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *NotificationOccurrence) Reset() { + *x = NotificationOccurrence{} + mi := &file_chatto_core_v1_notification_proto_msgTypes[8] + 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[8] + 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{8} +} + +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) GetSaved() bool { + if x != nil { + return x.Saved + } + return false +} + +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 +} + var File_chatto_core_v1_notification_proto protoreflect.FileDescriptor const file_chatto_core_v1_notification_proto_rawDesc = "" + @@ -466,7 +1122,81 @@ 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\"}\n" + + "\x15NotificationCandidate\x12!\n" + + "\frecipient_id\x18\x01 \x01(\tR\vrecipientId\x12A\n" + + "\areasons\x18\x02 \x03(\v2'.chatto.core.v1.NotificationReasonMatchR\areasons\"\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\"\xeb\a\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\x14\n" + + "\x05saved\x18\n" + + " \x01(\bR\x05saved\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*\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 +1211,51 @@ 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, 9) 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 + (*NotificationCandidate)(nil), // 11: chatto.core.v1.NotificationCandidate + (*NotificationTarget)(nil), // 12: chatto.core.v1.NotificationTarget + (*NotificationOccurrence)(nil), // 13: chatto.core.v1.NotificationOccurrence + (*timestamppb.Timestamp)(nil), // 14: 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 + 14, // 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 + 10, // 7: chatto.core.v1.NotificationCandidate.reasons:type_name -> chatto.core.v1.NotificationReasonMatch + 14, // 8: chatto.core.v1.NotificationOccurrence.source_created_at:type_name -> google.protobuf.Timestamp + 12, // 9: chatto.core.v1.NotificationOccurrence.target:type_name -> chatto.core.v1.NotificationTarget + 10, // 10: chatto.core.v1.NotificationOccurrence.reasons:type_name -> chatto.core.v1.NotificationReasonMatch + 1, // 11: chatto.core.v1.NotificationOccurrence.strongest_intensity:type_name -> chatto.core.v1.NotificationDeliveryIntensity + 2, // 12: chatto.core.v1.NotificationOccurrence.inbox_state:type_name -> chatto.core.v1.NotificationInboxState + 14, // 13: chatto.core.v1.NotificationOccurrence.evaluated_at:type_name -> google.protobuf.Timestamp + 14, // 14: chatto.core.v1.NotificationOccurrence.updated_at:type_name -> google.protobuf.Timestamp + 14, // 15: chatto.core.v1.NotificationOccurrence.expires_at:type_name -> google.protobuf.Timestamp + 3, // 16: chatto.core.v1.NotificationOccurrence.removal_reason:type_name -> chatto.core.v1.NotificationRemovalReason + 14, // 17: chatto.core.v1.NotificationOccurrence.removed_at:type_name -> google.protobuf.Timestamp + 4, // 18: chatto.core.v1.NotificationOccurrence.alert_state:type_name -> chatto.core.v1.NotificationAlertState + 14, // 19: chatto.core.v1.NotificationOccurrence.alert_claimed_until:type_name -> google.protobuf.Timestamp + 20, // [20:20] is the sub-list for method output_type + 20, // [20:20] is the sub-list for method input_type + 20, // [20:20] is the sub-list for extension type_name + 20, // [20:20] is the sub-list for extension extendee + 0, // [0:20] is the sub-list for field type_name } func init() { file_chatto_core_v1_notification_proto_init() } @@ -514,18 +1269,20 @@ func file_chatto_core_v1_notification_proto_init() { (*Notification_Reply)(nil), (*Notification_RoomMessage)(nil), } + file_chatto_core_v1_notification_proto_msgTypes[7].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: 9, 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 8faeafe07..550f311e2 100644 --- a/cli/internal/pb/chatto/core/v1/projection_snapshots.pb.go +++ b/cli/internal/pb/chatto/core/v1/projection_snapshots.pb.go @@ -1588,14 +1588,16 @@ 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"` + 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"` + 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() { @@ -1663,6 +1665,20 @@ func (x *UserConfigSnapshot) GetRoomNotificationLevels() []*RoomNotificationLeve return nil } +func (x *UserConfigSnapshot) GetServerNotificationPreferences() []*NotificationPreferenceSnapshot { + if x != nil { + return x.ServerNotificationPreferences + } + return nil +} + +func (x *UserConfigSnapshot) GetRoomNotificationPreferences() []*RoomNotificationPreferenceSnapshot { + if x != nil { + 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"` @@ -1715,6 +1731,110 @@ func (x *RoomNotificationLevelSnapshot) GetLevel() NotificationLevel { return NotificationLevel_NOTIFICATION_LEVEL_UNSPECIFIED } +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 *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 *NotificationPreferenceSnapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*NotificationPreferenceSnapshot) ProtoMessage() {} + +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 { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// 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 *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 *RoomNotificationPreferenceSnapshot) GetPreferences() []*NotificationPreferenceSnapshot { + if x != nil { + return x.Preferences + } + return nil +} + type AssetProjectionSnapshot struct { state protoimpl.MessageState `protogen:"open.v1"` Creations []*AssetCreatedEvent `protobuf:"bytes,1,rep,name=creations,proto3" json:"creations,omitempty"` @@ -1730,7 +1850,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) } @@ -1742,7 +1862,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 { @@ -1755,7 +1875,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 { @@ -1817,7 +1937,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) } @@ -1829,7 +1949,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 { @@ -1842,7 +1962,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 { @@ -1871,7 +1991,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) } @@ -1883,7 +2003,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 { @@ -1896,7 +2016,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 { @@ -1937,7 +2057,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) } @@ -1949,7 +2069,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 { @@ -1962,7 +2082,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 { @@ -1993,7 +2113,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) } @@ -2005,7 +2125,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 { @@ -2018,7 +2138,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 { @@ -2073,7 +2193,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) } @@ -2085,7 +2205,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 { @@ -2098,7 +2218,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 { @@ -2125,7 +2245,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) } @@ -2137,7 +2257,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 { @@ -2150,7 +2270,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 { @@ -2171,13 +2291,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) } @@ -2189,7 +2310,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 { @@ -2202,7 +2323,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 { @@ -2219,6 +2340,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"` @@ -2229,7 +2357,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) } @@ -2241,7 +2369,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 { @@ -2254,7 +2382,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 { @@ -2281,7 +2409,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) } @@ -2293,7 +2421,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 { @@ -2306,7 +2434,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 { @@ -2334,7 +2462,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) } @@ -2346,7 +2474,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 { @@ -2359,7 +2487,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 { @@ -2399,7 +2527,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) } @@ -2411,7 +2539,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 { @@ -2424,7 +2552,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 { @@ -2481,7 +2609,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) } @@ -2493,7 +2621,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 { @@ -2506,7 +2634,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 { @@ -2598,7 +2726,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) } @@ -2610,7 +2738,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 { @@ -2623,7 +2751,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 { @@ -2665,7 +2793,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) } @@ -2677,7 +2805,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 { @@ -2690,7 +2818,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 { @@ -2730,7 +2858,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) } @@ -2742,7 +2870,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 { @@ -2755,7 +2883,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 { @@ -2824,7 +2952,7 @@ type TimelineEntrySnapshot struct { func (x *TimelineEntrySnapshot) Reset() { *x = TimelineEntrySnapshot{} - 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) } @@ -2836,7 +2964,7 @@ func (x *TimelineEntrySnapshot) String() string { func (*TimelineEntrySnapshot) ProtoMessage() {} func (x *TimelineEntrySnapshot) 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 { @@ -2849,7 +2977,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{40} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{42} } func (x *TimelineEntrySnapshot) GetStreamSequence() uint64 { @@ -2878,7 +3006,7 @@ type TimelineBodySnapshot struct { func (x *TimelineBodySnapshot) Reset() { *x = TimelineBodySnapshot{} - 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) } @@ -2890,7 +3018,7 @@ func (x *TimelineBodySnapshot) String() string { func (*TimelineBodySnapshot) ProtoMessage() {} func (x *TimelineBodySnapshot) 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 { @@ -2903,7 +3031,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{41} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{43} } func (x *TimelineBodySnapshot) GetMessageEventId() string { @@ -2944,7 +3072,7 @@ type StringTimestampSnapshot struct { func (x *StringTimestampSnapshot) Reset() { *x = StringTimestampSnapshot{} - 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) } @@ -2956,7 +3084,7 @@ func (x *StringTimestampSnapshot) String() string { func (*StringTimestampSnapshot) ProtoMessage() {} func (x *StringTimestampSnapshot) 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 { @@ -2969,7 +3097,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{42} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{44} } func (x *StringTimestampSnapshot) GetKey() string { @@ -2998,7 +3126,7 @@ type AssetMessageOwnerSnapshot struct { func (x *AssetMessageOwnerSnapshot) Reset() { *x = AssetMessageOwnerSnapshot{} - 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) } @@ -3010,7 +3138,7 @@ func (x *AssetMessageOwnerSnapshot) String() string { func (*AssetMessageOwnerSnapshot) ProtoMessage() {} func (x *AssetMessageOwnerSnapshot) 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 { @@ -3023,7 +3151,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{43} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{45} } func (x *AssetMessageOwnerSnapshot) GetAssetId() string { @@ -3058,7 +3186,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" + @@ -3189,20 +3317,28 @@ 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\"\x88\x05\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" + + "\x18room_notification_levels\x18\x05 \x03(\v2-.chatto.core.v1.RoomNotificationLevelSnapshotR\x16roomNotificationLevels\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" + + "\x05level\x18\x02 \x01(\x0e2!.chatto.core.v1.NotificationLevelR\x05level\"\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" + @@ -3235,10 +3371,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" + @@ -3323,7 +3460,7 @@ 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, 44) +var file_chatto_core_v1_projection_snapshots_proto_msgTypes = make([]protoimpl.MessageInfo, 46) var file_chatto_core_v1_projection_snapshots_proto_goTypes = []any{ (*ProjectionSnapshotGeneration)(nil), // 0: chatto.core.v1.ProjectionSnapshotGeneration (*ProjectionSnapshotPointer)(nil), // 1: chatto.core.v1.ProjectionSnapshotPointer @@ -3349,129 +3486,138 @@ var file_chatto_core_v1_projection_snapshots_proto_goTypes = []any{ (*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 - (*TimelineEntrySnapshot)(nil), // 40: chatto.core.v1.TimelineEntrySnapshot - (*TimelineBodySnapshot)(nil), // 41: chatto.core.v1.TimelineBodySnapshot - (*StringTimestampSnapshot)(nil), // 42: chatto.core.v1.StringTimestampSnapshot - (*AssetMessageOwnerSnapshot)(nil), // 43: chatto.core.v1.AssetMessageOwnerSnapshot - (*timestamppb.Timestamp)(nil), // 44: google.protobuf.Timestamp - (*Room)(nil), // 45: chatto.core.v1.Room - (*RoomGroup)(nil), // 46: chatto.core.v1.RoomGroup - (CallParticipantEventSource)(0), // 47: chatto.core.v1.CallParticipantEventSource - (*UserDEKGeneratedEvent)(nil), // 48: chatto.core.v1.UserDEKGeneratedEvent - (*Role)(nil), // 49: chatto.core.v1.Role - (RbacPermissionSubjectKind)(0), // 50: chatto.core.v1.RbacPermissionSubjectKind - (*AssetRecord)(nil), // 51: chatto.core.v1.AssetRecord - (TimeFormat)(0), // 52: chatto.core.v1.TimeFormat - (NotificationLevel)(0), // 53: chatto.core.v1.NotificationLevel - (*AssetCreatedEvent)(nil), // 54: chatto.core.v1.AssetCreatedEvent - (*AssetProcessingStartedEvent)(nil), // 55: chatto.core.v1.AssetProcessingStartedEvent - (*AssetProcessingSucceededEvent)(nil), // 56: chatto.core.v1.AssetProcessingSucceededEvent - (*AssetProcessingFailedEvent)(nil), // 57: chatto.core.v1.AssetProcessingFailedEvent - (*Event)(nil), // 58: chatto.core.v1.Event - (*User)(nil), // 59: chatto.core.v1.User - (*ServerUserPreferences)(nil), // 60: chatto.core.v1.ServerUserPreferences - (*EncryptedUserString)(nil), // 61: chatto.core.v1.EncryptedUserString - (*MessageBody)(nil), // 62: chatto.core.v1.MessageBody + (*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 + (*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 + (NotificationReason)(0), // 56: chatto.core.v1.NotificationReason + (NotificationDeliveryIntensity)(0), // 57: chatto.core.v1.NotificationDeliveryIntensity + (*AssetCreatedEvent)(nil), // 58: chatto.core.v1.AssetCreatedEvent + (*AssetProcessingStartedEvent)(nil), // 59: chatto.core.v1.AssetProcessingStartedEvent + (*AssetProcessingSucceededEvent)(nil), // 60: chatto.core.v1.AssetProcessingSucceededEvent + (*AssetProcessingFailedEvent)(nil), // 61: chatto.core.v1.AssetProcessingFailedEvent + (*Event)(nil), // 62: chatto.core.v1.Event + (*User)(nil), // 63: chatto.core.v1.User + (*ServerUserPreferences)(nil), // 64: chatto.core.v1.ServerUserPreferences + (*EncryptedUserString)(nil), // 65: chatto.core.v1.EncryptedUserString + (*MessageBody)(nil), // 66: chatto.core.v1.MessageBody } var file_chatto_core_v1_projection_snapshots_proto_depIdxs = []int32{ - 44, // 0: chatto.core.v1.ProjectionSnapshotGeneration.created_at:type_name -> google.protobuf.Timestamp - 44, // 1: chatto.core.v1.ProjectionSnapshotPointer.current_created_at:type_name -> google.protobuf.Timestamp - 44, // 2: chatto.core.v1.ProjectionSnapshotPointer.previous_created_at:type_name -> google.protobuf.Timestamp + 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 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 - 44, // 8: chatto.core.v1.ThreadReplySnapshot.created_at:type_name -> google.protobuf.Timestamp - 45, // 9: chatto.core.v1.RoomDirectoryProjectionSnapshot.rooms:type_name -> chatto.core.v1.Room + 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 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 - 44, // 12: chatto.core.v1.RoomBanSnapshot.created_at:type_name -> google.protobuf.Timestamp - 44, // 13: chatto.core.v1.RoomBanSnapshot.expires_at:type_name -> google.protobuf.Timestamp + 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 12, // 14: chatto.core.v1.RoomGroupLayoutProjectionSnapshot.groups:type_name -> chatto.core.v1.RoomGroupStateSnapshot - 46, // 15: chatto.core.v1.RoomGroupStateSnapshot.group:type_name -> chatto.core.v1.RoomGroup + 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 - 47, // 19: chatto.core.v1.CallSessionSnapshot.source:type_name -> chatto.core.v1.CallParticipantEventSource - 47, // 20: chatto.core.v1.CallParticipantSnapshot.source:type_name -> chatto.core.v1.CallParticipantEventSource - 48, // 21: chatto.core.v1.ContentKeyProjectionSnapshot.keys:type_name -> chatto.core.v1.UserDEKGeneratedEvent + 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 - 49, // 23: chatto.core.v1.RBACProjectionSnapshot.roles:type_name -> chatto.core.v1.Role + 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 - 50, // 27: chatto.core.v1.RBACDecisionSnapshot.subject_kind:type_name -> chatto.core.v1.RbacPermissionSubjectKind - 51, // 28: chatto.core.v1.ConfigProjectionSnapshot.logo:type_name -> chatto.core.v1.AssetRecord - 51, // 29: chatto.core.v1.ConfigProjectionSnapshot.banner:type_name -> chatto.core.v1.AssetRecord + 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 - 52, // 31: chatto.core.v1.UserConfigSnapshot.time_format:type_name -> chatto.core.v1.TimeFormat - 53, // 32: chatto.core.v1.UserConfigSnapshot.server_notification_level:type_name -> chatto.core.v1.NotificationLevel + 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 - 53, // 34: chatto.core.v1.RoomNotificationLevelSnapshot.level:type_name -> chatto.core.v1.NotificationLevel - 54, // 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 - 43, // 40: chatto.core.v1.AssetProjectionSnapshot.message_owners:type_name -> chatto.core.v1.AssetMessageOwnerSnapshot - 55, // 41: chatto.core.v1.AssetManifestSnapshot.started:type_name -> chatto.core.v1.AssetProcessingStartedEvent - 56, // 42: chatto.core.v1.AssetManifestSnapshot.succeeded:type_name -> chatto.core.v1.AssetProcessingSucceededEvent - 57, // 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 - 58, // 52: chatto.core.v1.MentionablesProjectionSnapshot.user_login_sources:type_name -> chatto.core.v1.Event - 48, // 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 - 48, // 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 - 59, // 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 - 51, // 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 - 60, // 64: chatto.core.v1.ProjectedUserProfileSnapshot.preferences:type_name -> chatto.core.v1.ServerUserPreferences - 44, // 65: chatto.core.v1.ProjectedUserProfileSnapshot.login_changed_at:type_name -> google.protobuf.Timestamp - 61, // 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 - 44, // 68: chatto.core.v1.ProjectedVerifiedEmailSnapshot.verified_at:type_name -> google.protobuf.Timestamp - 40, // 69: chatto.core.v1.RoomTimelineProjectionSnapshot.entries:type_name -> chatto.core.v1.TimelineEntrySnapshot - 41, // 70: chatto.core.v1.RoomTimelineProjectionSnapshot.bodies:type_name -> chatto.core.v1.TimelineBodySnapshot - 42, // 71: chatto.core.v1.RoomTimelineProjectionSnapshot.tombstoned_at:type_name -> chatto.core.v1.StringTimestampSnapshot - 42, // 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 - 58, // 74: chatto.core.v1.TimelineEntrySnapshot.event:type_name -> chatto.core.v1.Event - 62, // 75: chatto.core.v1.TimelineBodySnapshot.body:type_name -> chatto.core.v1.MessageBody - 44, // 76: chatto.core.v1.StringTimestampSnapshot.value:type_name -> google.protobuf.Timestamp - 77, // [77:77] is the sub-list for method output_type - 77, // [77:77] is the sub-list for method input_type - 77, // [77:77] is the sub-list for extension type_name - 77, // [77:77] is the sub-list for extension extendee - 0, // [0:77] is the sub-list for field type_name + 24, // 34: chatto.core.v1.UserConfigSnapshot.server_notification_preferences:type_name -> chatto.core.v1.NotificationPreferenceSnapshot + 25, // 35: chatto.core.v1.UserConfigSnapshot.room_notification_preferences:type_name -> chatto.core.v1.RoomNotificationPreferenceSnapshot + 55, // 36: chatto.core.v1.RoomNotificationLevelSnapshot.level:type_name -> chatto.core.v1.NotificationLevel + 56, // 37: chatto.core.v1.NotificationPreferenceSnapshot.reason:type_name -> chatto.core.v1.NotificationReason + 57, // 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 + 58, // 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 + 45, // 45: chatto.core.v1.AssetProjectionSnapshot.message_owners:type_name -> chatto.core.v1.AssetMessageOwnerSnapshot + 59, // 46: chatto.core.v1.AssetManifestSnapshot.started:type_name -> chatto.core.v1.AssetProcessingStartedEvent + 60, // 47: chatto.core.v1.AssetManifestSnapshot.succeeded:type_name -> chatto.core.v1.AssetProcessingSucceededEvent + 61, // 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 + 62, // 57: chatto.core.v1.MentionablesProjectionSnapshot.user_login_sources:type_name -> chatto.core.v1.Event + 50, // 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 + 50, // 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 + 63, // 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 + 53, // 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 + 64, // 69: chatto.core.v1.ProjectedUserProfileSnapshot.preferences:type_name -> chatto.core.v1.ServerUserPreferences + 46, // 70: chatto.core.v1.ProjectedUserProfileSnapshot.login_changed_at:type_name -> google.protobuf.Timestamp + 65, // 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 + 46, // 73: chatto.core.v1.ProjectedVerifiedEmailSnapshot.verified_at:type_name -> google.protobuf.Timestamp + 42, // 74: chatto.core.v1.RoomTimelineProjectionSnapshot.entries:type_name -> chatto.core.v1.TimelineEntrySnapshot + 43, // 75: chatto.core.v1.RoomTimelineProjectionSnapshot.bodies:type_name -> chatto.core.v1.TimelineBodySnapshot + 44, // 76: chatto.core.v1.RoomTimelineProjectionSnapshot.tombstoned_at:type_name -> chatto.core.v1.StringTimestampSnapshot + 44, // 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 + 62, // 79: chatto.core.v1.TimelineEntrySnapshot.event:type_name -> chatto.core.v1.Event + 66, // 80: chatto.core.v1.TimelineBodySnapshot.body:type_name -> chatto.core.v1.MessageBody + 46, // 81: chatto.core.v1.StringTimestampSnapshot.value:type_name -> google.protobuf.Timestamp + 82, // [82:82] is the sub-list for method output_type + 82, // [82:82] is the sub-list for method input_type + 82, // [82:82] is the sub-list for extension type_name + 82, // [82:82] is the sub-list for extension extendee + 0, // [0:82] is the sub-list for field type_name } func init() { file_chatto_core_v1_projection_snapshots_proto_init() } @@ -3482,6 +3628,7 @@ 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() @@ -3494,7 +3641,7 @@ func file_chatto_core_v1_projection_snapshots_proto_init() { 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: 44, + NumMessages: 46, NumExtensions: 0, NumServices: 0, }, diff --git a/cli/internal/pb/chatto/core/v1/reaction_events.pb.go b/cli/internal/pb/chatto/core/v1/reaction_events.pb.go index 0f9b78e4e..101f4499d 100644 --- a/cli/internal/pb/chatto/core/v1/reaction_events.pb.go +++ b/cli/internal/pb/chatto/core/v1/reaction_events.pb.go @@ -28,9 +28,12 @@ type ReactionAddedEvent struct { // Event ID of the message being reacted to (NanoID) MessageEventId string `protobuf:"bytes,3,opt,name=message_event_id,json=messageEventId,proto3" json:"message_event_id,omitempty"` // The emoji used for the reaction (shortcode name) - Emoji string `protobuf:"bytes,4,opt,name=emoji,proto3" json:"emoji,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Emoji string `protobuf:"bytes,4,opt,name=emoji,proto3" json:"emoji,omitempty"` + // Evaluated recipient/cause provenance. Absent for self-reactions or when + // the recipient's effective reaction policy is Off. + NotificationCandidate *NotificationCandidate `protobuf:"bytes,5,opt,name=notification_candidate,json=notificationCandidate,proto3" json:"notification_candidate,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ReactionAddedEvent) Reset() { @@ -84,6 +87,13 @@ func (x *ReactionAddedEvent) GetEmoji() string { return "" } +func (x *ReactionAddedEvent) GetNotificationCandidate() *NotificationCandidate { + if x != nil { + return x.NotificationCandidate + } + return nil +} + type ReactionRemovedEvent struct { state protoimpl.MessageState `protogen:"open.v1"` // Room ID - identifies the room containing the message @@ -91,9 +101,13 @@ type ReactionRemovedEvent struct { // Event ID of the message the reaction was removed from (NanoID) MessageEventId string `protobuf:"bytes,3,opt,name=message_event_id,json=messageEventId,proto3" json:"message_event_id,omitempty"` // The emoji shortcode that was removed - Emoji string `protobuf:"bytes,4,opt,name=emoji,proto3" json:"emoji,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Emoji string `protobuf:"bytes,4,opt,name=emoji,proto3" json:"emoji,omitempty"` + // Source identity of the corresponding ReactionAddedEvent and its + // notification recipient, if that add produced a candidate. + NotificationSourceEventId string `protobuf:"bytes,5,opt,name=notification_source_event_id,json=notificationSourceEventId,proto3" json:"notification_source_event_id,omitempty"` + NotificationRecipientId string `protobuf:"bytes,6,opt,name=notification_recipient_id,json=notificationRecipientId,proto3" json:"notification_recipient_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ReactionRemovedEvent) Reset() { @@ -147,19 +161,36 @@ func (x *ReactionRemovedEvent) GetEmoji() string { return "" } +func (x *ReactionRemovedEvent) GetNotificationSourceEventId() string { + if x != nil { + return x.NotificationSourceEventId + } + return "" +} + +func (x *ReactionRemovedEvent) GetNotificationRecipientId() string { + if x != nil { + return x.NotificationRecipientId + } + return "" +} + var File_chatto_core_v1_reaction_events_proto protoreflect.FileDescriptor const file_chatto_core_v1_reaction_events_proto_rawDesc = "" + "\n" + - "$chatto/core/v1/reaction_events.proto\x12\x0echatto.core.v1\"}\n" + + "$chatto/core/v1/reaction_events.proto\x12\x0echatto.core.v1\x1a!chatto/core/v1/notification.proto\"\xdb\x01\n" + "\x12ReactionAddedEvent\x12\x17\n" + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12(\n" + "\x10message_event_id\x18\x03 \x01(\tR\x0emessageEventId\x12\x14\n" + - "\x05emoji\x18\x04 \x01(\tR\x05emojiJ\x04\b\x01\x10\x02R\bspace_id\"\x7f\n" + + "\x05emoji\x18\x04 \x01(\tR\x05emoji\x12\\\n" + + "\x16notification_candidate\x18\x05 \x01(\v2%.chatto.core.v1.NotificationCandidateR\x15notificationCandidateJ\x04\b\x01\x10\x02R\bspace_id\"\xfc\x01\n" + "\x14ReactionRemovedEvent\x12\x17\n" + "\aroom_id\x18\x02 \x01(\tR\x06roomId\x12(\n" + "\x10message_event_id\x18\x03 \x01(\tR\x0emessageEventId\x12\x14\n" + - "\x05emoji\x18\x04 \x01(\tR\x05emojiJ\x04\b\x01\x10\x02R\bspace_idB\xb6\x01\n" + + "\x05emoji\x18\x04 \x01(\tR\x05emoji\x12?\n" + + "\x1cnotification_source_event_id\x18\x05 \x01(\tR\x19notificationSourceEventId\x12:\n" + + "\x19notification_recipient_id\x18\x06 \x01(\tR\x17notificationRecipientIdJ\x04\b\x01\x10\x02R\bspace_idB\xb6\x01\n" + "\x12com.chatto.core.v1B\x13ReactionEventsProtoP\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 ( @@ -176,15 +207,17 @@ func file_chatto_core_v1_reaction_events_proto_rawDescGZIP() []byte { var file_chatto_core_v1_reaction_events_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_chatto_core_v1_reaction_events_proto_goTypes = []any{ - (*ReactionAddedEvent)(nil), // 0: chatto.core.v1.ReactionAddedEvent - (*ReactionRemovedEvent)(nil), // 1: chatto.core.v1.ReactionRemovedEvent + (*ReactionAddedEvent)(nil), // 0: chatto.core.v1.ReactionAddedEvent + (*ReactionRemovedEvent)(nil), // 1: chatto.core.v1.ReactionRemovedEvent + (*NotificationCandidate)(nil), // 2: chatto.core.v1.NotificationCandidate } var file_chatto_core_v1_reaction_events_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 2, // 0: chatto.core.v1.ReactionAddedEvent.notification_candidate:type_name -> chatto.core.v1.NotificationCandidate + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_chatto_core_v1_reaction_events_proto_init() } @@ -192,6 +225,7 @@ func file_chatto_core_v1_reaction_events_proto_init() { if File_chatto_core_v1_reaction_events_proto != nil { return } + file_chatto_core_v1_notification_proto_init() type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ diff --git a/cli/internal/pb/chatto/realtime/v1/realtime.pb.go b/cli/internal/pb/chatto/realtime/v1/realtime.pb.go index 2670f5d39..45420c76a 100644 --- a/cli/internal/pb/chatto/realtime/v1/realtime.pb.go +++ b/cli/internal/pb/chatto/realtime/v1/realtime.pb.go @@ -30,6 +30,8 @@ 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. @@ -38,11 +40,15 @@ var ( 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, } ) @@ -1996,7 +2002,9 @@ type RealtimeProjectionNotificationsReplace struct { 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 Inbox groups and unread group count. + Groups *v1.ListNotificationGroupsResponse `protobuf:"bytes,4,opt,name=groups,proto3" json:"groups,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2052,6 +2060,13 @@ func (x *RealtimeProjectionNotificationsReplace) GetChange() *RealtimeProjection return nil } +func (x *RealtimeProjectionNotificationsReplace) GetGroups() *v1.ListNotificationGroupsResponse { + if x != nil { + return x.Groups + } + return nil +} + // One live notification transition accompanying authoritative current state. // // This metadata exists for one-shot presentation effects such as sounds. The @@ -3212,12 +3227,13 @@ 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" + + "\bevent_id\x18\x02 \x01(\tR\aeventId\"\xd6\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" + + "\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_change\"\xb9\x01\n" + "$RealtimeProjectionNotificationChange\x12P\n" + "\x06action\x18\x01 \x01(\x0e28.chatto.realtime.v1.RealtimeProjectionNotificationActionR\x06action\x12'\n" + @@ -3295,11 +3311,13 @@ const file_chatto_realtime_v1_realtime_proto_rawDesc = "" + "\x12_sender_avatar_urlB\x14\n" + "\x12_conversation_name\"8\n" + "\x1eRealtimeSessionTerminatedEvent\x12\x16\n" + - "\x06reason\x18\x01 \x01(\tR\x06reason*\xcb\x01\n" + + "\x06reason\x18\x01 \x01(\tR\x06reason*\xb5\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" + + "1REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED\x10\x02\x123\n" + + "/REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED\x10\x03\x123\n" + + "/REALTIME_PROJECTION_NOTIFICATION_ACTION_DELETED\x10\x04*\xb7\x01\n" + " RealtimeProjectionReactionAction\x123\n" + "/REALTIME_PROJECTION_REACTION_ACTION_UNSPECIFIED\x10\x00\x12-\n" + ")REALTIME_PROJECTION_REACTION_ACTION_ADDED\x10\x01\x12/\n" + @@ -3377,9 +3395,10 @@ var file_chatto_realtime_v1_realtime_proto_goTypes = []any{ (*v1.RoomTimelineIncludes)(nil), // 53: chatto.api.v1.RoomTimelineIncludes (*v1.ListNotificationsResponse)(nil), // 54: chatto.api.v1.ListNotificationsResponse (*v1.RoomNotificationCount)(nil), // 55: chatto.api.v1.RoomNotificationCount - (*v1.RoomViewerState)(nil), // 56: chatto.api.v1.RoomViewerState - (*v1.ActiveCall)(nil), // 57: chatto.api.v1.ActiveCall - (v1.PresenceStatus)(0), // 58: chatto.api.v1.PresenceStatus + (*v1.ListNotificationGroupsResponse)(nil), // 56: chatto.api.v1.ListNotificationGroupsResponse + (*v1.RoomViewerState)(nil), // 57: chatto.api.v1.RoomViewerState + (*v1.ActiveCall)(nil), // 58: chatto.api.v1.ActiveCall + (v1.PresenceStatus)(0), // 59: chatto.api.v1.PresenceStatus } var file_chatto_realtime_v1_realtime_proto_depIdxs = []int32{ 4, // 0: chatto.realtime.v1.RealtimeClientFrame.hello:type_name -> chatto.realtime.v1.RealtimeClientHello @@ -3429,24 +3448,25 @@ var file_chatto_realtime_v1_realtime_proto_depIdxs = []int32{ 54, // 44: chatto.realtime.v1.RealtimeProjectionNotificationsReplace.page:type_name -> chatto.api.v1.ListNotificationsResponse 55, // 45: chatto.realtime.v1.RealtimeProjectionNotificationsReplace.room_counts:type_name -> chatto.api.v1.RoomNotificationCount 26, // 46: chatto.realtime.v1.RealtimeProjectionNotificationsReplace.change:type_name -> chatto.realtime.v1.RealtimeProjectionNotificationChange - 0, // 47: chatto.realtime.v1.RealtimeProjectionNotificationChange.action:type_name -> chatto.realtime.v1.RealtimeProjectionNotificationAction - 56, // 48: chatto.realtime.v1.RealtimeProjectionRoomViewerStateReplace.viewer_state:type_name -> chatto.api.v1.RoomViewerState - 57, // 49: chatto.realtime.v1.RealtimeProjectionActiveCallsReplace.calls:type_name -> chatto.api.v1.ActiveCall - 1, // 50: chatto.realtime.v1.RealtimeProjectionReactionChange.action:type_name -> chatto.realtime.v1.RealtimeProjectionReactionAction - 43, // 51: chatto.realtime.v1.RealtimeHeartbeat.created_at:type_name -> google.protobuf.Timestamp - 43, // 52: chatto.realtime.v1.RealtimeEventEnvelope.created_at:type_name -> google.protobuf.Timestamp - 36, // 53: chatto.realtime.v1.RealtimeEventEnvelope.user_typing:type_name -> chatto.realtime.v1.RealtimeTypingEvent - 37, // 54: chatto.realtime.v1.RealtimeEventEnvelope.presence_changed:type_name -> chatto.realtime.v1.RealtimePresenceChangedEvent - 38, // 55: chatto.realtime.v1.RealtimeEventEnvelope.mention_notification:type_name -> chatto.realtime.v1.RealtimeMentionNotificationEvent - 39, // 56: chatto.realtime.v1.RealtimeEventEnvelope.new_direct_message_notification:type_name -> chatto.realtime.v1.RealtimeNewDirectMessageNotificationEvent - 40, // 57: chatto.realtime.v1.RealtimeEventEnvelope.session_terminated:type_name -> chatto.realtime.v1.RealtimeSessionTerminatedEvent - 58, // 58: chatto.realtime.v1.RealtimePresenceChangedEvent.status:type_name -> chatto.api.v1.PresenceStatus - 58, // 59: chatto.realtime.v1.RealtimeProjectionPresencesReplace.StatusesEntry.value:type_name -> chatto.api.v1.PresenceStatus - 60, // [60:60] is the sub-list for method output_type - 60, // [60:60] is the sub-list for method input_type - 60, // [60:60] is the sub-list for extension type_name - 60, // [60:60] is the sub-list for extension extendee - 0, // [0:60] is the sub-list for field type_name + 56, // 47: chatto.realtime.v1.RealtimeProjectionNotificationsReplace.groups:type_name -> chatto.api.v1.ListNotificationGroupsResponse + 0, // 48: chatto.realtime.v1.RealtimeProjectionNotificationChange.action:type_name -> chatto.realtime.v1.RealtimeProjectionNotificationAction + 57, // 49: chatto.realtime.v1.RealtimeProjectionRoomViewerStateReplace.viewer_state:type_name -> chatto.api.v1.RoomViewerState + 58, // 50: chatto.realtime.v1.RealtimeProjectionActiveCallsReplace.calls:type_name -> chatto.api.v1.ActiveCall + 1, // 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 + 36, // 54: chatto.realtime.v1.RealtimeEventEnvelope.user_typing:type_name -> chatto.realtime.v1.RealtimeTypingEvent + 37, // 55: chatto.realtime.v1.RealtimeEventEnvelope.presence_changed:type_name -> chatto.realtime.v1.RealtimePresenceChangedEvent + 38, // 56: chatto.realtime.v1.RealtimeEventEnvelope.mention_notification:type_name -> chatto.realtime.v1.RealtimeMentionNotificationEvent + 39, // 57: chatto.realtime.v1.RealtimeEventEnvelope.new_direct_message_notification:type_name -> chatto.realtime.v1.RealtimeNewDirectMessageNotificationEvent + 40, // 58: chatto.realtime.v1.RealtimeEventEnvelope.session_terminated:type_name -> chatto.realtime.v1.RealtimeSessionTerminatedEvent + 59, // 59: chatto.realtime.v1.RealtimePresenceChangedEvent.status:type_name -> chatto.api.v1.PresenceStatus + 59, // 60: chatto.realtime.v1.RealtimeProjectionPresencesReplace.StatusesEntry.value:type_name -> chatto.api.v1.PresenceStatus + 61, // [61:61] is the sub-list for method output_type + 61, // [61:61] is the sub-list for method input_type + 61, // [61:61] is the sub-list for extension type_name + 61, // [61:61] is the sub-list for extension extendee + 0, // [0:61] is the sub-list for field type_name } func init() { file_chatto_realtime_v1_realtime_proto_init() } diff --git a/docs/GLOSSARY.md b/docs/GLOSSARY.md index 069d4db6b..1fefc6efc 100644 --- a/docs/GLOSSARY.md +++ b/docs/GLOSSARY.md @@ -64,6 +64,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 inbox attention created for activity such as a DM, reply, mention, followed conversation, or reaction. Notifications can be read, moved to Done, saved, or deleted independently of room read state. See [FDR-012](fdr/FDR-012-notifications.md). + +**Notification Group** — Inbox row that combines related notification occurrences by conversation or target while retaining their exact underlying activity. See [ADR-070](adr/ADR-070-triageable-notification-inbox.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). @@ -134,6 +140,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-069](adr/ADR-069-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-069-deterministic-notification-occurrences.md b/docs/adr/ADR-069-deterministic-notification-occurrences.md new file mode 100644 index 000000000..ba2a4e6c7 --- /dev/null +++ b/docs/adr/ADR-069-deterministic-notification-occurrences.md @@ -0,0 +1,226 @@ +# ADR-069: 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 inbox 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. + +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 source-bound decisions + +The source command evaluates notification policy against its authoritative +projections and writes the complete recipient/reason/intensity decision into +the same durable `EVT` fact as the source activity. This boundary is important: +the asynchronous materializer never re-evaluates a later preference, follow, +membership, or presence state for an older activity. + +Source-bound decision evaluation is part of committing the source command. If +recipient discovery or policy evaluation cannot complete, the command fails +before appending the source fact; it must not commit an ambiguous "nobody" +decision. Once the source fact is committed, occurrence materialization and +delivery are recoverable best-effort effects and cannot roll back the source +action. + +A domain-owned ordered incremental effect consumer discovers +notification-relevant facts in stream order and retries failed idempotent +effects. Its cursor and failed-work queue are process-local; a new process +safely replays matching history from the beginning. A failed effect blocks +later notification effects on that process until it succeeds, preserving +causal order when a later retraction, reaction removal, membership loss, or +account deletion supersedes earlier creation. Prompt request-path attempts +still provide low latency for newly committed sources. + +After committing a message or another source fact, its request path makes one +prompt best-effort materialization attempt for low latency. Failure does not +roll back the already committed source action; the background consumer +rediscovers the work after crashes and replica turnover. More than one replica +may replay or briefly overlap the same work. Deterministic KV creation makes +that overlap safe, and only the replica that establishes or successfully +claims the occurrence may initiate its alert delivery. + +Source facts must contain enough immutable provenance to reproduce the +recipient and reason decision without later policy evaluation. In particular, a message event must distinguish +direct-user, role, `@here`, and `@all` matches instead of exposing only one +combined mentioned-user list. Any eligibility that depends on transient state, +such as who counted as present for `@here`, is resolved when the source fact is +created and retained as durable provenance. Ordered preference, membership, +and thread-follow facts may be applied by the notification subsystem as it +advances through the stream. + +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, source kind, actor ID, and source time; +- exact destination: room, optional thread root, and target event; +- all matched reasons and their evaluated intensities; +- strongest effective intensity and policy-evaluation time; +- inbox state, saved 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. + +### Read-state and lifecycle convergence + +Inbox state is distinct from room and thread read cursors. When an occurrence +is first derived, the notification subsystem compares its exact target with the +authoritative read cursor. Covered activity starts as read; newer activity +starts as unread. Read-cursor advancement also transitions covered existing +occurrences from unread to read. Both orders therefore converge without +deleting notification history. + +User triage mutations, read reconciliation, retraction, reaction removal, and +visibility changes all use KV OCC. 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 purges the recipient's records, and replay skips candidates whose +recipient account no longer exists. A room-leave or member-removal fact removes +only occurrences whose source time is at or before that lifecycle fact; replay +of an old leave therefore cannot delete activity created after a later rejoin. + +Notification policy changes affect future source activity. They do not rewrite +or erase existing inbox history; users triage existing 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 unread, saving it, moving it to Done, 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, and realtime replacement assembly use the index instead of scanning a +KV prefix per request or connection. 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 or Done silences any pending or claimed Alert. +Workers verify the exact claim immediately before delivery and revalidate +current target visibility before hydration and again before sending. Failed +delivery remains claimed until a bounded retry delay, avoiding a hot loop. The +worker renews the exact claim for a delivery-sized interval immediately before +calling the provider. 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 additive persisted protobuf rather than breaking +the existing `chatto.core.v1.Notification` storage contract. Existing legacy +notification records are neither migrated nor read by Notifications 2.0. The +cutover starts with an empty 2.0 inbox; legacy records remain inert until their +existing retention removes them. This is an intentional pre-1.0 product reset, +not a period in which two notification stores remain authoritative. + +The public +`chatto.api.v1` surface grows additive inbox resources and mutations. The old +notification RPCs remain mounted for wire compatibility but do not translate +or expose 2.0 occurrences. The old coarse preference API remains as a cheap +deprecated preset layer: Muted resolves every cause Off, Normal selects product +defaults, and All Messages selects product defaults plus followed-room Alert; +an explicit 2.0 cause value at the same scope wins. The bundled client switches +to the new resources as one release boundary. An older client connected to an +upgraded server therefore sees an empty legacy notification centre, and a new +client requires a server version that advertises Notifications 2.0 support. +This explicitly accepts the user's pre-1.0 clean-cutover direction and avoids +a second compatibility projection for notification records. + +Every upgraded replica writes and reads only the 2.0 key family. 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 +inbox view; 2.0 state remains isolated and is not interpreted by that binary. + +## Consequences + +- Source commands fail before commit when their durable notification decision + cannot be evaluated; after commit, notification processing is recoverable + and cannot make the source action fail retroactively. +- 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 lag, retry-queue, and health + observability; a persistent failed effect stalls that process's replay, and + restarts trade a durable checkpoint for safe full ordered replay. +- 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 inbox and avoids a dual-read migration path. diff --git a/docs/adr/ADR-070-triageable-notification-inbox.md b/docs/adr/ADR-070-triageable-notification-inbox.md new file mode 100644 index 000000000..02979b3e4 --- /dev/null +++ b/docs/adr/ADR-070-triageable-notification-inbox.md @@ -0,0 +1,162 @@ +# ADR-070: Model Notifications as a Triageable Inbox with Derived Groups + +**Date:** 2026-08-10 + +## Context + +Chatto currently equates a notification record with pending unread attention. +Reading covered activity or dismissing the notification deletes the record. +Users therefore cannot keep a read notification for later, dismiss one without +opening its message, review dismissed history, or separate inbox organization +from room read state. + +[GitHub's notification inbox](https://docs.github.com/en/subscriptions-and-notifications/how-tos/viewing-and-triaging-notifications/managing-notifications-from-your-inbox) +provides a useful interaction model: notifications can be read or unread, +moved to Done, saved, and unsubscribed without treating all of those actions as +content reads. Chatto also needs to group related occurrences, such as several +DM messages, thread replies, or reactions to one message, without letting +mutable group records become another source of truth. + +## Decision + +### Inbox state and views + +Each visible notification occurrence has exactly one inbox state: + +- **Unread** — in Inbox and counted as new attention; +- **Read** — still in Inbox, but not counted as new attention; or +- **Done** — removed from Inbox and visible in Done until expiry. + +Saving is an independent boolean. Saved items appear in the Saved view whether +they are in Inbox or Done. Saving is a retrieval aid, not indefinite retention; +the absolute 90-day lifetime from ADR-069 still applies. + +Deleting an occurrence is distinct from Done. Delete removes it from every +user-visible view and leaves only the minimal anti-recreation tombstone until +the original expiry. Normal triage should prefer Done; Delete is the explicit +request to discard history. + +### User actions + +The public behavior follows these rules: + +- Opening a notification navigates to its exact room, thread, and event and + marks the occurrence read. The room or thread read cursor advances only once + that target is actually displayed as read. +- Mark Read and Mark Unread change inbox state without changing a room or + thread read cursor. +- Reading a room or thread marks covered notification occurrences read, but + does not move them to Done or delete them. +- Done can be applied directly from Inbox without opening or otherwise handling + the source activity. +- Moving a Done item back to Inbox restores it as read by default; the user may + then mark it unread. +- Save and Unsave do not change Inbox/Done state and do not extend expiry. +- Unsubscribe moves the current group to Done and changes the relevant ambient + conversation subscription for future activity. Direct-attention reasons, + such as a direct mention or reply to the user, can still create a later + occurrence unless separately disabled by policy. + +These mutations are server-owned and synchronized across every session. +Single-occurrence mutations are idempotent. Group mutations capture the +members present when the server handles the request and are deliberately not +advertised as idempotent: a later occurrence may reuse the same derived group +ID, so clients must not automatically retry an ambiguous group mutation. +They are exposed as resource-oriented public API operations; the bundled UI is +not the API's only intended consumer. + +### 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; +- an ungroupable future cause falls back to its source occurrence. + +A group exposes its current member occurrence IDs, 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 all members are read. + +Group list responses contain a bounded newest-member preview, always including +the open occurrence, plus total count and aggregate action state. Exact members +are available through a separately paginated occurrence list. This bounds both +ConnectRPC pages and realtime replacement frames even when one busy room, DM, +or thread has thousands of retained occurrences. Clients render the first +group page immediately and automatically append later pages as the trailing +sentinel becomes visible; broad realtime invalidations never eagerly download +an entire 90-day view. + +Groups are assembled within a view. Inbox membership includes only Unread and +Read occurrences, Done membership includes only Done occurrences, and Saved +membership includes saved occurrences regardless of inbox state. The same +stable grouping target may therefore have an Inbox row for new activity and a +Done row for older history at the same time. + +Group actions operate on the occurrences that are members at the mutation's +authoritative boundary. Moving a group to Done does not create a permanent +group-level dismissal: later activity creates a new occurrence and makes the +derived group appear in Inbox again. This prevents a race from silently +dismissing activity that arrived after the user's action. Group mutations +return a bounded affected-count acknowledgement and publish one coalesced +realtime invalidation after their ordered member writes; they never return or +broadcast the full member set. + +The bell shows the number of unread groups. A group row may also show its +occurrence count. APIs expose explicit group and occurrence counts so clients +do not have to infer whether a number represents grouped conversations or raw +activity. Room and installed-app indicators are assembled from the same unread +occurrences and grouping rules. + +### Read state, policy, and triage remain separate + +Room/thread read cursors describe content consumption. Notification policy +decides whether new activity creates an occurrence and whether it is eligible +to interrupt. Inbox state organizes the resulting occurrences. None of these +three concepts stands in 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 +the per-cause Notifications 2.0 model. + +### Reconciliation and visibility + +Inbox groups and counts are finite authoritative state in the server-scoped +client projection. Reconnect and reset replace them from the current +notification index. Live operations may optimize a single transition but do +not define correctness. Realtime changes invalidate all three views so an open +Done or Saved view refetches across sessions. Responses and realtime Inbox +replacements expose the next Inbox expiry boundary, while each group exposes +its own earliest expiry, so continuously connected clients refresh every open +view even when KV TTL removal itself produces no live watcher transition. +One room/thread read-through may update many occurrences, but publishes one +revision-fenced invalidation after its ordered writes. + +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 from all views and groups. 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 moves them to Done, + deletes them, or they expire. +- Users can clear Inbox directly without pretending they opened or read the + source content. +- Done supplies dismissible history, while Delete has a clear privacy and + anti-recreation meaning. +- Derived groups reduce noise without introducing mutable group records that + can drift from their member occurrences. +- Group mutations require an authoritative boundary so concurrent later + occurrences are not accidentally included. +- Read cursors, delivery policy, and inbox organization can evolve without + overloading one another's semantics. +- Saved is intentionally bounded to 90 days, unlike GitHub's indefinite saved + retention. diff --git a/docs/adr/INDEX.md b/docs/adr/INDEX.md index b8e8d8632..82cd6cda5 100644 --- a/docs/adr/INDEX.md +++ b/docs/adr/INDEX.md @@ -80,3 +80,5 @@ replace part of their original design. | [ADR-066](ADR-066-durable-asset-processing-runtime-unit.md) | Durable Asset Processing as a Runtime Unit | Accepted | 2026-08-08 | | [ADR-067](ADR-067-electron-desktop-client.md) | Package Chatto Desktop with Electron | Accepted | 2026-08-08 | | [ADR-068](ADR-068-selectable-event-mutation-consistency-boundaries.md) | Select Event Mutation Consistency Boundaries Explicitly | Accepted | 2026-08-10 | +| [ADR-069](ADR-069-deterministic-notification-occurrences.md) | Derive Deterministic Notification Occurrences into Runtime State | Accepted | 2026-08-10 | +| [ADR-070](ADR-070-triageable-notification-inbox.md) | Model Notifications as a Triageable Inbox with Derived Groups | Accepted | 2026-08-10 | diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index f757aac90..1666e6dcc 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -31,7 +31,7 @@ use separate domain-owned consumers. | 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 | `UserKeyShreddedEvent` tells projections to tombstone encrypted user content | Content keys and wrapping keys are irreversibly shredded before the event is appended | If event append fails after shredding, a retry finds no remaining key and does not currently recreate the missing tombstone fact | Irreversible pre-commit effect with a durable-signal gap | | 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 | Message/reaction source facts carry source-bound recipient, cause, and evaluated intensity decisions; lifecycle facts identify retraction, reaction removal, and visibility loss. Decision-evaluation failure aborts the source command before commit | The committing path promptly attempts deterministic KV materialization; a process-local ordered incremental consumer rediscovers facts and retries idempotent effects without letting later lifecycle facts overtake failed creation. Replay treats a currently absent historical room as a terminal no-op. Committed unread Alert occurrences are OCC-leased before Web Push; Read/Done cancels pending delivery, failed claims wait 30 seconds, and delivery renews its exact lease and revalidates current visibility | Every replica may full-replay; recipient/source KV identity and tombstones make overlap safe. Delayed message/reaction creates also consult current monotonic retraction/removal state, and lifecycle cleanup is source-time bounded. Claims prevent concurrent replica delivery; any-device acceptance completes the occurrence, 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; a persistent non-terminal failed fact stalls one process's ordered replay and pending state is 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 | Observability is currently domain-specific. Call reconciliation records its @@ -51,7 +51,8 @@ pre-queue backfill, exact-event confirmation after ambiguous terminal publication, terminal manifest races, and bounded prompt cleanup of failed generations; message-body cleanup covers immediate secure deletion after edits and -retractions. Notification derivation, branding cleanup, the message-body boot +retractions. Notification occurrence tests cover replay-safe identity, OCC, +read/materialization ordering, tombstones, and alert-claim retry. Branding cleanup, the message-body boot sweep, and the user-key shred/event boundary do not have equivalent crash-and-recovery coverage. The call-key, user-DEK, and asset-creation compensation paths likewise lack durable @@ -60,7 +61,7 @@ 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/nats-resources.md b/docs/architecture/nats-resources.md index 68fcb64fc..cf6c687c9 100644 --- a/docs/architecture/nats-resources.md +++ b/docs/architecture/nats-resources.md @@ -16,7 +16,7 @@ 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 | `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 | diff --git a/docs/architecture/projections.md b/docs/architecture/projections.md index 71698bae1..84e1150ed 100644 --- a/docs/architecture/projections.md +++ b/docs/architecture/projections.md @@ -247,7 +247,7 @@ reconstruction. Legacy cohort paths remain outside application S3 expiry. | 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_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, legacy notification levels, per-cause server/room notification intensities, blocked usernames | | 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 | | Content keys | Content Keys | `evt.user.*.dek_generated`, `evt.user.*.user_key_shredded` | `UserModel`; active and historical user DEK epochs, legacy-purpose fallback, and key references used by crypto-shredding | diff --git a/docs/architecture/realtime-delivery.md b/docs/architecture/realtime-delivery.md index b38189ce1..8c5848da4 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 Inbox groups, unread-group count, and complete per-room unread-occurrence counts (plus the inert legacy page shape during the clean cutover); - 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 Inbox groups and room counts; and the server directory's current presence. Missing followed-thread entries authoritatively clear follow/unread state on retained thread roots. @@ -358,14 +358,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 complete-Inbox 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-inbox 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 bb7d730ae..ea77786b9 100644 --- a/docs/architecture/runtime-components.md +++ b/docs/architecture/runtime-components.md @@ -56,6 +56,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 | | `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 | +| `NotificationOccurrenceModel` / `NotificationMaterializer` | [`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_policy.go`](../../cli/internal/core/notification_policy.go) | Deterministic recipient/source occurrence ownership, one process-wide KV index, per-cause policy, lifecycle OCC, replayable EVT materialization, 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, 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 57bd74840..7801f5bfe 100644 --- a/docs/architecture/runtime-state.md +++ b/docs/architecture/runtime-state.md @@ -13,7 +13,7 @@ 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 | +| `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` | @@ -59,6 +59,7 @@ 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, Inbox/Done and Saved state, and leased Alert delivery. `Create` plus revision OCC is safe across replicas; every rewrite preserves the absolute source-time-plus-90-days expiry. One filtered watcher per process supplies authoritative indexed reads and realtime replacements, 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. | | `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 199fef532..4c8cfd382 100644 --- a/docs/architecture/subjects-and-events.md +++ b/docs/architecture/subjects-and-events.md @@ -251,6 +251,10 @@ cursors are trusted integration coordinates and are not public API cursors. | `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_preference_set` | `UserServerNotificationPreferenceSetEvent` | +| `evt.config.{subject}.user_server_notification_preference_cleared` | `UserServerNotificationPreferenceClearedEvent` | +| `evt.config.{subject}.user_room_notification_preference_set` | `UserRoomNotificationPreferenceSetEvent` | +| `evt.config.{subject}.user_room_notification_preference_cleared` | `UserRoomNotificationPreferenceClearedEvent` | | `evt.group.{groupId}.group_created` | `RoomGroupCreatedEvent` | | `evt.group.{groupId}.group_updated` | `RoomGroupUpdatedEvent` | | `evt.group.{groupId}.group_deleted` | `RoomGroupDeletedEvent` | @@ -345,6 +349,7 @@ Patterns: `live.sync.>` for transient `LiveEvent` pubsub and `live.evt.>` for ra | `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-002-replies-and-threads.md b/docs/fdr/FDR-002-replies-and-threads.md index 8320b5b75..48ff261f0 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-069 (deterministic notification occurrences), ADR-070 (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..7fec98ab6 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-069 (deterministic notification occurrences), ADR-070 (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..7712cecb4 100644 --- a/docs/fdr/FDR-006-mentions.md +++ b/docs/fdr/FDR-006-mentions.md @@ -1,7 +1,7 @@ # FDR-006: @Mentions **Status:** Active -**Last reviewed:** 2026-07-04 +**Last reviewed:** 2026-08-10 ## Overview @@ -98,5 +98,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-069 (deterministic notification occurrences), ADR-070 (triageable notification inbox) - **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..046cbfefb 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-069 (deterministic notification occurrences), ADR-070 (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..112cb28c7 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -1,112 +1,222 @@ # FDR-012: Notifications -**Status:** Active -**Last reviewed:** 2026-07-20 +**Status:** Experimental +**Last reviewed:** 2026-08-10 + +> **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 inbox for activity that deserves +attention. They cover direct messages, replies, mentions, followed +conversations, reactions, and future attention causes. The inbox is modeled +after GitHub notifications: users can keep read items, dismiss them to Done +without opening them, save them for easy retrieval, or delete them. 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. +- Inbox contains both Unread and Read notification groups. Done contains groups + the user dismissed, and Saved contains saved items from either state. +- 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 and Mark Unread organize the notification inbox without changing + the room or thread read cursor. +- Reading a room or thread marks covered notifications read. It does not remove + them from Inbox. +- Done removes a notification or group from Inbox without requiring the user to + open or otherwise handle its source activity. It remains reviewable in Done. +- A Done item can return to Inbox. Delete removes it from every visible view. +- Save is independent of Inbox and Done. Saved notifications remain easy to + find, but still expire on the normal schedule. +- Notifications expire 90 days after their source activity. Read, Done, Save, + and other updates 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 in Inbox again while its earlier items remain in Done. +- A group opens its newest unread occurrence, or its newest occurrence when all + members are read. Individual occurrences retain exact destinations. +- The bell count is the number of unread groups. Group rows may show how many + occurrences they contain. +- Retraction, reaction removal, lost room visibility, and account deletion + remove notifications that the user can no longer act on or view. +- Inbox 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 inbox/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 invitation, once supported | Alert | + +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 inbox +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. +- Unsubscribe from a notification moves the current group to Done and disables + that ambient conversation subscription for future activity. +- Unsubscribe does not block future direct-attention causes such as a direct + mention or a reply to the user. Those causes have their own policy controls. ## 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 triageable inbox replaces delete-on-read pending alerts + +**Decision:** Notification occurrences have Unread, Read, or Done inbox state; +Save is orthogonal and Delete is explicit. +**Why:** Users need to review a read notification later and dismiss noise +without pretending they opened it. This follows the useful parts of GitHub's +Inbox/Done/Saved model. See ADR-070. +**Tradeoff:** The inbox contains more state and actions than a delete-only list, +and Done/Saved views need their own empty, pagination, and bulk-action behavior. + +### 2. Content read state and inbox triage 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 Inbox cleanup. See ADR-028 and ADR-070. +**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-069. +**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. +**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-070. +**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. +**Why:** Grouping should reduce inbox noise without creating a second lifecycle +that can drift from exact targets. A group-level action updates the members +present at its authoritative boundary; later activity remains new. See ADR-070. +**Tradeoff:** Group assembly and group actions 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; their responses are 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-069. +**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, including Saved and Done items, expires 90 days +after the source activity. Mutations do not reset the clock. +**Why:** The inbox 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-069. +**Tradeoff:** Unlike GitHub, Saved does not preserve a notification +indefinitely. + +### 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-069 and FDR-013. +**Tradeoff:** Delivery waits for durable occurrence creation and may be delayed +while the notification worker catches up. + +### 10. Preferences affect future activity only + +**Decision:** Changing a cause intensity or subscription does not retroactively +rewrite existing occurrences. Unsubscribe explicitly moves the current group +to Done as part of that action. +**Why:** Existing notifications explain decisions made when their activity +occurred. Silent retroactive cleanup would make inbox history unpredictable. +**Tradeoff:** After turning a cause Off, users may still need to triage older +items from that cause. ## 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 inbox triage 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-069 (deterministic notification occurrences), ADR-070 (triageable notification inbox) +- **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..caf799a4d 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-10 ## Overview @@ -17,7 +17,10 @@ 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. - Clicking a push notification navigates to the relevant room, thread, or DM. @@ -103,4 +106,5 @@ No Chatto-side permission gates push. The OS and browser permissions are the onl ## Related +- **ADRs:** ADR-069 (deterministic notification occurrences), ADR-070 (triageable notification inbox) - **FDRs:** FDR-006 (@Mentions), FDR-012 (Notifications) diff --git a/docs/fdr/INDEX.md b/docs/fdr/INDEX.md index a7ecce90b..5d93cfb1c 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-10 | +| [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-08 | | [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-10 | +| [FDR-013](FDR-013-web-push-notifications.md) | Web Push Notifications | Active | 2026-08-10 | | [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-07-15 | 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..43547cea5 100644 --- a/packages/api-types/src/chatto/api/v1/notifications_connect.ts +++ b/packages/api-types/src/chatto/api/v1/notifications_connect.ts @@ -3,7 +3,7 @@ /* 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 { BatchGetNotificationsRequest, BatchGetNotificationsResponse, DeleteNotificationGroupRequest, DeleteNotificationGroupResponse, DeleteNotificationOccurrenceRequest, DeleteNotificationOccurrenceResponse, DismissAllNotificationsRequest, DismissAllNotificationsResponse, DismissNotificationRequest, DismissNotificationResponse, GetNotificationOccurrenceRequest, GetNotificationOccurrenceResponse, GetNotificationPolicyRequest, GetNotificationPolicyResponse, GetNotificationRequest, GetNotificationResponse, HasNotificationsRequest, HasNotificationsResponse, ListNotificationGroupsRequest, ListNotificationGroupsResponse, ListNotificationOccurrencesRequest, ListNotificationOccurrencesResponse, ListNotificationsRequest, ListNotificationsResponse, ListRoomNotificationCountsRequest, ListRoomNotificationCountsResponse, ListRoomNotificationsRequest, ListRoomNotificationsResponse, SetNotificationPolicyPreferenceRequest, SetNotificationPolicyPreferenceResponse, UnsubscribeNotificationGroupRequest, UnsubscribeNotificationGroupResponse, UpdateNotificationGroupRequest, UpdateNotificationGroupResponse, UpdateNotificationOccurrenceRequest, UpdateNotificationOccurrenceResponse } from "./notifications_pb.js"; import { MethodIdempotency, MethodKind } from "@bufbuild/protobuf"; /** @@ -14,6 +14,128 @@ import { MethodIdempotency, MethodKind } from "@bufbuild/protobuf"; export const NotificationService = { typeName: "chatto.api.v1.NotificationService", methods: { + /** + * Lists the Notifications 2.0 Inbox, Done, or Saved groups. + * + * @generated from rpc chatto.api.v1.NotificationService.ListNotificationGroups + */ + listNotificationGroups: { + name: "ListNotificationGroups", + I: ListNotificationGroupsRequest, + O: ListNotificationGroupsResponse, + kind: MethodKind.Unary, + }, + /** + * Lists exact members of one derived notification group. + * + * @generated from rpc chatto.api.v1.NotificationService.ListNotificationOccurrences + */ + listNotificationOccurrences: { + name: "ListNotificationOccurrences", + I: ListNotificationOccurrencesRequest, + O: ListNotificationOccurrencesResponse, + kind: MethodKind.Unary, + }, + /** + * Gets one visible occurrence. Returns NOT_FOUND when absent or inaccessible. + * + * @generated from rpc chatto.api.v1.NotificationService.GetNotificationOccurrence + */ + getNotificationOccurrence: { + name: "GetNotificationOccurrence", + I: GetNotificationOccurrenceRequest, + O: GetNotificationOccurrenceResponse, + kind: MethodKind.Unary, + }, + /** + * Patches one occurrence's inbox and Saved state. + * + * @generated from rpc chatto.api.v1.NotificationService.UpdateNotificationOccurrence + */ + updateNotificationOccurrence: { + name: "UpdateNotificationOccurrence", + I: UpdateNotificationOccurrenceRequest, + O: UpdateNotificationOccurrenceResponse, + kind: MethodKind.Unary, + idempotency: MethodIdempotency.Idempotent, + }, + /** + * Permanently deletes one occurrence from every notification view. + * + * @generated from rpc chatto.api.v1.NotificationService.DeleteNotificationOccurrence + */ + deleteNotificationOccurrence: { + name: "DeleteNotificationOccurrence", + I: DeleteNotificationOccurrenceRequest, + O: DeleteNotificationOccurrenceResponse, + kind: MethodKind.Unary, + idempotency: MethodIdempotency.Idempotent, + }, + /** + * Patches 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.UpdateNotificationGroup + */ + updateNotificationGroup: { + name: "UpdateNotificationGroup", + I: UpdateNotificationGroupRequest, + O: UpdateNotificationGroupResponse, + kind: MethodKind.Unary, + }, + /** + * 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.DeleteNotificationGroup + */ + deleteNotificationGroup: { + name: "DeleteNotificationGroup", + I: DeleteNotificationGroupRequest, + O: DeleteNotificationGroupResponse, + kind: MethodKind.Unary, + }, + /** + * Disables an ambient thread/room source and moves its current group to Done. + * 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.UnsubscribeNotificationGroup + */ + unsubscribeNotificationGroup: { + name: "UnsubscribeNotificationGroup", + I: UnsubscribeNotificationGroupRequest, + O: UnsubscribeNotificationGroupResponse, + kind: MethodKind.Unary, + }, + /** + * Gets every supported cause and its effective inherited delivery intensity. + * + * @generated from rpc chatto.api.v1.NotificationService.GetNotificationPolicy + */ + getNotificationPolicy: { + name: "GetNotificationPolicy", + I: GetNotificationPolicyRequest, + O: GetNotificationPolicyResponse, + kind: MethodKind.Unary, + }, + /** + * Sets or clears one server- or room-scoped cause override. + * + * @generated from rpc chatto.api.v1.NotificationService.SetNotificationPolicyPreference + */ + setNotificationPolicyPreference: { + name: "SetNotificationPolicyPreference", + I: SetNotificationPolicyPreferenceRequest, + O: SetNotificationPolicyPreferenceResponse, + kind: MethodKind.Unary, + idempotency: MethodIdempotency.Idempotent, + }, /** * Lists the authenticated viewer's pending notifications. * 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..fc11c1f87 100644 --- a/packages/api-types/src/chatto/api/v1/notifications_pb.ts +++ b/packages/api-types/src/chatto/api/v1/notifications_pb.ts @@ -9,6 +9,230 @@ import { RoomSummary } from "./rooms_pb.js"; import { User } from "./users_pb.js"; import { PageInfo, PageRequest } from "./pagination_pb.js"; +/** + * Why source activity matched the authenticated viewer's notification policy. + * + * @generated from enum chatto.api.v1.NotificationReason + */ +export enum NotificationReason { + /** + * No cause was specified. This value is not valid in preference writes. + * + * @generated from enum value: NOTIFICATION_REASON_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * A message was posted in a direct-message conversation. + * + * @generated from enum value: NOTIFICATION_REASON_DIRECT_MESSAGE = 1; + */ + DIRECT_MESSAGE = 1, + + /** + * The viewer's username was mentioned directly. + * + * @generated from enum value: NOTIFICATION_REASON_DIRECT_MENTION = 2; + */ + DIRECT_MENTION = 2, + + /** + * Activity replied directly to the viewer's message. + * + * @generated from enum value: NOTIFICATION_REASON_REPLY = 3; + */ + REPLY = 3, + + /** + * A role held by the viewer was mentioned. + * + * @generated from enum value: NOTIFICATION_REASON_ROLE_MENTION = 4; + */ + ROLE_MENTION = 4, + + /** + * An `@here` mention included the viewer. + * + * @generated from enum value: NOTIFICATION_REASON_HERE = 5; + */ + HERE = 5, + + /** + * An `@all` mention included the viewer. + * + * @generated from enum value: NOTIFICATION_REASON_ALL = 6; + */ + ALL = 6, + + /** + * 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" }, +]); + +/** + * Delivery strength for one notification cause. + * + * @generated from enum chatto.api.v1.NotificationDeliveryIntensity + */ +export enum NotificationDeliveryIntensity { + /** + * In preference writes, unspecified clears the override (Inherit). + * + * @generated from enum value: NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Matching activity does not create a notification occurrence. + * + * @generated from enum value: NOTIFICATION_DELIVERY_INTENSITY_OFF = 1; + */ + OFF = 1, + + /** + * Matching activity appears in the inbox without interruptive delivery. + * + * @generated from enum value: NOTIFICATION_DELIVERY_INTENSITY_BADGE = 2; + */ + BADGE = 2, + + /** + * Matching activity appears in the inbox 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" }, +]); + +/** + * User-controlled triage state for one notification occurrence. + * + * @generated from enum chatto.api.v1.NotificationInboxState + */ +export enum NotificationInboxState { + /** + * No inbox state was specified. + * + * @generated from enum value: NOTIFICATION_INBOX_STATE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * The occurrence is in Inbox and contributes unread attention. + * + * @generated from enum value: NOTIFICATION_INBOX_STATE_UNREAD = 1; + */ + UNREAD = 1, + + /** + * The occurrence remains in Inbox without contributing unread attention. + * + * @generated from enum value: NOTIFICATION_INBOX_STATE_READ = 2; + */ + READ = 2, + + /** + * The occurrence is removed from Inbox and retained in Done. + * + * @generated from enum value: NOTIFICATION_INBOX_STATE_DONE = 3; + */ + DONE = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(NotificationInboxState) +proto3.util.setEnumType(NotificationInboxState, "chatto.api.v1.NotificationInboxState", [ + { no: 0, name: "NOTIFICATION_INBOX_STATE_UNSPECIFIED" }, + { no: 1, name: "NOTIFICATION_INBOX_STATE_UNREAD" }, + { no: 2, name: "NOTIFICATION_INBOX_STATE_READ" }, + { no: 3, name: "NOTIFICATION_INBOX_STATE_DONE" }, +]); + +/** + * Selects one derived notification-inbox view. + * + * @generated from enum chatto.api.v1.NotificationView + */ +export enum NotificationView { + /** + * Defaults to Inbox on reads and mutations. + * + * @generated from enum value: NOTIFICATION_VIEW_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * Unread and read occurrences that have not been moved to Done. + * + * @generated from enum value: NOTIFICATION_VIEW_INBOX = 1; + */ + INBOX = 1, + + /** + * Occurrences moved out of Inbox. + * + * @generated from enum value: NOTIFICATION_VIEW_DONE = 2; + */ + DONE = 2, + + /** + * Saved occurrences from either Inbox or Done. + * + * @generated from enum value: NOTIFICATION_VIEW_SAVED = 3; + */ + SAVED = 3, +} +// Retrieve enum metadata with: proto3.getEnumType(NotificationView) +proto3.util.setEnumType(NotificationView, "chatto.api.v1.NotificationView", [ + { no: 0, name: "NOTIFICATION_VIEW_UNSPECIFIED" }, + { no: 1, name: "NOTIFICATION_VIEW_INBOX" }, + { no: 2, name: "NOTIFICATION_VIEW_DONE" }, + { no: 3, name: "NOTIFICATION_VIEW_SAVED" }, +]); + /** * Direct-message notification payload. * @@ -1033,3 +1257,1396 @@ export class DismissAllNotificationsResponse 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.NotificationReasonMatch"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { 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): NotificationReasonMatch { + return new NotificationReasonMatch().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationReasonMatch { + return new NotificationReasonMatch().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): NotificationReasonMatch { + return new NotificationReasonMatch().fromJsonString(jsonString, options); + } + + static equals(a: NotificationReasonMatch | PlainMessage | undefined, b: NotificationReasonMatch | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationReasonMatch, a, b); + } +} + +/** + * Exact visible destination of one notification occurrence. + * + * @generated from message chatto.api.v1.NotificationTarget + */ +export class NotificationTarget extends Message { + /** + * Room containing the source activity. + * + * @generated from field: chatto.api.v1.RoomSummary room = 1; + */ + room?: RoomSummary; + + /** + * Exact source or reacted-to message event to reveal. + * + * @generated from field: string event_id = 2; + */ + eventId = ""; + + /** + * Thread root when the target is inside a thread. + * + * @generated from field: optional string thread_root_event_id = 3; + */ + threadRootEventId?: string; + + /** + * Direct reply target when the occurrence was caused by a reply. + * + * @generated from field: optional string parent_event_id = 4; + */ + parentEventId?: string; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + 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: "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): NotificationTarget { + return new NotificationTarget().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationTarget { + return new NotificationTarget().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): NotificationTarget { + return new NotificationTarget().fromJsonString(jsonString, options); + } + + static equals(a: NotificationTarget | PlainMessage | undefined, b: NotificationTarget | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationTarget, a, b); + } +} + +/** + * One exact Notifications 2.0 source occurrence. + * + * @generated from message chatto.api.v1.NotificationOccurrence + */ +export class NotificationOccurrence extends Message { + /** + * Stable occurrence ID. + * + * @generated from field: string id = 1; + */ + id = ""; + + /** + * Durable source event from which this occurrence was derived. + * + * @generated from field: string source_event_id = 2; + */ + sourceEventId = ""; + + /** + * 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; + + /** + * Current user-controlled inbox state. + * + * @generated from field: chatto.api.v1.NotificationInboxState inbox_state = 8; + */ + inboxState = NotificationInboxState.UNSPECIFIED; + + /** + * Whether the occurrence also appears in Saved. + * + * @generated from field: bool saved = 9; + */ + saved = 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.NotificationOccurrence"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { 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: "inbox_state", kind: "enum", T: proto3.getEnumType(NotificationInboxState) }, + { no: 9, name: "saved", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 10, name: "expires_at", kind: "message", T: Timestamp }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): NotificationOccurrence { + return new NotificationOccurrence().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationOccurrence { + return new NotificationOccurrence().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): NotificationOccurrence { + return new NotificationOccurrence().fromJsonString(jsonString, options); + } + + static equals(a: NotificationOccurrence | PlainMessage | undefined, b: NotificationOccurrence | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationOccurrence, a, b); + } +} + +/** + * A presentation group derived from occurrences in one view. + * + * @generated from message chatto.api.v1.NotificationGroup + */ +export class NotificationGroup extends Message { + /** + * Stable ID derived from the viewer and grouping target. + * + * @generated from field: string id = 1; + */ + id = ""; + + /** + * Bounded newest-occurrence preview. It also includes the open target when + * that target falls outside the newest preview window. + * + * @generated from field: repeated chatto.api.v1.NotificationOccurrence occurrences = 2; + */ + occurrences: NotificationOccurrence[] = []; + + /** + * Target to open: newest unread, or newest when all are read. + * + * @generated from field: chatto.api.v1.NotificationTarget open_target = 3; + */ + 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 and view, 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[] = []; + + /** + * True when every occurrence in this group and view is saved. + * + * @generated from field: bool all_saved = 9; + */ + allSaved = false; + + /** + * True when the group contains an active ambient subscription that can be + * disabled through UnsubscribeNotificationGroup. + * + * @generated from field: bool can_unsubscribe = 10; + */ + canUnsubscribe = false; + + /** + * 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.NotificationGroup"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { 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: 9, name: "all_saved", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { no: 10, name: "can_unsubscribe", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + { 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): NotificationGroup { + return new NotificationGroup().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationGroup { + return new NotificationGroup().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): NotificationGroup { + return new NotificationGroup().fromJsonString(jsonString, options); + } + + static equals(a: NotificationGroup | PlainMessage | undefined, b: NotificationGroup | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationGroup, a, b); + } +} + +/** + * Request for one page of grouped notification occurrences. + * + * @generated from message chatto.api.v1.ListNotificationGroupsRequest + */ +export class ListNotificationGroupsRequest extends Message { + /** + * View to list. Unspecified selects Inbox. + * + * @generated from field: chatto.api.v1.NotificationView view = 1; + */ + view = NotificationView.UNSPECIFIED; + + /** + * Page request. Defaults to 50 results when absent or limit is zero. + * + * @generated from field: chatto.api.v1.PageRequest page = 2; + */ + page?: PageRequest; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.ListNotificationGroupsRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "view", kind: "enum", T: proto3.getEnumType(NotificationView) }, + { no: 2, name: "page", kind: "message", T: PageRequest }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListNotificationGroupsRequest { + return new ListNotificationGroupsRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListNotificationGroupsRequest { + return new ListNotificationGroupsRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListNotificationGroupsRequest { + return new ListNotificationGroupsRequest().fromJsonString(jsonString, options); + } + + static equals(a: ListNotificationGroupsRequest | PlainMessage | undefined, b: ListNotificationGroupsRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ListNotificationGroupsRequest, a, b); + } +} + +/** + * One page of derived notification groups. + * + * @generated from message chatto.api.v1.ListNotificationGroupsResponse + */ +export class ListNotificationGroupsResponse extends Message { + /** + * Groups in the selected view, newest activity first. + * + * @generated from field: repeated chatto.api.v1.NotificationGroup groups = 1; + */ + groups: NotificationGroup[] = []; + + /** + * Page metadata. + * + * @generated from field: chatto.api.v1.PageInfo page = 2; + */ + page?: PageInfo; + + /** + * Total unread group count in Inbox, independent of the selected view. + * + * @generated from field: int32 unread_group_count = 3; + */ + unreadGroupCount = 0; + + /** + * Earliest expiry in the complete Inbox, including groups outside this page. + * Clients refresh authoritative notification state at this boundary. + * + * @generated from field: google.protobuf.Timestamp next_inbox_expiry_at = 4; + */ + nextInboxExpiryAt?: Timestamp; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.ListNotificationGroupsResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { 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_inbox_expiry_at", kind: "message", T: Timestamp }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListNotificationGroupsResponse { + return new ListNotificationGroupsResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListNotificationGroupsResponse { + return new ListNotificationGroupsResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListNotificationGroupsResponse { + return new ListNotificationGroupsResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListNotificationGroupsResponse | PlainMessage | undefined, b: ListNotificationGroupsResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListNotificationGroupsResponse, a, b); + } +} + +/** + * Request one page of exact occurrences belonging to a derived group. + * + * @generated from message chatto.api.v1.ListNotificationOccurrencesRequest + */ +export class ListNotificationOccurrencesRequest extends Message { + /** + * Required stable group ID from the selected view. + * + * @generated from field: string group_id = 1; + */ + groupId = ""; + + /** + * View containing the group. Unspecified selects Inbox. + * + * @generated from field: chatto.api.v1.NotificationView view = 2; + */ + view = NotificationView.UNSPECIFIED; + + /** + * Page request. Defaults to 50 results when absent or limit is zero. + * + * @generated from field: chatto.api.v1.PageRequest page = 3; + */ + page?: PageRequest; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.ListNotificationOccurrencesRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "group_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "view", kind: "enum", T: proto3.getEnumType(NotificationView) }, + { no: 3, name: "page", kind: "message", T: PageRequest }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListNotificationOccurrencesRequest { + return new ListNotificationOccurrencesRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListNotificationOccurrencesRequest { + return new ListNotificationOccurrencesRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListNotificationOccurrencesRequest { + return new ListNotificationOccurrencesRequest().fromJsonString(jsonString, options); + } + + static equals(a: ListNotificationOccurrencesRequest | PlainMessage | undefined, b: ListNotificationOccurrencesRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(ListNotificationOccurrencesRequest, a, b); + } +} + +/** + * One bounded page of exact notification occurrences. + * + * @generated from message chatto.api.v1.ListNotificationOccurrencesResponse + */ +export class ListNotificationOccurrencesResponse extends Message { + /** + * Occurrences in newest-first order. + * + * @generated from field: repeated chatto.api.v1.NotificationOccurrence notifications = 1; + */ + notifications: NotificationOccurrence[] = []; + + /** + * Page metadata for all occurrences in the group and selected view. + * + * @generated from field: chatto.api.v1.PageInfo page = 2; + */ + page?: PageInfo; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.ListNotificationOccurrencesResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "notifications", kind: "message", T: NotificationOccurrence, repeated: true }, + { no: 2, name: "page", kind: "message", T: PageInfo }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): ListNotificationOccurrencesResponse { + return new ListNotificationOccurrencesResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): ListNotificationOccurrencesResponse { + return new ListNotificationOccurrencesResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): ListNotificationOccurrencesResponse { + return new ListNotificationOccurrencesResponse().fromJsonString(jsonString, options); + } + + static equals(a: ListNotificationOccurrencesResponse | PlainMessage | undefined, b: ListNotificationOccurrencesResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(ListNotificationOccurrencesResponse, a, b); + } +} + +/** + * Request one notification occurrence owned by the authenticated viewer. + * + * @generated from message chatto.api.v1.GetNotificationOccurrenceRequest + */ +export class GetNotificationOccurrenceRequest extends Message { + /** + * Required stable occurrence ID. + * + * @generated from field: string notification_id = 1; + */ + notificationId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.GetNotificationOccurrenceRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "notification_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetNotificationOccurrenceRequest { + return new GetNotificationOccurrenceRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetNotificationOccurrenceRequest { + return new GetNotificationOccurrenceRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetNotificationOccurrenceRequest { + return new GetNotificationOccurrenceRequest().fromJsonString(jsonString, options); + } + + static equals(a: GetNotificationOccurrenceRequest | PlainMessage | undefined, b: GetNotificationOccurrenceRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(GetNotificationOccurrenceRequest, a, b); + } +} + +/** + * One visible notification occurrence. + * + * @generated from message chatto.api.v1.GetNotificationOccurrenceResponse + */ +export class GetNotificationOccurrenceResponse extends Message { + /** + * Requested occurrence. + * + * @generated from field: chatto.api.v1.NotificationOccurrence notification = 1; + */ + notification?: NotificationOccurrence; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.GetNotificationOccurrenceResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "notification", kind: "message", T: NotificationOccurrence }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetNotificationOccurrenceResponse { + return new GetNotificationOccurrenceResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetNotificationOccurrenceResponse { + return new GetNotificationOccurrenceResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetNotificationOccurrenceResponse { + return new GetNotificationOccurrenceResponse().fromJsonString(jsonString, options); + } + + static equals(a: GetNotificationOccurrenceResponse | PlainMessage | undefined, b: GetNotificationOccurrenceResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(GetNotificationOccurrenceResponse, a, b); + } +} + +/** + * Patch one notification occurrence's triage state. + * + * @generated from message chatto.api.v1.UpdateNotificationOccurrenceRequest + */ +export class UpdateNotificationOccurrenceRequest extends Message { + /** + * Required stable occurrence ID. + * + * @generated from field: string notification_id = 1; + */ + notificationId = ""; + + /** + * New inbox state. Omit to leave unchanged. + * + * @generated from field: optional chatto.api.v1.NotificationInboxState inbox_state = 2; + */ + inboxState?: NotificationInboxState; + + /** + * New Saved value. Omit to leave unchanged. + * + * @generated from field: optional bool saved = 3; + */ + saved?: boolean; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.UpdateNotificationOccurrenceRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "notification_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "inbox_state", kind: "enum", T: proto3.getEnumType(NotificationInboxState), opt: true }, + { no: 3, name: "saved", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateNotificationOccurrenceRequest { + return new UpdateNotificationOccurrenceRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateNotificationOccurrenceRequest { + return new UpdateNotificationOccurrenceRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateNotificationOccurrenceRequest { + return new UpdateNotificationOccurrenceRequest().fromJsonString(jsonString, options); + } + + static equals(a: UpdateNotificationOccurrenceRequest | PlainMessage | undefined, b: UpdateNotificationOccurrenceRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateNotificationOccurrenceRequest, a, b); + } +} + +/** + * Updated notification occurrence. + * + * @generated from message chatto.api.v1.UpdateNotificationOccurrenceResponse + */ +export class UpdateNotificationOccurrenceResponse extends Message { + /** + * Occurrence after applying the patch. + * + * @generated from field: chatto.api.v1.NotificationOccurrence notification = 1; + */ + notification?: NotificationOccurrence; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.UpdateNotificationOccurrenceResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "notification", kind: "message", T: NotificationOccurrence }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateNotificationOccurrenceResponse { + return new UpdateNotificationOccurrenceResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateNotificationOccurrenceResponse { + return new UpdateNotificationOccurrenceResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateNotificationOccurrenceResponse { + return new UpdateNotificationOccurrenceResponse().fromJsonString(jsonString, options); + } + + static equals(a: UpdateNotificationOccurrenceResponse | PlainMessage | undefined, b: UpdateNotificationOccurrenceResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateNotificationOccurrenceResponse, a, b); + } +} + +/** + * Request permanent deletion of one notification occurrence. + * + * @generated from message chatto.api.v1.DeleteNotificationOccurrenceRequest + */ +export class DeleteNotificationOccurrenceRequest extends Message { + /** + * Required stable occurrence ID. + * + * @generated from field: string notification_id = 1; + */ + notificationId = ""; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.DeleteNotificationOccurrenceRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "notification_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): DeleteNotificationOccurrenceRequest { + return new DeleteNotificationOccurrenceRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteNotificationOccurrenceRequest { + return new DeleteNotificationOccurrenceRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DeleteNotificationOccurrenceRequest { + return new DeleteNotificationOccurrenceRequest().fromJsonString(jsonString, options); + } + + static equals(a: DeleteNotificationOccurrenceRequest | PlainMessage | undefined, b: DeleteNotificationOccurrenceRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(DeleteNotificationOccurrenceRequest, a, b); + } +} + +/** + * Result of deleting one notification occurrence. + * + * @generated from message chatto.api.v1.DeleteNotificationOccurrenceResponse + */ +export class DeleteNotificationOccurrenceResponse extends Message { + /** + * True when a visible occurrence was replaced by a deletion tombstone. + * + * @generated from field: bool deleted = 1; + */ + deleted = false; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.DeleteNotificationOccurrenceResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "deleted", kind: "scalar", T: 8 /* ScalarType.BOOL */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): DeleteNotificationOccurrenceResponse { + return new DeleteNotificationOccurrenceResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteNotificationOccurrenceResponse { + return new DeleteNotificationOccurrenceResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DeleteNotificationOccurrenceResponse { + return new DeleteNotificationOccurrenceResponse().fromJsonString(jsonString, options); + } + + static equals(a: DeleteNotificationOccurrenceResponse | PlainMessage | undefined, b: DeleteNotificationOccurrenceResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(DeleteNotificationOccurrenceResponse, a, b); + } +} + +/** + * Patch all current members of one derived notification group. + * + * @generated from message chatto.api.v1.UpdateNotificationGroupRequest + */ +export class UpdateNotificationGroupRequest extends Message { + /** + * Required stable group ID from the selected view. + * + * @generated from field: string group_id = 1; + */ + groupId = ""; + + /** + * View whose current group members are updated. Unspecified selects Inbox. + * + * @generated from field: chatto.api.v1.NotificationView view = 2; + */ + view = NotificationView.UNSPECIFIED; + + /** + * New inbox state. Omit to leave unchanged. + * + * @generated from field: optional chatto.api.v1.NotificationInboxState inbox_state = 3; + */ + inboxState?: NotificationInboxState; + + /** + * New Saved value. Omit to leave unchanged. + * + * @generated from field: optional bool saved = 4; + */ + saved?: boolean; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.UpdateNotificationGroupRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "group_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "view", kind: "enum", T: proto3.getEnumType(NotificationView) }, + { no: 3, name: "inbox_state", kind: "enum", T: proto3.getEnumType(NotificationInboxState), opt: true }, + { no: 4, name: "saved", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateNotificationGroupRequest { + return new UpdateNotificationGroupRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateNotificationGroupRequest { + return new UpdateNotificationGroupRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateNotificationGroupRequest { + return new UpdateNotificationGroupRequest().fromJsonString(jsonString, options); + } + + static equals(a: UpdateNotificationGroupRequest | PlainMessage | undefined, b: UpdateNotificationGroupRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateNotificationGroupRequest, a, b); + } +} + +/** + * Bounded acknowledgement for a group patch. + * + * @generated from message chatto.api.v1.UpdateNotificationGroupResponse + */ +export class UpdateNotificationGroupResponse extends Message { + /** + * Number of occurrences updated at the mutation boundary. + * + * @generated from field: int32 updated_count = 1; + */ + updatedCount = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.UpdateNotificationGroupResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "updated_count", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UpdateNotificationGroupResponse { + return new UpdateNotificationGroupResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UpdateNotificationGroupResponse { + return new UpdateNotificationGroupResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UpdateNotificationGroupResponse { + return new UpdateNotificationGroupResponse().fromJsonString(jsonString, options); + } + + static equals(a: UpdateNotificationGroupResponse | PlainMessage | undefined, b: UpdateNotificationGroupResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(UpdateNotificationGroupResponse, a, b); + } +} + +/** + * Request permanent deletion of one derived notification group. + * + * @generated from message chatto.api.v1.DeleteNotificationGroupRequest + */ +export class DeleteNotificationGroupRequest extends Message { + /** + * Required stable group ID from the selected view. + * + * @generated from field: string group_id = 1; + */ + groupId = ""; + + /** + * View whose current group members are deleted. Unspecified selects Inbox. + * + * @generated from field: chatto.api.v1.NotificationView view = 2; + */ + view = NotificationView.UNSPECIFIED; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.DeleteNotificationGroupRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "group_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "view", kind: "enum", T: proto3.getEnumType(NotificationView) }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): DeleteNotificationGroupRequest { + return new DeleteNotificationGroupRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteNotificationGroupRequest { + return new DeleteNotificationGroupRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DeleteNotificationGroupRequest { + return new DeleteNotificationGroupRequest().fromJsonString(jsonString, options); + } + + static equals(a: DeleteNotificationGroupRequest | PlainMessage | undefined, b: DeleteNotificationGroupRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(DeleteNotificationGroupRequest, a, b); + } +} + +/** + * Result of deleting one derived notification group. + * + * @generated from message chatto.api.v1.DeleteNotificationGroupResponse + */ +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.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): DeleteNotificationGroupResponse { + return new DeleteNotificationGroupResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): DeleteNotificationGroupResponse { + return new DeleteNotificationGroupResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): DeleteNotificationGroupResponse { + return new DeleteNotificationGroupResponse().fromJsonString(jsonString, options); + } + + static equals(a: DeleteNotificationGroupResponse | PlainMessage | undefined, b: DeleteNotificationGroupResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(DeleteNotificationGroupResponse, a, b); + } +} + +/** + * Request to disable a group's ambient source and move it to Done. + * + * @generated from message chatto.api.v1.UnsubscribeNotificationGroupRequest + */ +export class UnsubscribeNotificationGroupRequest extends Message { + /** + * Required stable group ID from the selected view. + * + * @generated from field: string group_id = 1; + */ + groupId = ""; + + /** + * View containing the group. Unspecified selects Inbox. + * + * @generated from field: chatto.api.v1.NotificationView view = 2; + */ + view = NotificationView.UNSPECIFIED; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.UnsubscribeNotificationGroupRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "group_id", kind: "scalar", T: 9 /* ScalarType.STRING */ }, + { no: 2, name: "view", kind: "enum", T: proto3.getEnumType(NotificationView) }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UnsubscribeNotificationGroupRequest { + return new UnsubscribeNotificationGroupRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UnsubscribeNotificationGroupRequest { + return new UnsubscribeNotificationGroupRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UnsubscribeNotificationGroupRequest { + return new UnsubscribeNotificationGroupRequest().fromJsonString(jsonString, options); + } + + static equals(a: UnsubscribeNotificationGroupRequest | PlainMessage | undefined, b: UnsubscribeNotificationGroupRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(UnsubscribeNotificationGroupRequest, a, b); + } +} + +/** + * Result of disabling the group's ambient source and moving its current + * occurrences to Done. Direct mentions and replies remain independently + * eligible under their own policy. + * + * @generated from message chatto.api.v1.UnsubscribeNotificationGroupResponse + */ +export class UnsubscribeNotificationGroupResponse extends Message { + /** + * Number of occurrences moved to Done by the unsubscribe action. + * + * @generated from field: int32 updated_count = 1; + */ + updatedCount = 0; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.UnsubscribeNotificationGroupResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "updated_count", kind: "scalar", T: 5 /* ScalarType.INT32 */ }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): UnsubscribeNotificationGroupResponse { + return new UnsubscribeNotificationGroupResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): UnsubscribeNotificationGroupResponse { + return new UnsubscribeNotificationGroupResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): UnsubscribeNotificationGroupResponse { + return new UnsubscribeNotificationGroupResponse().fromJsonString(jsonString, options); + } + + static equals(a: UnsubscribeNotificationGroupResponse | PlainMessage | undefined, b: UnsubscribeNotificationGroupResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(UnsubscribeNotificationGroupResponse, a, b); + } +} + +/** + * Explicit and effective delivery policy for one notification cause. + * + * @generated from message chatto.api.v1.NotificationPolicyPreference + */ +export class NotificationPolicyPreference extends Message { + /** + * Notification cause controlled by this row. + * + * @generated from field: chatto.api.v1.NotificationReason reason = 1; + */ + reason = NotificationReason.UNSPECIFIED; + + /** + * Explicit server override, or unspecified when inherited from product defaults. + * + * @generated from field: chatto.api.v1.NotificationDeliveryIntensity server_intensity = 2; + */ + serverIntensity = NotificationDeliveryIntensity.UNSPECIFIED; + + /** + * Explicit room override, or unspecified when inherited from server scope. + * + * @generated from field: chatto.api.v1.NotificationDeliveryIntensity room_intensity = 3; + */ + roomIntensity = NotificationDeliveryIntensity.UNSPECIFIED; + + /** + * Effective intensity after applying product, server, and room inheritance. + * + * @generated from field: chatto.api.v1.NotificationDeliveryIntensity effective_intensity = 4; + */ + effectiveIntensity = NotificationDeliveryIntensity.UNSPECIFIED; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.NotificationPolicyPreference"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { 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): NotificationPolicyPreference { + return new NotificationPolicyPreference().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): NotificationPolicyPreference { + return new NotificationPolicyPreference().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): NotificationPolicyPreference { + return new NotificationPolicyPreference().fromJsonString(jsonString, options); + } + + static equals(a: NotificationPolicyPreference | PlainMessage | undefined, b: NotificationPolicyPreference | PlainMessage | undefined): boolean { + return proto3.util.equals(NotificationPolicyPreference, a, b); + } +} + +/** + * Request the authenticated viewer's notification policy. + * + * @generated from message chatto.api.v1.GetNotificationPolicyRequest + */ +export class GetNotificationPolicyRequest extends Message { + /** + * Empty returns server-scoped preferences. A room ID returns the inherited + * effective policy for that room and requires current membership. + * + * @generated from field: optional string room_id = 1; + */ + roomId?: string; + + constructor(data?: PartialMessage) { + super(); + proto3.util.initPartial(data, this); + } + + static readonly runtime: typeof proto3 = proto3; + static readonly typeName = "chatto.api.v1.GetNotificationPolicyRequest"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { no: 1, name: "room_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, + ]); + + static fromBinary(bytes: Uint8Array, options?: Partial): GetNotificationPolicyRequest { + return new GetNotificationPolicyRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetNotificationPolicyRequest { + return new GetNotificationPolicyRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetNotificationPolicyRequest { + return new GetNotificationPolicyRequest().fromJsonString(jsonString, options); + } + + static equals(a: GetNotificationPolicyRequest | PlainMessage | undefined, b: GetNotificationPolicyRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(GetNotificationPolicyRequest, a, b); + } +} + +/** + * Complete supported notification policy for one scope. + * + * @generated from message chatto.api.v1.GetNotificationPolicyResponse + */ +export class GetNotificationPolicyResponse extends Message { + /** + * Room scope when requested; absent for server scope. + * + * @generated from field: optional string room_id = 1; + */ + roomId?: string; + + /** + * 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.GetNotificationPolicyResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { 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): GetNotificationPolicyResponse { + return new GetNotificationPolicyResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): GetNotificationPolicyResponse { + return new GetNotificationPolicyResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): GetNotificationPolicyResponse { + return new GetNotificationPolicyResponse().fromJsonString(jsonString, options); + } + + static equals(a: GetNotificationPolicyResponse | PlainMessage | undefined, b: GetNotificationPolicyResponse | PlainMessage | undefined): boolean { + return proto3.util.equals(GetNotificationPolicyResponse, a, b); + } +} + +/** + * Set or clear one notification preference override. + * + * @generated from message chatto.api.v1.SetNotificationPolicyPreferenceRequest + */ +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.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): SetNotificationPolicyPreferenceRequest { + return new SetNotificationPolicyPreferenceRequest().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SetNotificationPolicyPreferenceRequest { + return new SetNotificationPolicyPreferenceRequest().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SetNotificationPolicyPreferenceRequest { + return new SetNotificationPolicyPreferenceRequest().fromJsonString(jsonString, options); + } + + static equals(a: SetNotificationPolicyPreferenceRequest | PlainMessage | undefined, b: SetNotificationPolicyPreferenceRequest | PlainMessage | undefined): boolean { + return proto3.util.equals(SetNotificationPolicyPreferenceRequest, a, b); + } +} + +/** + * Complete supported notification policy after one preference change. + * + * @generated from message chatto.api.v1.SetNotificationPolicyPreferenceResponse + */ +export class SetNotificationPolicyPreferenceResponse extends Message { + /** + * Room scope when changed; absent for server scope. + * + * @generated from field: optional string room_id = 1; + */ + roomId?: string; + + /** + * 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.SetNotificationPolicyPreferenceResponse"; + static readonly fields: FieldList = proto3.util.newFieldList(() => [ + { 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): SetNotificationPolicyPreferenceResponse { + return new SetNotificationPolicyPreferenceResponse().fromBinary(bytes, options); + } + + static fromJson(jsonValue: JsonValue, options?: Partial): SetNotificationPolicyPreferenceResponse { + return new SetNotificationPolicyPreferenceResponse().fromJson(jsonValue, options); + } + + static fromJsonString(jsonString: string, options?: Partial): SetNotificationPolicyPreferenceResponse { + return new SetNotificationPolicyPreferenceResponse().fromJsonString(jsonString, options); + } + + 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/realtime/v1/realtime_pb.ts b/packages/api-types/src/chatto/realtime/v1/realtime_pb.ts index acce3dfda..d5aeac718 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, ListNotificationsResponse, RoomNotificationCount } from "../../api/v1/notifications_pb.js"; import { ActiveCall } from "../../api/v1/voice_calls_pb.js"; /** @@ -36,12 +36,24 @@ export enum RealtimeProjectionNotificationAction { * @generated from enum value: REALTIME_PROJECTION_NOTIFICATION_ACTION_DISMISSED = 2; */ DISMISSED = 2, + + /** + * @generated from enum value: REALTIME_PROJECTION_NOTIFICATION_ACTION_UPDATED = 3; + */ + 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" }, ]); /** @@ -1548,6 +1560,13 @@ export class RealtimeProjectionNotificationsReplace extends Message) { super(); proto3.util.initPartial(data, this); @@ -1559,6 +1578,7 @@ export class RealtimeProjectionNotificationsReplace extends Message): RealtimeProjectionNotificationsReplace { diff --git a/proto/chatto/api/v1/notifications.proto b/proto/chatto/api/v1/notifications.proto index 94145680f..113fadcf2 100644 --- a/proto/chatto/api/v1/notifications.proto +++ b/proto/chatto/api/v1/notifications.proto @@ -201,8 +201,363 @@ message DismissAllNotificationsResponse { int32 dismissed_count = 1; } +// 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; +} + +// 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 inbox without interruptive delivery. + NOTIFICATION_DELIVERY_INTENSITY_BADGE = 2; + // Matching activity appears in the inbox and may trigger sound or push. + NOTIFICATION_DELIVERY_INTENSITY_ALERT = 3; +} + +// User-controlled triage state for one notification occurrence. +enum NotificationInboxState { + // No inbox state was specified. + NOTIFICATION_INBOX_STATE_UNSPECIFIED = 0; + // The occurrence is in Inbox and contributes unread attention. + NOTIFICATION_INBOX_STATE_UNREAD = 1; + // The occurrence remains in Inbox without contributing unread attention. + NOTIFICATION_INBOX_STATE_READ = 2; + // The occurrence is removed from Inbox and retained in Done. + NOTIFICATION_INBOX_STATE_DONE = 3; +} + +// Selects one derived notification-inbox view. +enum NotificationView { + // Defaults to Inbox on reads and mutations. + NOTIFICATION_VIEW_UNSPECIFIED = 0; + // Unread and read occurrences that have not been moved to Done. + NOTIFICATION_VIEW_INBOX = 1; + // Occurrences moved out of Inbox. + NOTIFICATION_VIEW_DONE = 2; + // Saved occurrences from either Inbox or Done. + NOTIFICATION_VIEW_SAVED = 3; +} + +// 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; +} + +// Exact visible destination of one notification occurrence. +message NotificationTarget { + // Room containing the source activity. + RoomSummary room = 1; + // 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 exact Notifications 2.0 source occurrence. +message NotificationOccurrence { + // Stable occurrence ID. + string id = 1; + // 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; + // Current user-controlled inbox state. + NotificationInboxState inbox_state = 8; + // Whether the occurrence also appears in Saved. + bool saved = 9; + // Absolute expiry, 90 days after the source activity. + google.protobuf.Timestamp expires_at = 10; +} + +// A presentation group derived from occurrences in one view. +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 and view, 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; + // True when every occurrence in this group and view is saved. + bool all_saved = 9; + // True when the group contains an active ambient subscription that can be + // disabled through UnsubscribeNotificationGroup. + bool can_unsubscribe = 10; + // 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 one page of grouped notification occurrences. +message ListNotificationGroupsRequest { + // View to list. Unspecified selects Inbox. + NotificationView view = 1 [(buf.validate.field).enum.defined_only = true]; + // Page request. Defaults to 50 results when absent or limit is zero. + PageRequest page = 2; +} + +// One page of derived notification groups. +message ListNotificationGroupsResponse { + // Groups in the selected view, newest activity first. + repeated NotificationGroup groups = 1; + // Page metadata. + PageInfo page = 2; + // Total unread group count in Inbox, independent of the selected view. + int32 unread_group_count = 3; + // Earliest expiry in the complete Inbox, including groups outside this page. + // Clients refresh authoritative notification state at this boundary. + google.protobuf.Timestamp next_inbox_expiry_at = 4; +} + +// Request one page of exact occurrences belonging to a derived group. +message ListNotificationOccurrencesRequest { + // Required stable group ID from the selected view. + string group_id = 1 [(buf.validate.field).string.min_len = 1]; + // View containing the group. Unspecified selects Inbox. + NotificationView view = 2 [(buf.validate.field).enum.defined_only = true]; + // Page request. Defaults to 50 results when absent or limit is zero. + PageRequest page = 3; +} + +// One bounded page of exact notification occurrences. +message ListNotificationOccurrencesResponse { + // Occurrences in newest-first order. + repeated NotificationOccurrence notifications = 1; + // Page metadata for all occurrences in the group and selected view. + PageInfo page = 2; +} + +// Request one notification occurrence owned by the authenticated viewer. +message GetNotificationOccurrenceRequest { + // Required stable occurrence ID. + string notification_id = 1 [(buf.validate.field).string.min_len = 1]; +} + +// One visible notification occurrence. +message GetNotificationOccurrenceResponse { + // Requested occurrence. + NotificationOccurrence notification = 1; +} + +// Patch one notification occurrence's triage state. +message UpdateNotificationOccurrenceRequest { + // Required stable occurrence ID. + string notification_id = 1 [(buf.validate.field).string.min_len = 1]; + // New inbox state. Omit to leave unchanged. + optional NotificationInboxState inbox_state = 2 [(buf.validate.field).enum = { + defined_only: true + not_in: [0] + }]; + // New Saved value. Omit to leave unchanged. + optional bool saved = 3; +} + +// Updated notification occurrence. +message UpdateNotificationOccurrenceResponse { + // Occurrence after applying the patch. + NotificationOccurrence notification = 1; +} + +// Request permanent deletion of one notification occurrence. +message DeleteNotificationOccurrenceRequest { + // Required stable occurrence ID. + string notification_id = 1 [(buf.validate.field).string.min_len = 1]; +} + +// Result of deleting one notification occurrence. +message DeleteNotificationOccurrenceResponse { + // True when a visible occurrence was replaced by a deletion tombstone. + bool deleted = 1; +} + +// Patch all current members of one derived notification group. +message UpdateNotificationGroupRequest { + // Required stable group ID from the selected view. + string group_id = 1 [(buf.validate.field).string.min_len = 1]; + // View whose current group members are updated. Unspecified selects Inbox. + NotificationView view = 2 [(buf.validate.field).enum.defined_only = true]; + // New inbox state. Omit to leave unchanged. + optional NotificationInboxState inbox_state = 3 [(buf.validate.field).enum = { + defined_only: true + not_in: [0] + }]; + // New Saved value. Omit to leave unchanged. + optional bool saved = 4; +} + +// Bounded acknowledgement for a group patch. +message UpdateNotificationGroupResponse { + // Number of occurrences updated at the mutation boundary. + int32 updated_count = 1; +} + +// Request permanent deletion of one derived notification group. +message DeleteNotificationGroupRequest { + // Required stable group ID from the selected view. + string group_id = 1 [(buf.validate.field).string.min_len = 1]; + // View whose current group members are deleted. Unspecified selects Inbox. + NotificationView view = 2 [(buf.validate.field).enum.defined_only = true]; +} + +// Result of deleting one derived notification group. +message DeleteNotificationGroupResponse { + // Number of visible occurrences replaced by deletion tombstones. + int32 deleted_count = 1; +} + +// Request to disable a group's ambient source and move it to Done. +message UnsubscribeNotificationGroupRequest { + // Required stable group ID from the selected view. + string group_id = 1 [(buf.validate.field).string.min_len = 1]; + // View containing the group. Unspecified selects Inbox. + NotificationView view = 2 [(buf.validate.field).enum.defined_only = true]; +} + +// Result of disabling the group's ambient source and moving its current +// occurrences to Done. Direct mentions and replies remain independently +// eligible under their own policy. +message UnsubscribeNotificationGroupResponse { + // Number of occurrences moved to Done by the unsubscribe action. + int32 updated_count = 1; +} + +// 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 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]; +} + +// 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; +} + +// 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]; +} + +// 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. service NotificationService { + // Lists the Notifications 2.0 Inbox, Done, or Saved groups. + rpc ListNotificationGroups(ListNotificationGroupsRequest) returns (ListNotificationGroupsResponse); + // Lists exact members of one derived notification group. + rpc ListNotificationOccurrences(ListNotificationOccurrencesRequest) returns (ListNotificationOccurrencesResponse); + // Gets one visible occurrence. Returns NOT_FOUND when absent or inaccessible. + rpc GetNotificationOccurrence(GetNotificationOccurrenceRequest) returns (GetNotificationOccurrenceResponse); + // Patches one occurrence's inbox and Saved state. + rpc UpdateNotificationOccurrence(UpdateNotificationOccurrenceRequest) returns (UpdateNotificationOccurrenceResponse) { + option idempotency_level = IDEMPOTENT; + } + // Permanently deletes one occurrence from every notification view. + rpc DeleteNotificationOccurrence(DeleteNotificationOccurrenceRequest) returns (DeleteNotificationOccurrenceResponse) { + option idempotency_level = IDEMPOTENT; + } + // Patches 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 UpdateNotificationGroup(UpdateNotificationGroupRequest) returns (UpdateNotificationGroupResponse); + // 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); + // Disables an ambient thread/room source and moves its current group to Done. + // 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 UnsubscribeNotificationGroup(UnsubscribeNotificationGroupRequest) returns (UnsubscribeNotificationGroupResponse); + // 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; + } + // Lists the authenticated viewer's pending notifications. rpc ListNotifications(ListNotificationsRequest) returns (ListNotificationsResponse); // Gets one pending notification. Returns NOT_FOUND when the notification is diff --git a/proto/chatto/core/v1/config_events.proto b/proto/chatto/core/v1/config_events.proto index 05a453784..ac9c4f582 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-521: 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. @@ -88,5 +89,29 @@ message UserRoomNotificationLevelClearedEvent { string room_id = 2; } +message UserServerNotificationPreferenceSetEvent { + string user_id = 1; + NotificationReason reason = 2; + NotificationDeliveryIntensity intensity = 3; +} + +message UserServerNotificationPreferenceClearedEvent { + string user_id = 1; + NotificationReason reason = 2; +} + +message UserRoomNotificationPreferenceSetEvent { + string user_id = 1; + string room_id = 2; + NotificationReason reason = 3; + NotificationDeliveryIntensity intensity = 4; +} + +message UserRoomNotificationPreferenceClearedEvent { + string user_id = 1; + string room_id = 2; + NotificationReason reason = 3; +} + // 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 718bddfe7..ff249ecd5 100644 --- a/proto/chatto/core/v1/event.proto +++ b/proto/chatto/core/v1/event.proto @@ -133,6 +133,10 @@ message Event { UserServerNotificationLevelClearedEvent user_server_notification_level_cleared = 515; UserRoomNotificationLevelSetEvent user_room_notification_level_set = 516; UserRoomNotificationLevelClearedEvent user_room_notification_level_cleared = 517; + UserServerNotificationPreferenceSetEvent user_server_notification_preference_set = 518; + UserServerNotificationPreferenceClearedEvent user_server_notification_preference_cleared = 519; + UserRoomNotificationPreferenceSetEvent user_room_notification_preference_set = 520; + UserRoomNotificationPreferenceClearedEvent user_room_notification_preference_cleared = 521; // ----- 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..c7ea8e55b 100644 --- a/proto/chatto/core/v1/live_events.proto +++ b/proto/chatto/core/v1/live_events.proto @@ -74,6 +74,7 @@ message LiveEvent { // ----- Notification sync ----- NotificationCreatedEvent notification_created = 70; NotificationDismissedEvent notification_dismissed = 71; + NotificationOccurrenceChangedEvent notification_occurrence_changed = 72; // ----- Unread indicators ----- RoomMarkedAsReadEvent room_marked_as_read = 80; @@ -215,6 +216,21 @@ message NotificationDismissedEvent { 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/message_events.proto b/proto/chatto/core/v1/message_events.proto index 0cf37e197..c17c3bebd 100644 --- a/proto/chatto/core/v1/message_events.proto +++ b/proto/chatto/core/v1/message_events.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package chatto.core.v1; import "chatto/core/v1/models.proto"; +import "chatto/core/v1/notification.proto"; option go_package = "hmans.de/chatto/internal/pb/chatto/core/v1;corev1"; @@ -48,6 +49,11 @@ message MessagePostedEvent { // The message's durable event ID lives on the Event envelope, not on this // payload. + + // Recipient-specific notification decisions evaluated before this source + // fact was committed. The notification materializer uses these candidates + // for recoverable, idempotent occurrence creation. + repeated NotificationCandidate notification_candidates = 10; } // ============================================================================ // MESSAGE MUTATION EVENTS (room-scoped) diff --git a/proto/chatto/core/v1/notification.proto b/proto/chatto/core/v1/notification.proto index 127a153b2..1a6925305 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 inbox. 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 recipient-controlled inbox triage state. +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. Inbox 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; +} + +// NotificationCandidate is durable source-event provenance for one recipient. +// It lets notification materialization recover without re-evaluating later +// preferences or transient @here presence. +message NotificationCandidate { + string recipient_id = 1; + repeated NotificationReasonMatch reasons = 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; + bool saved = 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; +} diff --git a/proto/chatto/core/v1/projection_snapshots.proto b/proto/chatto/core/v1/projection_snapshots.proto index ca3350bda..b173a37ac 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"; @@ -191,6 +192,8 @@ message UserConfigSnapshot { 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 RoomNotificationLevelSnapshot { @@ -198,6 +201,16 @@ message RoomNotificationLevelSnapshot { NotificationLevel level = 2; } +message NotificationPreferenceSnapshot { + NotificationReason reason = 1; + NotificationDeliveryIntensity intensity = 2; +} + +message RoomNotificationPreferenceSnapshot { + string room_id = 1; + repeated NotificationPreferenceSnapshot preferences = 2; +} + message AssetProjectionSnapshot { repeated AssetCreatedEvent creations = 1; repeated AssetChildrenSnapshot children = 2; @@ -247,6 +260,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/core/v1/reaction_events.proto b/proto/chatto/core/v1/reaction_events.proto index 7399ce711..304f8fffb 100644 --- a/proto/chatto/core/v1/reaction_events.proto +++ b/proto/chatto/core/v1/reaction_events.proto @@ -2,6 +2,8 @@ syntax = "proto3"; package chatto.core.v1; +import "chatto/core/v1/notification.proto"; + option go_package = "hmans.de/chatto/internal/pb/chatto/core/v1;corev1"; // ============================================================================ @@ -29,6 +31,10 @@ message ReactionAddedEvent { // The emoji used for the reaction (shortcode name) string emoji = 4; + + // Evaluated recipient/cause provenance. Absent for self-reactions or when + // the recipient's effective reaction policy is Off. + NotificationCandidate notification_candidate = 5; } message ReactionRemovedEvent { @@ -43,4 +49,9 @@ message ReactionRemovedEvent { // The emoji shortcode that was removed string emoji = 4; + + // Source identity of the corresponding ReactionAddedEvent and its + // notification recipient, if that add produced a candidate. + string notification_source_event_id = 5; + string notification_recipient_id = 6; } diff --git a/proto/chatto/realtime/v1/realtime.proto b/proto/chatto/realtime/v1/realtime.proto index 2ae2eb6df..ebc0b12be 100644 --- a/proto/chatto/realtime/v1/realtime.proto +++ b/proto/chatto/realtime/v1/realtime.proto @@ -337,6 +337,8 @@ message RealtimeProjectionNotificationsReplace { // 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 Inbox groups and unread group count. + chatto.api.v1.ListNotificationGroupsResponse groups = 4; } // One live notification transition accompanying authoritative current state. @@ -355,6 +357,8 @@ enum RealtimeProjectionNotificationAction { 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. From df6a3c1e4b2913c70b023e1b718ab7ee738aa74c Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Mon, 10 Aug 2026 22:38:22 +0200 Subject: [PATCH 02/30] fix(notifications): simplify inbox triage controls --- .../operations/notifications-web-push.mdx | 7 +- .../connectrpc-api/notifications.mdx | 6 +- .../docs/reference/connectrpc-api/types.mdx | 3 - .../src/generated/connectrpc-api/api.raw.mdx | 9 +- apps/frontend/messages/ar/chat.json | 6 +- apps/frontend/messages/cs-CZ/chat.json | 6 +- apps/frontend/messages/de-AT/chat.json | 6 +- apps/frontend/messages/de-CH/chat.json | 6 +- apps/frontend/messages/de-DE/chat.json | 6 +- apps/frontend/messages/en-GB/chat.json | 6 +- apps/frontend/messages/eo/chat.json | 6 +- apps/frontend/messages/es-419/chat.json | 6 +- apps/frontend/messages/es-ES/chat.json | 6 +- apps/frontend/messages/et-EE/chat.json | 6 +- apps/frontend/messages/fr-CA/chat.json | 6 +- apps/frontend/messages/fr-FR/chat.json | 6 +- apps/frontend/messages/he-IL/chat.json | 6 +- apps/frontend/messages/it-IT/chat.json | 6 +- apps/frontend/messages/ja-JP/chat.json | 6 +- apps/frontend/messages/lv-LV/chat.json | 6 +- apps/frontend/messages/nb-NO/chat.json | 6 +- apps/frontend/messages/nl-BE/chat.json | 6 +- apps/frontend/messages/nl-NL/chat.json | 6 +- apps/frontend/messages/pl-PL/chat.json | 6 +- apps/frontend/messages/pt-BR/chat.json | 6 +- apps/frontend/messages/pt-PT/chat.json | 6 +- apps/frontend/messages/ru-RU/chat.json | 6 +- apps/frontend/messages/sv-SE/chat.json | 6 +- apps/frontend/messages/tr-TR/chat.json | 6 +- apps/frontend/messages/uk-UA/chat.json | 6 +- apps/frontend/messages/zh-CN/chat.json | 6 +- apps/frontend/messages/zh-TW/chat.json | 6 +- .../api-client-tests/notifications.spec.ts | 9 +- .../src/lib/api-client/notifications.ts | 36 +--- .../lib/state/server/notifications.spec.ts | 5 +- .../lib/state/server/notifications.svelte.ts | 16 +- .../src/lib/ui/form/Button.stories.svelte | 22 +++ apps/frontend/src/lib/ui/form/Button.svelte | 10 + .../routes/chat/notifications/+page.svelte | 172 +++++++++--------- .../notifications.page.svelte.spec.ts | 32 +++- .../notification_occurrence_assembler.go | 64 +++++-- .../connectapi/notification_occurrences.go | 32 +--- .../connectapi/realtime_projection.go | 16 +- cli/internal/connectapi/room_services_test.go | 25 +-- .../core/notification_occurrence_model.go | 7 - .../notification_occurrence_model_test.go | 21 ++- .../v1/apiv1connect/notifications.connect.go | 8 +- .../pb/chatto/api/v1/notifications.pb.go | 79 ++------ .../pb/chatto/core/v1/notification.pb.go | 17 +- docs/GLOSSARY.md | 2 +- ...-deterministic-notification-occurrences.md | 6 +- .../ADR-070-triageable-notification-inbox.md | 30 ++- docs/architecture/runtime-state.md | 2 +- docs/fdr/FDR-012-notifications.md | 23 +-- .../chatto/api/v1/notifications_connect.ts | 4 +- .../src/chatto/api/v1/notifications_pb.ts | 40 ---- proto/chatto/api/v1/notifications.proto | 29 +-- proto/chatto/core/v1/notification.proto | 4 +- 58 files changed, 360 insertions(+), 544 deletions(-) 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 98f272f10..727a86ef4 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 @@ -21,18 +21,17 @@ Chatto creates persistent notifications for attention-worthy events: | 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. Read, Done, Saved, and Delete changes synchronize everywhere for that user. +Notifications are stored for the user, survive browser restarts, sync across tabs and devices, and expire after 90 days. Read, Done, and Delete changes synchronize everywhere for that user. -## Inbox, Done, and Saved +## Inbox and Done The notification centre follows a triage model: - **Inbox** keeps both unread and read notifications until you move or delete them. - **Done** holds notifications dismissed from Inbox without requiring you to open them. -- **Saved** contains bookmarked notifications from either Inbox or Done. - **Delete** permanently removes a notification from every view. -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, but does not move them to Done. Saved and Done notifications still expire 90 days after their source activity. +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, but does not move them to Done. Done notifications still expire 90 days after their source activity. ## Notification Policy 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 4b9fe4768..0fa9370aa 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 @@ -24,7 +24,7 @@ Reads and dismisses pending notifications for the authenticated viewer. ### ListNotificationGroups -Lists the Notifications 2.0 Inbox, Done, or Saved groups. +Lists the Notifications 2.0 Inbox or Done groups. ```http POST /api/connect/chatto.api.v1.NotificationService/ListNotificationGroups @@ -127,7 +127,7 @@ One visible notification occurrence. ### UpdateNotificationOccurrence -Patches one occurrence's inbox and Saved state. +Patches one occurrence's inbox state. ```http POST /api/connect/chatto.api.v1.NotificationService/UpdateNotificationOccurrence @@ -143,7 +143,6 @@ Patch one notification occurrence's triage state. | --- | --- | --- | | `notification_id` | `string` | Required stable occurrence ID. | | `inbox_state` | optional [`NotificationInboxState`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationInboxState) | New inbox state. Omit to leave unchanged. | -| `saved` | `optional bool` | New Saved value. Omit to leave unchanged. | @@ -213,7 +212,6 @@ Patch all current members of one derived notification group. | `group_id` | `string` | Required stable group ID from the selected view. | | `view` | [`NotificationView`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationView) | View whose current group members are updated. Unspecified selects Inbox. | | `inbox_state` | optional [`NotificationInboxState`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationInboxState) | New inbox state. Omit to leave unchanged. | -| `saved` | `optional bool` | New Saved value. Omit to leave unchanged. | 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 b22e0bd49..db2bfe707 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 @@ -735,7 +735,6 @@ A presentation group derived from occurrences in one view. | `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. | -| `all_saved` | `bool` | True when every occurrence in this group and view is saved. | | `can_unsubscribe` | `bool` | True when the group contains an active ambient subscription that can be disabled through UnsubscribeNotificationGroup. | | `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. | @@ -772,7 +771,6 @@ One exact Notifications 2.0 source occurrence. | `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. | | `inbox_state` | [`NotificationInboxState`](#chatto-api-v1-NotificationInboxState) | Current user-controlled inbox state. | -| `saved` | `bool` | Whether the occurrence also appears in Saved. | | `expires_at` | `google.protobuf.Timestamp` | Absolute expiry, 90 days after the source activity. | @@ -1659,7 +1657,6 @@ Selects one derived notification-inbox view. | `NOTIFICATION_VIEW_UNSPECIFIED` | `0` | Defaults to Inbox on reads and mutations. | | `NOTIFICATION_VIEW_INBOX` | `1` | Unread and read occurrences that have not been moved to Done. | | `NOTIFICATION_VIEW_DONE` | `2` | Occurrences moved out of Inbox. | -| `NOTIFICATION_VIEW_SAVED` | `3` | Saved occurrences from either Inbox or Done. | 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 305622428..df92d5252 100644 --- a/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx +++ b/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx @@ -2200,7 +2200,7 @@ Reads and dismisses pending notifications for the authenticated viewer. ### ListNotificationGroups -Lists the Notifications 2.0 Inbox, Done, or Saved groups. +Lists the Notifications 2.0 Inbox or Done groups. ```http POST /api/connect/chatto.api.v1.NotificationService/ListNotificationGroups @@ -2303,7 +2303,7 @@ One visible notification occurrence. ### UpdateNotificationOccurrence -Patches one occurrence's inbox and Saved state. +Patches one occurrence's inbox state. ```http POST /api/connect/chatto.api.v1.NotificationService/UpdateNotificationOccurrence @@ -2319,7 +2319,6 @@ Patch one notification occurrence's triage state. | --- | --- | --- | | `notification_id` | `string` | Required stable occurrence ID. | | `inbox_state` | optional [`NotificationInboxState`](#chatto-api-v1-NotificationInboxState) | New inbox state. Omit to leave unchanged. | -| `saved` | `optional bool` | New Saved value. Omit to leave unchanged. | @@ -2389,7 +2388,6 @@ Patch all current members of one derived notification group. | `group_id` | `string` | Required stable group ID from the selected view. | | `view` | [`NotificationView`](#chatto-api-v1-NotificationView) | View whose current group members are updated. Unspecified selects Inbox. | | `inbox_state` | optional [`NotificationInboxState`](#chatto-api-v1-NotificationInboxState) | New inbox state. Omit to leave unchanged. | -| `saved` | `optional bool` | New Saved value. Omit to leave unchanged. | @@ -4555,7 +4553,6 @@ A presentation group derived from occurrences in one view. | `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. | -| `all_saved` | `bool` | True when every occurrence in this group and view is saved. | | `can_unsubscribe` | `bool` | True when the group contains an active ambient subscription that can be disabled through UnsubscribeNotificationGroup. | | `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. | @@ -4596,7 +4593,6 @@ One exact Notifications 2.0 source occurrence. | `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. | | `inbox_state` | [`NotificationInboxState`](#chatto-api-v1-NotificationInboxState) | Current user-controlled inbox state. | -| `saved` | `bool` | Whether the occurrence also appears in Saved. | | `expires_at` | `google.protobuf.Timestamp` | Absolute expiry, 90 days after the source activity. | @@ -5085,7 +5081,6 @@ Selects one derived notification-inbox view. | `NOTIFICATION_VIEW_UNSPECIFIED` | `0` | Defaults to Inbox on reads and mutations. | | `NOTIFICATION_VIEW_INBOX` | `1` | Unread and read occurrences that have not been moved to Done. | | `NOTIFICATION_VIEW_DONE` | `2` | Occurrences moved out of Inbox. | -| `NOTIFICATION_VIEW_SAVED` | `3` | Saved occurrences from either Inbox or Done. | diff --git a/apps/frontend/messages/ar/chat.json b/apps/frontend/messages/ar/chat.json index 6b0785644..d8c84f0c7 100644 --- a/apps/frontend/messages/ar/chat.json +++ b/apps/frontend/messages/ar/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d منذ", "inbox": "الوارد", "done": "تم", - "saved": "المحفوظة", "unread": "غير مقروء", "activity": "نشاط جديد", - "save": "حفظ", - "unsave": "إزالة من المحفوظات", "mark_done": "وضع علامة تم", - "restore": "نقل إلى الوارد", - "unsubscribe": "إلغاء الاشتراك ووضع علامة تم" + "restore": "نقل إلى الوارد" }, "sign_out": { "title": "تسجيل الخروج", diff --git a/apps/frontend/messages/cs-CZ/chat.json b/apps/frontend/messages/cs-CZ/chat.json index 27f021ff3..0c60611d6 100644 --- a/apps/frontend/messages/cs-CZ/chat.json +++ b/apps/frontend/messages/cs-CZ/chat.json @@ -12,14 +12,10 @@ "time_days": "Před {count}d", "inbox": "Doručené", "done": "Hotovo", - "saved": "Uložené", "unread": "Nepřečtené", "activity": "Nová aktivita", - "save": "Uložit", - "unsave": "Odebrat z uložených", "mark_done": "Označit jako hotové", - "restore": "Přesunout do doručených", - "unsubscribe": "Odhlásit odběr a označit jako hotové" + "restore": "Přesunout do doručených" }, "sign_out": { "title": "Odhlásit se", diff --git a/apps/frontend/messages/de-AT/chat.json b/apps/frontend/messages/de-AT/chat.json index 00e72e714..d29301275 100644 --- a/apps/frontend/messages/de-AT/chat.json +++ b/apps/frontend/messages/de-AT/chat.json @@ -12,14 +12,10 @@ "time_days": "vor {count} Tg.", "inbox": "Posteingang", "done": "Erledigt", - "saved": "Gespeichert", "unread": "Ungelesen", "activity": "Neue Aktivität", - "save": "Speichern", - "unsave": "Nicht mehr speichern", "mark_done": "Als erledigt markieren", - "restore": "In den Posteingang verschieben", - "unsubscribe": "Abbestellen und als erledigt markieren" + "restore": "In den Posteingang verschieben" }, "sign_out": { "title": "Abmelden", diff --git a/apps/frontend/messages/de-CH/chat.json b/apps/frontend/messages/de-CH/chat.json index 00e72e714..d29301275 100644 --- a/apps/frontend/messages/de-CH/chat.json +++ b/apps/frontend/messages/de-CH/chat.json @@ -12,14 +12,10 @@ "time_days": "vor {count} Tg.", "inbox": "Posteingang", "done": "Erledigt", - "saved": "Gespeichert", "unread": "Ungelesen", "activity": "Neue Aktivität", - "save": "Speichern", - "unsave": "Nicht mehr speichern", "mark_done": "Als erledigt markieren", - "restore": "In den Posteingang verschieben", - "unsubscribe": "Abbestellen und als erledigt markieren" + "restore": "In den Posteingang verschieben" }, "sign_out": { "title": "Abmelden", diff --git a/apps/frontend/messages/de-DE/chat.json b/apps/frontend/messages/de-DE/chat.json index 00e72e714..d29301275 100644 --- a/apps/frontend/messages/de-DE/chat.json +++ b/apps/frontend/messages/de-DE/chat.json @@ -12,14 +12,10 @@ "time_days": "vor {count} Tg.", "inbox": "Posteingang", "done": "Erledigt", - "saved": "Gespeichert", "unread": "Ungelesen", "activity": "Neue Aktivität", - "save": "Speichern", - "unsave": "Nicht mehr speichern", "mark_done": "Als erledigt markieren", - "restore": "In den Posteingang verschieben", - "unsubscribe": "Abbestellen und als erledigt markieren" + "restore": "In den Posteingang verschieben" }, "sign_out": { "title": "Abmelden", diff --git a/apps/frontend/messages/en-GB/chat.json b/apps/frontend/messages/en-GB/chat.json index 245fa97f2..73cb9ce72 100644 --- a/apps/frontend/messages/en-GB/chat.json +++ b/apps/frontend/messages/en-GB/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d ago", "inbox": "Inbox", "done": "Done", - "saved": "Saved", "unread": "Unread", "activity": "New activity", - "save": "Save", - "unsave": "Remove from saved", "mark_done": "Mark done", - "restore": "Move to inbox", - "unsubscribe": "Unsubscribe and mark done" + "restore": "Move to inbox" }, "sign_out": { "title": "Sign Out", diff --git a/apps/frontend/messages/eo/chat.json b/apps/frontend/messages/eo/chat.json index 4a8f8f02c..161c44eb2 100644 --- a/apps/frontend/messages/eo/chat.json +++ b/apps/frontend/messages/eo/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d antaŭ", "inbox": "Ricevujo", "done": "Farite", - "saved": "Konservite", "unread": "Nelegite", "activity": "Nova aktiveco", - "save": "Konservi", - "unsave": "Forigi el konservitaj", "mark_done": "Marki kiel farita", - "restore": "Movi al ricevujo", - "unsubscribe": "Malaboni kaj marki kiel farita" + "restore": "Movi al ricevujo" }, "sign_out": { "title": "Eliru", diff --git a/apps/frontend/messages/es-419/chat.json b/apps/frontend/messages/es-419/chat.json index ede4aa671..b7694ac40 100644 --- a/apps/frontend/messages/es-419/chat.json +++ b/apps/frontend/messages/es-419/chat.json @@ -12,14 +12,10 @@ "time_days": "Hace {count}d", "inbox": "Bandeja de entrada", "done": "Listo", - "saved": "Guardado", "unread": "No leído", "activity": "Actividad nueva", - "save": "Guardar", - "unsave": "Quitar de guardados", "mark_done": "Marcar como listo", - "restore": "Mover a la bandeja de entrada", - "unsubscribe": "Cancelar suscripción y marcar como listo" + "restore": "Mover a la bandeja de entrada" }, "sign_out": { "title": "Cerrar sesión", diff --git a/apps/frontend/messages/es-ES/chat.json b/apps/frontend/messages/es-ES/chat.json index 27a64a569..1c91c4435 100644 --- a/apps/frontend/messages/es-ES/chat.json +++ b/apps/frontend/messages/es-ES/chat.json @@ -12,14 +12,10 @@ "time_days": "Hace {count}d", "inbox": "Bandeja de entrada", "done": "Hecho", - "saved": "Guardado", "unread": "No leído", "activity": "Actividad nueva", - "save": "Guardar", - "unsave": "Quitar de guardados", "mark_done": "Marcar como hecho", - "restore": "Mover a la bandeja de entrada", - "unsubscribe": "Cancelar suscripción y marcar como hecho" + "restore": "Mover a la bandeja de entrada" }, "sign_out": { "title": "Cerrar sesión", diff --git a/apps/frontend/messages/et-EE/chat.json b/apps/frontend/messages/et-EE/chat.json index a2b08bb95..0675b4ff5 100644 --- a/apps/frontend/messages/et-EE/chat.json +++ b/apps/frontend/messages/et-EE/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}p tagasi", "inbox": "Postkast", "done": "Tehtud", - "saved": "Salvestatud", "unread": "Lugemata", "activity": "Uus tegevus", - "save": "Salvesta", - "unsave": "Eemalda salvestatutest", "mark_done": "Märgi tehtuks", - "restore": "Teisalda postkasti", - "unsubscribe": "Loobu tellimusest ja märgi tehtuks" + "restore": "Teisalda postkasti" }, "sign_out": { "title": "Logi välja", diff --git a/apps/frontend/messages/fr-CA/chat.json b/apps/frontend/messages/fr-CA/chat.json index 80600e8d2..1cd5a05c2 100644 --- a/apps/frontend/messages/fr-CA/chat.json +++ b/apps/frontend/messages/fr-CA/chat.json @@ -12,14 +12,10 @@ "time_days": "Y a {count}d", "inbox": "Boîte de réception", "done": "Terminé", - "saved": "Enregistré", "unread": "Non lu", "activity": "Nouvelle activité", - "save": "Enregistrer", - "unsave": "Retirer des éléments enregistrés", "mark_done": "Marquer comme terminé", - "restore": "Déplacer vers la boîte de réception", - "unsubscribe": "Se désabonner et marquer comme terminé" + "restore": "Déplacer vers la boîte de réception" }, "sign_out": { "title": "Se déconnecter", diff --git a/apps/frontend/messages/fr-FR/chat.json b/apps/frontend/messages/fr-FR/chat.json index f5b9feb70..86befe3a0 100644 --- a/apps/frontend/messages/fr-FR/chat.json +++ b/apps/frontend/messages/fr-FR/chat.json @@ -12,14 +12,10 @@ "time_days": "Il y a {count}d", "inbox": "Boîte de réception", "done": "Terminé", - "saved": "Enregistré", "unread": "Non lu", "activity": "Nouvelle activité", - "save": "Enregistrer", - "unsave": "Retirer des éléments enregistrés", "mark_done": "Marquer comme terminé", - "restore": "Déplacer vers la boîte de réception", - "unsubscribe": "Se désabonner et marquer comme terminé" + "restore": "Déplacer vers la boîte de réception" }, "sign_out": { "title": "Se déconnecter", diff --git a/apps/frontend/messages/he-IL/chat.json b/apps/frontend/messages/he-IL/chat.json index 55f900dd3..18d3f74d3 100644 --- a/apps/frontend/messages/he-IL/chat.json +++ b/apps/frontend/messages/he-IL/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d לפני", "inbox": "תיבת דואר נכנס", "done": "הושלם", - "saved": "נשמר", "unread": "לא נקרא", "activity": "פעילות חדשה", - "save": "שמירה", - "unsave": "הסרה מהשמורים", "mark_done": "סימון כהושלם", - "restore": "העברה לתיבת הדואר הנכנס", - "unsubscribe": "ביטול הרשמה וסימון כהושלם" + "restore": "העברה לתיבת הדואר הנכנס" }, "sign_out": { "title": "צא", diff --git a/apps/frontend/messages/it-IT/chat.json b/apps/frontend/messages/it-IT/chat.json index f388a0ada..84069ef22 100644 --- a/apps/frontend/messages/it-IT/chat.json +++ b/apps/frontend/messages/it-IT/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d fa", "inbox": "Posta in arrivo", "done": "Completate", - "saved": "Salvate", "unread": "Non letta", "activity": "Nuova attività", - "save": "Salva", - "unsave": "Rimuovi dalle salvate", "mark_done": "Segna come completata", - "restore": "Sposta nella posta in arrivo", - "unsubscribe": "Annulla iscrizione e segna come completata" + "restore": "Sposta nella posta in arrivo" }, "sign_out": { "title": "Esci", diff --git a/apps/frontend/messages/ja-JP/chat.json b/apps/frontend/messages/ja-JP/chat.json index 1da02894f..73637a703 100644 --- a/apps/frontend/messages/ja-JP/chat.json +++ b/apps/frontend/messages/ja-JP/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d 前", "inbox": "受信トレイ", "done": "完了", - "saved": "保存済み", "unread": "未読", "activity": "新しいアクティビティ", - "save": "保存", - "unsave": "保存済みから削除", "mark_done": "完了にする", - "restore": "受信トレイに戻す", - "unsubscribe": "購読を解除して完了にする" + "restore": "受信トレイに戻す" }, "sign_out": { "title": "サインアウト", diff --git a/apps/frontend/messages/lv-LV/chat.json b/apps/frontend/messages/lv-LV/chat.json index 29787867b..2ca4c66bc 100644 --- a/apps/frontend/messages/lv-LV/chat.json +++ b/apps/frontend/messages/lv-LV/chat.json @@ -12,14 +12,10 @@ "time_days": "Pirms {count}d", "inbox": "Iesūtne", "done": "Pabeigts", - "saved": "Saglabāts", "unread": "Nelasīts", "activity": "Jauna aktivitāte", - "save": "Saglabāt", - "unsave": "Noņemt no saglabātajiem", "mark_done": "Atzīmēt kā pabeigtu", - "restore": "Pārvietot uz iesūtni", - "unsubscribe": "Atteikties un atzīmēt kā pabeigtu" + "restore": "Pārvietot uz iesūtni" }, "sign_out": { "title": "Izrakstīties", diff --git a/apps/frontend/messages/nb-NO/chat.json b/apps/frontend/messages/nb-NO/chat.json index 8c4a4141e..1e39410e7 100644 --- a/apps/frontend/messages/nb-NO/chat.json +++ b/apps/frontend/messages/nb-NO/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d siden", "inbox": "Innboks", "done": "Ferdig", - "saved": "Lagret", "unread": "Ulest", "activity": "Ny aktivitet", - "save": "Lagre", - "unsave": "Fjern fra lagret", "mark_done": "Merk som ferdig", - "restore": "Flytt til innboksen", - "unsubscribe": "Avslutt abonnementet og merk som ferdig" + "restore": "Flytt til innboksen" }, "sign_out": { "title": "Logg av", diff --git a/apps/frontend/messages/nl-BE/chat.json b/apps/frontend/messages/nl-BE/chat.json index 41ffcab3f..fa0b2d0fe 100644 --- a/apps/frontend/messages/nl-BE/chat.json +++ b/apps/frontend/messages/nl-BE/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d geleden", "inbox": "Postvak IN", "done": "Afgehandeld", - "saved": "Opgeslagen", "unread": "Ongelezen", "activity": "Nieuwe activiteit", - "save": "Opslaan", - "unsave": "Verwijderen uit opgeslagen", "mark_done": "Markeren als afgehandeld", - "restore": "Naar Postvak IN verplaatsen", - "unsubscribe": "Afmelden en markeren als afgehandeld" + "restore": "Naar Postvak IN verplaatsen" }, "sign_out": { "title": "Uitloggen", diff --git a/apps/frontend/messages/nl-NL/chat.json b/apps/frontend/messages/nl-NL/chat.json index 41ffcab3f..fa0b2d0fe 100644 --- a/apps/frontend/messages/nl-NL/chat.json +++ b/apps/frontend/messages/nl-NL/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d geleden", "inbox": "Postvak IN", "done": "Afgehandeld", - "saved": "Opgeslagen", "unread": "Ongelezen", "activity": "Nieuwe activiteit", - "save": "Opslaan", - "unsave": "Verwijderen uit opgeslagen", "mark_done": "Markeren als afgehandeld", - "restore": "Naar Postvak IN verplaatsen", - "unsubscribe": "Afmelden en markeren als afgehandeld" + "restore": "Naar Postvak IN verplaatsen" }, "sign_out": { "title": "Uitloggen", diff --git a/apps/frontend/messages/pl-PL/chat.json b/apps/frontend/messages/pl-PL/chat.json index b481e3fe0..d79751eea 100644 --- a/apps/frontend/messages/pl-PL/chat.json +++ b/apps/frontend/messages/pl-PL/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d temu", "inbox": "Odebrane", "done": "Gotowe", - "saved": "Zapisane", "unread": "Nieprzeczytane", "activity": "Nowa aktywność", - "save": "Zapisz", - "unsave": "Usuń z zapisanych", "mark_done": "Oznacz jako gotowe", - "restore": "Przenieś do odebranych", - "unsubscribe": "Anuluj subskrypcję i oznacz jako gotowe" + "restore": "Przenieś do odebranych" }, "sign_out": { "title": "Wyloguj się", diff --git a/apps/frontend/messages/pt-BR/chat.json b/apps/frontend/messages/pt-BR/chat.json index 84f3cd8d3..cae351ab9 100644 --- a/apps/frontend/messages/pt-BR/chat.json +++ b/apps/frontend/messages/pt-BR/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d atrás", "inbox": "Caixa de entrada", "done": "Concluído", - "saved": "Salvo", "unread": "Não lido", "activity": "Nova atividade", - "save": "Salvar", - "unsave": "Remover dos salvos", "mark_done": "Marcar como concluído", - "restore": "Mover para a caixa de entrada", - "unsubscribe": "Cancelar inscrição e marcar como concluído" + "restore": "Mover para a caixa de entrada" }, "sign_out": { "title": "Sair", diff --git a/apps/frontend/messages/pt-PT/chat.json b/apps/frontend/messages/pt-PT/chat.json index 05c8db0ac..45f3fcd93 100644 --- a/apps/frontend/messages/pt-PT/chat.json +++ b/apps/frontend/messages/pt-PT/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d atrás", "inbox": "Caixa de entrada", "done": "Concluído", - "saved": "Guardado", "unread": "Não lido", "activity": "Nova atividade", - "save": "Guardar", - "unsave": "Remover dos guardados", "mark_done": "Marcar como concluído", - "restore": "Mover para a caixa de entrada", - "unsubscribe": "Cancelar subscrição e marcar como concluído" + "restore": "Mover para a caixa de entrada" }, "sign_out": { "title": "Sair", diff --git a/apps/frontend/messages/ru-RU/chat.json b/apps/frontend/messages/ru-RU/chat.json index 0b7b4675f..a9c9d0d12 100644 --- a/apps/frontend/messages/ru-RU/chat.json +++ b/apps/frontend/messages/ru-RU/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}дн назад", "inbox": "Входящие", "done": "Готово", - "saved": "Сохранённые", "unread": "Непрочитанное", "activity": "Новое событие", - "save": "Сохранить", - "unsave": "Убрать из сохранённых", "mark_done": "Отметить как готовое", - "restore": "Вернуть во входящие", - "unsubscribe": "Отписаться и отметить как готовое" + "restore": "Вернуть во входящие" }, "sign_out": { "title": "Выйти", diff --git a/apps/frontend/messages/sv-SE/chat.json b/apps/frontend/messages/sv-SE/chat.json index b859b8080..1faa6b7eb 100644 --- a/apps/frontend/messages/sv-SE/chat.json +++ b/apps/frontend/messages/sv-SE/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d sedan", "inbox": "Inkorg", "done": "Klart", - "saved": "Sparat", "unread": "Oläst", "activity": "Ny aktivitet", - "save": "Spara", - "unsave": "Ta bort från sparat", "mark_done": "Markera som klar", - "restore": "Flytta till inkorgen", - "unsubscribe": "Avsluta prenumeration och markera som klar" + "restore": "Flytta till inkorgen" }, "sign_out": { "title": "Logga ut", diff --git a/apps/frontend/messages/tr-TR/chat.json b/apps/frontend/messages/tr-TR/chat.json index f610b3cae..63732c921 100644 --- a/apps/frontend/messages/tr-TR/chat.json +++ b/apps/frontend/messages/tr-TR/chat.json @@ -12,14 +12,10 @@ "time_days": "{count} gün önce", "inbox": "Gelen kutusu", "done": "Tamamlandı", - "saved": "Kaydedilenler", "unread": "Okunmadı", "activity": "Yeni etkinlik", - "save": "Kaydet", - "unsave": "Kaydedilenlerden kaldır", "mark_done": "Tamamlandı olarak işaretle", - "restore": "Gelen kutusuna taşı", - "unsubscribe": "Abonelikten çık ve tamamlandı olarak işaretle" + "restore": "Gelen kutusuna taşı" }, "sign_out": { "title": "Oturumu Kapat", diff --git a/apps/frontend/messages/uk-UA/chat.json b/apps/frontend/messages/uk-UA/chat.json index 7d275aff2..8a1be34d7 100644 --- a/apps/frontend/messages/uk-UA/chat.json +++ b/apps/frontend/messages/uk-UA/chat.json @@ -12,14 +12,10 @@ "time_days": "{count}d тому", "inbox": "Вхідні", "done": "Готово", - "saved": "Збережені", "unread": "Непрочитане", "activity": "Нова активність", - "save": "Зберегти", - "unsave": "Вилучити зі збережених", "mark_done": "Позначити як готове", - "restore": "Повернути до вхідних", - "unsubscribe": "Відписатися й позначити як готове" + "restore": "Повернути до вхідних" }, "sign_out": { "title": "Вийти", diff --git a/apps/frontend/messages/zh-CN/chat.json b/apps/frontend/messages/zh-CN/chat.json index 8f3e99c74..c2b06a2a6 100644 --- a/apps/frontend/messages/zh-CN/chat.json +++ b/apps/frontend/messages/zh-CN/chat.json @@ -12,14 +12,10 @@ "time_days": "{count} 天前", "inbox": "收件箱", "done": "已完成", - "saved": "已保存", "unread": "未读", "activity": "新动态", - "save": "保存", - "unsave": "取消保存", "mark_done": "标记为已完成", - "restore": "移至收件箱", - "unsubscribe": "取消订阅并标记为已完成" + "restore": "移至收件箱" }, "sign_out": { "title": "退出登录", diff --git a/apps/frontend/messages/zh-TW/chat.json b/apps/frontend/messages/zh-TW/chat.json index 22385118c..e3d616782 100644 --- a/apps/frontend/messages/zh-TW/chat.json +++ b/apps/frontend/messages/zh-TW/chat.json @@ -12,14 +12,10 @@ "time_days": "{count} 天前", "inbox": "收件匣", "done": "已完成", - "saved": "已儲存", "unread": "未讀", "activity": "新動態", - "save": "儲存", - "unsave": "取消儲存", "mark_done": "標記為已完成", - "restore": "移至收件匣", - "unsubscribe": "取消訂閱並標記為已完成" + "restore": "移至收件匣" }, "sign_out": { "title": "登出", 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 2427a4dab..83ed830d6 100644 --- a/apps/frontend/src/lib/api-client-tests/notifications.spec.ts +++ b/apps/frontend/src/lib/api-client-tests/notifications.spec.ts @@ -191,7 +191,7 @@ describe('notification occurrence compatibility mapping', () => { }) ); - expect(occurrence.summary).toBe('Alice posted in a thread you follow'); + expect(occurrence.reasons).toEqual([NotificationReason.FOLLOWED_THREAD]); expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ kind: NotificationItemKind.Reply, replyEventId: 'reply-1', @@ -224,7 +224,10 @@ describe('notification occurrence compatibility mapping', () => { }) ); - expect(occurrence.summary).toBe('Alice mentioned you'); + expect(occurrence.reasons).toEqual([ + NotificationReason.DIRECT_MENTION, + NotificationReason.FOLLOWED_THREAD + ]); expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ kind: NotificationItemKind.Mention, mentionEventId: 'reply-2', @@ -249,7 +252,7 @@ describe('notification occurrence compatibility mapping', () => { }) ); - expect(occurrence.summary).toBe('Alice posted a message'); + expect(occurrence.reasons).toEqual([NotificationReason.FOLLOWED_ROOM]); expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ kind: NotificationItemKind.RoomMessage, roomMsgEventId: 'message-1' diff --git a/apps/frontend/src/lib/api-client/notifications.ts b/apps/frontend/src/lib/api-client/notifications.ts index fe87549d9..f1b85005c 100644 --- a/apps/frontend/src/lib/api-client/notifications.ts +++ b/apps/frontend/src/lib/api-client/notifications.ts @@ -107,7 +107,6 @@ export type NotificationOccurrenceItem = { sourceEventId: string; createdAt: string; actor: NotificationActor | null; - summary: string; room: { id: string; name: string } | null; eventId: string; threadRootId: string | null; @@ -118,7 +117,6 @@ export type NotificationOccurrenceItem = { intensity: NotificationDeliveryIntensity; }>; inboxState: NotificationInboxState; - saved: boolean; expiresAt?: string; }; @@ -130,7 +128,6 @@ export type NotificationGroupItem = { occurrenceCount: number; latestAt: string; reasons: NotificationReason[]; - allSaved?: boolean; canUnsubscribe?: boolean; nextExpiryAt?: string | null; }; @@ -203,7 +200,7 @@ export function createNotificationAPI(config: NotificationAPIConfig) { async updateNotificationGroup( groupId: string, view: NotificationView, - update: { inboxState?: NotificationInboxState; saved?: boolean } + update: { inboxState?: NotificationInboxState } ): Promise { await client.updateNotificationGroup({ groupId, view, ...update }, { headers: headers() }); }, @@ -219,7 +216,7 @@ export function createNotificationAPI(config: NotificationAPIConfig) { async updateNotificationOccurrence( notificationId: string, - update: { inboxState?: NotificationInboxState; saved?: boolean } + update: { inboxState?: NotificationInboxState } ): Promise { const response = await client.updateNotificationOccurrence( { notificationId, ...update }, @@ -349,7 +346,6 @@ function notificationGroup(group: APINotificationGroup): NotificationGroupItem { occurrenceCount: Number(group.occurrenceCount), latestAt: group.latestAt?.toDate().toISOString() ?? new Date(0).toISOString(), reasons: [...group.reasons], - allSaved: group.allSaved, canUnsubscribe: group.canUnsubscribe, nextExpiryAt: group.nextExpiryAt?.toDate().toISOString() ?? null }; @@ -369,7 +365,6 @@ export function notificationOccurrence( sourceEventId: item.sourceEventId, createdAt: item.createdAt?.toDate().toISOString() ?? new Date(0).toISOString(), actor, - summary: occurrenceSummary(actor, reasons), room: item.target?.room ? { id: item.target.room.id, name: item.target.room.name } : null, eventId: item.target?.eventId ?? '', threadRootId: item.target?.threadRootEventId ?? null, @@ -377,7 +372,6 @@ export function notificationOccurrence( reasons, reasonMatches, inboxState: item.inboxState, - saved: item.saved, expiresAt: item.expiresAt?.toDate().toISOString() ?? new Date(0).toISOString() }; } @@ -387,7 +381,9 @@ export function occurrenceAsNotificationItem(item: NotificationOccurrenceItem): id: item.id, createdAt: item.createdAt, actor: item.actor, - summary: item.summary + // Legacy compatibility consumers require this field, but Notifications 2.0 + // renders its structured reason and actor through the active locale. + summary: '' }; if (item.reasons.includes(NotificationReason.DIRECT_MESSAGE)) { return { @@ -439,28 +435,6 @@ export function occurrenceAsNotificationItem(item: NotificationOccurrenceItem): }; } -function occurrenceSummary(actor: NotificationActor | null, reasons: NotificationReason[]): string { - const actorName = actor?.displayName || 'Someone'; - if (reasons.includes(NotificationReason.DIRECT_MESSAGE)) return `${actorName} sent you a message`; - if (reasons.includes(NotificationReason.REACTION)) return `${actorName} reacted to your message`; - if (reasons.includes(NotificationReason.REPLY)) return `${actorName} replied to your message`; - if ( - reasons.includes(NotificationReason.DIRECT_MENTION) || - reasons.includes(NotificationReason.ROLE_MENTION) || - reasons.includes(NotificationReason.HERE) || - reasons.includes(NotificationReason.ALL) - ) { - return `${actorName} mentioned you`; - } - if (reasons.includes(NotificationReason.FOLLOWED_THREAD)) { - return `${actorName} posted in a thread you follow`; - } - if (reasons.includes(NotificationReason.FOLLOWED_ROOM)) { - return `${actorName} posted a message`; - } - return `${actorName} posted new activity`; -} - function notificationItem(item: APINotificationItem): NotificationItem | null { const actor = notificationActor(item.actor); const base = { diff --git a/apps/frontend/src/lib/state/server/notifications.spec.ts b/apps/frontend/src/lib/state/server/notifications.spec.ts index d9f3b2b71..fc2d377ff 100644 --- a/apps/frontend/src/lib/state/server/notifications.spec.ts +++ b/apps/frontend/src/lib/state/server/notifications.spec.ts @@ -48,7 +48,6 @@ function groupPage(source: NotificationPage): NotificationGroupPage { sourceEventId: item.id, createdAt: item.createdAt, actor: item.actor ?? null, - summary: item.summary, room: target.roomId ? { id: target.roomId, name: target.roomName ?? '' } : null, eventId: target.eventId ?? '', threadRootId: target.threadRootId, @@ -60,8 +59,7 @@ function groupPage(source: NotificationPage): NotificationGroupPage { intensity: NotificationDeliveryIntensity.ALERT } ], - inboxState: NotificationInboxState.UNREAD, - saved: false + inboxState: NotificationInboxState.UNREAD }; return { id: `group-${item.id}`, @@ -184,7 +182,6 @@ describe('NotificationStore', () => { 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]?.occurrences[0]?.summary).not.toContain('Tester'); expect(store.groups[0]?.openTarget?.actor).toBeNull(); }); diff --git a/apps/frontend/src/lib/state/server/notifications.svelte.ts b/apps/frontend/src/lib/state/server/notifications.svelte.ts index 52fabbc85..ce8e8b147 100644 --- a/apps/frontend/src/lib/state/server/notifications.svelte.ts +++ b/apps/frontend/src/lib/state/server/notifications.svelte.ts @@ -227,8 +227,7 @@ export class NotificationStore { groupsChanged = true; return { ...occurrence, - actor: null, - summary: redactedNotificationSummary(occurrenceAsNotificationItem(occurrence).kind) + actor: null }; }); if (!groupChanged) return group; @@ -403,12 +402,19 @@ export class NotificationStore { async updateGroup( groupId: string, view: NotificationView, - update: { inboxState?: NotificationInboxState; saved?: boolean } + update: { inboxState?: NotificationInboxState } ): Promise { await this.#api.updateNotificationGroup(groupId, view, update); await this.fetch(); } + async markOccurrenceRead(notificationId: string): Promise { + await this.#api.updateNotificationOccurrence(notificationId, { + inboxState: NotificationInboxState.READ + }); + await this.fetch(); + } + async moveGroupToDone(groupId: string, view: NotificationView): Promise { await this.updateGroup(groupId, view, { inboxState: NotificationInboxState.DONE }); } @@ -417,10 +423,6 @@ export class NotificationStore { await this.updateGroup(groupId, view, { inboxState: NotificationInboxState.READ }); } - async setGroupSaved(groupId: string, view: NotificationView, saved: boolean): Promise { - await this.updateGroup(groupId, view, { saved }); - } - async deleteGroup(groupId: string, view: NotificationView): Promise { await this.#api.deleteNotificationGroup(groupId, view); await this.fetch(); 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 view === NotificationView.INBOX} - - {:else if view === NotificationView.DONE} - - {/if} + + + {:else if visibleGroups.length === 0} {m('chat.notifications.empty_body')} {:else}
- {#each groups as item (mutationKey(item))} + {#each visibleGroups as item (rowKey(item))} {@const occurrence = item.group.openTarget} {@const actor = occurrence?.actor ?? null} {@const isDone = item.view === NotificationView.DONE} @@ -343,7 +400,8 @@ >
{/each} - {#if hasMore} + {#if pageError} + + {:else if hasMore}
{#if loadingMore}{m('common.loading')}{/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 7ba81b7f0..360f14d9c 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,10 +1,11 @@ -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 { NotificationInboxState, NotificationView } 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: { @@ -79,6 +80,7 @@ import NotificationsPage from './+page.svelte'; describe('notifications page', () => { beforeEach(async () => { vi.clearAllMocks(); + toast.clear(); await loadLocaleMessages('en-GB'); setReactiveLocale('en-GB'); const group = { @@ -107,6 +109,10 @@ describe('notifications page', () => { mocks.stores.set('origin', mocks.store); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it('reveals the target room before navigating from a notification row', async () => { const { container } = render(NotificationsPage); @@ -209,6 +215,254 @@ describe('notifications page', () => { }); }); + it('holds older rows behind a source with an unloaded newer page', async () => { + let intersectionCallback: IntersectionObserverCallback | undefined; + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + root = null; + rootMargin = ''; + thresholds = []; + } + ); + const groupAt = (id: string, latestAt: string, state: NotificationInboxState) => { + const occurrence = { + ...mocks.occurrence, + id: `${id}-occurrence`, + createdAt: latestAt, + inboxState: state + }; + return { + id, + occurrences: [occurrence], + openTarget: occurrence, + unread: state === NotificationInboxState.UNREAD, + occurrenceCount: 1, + latestAt, + reasons: [2] + }; + }; + mocks.store.notifications.fetchView.mockImplementation((view: NotificationView, offset = 0) => { + if (view === NotificationView.INBOX && offset === 0) { + return Promise.resolve({ + groups: [groupAt('newest', '2026-08-11T12:00:00Z', NotificationInboxState.UNREAD)], + unreadGroupCount: 2, + roomUnreadGroupCounts: {}, + totalCount: 2, + hasMore: true + }); + } + if (view === NotificationView.INBOX) { + return Promise.resolve({ + groups: [groupAt('middle', '2026-08-11T11:00:00Z', NotificationInboxState.UNREAD)], + unreadGroupCount: 2, + roomUnreadGroupCounts: {}, + totalCount: 2, + hasMore: false + }); + } + return Promise.resolve({ + groups: [groupAt('oldest', '2026-08-11T10:00:00Z', NotificationInboxState.DONE)], + unreadGroupCount: 0, + roomUnreadGroupCounts: {}, + totalCount: 1, + hasMore: false + }); + }); + + const { container } = render(NotificationsPage); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-testid="notification-group"]')).toHaveLength(1); + }); + expect(container.textContent).not.toContain('10:00'); + intersectionCallback?.( + [{ isIntersecting: true } as IntersectionObserverEntry], + {} as IntersectionObserver + ); + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-testid="notification-group"]')).toHaveLength(3); + }); + const rows = [...container.querySelectorAll('[data-testid="notification-group"]')]; + expect(rows.map((row) => row.getAttribute('data-notification-state'))).toEqual([ + 'inbox', + 'inbox', + 'done' + ]); + }); + + it('advances each paginated view with its own result', async () => { + let intersectionCallback: IntersectionObserverCallback | undefined; + vi.stubGlobal( + 'IntersectionObserver', + class { + constructor(callback: IntersectionObserverCallback) { + intersectionCallback = callback; + } + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + root = null; + rootMargin = ''; + thresholds = []; + } + ); + const groupAt = (id: string, latestAt: string, state: NotificationInboxState) => { + const occurrence = { + ...mocks.occurrence, + id: `${id}-occurrence`, + createdAt: latestAt, + inboxState: state + }; + return { + id, + occurrences: [occurrence], + openTarget: occurrence, + unread: state === NotificationInboxState.UNREAD, + occurrenceCount: 1, + latestAt, + reasons: [2] + }; + }; + mocks.store.notifications.fetchView.mockImplementation((view: NotificationView, offset = 0) => { + const state = + view === NotificationView.INBOX + ? NotificationInboxState.UNREAD + : NotificationInboxState.DONE; + const prefix = view === NotificationView.INBOX ? 'inbox' : 'done'; + return Promise.resolve({ + groups: [ + groupAt( + `${prefix}-${offset}`, + `2026-08-11T${view === NotificationView.INBOX ? '12' : '11'}:0${offset}:00Z`, + state + ) + ], + unreadGroupCount: 1, + roomUnreadGroupCounts: {}, + totalCount: 2, + hasMore: 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.fetchView).toHaveBeenCalledWith(NotificationView.INBOX, 1); + expect(mocks.store.notifications.fetchView).toHaveBeenCalledWith(NotificationView.DONE, 1); + expect(container.querySelectorAll('[data-testid="notification-group"]')).toHaveLength(4); + }); + }); + + it('keeps Inbox and Done subsets of the same conversation as separate rows', async () => { + const pageFor = (state: NotificationInboxState) => { + const occurrence = { + ...mocks.occurrence, + id: `same-group-${state}`, + inboxState: state + }; + return { + groups: [ + { + id: 'same-group', + occurrences: [occurrence], + openTarget: occurrence, + unread: state === NotificationInboxState.UNREAD, + occurrenceCount: 1, + latestAt: occurrence.createdAt, + reasons: [2] + } + ], + unreadGroupCount: state === NotificationInboxState.UNREAD ? 1 : 0, + roomUnreadGroupCounts: {}, + totalCount: 1, + hasMore: false + }; + }; + mocks.store.notifications.fetchView.mockImplementation((view: NotificationView) => + Promise.resolve( + pageFor( + view === NotificationView.INBOX + ? NotificationInboxState.UNREAD + : NotificationInboxState.DONE + ) + ) + ); + + const { container } = render(NotificationsPage); + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-testid="notification-group"]')).toHaveLength(2); + }); + }); + + it('renders a retry state instead of an empty inbox when any source fails', async () => { + mocks.store.notifications.fetchView.mockImplementation((view: NotificationView) => { + if (view === NotificationView.DONE) return Promise.reject(new Error('offline')); + return Promise.resolve({ + groups: [], + unreadGroupCount: 0, + roomUnreadGroupCounts: {}, + totalCount: 0, + hasMore: false + }); + }); + + const { container } = render(NotificationsPage); + + await vi.waitFor(() => { + expect(q(container, 'button[aria-label="Try Again"]')).not.toBeNull(); + }); + expect(container.textContent).toContain('Network error. Please try again.'); + expect(container.textContent).not.toContain('You’re all caught up'); + }); + + it('fences row opening while triage is pending and reports mutation failures', async () => { + let rejectMutation: ((reason?: unknown) => void) | undefined; + mocks.store.notifications.moveGroupToDone.mockImplementation( + () => + new Promise((_, reject) => { + rejectMutation = reject; + }) + ); + const { container } = render(NotificationsPage); + await vi.waitFor(() => { + expect(q(container, 'button[aria-label="Mark done"]')).not.toBeNull(); + }); + const doneButton = q(container, 'button[aria-label="Mark done"]') as HTMLButtonElement; + const rowButton = q( + container, + '[data-testid="notification-group"] > button' + ) as HTMLButtonElement; + doneButton.click(); + await vi.waitFor(() => expect(rowButton.disabled).toBe(true)); + rowButton.click(); + expect(mocks.goto).not.toHaveBeenCalled(); + + rejectMutation?.(new Error('offline')); + await vi.waitFor(() => { + expect(getToasts().at(-1)?.message).toBe('Network error. Please try again.'); + expect(rowButton.disabled).toBe(false); + }); + }); + it('formats old notifications with their source server viewer settings', async () => { const createdAt = '2025-04-27T00:30:00Z'; mocks.store.currentUser.user.settings = { diff --git a/cli/cmd/run.go b/cli/cmd/run.go index cf0ccce70..f69f9e0ef 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") } @@ -494,7 +497,10 @@ func setupPushNotifications(chattoCore *core.ChattoCore, cfg config.ChattoConfig _, _ = chattoCore.NotificationOccurrences().Delete(ctx, occurrence.GetRecipientId(), occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) return nil } - subscriptions = filterOwnedPushSubscriptions(ctx, chattoCore, occurrence.GetRecipientId(), subscriptions, logger) + subscriptions, err = filterOwnedPushSubscriptions(ctx, chattoCore, occurrence.GetRecipientId(), subscriptions) + if err != nil { + return fmt.Errorf("revalidate push endpoint ownership: %w", err) + } if len(subscriptions) == 0 { return nil } @@ -504,6 +510,14 @@ func setupPushNotifications(chattoCore *core.ChattoCore, cfg config.ChattoConfig } // 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) + } + if status == core.PresenceStatusDoNotDisturb { + _, err := chattoCore.NotificationOccurrences().SilenceAlertClaim(ctx, occurrence) + return err + } results := sender.SendToMany(ctx, subscriptions, payload) var sendErr error accepted := false @@ -536,23 +550,18 @@ 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 } // fetchOccurrencePayloadContext builds a best-effort message preview and room diff --git a/cli/internal/connectapi/timeline_thread_services_test.go b/cli/internal/connectapi/timeline_thread_services_test.go index e3c5fbb1f..b94798b11 100644 --- a/cli/internal/connectapi/timeline_thread_services_test.go +++ b/cli/internal/connectapi/timeline_thread_services_test.go @@ -821,16 +821,21 @@ func TestRoomAndThreadServicesMarkThreadAsReadAnchorsAndDoesNotRegress(t *testin func createReadTestOccurrence(t *testing.T, env *connectAPITestEnv, recipientID, actorID, roomID string, event *corev1.Event, threadRootID string, reason corev1.NotificationReason) *corev1.NotificationOccurrence { t.Helper() + sequence, err := env.core.GetEventSequence(env.ctx, core.KindChannel, roomID, event.GetId()) + if err != nil { + 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(), - ActorID: actorID, - Target: target, + 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, diff --git a/cli/internal/core/messages.go b/cli/internal/core/messages.go index 8f8e3a6f8..f2b81ce59 100644 --- a/cli/internal/core/messages.go +++ b/cli/internal/core/messages.go @@ -1140,15 +1140,9 @@ func (c *ChattoCore) PostMessage(ctx context.Context, kind RoomKind, room_id, us } } } - - // Materialize promptly for low latency. The durable worker consumes the - // same MessagePosted fact after crashes; deterministic occurrence identity - // makes both paths safe. - if err := c.notificationMaterializer.MaterializeEvent(ctx, event, sequenceID); err != nil { - c.logger.Warn("Failed to materialize message notifications; background replay will retry", - "room_id", room_id, - "event_id", event.Id, - "error", err) + 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. @@ -1549,8 +1543,8 @@ 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.MaterializeEvent(ctx, event, seqs[lastIndex]); err != nil { - c.logger.Warn("Failed to remove notifications for retracted message; background replay will retry", + 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 diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go index f567c3dd9..d1fd51b84 100644 --- a/cli/internal/core/notification_materializer.go +++ b/cli/internal/core/notification_materializer.go @@ -2,7 +2,6 @@ package core import ( "context" - "encoding/binary" "errors" "fmt" "time" @@ -28,7 +27,6 @@ const ( // when several Chatto replicas share the consumer. notificationWorkerMaxPending = 1 notificationWorkKeyPrefix = "notification_work." - notificationVisibilityKeyPrefix = "notification_visibility_boundary." maxNotificationWorkWriteRetries = 8 ) @@ -40,6 +38,7 @@ type NotificationMaterializer struct { core *ChattoCore pollEvery time.Duration ready chan struct{} + consumer jetstream.Consumer } func NewNotificationMaterializer(core *ChattoCore) *NotificationMaterializer { @@ -62,6 +61,7 @@ func (m *NotificationMaterializer) Run(ctx context.Context) error { if err != nil { return err } + m.consumer = consumer close(m.ready) worker, err := events.NewDurableWorker( consumer, @@ -111,6 +111,34 @@ func (m *NotificationMaterializer) WaitReady(ctx context.Context) error { } } +// 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.consumer.Info(ctx) + if err != nil { + return fmt.Errorf("read notification consumer progress: %w", err) + } + if info.AckFloor.Stream >= streamSequence { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + func (m *NotificationMaterializer) createConsumer(ctx context.Context) (jetstream.Consumer, error) { consumer, err := m.core.storage.serverEvtStream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{ Name: notificationWorkerConsumerName, @@ -304,66 +332,6 @@ func notificationWorkFilter(triggerEventID string) string { return notificationWorkKeyPrefix + triggerEventID + ".*" } -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) deliverPendingAlerts(ctx context.Context) { if m.core.OnNotificationOccurrenceCreated == nil { return @@ -389,13 +357,6 @@ func (m *NotificationMaterializer) deliverPendingAlerts(ctx context.Context) { } } -// MaterializeEvent promptly applies prepared work on the request path. The -// committed EVT stream sequence is required so prompt and durable delivery -// obey the same causal lifecycle boundaries. -func (m *NotificationMaterializer) MaterializeEvent(ctx context.Context, event *corev1.Event, streamSequence uint64) error { - return m.materializeEvent(ctx, event, streamSequence, false) -} - func (m *NotificationMaterializer) materializeEvent(ctx context.Context, event *corev1.Event, streamSequence uint64, durableDelivery bool) error { if event == nil { return nil @@ -436,19 +397,10 @@ func (m *NotificationMaterializer) materializeEvent(ctx context.Context, event * if _, err := m.core.notificationOccurrences.PurgeUser(ctx, userID); err != nil { return err } - 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 - } + if err := m.core.notificationOccurrences.purgeNotificationReadBoundaries(ctx, userID); err != nil { + return err } - return nil + return m.purgeVisibilityBoundaries(ctx, userID) } return nil } diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go index 5fec99a49..0cb262125 100644 --- a/cli/internal/core/notification_materializer_test.go +++ b/cli/internal/core/notification_materializer_test.go @@ -1,6 +1,7 @@ package core import ( + "encoding/binary" "errors" "testing" "time" @@ -184,90 +185,6 @@ func TestStoreWorkClearsStaleRecipientsWhenRetryNowProducesNoWork(t *testing.T) } } -func TestVisibilityBoundaryRejectsDelayedSourceAfterLeaveAndRejoin(t *testing.T) { - chattoCore, _ := setupTestCore(t) - ctx := testContext(t) - author, err := chattoCore.CreateUser(ctx, SystemActorID, "boundary-author", "Boundary Author", "password") - if err != nil { - t.Fatalf("CreateUser author: %v", err) - } - recipient, err := chattoCore.CreateUser(ctx, SystemActorID, "boundary-recipient", "Boundary Recipient", "password") - if err != nil { - t.Fatalf("CreateUser recipient: %v", err) - } - room, err := chattoCore.CreateRoom(ctx, author.Id, KindChannel, "", "boundary-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) - } - makeSource := func(id string) (*corev1.Event, []*corev1.NotificationOccurrence) { - t.Helper() - source := newEvent(author.Id, &corev1.Event{Event: &corev1.Event_MessagePosted{ - MessagePosted: &corev1.MessagePostedEvent{RoomId: room.Id}, - }}) - source.Id = id - return source, 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, - }}, - }}, - ) - } - older, olderWork := makeSource("E-before-leave") - olderSequence, err := chattoCore.EventPublisher.AppendEventually(ctx, evtstream.RoomAggregate(room.Id).SubjectFor(older), older) - if err != nil { - t.Fatalf("append older source: %v", err) - } - if err := chattoCore.LeaveRoom(ctx, recipient.Id, KindChannel, recipient.Id, room.Id); err != nil { - t.Fatalf("LeaveRoom: %v", err) - } - if _, err := chattoCore.JoinRoom(ctx, recipient.Id, KindChannel, recipient.Id, room.Id); err != nil { - t.Fatalf("rejoin room: %v", err) - } - if err := chattoCore.notificationMaterializer.StoreWork(ctx, older, olderWork); err != nil { - t.Fatalf("StoreWork older: %v", err) - } - if err := chattoCore.notificationMaterializer.MaterializeEvent(ctx, older, olderSequence); err != nil { - t.Fatalf("MaterializeEvent older: %v", err) - } - if occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id, NotificationOccurrenceViewInbox); err != nil || len(occurrences) != 0 { - t.Fatalf("older occurrences = (%v, %v), want none", occurrences, err) - } - - newer, newerWork := makeSource("E-after-rejoin") - newerSequence, err := chattoCore.EventPublisher.AppendEventually(ctx, evtstream.RoomAggregate(room.Id).SubjectFor(newer), newer) - if err != nil { - t.Fatalf("append newer source: %v", err) - } - if err := chattoCore.notificationMaterializer.StoreWork(ctx, newer, newerWork); err != nil { - t.Fatalf("StoreWork newer: %v", err) - } - if err := chattoCore.notificationMaterializer.MaterializeEvent(ctx, newer, newerSequence); err != nil { - t.Fatalf("MaterializeEvent newer: %v", err) - } - if occurrences, err := chattoCore.NotificationOccurrences().List(ctx, recipient.Id, NotificationOccurrenceViewInbox); err != nil || len(occurrences) != 1 || occurrences[0].GetSourceEventId() != newer.GetId() { - t.Fatalf("newer occurrences = (%v, %v), want newer source", occurrences, err) - } - if err := chattoCore.notificationMaterializer.materializeEvent(ctx, &corev1.Event{ - Id: "E-boundary-account-delete", - Event: &corev1.Event_UserAccountDeleted{UserAccountDeleted: &corev1.UserAccountDeletedEvent{ - UserId: recipient.Id, - }}, - }, newerSequence+1, true); err != nil { - t.Fatalf("materialize account deletion: %v", err) - } - if _, err := chattoCore.storage.runtimeStateKV.Get(ctx, notificationVisibilityBoundaryKey(recipient.Id, room.Id)); !errors.Is(err, jetstream.ErrKeyNotFound) && !errors.Is(err, jetstream.ErrKeyDeleted) { - t.Fatalf("visibility boundary remains after account deletion: %v", err) - } -} - func TestNotificationMaterializerConsumerStartsAtCreationBoundary(t *testing.T) { chattoCore, _ := setupTestCore(t) ctx := testContext(t) @@ -303,7 +220,7 @@ func TestNotificationMaterializerSkipsFactsOutsideRetentionWindow(t *testing.T) t.Fatalf("create stale work: %v", err) } - if err := chattoCore.notificationMaterializer.MaterializeEvent(ctx, source, 1); err != nil { + 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 { @@ -394,13 +311,20 @@ func TestLateNotificationOccurrenceStartsReadWhenCursorAlreadyCoversTarget(t *te 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: "E-late-materialization", - SourceCreated: posted.GetCreatedAt().AsTime(), - ActorID: author.Id, - Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: posted.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, @@ -417,7 +341,7 @@ func TestLateNotificationOccurrenceStartsReadWhenCursorAlreadyCoversTarget(t *te } } -func TestDuplicateMaterializationReconcilesReadMarkerThatWonCreationRace(t *testing.T) { +func TestDuplicateMaterializationPreservesExplicitMarkUnread(t *testing.T) { chattoCore, _ := setupTestCore(t) ctx := testContext(t) author, err := chattoCore.CreateUser(ctx, SystemActorID, "race-author", "Race Author", "password") @@ -441,12 +365,17 @@ func TestDuplicateMaterializationReconcilesReadMarkerThatWonCreationRace(t *test 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: "E-read-race", - SourceCreated: posted.GetCreatedAt().AsTime(), - ActorID: author.Id, - Target: &corev1.NotificationTarget{RoomId: room.Id, EventId: posted.Id}, + 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, @@ -460,8 +389,8 @@ func TestDuplicateMaterializationReconcilesReadMarkerThatWonCreationRace(t *test if _, err := chattoCore.ReadState().MarkRoomAsRead(ctx, reader.Id, room.Id, posted.Id); err != nil { t.Fatalf("MarkRoomAsRead: %v", err) } - // Put the occurrence back into the exact stale state produced when the - // marker scan and creation miss one another, then retry materialization. + // An explicit Mark unread is user-owned triage and must survive durable + // source redelivery. unread := corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD if _, err := chattoCore.NotificationOccurrences().Update(ctx, reader.Id, created.GetId(), UpdateNotificationOccurrenceInput{InboxState: &unread}); err != nil { t.Fatalf("restore stale unread occurrence: %v", err) @@ -471,15 +400,15 @@ func TestDuplicateMaterializationReconcilesReadMarkerThatWonCreationRace(t *test 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("reconciled state = %v, want READ", reconciled.GetInboxState()) + if reconciled.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { + t.Fatalf("duplicate state = %v, want UNREAD", reconciled.GetInboxState()) } if reconciled.GetAlertState() != corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED { - t.Fatalf("reconciled alert state = %v, want SILENCED", reconciled.GetAlertState()) + t.Fatalf("duplicate alert state = %v, want SILENCED", reconciled.GetAlertState()) } } -func TestReactionRemovalSkipsPromptMaterializedLaterReadd(t *testing.T) { +func TestReactionRemovalPreservesCausallyLaterReadd(t *testing.T) { chattoCore, _ := setupTestCore(t) ctx := testContext(t) now := time.Now().UTC() @@ -505,7 +434,7 @@ func TestReactionRemovalSkipsPromptMaterializedLaterReadd(t *testing.T) { return occurrence } older := create("E-reaction-before-removal", 100) - later := create("E-reaction-after-removal", 0) + later := create("E-reaction-after-removal", 300) removed, err := chattoCore.NotificationOccurrences().RemoveReaction( ctx, @@ -523,7 +452,76 @@ func TestReactionRemovalSkipsPromptMaterializedLaterReadd(t *testing.T) { t.Fatalf("older reaction occurrence remains: %v", err) } if _, err := chattoCore.NotificationOccurrences().Get(ctx, "U-reaction-recipient", later.GetId()); err != nil { - t.Fatalf("prompt-materialized later re-add was removed: %v", err) + 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, NotificationOccurrenceViewInbox); 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, NotificationOccurrenceViewInbox); err != nil || len(occurrences) != 1 || occurrences[0].GetSourceEventId() != newer.GetId() { + t.Fatalf("newer occurrences = (%v, %v), want newer source", occurrences, err) } } @@ -647,7 +645,7 @@ func TestHistoricalNotificationReplaySkipsDeletedRecipient(t *testing.T) { if err := chattoCore.notificationMaterializer.StoreWork(ctx, source, work); err != nil { t.Fatalf("StoreWork: %v", err) } - err = chattoCore.notificationMaterializer.MaterializeEvent(ctx, source, 1) + err = chattoCore.notificationMaterializer.materializeEvent(ctx, source, 1, true) if err != nil { t.Fatalf("replay notification source after account deletion: %v", err) } @@ -694,7 +692,7 @@ func TestHistoricalNotificationReplaySkipsDeletedRoom(t *testing.T) { if err := chattoCore.notificationMaterializer.StoreWork(ctx, source, work); err != nil { t.Fatalf("StoreWork: %v", err) } - err = chattoCore.notificationMaterializer.MaterializeEvent(ctx, source, 1) + err = chattoCore.notificationMaterializer.materializeEvent(ctx, source, 1, true) if err != nil { t.Fatalf("replay notification source after room deletion: %v", err) } @@ -745,7 +743,7 @@ func TestDelayedMessageNotificationRetryDoesNotOutrunRetraction(t *testing.T) { if err := chattoCore.notificationMaterializer.StoreWork(ctx, posted, work); err != nil { t.Fatalf("StoreWork: %v", err) } - if err := chattoCore.notificationMaterializer.MaterializeEvent(ctx, posted, 100); err != nil { + 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, NotificationOccurrenceViewInbox) @@ -813,7 +811,7 @@ func TestDelayedReactionNotificationRetryDoesNotOutrunRemoval(t *testing.T) { if err := chattoCore.notificationMaterializer.StoreWork(ctx, addEvent, work); err != nil { t.Fatalf("StoreWork: %v", err) } - if err := chattoCore.notificationMaterializer.MaterializeEvent(ctx, addEvent, 100); err != nil { + 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, NotificationOccurrenceViewInbox) diff --git a/cli/internal/core/notification_occurrence_model.go b/cli/internal/core/notification_occurrence_model.go index 4c6063326..f6af9c832 100644 --- a/cli/internal/core/notification_occurrence_model.go +++ b/cli/internal/core/notification_occurrence_model.go @@ -149,22 +149,12 @@ func (m *NotificationOccurrenceModel) Create(ctx context.Context, input CreateNo state := input.InitialState if state == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNSPECIFIED { state = corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD - if !input.SkipReadLookup { - covered, err := m.targetCoveredByReadState(ctx, input.RecipientID, input.Target, input.SourceCreated) - if err != nil { - return nil, false, fmt.Errorf("resolve initial notification read state: %w", err) - } - if covered { - state = corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ - } - } } alertState := corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_NOT_APPLICABLE - if strongest == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT && - state == corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { - alertState = corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_PENDING - } else if strongest == corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT { - alertState = corev1.NotificationAlertState_NOTIFICATION_ALERT_STATE_SILENCED + 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), @@ -179,7 +169,6 @@ func (m *NotificationOccurrenceModel) Create(ctx context.Context, input CreateNo StrongestIntensity: strongest, InboxState: state, EvaluatedAt: timestamppb.New(evaluatedAt), - UpdatedAt: timestamppb.New(now), ExpiresAt: timestamppb.New(expiresAt), AlertState: alertState, } @@ -190,43 +179,14 @@ func (m *NotificationOccurrenceModel) Create(ctx context.Context, input CreateNo 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.index.occurrenceBySource(ctx, input.RecipientID, input.SourceEventID) - if readErr != nil { - return nil, false, readErr - } - if exists { - if existing.occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED { - return nil, false, nil - } - existingOccurrence, ensureErr := m.ensureSourceStreamSequence(ctx, input.RecipientID, input.SourceEventID, input.SourceStreamSequence) - if ensureErr != nil { - return nil, false, ensureErr - } - existingOccurrence, changed, reconcileErr := m.reconcileOccurrenceReadState(ctx, existingOccurrence, input.SkipReadLookup) - if changed { - m.core.publishNotificationOccurrenceChanged(ctx, existingOccurrence, false, false) - } - return existingOccurrence, false, reconcileErr - } - entry, getErr := m.kv.Get(ctx, key) - if getErr != nil { - return nil, false, fmt.Errorf("read concurrently created notification occurrence: %w", getErr) - } - if waitErr := m.index.waitForRevision(ctx, key, entry.Revision()); waitErr != nil { - return nil, false, waitErr - } - existing, exists, readErr = m.index.occurrenceBySource(ctx, input.RecipientID, input.SourceEventID) + 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, ensureErr := m.ensureSourceStreamSequence(ctx, input.RecipientID, input.SourceEventID, input.SourceStreamSequence) - if ensureErr != nil { - return nil, false, ensureErr - } - existingOccurrence, changed, reconcileErr := m.reconcileOccurrenceReadState(ctx, existingOccurrence, input.SkipReadLookup) + existingOccurrence, changed, reconcileErr := m.finalizeOccurrence(ctx, existing.occurrence, input.SkipReadLookup) if changed { m.core.publishNotificationOccurrenceChanged(ctx, existingOccurrence, false, false) } @@ -238,9 +198,9 @@ func (m *NotificationOccurrenceModel) Create(ctx context.Context, input CreateNo if err := m.index.waitForRevision(ctx, key, revision); err != nil { return nil, false, fmt.Errorf("wait for notification occurrence: %w", err) } - occurrence, _, err = m.reconcileOccurrenceReadState(ctx, occurrence, input.SkipReadLookup) + occurrence, _, err = m.finalizeOccurrence(ctx, occurrence, input.SkipReadLookup) if err != nil { - return nil, true, fmt.Errorf("reconcile created notification read state: %w", err) + return nil, true, fmt.Errorf("finalize created notification occurrence: %w", err) } m.logger.Debug("Notification occurrence created", "notification_id", occurrence.GetId(), @@ -252,51 +212,47 @@ func (m *NotificationOccurrenceModel) Create(ctx context.Context, input CreateNo return proto.Clone(occurrence).(*corev1.NotificationOccurrence), true, nil } -// reconcileOccurrenceReadState closes the race between advancing a room or -// thread read marker and creating the covered occurrence. Creation first waits -// for the occurrence index; after this check, a later marker advance must see -// the indexed occurrence in MarkCoveredRead. -func (m *NotificationOccurrenceModel) reconcileOccurrenceReadState(ctx context.Context, occurrence *corev1.NotificationOccurrence, skip bool) (*corev1.NotificationOccurrence, bool, error) { - if skip || occurrence == nil || occurrence.GetInboxState() != corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD { - return occurrence, false, nil - } - covered, err := m.targetCoveredByReadState(ctx, occurrence.GetRecipientId(), occurrence.GetTarget(), occurrence.GetSourceCreatedAt().AsTime()) - if err != nil { - return occurrence, false, err - } - if !covered { - return occurrence, false, nil - } - read := corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ - updated, err := m.update(ctx, occurrence.GetRecipientId(), occurrence.GetId(), UpdateNotificationOccurrenceInput{InboxState: &read}, false) - if errors.Is(err, ErrNotFound) { - return occurrence, false, 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 to a later explicit "Mark unread" mutation. +func (m *NotificationOccurrenceModel) finalizeOccurrence(ctx context.Context, occurrence *corev1.NotificationOccurrence, skipReadLookup bool) (*corev1.NotificationOccurrence, bool, error) { + if occurrence == nil { + return nil, false, nil } - return updated, err == nil, err -} - -func (m *NotificationOccurrenceModel) ensureSourceStreamSequence(ctx context.Context, userID, sourceEventID string, sequence uint64) (*corev1.NotificationOccurrence, error) { for attempt := 0; attempt < maxNotificationUpdateRetries; attempt++ { - entry, exists, err := m.index.occurrenceBySource(ctx, userID, sourceEventID) + entry, exists, err := m.storedOccurrenceBySource(ctx, occurrence.GetRecipientId(), occurrence.GetSourceEventId()) if err != nil || !exists { - return nil, err + return nil, false, err } if entry.occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED || - sequence == 0 || entry.occurrence.GetSourceStreamSequence() != 0 { - return entry.occurrence, nil + entry.occurrence.GetUpdatedAt() != nil { + return entry.occurrence, false, nil } updated := proto.Clone(entry.occurrence).(*corev1.NotificationOccurrence) - updated.SourceStreamSequence = sequence + 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) { - if waitErr := m.index.waitForRevisionAfter(ctx, entry.key, entry.revision); waitErr != nil { - return nil, waitErr - } continue } - return written, err + return written, err == nil, err } - return nil, fmt.Errorf("notification source sequence update failed after %d retries", maxNotificationUpdateRetries) + 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) { @@ -540,14 +496,19 @@ func (m *NotificationOccurrenceModel) DeleteGroup(ctx context.Context, userID, g return 0, ErrNotFound } -func (m *NotificationOccurrenceModel) MarkCoveredRead(ctx context.Context, userID, roomID, threadRootEventID string, readThrough time.Time) (int, error) { - entries, err := m.index.userEntries(ctx, userID) +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 - read := corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_READ for _, entry := range entries { occurrence := entry.occurrence if occurrence.GetRemovalReason() != corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_UNSPECIFIED || @@ -556,32 +517,53 @@ func (m *NotificationOccurrenceModel) MarkCoveredRead(ctx context.Context, userI occurrence.GetTarget().GetThreadRootEventId() != threadRootEventID { continue } - coveredAt := occurrence.GetSourceCreatedAt().AsTime() - if notificationOccurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REACTION) { - room, err := m.core.FindRoomByID(ctx, roomID) - if err != nil { - return updated, err + 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 } - targetAt, err := m.core.GetEventTimestamp(ctx, KindOfRoom(room), roomID, occurrence.GetTarget().GetEventId()) + 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 !targetAt.IsZero() { - coveredAt = targetAt + if !covered { + break } - } - if coveredAt.After(readThrough) { - continue - } - item, err := m.update(ctx, userID, occurrence.GetId(), UpdateNotificationOccurrenceInput{InboxState: &read}, false) - if err != nil { - if errors.Is(err, ErrNotFound) { + 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 lastUpdated != nil { - m.core.publishNotificationOccurrenceChanged(ctx, lastUpdated, false, false) + if err != nil { + return updated, err } - return updated, err + break + } + if item == nil { + continue } updated++ lastUpdated = item @@ -702,12 +684,42 @@ func (m *NotificationOccurrenceModel) RenewAlertClaim(ctx context.Context, expec return renewed, err == nil, err } -// TargetVisible revalidates the recipient's current room membership before an -// occurrence is hydrated or delivered outside Chatto. +// 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 +} + +// TargetVisible revalidates the recipient, room membership, target-message +// lifecycle, and (for reaction occurrences) the exact current reaction before +// an occurrence is listed, hydrated, or delivered outside Chatto. func (m *NotificationOccurrenceModel) TargetVisible(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 @@ -715,7 +727,35 @@ func (m *NotificationOccurrenceModel) TargetVisible(ctx context.Context, recipie if err != nil { return false, err } - return m.core.RoomMembershipExists(ctx, KindOfRoom(room), recipientID, room.GetId()) + 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) { @@ -730,7 +770,7 @@ func (m *NotificationOccurrenceModel) setAlertState(ctx context.Context, entry n } func (m *NotificationOccurrenceModel) RemoveTarget(ctx context.Context, roomID, eventID string, reason corev1.NotificationRemovalReason) (int, error) { - entries, err := m.index.allEntries(ctx) + entries, err := m.storedOccurrenceEntries(ctx, "") if err != nil { return 0, err } @@ -740,27 +780,28 @@ func (m *NotificationOccurrenceModel) RemoveTarget(ctx context.Context, roomID, if target.GetRoomId() != roomID || (target.GetEventId() != eventID && target.GetThreadRootEventId() != eventID) { continue } - ok, err := m.Delete(ctx, entry.occurrence.GetRecipientId(), entry.occurrence.GetId(), reason) + 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) { - entry, exists, err := m.index.occurrenceBySource(ctx, userID, sourceEventID) - if err != nil || !exists { - return false, err + written, removed, err := m.deleteStoredOccurrence(ctx, userID, sourceEventID, reason) + if err == nil && removed { + m.core.publishNotificationOccurrenceChanged(ctx, written, false, true) } - return m.Delete(ctx, userID, entry.occurrence.GetId(), reason) + return removed, err } func (m *NotificationOccurrenceModel) RemoveReaction(ctx context.Context, recipientID, roomID, messageEventID, actorID, emoji string, removedAtSequence uint64) (int, error) { - entries, err := m.index.userEntries(ctx, recipientID) + entries, err := m.storedOccurrenceEntries(ctx, recipientID) if err != nil { return 0, err } @@ -773,20 +814,16 @@ func (m *NotificationOccurrenceModel) RemoveReaction(ctx context.Context, recipi !notificationOccurrenceHasReason(occurrence, corev1.NotificationReason_NOTIFICATION_REASON_REACTION) { continue } - if occurrence.GetSourceStreamSequence() == 0 { - // The ordered worker has already backfilled every source before this - // removal. A zero here is prompt materialization from a later fact. - continue - } if occurrence.GetSourceStreamSequence() >= removedAtSequence { continue } - ok, err := m.Delete(ctx, occurrence.GetRecipientId(), occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_REACTION_REMOVED) + 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 @@ -802,7 +839,7 @@ func notificationOccurrenceHasReason(occurrence *corev1.NotificationOccurrence, } func (m *NotificationOccurrenceModel) RemoveRoomForUser(ctx context.Context, userID, roomID string, removedThroughSequence uint64, reason corev1.NotificationRemovalReason) (int, error) { - entries, err := m.index.userEntries(ctx, userID) + entries, err := m.storedOccurrenceEntries(ctx, userID) if err != nil { return 0, err } @@ -811,28 +848,23 @@ func (m *NotificationOccurrenceModel) RemoveRoomForUser(ctx context.Context, use if entry.occurrence.GetTarget().GetRoomId() != roomID { continue } - if entry.occurrence.GetSourceStreamSequence() == 0 { - // The ordered worker has already backfilled every source before this - // membership loss. A zero here is prompt materialization from a - // causally later fact and must survive. + if removedThroughSequence != 0 && entry.occurrence.GetSourceStreamSequence() >= removedThroughSequence { continue } - if removedThroughSequence == 0 || entry.occurrence.GetSourceStreamSequence() >= removedThroughSequence { - continue - } - ok, err := m.Delete(ctx, userID, entry.occurrence.GetId(), reason) + 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.index.allEntries(ctx) + entries, err := m.storedOccurrenceEntries(ctx, "") if err != nil { return 0, err } @@ -841,12 +873,13 @@ func (m *NotificationOccurrenceModel) RemoveRoom(ctx context.Context, roomID str if entry.occurrence.GetTarget().GetRoomId() != roomID { continue } - ok, err := m.Delete(ctx, entry.occurrence.GetRecipientId(), entry.occurrence.GetId(), reason) + 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 @@ -855,7 +888,7 @@ func (m *NotificationOccurrenceModel) RemoveRoom(ctx context.Context, roomID str func (m *NotificationOccurrenceModel) PurgeUser(ctx context.Context, userID string) (int, error) { purged := 0 for { - entries, err := m.index.userEntries(ctx, userID) + entries, err := m.storedOccurrenceEntries(ctx, userID) if err != nil { return purged, err } @@ -865,9 +898,6 @@ func (m *NotificationOccurrenceModel) PurgeUser(ctx context.Context, userID stri 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) { - if waitErr := m.index.waitForRevisionAfter(ctx, entry.key, entry.revision); waitErr != nil { - return purged, waitErr - } continue } return purged, err @@ -910,24 +940,6 @@ func (m *NotificationOccurrenceModel) updateAtRevision(ctx context.Context, entr return fresh.occurrence, nil } -func (m *NotificationOccurrenceModel) targetCoveredByReadState(ctx context.Context, userID string, target *corev1.NotificationTarget, sourceCreated time.Time) (bool, error) { - room, err := m.core.FindRoomByID(ctx, target.GetRoomId()) - if err != nil { - return false, err - } - kind := KindOfRoom(room) - if target.GetThreadRootEventId() != "" { - readAt, err := m.core.GetThreadLastOpened(ctx, kind, userID, target.GetRoomId(), target.GetThreadRootEventId()) - return !readAt.IsZero() && !readAt.Before(sourceCreated), err - } - markerID, exists, err := m.core.PeekLastReadEventID(ctx, userID, target.GetRoomId()) - if err != nil || !exists || markerID == "" { - return false, err - } - readAt, err := m.core.GetEventTimestamp(ctx, kind, target.GetRoomId(), markerID) - return !readAt.IsZero() && !readAt.Before(sourceCreated), err -} - func normalizeNotificationReasons(input []*corev1.NotificationReasonMatch) []*corev1.NotificationReasonMatch { byReason := make(map[corev1.NotificationReason]corev1.NotificationDeliveryIntensity) for _, match := range input { diff --git a/cli/internal/core/notification_occurrence_model_test.go b/cli/internal/core/notification_occurrence_model_test.go index 10a82de9b..57c55aab0 100644 --- a/cli/internal/core/notification_occurrence_model_test.go +++ b/cli/internal/core/notification_occurrence_model_test.go @@ -228,6 +228,101 @@ func TestNotificationOccurrenceReadCancelsPendingAlert(t *testing.T) { } } +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 TestTargetVisibleChecksMessageAndExactReactionLifecycle(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, NotificationOccurrenceViewInbox) + 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().TargetVisible(ctx, author.Id, reactionOccurrence); err != nil || !visible { + t.Fatalf("TargetVisible before removal = (%v, %v), want true, 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().TargetVisible(ctx, author.Id, reactionOccurrence); err != nil || visible { + t.Fatalf("TargetVisible after reaction removal = (%v, %v), want false, 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().TargetVisible(ctx, author.Id, reactionOccurrence); err != nil || visible { + t.Fatalf("TargetVisible after target retraction = (%v, %v), want false, nil", visible, err) + } +} + func TestNotificationOccurrenceIndexConvergesAcrossReplicas(t *testing.T) { chattoCore, _ := setupTestCore(t) ctx := testContext(t) diff --git a/cli/internal/core/notification_occurrence_storage.go b/cli/internal/core/notification_occurrence_storage.go new file mode 100644 index 000000000..b8d46deac --- /dev/null +++ b/cli/internal/core/notification_occurrence_storage.go @@ -0,0 +1,110 @@ +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() { + 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_DONE, + 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 index cbce7f244..06404507a 100644 --- a/cli/internal/core/notification_policy.go +++ b/cli/internal/core/notification_policy.go @@ -18,7 +18,6 @@ var notificationPolicyReasons = []corev1.NotificationReason{ corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_THREAD, corev1.NotificationReason_NOTIFICATION_REASON_FOLLOWED_ROOM, corev1.NotificationReason_NOTIFICATION_REASON_REACTION, - corev1.NotificationReason_NOTIFICATION_REASON_ROOM_INVITATION, } // NotificationPolicyPreference is one cause's explicit and effective policy @@ -55,8 +54,7 @@ func defaultNotificationIntensity(reason corev1.NotificationReason) corev1.Notif 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_ROOM_INVITATION: + corev1.NotificationReason_NOTIFICATION_REASON_ALL: return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_ALERT default: return corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_UNSPECIFIED diff --git a/cli/internal/core/notification_policy_test.go b/cli/internal/core/notification_policy_test.go index a419fe069..3f413c4b1 100644 --- a/cli/internal/core/notification_policy_test.go +++ b/cli/internal/core/notification_policy_test.go @@ -26,6 +26,17 @@ func TestNotificationPolicyInheritanceByCause(t *testing.T) { 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, 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_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/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/reactions.go b/cli/internal/core/reactions.go index 1f486c736..2a278ec8c 100644 --- a/cli/internal/core/reactions.go +++ b/cli/internal/core/reactions.go @@ -81,11 +81,10 @@ func (s *ReactionModel) addReaction(ctx context.Context, kind RoomKind, roomID, if !added { return false, nil } - if err := s.core.notificationMaterializer.MaterializeEvent(ctx, event, sequence); err != nil { - s.core.logger.Warn("Failed to materialize reaction notification; background replay will retry", + 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, @@ -130,11 +129,10 @@ func (s *ReactionModel) removeReaction(ctx context.Context, kind RoomKind, roomI if !removed { return false, nil } - if err := s.core.notificationMaterializer.MaterializeEvent(ctx, event, sequence); err != nil { - s.core.logger.Warn("Failed to remove reaction notification; background replay will retry", + 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, @@ -400,13 +398,10 @@ func (s *ReactionModel) mutateAuthorizedReaction(ctx context.Context, input Reac 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.MaterializeEvent(ctx, event, result.Sequences[0]); err != nil { - s.core.logger.Warn("Failed to apply reaction notification effect; background replay will retry", - "room_id", input.RoomID, - "message_event_id", committedMessageEventID, - "error", 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" diff --git a/cli/internal/core/read_state_model.go b/cli/internal/core/read_state_model.go index cd02bae37..4719329ac 100644 --- a/cli/internal/core/read_state_model.go +++ b/cli/internal/core/read_state_model.go @@ -112,12 +112,10 @@ func (s *ReadStateModel) MarkRoomAsRead(ctx context.Context, actorID, roomID, up } readNotifications := 0 - if hasLast && !lastTime.IsZero() { - readNotifications, err = s.core.notificationOccurrences.MarkCoveredRead(ctx, actorID, room.Id, "", lastTime) + if hasLast && lastEventID != "" { + readNotifications, err = s.core.notificationOccurrences.MarkCoveredRead(ctx, actorID, room.Id, "", lastEventID) if err != nil { - s.core.logger.Warn("Failed to reconcile room read state with notification inbox", - "user_id", actorID, "room_id", room.Id, "error", err) - readNotifications = 0 + return nil, fmt.Errorf("reconcile room read state with notification inbox: %w", err) } } if markerUpdated || readNotifications > 0 { @@ -170,11 +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() { - if _, err := s.core.notificationOccurrences.MarkCoveredRead(ctx, actorID, room.Id, threadRootEventID, markerTime); err != nil { - s.core.logger.Warn("Failed to reconcile thread read state with notification inbox", - "user_id", actorID, "room_id", room.Id, "thread_root_event_id", threadRootEventID, "error", err) - } + 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 bdfb394f9..f215487d7 100644 --- a/cli/internal/core/read_state_model_test.go +++ b/cli/internal/core/read_state_model_test.go @@ -147,6 +147,10 @@ func TestReadStateModel_MarkRoomAsReadPublishesLiveEventWhenOccurrencesBecomeRea if err := core.SetLastReadEventID(ctx, KindChannel, reader.Id, room.Id, second.Id); err != nil { t.Fatalf("SetLastReadEventID: %v", err) } + 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, @@ -157,8 +161,9 @@ func TestReadStateModel_MarkRoomAsReadPublishesLiveEventWhenOccurrencesBecomeRea Reason: corev1.NotificationReason_NOTIFICATION_REASON_DIRECT_MENTION, Intensity: corev1.NotificationDeliveryIntensity_NOTIFICATION_DELIVERY_INTENSITY_BADGE, }}, - InitialState: corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD, - SkipReadLookup: true, + InitialState: corev1.NotificationInboxState_NOTIFICATION_INBOX_STATE_UNREAD, + SkipReadLookup: true, + SourceStreamSequence: firstEntry.StreamSeq, }) if err != nil { t.Fatalf("Create occurrence: %v", err) diff --git a/cli/internal/core/room_membership.go b/cli/internal/core/room_membership.go index e870b894b..c6f88a33b 100644 --- a/cli/internal/core/room_membership.go +++ b/cli/internal/core/room_membership.go @@ -418,13 +418,12 @@ func (c *ChattoCore) appendRoomLeaveBatch(ctx context.Context, kind RoomKind, ro return fmt.Errorf("publish room leave batch: %w", err) } leaveSequence := seqs[len(prefixEvents)] - // Establish the causal visibility boundary immediately after commit and - // before the leave becomes visible to a possible rejoin request. The durable - // notification consumer repeats this write after crashes. + // 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 { - c.logger.Warn("Failed to record prompt notification visibility boundary; background replay will retry", - "user_id", userID, "room_id", roomID, "error", err) + return fmt.Errorf("record notification visibility boundary: %w", err) } } pos := events.SubjectPosition(filter, seqs[len(seqs)-1]) diff --git a/cli/internal/pb/chatto/core/v1/notification.pb.go b/cli/internal/pb/chatto/core/v1/notification.pb.go index d473e36ce..46951d147 100644 --- a/cli/internal/pb/chatto/core/v1/notification.pb.go +++ b/cli/internal/pb/chatto/core/v1/notification.pb.go @@ -885,8 +885,9 @@ type NotificationOccurrence struct { 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 only to order - // lifecycle cleanup; never exposed through the public API. + // 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 diff --git a/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index 1517c57d7..7bdcdd76f 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -90,15 +90,15 @@ the shared `events.DurableWorker` framework without replaying unrelated message history at rollout. The single consumer lane preserves source-before-lifecycle order across replicas. -The committing request also makes one prompt materialization attempt for low -latency and passes the committed EVT stream sequence into the same causal -checks used by the worker. More than one replica may overlap the same work; -deterministic recipient/source occurrence identity and KV OCC make that overlap -safe. Delayed creation additionally 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. +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. Prepared work contains enough immutable provenance to reproduce the recipient and reason decision without later policy evaluation. In particular, message @@ -122,7 +122,7 @@ 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 only for causal cleanup; + 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; @@ -136,19 +136,28 @@ loses visibility, the occurrence cannot preserve stale copied content. ### Read-state and lifecycle convergence -Inbox state is distinct from room and thread read cursors. When an occurrence -is first derived, the notification subsystem compares its exact target with the -authoritative read cursor. Covered activity starts as read; newer activity -starts as unread. Read-cursor advancement also transitions covered existing -occurrences from unread to read. Creation waits for the occurrence index and -then checks the cursor again; once that check begins, a later cursor advance -must see the indexed occurrence. Both orders therefore converge without -deleting notification history. A reaction remains new when it arrives, but a -later room/thread read covers it according to the reacted-to message's -timestamp rather than the reaction's source time. +Inbox 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; an explicit later Mark unread therefore +cannot be undone by duplicate source delivery. User triage mutations, read reconciliation, retraction, reaction removal, and -visibility changes all use KV OCC. Retraction, lost visibility, explicit +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 @@ -201,14 +210,17 @@ 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 or Done silences any pending or claimed Alert. -Workers verify the exact claim immediately before delivery and revalidate -current target visibility before hydration and again before sending. Failed -delivery remains claimed until a bounded retry delay, avoiding a hot loop. The -worker renews the exact claim for a delivery-sized interval immediately before -calling the provider. 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. +Workers verify the exact claim immediately before delivery and revalidate the +current account, membership, unretracted target message, exact reaction, and +subscription ownership before sending. 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. +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. diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 37d4942f4..5ed32b9a2 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -46,7 +46,7 @@ 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 | -| 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 mutation attempt reconciles its exact prepared recipient set, including clearing stale work when a retry now evaluates to Off. The committing path promptly attempts deterministic KV materialization with the committed EVT sequence but leaves prepared work for the durable pass. The shared `chatto-notification-materializer-v2` pull consumer begins at its creation boundary, and server boot readiness waits for that boundary before serving commands. It permits one globally in-flight delivery, waits for source projections, rejects expired facts, checks the trigger marker, loads recipient work only when present, applies it idempotently, deletes completed work and the marker, and acknowledges. Room visibility loss records a 90-day causal boundary immediately after commit and again in the worker; delayed work at or before it is rejected after rejoin. Reaction removals without v2 work match exact recipient/actor/message/emoji provenance below the removal sequence. Committed unread Alert occurrences are indexed and OCC-leased before Web Push; a post-create read check closes marker/materialization races. Read/Done cancels pending delivery, failed or expired claims remain retryable, and delivery renews its exact lease and revalidates current visibility | Replicas share the ordered queue lane. Recipient/source KV identity, tombstones, exact prepared-work replacement, and visibility boundaries make request/worker overlap safe. Delayed creation checks current account, membership, retraction, and exact reaction state. Account deletion retries occurrence purge and removes visibility boundaries. Failed source appends can leave untriggered work and markers, 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 | +| 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 mutation attempt reconciles 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. 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; final delivery revalidates the exact target/reaction, 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, and visibility boundaries 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 work and markers, 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 | Observability is currently domain-specific. Call reconciliation records its diff --git a/docs/architecture/runtime-state.md b/docs/architecture/runtime-state.md index ad4e882ca..76f9a61e6 100644 --- a/docs/architecture/runtime-state.md +++ b/docs/architecture/runtime-state.md @@ -60,9 +60,10 @@ 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_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, Inbox/Done state, and leased Alert delivery. 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. One filtered watcher per process supplies authoritative indexed reads, pending-Alert candidates, and realtime replacements; 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_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, Inbox/Done state, and leased Alert delivery. 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 room leave or member removal relevant to notification materialization. The request path records it immediately after the membership fact commits and the ordered worker repeats it. 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. | | `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. | diff --git a/docs/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index ec12171f7..efd40faa4 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -76,7 +76,10 @@ The initial product defaults are: | New activity in a followed thread | Badge | | New activity in a followed room | Off | | Reaction to the user's message | Badge | -| Room invitation, once supported | Alert | + +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 diff --git a/docs/fdr/FDR-013-web-push-notifications.md b/docs/fdr/FDR-013-web-push-notifications.md index fe19d220d..967365ef0 100644 --- a/docs/fdr/FDR-013-web-push-notifications.md +++ b/docs/fdr/FDR-013-web-push-notifications.md @@ -24,7 +24,7 @@ Users can opt in to receive notifications through the browser's W3C Web Push sys - 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. - Clicking a push notification navigates to the relevant room, thread, or DM. -- Immediately before a regular push is sent, Chatto confirms that the occurrence is still unread and Alert-eligible, its target is still visible, and the exact prepared subscription is still active. This prevents slower asynchronous delivery from overtaking inbox triage, visibility loss, or subscription rotation. +- Immediately before a regular push is sent, Chatto 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 subscription reads fail the attempt for retry instead of being treated as an empty device set. This prevents slower asynchronous delivery from overtaking inbox triage, 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. Inbox state changes only through Chatto's read, Done, 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. diff --git a/proto/chatto/core/v1/notification.proto b/proto/chatto/core/v1/notification.proto index 0ca01445d..f42b6297f 100644 --- a/proto/chatto/core/v1/notification.proto +++ b/proto/chatto/core/v1/notification.proto @@ -196,7 +196,8 @@ message NotificationOccurrence { 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 only to order - // lifecycle cleanup; never exposed through the public API. + // 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; } From 870a06284145378b6ba46e815597370bea7a05ba Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 15:07:46 +0200 Subject: [PATCH 12/30] fix(notifications): fence target visibility checks --- cli/cmd/run.go | 8 +-- .../connectapi/notification_occurrences.go | 55 +++++++++------- cli/internal/connectapi/room_services_test.go | 62 ++++++++++++++++++ .../core/notification_occurrence_model.go | 65 +++++++++++++++++-- .../notification_occurrence_model_test.go | 14 ++-- ...-deterministic-notification-occurrences.md | 15 +++-- docs/architecture/durable-effects.md | 2 +- docs/fdr/FDR-012-notifications.md | 5 +- docs/fdr/FDR-013-web-push-notifications.md | 2 +- 9 files changed, 182 insertions(+), 46 deletions(-) diff --git a/cli/cmd/run.go b/cli/cmd/run.go index f69f9e0ef..9c1fb0880 100644 --- a/cli/cmd/run.go +++ b/cli/cmd/run.go @@ -451,11 +451,11 @@ func setupPushNotifications(chattoCore *core.ChattoCore, cfg config.ChattoConfig } chattoCore.OnNotificationOccurrenceCreated = func(ctx context.Context, occurrence *corev1.NotificationOccurrence) error { - visible, err := chattoCore.NotificationOccurrences().TargetVisible(ctx, occurrence.GetRecipientId(), occurrence) + visibleOccurrences, err := chattoCore.NotificationOccurrences().VisibleOccurrences(ctx, occurrence.GetRecipientId(), []*corev1.NotificationOccurrence{occurrence}) if err != nil { return fmt.Errorf("revalidate notification target visibility: %w", err) } - if !visible { + if len(visibleOccurrences) == 0 { _, _ = chattoCore.NotificationOccurrences().Delete(ctx, occurrence.GetRecipientId(), occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) return nil } @@ -489,11 +489,11 @@ func setupPushNotifications(chattoCore *core.ChattoCore, cfg config.ChattoConfig if err != nil || !claimCurrent { return err } - visible, err = chattoCore.NotificationOccurrences().TargetVisible(ctx, occurrence.GetRecipientId(), occurrence) + 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) } - if !visible { + if len(visibleOccurrences) == 0 { _, _ = chattoCore.NotificationOccurrences().Delete(ctx, occurrence.GetRecipientId(), occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) return nil } diff --git a/cli/internal/connectapi/notification_occurrences.go b/cli/internal/connectapi/notification_occurrences.go index 3fb6ab592..12c1ddf4c 100644 --- a/cli/internal/connectapi/notification_occurrences.go +++ b/cli/internal/connectapi/notification_occurrences.go @@ -2,7 +2,6 @@ package connectapi import ( "context" - "errors" "sort" "connectrpc.com/connect" @@ -116,36 +115,40 @@ func notificationInboxSummary(groups []core.NotificationOccurrenceGroup) (int32, } 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 { - if len(group.Occurrences) == 0 { - continue - } - allowed, err := s.notificationOccurrenceVisible(ctx, userID, group.Occurrences[0]) - if err != nil { - return nil, err - } - if !allowed { - for _, occurrence := range group.Occurrences { + visibleOccurrences := make([]*corev1.NotificationOccurrence, 0, len(group.Occurrences)) + for _, occurrence := range group.Occurrences { + if _, allowed := allowedIDs[occurrence.GetId()]; !allowed { _, _ = s.api.core.NotificationOccurrences().Delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + 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) { - room, err := s.api.core.FindRoomByID(ctx, occurrence.GetTarget().GetRoomId()) - if errors.Is(err, core.ErrNotFound) { - return false, nil - } - if err != nil { - return false, err - } - member, err := s.api.core.RoomMembershipExists(ctx, core.KindOfRoom(room), userID, room.GetId()) - return member, err + visible, err := s.api.core.NotificationOccurrences().VisibleOccurrences(ctx, userID, []*corev1.NotificationOccurrence{occurrence}) + return len(visible) == 1, err } func occurrenceUpdate(inboxState *apiv1.NotificationInboxState) core.UpdateNotificationOccurrenceInput { @@ -213,15 +216,21 @@ func (s *notificationService) requireVisibleNotificationGroup(ctx context.Contex if group.ID != groupID || len(group.Occurrences) == 0 { continue } - visible, err := s.notificationOccurrenceVisible(ctx, userID, group.Occurrences[0]) + visible, err := s.api.core.NotificationOccurrences().VisibleOccurrences(ctx, userID, group.Occurrences) if err != nil { return err } - if visible { - return nil + visibleIDs := make(map[string]struct{}, len(visible)) + for _, occurrence := range visible { + visibleIDs[occurrence.GetId()] = struct{}{} } for _, occurrence := range group.Occurrences { - _, _ = s.api.core.NotificationOccurrences().Delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + if _, allowed := visibleIDs[occurrence.GetId()]; !allowed { + _, _ = s.api.core.NotificationOccurrences().Delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + } + } + if len(visible) > 0 { + return nil } return core.ErrNotFound } diff --git a/cli/internal/connectapi/room_services_test.go b/cli/internal/connectapi/room_services_test.go index 01440d7aa..2c246129e 100644 --- a/cli/internal/connectapi/room_services_test.go +++ b/cli/internal/connectapi/room_services_test.go @@ -1480,6 +1480,68 @@ func TestNotificationServiceOccurrenceInboxLifecycle(t *testing.T) { } +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("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) + } + 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) + } + sequence, err := env.core.GetEventSequence(env.ctx, core.KindDM, dm.Id, posted.Id) + if err != nil { + t.Fatalf("GetEventSequence: %v", err) + } + if err := env.core.DeleteMessage(env.ctx, actor.Id, core.KindDM, dm.Id, posted.Id); err != nil { + t.Fatalf("DeleteMessage: %v", err) + } + 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 + } + + createStale("stale-list-" + posted.Id) + inbox, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{ + View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX, + })) + if err != nil || len(inbox.Msg.GetGroups()) != 0 { + t.Fatalf("ListNotificationGroups with retracted target = (%+v, %v), want empty", inbox, err) + } + + staleUpdate := createStale("stale-update-" + posted.Id) + done := apiv1.NotificationInboxState_NOTIFICATION_INBOX_STATE_DONE + _, err = env.notifications.UpdateNotificationOccurrence(ctx, connect.NewRequest(&apiv1.UpdateNotificationOccurrenceRequest{ + NotificationId: staleUpdate.GetId(), + InboxState: &done, + })) + if connect.CodeOf(err) != connect.CodeNotFound { + t.Fatalf("UpdateNotificationOccurrence retracted target code = %v, want not found", connect.CodeOf(err)) + } +} + func TestNotificationServiceBoundsGroupPreview(t *testing.T) { env := newConnectAPITestEnv(t) ctx := withCaller(env.ctx, env.viewer) diff --git a/cli/internal/core/notification_occurrence_model.go b/cli/internal/core/notification_occurrence_model.go index f6af9c832..17f2afa7f 100644 --- a/cli/internal/core/notification_occurrence_model.go +++ b/cli/internal/core/notification_occurrence_model.go @@ -16,8 +16,10 @@ import ( "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" + "hmans.de/chatto/pkg/events" ) const ( @@ -708,10 +710,65 @@ func (m *NotificationOccurrenceModel) SilenceAlertClaim(ctx context.Context, exp return err == nil, err } -// TargetVisible revalidates the recipient, room membership, target-message -// lifecycle, and (for reaction occurrences) the exact current reaction before -// an occurrence is listed, hydrated, or delivered outside Chatto. -func (m *NotificationOccurrenceModel) TargetVisible(ctx context.Context, recipientID string, occurrence *corev1.NotificationOccurrence) (bool, error) { +// VisibleOccurrences waits this replica's authoritative projections through a +// freshly captured user/room boundary, then returns the occurrences whose +// recipient, membership, target-message lifecycle, and exact reaction remain +// visible. Capturing one boundary per room keeps list validation bounded while +// preventing 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) + } + roomPositions := make(map[string]events.StreamPosition) + for _, occurrence := range occurrences { + if occurrence == nil || occurrence.GetRecipientId() != recipientID || occurrence.GetTarget().GetRoomId() == "" { + continue + } + roomID := occurrence.GetTarget().GetRoomId() + if _, ok := roomPositions[roomID]; ok { + continue + } + position, err := m.core.EventPublisher.LastSubjectPosition(ctx, evtstream.RoomAggregate(roomID).AllEventsFilter()) + if err != nil { + return nil, fmt.Errorf("capture notification room boundary: %w", err) + } + roomPositions[roomID] = position + } + if !userPosition.IsZero() { + if err := m.core.userModel.waitForUsers(ctx, userPosition); err != nil { + return nil, fmt.Errorf("wait for notification recipient boundary: %w", err) + } + } + for roomID, position := range roomPositions { + if position.IsZero() { + continue + } + if err := waitForPositionAll(ctx, position, + 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 room %s visibility boundary: %w", roomID, 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 } diff --git a/cli/internal/core/notification_occurrence_model_test.go b/cli/internal/core/notification_occurrence_model_test.go index 57c55aab0..1175d0f9e 100644 --- a/cli/internal/core/notification_occurrence_model_test.go +++ b/cli/internal/core/notification_occurrence_model_test.go @@ -270,7 +270,7 @@ func TestSilenceAlertClaimTerminatesExactClaim(t *testing.T) { } } -func TestTargetVisibleChecksMessageAndExactReactionLifecycle(t *testing.T) { +func TestVisibleOccurrencesChecksMessageAndExactReactionLifecycle(t *testing.T) { chattoCore, _ := setupTestCore(t) ctx := testContext(t) author, err := chattoCore.CreateUser(ctx, SystemActorID, "visible-target-author", "Visible Target Author", "password") @@ -304,22 +304,22 @@ func TestTargetVisibleChecksMessageAndExactReactionLifecycle(t *testing.T) { t.Fatalf("reaction occurrences = (%v, %v), want one", occurrences, err) } reactionOccurrence := proto.Clone(occurrences[0]).(*corev1.NotificationOccurrence) - if visible, err := chattoCore.NotificationOccurrences().TargetVisible(ctx, author.Id, reactionOccurrence); err != nil || !visible { - t.Fatalf("TargetVisible before removal = (%v, %v), want true, nil", visible, err) + 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().TargetVisible(ctx, author.Id, reactionOccurrence); err != nil || visible { - t.Fatalf("TargetVisible after reaction removal = (%v, %v), want false, nil", visible, 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().TargetVisible(ctx, author.Id, reactionOccurrence); err != nil || visible { - t.Fatalf("TargetVisible after target retraction = (%v, %v), want false, nil", visible, 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) } } diff --git a/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index 7bdcdd76f..0de250995 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -212,11 +212,16 @@ available, but a crash after provider acceptance may produce a duplicate alert. Marking an occurrence Read or Done 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. 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. +subscription ownership before sending. Before account, room, message, or +reaction absence is treated as authoritative, the serving replica captures the +current user and room aggregate 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. 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. 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 diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 5ed32b9a2..258c109c8 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -46,7 +46,7 @@ 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 | -| 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 mutation attempt reconciles 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. 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; final delivery revalidates the exact target/reaction, 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, and visibility boundaries 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 work and markers, 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 | +| 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 mutation attempt reconciles 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. 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current user/room aggregate tails and wait local projections before exact target/reaction validation. 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, and causal projection fences 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 work and markers, 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 | Observability is currently domain-specific. Call reconciliation records its diff --git a/docs/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index efd40faa4..b89e3f8cb 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -43,7 +43,10 @@ losing the exact events and reasons underneath. - The bell count is the number of unread groups. Group rows may show how many occurrences they contain. - Retraction, reaction removal, lost room visibility, and account deletion - remove notifications that the user can no longer act on or view. + 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 user and room aggregate boundaries; + temporary projection lag is never interpreted as permanent visibility loss. - Inbox 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. diff --git a/docs/fdr/FDR-013-web-push-notifications.md b/docs/fdr/FDR-013-web-push-notifications.md index 967365ef0..3b0d92651 100644 --- a/docs/fdr/FDR-013-web-push-notifications.md +++ b/docs/fdr/FDR-013-web-push-notifications.md @@ -24,7 +24,7 @@ Users can opt in to receive notifications through the browser's W3C Web Push sys - 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. - Clicking a push notification navigates to the relevant room, thread, or DM. -- Immediately before a regular push is sent, Chatto 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 subscription reads fail the attempt for retry instead of being treated as an empty device set. This prevents slower asynchronous delivery from overtaking inbox triage, target removal, visibility loss, subscription rotation, or a newly enabled DND state. +- Immediately before a regular push is sent, Chatto waits the sending replica's user and room projections through freshly captured aggregate 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 inbox triage, 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. Inbox state changes only through Chatto's read, Done, 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. From ddcf62a048da7d5b66f1a0b68a2cb3cc2ff4b7a0 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 15:16:41 +0200 Subject: [PATCH 13/30] fix(notifications): bound visibility validation --- .../connectapi/notification_occurrences.go | 58 +++++++++++---- .../connectapi/realtime_projection.go | 12 ++- cli/internal/connectapi/room_services_test.go | 74 +++++++++++++++++++ .../core/notification_occurrence_model.go | 32 +++----- ...-deterministic-notification-occurrences.md | 11 ++- docs/architecture/durable-effects.md | 2 +- docs/fdr/FDR-012-notifications.md | 4 +- docs/fdr/FDR-013-web-push-notifications.md | 2 +- 8 files changed, 148 insertions(+), 47 deletions(-) diff --git a/cli/internal/connectapi/notification_occurrences.go b/cli/internal/connectapi/notification_occurrences.go index 12c1ddf4c..1d9260270 100644 --- a/cli/internal/connectapi/notification_occurrences.go +++ b/cli/internal/connectapi/notification_occurrences.go @@ -45,26 +45,22 @@ func (s *notificationService) ListNotificationGroups(ctx context.Context, req *c if err != nil { return nil, connectError(err) } - groups, err = s.visibleNotificationGroups(ctx, caller.UserID, groups) + 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) } - limit, offset := apiPagination(req.Msg.GetPage(), defaultNotificationLimit, maxNotificationLimit) - page, total, hasMore := apiSlicePage(groups, limit, offset) assembler := newNotificationAssembler(s.api) hydrated, err := assembler.groups(ctx, page) if err != nil { return nil, connectError(err) } - inboxGroups := groups - if view != core.NotificationOccurrenceViewInbox { - inboxGroups, err = s.api.core.NotificationOccurrences().Groups(ctx, caller.UserID, core.NotificationOccurrenceViewInbox) - if err == nil { - inboxGroups, err = s.visibleNotificationGroups(ctx, caller.UserID, inboxGroups) - } - if err != nil { - return nil, connectError(err) - } + // Visibility filtering may have tombstoned stale Inbox rows. Re-read the + // local occurrence index so summary counts reflect those writes without + // performing another projection fence or exhaustive target validation. + inboxGroups, err := s.api.core.NotificationOccurrences().Groups(ctx, caller.UserID, core.NotificationOccurrenceViewInbox) + if err != nil { + return nil, connectError(err) } unreadGroupCount, nextInboxExpiryAt, roomCounts := notificationInboxSummary(inboxGroups) return connect.NewResponse(&apiv1.ListNotificationGroupsResponse{ @@ -76,6 +72,36 @@ func (s *notificationService) ListNotificationGroups(ctx context.Context, req *c }), 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 inbox 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) + var visible []core.NotificationOccurrenceGroup + for { + var err error + visible, err = s.visibleNotificationGroups(ctx, userID, groups[:scanEnd]) + if err != nil { + return nil, 0, false, err + } + if len(visible) >= targetCount || scanEnd == len(groups) { + break + } + 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 notificationInboxSummary(groups []core.NotificationOccurrenceGroup) (int32, *timestamppb.Timestamp, []*apiv1.NotificationRoomUnreadGroupCount) { unreadGroupCount := int32(0) roomCounts := make(map[string]int32) @@ -132,7 +158,9 @@ func (s *notificationService) visibleNotificationGroups(ctx context.Context, use visibleOccurrences := make([]*corev1.NotificationOccurrence, 0, len(group.Occurrences)) for _, occurrence := range group.Occurrences { if _, allowed := allowedIDs[occurrence.GetId()]; !allowed { - _, _ = s.api.core.NotificationOccurrences().Delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + 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) @@ -226,7 +254,9 @@ func (s *notificationService) requireVisibleNotificationGroup(ctx context.Contex } for _, occurrence := range group.Occurrences { if _, allowed := visibleIDs[occurrence.GetId()]; !allowed { - _, _ = s.api.core.NotificationOccurrences().Delete(ctx, userID, occurrence.GetId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) + 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 { diff --git a/cli/internal/connectapi/realtime_projection.go b/cli/internal/connectapi/realtime_projection.go index bdb3fb462..45d49aeca 100644 --- a/cli/internal/connectapi/realtime_projection.go +++ b/cli/internal/connectapi/realtime_projection.go @@ -430,20 +430,26 @@ func (a *API) BuildRealtimeProjectionNotifications(ctx context.Context, userID s if err != nil { return nil, err } - groups, err = (¬ificationService{api: a}).visibleNotificationGroups(ctx, userID, groups) + page, total, hasMore, err := (¬ificationService{api: a}).visibleNotificationPage(ctx, userID, groups, defaultNotificationLimit, 0) if err != nil { return nil, err } assembler := newNotificationAssembler(a) + // The page validation may have purged stale occurrences. Refresh the local + // index-backed groups before deriving aggregate Inbox counts. + groups, err = a.core.NotificationOccurrences().Groups(ctx, userID, core.NotificationOccurrenceViewInbox) + if err != nil { + return nil, err + } unreadGroups, nextInboxExpiryAt, roomCounts := notificationInboxSummary(groups) - hydratedGroups, err := assembler.groups(ctx, groups[:min(len(groups), defaultNotificationLimit)]) + hydratedGroups, err := assembler.groups(ctx, page) if err != nil { return nil, err } return &RealtimeProjectionNotifications{ Groups: &apiv1.ListNotificationGroupsResponse{ Groups: hydratedGroups, - Page: apiPageInfo(len(groups), len(groups) > defaultNotificationLimit), + Page: apiPageInfo(total, hasMore), UnreadGroupCount: unreadGroups, NextInboxExpiryAt: nextInboxExpiryAt, RoomUnreadGroupCounts: roomCounts, diff --git a/cli/internal/connectapi/room_services_test.go b/cli/internal/connectapi/room_services_test.go index 2c246129e..3e39c5737 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" @@ -1542,6 +1543,79 @@ func TestNotificationServiceRejectsRetractedTargetsBeforeCleanup(t *testing.T) { } } +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("CreateUser actor: %v", err) + } + 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) + } + + first, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{ + View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX, + 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) + } + second, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{ + View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX, + 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 TestNotificationServiceBoundsGroupPreview(t *testing.T) { env := newConnectAPITestEnv(t) ctx := withCaller(env.ctx, env.viewer) diff --git a/cli/internal/core/notification_occurrence_model.go b/cli/internal/core/notification_occurrence_model.go index 17f2afa7f..e3291147a 100644 --- a/cli/internal/core/notification_occurrence_model.go +++ b/cli/internal/core/notification_occurrence_model.go @@ -19,7 +19,6 @@ import ( "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 ( @@ -713,8 +712,9 @@ func (m *NotificationOccurrenceModel) SilenceAlertClaim(ctx context.Context, exp // VisibleOccurrences waits this replica's authoritative projections through a // freshly captured user/room boundary, then returns the occurrences whose // recipient, membership, target-message lifecycle, and exact reaction remain -// visible. Capturing one boundary per room keeps list validation bounded while -// preventing projection lag from being mistaken for permanent visibility loss. +// 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 @@ -723,36 +723,22 @@ func (m *NotificationOccurrenceModel) VisibleOccurrences(ctx context.Context, re if err != nil { return nil, fmt.Errorf("capture notification recipient boundary: %w", err) } - roomPositions := make(map[string]events.StreamPosition) - for _, occurrence := range occurrences { - if occurrence == nil || occurrence.GetRecipientId() != recipientID || occurrence.GetTarget().GetRoomId() == "" { - continue - } - roomID := occurrence.GetTarget().GetRoomId() - if _, ok := roomPositions[roomID]; ok { - continue - } - position, err := m.core.EventPublisher.LastSubjectPosition(ctx, evtstream.RoomAggregate(roomID).AllEventsFilter()) - if err != nil { - return nil, fmt.Errorf("capture notification room boundary: %w", err) - } - roomPositions[roomID] = position + roomPosition, err := m.core.EventPublisher.LastSubjectPosition(ctx, evtstream.RoomSubjectFilter()) + if err != nil { + return nil, fmt.Errorf("capture notification rooms 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) } } - for roomID, position := range roomPositions { - if position.IsZero() { - continue - } - if err := waitForPositionAll(ctx, position, + 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 room %s visibility boundary: %w", roomID, err) + return nil, fmt.Errorf("wait for notification rooms visibility boundary: %w", err) } } visible := make([]*corev1.NotificationOccurrence, 0, len(occurrences)) diff --git a/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index 0de250995..535c7edce 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -214,10 +214,13 @@ 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 user and room aggregate 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. The worker renews the claim, then makes a final Do Not Disturb +current recipient aggregate and server-wide room-event 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. List validation scans only the prefix +needed to fill the requested offset page, extending by bounded page-sized +chunks 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 diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 258c109c8..5fcfe4a4f 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -46,7 +46,7 @@ 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 | -| 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 mutation attempt reconciles 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. 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current user/room aggregate tails and wait local projections before exact target/reaction validation. 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, and causal projection fences 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 work and markers, 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 | +| 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 mutation attempt reconciles 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. 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current recipient and server-wide room-event tails and wait local projections before exact target/reaction validation. Paged lists validate only the required prefix and extend with page-sized overfetch when stale groups are removed. 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, and causal projection fences 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 work and markers, 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 | Observability is currently domain-specific. Call reconciliation records its diff --git a/docs/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index b89e3f8cb..cf507e20f 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -45,7 +45,9 @@ losing the exact events and reasons underneath. - 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 user and room aggregate boundaries; + projections through freshly captured recipient and server-wide room-event + boundaries. List validation scans only far enough to fill the requested + offset page, with bounded page-sized overfetch when stale groups are removed; temporary projection lag is never interpreted as permanent visibility loss. - Inbox state, groups, counts, sounds, Web Push, and installed-app badges reconcile from authoritative server state after reconnect. Missing one live diff --git a/docs/fdr/FDR-013-web-push-notifications.md b/docs/fdr/FDR-013-web-push-notifications.md index 3b0d92651..2017c5f22 100644 --- a/docs/fdr/FDR-013-web-push-notifications.md +++ b/docs/fdr/FDR-013-web-push-notifications.md @@ -24,7 +24,7 @@ Users can opt in to receive notifications through the browser's W3C Web Push sys - 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. - Clicking a push notification navigates to the relevant room, thread, or DM. -- Immediately before a regular push is sent, Chatto waits the sending replica's user and room projections through freshly captured aggregate 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 inbox triage, target removal, visibility loss, subscription rotation, or a newly enabled DND state. +- 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 inbox triage, 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. Inbox state changes only through Chatto's read, Done, 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. From da9a0e579e21d030c5b2cb3d39089fc6bfac5da9 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 15:23:49 +0200 Subject: [PATCH 14/30] fix(notifications): fence exhaustive inbox summaries --- .../connectapi/notification_occurrences.go | 11 +++- .../connectapi/realtime_projection.go | 3 + .../core/notification_materializer.go | 60 ++++++++++++++----- .../core/notification_materializer_test.go | 27 +++++++++ .../core/notification_occurrence_model.go | 7 +++ ...-deterministic-notification-occurrences.md | 12 ++-- docs/architecture/durable-effects.md | 2 +- docs/fdr/FDR-012-notifications.md | 8 ++- 8 files changed, 105 insertions(+), 25 deletions(-) diff --git a/cli/internal/connectapi/notification_occurrences.go b/cli/internal/connectapi/notification_occurrences.go index 1d9260270..6d9694199 100644 --- a/cli/internal/connectapi/notification_occurrences.go +++ b/cli/internal/connectapi/notification_occurrences.go @@ -41,6 +41,9 @@ func (s *notificationService) ListNotificationGroups(ctx context.Context, req *c if err != nil { return nil, connectError(err) } + if err := s.api.core.NotificationOccurrences().WaitCurrent(ctx); err != nil { + return nil, connectError(err) + } groups, err := s.api.core.NotificationOccurrences().Groups(ctx, caller.UserID, view) if err != nil { return nil, connectError(err) @@ -81,16 +84,18 @@ func (s *notificationService) visibleNotificationPage(ctx context.Context, userI } targetCount := offset + limit + 1 scanEnd := min(len(groups), targetCount) - var visible []core.NotificationOccurrenceGroup + scanStart := 0 + visible := make([]core.NotificationOccurrenceGroup, 0, targetCount) for { - var err error - visible, err = s.visibleNotificationGroups(ctx, userID, groups[:scanEnd]) + 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)) diff --git a/cli/internal/connectapi/realtime_projection.go b/cli/internal/connectapi/realtime_projection.go index 45d49aeca..a49305706 100644 --- a/cli/internal/connectapi/realtime_projection.go +++ b/cli/internal/connectapi/realtime_projection.go @@ -426,6 +426,9 @@ func (a *API) BuildRealtimeProjectionRoomViewerState(ctx context.Context, userID // Notifications 2.0 Inbox 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) { + if err := a.core.NotificationOccurrences().WaitCurrent(ctx); err != nil { + return nil, err + } groups, err := a.core.NotificationOccurrences().Groups(ctx, userID, core.NotificationOccurrenceViewInbox) if err != nil { return nil, err diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go index d1fd51b84..5cc19d924 100644 --- a/cli/internal/core/notification_materializer.go +++ b/cli/internal/core/notification_materializer.go @@ -128,7 +128,8 @@ func (m *NotificationMaterializer) WaitThrough(ctx context.Context, streamSequen if err != nil { return fmt.Errorf("read notification consumer progress: %w", err) } - if info.AckFloor.Stream >= streamSequence { + if info.AckFloor.Stream >= streamSequence || + (info.Delivered.Consumer == 0 && info.NumAckPending == 0 && info.Delivered.Stream >= streamSequence) { return nil } select { @@ -139,6 +140,44 @@ func (m *NotificationMaterializer) WaitThrough(ctx context.Context, streamSequen } } +// 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 + } + } + return m.WaitThrough(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.EventRoomDeleted), + evtstream.UserEventTypeFilter(evtstream.EventUserAccountDeleted), + } +} + func (m *NotificationMaterializer) createConsumer(ctx context.Context) (jetstream.Consumer, error) { consumer, err := m.core.storage.serverEvtStream.CreateOrUpdateConsumer(ctx, jetstream.ConsumerConfig{ Name: notificationWorkerConsumerName, @@ -147,20 +186,11 @@ func (m *NotificationMaterializer) createConsumer(ctx context.Context) (jetstrea // 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: []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.EventRoomDeleted), - evtstream.UserEventTypeFilter(evtstream.EventUserAccountDeleted), - }, + DeliverPolicy: jetstream.DeliverNewPolicy, + AckPolicy: jetstream.AckExplicitPolicy, + AckWait: notificationWorkerAckWait, + MaxDeliver: -1, + FilterSubjects: notificationWorkerFilterSubjects(), ReplayPolicy: jetstream.ReplayInstantPolicy, MaxAckPending: notificationWorkerMaxPending, MaxRequestBatch: notificationWorkerMaxPending, diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go index 0cb262125..4c4ddbca3 100644 --- a/cli/internal/core/notification_materializer_test.go +++ b/cli/internal/core/notification_materializer_test.go @@ -202,6 +202,33 @@ func TestNotificationMaterializerConsumerStartsAtCreationBoundary(t *testing.T) } } +func TestNotificationMaterializerWaitCurrentFencesRelevantEventTail(t *testing.T) { + chattoCore, _ := setupTestCore(t) + ctx := testContext(t) + 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.consumer.Info(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) + } +} + func TestNotificationMaterializerSkipsFactsOutsideRetentionWindow(t *testing.T) { chattoCore, _ := setupTestCore(t) ctx := testContext(t) diff --git a/cli/internal/core/notification_occurrence_model.go b/cli/internal/core/notification_occurrence_model.go index e3291147a..c2130e018 100644 --- a/cli/internal/core/notification_occurrence_model.go +++ b/cli/internal/core/notification_occurrence_model.go @@ -60,6 +60,13 @@ type NotificationOccurrenceGroup struct { 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 { diff --git a/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index 535c7edce..1e58aa50f 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -217,10 +217,14 @@ reaction absence is treated as authoritative, the serving replica captures the current recipient aggregate and server-wide room-event 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. List validation scans only the prefix -needed to fill the requested offset page, extending by bounded page-sized -chunks when stale groups are removed. The worker renews the claim, then makes a -final Do Not Disturb +occurrence or expose a removed target. Before list or realtime responses derive +exhaustive totals and Inbox summaries, they capture the latest sequence for +every notification-worker EVT filter and wait for the sole durable writer to +acknowledge that boundary. A retrying lifecycle cleanup therefore 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 diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 5fcfe4a4f..790310f12 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -46,7 +46,7 @@ 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 | -| 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 mutation attempt reconciles 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. 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current recipient and server-wide room-event tails and wait local projections before exact target/reaction validation. Paged lists validate only the required prefix and extend with page-sized overfetch when stale groups are removed. 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, and causal projection fences 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 work and markers, 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 | +| 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 mutation attempt reconciles 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. 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current recipient and server-wide room-event tails and wait local projections before exact target/reaction validation. List and realtime reads also wait the durable consumer through a captured tail of every worker filter 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 read fences, and causal projection fences 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 work and markers, 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 | Observability is currently domain-specific. Call reconciliation records its diff --git a/docs/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index cf507e20f..bcffbf91e 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -47,8 +47,12 @@ losing the exact events and reasons underneath. mutation requests validate the exact current target after waiting local projections through freshly captured recipient and server-wide room-event boundaries. List validation scans only far enough to fill the requested - offset page, with bounded page-sized overfetch when stale groups are removed; - temporary projection lag is never interpreted as permanent visibility loss. + offset page and validates each page-sized overfetch chunk once when stale + groups are removed. Before exhaustive totals and badge summaries are read, + Chatto waits the sole durable notification writer through a captured tail of + every relevant EVT filter. Temporary projection or worker lag is therefore + never interpreted as permanent visibility loss or an authoritative stale + count. - Inbox 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. From 00b71e8f9f0196c078e703c53b1250318feb6aa8 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 15:40:54 +0200 Subject: [PATCH 15/30] fix(notifications): fence replica indexes --- .../core/notification_materializer.go | 25 +++++++- .../core/notification_materializer_test.go | 32 ++++++++++ .../core/notification_occurrence_index.go | 61 ++++++++++++++++--- .../core/notification_occurrence_storage.go | 5 ++ ...-deterministic-notification-occurrences.md | 13 ++-- docs/architecture/durable-effects.md | 2 +- docs/architecture/runtime-state.md | 1 + docs/fdr/FDR-012-notifications.md | 7 ++- 8 files changed, 128 insertions(+), 18 deletions(-) diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go index 5cc19d924..c8e9bb0b5 100644 --- a/cli/internal/core/notification_materializer.go +++ b/cli/internal/core/notification_materializer.go @@ -2,6 +2,7 @@ package core import ( "context" + "encoding/binary" "errors" "fmt" "time" @@ -27,6 +28,7 @@ const ( // when several Chatto replicas share the consumer. notificationWorkerMaxPending = 1 notificationWorkKeyPrefix = "notification_work." + notificationReadFenceKey = "notification_v2.read_fence" maxNotificationWorkWriteRetries = 8 ) @@ -162,7 +164,10 @@ func (m *NotificationMaterializer) WaitCurrent(ctx context.Context) error { boundary = position.Seq } } - return m.WaitThrough(ctx, boundary) + if err := m.WaitThrough(ctx, boundary); err != nil { + return err + } + return m.fenceLocalOccurrenceIndex(ctx, boundary) } func notificationWorkerFilterSubjects() []string { @@ -218,6 +223,24 @@ func (m *NotificationMaterializer) processDelivery(ctx context.Context, delivery return m.materializeEvent(ctx, &event, delivery.StreamSequence, true) } +// 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. diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go index 4c4ddbca3..b74f522cb 100644 --- a/cli/internal/core/notification_materializer_test.go +++ b/cli/internal/core/notification_materializer_test.go @@ -1,6 +1,7 @@ package core import ( + "context" "encoding/binary" "errors" "testing" @@ -205,6 +206,21 @@ func TestNotificationMaterializerConsumerStartsAtCreationBoundary(t *testing.T) 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(), @@ -227,6 +243,22 @@ func TestNotificationMaterializerWaitCurrentFencesRelevantEventTail(t *testing.T 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 TestNotificationMaterializerSkipsFactsOutsideRetentionWindow(t *testing.T) { diff --git a/cli/internal/core/notification_occurrence_index.go b/cli/internal/core/notification_occurrence_index.go index 6880d1314..7a8146483 100644 --- a/cli/internal/core/notification_occurrence_index.go +++ b/cli/internal/core/notification_occurrence_index.go @@ -32,14 +32,17 @@ 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 - changed chan struct{} - ready chan struct{} - readyOnce sync.Once - resyncRequests chan chan error + 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 { @@ -149,6 +152,7 @@ func (i *NotificationOccurrenceIndex) resetSnapshot() { 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{}) } @@ -156,6 +160,7 @@ func (i *NotificationOccurrenceIndex) resetSnapshot() { func (i *NotificationOccurrenceIndex) apply(entry jetstream.KeyValueEntry) { userID, _, ok := parseNotificationOccurrenceKey(entry.Key()) if !ok { + i.advanceObservedRevision(entry.Revision()) return } @@ -183,8 +188,14 @@ func (i *NotificationOccurrenceIndex) apply(entry jetstream.KeyValueEntry) { 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()) @@ -212,6 +223,17 @@ func (i *NotificationOccurrenceIndex) apply(entry jetstream.KeyValueEntry) { 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 @@ -411,6 +433,29 @@ func (i *NotificationOccurrenceIndex) waitForRevisionAfter(ctx context.Context, } } +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) { diff --git a/cli/internal/core/notification_occurrence_storage.go b/cli/internal/core/notification_occurrence_storage.go index b8d46deac..85c927e22 100644 --- a/cli/internal/core/notification_occurrence_storage.go +++ b/cli/internal/core/notification_occurrence_storage.go @@ -33,6 +33,11 @@ func (m *NotificationOccurrenceModel) storedOccurrenceEntries(ctx context.Contex } 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 diff --git a/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index 1e58aa50f..343e95dc9 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -220,11 +220,14 @@ same causally fenced validation, so projection lag cannot tombstone a valid occurrence or expose a removed target. Before list or realtime responses derive exhaustive totals and Inbox summaries, they capture the latest sequence for every notification-worker EVT filter and wait for the sole durable writer to -acknowledge that boundary. A retrying lifecycle cleanup therefore 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 +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 diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 790310f12..26d3cca92 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -46,7 +46,7 @@ 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 | -| 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 mutation attempt reconciles 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. 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current recipient and server-wide room-event tails and wait local projections before exact target/reaction validation. List and realtime reads also wait the durable consumer through a captured tail of every worker filter 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 read fences, and causal projection fences 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 work and markers, 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 | +| 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 mutation attempt reconciles 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. 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current recipient and server-wide room-event tails and wait local projections before exact target/reaction validation. List and realtime reads also wait the durable 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 causal projection fences 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 work and markers, 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 | Observability is currently domain-specific. Call reconciliation records its diff --git a/docs/architecture/runtime-state.md b/docs/architecture/runtime-state.md index 76f9a61e6..c45e82792 100644 --- a/docs/architecture/runtime-state.md +++ b/docs/architecture/runtime-state.md @@ -65,6 +65,7 @@ survives restart but is not content/domain history. See | `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 room leave or member removal relevant to notification materialization. The request path records it immediately after the membership fact commits and the ordered worker repeats it. 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/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index bcffbf91e..b3785996b 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -50,9 +50,10 @@ losing the exact events and reasons underneath. offset page and validates each page-sized overfetch chunk once when stale groups are removed. Before exhaustive totals and badge summaries are read, Chatto waits the sole durable notification writer through a captured tail of - every relevant EVT filter. Temporary projection or worker lag is therefore - never interpreted as permanent visibility loss or an authoritative stale - count. + 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. - Inbox 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. From fe2a33da7d5ba9723b034a78e29ec0a517ed705f Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 15:53:06 +0200 Subject: [PATCH 16/30] fix(notifications): reconcile implicit visibility loss --- cli/internal/connectapi/room_services_test.go | 76 ++++++++++ .../core/notification_materializer.go | 132 +++++++++++++++++- .../core/notification_materializer_test.go | 77 ++++++++++ ...-deterministic-notification-occurrences.md | 29 ++-- docs/architecture/runtime-components.md | 2 +- docs/architecture/runtime-state.md | 2 +- docs/fdr/FDR-012-notifications.md | 10 +- 7 files changed, 309 insertions(+), 19 deletions(-) diff --git a/cli/internal/connectapi/room_services_test.go b/cli/internal/connectapi/room_services_test.go index 3e39c5737..fb45174f6 100644 --- a/cli/internal/connectapi/room_services_test.go +++ b/cli/internal/connectapi/room_services_test.go @@ -1616,6 +1616,82 @@ func TestNotificationServiceVisibilityFilteringFillsOffsetPages(t *testing.T) { } } +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("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 _, err := env.core.SetRoomUniversal(env.ctx, actor.Id, core.KindChannel, implicitRoom.Id, false); err != nil { + t.Fatalf("SetRoomUniversal false: %v", err) + } + + response, err := env.notifications.ListNotificationGroups(ctx, connect.NewRequest(&apiv1.ListNotificationGroupsRequest{ + View: apiv1.NotificationView_NOTIFICATION_VIEW_INBOX, + Page: &apiv1.PageRequest{Limit: 1}, + })) + if err != nil { + 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 _, 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 TestNotificationServiceBoundsGroupPreview(t *testing.T) { env := newConnectAPITestEnv(t) ctx := withCaller(env.ctx, env.viewer) diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go index c8e9bb0b5..3cf817c7a 100644 --- a/cli/internal/core/notification_materializer.go +++ b/cli/internal/core/notification_materializer.go @@ -178,8 +178,17 @@ func notificationWorkerFilterSubjects() []string { 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.RBACEventTypeFilter(evtstream.EventRBACRoleDeleted), + evtstream.RBACEventTypeFilter(evtstream.EventRBACRoleAssigned), + evtstream.RBACEventTypeFilter(evtstream.EventRBACRoleRevoked), + evtstream.RBACEventTypeFilter(evtstream.EventRBACPermissionGranted), + evtstream.RBACEventTypeFilter(evtstream.EventRBACPermissionDenied), + evtstream.RBACEventTypeFilter(evtstream.EventRBACPermissionCleared), } } @@ -213,12 +222,28 @@ func (m *NotificationMaterializer) processDelivery(ctx context.Context, delivery return events.TerminateDelivery("invalid Chatto event envelope", err) } position := events.SubjectPosition(delivery.Subject, delivery.StreamSequence) - if event.GetUserAccountDeleted() != nil { + switch event.GetEvent().(type) { + case *corev1.Event_UserAccountDeleted: if err := m.core.userModel.waitForUsers(ctx, position); err != nil { return fmt.Errorf("wait for user projection: %w", err) } - } else if err := m.core.roomModel.waitForLiveEVTEvent(ctx, position, &event); err != nil { - return fmt.Errorf("wait for room projections: %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) + } } return m.materializeEvent(ctx, &event, delivery.StreamSequence, true) } @@ -442,6 +467,12 @@ func (m *NotificationMaterializer) materializeEvent(ctx context.Context, event * } _, 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) + case *corev1.Event_RoomUniversalChanged: + return m.reconcileOccurrenceVisibility(ctx, "", payload.RoomUniversalChanged.GetRoomId(), streamSequence) + case *corev1.Event_RoomAddedToGroup: + return m.reconcileOccurrenceVisibility(ctx, "", payload.RoomAddedToGroup.GetRoomId(), streamSequence) case *corev1.Event_RoomDeleted: _, err := m.core.notificationOccurrences.RemoveRoom(ctx, payload.RoomDeleted.GetRoomId(), corev1.NotificationRemovalReason_NOTIFICATION_REMOVAL_REASON_VISIBILITY_LOST) return err @@ -454,6 +485,101 @@ func (m *NotificationMaterializer) materializeEvent(ctx context.Context, event * return err } return m.purgeVisibilityBoundaries(ctx, userID) + case *corev1.Event_RbacRoleAssigned: + return m.reconcileOccurrenceVisibility(ctx, payload.RbacRoleAssigned.GetUserId(), "", streamSequence) + case *corev1.Event_RbacRoleRevoked: + return m.reconcileOccurrenceVisibility(ctx, payload.RbacRoleRevoked.GetUserId(), "", streamSequence) + case *corev1.Event_RbacRoleDeleted: + return m.reconcileOccurrenceVisibility(ctx, "", "", streamSequence) + case *corev1.Event_RbacPermissionGranted: + return m.reconcilePermissionVisibility(ctx, payload.RbacPermissionGranted.GetPermission(), payload.RbacPermissionGranted.GetScope(), payload.RbacPermissionGranted.GetSubject(), streamSequence) + case *corev1.Event_RbacPermissionDenied: + return m.reconcilePermissionVisibility(ctx, payload.RbacPermissionDenied.GetPermission(), payload.RbacPermissionDenied.GetScope(), payload.RbacPermissionDenied.GetSubject(), streamSequence) + case *corev1.Event_RbacPermissionCleared: + return m.reconcilePermissionVisibility(ctx, payload.RbacPermissionCleared.GetPermission(), payload.RbacPermissionCleared.GetScope(), payload.RbacPermissionCleared.GetSubject(), streamSequence) + } + return nil +} + +func (m *NotificationMaterializer) reconcilePermissionVisibility( + ctx context.Context, + permission string, + scope *corev1.RbacPermissionScope, + subject *corev1.RbacPermissionSubject, + streamSequence uint64, +) 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) +} + +// 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) 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) + } + + for pair, pairEntries := range entriesByPair { + room, err := m.core.FindRoomByID(ctx, pair.roomID) + if err != nil && !errors.Is(err, ErrNotFound) { + return err + } + visible := false + if err == nil { + visible, err = m.core.RoomMembershipExists(ctx, KindOfRoom(room), pair.recipientID, pair.roomID) + if err != nil { + return err + } + } + if visible { + 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 } diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go index b74f522cb..7821c9ce4 100644 --- a/cli/internal/core/notification_materializer_test.go +++ b/cli/internal/core/notification_materializer_test.go @@ -261,6 +261,83 @@ func TestNotificationMaterializerWaitCurrentFencesRelevantEventTail(t *testing.T } } +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, NotificationOccurrenceViewInbox) + 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, NotificationOccurrenceViewInbox); 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 TestNotificationMaterializerSkipsFactsOutsideRetentionWindow(t *testing.T) { chattoCore, _ := setupTestCore(t) ctx := testContext(t) diff --git a/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index 343e95dc9..609af2aac 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -77,10 +77,11 @@ 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-deletion, and account-deletion facts. 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 +room visibility, room-group placement, relevant RBAC, room-deletion, and +account-deletion facts. 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 @@ -100,6 +101,14 @@ 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 their room-group or RBAC projection catches up, +scans authoritative occurrences, and tombstones only recipient/room pairs that +no longer have effective membership. Those facts are rare administrative +operations, so this exhaustive cleanup avoids another durable recipient index. + 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 @@ -164,12 +173,12 @@ 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 or member-removal 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 leave -cannot delete activity created after a later rejoin, regardless of replica -clock skew. +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 inbox history; users triage existing items explicitly. diff --git a/docs/architecture/runtime-components.md b/docs/architecture/runtime-components.md index e5a3a10e2..8aaea67e6 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 | -| `NotificationPolicyModel` / `NotificationOccurrenceModel` / `NotificationMaterializer` | [`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) | 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 facts; lifecycle OCC; and leased Alert delivery | +| `NotificationPolicyModel` / `NotificationOccurrenceModel` / `NotificationMaterializer` | [`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) | 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, and relevant RBAC facts; 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 c45e82792..a025c90b7 100644 --- a/docs/architecture/runtime-state.md +++ b/docs/architecture/runtime-state.md @@ -64,7 +64,7 @@ survives restart but is not content/domain history. See | `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 room leave or member removal relevant to notification materialization. The request path records it immediately after the membership fact commits and the ordered worker repeats it. 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_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. | diff --git a/docs/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index b3785996b..a5fbd93b4 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -48,10 +48,12 @@ losing the exact events and reasons underneath. projections through freshly captured recipient and server-wide room-event 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. Before exhaustive totals and badge summaries are read, - Chatto waits the sole durable notification 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. + 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. 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. - Inbox state, groups, counts, sounds, Web Push, and installed-app badges From ff39601085f4a11095dcd93c143ab1482d024c81 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 16:05:57 +0200 Subject: [PATCH 17/30] fix(notifications): reconcile visibility at event boundary --- .../core/notification_materializer.go | 47 +++--- .../core/notification_materializer_test.go | 98 ++++++++++++ .../core/notification_visibility_snapshot.go | 146 ++++++++++++++++++ cli/internal/evtstream/publisher.go | 12 +- ...-deterministic-notification-occurrences.md | 11 +- docs/architecture/runtime-components.md | 2 +- docs/fdr/FDR-012-notifications.md | 15 +- 7 files changed, 293 insertions(+), 38 deletions(-) create mode 100644 cli/internal/core/notification_visibility_snapshot.go diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go index 3cf817c7a..5f5419c78 100644 --- a/cli/internal/core/notification_materializer.go +++ b/cli/internal/core/notification_materializer.go @@ -439,6 +439,10 @@ func (m *NotificationMaterializer) materializeEvent(ctx context.Context, event * 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 { @@ -468,11 +472,11 @@ func (m *NotificationMaterializer) materializeEvent(ctx context.Context, event * _, 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) + return m.reconcileOccurrenceVisibility(ctx, payload.RoomMemberBanned.GetUserId(), payload.RoomMemberBanned.GetRoomId(), streamSequence, visibilityAt) case *corev1.Event_RoomUniversalChanged: - return m.reconcileOccurrenceVisibility(ctx, "", payload.RoomUniversalChanged.GetRoomId(), streamSequence) + return m.reconcileOccurrenceVisibility(ctx, "", payload.RoomUniversalChanged.GetRoomId(), streamSequence, visibilityAt) case *corev1.Event_RoomAddedToGroup: - return m.reconcileOccurrenceVisibility(ctx, "", payload.RoomAddedToGroup.GetRoomId(), streamSequence) + 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 @@ -486,17 +490,17 @@ func (m *NotificationMaterializer) materializeEvent(ctx context.Context, event * } return m.purgeVisibilityBoundaries(ctx, userID) case *corev1.Event_RbacRoleAssigned: - return m.reconcileOccurrenceVisibility(ctx, payload.RbacRoleAssigned.GetUserId(), "", streamSequence) + return m.reconcileOccurrenceVisibility(ctx, payload.RbacRoleAssigned.GetUserId(), "", streamSequence, visibilityAt) case *corev1.Event_RbacRoleRevoked: - return m.reconcileOccurrenceVisibility(ctx, payload.RbacRoleRevoked.GetUserId(), "", streamSequence) + return m.reconcileOccurrenceVisibility(ctx, payload.RbacRoleRevoked.GetUserId(), "", streamSequence, visibilityAt) case *corev1.Event_RbacRoleDeleted: - return m.reconcileOccurrenceVisibility(ctx, "", "", streamSequence) + return m.reconcileOccurrenceVisibility(ctx, "", "", streamSequence, visibilityAt) case *corev1.Event_RbacPermissionGranted: - return m.reconcilePermissionVisibility(ctx, payload.RbacPermissionGranted.GetPermission(), payload.RbacPermissionGranted.GetScope(), payload.RbacPermissionGranted.GetSubject(), streamSequence) + 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) + 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) + return m.reconcilePermissionVisibility(ctx, payload.RbacPermissionCleared.GetPermission(), payload.RbacPermissionCleared.GetScope(), payload.RbacPermissionCleared.GetSubject(), streamSequence, visibilityAt) } return nil } @@ -507,6 +511,7 @@ func (m *NotificationMaterializer) reconcilePermissionVisibility( scope *corev1.RbacPermissionScope, subject *corev1.RbacPermissionSubject, streamSequence uint64, + visibilityAt time.Time, ) error { if permission != string(PermRoomJoin) { return nil @@ -518,7 +523,7 @@ func (m *NotificationMaterializer) reconcilePermissionVisibility( if scope.GetKind() == corev1.RbacPermissionScopeKind_RBAC_PERMISSION_SCOPE_KIND_ROOM { roomID = scope.GetId() } - return m.reconcileOccurrenceVisibility(ctx, userID, roomID, streamSequence) + return m.reconcileOccurrenceVisibility(ctx, userID, roomID, streamSequence, visibilityAt) } // reconcileOccurrenceVisibility handles effective membership changes that do @@ -526,7 +531,7 @@ func (m *NotificationMaterializer) reconcilePermissionVisibility( // 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) error { +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 @@ -547,20 +552,16 @@ func (m *NotificationMaterializer) reconcileOccurrenceVisibility(ctx context.Con pair := recipientRoom{recipientID: occurrence.GetRecipientId(), roomID: targetRoomID} entriesByPair[pair] = append(entriesByPair[pair], entry) } + if len(entriesByPair) == 0 { + return nil + } + snapshot, err := m.visibilitySnapshotAt(ctx, streamSequence, visibilityAt) + if err != nil { + return err + } for pair, pairEntries := range entriesByPair { - room, err := m.core.FindRoomByID(ctx, pair.roomID) - if err != nil && !errors.Is(err, ErrNotFound) { - return err - } - visible := false - if err == nil { - visible, err = m.core.RoomMembershipExists(ctx, KindOfRoom(room), pair.recipientID, pair.roomID) - if err != nil { - return err - } - } - if visible { + if snapshot.membershipExists(pair.recipientID, pair.roomID) { continue } if err := m.recordVisibilityBoundary(ctx, pair.recipientID, pair.roomID, streamSequence); err != nil { diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go index 7821c9ce4..f5b33edfa 100644 --- a/cli/internal/core/notification_materializer_test.go +++ b/cli/internal/core/notification_materializer_test.go @@ -338,6 +338,104 @@ func TestNotificationMaterializerRemovesOccurrencesAfterImplicitMembershipLoss(t } } +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) + + 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) diff --git a/cli/internal/core/notification_visibility_snapshot.go b/cli/internal/core/notification_visibility_snapshot.go new file mode 100644 index 000000000..d54df012d --- /dev/null +++ b/cli/internal/core/notification_visibility_snapshot.go @@ -0,0 +1,146 @@ +package core + +import ( + "context" + "fmt" + "sort" + "time" + + "hmans.de/chatto/internal/evtstream" + corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" +) + +// notificationVisibilitySnapshot reconstructs only the projections needed to +// decide effective room membership at an exact EVT boundary. Administrative +// visibility changes are rare, while using latest projections here would lose +// a revoke-then-restore transition whenever those projections outrun the +// notification worker. +type notificationVisibilitySnapshot struct { + rooms *RoomDirectoryProjection + groups *RoomGroupLayoutProjection + rbac *RBACProjection + at time.Time +} + +type notificationVisibilityProjectionEvent struct { + sequence uint64 + event *corev1.Event +} + +func (m *NotificationMaterializer) visibilitySnapshotAt(ctx context.Context, boundary uint64, at time.Time) (*notificationVisibilitySnapshot, error) { + rooms := NewRoomDirectoryProjection() + groups := NewRoomGroupLayoutProjection() + rbac := NewRBACProjection() + + roomEvents, err := m.notificationVisibilityEventsThrough(ctx, boundary, []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), + }) + if err != nil { + return nil, fmt.Errorf("load room visibility history: %w", err) + } + for _, item := range roomEvents { + if err := rooms.Apply(item.event, item.sequence); err != nil { + return nil, fmt.Errorf("replay room visibility event at %d: %w", item.sequence, err) + } + } + + groupEvents, err := m.notificationVisibilityEventsThrough(ctx, boundary, []string{ + evtstream.GroupEventTypeFilter(evtstream.EventRoomGroupCreated), + evtstream.GroupEventTypeFilter(evtstream.EventRoomGroupDeleted), + evtstream.GroupEventTypeFilter(evtstream.EventRoomAddedToGroup), + evtstream.GroupEventTypeFilter(evtstream.EventRoomRemovedFromGroup), + }) + if err != nil { + return nil, fmt.Errorf("load room group visibility history: %w", err) + } + for _, item := range groupEvents { + if err := groups.Apply(item.event, item.sequence); err != nil { + return nil, fmt.Errorf("replay room group visibility event at %d: %w", item.sequence, err) + } + } + + rbacEvents, err := m.notificationVisibilityEventsThrough(ctx, boundary, []string{evtstream.RBACSubjectFilter()}) + if err != nil { + return nil, fmt.Errorf("load RBAC visibility history: %w", err) + } + for _, item := range rbacEvents { + if err := rbac.Apply(item.event, item.sequence); err != nil { + return nil, fmt.Errorf("replay RBAC visibility event at %d: %w", item.sequence, err) + } + } + + return ¬ificationVisibilitySnapshot{rooms: rooms, groups: groups, rbac: rbac, at: at}, nil +} + +func (m *NotificationMaterializer) notificationVisibilityEventsThrough(ctx context.Context, boundary uint64, filters []string) ([]notificationVisibilityProjectionEvent, error) { + items := make([]notificationVisibilityProjectionEvent, 0) + for _, filter := range filters { + eventsOnSubject, _, err := m.core.EventPublisher.SubjectEventsWithSubjectsAfter(ctx, filter, 0) + if err != nil { + return nil, fmt.Errorf("read %s: %w", filter, err) + } + for _, event := range eventsOnSubject { + if event.Sequence > boundary { + continue + } + items = append(items, notificationVisibilityProjectionEvent{sequence: event.Sequence, event: event.Event}) + } + } + sort.Slice(items, func(a, b int) bool { return items[a].sequence < items[b].sequence }) + return items, nil +} + +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/evtstream/publisher.go b/cli/internal/evtstream/publisher.go index 9cac5a125..8f1527bc6 100644 --- a/cli/internal/evtstream/publisher.go +++ b/cli/internal/evtstream/publisher.go @@ -231,14 +231,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, @@ -254,7 +256,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}) } return events, lastSeq, nil } diff --git a/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index 609af2aac..713eb34c1 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -105,9 +105,14 @@ 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 their room-group or RBAC projection catches up, -scans authoritative occurrences, and tombstones only recipient/room pairs that -no longer have effective membership. Those facts are rare administrative -operations, so this exhaustive cleanup avoids another durable recipient index. +reconstructs the minimal room, membership, room-group, and RBAC state at the +fact's exact EVT sequence, 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. Those facts are rare administrative operations, +so bounded event-time replay and exhaustive cleanup avoid another durable +recipient index. Prepared work contains enough immutable provenance to reproduce the recipient and reason decision without later policy evaluation. In particular, message diff --git a/docs/architecture/runtime-components.md b/docs/architecture/runtime-components.md index 8aaea67e6..28121a089 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 | -| `NotificationPolicyModel` / `NotificationOccurrenceModel` / `NotificationMaterializer` | [`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) | 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, and relevant RBAC facts; authoritative effective-membership cleanup; lifecycle OCC; and leased Alert delivery | +| `NotificationPolicyModel` / `NotificationOccurrenceModel` / `NotificationMaterializer` | [`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_snapshot.go`](../../cli/internal/core/notification_visibility_snapshot.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, and relevant RBAC facts; exact-boundary visibility reconstruction and 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/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index a5fbd93b4..7d582a0f0 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -50,12 +50,15 @@ losing the exact events and reasons underneath. 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. 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. + including visibility loss without an explicit leave. It reconstructs + effective membership at the change's exact EVT boundary, so a later regain + that reaches projections first cannot preserve pre-loss history or remove + post-regain activity. 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. - Inbox 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. From c0b4a2f61840220899828340c6ba8a8aff074b7f Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 16:25:33 +0200 Subject: [PATCH 18/30] fix(notifications): checkpoint visibility boundaries --- cli/internal/core/core_services.go | 5 +- .../core/notification_materializer.go | 81 ++- .../core/notification_materializer_test.go | 7 + .../notification_visibility_projection.go | 309 ++++++++++ ...notification_visibility_projection_test.go | 107 ++++ .../core/notification_visibility_snapshot.go | 146 ----- cli/internal/core/projection_registry_test.go | 32 +- .../projection_snapshot_integration_test.go | 10 +- .../core/projection_snapshots_test.go | 11 +- cli/internal/core/projection_subjects_test.go | 5 + cli/internal/core/projection_wiring.go | 46 +- cli/internal/core/rbac.go | 25 + .../core/role_assignment_authorization.go | 9 + cli/internal/core/verified_emails_test.go | 45 ++ .../chatto/core/v1/projection_snapshots.pb.go | 552 ++++++++++-------- cli/internal/projectionsnapshot/repository.go | 25 +- ...-deterministic-notification-occurrences.md | 20 +- docs/architecture/durable-effects.md | 4 +- docs/architecture/projections.md | 3 +- docs/architecture/runtime-components.md | 2 +- docs/fdr/FDR-012-notifications.md | 11 +- .../chatto/core/v1/projection_snapshots.proto | 8 + 22 files changed, 1000 insertions(+), 463 deletions(-) create mode 100644 cli/internal/core/notification_visibility_projection.go create mode 100644 cli/internal/core/notification_visibility_projection_test.go delete mode 100644 cli/internal/core/notification_visibility_snapshot.go diff --git a/cli/internal/core/core_services.go b/cli/internal/core/core_services.go index c9ffe2852..073c2c964 100644 --- a/cli/internal/core/core_services.go +++ b/cli/internal/core/core_services.go @@ -118,7 +118,7 @@ func initializeCoreServices( infra.storage.runtimeStateKV, logger.WithPrefix("core.NotificationOccurrences"), ) - core.notificationMaterializer = NewNotificationMaterializer(core) + 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")) @@ -129,6 +129,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/notification_materializer.go b/cli/internal/core/notification_materializer.go index 5f5419c78..9c95963b0 100644 --- a/cli/internal/core/notification_materializer.go +++ b/cli/internal/core/notification_materializer.go @@ -37,20 +37,51 @@ const ( // short-lived RUNTIME_STATE work records before the source fact commits; EVT // contains no notification-only planning events. type NotificationMaterializer struct { - core *ChattoCore - pollEvery time.Duration - ready chan struct{} - consumer jetstream.Consumer + core *ChattoCore + visibility events.ProjectionHandle[*NotificationVisibilityProjection] + pollEvery time.Duration + ready chan struct{} + consumer jetstream.Consumer } -func NewNotificationMaterializer(core *ChattoCore) *NotificationMaterializer { +func NewNotificationMaterializer(core *ChattoCore, visibility events.ProjectionHandle[*NotificationVisibilityProjection]) *NotificationMaterializer { return &NotificationMaterializer{ - core: core, - pollEvery: notificationMaterializerPollEvery, - ready: make(chan struct{}), + core: core, + visibility: visibility, + pollEvery: notificationMaterializerPollEvery, + ready: make(chan struct{}), } } +// 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.core.EventPublisher.LastStreamSeq(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 := info.AckFloor.Stream + if info.NumPending == 0 && info.NumAckPending == 0 { + processed = tail + } + m.visibility.Projection().SetRestoreMaxCutoff(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) @@ -59,14 +90,8 @@ func (m *NotificationMaterializer) Run(ctx context.Context) error { return fmt.Errorf("wait for notification index before worker: %w", err) } - consumer, err := m.createConsumer(ctx) - if err != nil { - return err - } - m.consumer = consumer - close(m.ready) worker, err := events.NewDurableWorker( - consumer, + m.consumer, m.processDelivery, events.DurableWorkerOptions{ MaxConcurrent: notificationWorkerMaxPending, @@ -96,11 +121,21 @@ func (m *NotificationMaterializer) Run(ctx context.Context) error { case <-ctx.Done(): return <-workerDone case <-ticker.C: + m.releaseAcknowledgedVisibilityBoundaries(ctx) m.deliverPendingAlerts(ctx) } } } +func (m *NotificationMaterializer) releaseAcknowledgedVisibilityBoundaries(ctx context.Context) { + info, err := m.consumer.Info(ctx) + if err != nil { + m.core.logger.Warn("Failed to read notification worker floor for visibility cleanup", "error", err) + return + } + m.visibility.Projection().ReleaseThrough(info.AckFloor.Stream) +} + // 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. @@ -222,6 +257,12 @@ func (m *NotificationMaterializer) processDelivery(ctx context.Context, delivery 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: if err := m.core.userModel.waitForUsers(ctx, position); err != nil { @@ -245,7 +286,13 @@ func (m *NotificationMaterializer) processDelivery(ctx context.Context, delivery return fmt.Errorf("wait for room projections: %w", err) } } - return m.materializeEvent(ctx, &event, delivery.StreamSequence, true) + if err := m.materializeEvent(ctx, &event, delivery.StreamSequence, true); err != nil { + return err + } + if hasVisibilityBoundary { + m.visibility.Projection().ReleaseThrough(delivery.StreamSequence) + } + return nil } // fenceLocalOccurrenceIndex appends a marker to the same KV stream as @@ -555,7 +602,7 @@ func (m *NotificationMaterializer) reconcileOccurrenceVisibility(ctx context.Con if len(entriesByPair) == 0 { return nil } - snapshot, err := m.visibilitySnapshotAt(ctx, streamSequence, visibilityAt) + snapshot, err := m.visibility.Projection().Boundary(streamSequence, visibilityAt) if err != nil { return err } diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go index f5b33edfa..736c07e8c 100644 --- a/cli/internal/core/notification_materializer_test.go +++ b/cli/internal/core/notification_materializer_test.go @@ -418,6 +418,13 @@ func TestNotificationVisibilityReconciliationUsesEventBoundaryWhenProjectionIsAh } 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, diff --git a/cli/internal/core/notification_visibility_projection.go b/cli/internal/core/notification_visibility_projection.go new file mode 100644 index 000000000..cd544f521 --- /dev/null +++ b/cli/internal/core/notification_visibility_projection.go @@ -0,0 +1,309 @@ +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 + + boundaries map[uint64][]byte + retainAfter atomic.Uint64 +} + +func NewNotificationVisibilityProjection() *NotificationVisibilityProjection { + return &NotificationVisibilityProjection{ + rooms: NewRoomDirectoryProjection(), + groups: NewRoomGroupLayoutProjection(), + rbac: NewRBACProjection(), + boundaries: make(map[uint64][]byte), + } +} + +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 + } + if seq <= p.retainAfter.Load() || !notificationVisibilityBoundaryEvent(event) { + return nil + } + payload, err := encodeNotificationVisibilityState(p.rooms, p.groups, p.rbac) + if err != nil { + return fmt.Errorf("capture notification visibility boundary %d: %w", seq, err) + } + p.boundaries[seq] = payload + 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.boundaries = make(map[uint64][]byte) + 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 +} + +// SetRestoreMaxCutoff binds snapshot restore to the notification consumer's +// acknowledged floor. Pending deliveries are replayed into exact boundary +// snapshots instead of being hidden behind a newer projection snapshot. +func (p *NotificationVisibilityProjection) SetRestoreMaxCutoff(sequence uint64) { + p.retainAfter.Store(sequence) +} + +func (p *NotificationVisibilityProjection) RestoreMaxCutoff() uint64 { + return p.retainAfter.Load() +} + +func (p *NotificationVisibilityProjection) Boundary(sequence uint64, at time.Time) (*notificationVisibilitySnapshot, error) { + p.mu.RLock() + payload := append([]byte(nil), p.boundaries[sequence]...) + p.mu.RUnlock() + if len(payload) == 0 { + return nil, fmt.Errorf("notification visibility boundary %d is unavailable", sequence) + } + rooms, groups, rbac, err := decodeNotificationVisibilityState(payload) + if err != nil { + return nil, fmt.Errorf("restore notification visibility boundary %d: %w", sequence, err) + } + return ¬ificationVisibilitySnapshot{rooms: rooms, groups: groups, rbac: rbac, at: at}, nil +} + +func (p *NotificationVisibilityProjection) ReleaseThrough(sequence uint64) { + p.mu.Lock() + for boundary := range p.boundaries { + if boundary <= sequence { + delete(p.boundaries, boundary) + } + } + p.mu.Unlock() +} + +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...) + return roomEntries + groupEntries + rbacEntries, roomBytes + groupBytes + rbacBytes, 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..d9913c611 --- /dev/null +++ b/cli/internal/core/notification_visibility_projection_test.go @@ -0,0 +1,107 @@ +package core + +import ( + "context" + "fmt" + "testing" + "time" + + "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 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.SetRestoreMaxCutoff(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) + } +} diff --git a/cli/internal/core/notification_visibility_snapshot.go b/cli/internal/core/notification_visibility_snapshot.go deleted file mode 100644 index d54df012d..000000000 --- a/cli/internal/core/notification_visibility_snapshot.go +++ /dev/null @@ -1,146 +0,0 @@ -package core - -import ( - "context" - "fmt" - "sort" - "time" - - "hmans.de/chatto/internal/evtstream" - corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" -) - -// notificationVisibilitySnapshot reconstructs only the projections needed to -// decide effective room membership at an exact EVT boundary. Administrative -// visibility changes are rare, while using latest projections here would lose -// a revoke-then-restore transition whenever those projections outrun the -// notification worker. -type notificationVisibilitySnapshot struct { - rooms *RoomDirectoryProjection - groups *RoomGroupLayoutProjection - rbac *RBACProjection - at time.Time -} - -type notificationVisibilityProjectionEvent struct { - sequence uint64 - event *corev1.Event -} - -func (m *NotificationMaterializer) visibilitySnapshotAt(ctx context.Context, boundary uint64, at time.Time) (*notificationVisibilitySnapshot, error) { - rooms := NewRoomDirectoryProjection() - groups := NewRoomGroupLayoutProjection() - rbac := NewRBACProjection() - - roomEvents, err := m.notificationVisibilityEventsThrough(ctx, boundary, []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), - }) - if err != nil { - return nil, fmt.Errorf("load room visibility history: %w", err) - } - for _, item := range roomEvents { - if err := rooms.Apply(item.event, item.sequence); err != nil { - return nil, fmt.Errorf("replay room visibility event at %d: %w", item.sequence, err) - } - } - - groupEvents, err := m.notificationVisibilityEventsThrough(ctx, boundary, []string{ - evtstream.GroupEventTypeFilter(evtstream.EventRoomGroupCreated), - evtstream.GroupEventTypeFilter(evtstream.EventRoomGroupDeleted), - evtstream.GroupEventTypeFilter(evtstream.EventRoomAddedToGroup), - evtstream.GroupEventTypeFilter(evtstream.EventRoomRemovedFromGroup), - }) - if err != nil { - return nil, fmt.Errorf("load room group visibility history: %w", err) - } - for _, item := range groupEvents { - if err := groups.Apply(item.event, item.sequence); err != nil { - return nil, fmt.Errorf("replay room group visibility event at %d: %w", item.sequence, err) - } - } - - rbacEvents, err := m.notificationVisibilityEventsThrough(ctx, boundary, []string{evtstream.RBACSubjectFilter()}) - if err != nil { - return nil, fmt.Errorf("load RBAC visibility history: %w", err) - } - for _, item := range rbacEvents { - if err := rbac.Apply(item.event, item.sequence); err != nil { - return nil, fmt.Errorf("replay RBAC visibility event at %d: %w", item.sequence, err) - } - } - - return ¬ificationVisibilitySnapshot{rooms: rooms, groups: groups, rbac: rbac, at: at}, nil -} - -func (m *NotificationMaterializer) notificationVisibilityEventsThrough(ctx context.Context, boundary uint64, filters []string) ([]notificationVisibilityProjectionEvent, error) { - items := make([]notificationVisibilityProjectionEvent, 0) - for _, filter := range filters { - eventsOnSubject, _, err := m.core.EventPublisher.SubjectEventsWithSubjectsAfter(ctx, filter, 0) - if err != nil { - return nil, fmt.Errorf("read %s: %w", filter, err) - } - for _, event := range eventsOnSubject { - if event.Sequence > boundary { - continue - } - items = append(items, notificationVisibilityProjectionEvent{sequence: event.Sequence, event: event.Event}) - } - } - sort.Slice(items, func(a, b int) bool { return items[a].sequence < items[b].sequence }) - return items, nil -} - -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/projection_registry_test.go b/cli/internal/core/projection_registry_test.go index fcad293a1..c44553207 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) != 13 { - t.Fatalf("registered projections = %d, want 13", len(core.projections)) + if len(core.projections) != 14 { + t.Fatalf("registered projections = %d, want 14", 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") } @@ -127,18 +130,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_snapshots_test.go b/cli/internal/core/projection_snapshots_test.go index 297958af3..835cb5dec 100644 --- a/cli/internal/core/projection_snapshots_test.go +++ b/cli/internal/core/projection_snapshots_test.go @@ -60,6 +60,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{}}, @@ -191,6 +192,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)}}}} @@ -260,7 +268,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 f23c469b5..789d1401a 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] } type projectionSnapshotPolicy bool @@ -92,6 +93,16 @@ func initializeCoreProjections( sharedSnapshots, ) + notificationVisibility := NewNotificationVisibilityProjection() + projections.notificationVisibility = registerProjection( + registrar, + notificationVisibility, + projectionsnapshot.ProjectionNotificationVisibilityKey, + "Notification Visibility", + notificationVisibility.adminProjectionEstimate, + sharedSnapshots, + ) + serverConfig := NewConfigProjection() projections.serverConfig = registerProjection( registrar, @@ -232,9 +243,16 @@ 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) diff --git a/cli/internal/core/rbac.go b/cli/internal/core/rbac.go index 790cc0be3..347bc9f65 100644 --- a/cli/internal/core/rbac.go +++ b/cli/internal/core/rbac.go @@ -108,6 +108,13 @@ func (c *ChattoCore) IsServerOwner(ctx context.Context, userID string) (bool, er if c.rbacModel.hasRole(userID, RoleOwner) { return true, nil } + return c.isConfiguredOwner(ctx, userID) +} + +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 { return false, err @@ -291,6 +298,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 +339,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/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/verified_emails_test.go b/cli/internal/core/verified_emails_test.go index 84c1e6512..63a6d2082 100644 --- a/cli/internal/core/verified_emails_test.go +++ b/cli/internal/core/verified_emails_test.go @@ -317,6 +317,51 @@ func TestChattoCore_ApplyConfigOwners(t *testing.T) { } } +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/pb/chatto/core/v1/projection_snapshots.pb.go b/cli/internal/pb/chatto/core/v1/projection_snapshots.pb.go index d4385045d..1fc197f4c 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 { @@ -1608,7 +1670,7 @@ type UserConfigSnapshot struct { 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 { @@ -1681,7 +1743,7 @@ type NotificationPreferenceSnapshot struct { func (x *NotificationPreferenceSnapshot) Reset() { *x = NotificationPreferenceSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[23] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1693,7 +1755,7 @@ func (x *NotificationPreferenceSnapshot) String() string { func (*NotificationPreferenceSnapshot) ProtoMessage() {} func (x *NotificationPreferenceSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[23] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1706,7 +1768,7 @@ func (x *NotificationPreferenceSnapshot) ProtoReflect() protoreflect.Message { // Deprecated: Use NotificationPreferenceSnapshot.ProtoReflect.Descriptor instead. func (*NotificationPreferenceSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{23} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{24} } func (x *NotificationPreferenceSnapshot) GetReason() NotificationReason { @@ -1733,7 +1795,7 @@ type RoomNotificationPreferenceSnapshot struct { func (x *RoomNotificationPreferenceSnapshot) Reset() { *x = RoomNotificationPreferenceSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[24] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1745,7 +1807,7 @@ func (x *RoomNotificationPreferenceSnapshot) String() string { func (*RoomNotificationPreferenceSnapshot) ProtoMessage() {} func (x *RoomNotificationPreferenceSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[24] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1758,7 +1820,7 @@ func (x *RoomNotificationPreferenceSnapshot) ProtoReflect() protoreflect.Message // Deprecated: Use RoomNotificationPreferenceSnapshot.ProtoReflect.Descriptor instead. func (*RoomNotificationPreferenceSnapshot) Descriptor() ([]byte, []int) { - return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{24} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{25} } func (x *RoomNotificationPreferenceSnapshot) GetRoomId() string { @@ -1790,7 +1852,7 @@ type AssetProjectionSnapshot struct { func (x *AssetProjectionSnapshot) Reset() { *x = AssetProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[25] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1802,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[25] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1815,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{25} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{26} } func (x *AssetProjectionSnapshot) GetCreations() []*AssetCreatedEvent { @@ -1877,7 +1939,7 @@ type AssetChildrenSnapshot struct { func (x *AssetChildrenSnapshot) Reset() { *x = AssetChildrenSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[26] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1889,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[26] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1902,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{26} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{27} } func (x *AssetChildrenSnapshot) GetParentAssetId() string { @@ -1931,7 +1993,7 @@ type AssetManifestSnapshot struct { func (x *AssetManifestSnapshot) Reset() { *x = AssetManifestSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[27] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1943,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[27] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1956,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{27} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{28} } func (x *AssetManifestSnapshot) GetAssetId() string { @@ -1997,7 +2059,7 @@ type DeletedAssetSnapshot struct { func (x *DeletedAssetSnapshot) Reset() { *x = DeletedAssetSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[28] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2009,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[28] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2022,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{28} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{29} } func (x *DeletedAssetSnapshot) GetAssetId() string { @@ -2053,7 +2115,7 @@ type ReactionProjectionSnapshot struct { func (x *ReactionProjectionSnapshot) Reset() { *x = ReactionProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[29] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2065,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[29] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2078,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{29} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{30} } func (x *ReactionProjectionSnapshot) GetMessages() []*MessageReactionsSnapshot { @@ -2133,7 +2195,7 @@ type MessageReactionsSnapshot struct { func (x *MessageReactionsSnapshot) Reset() { *x = MessageReactionsSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[30] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2145,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[30] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2158,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{30} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{31} } func (x *MessageReactionsSnapshot) GetMessageEventId() string { @@ -2185,7 +2247,7 @@ type EmojiReactionsSnapshot struct { func (x *EmojiReactionsSnapshot) Reset() { *x = EmojiReactionsSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[31] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2197,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[31] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2210,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{31} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{32} } func (x *EmojiReactionsSnapshot) GetEmoji() string { @@ -2238,7 +2300,7 @@ type UserReactionSnapshot struct { func (x *UserReactionSnapshot) Reset() { *x = UserReactionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[32] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[33] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2250,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[32] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[33] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2263,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{32} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{33} } func (x *UserReactionSnapshot) GetUserId() string { @@ -2297,7 +2359,7 @@ type StringUint64Snapshot struct { func (x *StringUint64Snapshot) Reset() { *x = StringUint64Snapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[33] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[34] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2309,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[33] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[34] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2322,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{33} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{34} } func (x *StringUint64Snapshot) GetKey() string { @@ -2349,7 +2411,7 @@ type StringStringSnapshot struct { func (x *StringStringSnapshot) Reset() { *x = StringStringSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[34] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[35] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2361,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[34] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[35] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2374,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{34} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{35} } func (x *StringStringSnapshot) GetKey() string { @@ -2402,7 +2464,7 @@ type MentionablesProjectionSnapshot struct { func (x *MentionablesProjectionSnapshot) Reset() { *x = MentionablesProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[35] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[36] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2414,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[35] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[36] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2427,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{35} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{36} } func (x *MentionablesProjectionSnapshot) GetUserLoginSources() []*Event { @@ -2467,7 +2529,7 @@ type UserProfileProjectionSnapshot struct { func (x *UserProfileProjectionSnapshot) Reset() { *x = UserProfileProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[36] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[37] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2479,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[36] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[37] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2492,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{36} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{37} } func (x *UserProfileProjectionSnapshot) GetUsers() []*ProjectedUserProfileSnapshot { @@ -2549,7 +2611,7 @@ type ProjectedUserProfileSnapshot struct { func (x *ProjectedUserProfileSnapshot) Reset() { *x = ProjectedUserProfileSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[37] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[38] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2561,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[37] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[38] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2574,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{37} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{38} } func (x *ProjectedUserProfileSnapshot) GetUserId() string { @@ -2666,7 +2728,7 @@ type ProjectedEncryptedUserStringSnapshot struct { func (x *ProjectedEncryptedUserStringSnapshot) Reset() { *x = ProjectedEncryptedUserStringSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[38] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[39] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2678,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[38] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[39] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2691,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{38} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{39} } func (x *ProjectedEncryptedUserStringSnapshot) GetEventId() string { @@ -2733,7 +2795,7 @@ type ProjectedVerifiedEmailSnapshot struct { func (x *ProjectedVerifiedEmailSnapshot) Reset() { *x = ProjectedVerifiedEmailSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[39] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[40] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2745,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[39] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[40] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2758,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{39} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{40} } func (x *ProjectedVerifiedEmailSnapshot) GetDigest() string { @@ -2798,7 +2860,7 @@ type RoomTimelineProjectionSnapshot struct { func (x *RoomTimelineProjectionSnapshot) Reset() { *x = RoomTimelineProjectionSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[40] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[41] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2810,7 +2872,7 @@ func (x *RoomTimelineProjectionSnapshot) String() string { func (*RoomTimelineProjectionSnapshot) ProtoMessage() {} func (x *RoomTimelineProjectionSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[40] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[41] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2823,7 +2885,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{40} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{41} } func (x *RoomTimelineProjectionSnapshot) GetEntries() []*TimelineEntrySnapshot { @@ -2892,7 +2954,7 @@ type TimelineEntrySnapshot struct { func (x *TimelineEntrySnapshot) Reset() { *x = TimelineEntrySnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[41] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[42] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2904,7 +2966,7 @@ func (x *TimelineEntrySnapshot) String() string { func (*TimelineEntrySnapshot) ProtoMessage() {} func (x *TimelineEntrySnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[41] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[42] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2917,7 +2979,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{41} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{42} } func (x *TimelineEntrySnapshot) GetStreamSequence() uint64 { @@ -2946,7 +3008,7 @@ type TimelineBodySnapshot struct { func (x *TimelineBodySnapshot) Reset() { *x = TimelineBodySnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[42] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[43] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2958,7 +3020,7 @@ func (x *TimelineBodySnapshot) String() string { func (*TimelineBodySnapshot) ProtoMessage() {} func (x *TimelineBodySnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[42] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[43] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2971,7 +3033,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{42} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{43} } func (x *TimelineBodySnapshot) GetMessageEventId() string { @@ -3012,7 +3074,7 @@ type StringTimestampSnapshot struct { func (x *StringTimestampSnapshot) Reset() { *x = StringTimestampSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[43] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[44] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3024,7 +3086,7 @@ func (x *StringTimestampSnapshot) String() string { func (*StringTimestampSnapshot) ProtoMessage() {} func (x *StringTimestampSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[43] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[44] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3037,7 +3099,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{43} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{44} } func (x *StringTimestampSnapshot) GetKey() string { @@ -3066,7 +3128,7 @@ type AssetMessageOwnerSnapshot struct { func (x *AssetMessageOwnerSnapshot) Reset() { *x = AssetMessageOwnerSnapshot{} - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[44] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[45] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -3078,7 +3140,7 @@ func (x *AssetMessageOwnerSnapshot) String() string { func (*AssetMessageOwnerSnapshot) ProtoMessage() {} func (x *AssetMessageOwnerSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[44] + mi := &file_chatto_core_v1_projection_snapshots_proto_msgTypes[45] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -3091,7 +3153,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{44} + return file_chatto_core_v1_projection_snapshots_proto_rawDescGZIP(), []int{45} } func (x *AssetMessageOwnerSnapshot) GetAssetId() string { @@ -3206,7 +3268,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" + @@ -3395,159 +3461,163 @@ 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, 45) +var file_chatto_core_v1_projection_snapshots_proto_msgTypes = make([]protoimpl.MessageInfo, 46) 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 - (*NotificationPreferenceSnapshot)(nil), // 23: chatto.core.v1.NotificationPreferenceSnapshot - (*RoomNotificationPreferenceSnapshot)(nil), // 24: chatto.core.v1.RoomNotificationPreferenceSnapshot - (*AssetProjectionSnapshot)(nil), // 25: chatto.core.v1.AssetProjectionSnapshot - (*AssetChildrenSnapshot)(nil), // 26: chatto.core.v1.AssetChildrenSnapshot - (*AssetManifestSnapshot)(nil), // 27: chatto.core.v1.AssetManifestSnapshot - (*DeletedAssetSnapshot)(nil), // 28: chatto.core.v1.DeletedAssetSnapshot - (*ReactionProjectionSnapshot)(nil), // 29: chatto.core.v1.ReactionProjectionSnapshot - (*MessageReactionsSnapshot)(nil), // 30: chatto.core.v1.MessageReactionsSnapshot - (*EmojiReactionsSnapshot)(nil), // 31: chatto.core.v1.EmojiReactionsSnapshot - (*UserReactionSnapshot)(nil), // 32: chatto.core.v1.UserReactionSnapshot - (*StringUint64Snapshot)(nil), // 33: chatto.core.v1.StringUint64Snapshot - (*StringStringSnapshot)(nil), // 34: chatto.core.v1.StringStringSnapshot - (*MentionablesProjectionSnapshot)(nil), // 35: chatto.core.v1.MentionablesProjectionSnapshot - (*UserProfileProjectionSnapshot)(nil), // 36: chatto.core.v1.UserProfileProjectionSnapshot - (*ProjectedUserProfileSnapshot)(nil), // 37: chatto.core.v1.ProjectedUserProfileSnapshot - (*ProjectedEncryptedUserStringSnapshot)(nil), // 38: chatto.core.v1.ProjectedEncryptedUserStringSnapshot - (*ProjectedVerifiedEmailSnapshot)(nil), // 39: chatto.core.v1.ProjectedVerifiedEmailSnapshot - (*RoomTimelineProjectionSnapshot)(nil), // 40: chatto.core.v1.RoomTimelineProjectionSnapshot - (*TimelineEntrySnapshot)(nil), // 41: chatto.core.v1.TimelineEntrySnapshot - (*TimelineBodySnapshot)(nil), // 42: chatto.core.v1.TimelineBodySnapshot - (*StringTimestampSnapshot)(nil), // 43: chatto.core.v1.StringTimestampSnapshot - (*AssetMessageOwnerSnapshot)(nil), // 44: chatto.core.v1.AssetMessageOwnerSnapshot - (*timestamppb.Timestamp)(nil), // 45: google.protobuf.Timestamp - (*Room)(nil), // 46: chatto.core.v1.Room - (*RoomGroup)(nil), // 47: chatto.core.v1.RoomGroup - (CallParticipantEventSource)(0), // 48: chatto.core.v1.CallParticipantEventSource - (*UserDEKGeneratedEvent)(nil), // 49: chatto.core.v1.UserDEKGeneratedEvent - (*Role)(nil), // 50: chatto.core.v1.Role - (RbacPermissionSubjectKind)(0), // 51: chatto.core.v1.RbacPermissionSubjectKind - (*AssetRecord)(nil), // 52: chatto.core.v1.AssetRecord - (TimeFormat)(0), // 53: chatto.core.v1.TimeFormat - (NotificationReason)(0), // 54: chatto.core.v1.NotificationReason - (NotificationDeliveryIntensity)(0), // 55: chatto.core.v1.NotificationDeliveryIntensity - (*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 + (*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 + (NotificationReason)(0), // 55: chatto.core.v1.NotificationReason + (NotificationDeliveryIntensity)(0), // 56: chatto.core.v1.NotificationDeliveryIntensity + (*AssetCreatedEvent)(nil), // 57: chatto.core.v1.AssetCreatedEvent + (*AssetProcessingStartedEvent)(nil), // 58: chatto.core.v1.AssetProcessingStartedEvent + (*AssetProcessingSucceededEvent)(nil), // 59: chatto.core.v1.AssetProcessingSucceededEvent + (*AssetProcessingFailedEvent)(nil), // 60: chatto.core.v1.AssetProcessingFailedEvent + (*Event)(nil), // 61: chatto.core.v1.Event + (*User)(nil), // 62: chatto.core.v1.User + (*ServerUserPreferences)(nil), // 63: chatto.core.v1.ServerUserPreferences + (*EncryptedUserString)(nil), // 64: chatto.core.v1.EncryptedUserString + (*MessageBody)(nil), // 65: chatto.core.v1.MessageBody } var file_chatto_core_v1_projection_snapshots_proto_depIdxs = []int32{ - 45, // 0: chatto.core.v1.ProjectionSnapshotGeneration.created_at:type_name -> google.protobuf.Timestamp - 45, // 1: chatto.core.v1.ProjectionSnapshotPointer.current_created_at:type_name -> google.protobuf.Timestamp - 45, // 2: chatto.core.v1.ProjectionSnapshotPointer.previous_created_at:type_name -> google.protobuf.Timestamp + 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 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 - 45, // 8: chatto.core.v1.ThreadReplySnapshot.created_at:type_name -> google.protobuf.Timestamp - 46, // 9: chatto.core.v1.RoomDirectoryProjectionSnapshot.rooms:type_name -> chatto.core.v1.Room + 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 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 - 45, // 12: chatto.core.v1.RoomBanSnapshot.created_at:type_name -> google.protobuf.Timestamp - 45, // 13: chatto.core.v1.RoomBanSnapshot.expires_at:type_name -> google.protobuf.Timestamp + 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 12, // 14: chatto.core.v1.RoomGroupLayoutProjectionSnapshot.groups:type_name -> chatto.core.v1.RoomGroupStateSnapshot - 47, // 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 - 48, // 19: chatto.core.v1.CallSessionSnapshot.source:type_name -> chatto.core.v1.CallParticipantEventSource - 48, // 20: chatto.core.v1.CallParticipantSnapshot.source:type_name -> chatto.core.v1.CallParticipantEventSource - 49, // 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 - 50, // 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 - 51, // 27: chatto.core.v1.RBACDecisionSnapshot.subject_kind:type_name -> chatto.core.v1.RbacPermissionSubjectKind - 52, // 28: chatto.core.v1.ConfigProjectionSnapshot.logo:type_name -> chatto.core.v1.AssetRecord - 52, // 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 - 53, // 31: chatto.core.v1.UserConfigSnapshot.time_format:type_name -> chatto.core.v1.TimeFormat - 23, // 32: chatto.core.v1.UserConfigSnapshot.server_notification_preferences:type_name -> chatto.core.v1.NotificationPreferenceSnapshot - 24, // 33: chatto.core.v1.UserConfigSnapshot.room_notification_preferences:type_name -> chatto.core.v1.RoomNotificationPreferenceSnapshot - 54, // 34: chatto.core.v1.NotificationPreferenceSnapshot.reason:type_name -> chatto.core.v1.NotificationReason - 55, // 35: chatto.core.v1.NotificationPreferenceSnapshot.intensity:type_name -> chatto.core.v1.NotificationDeliveryIntensity - 23, // 36: chatto.core.v1.RoomNotificationPreferenceSnapshot.preferences:type_name -> chatto.core.v1.NotificationPreferenceSnapshot - 56, // 37: chatto.core.v1.AssetProjectionSnapshot.creations:type_name -> chatto.core.v1.AssetCreatedEvent - 26, // 38: chatto.core.v1.AssetProjectionSnapshot.children:type_name -> chatto.core.v1.AssetChildrenSnapshot - 27, // 39: chatto.core.v1.AssetProjectionSnapshot.manifests:type_name -> chatto.core.v1.AssetManifestSnapshot - 28, // 40: chatto.core.v1.AssetProjectionSnapshot.deleted_assets:type_name -> chatto.core.v1.DeletedAssetSnapshot - 7, // 41: chatto.core.v1.AssetProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot - 44, // 42: chatto.core.v1.AssetProjectionSnapshot.message_owners:type_name -> chatto.core.v1.AssetMessageOwnerSnapshot - 57, // 43: chatto.core.v1.AssetManifestSnapshot.started:type_name -> chatto.core.v1.AssetProcessingStartedEvent - 58, // 44: chatto.core.v1.AssetManifestSnapshot.succeeded:type_name -> chatto.core.v1.AssetProcessingSucceededEvent - 59, // 45: chatto.core.v1.AssetManifestSnapshot.failed:type_name -> chatto.core.v1.AssetProcessingFailedEvent - 30, // 46: chatto.core.v1.ReactionProjectionSnapshot.messages:type_name -> chatto.core.v1.MessageReactionsSnapshot - 33, // 47: chatto.core.v1.ReactionProjectionSnapshot.room_sequences:type_name -> chatto.core.v1.StringUint64Snapshot - 34, // 48: chatto.core.v1.ReactionProjectionSnapshot.message_rooms:type_name -> chatto.core.v1.StringStringSnapshot - 34, // 49: chatto.core.v1.ReactionProjectionSnapshot.echo_originals:type_name -> chatto.core.v1.StringStringSnapshot - 34, // 50: chatto.core.v1.ReactionProjectionSnapshot.asset_rooms:type_name -> chatto.core.v1.StringStringSnapshot - 7, // 51: chatto.core.v1.ReactionProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot - 31, // 52: chatto.core.v1.MessageReactionsSnapshot.emojis:type_name -> chatto.core.v1.EmojiReactionsSnapshot - 32, // 53: chatto.core.v1.EmojiReactionsSnapshot.users:type_name -> chatto.core.v1.UserReactionSnapshot - 60, // 54: chatto.core.v1.MentionablesProjectionSnapshot.user_login_sources:type_name -> chatto.core.v1.Event - 49, // 55: chatto.core.v1.MentionablesProjectionSnapshot.keys:type_name -> chatto.core.v1.UserDEKGeneratedEvent - 37, // 56: chatto.core.v1.UserProfileProjectionSnapshot.users:type_name -> chatto.core.v1.ProjectedUserProfileSnapshot - 49, // 57: chatto.core.v1.UserProfileProjectionSnapshot.keys:type_name -> chatto.core.v1.UserDEKGeneratedEvent - 7, // 58: chatto.core.v1.UserProfileProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot - 34, // 59: chatto.core.v1.UserProfileProjectionSnapshot.login_index:type_name -> chatto.core.v1.StringStringSnapshot - 34, // 60: chatto.core.v1.UserProfileProjectionSnapshot.email_index:type_name -> chatto.core.v1.StringStringSnapshot - 61, // 61: chatto.core.v1.ProjectedUserProfileSnapshot.user:type_name -> chatto.core.v1.User - 38, // 62: chatto.core.v1.ProjectedUserProfileSnapshot.login:type_name -> chatto.core.v1.ProjectedEncryptedUserStringSnapshot - 38, // 63: chatto.core.v1.ProjectedUserProfileSnapshot.display_name:type_name -> chatto.core.v1.ProjectedEncryptedUserStringSnapshot - 52, // 64: chatto.core.v1.ProjectedUserProfileSnapshot.avatar:type_name -> chatto.core.v1.AssetRecord - 39, // 65: chatto.core.v1.ProjectedUserProfileSnapshot.verified_emails:type_name -> chatto.core.v1.ProjectedVerifiedEmailSnapshot - 62, // 66: chatto.core.v1.ProjectedUserProfileSnapshot.preferences:type_name -> chatto.core.v1.ServerUserPreferences - 45, // 67: chatto.core.v1.ProjectedUserProfileSnapshot.login_changed_at:type_name -> google.protobuf.Timestamp - 63, // 68: chatto.core.v1.ProjectedEncryptedUserStringSnapshot.encrypted:type_name -> chatto.core.v1.EncryptedUserString - 38, // 69: chatto.core.v1.ProjectedVerifiedEmailSnapshot.value:type_name -> chatto.core.v1.ProjectedEncryptedUserStringSnapshot - 45, // 70: chatto.core.v1.ProjectedVerifiedEmailSnapshot.verified_at:type_name -> google.protobuf.Timestamp - 41, // 71: chatto.core.v1.RoomTimelineProjectionSnapshot.entries:type_name -> chatto.core.v1.TimelineEntrySnapshot - 42, // 72: chatto.core.v1.RoomTimelineProjectionSnapshot.bodies:type_name -> chatto.core.v1.TimelineBodySnapshot - 43, // 73: chatto.core.v1.RoomTimelineProjectionSnapshot.tombstoned_at:type_name -> chatto.core.v1.StringTimestampSnapshot - 43, // 74: chatto.core.v1.RoomTimelineProjectionSnapshot.shredded_at:type_name -> chatto.core.v1.StringTimestampSnapshot - 7, // 75: chatto.core.v1.RoomTimelineProjectionSnapshot.replay_guard:type_name -> chatto.core.v1.ProjectionReplayGuardSnapshot - 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 - 45, // 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 + 48, // 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 + 49, // 22: chatto.core.v1.CallSessionSnapshot.source:type_name -> chatto.core.v1.CallParticipantEventSource + 49, // 23: chatto.core.v1.CallParticipantSnapshot.source:type_name -> chatto.core.v1.CallParticipantEventSource + 50, // 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 + 51, // 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 + 52, // 30: chatto.core.v1.RBACDecisionSnapshot.subject_kind:type_name -> chatto.core.v1.RbacPermissionSubjectKind + 53, // 31: chatto.core.v1.ConfigProjectionSnapshot.logo:type_name -> chatto.core.v1.AssetRecord + 53, // 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 + 54, // 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 + 55, // 37: chatto.core.v1.NotificationPreferenceSnapshot.reason:type_name -> chatto.core.v1.NotificationReason + 56, // 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 + 57, // 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 + 45, // 45: chatto.core.v1.AssetProjectionSnapshot.message_owners:type_name -> chatto.core.v1.AssetMessageOwnerSnapshot + 58, // 46: chatto.core.v1.AssetManifestSnapshot.started:type_name -> chatto.core.v1.AssetProcessingStartedEvent + 59, // 47: chatto.core.v1.AssetManifestSnapshot.succeeded:type_name -> chatto.core.v1.AssetProcessingSucceededEvent + 60, // 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 + 61, // 57: chatto.core.v1.MentionablesProjectionSnapshot.user_login_sources:type_name -> chatto.core.v1.Event + 50, // 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 + 50, // 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 + 62, // 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 + 53, // 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 + 63, // 69: chatto.core.v1.ProjectedUserProfileSnapshot.preferences:type_name -> chatto.core.v1.ServerUserPreferences + 46, // 70: chatto.core.v1.ProjectedUserProfileSnapshot.login_changed_at:type_name -> google.protobuf.Timestamp + 64, // 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 + 46, // 73: chatto.core.v1.ProjectedVerifiedEmailSnapshot.verified_at:type_name -> google.protobuf.Timestamp + 42, // 74: chatto.core.v1.RoomTimelineProjectionSnapshot.entries:type_name -> chatto.core.v1.TimelineEntrySnapshot + 43, // 75: chatto.core.v1.RoomTimelineProjectionSnapshot.bodies:type_name -> chatto.core.v1.TimelineBodySnapshot + 44, // 76: chatto.core.v1.RoomTimelineProjectionSnapshot.tombstoned_at:type_name -> chatto.core.v1.StringTimestampSnapshot + 44, // 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 + 61, // 79: chatto.core.v1.TimelineEntrySnapshot.event:type_name -> chatto.core.v1.Event + 65, // 80: chatto.core.v1.TimelineBodySnapshot.body:type_name -> chatto.core.v1.MessageBody + 46, // 81: chatto.core.v1.StringTimestampSnapshot.value:type_name -> google.protobuf.Timestamp + 82, // [82:82] is the sub-list for method output_type + 82, // [82:82] is the sub-list for method input_type + 82, // [82:82] is the sub-list for extension type_name + 82, // [82:82] is the sub-list for extension extendee + 0, // [0:82] is the sub-list for field type_name } func init() { file_chatto_core_v1_projection_snapshots_proto_init() } @@ -3563,15 +3633,15 @@ func file_chatto_core_v1_projection_snapshots_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: 45, + NumMessages: 46, 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/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index 713eb34c1..a472453bb 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -104,15 +104,21 @@ 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 their room-group or RBAC projection catches up, -reconstructs the minimal room, membership, room-group, and RBAC state at the -fact's exact EVT sequence, scans authoritative occurrences, and tombstones only -recipient/room pairs that lacked effective membership at that boundary. A +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. Those facts are rare administrative operations, -so bounded event-time replay and exhaustive cleanup avoid another durable -recipient index. +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. Each administrative fact reads one retained boundary +instead of replaying lifetime EVT history on the single notification lane. + +Configured `owners.emails` identities are materialized as durable owner-role +assignments. While an email remains configured, that role cannot be revoked; +the event-time RBAC projection and the live effective-owner override therefore +cannot disagree about room visibility. Prepared work contains enough immutable provenance to reproduce the recipient and reason decision without later policy evaluation. In particular, message diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 26d3cca92..1c033c4bf 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -1,6 +1,6 @@ # 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/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/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 @@ -46,7 +46,7 @@ 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 | -| 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 mutation attempt reconciles 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. 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current recipient and server-wide room-event tails and wait local projections before exact target/reaction validation. List and realtime reads also wait the durable 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 causal projection fences 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 work and markers, 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 | +| 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 mutation attempt reconciles 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current recipient and server-wide room-event tails and wait local projections before exact target/reaction validation. List and realtime reads also wait the durable 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 | Observability is currently domain-specific. Call reconciliation records its diff --git a/docs/architecture/projections.md b/docs/architecture/projections.md index f23cb4902..e9fe3358c 100644 --- a/docs/architecture/projections.md +++ b/docs/architecture/projections.md @@ -236,7 +236,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 acknowledged floor so pending exact boundaries replay | | 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 | Rebuilds Slow Mode's latest-original-post index on restore; `v4` remains 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 | @@ -247,6 +247,7 @@ 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 only for administrative boundaries still pending on the shared notification worker | | 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, 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 | diff --git a/docs/architecture/runtime-components.md b/docs/architecture/runtime-components.md index 28121a089..f06c04c84 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 | -| `NotificationPolicyModel` / `NotificationOccurrenceModel` / `NotificationMaterializer` | [`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_snapshot.go`](../../cli/internal/core/notification_visibility_snapshot.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, and relevant RBAC facts; exact-boundary visibility reconstruction and authoritative effective-membership cleanup; lifecycle OCC; and leased Alert delivery | +| `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, and relevant RBAC facts; snapshot-capped exact-boundary visibility state and 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/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index 7d582a0f0..21ee2daef 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -50,10 +50,13 @@ losing the exact events and reasons underneath. 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. It reconstructs - effective membership at the change's exact EVT boundary, so a later regain - that reaches projections first cannot preserve pre-loss history or remove - post-regain activity. Before exhaustive totals and badge summaries are read, + 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; an administrative fact never replays lifetime + membership or RBAC history on the notification lane. 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, diff --git a/proto/chatto/core/v1/projection_snapshots.proto b/proto/chatto/core/v1/projection_snapshots.proto index 9206272b9..450adf342 100644 --- a/proto/chatto/core/v1/projection_snapshots.proto +++ b/proto/chatto/core/v1/projection_snapshots.proto @@ -124,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; } From bf3e2ef685a63dc6f33f08336c2755d1246be922 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 16:41:19 +0200 Subject: [PATCH 19/30] fix(notifications): retain recoverable visibility boundaries --- .../core/notification_materializer.go | 56 ++++++-- .../core/notification_materializer_test.go | 120 ++++++++++++++++ .../notification_visibility_projection.go | 133 +++++++++++++++--- ...notification_visibility_projection_test.go | 56 ++++++++ cli/internal/core/rbac.go | 12 +- cli/internal/core/verified_emails.go | 33 +++-- cli/internal/core/verified_emails_test.go | 19 +++ ...ermission-only-rbac-with-owner-override.md | 19 ++- ...-deterministic-notification-occurrences.md | 21 ++- docs/architecture/durable-effects.md | 8 ++ docs/architecture/projections.md | 4 +- docs/architecture/runtime-components.md | 2 +- docs/fdr/FDR-001-roles-and-permissions.md | 12 +- docs/fdr/FDR-012-notifications.md | 7 +- 14 files changed, 429 insertions(+), 73 deletions(-) diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go index 9c95963b0..c3c6ff23a 100644 --- a/cli/internal/core/notification_materializer.go +++ b/cli/internal/core/notification_materializer.go @@ -37,19 +37,23 @@ const ( // 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] - pollEvery time.Duration - ready chan struct{} - consumer jetstream.Consumer + core *ChattoCore + visibility events.ProjectionHandle[*NotificationVisibilityProjection] + assignConfiguredOwnerRole func(context.Context, string) error + pollEvery time.Duration + ready chan struct{} + consumer jetstream.Consumer } func NewNotificationMaterializer(core *ChattoCore, visibility events.ProjectionHandle[*NotificationVisibilityProjection]) *NotificationMaterializer { return &NotificationMaterializer{ core: core, visibility: visibility, - pollEvery: notificationMaterializerPollEvery, - ready: make(chan struct{}), + assignConfiguredOwnerRole: func(ctx context.Context, userID string) error { + return core.AssignServerRoleToExistingUser(ctx, SystemActorID, userID, RoleOwner) + }, + pollEvery: notificationMaterializerPollEvery, + ready: make(chan struct{}), } } @@ -133,7 +137,9 @@ func (m *NotificationMaterializer) releaseAcknowledgedVisibilityBoundaries(ctx c m.core.logger.Warn("Failed to read notification worker floor for visibility cleanup", "error", err) return } - m.visibility.Projection().ReleaseThrough(info.AckFloor.Stream) + if err := m.visibility.Projection().ReleaseThrough(info.AckFloor.Stream); err != nil { + m.core.logger.Warn("Failed to compact acknowledged notification visibility boundaries", "error", err) + } } // WaitReady waits until the durable consumer exists. Serving must not begin @@ -218,6 +224,7 @@ func notificationWorkerFilterSubjects() []string { 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), @@ -264,7 +271,7 @@ func (m *NotificationMaterializer) processDelivery(ctx context.Context, delivery } } switch event.GetEvent().(type) { - case *corev1.Event_UserAccountDeleted: + 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) } @@ -289,9 +296,6 @@ func (m *NotificationMaterializer) processDelivery(ctx context.Context, delivery if err := m.materializeEvent(ctx, &event, delivery.StreamSequence, true); err != nil { return err } - if hasVisibilityBoundary { - m.visibility.Projection().ReleaseThrough(delivery.StreamSequence) - } return nil } @@ -536,6 +540,8 @@ func (m *NotificationMaterializer) materializeEvent(ctx context.Context, event * 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: @@ -552,6 +558,32 @@ func (m *NotificationMaterializer) materializeEvent(ctx context.Context, event * 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, diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go index 736c07e8c..992cc5e26 100644 --- a/cli/internal/core/notification_materializer_test.go +++ b/cli/internal/core/notification_materializer_test.go @@ -8,9 +8,13 @@ import ( "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 TestMessageNotificationMaterializationMergesReasonsAndReconcilesReadState(t *testing.T) { @@ -89,6 +93,122 @@ func TestMessageNotificationMaterializationMergesReasonsAndReconcilesReadState(t } } +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 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) + } + 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) diff --git a/cli/internal/core/notification_visibility_projection.go b/cli/internal/core/notification_visibility_projection.go index cd544f521..8e184439f 100644 --- a/cli/internal/core/notification_visibility_projection.go +++ b/cli/internal/core/notification_visibility_projection.go @@ -27,8 +27,25 @@ type NotificationVisibilityProjection struct { groups *RoomGroupLayoutProjection rbac *RBACProjection - boundaries map[uint64][]byte - retainAfter atomic.Uint64 + // 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 + retainAfter atomic.Uint64 +} + +type notificationVisibilityDelta struct { + sequence uint64 + event *corev1.Event } func NewNotificationVisibilityProjection() *NotificationVisibilityProjection { @@ -36,7 +53,7 @@ func NewNotificationVisibilityProjection() *NotificationVisibilityProjection { rooms: NewRoomDirectoryProjection(), groups: NewRoomGroupLayoutProjection(), rbac: NewRBACProjection(), - boundaries: make(map[uint64][]byte), + boundaries: make(map[uint64]struct{}), } } @@ -73,14 +90,29 @@ func (p *NotificationVisibilityProjection) Apply(event *corev1.Event, seq uint64 if err := p.rbac.Apply(event, seq); err != nil { return err } - if seq <= p.retainAfter.Load() || !notificationVisibilityBoundaryEvent(event) { + boundary := seq > p.retainAfter.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 } - payload, err := encodeNotificationVisibilityState(p.rooms, p.groups, p.rbac) - if err != nil { - return fmt.Errorf("capture notification visibility boundary %d: %w", seq, err) + if seq > p.checkpointSequence { + p.deltas = append(p.deltas, notificationVisibilityDelta{ + sequence: seq, + event: proto.Clone(event).(*corev1.Event), + }) + } + if boundary { + p.boundaries[seq] = struct{}{} } - p.boundaries[seq] = payload return nil } @@ -124,7 +156,12 @@ func (p *NotificationVisibilityProjection) Restore(data []byte) error { } p.mu.Lock() p.rooms, p.groups, p.rbac = rooms, groups, rbac - p.boundaries = make(map[uint64][]byte) + 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 } @@ -206,27 +243,75 @@ func (p *NotificationVisibilityProjection) RestoreMaxCutoff() uint64 { } func (p *NotificationVisibilityProjection) Boundary(sequence uint64, at time.Time) (*notificationVisibilitySnapshot, error) { - p.mu.RLock() - payload := append([]byte(nil), p.boundaries[sequence]...) - p.mu.RUnlock() - if len(payload) == 0 { + 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) } - rooms, groups, rbac, err := decodeNotificationVisibilityState(payload) - if err != nil { - return nil, fmt.Errorf("restore notification visibility boundary %d: %w", sequence, err) + 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 } - return ¬ificationVisibilitySnapshot{rooms: rooms, groups: groups, rbac: rbac, at: at}, nil + 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 (p *NotificationVisibilityProjection) ReleaseThrough(sequence uint64) { +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 { 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) } } - p.mu.Unlock() + 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) { @@ -237,7 +322,15 @@ func (p *NotificationVisibilityProjection) adminProjectionEstimate() (int64, int rbacEntries, rbacBytes, rbacMetrics := p.rbac.adminProjectionEstimate() metrics := append(roomMetrics, groupMetrics...) metrics = append(metrics, rbacMetrics...) - return roomEntries + groupEntries + rbacEntries, roomBytes + groupBytes + rbacBytes, metrics + 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 diff --git a/cli/internal/core/notification_visibility_projection_test.go b/cli/internal/core/notification_visibility_projection_test.go index d9913c611..b12b5cfc5 100644 --- a/cli/internal/core/notification_visibility_projection_test.go +++ b/cli/internal/core/notification_visibility_projection_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" corev1 "hmans.de/chatto/internal/pb/chatto/core/v1" @@ -47,6 +48,61 @@ func TestNotificationVisibilityProjectionRetainsExactBoundaryWhenCurrentStateAdv } } +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{ diff --git a/cli/internal/core/rbac.go b/cli/internal/core/rbac.go index 347bc9f65..8c73a2775 100644 --- a/cli/internal/core/rbac.go +++ b/cli/internal/core/rbac.go @@ -101,14 +101,12 @@ 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.isConfiguredOwner(ctx, userID) + return c.rbacModel.hasRole(userID, RoleOwner), nil } func (c *ChattoCore) isConfiguredOwner(ctx context.Context, userID string) (bool, error) { diff --git a/cli/internal/core/verified_emails.go b/cli/internal/core/verified_emails.go index 56cba73d0..412dda1c6 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,7 +268,8 @@ 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. @@ -279,17 +280,27 @@ 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) + } + 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 63a6d2082..5a2a4b71f 100644 --- a/cli/internal/core/verified_emails_test.go +++ b/cli/internal/core/verified_emails_test.go @@ -317,6 +317,25 @@ 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 TestConfiguredOwnerRoleCannotDivergeFromEffectiveVisibility(t *testing.T) { core, _ := setupTestCore(t) ctx := testContext(t) 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-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index a472453bb..737beab4f 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -78,7 +78,9 @@ 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. A delivery waits for the projections needed by that +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 @@ -112,13 +114,20 @@ 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. Each administrative fact reads one retained boundary -instead of replaying lifetime EVT history on the single notification lane. +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. Configured `owners.emails` identities are materialized as durable owner-role -assignments. While an email remains configured, that role cannot be revoked; -the event-time RBAC projection and the live effective-owner override therefore -cannot disagree about room visibility. +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 diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 1c033c4bf..345809613 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -49,6 +49,14 @@ redelivery counts remain informational rather than a current failure flag. | 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 mutation attempt reconciles 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. Read/Done cancels pending delivery. Failed or expired claims remain retryable; list, mutation, and final delivery paths capture current recipient and server-wide room-event tails and wait local projections before exact target/reaction validation. List and realtime reads also wait the durable 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. + 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 diff --git a/docs/architecture/projections.md b/docs/architecture/projections.md index e9fe3358c..f6b5f290b 100644 --- a/docs/architecture/projections.md +++ b/docs/architecture/projections.md @@ -236,7 +236,7 @@ reconstruction. Legacy cohort paths remain outside application S3 expiry. | Projection | Contract | Payload store | Pointer store | Publication | | ---------- | -------- | ------------- | ------------- | ----------- | -| 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 acknowledged floor so pending exact boundaries replay | +| 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 acknowledged floor so pending exact boundaries replay into one full checkpoint plus compact deltas | | 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 | Rebuilds Slow Mode's latest-original-post index on restore; `v4` remains 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 | @@ -247,7 +247,7 @@ 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 only for administrative boundaries still pending on the shared notification worker | +| 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, 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 | diff --git a/docs/architecture/runtime-components.md b/docs/architecture/runtime-components.md index f06c04c84..127cb3044 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 | -| `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, and relevant RBAC facts; snapshot-capped exact-boundary visibility state and authoritative effective-membership cleanup; lifecycle OCC; and leased Alert delivery | +| `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/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-012-notifications.md b/docs/fdr/FDR-012-notifications.md index 21ee2daef..c2d64c2a3 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -55,8 +55,11 @@ losing the exact events and reasons underneath. 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; an administrative fact never replays lifetime - membership or RBAC history on the notification lane. Before exhaustive totals and badge summaries are read, + 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. 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, From 50cdcc0ca06a8347683aa6770bd1da94dd26787f Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 16:49:52 +0200 Subject: [PATCH 20/30] fix(auth): fence configured owner materialization --- cli/internal/core/verified_emails.go | 14 +++- cli/internal/core/verified_emails_test.go | 94 +++++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/cli/internal/core/verified_emails.go b/cli/internal/core/verified_emails.go index 412dda1c6..891f3af87 100644 --- a/cli/internal/core/verified_emails.go +++ b/cli/internal/core/verified_emails.go @@ -271,8 +271,8 @@ func (c *ChattoCore) addVerifiedEmailAs(ctx context.Context, actorID, userID, em }) 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 { @@ -299,6 +299,16 @@ func (c *ChattoCore) addVerifiedEmailAs(ctx context.Context, actorID, userID, em 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 5a2a4b71f..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" ) // ============================================================================ @@ -336,6 +339,97 @@ func TestConfiguredOwnerVerificationWaitsForDurableRole(t *testing.T) { } } +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) From 857c75322d5c0494415ea73d46d01dcc169ce960 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 16:55:36 +0200 Subject: [PATCH 21/30] fix(notifications): preserve safe visibility snapshots --- .../notification_visibility_projection.go | 15 +++++++++ ...notification_visibility_projection_test.go | 31 +++++++++++++++++++ .../core/projection_snapshot_worker.go | 16 +++++++--- .../core/projection_snapshot_worker_test.go | 23 ++++++++++++++ cli/internal/core/projection_wiring.go | 8 +++-- ...-deterministic-notification-occurrences.md | 3 ++ docs/architecture/durable-effects.md | 4 ++- docs/architecture/projections.md | 2 +- docs/fdr/FDR-012-notifications.md | 4 ++- 9 files changed, 97 insertions(+), 9 deletions(-) diff --git a/cli/internal/core/notification_visibility_projection.go b/cli/internal/core/notification_visibility_projection.go index 8e184439f..8e46d5fcb 100644 --- a/cli/internal/core/notification_visibility_projection.go +++ b/cli/internal/core/notification_visibility_projection.go @@ -242,6 +242,21 @@ func (p *NotificationVisibilityProjection) RestoreMaxCutoff() uint64 { return p.retainAfter.Load() } +// AllowSnapshotPublication prevents a current projector snapshot from rotating +// away the last generation at or below an unacknowledged worker boundary. A +// capture before a newly pending boundary remains safe because its cutoff does +// not include that fact. +func (p *NotificationVisibilityProjection) AllowSnapshotPublication(cutoff uint64) bool { + p.mu.RLock() + defer p.mu.RUnlock() + for boundary := range p.boundaries { + if boundary <= cutoff { + return false + } + } + return true +} + func (p *NotificationVisibilityProjection) Boundary(sequence uint64, at time.Time) (*notificationVisibilitySnapshot, error) { p.mu.Lock() defer p.mu.Unlock() diff --git a/cli/internal/core/notification_visibility_projection_test.go b/cli/internal/core/notification_visibility_projection_test.go index b12b5cfc5..e968b062e 100644 --- a/cli/internal/core/notification_visibility_projection_test.go +++ b/cli/internal/core/notification_visibility_projection_test.go @@ -161,3 +161,34 @@ func TestNotificationVisibilitySnapshotRestoreIsCappedAtWorkerFloor(t *testing.T t.Fatalf("snapshot max cutoff = %d, want worker floor 41", underlying.request.MaxCutoff) } } + +func TestNotificationVisibilitySnapshotPublicationPreservesSafeGenerationWhilePending(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) + } + 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") + } +} 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_wiring.go b/cli/internal/core/projection_wiring.go index 789d1401a..4c1441e8f 100644 --- a/cli/internal/core/projection_wiring.go +++ b/cli/internal/core/projection_wiring.go @@ -257,12 +257,16 @@ func configureProjectionSnapshots( ); 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/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index 737beab4f..8165c0046 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -120,6 +120,9 @@ 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. +While such a boundary is pending, snapshot publication is deferred rather than +rotating the repository's last generation at or below the acknowledged floor; +a restart can therefore restore that safe generation and replay only its tail. Configured `owners.emails` identities are materialized as durable owner-role assignments at boot and through the same retryable durable lane after email diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 345809613..4a8925cf4 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -55,7 +55,9 @@ 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. +redelivery gaps without adding notification-only EVT facts. Snapshot publication +also defers while a pending boundary would make the new generation unsafe for +the acknowledged restore cap, preserving the repository's last safe generation. Observability is currently domain-specific. Call reconciliation records its consecutive LiveKit listing failures in `MEMORY_CACHE`. Owner-only asset-cleanup diff --git a/docs/architecture/projections.md b/docs/architecture/projections.md index f6b5f290b..8fb2b88cb 100644 --- a/docs/architecture/projections.md +++ b/docs/architecture/projections.md @@ -236,7 +236,7 @@ reconstruction. Legacy cohort paths remain outside application S3 expiry. | Projection | Contract | Payload store | Pointer store | Publication | | ---------- | -------- | ------------- | ------------- | ----------- | -| 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 acknowledged floor so pending exact boundaries replay into one full checkpoint plus compact deltas | +| 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 acknowledged floor so pending exact boundaries replay into one full checkpoint plus compact deltas; publication defers while it would rotate away the last safe generation | | 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 | Rebuilds Slow Mode's latest-original-post index on restore; `v4` remains 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 | diff --git a/docs/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index c2d64c2a3..d7a789158 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -59,7 +59,9 @@ losing the exact events and reasons underneath. 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. Before exhaustive totals and badge summaries are read, + on the notification lane. Snapshot publication pauses while a captured + generation would cross an unacknowledged boundary, preserving 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, From 4ae1d9f7221734c0d05d30e6b0b05183834a4cc3 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 17:01:56 +0200 Subject: [PATCH 22/30] fix(notifications): align snapshot publication floor --- .../core/notification_materializer.go | 28 ++++++++++--- .../core/notification_materializer_test.go | 35 ++++++++++++++++ .../notification_visibility_projection.go | 42 +++++++++---------- ...notification_visibility_projection_test.go | 30 ++++++++++++- ...-deterministic-notification-occurrences.md | 7 ++-- docs/architecture/durable-effects.md | 5 ++- docs/architecture/projections.md | 2 +- docs/fdr/FDR-012-notifications.md | 5 ++- 8 files changed, 117 insertions(+), 37 deletions(-) diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go index c3c6ff23a..20cf383d8 100644 --- a/cli/internal/core/notification_materializer.go +++ b/cli/internal/core/notification_materializer.go @@ -76,11 +76,7 @@ func (m *NotificationMaterializer) Initialize(ctx context.Context) error { if err != nil { return fmt.Errorf("read notification consumer initialization floor: %w", err) } - processed := info.AckFloor.Stream - if info.NumPending == 0 && info.NumAckPending == 0 { - processed = tail - } - m.visibility.Projection().SetRestoreMaxCutoff(processed) + m.visibility.Projection().SetAcknowledgedThrough(notificationAcknowledgedThrough(tail, info)) m.consumer = consumer close(m.ready) return nil @@ -132,16 +128,36 @@ func (m *NotificationMaterializer) Run(ctx context.Context) error { } 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.core.EventPublisher.LastStreamSeq(ctx) + if err != nil { + m.core.logger.Warn("Failed to read EVT tail for visibility cleanup", "error", err) + return + } info, err := m.consumer.Info(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(info.AckFloor.Stream); err != nil { + if err := m.visibility.Projection().ReleaseThrough(notificationAcknowledgedThrough(tail, info)); err != nil { m.core.logger.Warn("Failed to compact acknowledged notification visibility boundaries", "error", err) } } +// 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 +} + // 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. diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go index 992cc5e26..a2494c54f 100644 --- a/cli/internal/core/notification_materializer_test.go +++ b/cli/internal/core/notification_materializer_test.go @@ -177,6 +177,41 @@ func TestNotificationVisibilityBoundarySurvivesSuccessfulHandlerUntilAckFloor(t } } +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 TestConfiguredOwnerMaterializationRetriesWithoutLiveFallbackDivergence(t *testing.T) { chattoCore, _ := setupTestCore(t) ctx := testContext(t) diff --git a/cli/internal/core/notification_visibility_projection.go b/cli/internal/core/notification_visibility_projection.go index 8e46d5fcb..0e14d470a 100644 --- a/cli/internal/core/notification_visibility_projection.go +++ b/cli/internal/core/notification_visibility_projection.go @@ -38,9 +38,9 @@ type NotificationVisibilityProjection 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 - retainAfter atomic.Uint64 + evaluatorSequence uint64 + evaluator *notificationVisibilitySnapshot + acknowledgedThrough atomic.Uint64 } type notificationVisibilityDelta struct { @@ -90,7 +90,7 @@ func (p *NotificationVisibilityProjection) Apply(event *corev1.Event, seq uint64 if err := p.rbac.Apply(event, seq); err != nil { return err } - boundary := seq > p.retainAfter.Load() && notificationVisibilityBoundaryEvent(event) + boundary := seq > p.acknowledgedThrough.Load() && notificationVisibilityBoundaryEvent(event) if len(p.checkpoint) == 0 { if !boundary { return nil @@ -231,30 +231,23 @@ func decodeNotificationVisibilityState(data []byte) (*RoomDirectoryProjection, * return rooms, groups, rbac, nil } -// SetRestoreMaxCutoff binds snapshot restore to the notification consumer's -// acknowledged floor. Pending deliveries are replayed into exact boundary -// snapshots instead of being hidden behind a newer projection snapshot. -func (p *NotificationVisibilityProjection) SetRestoreMaxCutoff(sequence uint64) { - p.retainAfter.Store(sequence) +// 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.retainAfter.Load() + return p.acknowledgedThrough.Load() } -// AllowSnapshotPublication prevents a current projector snapshot from rotating -// away the last generation at or below an unacknowledged worker boundary. A -// capture before a newly pending boundary remains safe because its cutoff does -// not include that fact. +// 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 { - p.mu.RLock() - defer p.mu.RUnlock() - for boundary := range p.boundaries { - if boundary <= cutoff { - return false - } - } - return true + return cutoff <= p.acknowledgedThrough.Load() } func (p *NotificationVisibilityProjection) Boundary(sequence uint64, at time.Time) (*notificationVisibilitySnapshot, error) { @@ -308,6 +301,11 @@ func applyNotificationVisibilityDeltas(rooms *RoomDirectoryProjection, groups *R // 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 { diff --git a/cli/internal/core/notification_visibility_projection_test.go b/cli/internal/core/notification_visibility_projection_test.go index e968b062e..a1fdeb8af 100644 --- a/cli/internal/core/notification_visibility_projection_test.go +++ b/cli/internal/core/notification_visibility_projection_test.go @@ -151,7 +151,7 @@ func (s *notificationVisibilityCapturingSnapshotSource) LoadProjectionSnapshot(_ func TestNotificationVisibilitySnapshotRestoreIsCappedAtWorkerFloor(t *testing.T) { projection := NewNotificationVisibilityProjection() - projection.SetRestoreMaxCutoff(41) + projection.SetAcknowledgedThrough(41) underlying := ¬ificationVisibilityCapturingSnapshotSource{} source := cappedNotificationVisibilitySnapshotSource{source: underlying, projection: projection} if _, err := source.LoadProjectionSnapshot(context.Background(), events.ProjectionSnapshotLoadRequest{MaxCutoff: 99}); err != nil { @@ -164,6 +164,7 @@ func TestNotificationVisibilitySnapshotRestoreIsCappedAtWorkerFloor(t *testing.T 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, }}} @@ -192,3 +193,30 @@ func TestNotificationVisibilitySnapshotPublicationPreservesSafeGenerationWhilePe 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/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index 8165c0046..ab065136f 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -120,9 +120,10 @@ 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. -While such a boundary is pending, snapshot publication is deferred rather than -rotating the repository's last generation at or below the acknowledged floor; -a restart can therefore restore that safe generation and replay only its tail. +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. The repository therefore retains a generation that restart can +accept and needs to replay only its tail. Configured `owners.emails` identities are materialized as durable owner-role assignments at boot and through the same retryable durable lane after email diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 4a8925cf4..7d78c91f0 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -56,8 +56,9 @@ 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 while a pending boundary would make the new generation unsafe for -the acknowledged restore cap, preserving the repository's last safe generation. +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. Observability is currently domain-specific. Call reconciliation records its consecutive LiveKit listing failures in `MEMORY_CACHE`. Owner-only asset-cleanup diff --git a/docs/architecture/projections.md b/docs/architecture/projections.md index 8fb2b88cb..705c133ff 100644 --- a/docs/architecture/projections.md +++ b/docs/architecture/projections.md @@ -236,7 +236,7 @@ reconstruction. Legacy cohort paths remain outside application S3 expiry. | Projection | Contract | Payload store | Pointer store | Publication | | ---------- | -------- | ------------- | ------------- | ----------- | -| 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 acknowledged floor so pending exact boundaries replay into one full checkpoint plus compact deltas; publication defers while it would rotate away the last safe generation | +| 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 | | 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 | Rebuilds Slow Mode's latest-original-post index on restore; `v4` remains 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 | diff --git a/docs/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index d7a789158..60457351c 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -59,8 +59,9 @@ losing the exact events and reasons underneath. 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 while a captured - generation would cross an unacknowledged boundary, preserving the last safe + 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. 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 From 2cee3f4584383e56093f17629a35569b11a87495 Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 17:14:18 +0200 Subject: [PATCH 23/30] fix(notifications): reconstruct safe restart floor --- .../core/notification_materializer.go | 41 ++++++++++++++- .../core/notification_materializer_test.go | 50 +++++++++++++++++++ ...-deterministic-notification-occurrences.md | 6 ++- docs/architecture/durable-effects.md | 4 +- docs/architecture/projections.md | 2 +- docs/fdr/FDR-012-notifications.md | 7 ++- 6 files changed, 103 insertions(+), 7 deletions(-) diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go index 20cf383d8..173358240 100644 --- a/cli/internal/core/notification_materializer.go +++ b/cli/internal/core/notification_materializer.go @@ -76,7 +76,11 @@ func (m *NotificationMaterializer) Initialize(ctx context.Context) error { if err != nil { return fmt.Errorf("read notification consumer initialization floor: %w", err) } - m.visibility.Projection().SetAcknowledgedThrough(notificationAcknowledgedThrough(tail, info)) + 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 @@ -158,6 +162,41 @@ func notificationAcknowledgedThrough(tail uint64, info *jetstream.ConsumerInfo) 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. diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go index a2494c54f..44fca0d0b 100644 --- a/cli/internal/core/notification_materializer_test.go +++ b/cli/internal/core/notification_materializer_test.go @@ -212,6 +212,56 @@ func TestNotificationAcknowledgedThroughUsesFullConsumerFloor(t *testing.T) { } } +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.consumer.Info(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) diff --git a/docs/adr/ADR-070-deterministic-notification-occurrences.md b/docs/adr/ADR-070-deterministic-notification-occurrences.md index ab065136f..4c381889b 100644 --- a/docs/adr/ADR-070-deterministic-notification-occurrences.md +++ b/docs/adr/ADR-070-deterministic-notification-occurrences.md @@ -122,8 +122,10 @@ 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. The repository therefore retains a generation that restart can -accept and needs to replay only its tail. +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 diff --git a/docs/architecture/durable-effects.md b/docs/architecture/durable-effects.md index 7d78c91f0..5fb0967b0 100644 --- a/docs/architecture/durable-effects.md +++ b/docs/architecture/durable-effects.md @@ -58,7 +58,9 @@ 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. +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 diff --git a/docs/architecture/projections.md b/docs/architecture/projections.md index 705c133ff..0ad205cf6 100644 --- a/docs/architecture/projections.md +++ b/docs/architecture/projections.md @@ -236,7 +236,7 @@ reconstruction. Legacy cohort paths remain outside application S3 expiry. | Projection | Contract | Payload store | Pointer store | Publication | | ---------- | -------- | ------------- | ------------- | ----------- | -| 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 | +| 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 | Rebuilds Slow Mode's latest-original-post index on restore; `v4` remains 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 | diff --git a/docs/fdr/FDR-012-notifications.md b/docs/fdr/FDR-012-notifications.md index 60457351c..4f34b75ab 100644 --- a/docs/fdr/FDR-012-notifications.md +++ b/docs/fdr/FDR-012-notifications.md @@ -61,8 +61,11 @@ losing the exact events and reasons underneath. 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. This preserves the last safe - restore point instead of rotating it away. Before exhaustive totals and badge summaries are read, + 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, From b4b4ac12ea1e36475933234298ab1a969e25536a Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 17:20:02 +0200 Subject: [PATCH 24/30] fix(notifications): isolate worker tail reads --- .../core/notification_materializer.go | 20 +++++++++++++++++-- .../core/notification_materializer_test.go | 3 +++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/cli/internal/core/notification_materializer.go b/cli/internal/core/notification_materializer.go index 173358240..32380fc36 100644 --- a/cli/internal/core/notification_materializer.go +++ b/cli/internal/core/notification_materializer.go @@ -68,7 +68,7 @@ func (m *NotificationMaterializer) Initialize(ctx context.Context) error { // 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.core.EventPublisher.LastStreamSeq(ctx) + tail, err := m.eventStreamTail(ctx) if err != nil { return fmt.Errorf("read notification consumer initialization tail: %w", err) } @@ -135,7 +135,7 @@ func (m *NotificationMaterializer) releaseAcknowledgedVisibilityBoundaries(ctx c // 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.core.EventPublisher.LastStreamSeq(ctx) + tail, err := m.eventStreamTail(ctx) if err != nil { m.core.logger.Warn("Failed to read EVT tail for visibility cleanup", "error", err) return @@ -150,6 +150,22 @@ func (m *NotificationMaterializer) releaseAcknowledgedVisibilityBoundaries(ctx c } } +// 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 diff --git a/cli/internal/core/notification_materializer_test.go b/cli/internal/core/notification_materializer_test.go index 44fca0d0b..fbe9c75e8 100644 --- a/cli/internal/core/notification_materializer_test.go +++ b/cli/internal/core/notification_materializer_test.go @@ -269,6 +269,9 @@ func TestConfiguredOwnerMaterializationRetriesWithoutLiveFallbackDivergence(t *t 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 From acfe40cddfde6bc9978c63c709dd2f184656a67f Mon Sep 17 00:00:00 2001 From: Hendrik Mans Date: Tue, 11 Aug 2026 19:39:42 +0200 Subject: [PATCH 25/30] fix(notifications): address inbox review findings --- .../connectrpc-api/notifications.mdx | 33 +++ .../src/generated/connectrpc-api/api.raw.mdx | 33 +++ .../api-client-tests/notifications.spec.ts | 28 ++ .../src/lib/api-client/notifications.ts | 12 +- .../NotificationPolicySettings.svelte | 48 +++- .../NotificationPolicySettings.svelte.spec.ts | 57 +++- .../lib/state/server/notifications.spec.ts | 6 +- .../lib/state/server/notifications.svelte.ts | 2 +- .../routes/chat/notifications/+page.svelte | 47 ++-- .../notifications.page.svelte.spec.ts | 22 ++ .../connectapi/notification_occurrences.go | 17 ++ cli/internal/connectapi/room_services_test.go | 37 +++ cli/internal/core/messages.go | 108 +++++--- .../core/notification_materializer.go | 37 ++- .../core/notification_materializer_test.go | 117 ++++++++- .../core/notification_occurrence_model.go | 54 +++- .../notification_occurrence_model_test.go | 102 ++++++++ cli/internal/core/projection_registry_test.go | 4 +- .../v1/apiv1connect/notifications.connect.go | 36 +++ .../pb/chatto/api/v1/notifications.pb.go | 246 +++++++++++++----- ...-deterministic-notification-occurrences.md | 22 +- docs/architecture/durable-effects.md | 2 +- docs/fdr/FDR-012-notifications.md | 14 +- .../chatto/api/v1/notifications_connect.ts | 15 +- .../src/chatto/api/v1/notifications_pb.ts | 83 ++++++ proto/chatto/api/v1/notifications.proto | 18 ++ 26 files changed, 1028 insertions(+), 172 deletions(-) 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 dd03b9080..6d1d9b3af 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 @@ -90,6 +90,39 @@ Updated notification occurrence. | `notification` | [`NotificationOccurrence`](/reference/connectrpc-api/types/#chatto-api-v1-NotificationOccurrence) | Occurrence after applying the patch. | +
+ +### DeleteNotificationOccurrence + +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/DeleteNotificationOccurrence +``` + + + +#### Input: DeleteNotificationOccurrenceRequest + +Request permanent deletion of one notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `notification_id` | `string` | Required stable occurrence ID. | + + + + +#### Result: DeleteNotificationOccurrenceResponse + +Result of deleting one notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `deleted` | `bool` | True when this call replaced a visible occurrence with a deletion tombstone. False when the occurrence was already absent or deleted. | + + ### UpdateNotificationGroup 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 f1e6e54c1..cd8a75744 100644 --- a/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx +++ b/apps/docs-website/src/generated/connectrpc-api/api.raw.mdx @@ -2128,6 +2128,39 @@ Updated notification occurrence. | `notification` | [`NotificationOccurrence`](#chatto-api-v1-NotificationOccurrence) | Occurrence after applying the patch. | + + +### DeleteNotificationOccurrence + +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/DeleteNotificationOccurrence +``` + + + +#### Input: DeleteNotificationOccurrenceRequest + +Request permanent deletion of one notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `notification_id` | `string` | Required stable occurrence ID. | + + + + +#### Result: DeleteNotificationOccurrenceResponse + +Result of deleting one notification occurrence. + +| Field | Type | Description | +| --- | --- | --- | +| `deleted` | `bool` | True when this call replaced a visible occurrence with a deletion tombstone. False when the occurrence was already absent or deleted. | + + ### UpdateNotificationGroup 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 d95c38139..63e157e62 100644 --- a/apps/frontend/src/lib/api-client-tests/notifications.spec.ts +++ b/apps/frontend/src/lib/api-client-tests/notifications.spec.ts @@ -99,4 +99,32 @@ describe('notification occurrence presentation mapping', () => { roomMsgEventId: 'message-1' }); }); + + 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 + } + ], + inboxState: NotificationInboxState.UNREAD + }) + ); + + expect(occurrenceAsNotificationItem(occurrence)).toMatchObject({ + kind: NotificationItemKind.RoomMessage, + roomMsgEventId: 'message-1', + roomMsgThreadRootId: 'thread-root-1' + }); + }); }); diff --git a/apps/frontend/src/lib/api-client/notifications.ts b/apps/frontend/src/lib/api-client/notifications.ts index 963fbf19e..850061b65 100644 --- a/apps/frontend/src/lib/api-client/notifications.ts +++ b/apps/frontend/src/lib/api-client/notifications.ts @@ -84,6 +84,7 @@ export type RoomMessageNotificationItem = { summary: string; roomMsgRoom: { id: string; name: string } | null; roomMsgEventId: string; + roomMsgThreadRootId?: string | null; }; export type NotificationItem = @@ -181,6 +182,14 @@ export function createNotificationAPI(config: NotificationAPIConfig) { return notificationOccurrence(response.notification); }, + async deleteNotificationOccurrence(notificationId: string): Promise { + const response = await client.deleteNotificationOccurrence( + { notificationId }, + { headers: headers() } + ); + return response.deleted; + }, + async deleteNotificationGroup(groupId: string, view: NotificationView): Promise { return Number( (await client.deleteNotificationGroup({ groupId, view }, { headers: headers() })) @@ -336,7 +345,8 @@ export function occurrenceAsNotificationItem(item: NotificationOccurrenceItem): kind: NotificationItemKind.RoomMessage, ...base, roomMsgRoom: item.room, - roomMsgEventId: item.eventId + roomMsgEventId: item.eventId, + roomMsgThreadRootId: item.threadRootId }; } diff --git a/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte b/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte index 416684aeb..318152f0e 100644 --- a/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte +++ b/apps/frontend/src/lib/components/settings/NotificationPolicySettings.svelte @@ -10,10 +10,15 @@ const serverScope = useServerScope(); const notificationStore = $derived(serverScope.store.notifications); + const policyRooms = $derived( + (serverScope.store.navigation?.rooms ?? []).filter((room) => room.viewerIsMember) + ); let preferences = $state.raw([]); let loading = $state(true); let error = $state(null); let savingReason = $state(null); + let selectedRoomId = $state(''); + let loadGeneration = 0; const reasons = [ NotificationReason.DIRECT_MESSAGE, @@ -28,10 +33,12 @@ ]; $effect(() => { - void load(); + const roomId = selectedRoomId; + void load(roomId); }); - async function load() { + async function load(roomId: string) { + const generation = ++loadGeneration; loading = true; error = null; if (!notificationStore?.getPolicy) { @@ -39,25 +46,30 @@ return; } try { - preferences = await notificationStore.getPolicy(); + const nextPreferences = await notificationStore.getPolicy(roomId || undefined); + if (generation !== loadGeneration) return; + preferences = nextPreferences; } catch (cause) { + if (generation !== loadGeneration) return; error = cause instanceof Error ? cause.message : m('settings.notifications.policy.load_failed'); } finally { - loading = false; + if (generation === loadGeneration) loading = false; } } async function change(reason: NotificationReason, event: Event) { const select = event.currentTarget as HTMLSelectElement; - const previousIntensity = - preferences.find((candidate) => candidate.reason === reason)?.serverIntensity ?? - NotificationDeliveryIntensity.UNSPECIFIED; + const preference = preferences.find((candidate) => candidate.reason === reason); + const roomId = selectedRoomId || undefined; + const previousIntensity = roomId + ? (preference?.roomIntensity ?? NotificationDeliveryIntensity.UNSPECIFIED) + : (preference?.serverIntensity ?? NotificationDeliveryIntensity.UNSPECIFIED); const intensity = Number(select.value) as NotificationDeliveryIntensity; savingReason = reason; error = null; try { - preferences = await notificationStore.setPolicyPreference(reason, intensity); + preferences = await notificationStore.setPolicyPreference(reason, intensity, roomId); } catch (cause) { select.value = String(previousIntensity); preferences = [...preferences]; @@ -109,6 +121,20 @@

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

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

{m('common.loading')}

@@ -130,7 +156,11 @@