Skip to content

fix: channel message notification read status fixes#2482

Merged
gbirman merged 12 commits intomainfrom
gab/fix/message-notification-read
Apr 9, 2026
Merged

fix: channel message notification read status fixes#2482
gbirman merged 12 commits intomainfrom
gab/fix/message-notification-read

Conversation

@gbirman
Copy link
Copy Markdown
Contributor

@gbirman gbirman commented Apr 9, 2026

a few issues here:

  • backend wasn't returning valid notification types since they didn't have an id field, any mark as seen logic was sending nulls. i fixed this by generating the connection gateway notification type and mapping to the unified notification with a zod-based safe parse
  • backend wasn't sending created_at/updated_at on a new notification, this was messing with some of the existing sort logic
  • we didn't have a oncleanup mark channel as viewed, so the last viewed timestamp was out of date
  • the markmessagenotifications component had an onmount that read from notifications, however sometimes the notification source wasn't updated so there would be a race condition where we never mark the message as read. now we will subscribe to updates

other changes:

  • i clean mark message notifications to assume a single notification for read. this seems to match up with existing logic where we don't send multiple channel message notifications for the same message send. this makes things like unsubscribing after an update easier
  • the unread widget indicator now works like a viewed notification stack, so you can keep clicking into it to see new and it will use the target message api. to do this i remove the debounced mark everything for an entity as read on the channel adapter, i don't think we need this anymore

@gbirman gbirman requested review from a team as code owners April 9, 2026 21:23
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 9, 2026

📝 Walkthrough

Summary by CodeRabbit

Release Notes

  • New Features

    • Improved notification navigation: enhanced support for opening notifications in the current view or launching them in new split windows based on user interaction.
  • Bug Fixes

    • Fixed notification tracking component issue affecting notification status.
    • Improved consistency of channel activity tracking across different user interactions.
  • Improvements

    • Added validation layer for incoming notifications with graceful fallback handling for invalid data.

Walkthrough

Refactors frontend notification handling and navigation, strengthens websocket notification parsing/validation, adds a unified Zod notification schema, renames and reworks message-notification components, and enables conditional OpenAPI schema generation for notification-related Rust types.

Changes

