diff --git a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts index 64eefdbc1..f08252265 100644 --- a/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts +++ b/src/modules/GroupChannel/components/MessageList/getMessagePartsInfo.ts @@ -15,7 +15,12 @@ export interface GetMessagePartsInfoProps { currentChannel?: GroupChannel | null; replyType?: string; hasPrevious?: boolean; - firstUnreadMessageId?: number | string | undefined; + // Phase 5.1.c — narrowed from `number | string` per audit (R-5 in + // .agentic/p0-phase-5-1/plan.md): all internal writers pass `number` + // (or `undefined`); the `string` union was unreachable defensive over- + // typing. `null` added to align with reducer-side return types ahead + // of a future consumer migration cycle. + firstUnreadMessageId?: number | null | undefined; isUnreadMessageExistInChannel?: React.MutableRefObject; } diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx index 564b72cd0..f3fb88194 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -44,6 +44,9 @@ import { mapOnMessagesReceived, mapOnMessagesUpdated, } from '../internal/runtime/adapter'; +import { createUnreadStore, dispatchToUnreadStore } from '../internal/unread/integration'; +import type { UnreadEvent } from '../internal/unread/reducer'; +import { GroupChannelUnreadContext } from './GroupChannelUnreadContext'; const initialState = () => ({ currentChannel: null, @@ -196,6 +199,60 @@ const GroupChannelManager :React.FC(null); + + // Thunk-guarded dispatch — see runtimeDispatch (W1). A bug in the + // reducer or in a caller-supplied factory MUST NOT prevent the legacy + // callback from continuing. + const dispatchChannelChanged = useCallback((channelUrl: string) => { + if (unreadChannelUrlRef.current === channelUrl) return; + unreadChannelUrlRef.current = channelUrl; + return dispatchToUnreadStore( + unreadStoreRef.current, + { type: 'CHANNEL_CHANGED', channelUrl }, + undefined, + (error) => { + logger?.warning?.('GroupChannelProvider: unread CHANNEL_CHANGED failed', { error }); + }, + ); + }, [logger]); + + const unreadDispatch = useCallback((makeEvent: () => UnreadEvent, isAtBottom?: boolean) => { + try { + const event = makeEvent(); + return dispatchToUnreadStore( + unreadStoreRef.current, + event, + { isAtBottom: isAtBottom ?? true }, + (error, failedEvent) => { + logger?.warning?.('GroupChannelProvider: unread dispatch failed (reducer)', { + eventType: failedEvent.type, + error, + }); + }, + ); + } catch (error) { + logger?.warning?.('GroupChannelProvider: unread dispatch failed (mapper)', error); + return unreadStoreRef.current.getState(); + } + }, [logger]); + // ScrollHandler initialization const { scrollRef, @@ -241,13 +298,21 @@ const GroupChannelManager :React.FC ({ + type: 'MARK_AS_UNREAD_SET', + messageId: message.messageId, + createdAt: message.createdAt, + })); } else { logger?.error?.('GroupChannelProvider: markAsUnread method not available in current SDK version'); } } catch (error) { logger?.error?.('GroupChannelProvider: markAsUnread failed', error); } - }, [state.currentChannel, logger, config.groupChannel.enableMarkAsUnread]); + }, [state.currentChannel, logger, config.groupChannel.enableMarkAsUnread, unreadDispatch]); // Message Collection setup const messageDataSource = useGroupChannelMessages(sdkStore.sdk, state.currentChannel!, { @@ -267,6 +332,24 @@ const GroupChannelManager :React.FC mapOnMessagesReceived(messages as never)); + + // Phase 5.1.a — Unread dispatch. Filter out current-user messages + // BEFORE dispatch so they never enter the unread set (review I-2). + // Prior `messages.every(...)` collapsed mixed bursts to + // `fromCurrentUser: false`, which would have grown the set with my + // own messageId — wrong the moment a consumer reads from the set. + // After filtering, the remaining burst is by definition non-self, + // so `fromCurrentUser: false` is correct. + const peerMessages = messages.filter( + (m) => (m as { sender?: { userId?: string } }).sender?.userId !== userId, + ); + if (peerMessages.length > 0) { + unreadDispatch(() => ({ + type: 'MESSAGES_RECEIVED', + messages: peerMessages.map((m) => ({ messageId: m.messageId, createdAt: m.createdAt })), + fromCurrentUser: false, + }), isScrollBottomReached); + } if (isScrollBottomReached && isContextMenuClosed() // Note: this shouldn't happen ideally, but it happens on re-rendering GroupChannelManager @@ -295,11 +378,15 @@ const GroupChannelManager :React.FC { runtimeDispatch(() => mapOnChannelDeleted()); + // Phase 5.1.a — channel cleared → reset unread tracking (idempotent + // via unreadChannelUrlRef). + dispatchChannelChanged(''); actions.setCurrentChannel(null); onBackClick?.(); }, onCurrentUserBanned: () => { runtimeDispatch(() => mapOnCurrentUserBanned()); + dispatchChannelChanged(''); actions.setCurrentChannel(null); onBackClick?.(); }, @@ -314,12 +401,36 @@ const GroupChannelManager :React.FC ({ + type: 'READ_CONFIRMED', + channelUrl: channel.url, + at: Date.now(), + })); } + // Phase 5.1.a — fire CHANNEL_CHANGED only when the url actually + // changes. dispatchChannelChanged handles its own idempotency via + // unreadChannelUrlRef, so this is purely a perf early-out. + dispatchChannelChanged(channel.url); actions.setCurrentChannel(channel); }, logger: logger as any, }); + // Phase 5.1.a — scroll-edge → unread reducer wiring. Dispatches + // USER_REACHED_BOTTOM / USER_LEFT_BOTTOM whenever the legacy + // `state.isScrollBottomReached` flag flips. Runs on initial mount as + // well (isScrollBottomReached defaults to true → fires + // USER_REACHED_BOTTOM, which the reducer treats as a no-op when state + // is already `clean`). + useEffect(() => { + if (isScrollBottomReached) { + unreadDispatch(() => ({ type: 'USER_REACHED_BOTTOM', at: Date.now() })); + } else { + unreadDispatch(() => ({ type: 'USER_LEFT_BOTTOM', at: Date.now() })); + } + }, [isScrollBottomReached, unreadDispatch]); + // Channel initialization useAsyncEffect(async () => { if (sdkStore.initialized && channelUrl) { @@ -327,6 +438,10 @@ const GroupChannelManager :React.FC mapChannelReady(channel)); + // Phase 5.1.a — fresh channel mount → reset unread tracking under + // the new url. dispatchChannelChanged collapses repeat fires + // through unreadChannelUrlRef (Plan §R-2, review I-1). + dispatchChannelChanged(channel.url); actions.setCurrentChannel(channel); } catch (error) { // Phase 2 dispatch — additive, before legacy actions.handleChannelError. @@ -487,7 +602,15 @@ const GroupChannelManager :React.FC it.serialize()), ]); - return children; + // Phase 5.1.b — expose the unread store to consumer subscriptions + // (useUnreadSelector). The context value is the same store ref for + // the lifetime of this manager mount; consumers re-render only when + // their selector output changes (useSyncExternalStore semantics). + return ( + + {children} + + ); }; const GroupChannelProvider: React.FC = (props) => { diff --git a/src/modules/GroupChannel/context/GroupChannelUnreadContext.tsx b/src/modules/GroupChannel/context/GroupChannelUnreadContext.tsx new file mode 100644 index 000000000..1f0da4747 --- /dev/null +++ b/src/modules/GroupChannel/context/GroupChannelUnreadContext.tsx @@ -0,0 +1,18 @@ +/** + * React context for the GroupChannel unread reducer store. + * + * Phase 5.1.b of the P0 runtime-coupling refactor (Plan §5.1.b). + * + * The store itself lives in `GroupChannelManager` (see + * `GroupChannelProvider.tsx`) — this context is the read boundary: + * consumers subscribe via `useUnreadSelector` rather than touching the + * store directly. + * + * Module-private — NOT re-exported from `src/index.ts`. BC-2 verifies the + * public dts export set is unchanged. + */ +import { createContext } from 'react'; +import type { Store } from '../../../utils/storeManager'; +import type { UnreadState } from '../internal/unread/model'; + +export const GroupChannelUnreadContext = createContext | null>(null); diff --git a/src/modules/GroupChannel/context/hooks/__tests__/useUnreadSelector.spec.tsx b/src/modules/GroupChannel/context/hooks/__tests__/useUnreadSelector.spec.tsx new file mode 100644 index 000000000..88980cc8e --- /dev/null +++ b/src/modules/GroupChannel/context/hooks/__tests__/useUnreadSelector.spec.tsx @@ -0,0 +1,153 @@ +/** + * Phase 5.1.b — useUnreadSelector subscription hook. + * + * Covers: + * - Narrow-slice subscription (re-renders only when selector output + * changes per Object.is) + * - Provider boundary (throws outside provider) + * - Equality-fn override for derived shapes + */ +import React from 'react'; +import { render, act } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { GroupChannelUnreadContext } from '../../GroupChannelUnreadContext'; +import { useUnreadSelector } from '../useUnreadSelector'; +import { createUnreadStore, dispatchToUnreadStore } from '../../../internal/unread/integration'; +import { + selectFirstUnreadMessageId, + selectFirstUnreadCreatedAt, + selectIsMessageUnread, +} from '../../../internal/unread/selectors'; + +function renderWithStore(node: React.ReactElement, store = createUnreadStore()) { + const utils = render( + + {node} + , + ); + return { ...utils, store }; +} + +describe('Phase 5.1.b — useUnreadSelector', () => { + it('returns the initial selector output on first render', () => { + let observed: number | null | undefined; + const Probe = () => { + observed = useUnreadSelector(selectFirstUnreadMessageId); + return null; + }; + renderWithStore(); + expect(observed).toBeNull(); + }); + + it('re-renders only when the selector output changes', () => { + let renders = 0; + let observed: number | null | undefined; + const Probe = () => { + renders++; + observed = useUnreadSelector(selectFirstUnreadMessageId); + return null; + }; + const { store } = renderWithStore(); + + expect(renders).toBe(1); + expect(observed).toBeNull(); + + // Dispatch that DOES change firstUnreadMessageId. + act(() => { + dispatchToUnreadStore( + store, + { type: 'MESSAGES_RECEIVED', messages: [{ messageId: 1, createdAt: 100 }], fromCurrentUser: false }, + { isAtBottom: false }, + ); + }); + expect(renders).toBe(2); + expect(observed).toBe(1); + + // Dispatch that does NOT change firstUnreadMessageId (same anchor; + // count grows but selector returns same primitive). + act(() => { + dispatchToUnreadStore( + store, + { type: 'MESSAGES_RECEIVED', messages: [{ messageId: 2, createdAt: 200 }], fromCurrentUser: false }, + { isAtBottom: false }, + ); + }); + // No re-render because selectFirstUnreadMessageId still returns 1. + expect(renders).toBe(2); + expect(observed).toBe(1); + }); + + it('throws when used outside a GroupChannelUnreadContext provider', () => { + const Probe = () => { + useUnreadSelector(selectFirstUnreadMessageId); + return null; + }; + // Suppress the React error boundary noise. + const origError = console.error; + console.error = jest.fn(); + try { + expect(() => render()).toThrow(/useStoreSelector must be used within/); + } finally { + console.error = origError; + } + }); + + it('selectIsMessageUnread tracks unread set membership', () => { + let observedFor1: boolean | undefined; + const Probe1 = () => { + observedFor1 = useUnreadSelector((s) => selectIsMessageUnread(s, { messageId: 1 })); + return null; + }; + const { store } = renderWithStore(); + expect(observedFor1).toBe(false); + + act(() => { + dispatchToUnreadStore( + store, + { type: 'MESSAGES_RECEIVED', messages: [{ messageId: 1, createdAt: 100 }], fromCurrentUser: false }, + { isAtBottom: false }, + ); + }); + expect(observedFor1).toBe(true); + + // A separate mount with a different messageId — NOT a rerender with + // a new prop. Inline selectors that close over props rely on a fresh + // mount because the selector swap intentionally reuses the memoized + // snapshot when the raw store state has not changed (zustand idiom; + // see useStoreSelector JSDoc). + let observedFor99: boolean | undefined; + const Probe99 = () => { + observedFor99 = useUnreadSelector((s) => selectIsMessageUnread(s, { messageId: 99 })); + return null; + }; + render( + + + , + ); + expect(observedFor99).toBe(false); + }); + + it('honors custom equalityFn — derived object selectors stay stable', () => { + let renders = 0; + const Probe = () => { + renders++; + // Selector synthesizes a new object each call; without the custom + // equality, identity would diverge on every dispatch. + useUnreadSelector( + (s) => ({ anchor: selectFirstUnreadMessageId(s), at: selectFirstUnreadCreatedAt(s) }), + (a, b) => a.anchor === b.anchor && a.at === b.at, + ); + return null; + }; + const { store } = renderWithStore(); + expect(renders).toBe(1); + + // Dispatch that changes nothing observable to this selector. + act(() => { + dispatchToUnreadStore(store, { type: 'USER_REACHED_BOTTOM', at: 500 }); + }); + // No re-render — selector output equal under the custom predicate. + expect(renders).toBe(1); + }); +}); diff --git a/src/modules/GroupChannel/context/hooks/useUnreadSelector.ts b/src/modules/GroupChannel/context/hooks/useUnreadSelector.ts new file mode 100644 index 000000000..ad482824d --- /dev/null +++ b/src/modules/GroupChannel/context/hooks/useUnreadSelector.ts @@ -0,0 +1,37 @@ +/** + * Narrow-slice subscription hook over the unread reducer store. + * + * Phase 5.1.b of the P0 runtime-coupling refactor (Plan §5.1.b). + * + * Wraps `useStoreSelector` with the module-private + * `GroupChannelUnreadContext`. Selectors should be **module-level + * functions or `useCallback`-stabilized** — inline arrow selectors that + * close over render-scope variables can produce stale-on-first-commit + * results because the memo cache only re-runs the selector when the raw + * snapshot reference changes (see `useStoreSelector` JSDoc). + * + * @example Module-level selector (preferred) + * ```ts + * const count = useUnreadSelector(selectUnreadCount); + * ``` + * + * @example Selector that closes over a prop — STABILIZE with useCallback + * so prop changes invalidate the memo cache: + * ```ts + * const isUnread = useUnreadSelector( + * useCallback((s) => selectIsMessageUnread(s, { messageId }), [messageId]), + * ); + * ``` + * + * Internal — NOT re-exported from `src/index.ts`. + */ +import { useStoreSelector, type EqualityFn } from '../../../../hooks/useStore'; +import type { UnreadState } from '../../internal/unread/model'; +import { GroupChannelUnreadContext } from '../GroupChannelUnreadContext'; + +export function useUnreadSelector( + selector: (state: UnreadState) => TSelected, + equalityFn?: EqualityFn, +): TSelected { + return useStoreSelector(GroupChannelUnreadContext, selector, equalityFn); +} diff --git a/src/modules/GroupChannel/internal/unread/__tests__/integration.spec.ts b/src/modules/GroupChannel/internal/unread/__tests__/integration.spec.ts new file mode 100644 index 000000000..9584503e7 --- /dev/null +++ b/src/modules/GroupChannel/internal/unread/__tests__/integration.spec.ts @@ -0,0 +1,144 @@ +/** + * Phase 5.1.a — unread integration smoke tests. + * + * Mirrors `internal/runtime/__tests__/integration.spec.ts` for the + * unread store. Covers the basic dispatch path plus the parallel-only + * (W1) invariant inherited from Phase 2. + */ +import { + createUnreadStore, + dispatchToUnreadStore, + UNREAD_DISPATCH_HOOK_GLOBAL_KEY, + type UnreadDispatchHookPayload, +} from '../integration'; + +describe('Phase 5.1.a — unread integration', () => { + afterEach(() => { + delete (globalThis as any)[UNREAD_DISPATCH_HOOK_GLOBAL_KEY]; + }); + + it('createUnreadStore returns a store at the initial state', () => { + const store = createUnreadStore(); + const s = store.getState(); + expect(s.mode).toBe('clean'); + expect(s.firstUnreadMessageId).toBeNull(); + expect(s.firstUnreadCreatedAt).toBeNull(); + expect(s.unreadCount).toBe(0); + expect(s.unreadMessageIds.size).toBe(0); + }); + + it('dispatchToUnreadStore applies the reducer and updates the store state', () => { + const store = createUnreadStore(); + const next = dispatchToUnreadStore( + store, + { type: 'MESSAGES_RECEIVED', messages: [{ messageId: 1, createdAt: 100 }], fromCurrentUser: false }, + { isAtBottom: false }, + ); + expect(next.unreadCount).toBe(1); + expect(next.firstUnreadMessageId).toBe(1); + // applyStorePatch spreads into a new object, so the store reference + // differs from the reducer output — assert structural parity instead. + expect(store.getState()).toEqual(next); + }); + + it('dispatchToUnreadStore fires the global hook with event/context/state', () => { + const store = createUnreadStore(); + const captured: UnreadDispatchHookPayload[] = []; + (globalThis as any)[UNREAD_DISPATCH_HOOK_GLOBAL_KEY] = (p: UnreadDispatchHookPayload) => captured.push(p); + + dispatchToUnreadStore(store, { type: 'CHANNEL_CHANGED', channelUrl: 'ch-x' }); + dispatchToUnreadStore( + store, + { type: 'MESSAGES_RECEIVED', messages: [{ messageId: 1, createdAt: 100 }], fromCurrentUser: false }, + { isAtBottom: false }, + ); + + expect(captured).toHaveLength(2); + expect(captured[0].event.type).toBe('CHANNEL_CHANGED'); + expect(captured[1].event.type).toBe('MESSAGES_RECEIVED'); + expect(captured[1].context.isAtBottom).toBe(false); + }); + + it('hook errors are swallowed so production callers are never affected', () => { + const store = createUnreadStore(); + (globalThis as any)[UNREAD_DISPATCH_HOOK_GLOBAL_KEY] = () => { throw new Error('hook explode'); }; + expect(() => { + dispatchToUnreadStore(store, { type: 'CHANNEL_CHANGED', channelUrl: 'ch-x' }); + }).not.toThrow(); + }); + + it('subscribers are notified on state-changing dispatch', () => { + const store = createUnreadStore(); + const spy = jest.fn(); + store.subscribe(spy); + dispatchToUnreadStore( + store, + { type: 'MESSAGES_RECEIVED', messages: [{ messageId: 1, createdAt: 100 }], fromCurrentUser: false }, + { isAtBottom: false }, + ); + expect(spy).toHaveBeenCalledTimes(1); + }); + + // Inherits the W1 parallel-only invariant from Phase 2 (runtime). A + // malformed MARK_AS_UNREAD_SET event with `messageId: undefined` would + // not throw at the reducer (it just stores it). We instead pass an + // event with `type` outside the union and force the reducer through + // its default branch (still does not throw — same shape as runtime). + // To provoke a real throw we Object.freeze the store and then dispatch: + // applyStorePatch's hasStateChanged path runs lodash isEqual on the + // result, which is safe; but `applyStorePatch` itself calls + // `store.setState` which we can monkey-patch to throw — covered by a + // jest.spyOn on the store helper. + describe('parallel-only invariant (W1)', () => { + it('reducer/patch exceptions are swallowed and onError is invoked', () => { + const store = createUnreadStore(); + const realSetState = store.setState; + // Force the patch path to throw — simulates a future bug where + // a downstream notifier mutates and breaks. + store.setState = jest.fn(() => { throw new Error('forced setState failure'); }) as any; + const onError = jest.fn(); + + const before = store.getState(); + let returned: ReturnType | undefined; + expect(() => { + returned = dispatchToUnreadStore( + store, + { type: 'CHANNEL_CHANGED', channelUrl: 'ch-x' }, + undefined, + onError, + ); + }).not.toThrow(); + + // Return value is the unchanged prior state. + expect(returned).toBe(before); + expect(onError).toHaveBeenCalledTimes(1); + const [errArg, eventArg] = onError.mock.calls[0]; + expect(errArg).toBeInstanceOf(Error); + expect((errArg as Error).message).toBe('forced setState failure'); + expect(eventArg).toEqual({ type: 'CHANNEL_CHANGED', channelUrl: 'ch-x' }); + + store.setState = realSetState; + }); + + it('onError throwing does not propagate either', () => { + const store = createUnreadStore(); + store.setState = jest.fn(() => { throw new Error('boom'); }) as any; + expect(() => { + dispatchToUnreadStore( + store, + { type: 'CHANNEL_CHANGED', channelUrl: 'ch-x' }, + undefined, + () => { throw new Error('onError exploded'); }, + ); + }).not.toThrow(); + }); + + it('dispatch with no onError still swallows', () => { + const store = createUnreadStore(); + store.setState = jest.fn(() => { throw new Error('boom'); }) as any; + expect(() => { + dispatchToUnreadStore(store, { type: 'CHANNEL_CHANGED', channelUrl: 'ch-x' }); + }).not.toThrow(); + }); + }); +}); diff --git a/src/modules/GroupChannel/internal/unread/integration.ts b/src/modules/GroupChannel/internal/unread/integration.ts new file mode 100644 index 000000000..1cbe01b37 --- /dev/null +++ b/src/modules/GroupChannel/internal/unread/integration.ts @@ -0,0 +1,95 @@ +/** + * Integration glue for the GroupChannel unread reducer. + * + * Phase 5.1 of the P0 runtime-coupling refactor (Plan §5.1.a). + * + * Provides: + * - `createUnreadStore(): Store` — produces a + * `createStore`-based handle seeded with `createInitialUnreadState()`. + * - `dispatchToUnreadStore(store, event, context?, onError?)` — pure + * helper that runs the reducer with the optional context hint, writes + * the new state via `applyStorePatch`, and fires the dev/test + * instrumentation hook so consumer specs can observe. + * + * **Parallel-only invariant** (inherited from W1, Plan §2.4 + spec §AC-8): + * A fault in the reducer or store layer MUST NOT prevent the legacy + * GroupChannelProvider callback from continuing. Any exception is caught + * here and surfaced via the optional `onError` callback. Hook exceptions + * are likewise caught. + * + * Internal — not exported via `src/index.ts`. BC-4 / BC-5 verify. + */ +import { createStore, applyStorePatch, type Store } from '../../../../utils/storeManager'; +import { unreadReducer, type UnreadEvent, type UnreadReducerContext } from './reducer'; +import { createInitialUnreadState, type UnreadState } from './model'; + +/** Shape of the global dev/test dispatch instrumentation hook. */ +export type UnreadDispatchHookPayload = { + event: UnreadEvent; + context: UnreadReducerContext; + state: UnreadState; +}; + +export type UnreadDispatchHook = (payload: UnreadDispatchHookPayload) => void; + +/** Hook key on `globalThis` — read by tests to inspect dispatches. */ +export const UNREAD_DISPATCH_HOOK_GLOBAL_KEY = '__GROUP_CHANNEL_UNREAD_DISPATCH_HOOK__' as const; + +const DEFAULT_CONTEXT: UnreadReducerContext = { isAtBottom: true }; + +/** + * Build the unread store. Returns the underlying `Store` so consumers + * can `subscribe` (the read hook in Phase 5.1.b will). + */ +export function createUnreadStore(seed?: Partial): Store { + return createStore({ ...createInitialUnreadState(), ...(seed ?? {}) }); +} + +/** + * Apply a single event to the unread store. Pure transition through + * `unreadReducer`, then `applyStorePatch` to write the new state + * (equality short-circuit honored), then fire the instrumentation hook + * if present. + * + * Returns the resulting `UnreadState` so the caller can route effects + * synchronously without an extra `store.getState()`. On failure returns + * the unchanged previous state — note this differs from + * `dispatchToRuntime` which returns an empty `SideEffect[]` on failure; + * each return type is the unit appropriate to its caller's contract + * (state read-back here, effect routing there). + */ +export function dispatchToUnreadStore( + store: Store, + event: UnreadEvent, + context: UnreadReducerContext = DEFAULT_CONTEXT, + onError?: (error: unknown, event: UnreadEvent) => void, +): UnreadState { + const before = store.getState(); + let next: UnreadState; + try { + next = unreadReducer(before, event, context); + applyStorePatch(store, next as Partial, event.type); + } catch (error) { + if (onError) { + try { + onError(error, event); + } catch { + // onError must never propagate — see Plan §5.1.a / W1 rationale. + } + } + return before; + } + if (process.env.NODE_ENV !== 'production') { + const hook = (globalThis as unknown as { [UNREAD_DISPATCH_HOOK_GLOBAL_KEY]?: UnreadDispatchHook })[ + UNREAD_DISPATCH_HOOK_GLOBAL_KEY + ]; + if (typeof hook === 'function') { + try { + hook({ event, context, state: next }); + } catch { + // Swallow hook errors so they never affect production callers. + } + } + } + return next; +} diff --git a/src/modules/GroupChannel/internal/unread/selectors.ts b/src/modules/GroupChannel/internal/unread/selectors.ts index a92d8678d..1236cb8a4 100644 --- a/src/modules/GroupChannel/internal/unread/selectors.ts +++ b/src/modules/GroupChannel/internal/unread/selectors.ts @@ -47,6 +47,19 @@ export const selectFirstUnreadCreatedAt = (s: UnreadState): number | null => s.f export const selectLastReadAt = (s: UnreadState): number | null => s.lastReadAt; +/** + * True when the given message contributes to the unread count — i.e. it + * is one of the messages received while the user was away from the + * bottom (or pinned via mark-as-unread). Replaces the legacy + * `newMessageIds?.includes(message.messageId)` check at MessageView and + * is identity-stable per `Object.is` (returns a primitive). + * + * Phase 5.1.b — consumed by `MessageView` via `useUnreadSelector`. + */ +export function selectIsMessageUnread(s: UnreadState, message: SelectorMessage): boolean { + return s.unreadMessageIds.has(message.messageId); +} + /** * The separator renders ABOVE the first unread message — so for a given * candidate message, return true iff its id matches the anchor.