Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>;
}

Expand Down
127 changes: 125 additions & 2 deletions src/modules/GroupChannel/context/GroupChannelProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -196,6 +199,60 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
}
}, [logger]);

// Phase 5.1.a — Unread reducer store. Dual-write strategy (spec §AC-4):
// dispatch fires alongside the legacy `setNewMessageIds` /
// `setFirstUnreadMessageId` calls. Phase 5.1.b/c switch consumer reads
// over to this store via `useUnreadSelector`; the legacy state slice is
// retained until Phase 5.2 has a verification window.
//
// MESSAGES_DELETED is intentionally NOT dispatched — `@sendbird/uikit-tools@0.1.0`
// does not expose `onMessagesDeleted` (Plan §1). Re-evaluate when the
// uikit-tools bump (separate follow-up) ships that callback.
const unreadStoreRef = useRef(createUnreadStore());

// Tracks the last channelUrl observed by the unread store so repeated
// CHANNEL_CHANGED dispatches (e.g. mount-time + a stale onChannelUpdated
// closure firing before the legacy setCurrentChannel has flushed) collapse
// to a single transition. Idempotent regardless, but avoids dev-hook
// double-fire and any future subscriber re-render.
const unreadChannelUrlRef = useRef<string | null>(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,
Expand Down Expand Up @@ -241,13 +298,21 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
source: source || 'unknown',
});
markAsUnreadSourceRef.current = source || 'internal';
// Phase 5.1.a — record the user-chosen unread anchor in the
// reducer. Only fires after the SDK call succeeds so the local
// and server states agree.
unreadDispatch(() => ({
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!, {
Expand All @@ -267,6 +332,24 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
// coreTs callback uses SendbirdMessage[] (broader than SendableMessageType);
// the adapter mapper accepts the broader shape via structural cast.
runtimeDispatch(() => 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
Expand Down Expand Up @@ -295,11 +378,15 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
// reducer-only until a follow-up cycle ships those callbacks.
onChannelDeleted: () => {
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?.();
},
Expand All @@ -314,19 +401,47 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
&& ctx.userIds.includes(userId)
) {
actions.setReadStateChanged('read');
// Phase 5.1.a — remote read confirmation → clear local unread.
unreadDispatch(() => ({
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) {
try {
const channel = await sdkStore.sdk.groupChannel.getChannel(channelUrl);
// Phase 2 dispatch — additive, before legacy actions.setCurrentChannel.
runtimeDispatch(() => 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.
Expand Down Expand Up @@ -487,7 +602,15 @@ const GroupChannelManager :React.FC<React.PropsWithChildren<GroupChannelProvider
messageDataSource.messages.map(it => 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 (
<GroupChannelUnreadContext.Provider value={unreadStoreRef.current}>
{children}
</GroupChannelUnreadContext.Provider>
);
};

const GroupChannelProvider: React.FC<GroupChannelProviderProps> = (props) => {
Expand Down
18 changes: 18 additions & 0 deletions src/modules/GroupChannel/context/GroupChannelUnreadContext.tsx
Original file line number Diff line number Diff line change
@@ -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<Store<UnreadState> | null>(null);
Original file line number Diff line number Diff line change
@@ -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(
<GroupChannelUnreadContext.Provider value={store}>
{node}
</GroupChannelUnreadContext.Provider>,
);
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(<Probe />);
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(<Probe />);

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;

Check warning on line 86 in src/modules/GroupChannel/context/hooks/__tests__/useUnreadSelector.spec.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected console statement
console.error = jest.fn();

Check warning on line 87 in src/modules/GroupChannel/context/hooks/__tests__/useUnreadSelector.spec.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected console statement
try {
expect(() => render(<Probe />)).toThrow(/useStoreSelector must be used within/);
} finally {
console.error = origError;

Check warning on line 91 in src/modules/GroupChannel/context/hooks/__tests__/useUnreadSelector.spec.tsx

View workflow job for this annotation

GitHub Actions / build-and-test

Unexpected console statement
}
});

it('selectIsMessageUnread tracks unread set membership', () => {
let observedFor1: boolean | undefined;
const Probe1 = () => {
observedFor1 = useUnreadSelector((s) => selectIsMessageUnread(s, { messageId: 1 }));
return null;
};
const { store } = renderWithStore(<Probe1 />);
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(
<GroupChannelUnreadContext.Provider value={store}>
<Probe99 />
</GroupChannelUnreadContext.Provider>,
);
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(<Probe />);
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);
});
});
Loading
Loading