Cohort / File(s) Summary
Channel UI & navigation
js/app/packages/app/component/app-sidebar/channels-unread-widget.tsx, js/app/packages/channel/Channel/Channel.tsx, js/app/packages/block-channel/component/NewChannelBlockAdapter.tsx, js/app/packages/notifications/notification-navigation.ts
Replaced split-opening calls with unified navigateToLatestNotification(...) and getChannelNotificationParams(...); centralized markAsViewed() with mount/cleanup/before-leave hooks; removed ChannelDebouncedNotificationReadMarker usage.
Message notification components
js/app/packages/channel/Thread/ChannelThread.tsx, js/app/packages/channel/Thread/ThreadReplyList.tsx, js/app/packages/notifications/components/MarkMessageNotifications.tsx
Fixed component name MarkMessaageNotificationsMarkMessageNotifications; rewrote component to use useNotificationsForEntity() and a subscription-based flow with isMessageNotification predicate, added cleanup on unmount.
Websocket parsing & notification types
js/app/packages/notifications/notification-source.ts, js/app/packages/notifications/types.ts
Introduced two-step websocket parsing: map raw ConnGatewayInnerNotifValueUnifiedNotification, validate with unifiedNotificationSchema.safeParse, and log Zod errors on failure; exported new unifiedNotificationSchema.
Service client / OpenAPI (frontend)
js/app/packages/service-clients/orval.config.ts, js/app/packages/service-clients/service-notification/openapi.json
Switched Orval output target to Zod client (zod) with split mode; added OpenAPI component schemas for gateway notification payloads and related types.
Rust: schema feature & OpenAPI types
rust/cloud-storage/macro_user_id/Cargo.toml, rust/cloud-storage/macro_user_id/src/user_id.rs, rust/cloud-storage/notification/Cargo.toml, rust/cloud-storage/notification/src/domain/models.rs, rust/cloud-storage/notification/src/domain/models/queue_message.rs, rust/cloud-storage/notification_service/src/api/swagger.rs
Added schema feature and optional utoipa dependency; gated utoipa::ToSchema/PartialSchema derives for MacroUserIdStr, NotificationTypeName, TaggedContent<T>, ConnGatewayInnerNotif<T>, and ConnGatewayNotification<'a, T>; added ConnGatewayNotificationPayload type and registered it in Swagger components.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title follows conventional commits format with 'fix:' prefix and is 51 characters, well under the 72-character limit. It clearly summarizes the main change: fixing channel message notification read status issues.
Description check ✅ Passed The pull request description is directly related to the changeset, providing detailed explanations of the backend notification fixes, race condition resolutions, and widget behavior changes that match the file modifications.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 9, 2026

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@js/app/packages/app/component/app-sidebar/channels-unread-widget.tsx`:
- Around line 132-149: The open actions are inconsistent: openInCurrentSplit and
the fullscreen/open-root path use layout.openWithSplit(content()) and thus open
the channel root instead of the specific unread notification; route these
through the same notification target as navigateToLatestNotification so clicks
always open the unread item. Update openInCurrentSplit and the
fullscreen/open-root handler to call navigateToLatestNotification(false) (or
call openNotification(latestNotification(), manager, false) via the
globalSplitManager) instead of layout.openWithSplit(content()), and ensure you
check latestNotification() exists before invoking openNotification so behavior
matches the new-split path.

In `@js/app/packages/channel/Channel/Channel.tsx`:
- Around line 164-170: onCleanup and useBeforeLeave both call markAsViewed()
causing duplicate non-idempotent requests; add a local guard boolean (e.g.,
hasMarkedViewed or didMarkViewed) scoped to the component and check it before
calling markAsViewed in both handlers, setting it to true immediately after the
first call so subsequent invocations (from the other lifecycle hook) are no-ops;
update the onCleanup and useBeforeLeave handlers to reference this guard and
only call markAsViewed when the guard is false.

In `@js/app/packages/notifications/components/MarkMessageNotifications.tsx`:
- Around line 35-40: The call to the async method notificationSource.markAsRead
is currently fire-and-forget in the subscribe callback
(notificationSource.subscribe -> callback using isMessageNotification and
unsubscribe) and at the other call site; either await the promise where the read
state must be confirmed (e.g., make the callback async and await
notificationSource.markAsRead) or, if you intend to keep it fire-and-forget,
append a .catch(...) to notificationSource.markAsRead to log errors (use your
app logger or console.error) so failures aren’t silently swallowed; update both
places that call markAsRead.
- Around line 43-45: The guard "if (existing && !existing.viewed_at) {
notificationSource.markAsRead(existing); }" is redundant because earlier code
returns when existing is falsy; remove the unnecessary truthy check and simplify
to check only viewed state, e.g., call notificationSource.markAsRead(existing)
when !existing.viewed_at, keeping the same variable name "existing" and method
"notificationSource.markAsRead" to locate the change.

In `@js/app/packages/notifications/notification-source.ts`:
- Around line 129-137: The mapWebsocketNotification function currently casts
raw.notification_metadata with "as NotifEvent", bypassing Zod validation; remove
that assertion and either (a) parse/validate notification_metadata with the
existing Zod schema inside mapWebsocketNotification (use schema.safeParse and
set notification_metadata to the parsed value on success or to a validated-safe
fallback on failure) or (b) leave notification_metadata typed as unknown/any on
the returned UnifiedNotification and let the existing safeParse call elsewhere
handle validation—reference mapWebsocketNotification, UnifiedNotification,
ConnGatewayInnerNotifValue, notification_metadata, NotifEvent, and the
schema.safeParse usage when making the change.

In `@js/app/packages/notifications/types.ts`:
- Around line 7-12: The code is accessing Zod internals (_def.left/_def.right)
to build unifiedNotificationSchema from
listTypedNotificationsResponse.shape.items.element; instead, stop using
_baseSchema/_entitySchema/_allOfSchema and rebuild unifiedNotificationSchema via
public Zod APIs: either import the specific exported schema parts from the
generator or operate on listTypedNotificationsResponse.shape.items.element with
public methods (e.g., .omit(), .pick(), .merge() or .extend()) to remove
owner_id and compose the entity, or define a local Zod object for the unified
notification shape and use that in place of the private-internal-based
construction.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0faa6e8d-7d35-4e3c-b52b-b508754a9cb4

📥 Commits

Reviewing files that changed from the base of the PR and between 5de8f3f and e4258f9.

⛔ Files ignored due to path filters (15)
  • js/app/packages/service-clients/service-notification/generated/client.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/connGatewayInnerNotifValue.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/connGatewayInnerNotifValueAllOf.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/connGatewayInnerNotifValueAllOfCreatedAt.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/connGatewayInnerNotifValueAllOfDeletedAt.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/connGatewayInnerNotifValueAllOfSenderId.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/connGatewayInnerNotifValueAllOfUpdatedAt.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/connGatewayInnerNotifValueAllOfViewedAt.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/connGatewayNotificationPayload.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/index.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/macroUserIdStr.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/notificationTypeName.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/schemas/taggedContentValue.ts is excluded by !**/generated/**
  • js/app/packages/service-clients/service-notification/generated/zod.ts is excluded by !**/generated/**
  • rust/cloud-storage/Cargo.lock is excluded by !**/*.lock, !**/Cargo.lock
📒 Files selected for processing (16)
  • js/app/packages/app/component/app-sidebar/channels-unread-widget.tsx
  • js/app/packages/block-channel/component/NewChannelBlockAdapter.tsx
  • js/app/packages/channel/Channel/Channel.tsx
  • js/app/packages/channel/Thread/ChannelThread.tsx
  • js/app/packages/channel/Thread/ThreadReplyList.tsx
  • js/app/packages/notifications/components/MarkMessageNotifications.tsx
  • js/app/packages/notifications/notification-source.ts
  • js/app/packages/notifications/types.ts
  • js/app/packages/service-clients/orval.config.ts
  • js/app/packages/service-clients/service-notification/openapi.json
  • rust/cloud-storage/macro_user_id/Cargo.toml
  • rust/cloud-storage/macro_user_id/src/user_id.rs
  • rust/cloud-storage/notification/Cargo.toml
  • rust/cloud-storage/notification/src/domain/models.rs
  • rust/cloud-storage/notification/src/domain/models/queue_message.rs
  • rust/cloud-storage/notification_service/src/api/swagger.rs
💤 Files with no reviewable changes (1)
  • js/app/packages/block-channel/component/NewChannelBlockAdapter.tsx

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
js/app/packages/app/component/app-sidebar/channels-unread-widget.tsx (1)

155-169: ⚠️ Potential issue | 🟠 Major

Keep the anchor href aligned with the unread target.

Line 156 still points at the channel root. Primary click is intercepted, but browser-native paths like middle-click, cmd/ctrl-click, “open link in new tab”, and copied links will still open the root channel instead of the unread message/thread. Build the href from the same notification params so every entry point lands on the same target.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/app/component/app-sidebar/channels-unread-widget.tsx` around
lines 155 - 169, The anchor's href currently points to the channel root (using
props.group.entityId) so non-primary clicks open the wrong location; change the
href to the exact unread-target URL built from the same notification parameters
used by navigateToLatestNotification (i.e., derive the target path the same way
navigateToLatestNotification computes it) so
middle-click/CMD-click/open-in-new-tab/copy-link all land on the unread
message/thread; update the href generation in the component to use that same
logic (or extract that logic into a helper like getLatestNotificationUrl and
reuse it) while keeping the existing onClick handler
(navigateToLatestNotification) and classList/isVisible behavior.
♻️ Duplicate comments (1)
js/app/packages/notifications/components/MarkMessageNotifications.tsx (1)

