Skip to content
Merged
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
2 changes: 1 addition & 1 deletion examples/ExpoMessaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"react-native-teleport": "^1.0.2",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.8.3",
"stream-chat": "^9.48.0",
"stream-chat": "^9.50.0",
"stream-chat-expo": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/SampleApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"react-native-teleport": "^1.1.7",
"react-native-video": "^6.19.2",
"react-native-worklets": "^0.8.3",
"stream-chat": "^9.48.0",
"stream-chat": "^9.50.0",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/TypeScriptMessaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"react-native-svg": "^15.12.0",
"react-native-video": "^6.16.1",
"react-native-worklets": "^0.4.1",
"stream-chat": "^9.48.0",
"stream-chat": "^9.50.0",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"path": "0.12.7",
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
"stream-chat": "^9.48.0",
"stream-chat": "^9.50.0",
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
Expand Down
43 changes: 33 additions & 10 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -511,12 +511,12 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
const styles = useStyles();
const [deleted, setDeleted] = useState<boolean>(false);
const [error, setError] = useState<Error | boolean>(false);
const [lastRead, setLastRead] = useState<Date | undefined>();
const lastReadRef = useRef<Date | undefined>(undefined);
const [thread, setThread] = useState<LocalMessage | null>(threadProps || null);
const [threadHasMore, setThreadHasMore] = useState(true);
const [threadLoadingMore, setThreadLoadingMore] = useState(false);
const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore());
const [messageInputHeightStore] = useState(new MessageInputHeightStore());
const [channelUnreadStateStore] = useState(() => new ChannelUnreadStateStore());
const [messageInputHeightStore] = useState(() => new MessageInputHeightStore());
// TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere.
const setChannelUnreadState = useCallback(
(data: ChannelUnreadStateStoreType['channelUnreadState']) => {
Expand Down Expand Up @@ -690,6 +690,13 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
return;
}

if (event.type === 'message.read_locally') {
// When local unread reset happens, the count is already updated in the client state,
// and the preview badge / unread divider are handled elsewhere, so there is nothing
// to copy into channel state here. Thus, we skip it.
return;
}

if (event.type === 'message.read' || event.type === 'notification.mark_read') {
setReadThrottled();
return;
Expand All @@ -703,7 +710,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
useEffect(() => {
let listener: ReturnType<typeof channel.on>;
const initChannel = async () => {
setLastRead(new Date());
lastReadRef.current = new Date();
const unreadCount = channel.countUnread();
const shouldLoadAtFirstUnread = shouldLoadInitialChannelAtFirstUnreadMessage(unreadCount);
if (!channel || !shouldSyncChannel) {
Expand Down Expand Up @@ -812,7 +819,25 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
const markReadInternal: ChannelContextValue['markRead'] = throttle(
async (options?: MarkReadFunctionOptions) => {
const { updateChannelUnreadState = true } = options ?? {};
if (!channel || channel?.disconnected || !clientChannelConfig?.read_events) {
if (!channel || channel?.disconnected) {
return;
}

// When read events are disabled (e.g. livestreams) we cannot mark read on the backend. If the
// client opted into a local unread count, reset it locally instead so the user's "caught up"
// state is reflected without a server round trip.
if (!clientChannelConfig?.read_events) {
if (client.options.isLocalUnreadCountEnabled) {
const event = channel.markReadLocally();
if (updateChannelUnreadState && event && lastReadRef.current) {
setChannelUnreadState({
last_read: lastReadRef.current,
last_read_message_id: event.last_read_message_id,
unread_messages: 0,
});
lastReadRef.current = new Date();
}
}
return;
}

Expand All @@ -821,13 +846,13 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
} else {
try {
const response = await channel.markRead();
if (updateChannelUnreadState && response && lastRead) {
if (updateChannelUnreadState && response && lastReadRef.current) {
setChannelUnreadState({
last_read: lastRead,
last_read: lastReadRef.current,
last_read_message_id: response?.event.last_read_message_id,
unread_messages: 0,
});
setLastRead(new Date());
lastReadRef.current = new Date();
}
} catch (err) {
console.log('Error marking channel as read:', err);
Expand Down Expand Up @@ -1578,7 +1603,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
hideStickyDateHeader,
highlightedMessageId,
isChannelActive: shouldSyncChannel,
lastRead,
loadChannelAroundMessage,
loadChannelAtFirstUnreadMessage,
loading: channelMessagesState.loading,
Expand All @@ -1590,7 +1614,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
reloadChannel,
scrollToFirstUnreadThreshold,
setChannelUnreadState,
setLastRead,
setTargetedMessage,
hasPendingInitialTargetLoad,
targetedMessage,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ export const useCreateChannelContext = ({
hideStickyDateHeader,
highlightedMessageId,
isChannelActive,
lastRead,
loadChannelAroundMessage,
loadChannelAtFirstUnreadMessage,
loading,
Expand All @@ -25,7 +24,6 @@ export const useCreateChannelContext = ({
reloadChannel,
scrollToFirstUnreadThreshold,
setChannelUnreadState,
setLastRead,
setTargetedMessage,
hasPendingInitialTargetLoad,
targetedMessage,
Expand All @@ -35,7 +33,6 @@ export const useCreateChannelContext = ({
watchers,
}: ChannelContextValue) => {
const channelId = channel?.id;
const lastReadTime = lastRead?.getTime();
const membersLength = Object.keys(members).length;

const readUsers = Object.values(read);
Expand All @@ -56,7 +53,6 @@ export const useCreateChannelContext = ({
hideStickyDateHeader,
highlightedMessageId,
isChannelActive,
lastRead,
loadChannelAroundMessage,
loadChannelAtFirstUnreadMessage,
loading,
Expand All @@ -68,7 +64,6 @@ export const useCreateChannelContext = ({
reloadChannel,
scrollToFirstUnreadThreshold,
setChannelUnreadState,
setLastRead,
setTargetedMessage,
hasPendingInitialTargetLoad,
targetedMessage,
Expand All @@ -84,7 +79,6 @@ export const useCreateChannelContext = ({
error,
isChannelActive,
highlightedMessageId,
lastReadTime,
loading,
membersLength,
readUsersLength,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,14 @@ export const useChannelPreviewData = (
setForceUpdate((prev) => prev + 1);
}
};
const { unsubscribe } = client.on('message.read', handleReadEvent);
return unsubscribe;
const readSubscription = client.on('message.read', handleReadEvent);
// `message.read_locally` is the client-only equivalent emitted by `channel.markReadLocally()` when
// read events are disabled (e.g. livestreams with `isLocalUnreadCountEnabled`).
const localReadSubscription = client.on('message.read_locally', handleReadEvent);
return () => {
readSubscription.unsubscribe();
localReadSubscription.unsubscribe();
};
}, [client, channel]);

/**
Expand Down
7 changes: 6 additions & 1 deletion package/src/components/MessageList/MessageFlashList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -718,9 +718,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
const lastReadMessageId = channelUnreadState?.last_read_message_id;
const lastReadMessageVisible = viewableItems.some((item) => item.item.id === lastReadMessageId);

// Channels with disabled `read-events` (i.e livestreams) still surface the unread
// notification when the client opted into a local unread count, so the gate accepts
// either source.
const unreadNotificationSupported = readEvents || client.options.isLocalUnreadCountEnabled;

if (
!viewableItems.length ||
!readEvents ||
!unreadNotificationSupported ||
lastReadMessageVisible ||
attachmentPickerStore.state.getLatestValue().selectedPicker === 'images'
) {
Expand Down
7 changes: 6 additions & 1 deletion package/src/components/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -555,9 +555,14 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
(item) => item.item.message.id === lastReadMessageId,
);

// Channels with disabled `read-events` (i.e livestreams) still surface the unread
// notification when the client opted into a local unread count, so the gate accepts
// either source.
const unreadNotificationSupported = readEvents || client.options.isLocalUnreadCountEnabled;

if (
!viewableItems.length ||
!readEvents ||
!unreadNotificationSupported ||
lastReadMessageVisible ||
attachmentPickerStore.state.getLatestValue().selectedPicker === 'images'
) {
Expand Down
8 changes: 5 additions & 3 deletions package/src/components/Thread/__tests__/Thread.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,9 @@ describe('Thread', () => {
threadResponses as unknown as Parameters<typeof channel.state.addMessagesSorted>[0],
);

let setLastRead: ((date?: Date) => void) | undefined;
let setChannelUnreadState:
| React.ContextType<typeof ChannelContext>['setChannelUnreadState']
| undefined;

const { getByText, toJSON } = render(
<ChannelsStateProvider>
Expand All @@ -161,7 +163,7 @@ describe('Thread', () => {
<Channel channel={channel} thread={thread} threadList>
<ChannelContext.Consumer>
{(c) => {
setLastRead = c.setLastRead;
setChannelUnreadState = c.setChannelUnreadState;
return <Thread />;
}}
</ChannelContext.Consumer>
Expand All @@ -178,7 +180,7 @@ describe('Thread', () => {
expect(getByText('Message6')).toBeTruthy();
});

act(() => setLastRead!(new Date('2020-08-17T18:08:03.196Z')));
act(() => setChannelUnreadState!(undefined));

const snapshot = toJSON() as unknown as {
children: Array<{
Expand Down
3 changes: 0 additions & 3 deletions package/src/contexts/channelContext/ChannelContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@ export type ChannelContextValue = {
reloadChannel: () => Promise<void>;
scrollToFirstUnreadThreshold: number;
setChannelUnreadState: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void;
setLastRead: React.Dispatch<React.SetStateAction<Date | undefined>>;
setTargetedMessage: (messageId?: string) => void;
/**
* Returns true when Channel is about to load an initial targeted message.
Expand All @@ -131,8 +130,6 @@ export type ChannelContextValue = {
*/
highlightedMessageId?: string;
isChannelActive?: boolean;

lastRead?: Date;
loading?: boolean;
/**
* Maximum time in milliseconds that should occur between messages
Expand Down
16 changes: 8 additions & 8 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -7442,7 +7442,7 @@ __metadata:
react-native-teleport: "npm:^1.0.2"
react-native-web: "npm:^0.21.0"
react-native-worklets: "npm:0.8.3"
stream-chat: "npm:^9.48.0"
stream-chat: "npm:^9.50.0"
stream-chat-expo: "workspace:^"
stream-chat-react-native-core: "workspace:^"
typescript: "npm:~5.9.2"
Expand Down Expand Up @@ -18884,7 +18884,7 @@ __metadata:
react-native-teleport: "npm:^1.1.7"
react-native-video: "npm:^6.19.2"
react-native-worklets: "npm:^0.8.3"
stream-chat: "npm:^9.48.0"
stream-chat: "npm:^9.50.0"
stream-chat-react-native: "workspace:^"
stream-chat-react-native-core: "workspace:^"
typescript: "npm:5.9.3"
Expand Down Expand Up @@ -19611,7 +19611,7 @@ __metadata:
react-native-worklets: "npm:^0.9.1"
react-test-renderer: "npm:19.2.3"
rimraf: "npm:^6.0.1"
stream-chat: "npm:^9.48.0"
stream-chat: "npm:^9.50.0"
typescript: "npm:5.9.3"
use-sync-external-store: "npm:^1.5.0"
uuid: "npm:^11.1.0"
Expand Down Expand Up @@ -19683,9 +19683,9 @@ __metadata:
languageName: unknown
linkType: soft

"stream-chat@npm:^9.48.0":
version: 9.48.0
resolution: "stream-chat@npm:9.48.0"
"stream-chat@npm:^9.50.0":
version: 9.50.0
resolution: "stream-chat@npm:9.50.0"
dependencies:
"@types/jsonwebtoken": "npm:^9.0.8"
"@types/ws": "npm:^8.18.1"
Expand All @@ -19701,7 +19701,7 @@ __metadata:
built: true
husky:
built: true
checksum: 10c0/45ecea98e5c566098ac73580ca91e6bcfd980015e0e33a6623e53fcab9a57a700369c9a09908d49dae1fba66cd01460a6b47a4c99b8c56198e8a3c7b9b3ba962
checksum: 10c0/be5941d74946bd1a1371192dd71933a991fd4d8dd070975ee47dca31f938a9c604b6590af5639932f433735907e76a03a7d3519df23c0807f55ef189b21e0c1d
languageName: node
linkType: hard

Expand Down Expand Up @@ -20536,7 +20536,7 @@ __metadata:
react-native-svg: "npm:^15.12.0"
react-native-video: "npm:^6.16.1"
react-native-worklets: "npm:^0.4.1"
stream-chat: "npm:^9.48.0"
stream-chat: "npm:^9.50.0"
stream-chat-react-native: "workspace:^"
stream-chat-react-native-core: "workspace:^"
typescript: "npm:5.9.3"
Expand Down