35-44: ⚠️ Potential issue | 🟠 Major

Wait for markAsRead to settle before dropping the subscription.

Line 37 and Line 44 both fire-and-forget the async read, and Line 38 unsubscribes immediately. If markAsRead rejects once, this component loses its only retry path and the notification can remain unread until remount.

🔧 Suggested change
     if (!existing) {
       unsubscribe = notificationSource.subscribe((n) => {
-        if (!isMessageNotification(n)) return;
-        notificationSource.markAsRead(n);
-        unsubscribe();
+        if (!isMessageNotification(n) || n.viewed_at) return;
+        void notificationSource
+          .markAsRead(n)
+          .then(() => unsubscribe())
+          .catch((error) => {
+            console.error(error);
+          });
       });
       return;
     }

     if (!existing.viewed_at) {
-      notificationSource.markAsRead(existing);
+      void notificationSource.markAsRead(existing).catch((error) => {
+        console.error(error);
+      });
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@js/app/packages/notifications/components/MarkMessageNotifications.tsx` around
lines 35 - 44, The component currently calls notificationSource.markAsRead(...)
without awaiting it and immediately calls unsubscribe(), which can drop the only
retry path on failure; update both places where markAsRead is invoked (the
subscriber callback created via notificationSource.subscribe and the branch
handling !existing.viewed_at) to await the Promise returned by
notificationSource.markAsRead (use async/await), only call unsubscribe() after
the await completes successfully, and catch errors (e.g., try/catch) so that on
rejection you do not unsubscribe and you can log or surface the error instead of
losing the subscription.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@js/app/packages/app/component/app-sidebar/channels-unread-widget.tsx`:
- Around line 140-149: Remove the unreachable fullscreen navigation path: delete
the _openFullscreen function and any commented-out menu entry that referenced
it, and remove any unused imports or variables only used by that function (e.g.,
latestNotification, getChannelNotificationParams, globalSplitManager,
createPopoverSplit, props.group.entityId) so nothing dead remains; if the menu
entry is intended for a future PR keep a single TODO comment instead of the
broken code.
- Around line 119-129: latestNotification() assumes props.group.notifications[0]
is newest which relies on an undocumented ordering guarantee from
notificationSource.notifications()/user_notifications; to fix, ensure ordering
is explicit by either (a) sorting the array by created_at descending where
groups are assembled (e.g. inside groupByChannel()) before returning the group,
or (b) add a runtime assertion in latestNotification() that verifies
notifications are newest-first (compare first and last timestamps) and fall back
to picking the max created_at if not; reference functions/values:
latestNotification(), props.group.notifications, groupByChannel(),
optimisticInsertNotification(), and the user_notifications API so the chosen
change enforces or documents created_at-desc ordering to avoid opening stale
notifications.

---

Outside diff comments:
In `@js/app/packages/app/component/app-sidebar/channels-unread-widget.tsx`:
- Around line 155-169: The anchor's href currently points to the channel root
(using props.group.entityId) so non-primary clicks open the wrong location;
change the href to the exact unread-target URL built from the same notification
parameters used by navigateToLatestNotification (i.e., derive the target path
the same way navigateToLatestNotification computes it) so
middle-click/CMD-click/open-in-new-tab/copy-link all land on the unread
message/thread; update the href generation in the component to use that same
logic (or extract that logic into a helper like getLatestNotificationUrl and
reuse it) while keeping the existing onClick handler
(navigateToLatestNotification) and classList/isVisible behavior.

---

Duplicate comments:
In `@js/app/packages/notifications/components/MarkMessageNotifications.tsx`:
- Around line 35-44: The component currently calls
notificationSource.markAsRead(...) without awaiting it and immediately calls
unsubscribe(), which can drop the only retry path on failure; update both places
where markAsRead is invoked (the subscriber callback created via
notificationSource.subscribe and the branch handling !existing.viewed_at) to
await the Promise returned by notificationSource.markAsRead (use async/await),
only call unsubscribe() after the await completes successfully, and catch errors
(e.g., try/catch) so that on rejection you do not unsubscribe and you can log or
surface the error instead of losing the subscription.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 50ee802c-806a-4ebf-a80b-30e766ddd238

📥 Commits

Reviewing files that changed from the base of the PR and between e4258f9 and 42ae27c.

📒 Files selected for processing (3)
  • js/app/packages/app/component/app-sidebar/channels-unread-widget.tsx
  • js/app/packages/notifications/components/MarkMessageNotifications.tsx
  • js/app/packages/notifications/notification-navigation.ts

@gbirman gbirman merged commit ef788b0 into main Apr 9, 2026
42 checks passed
@gbirman gbirman deleted the gab/fix/message-notification-read branch April 9, 2026 22:39
@coderabbitai coderabbitai bot mentioned this pull request Apr 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants