Skip to content

Commit 448ebd8

Browse files
authored
feat: localized unread count (#3679)
## 🎯 Goal SDK followup to this PR: GetStream/stream-chat-js#1787. All details can be read there. ## πŸ›  Implementation details <!-- Provide a description of the implementation --> ## 🎨 UI Changes <!-- Add relevant screenshots --> <details> <summary>iOS</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> <details> <summary>Android</summary> <table> <thead> <tr> <td>Before</td> <td>After</td> </tr> </thead> <tbody> <tr> <td> <!--<img src="" /> --> </td> <td> <!--<img src="" /> --> </td> </tr> </tbody> </table> </details> ## πŸ§ͺ Testing <!-- Explain how this change can be tested (or why it can't be tested) --> ## β˜‘οΈ Checklist - [ ] I have signed the [Stream CLA](https://docs.google.com/forms/d/e/1FAIpQLScFKsKkAJI7mhCr7K9rEIOpqIDThrWxuvxnwUq2XkHyG154vQ/viewform) (required) - [ ] PR targets the `develop` branch - [ ] Documentation is updated - [ ] New code is tested in main example apps, including all possible scenarios - [ ] SampleApp iOS and Android - [ ] Expo iOS and Android
1 parent 2e375f7 commit 448ebd8

12 files changed

Lines changed: 70 additions & 38 deletions

File tree

β€Žexamples/ExpoMessaging/package.jsonβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
"react-native-teleport": "^1.0.2",
5252
"react-native-web": "^0.21.0",
5353
"react-native-worklets": "0.8.3",
54-
"stream-chat": "^9.48.0",
54+
"stream-chat": "^9.50.0",
5555
"stream-chat-expo": "workspace:^",
5656
"stream-chat-react-native-core": "workspace:^"
5757
},

β€Žexamples/SampleApp/package.jsonβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"react-native-teleport": "^1.1.7",
6565
"react-native-video": "^6.19.2",
6666
"react-native-worklets": "^0.8.3",
67-
"stream-chat": "^9.48.0",
67+
"stream-chat": "^9.50.0",
6868
"stream-chat-react-native": "workspace:^",
6969
"stream-chat-react-native-core": "workspace:^"
7070
},

β€Žexamples/TypeScriptMessaging/package.jsonβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"react-native-svg": "^15.12.0",
3434
"react-native-video": "^6.16.1",
3535
"react-native-worklets": "^0.4.1",
36-
"stream-chat": "^9.48.0",
36+
"stream-chat": "^9.50.0",
3737
"stream-chat-react-native": "workspace:^",
3838
"stream-chat-react-native-core": "workspace:^"
3939
},

β€Žpackage/package.jsonβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@
7878
"path": "0.12.7",
7979
"react-native-markdown-package": "1.8.2",
8080
"react-native-url-polyfill": "^2.0.0",
81-
"stream-chat": "^9.48.0",
81+
"stream-chat": "^9.50.0",
8282
"use-sync-external-store": "^1.5.0"
8383
},
8484
"peerDependencies": {

β€Žpackage/src/components/Channel/Channel.tsxβ€Ž

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -511,12 +511,12 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
511511
const styles = useStyles();
512512
const [deleted, setDeleted] = useState<boolean>(false);
513513
const [error, setError] = useState<Error | boolean>(false);
514-
const [lastRead, setLastRead] = useState<Date | undefined>();
514+
const lastReadRef = useRef<Date | undefined>(undefined);
515515
const [thread, setThread] = useState<LocalMessage | null>(threadProps || null);
516516
const [threadHasMore, setThreadHasMore] = useState(true);
517517
const [threadLoadingMore, setThreadLoadingMore] = useState(false);
518-
const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore());
519-
const [messageInputHeightStore] = useState(new MessageInputHeightStore());
518+
const [channelUnreadStateStore] = useState(() => new ChannelUnreadStateStore());
519+
const [messageInputHeightStore] = useState(() => new MessageInputHeightStore());
520520
// TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere.
521521
const setChannelUnreadState = useCallback(
522522
(data: ChannelUnreadStateStoreType['channelUnreadState']) => {
@@ -690,6 +690,13 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
690690
return;
691691
}
692692

693+
if (event.type === 'message.read_locally') {
694+
// When local unread reset happens, the count is already updated in the client state,
695+
// and the preview badge / unread divider are handled elsewhere, so there is nothing
696+
// to copy into channel state here. Thus, we skip it.
697+
return;
698+
}
699+
693700
if (event.type === 'message.read' || event.type === 'notification.mark_read') {
694701
setReadThrottled();
695702
return;
@@ -703,7 +710,7 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
703710
useEffect(() => {
704711
let listener: ReturnType<typeof channel.on>;
705712
const initChannel = async () => {
706-
setLastRead(new Date());
713+
lastReadRef.current = new Date();
707714
const unreadCount = channel.countUnread();
708715
const shouldLoadAtFirstUnread = shouldLoadInitialChannelAtFirstUnreadMessage(unreadCount);
709716
if (!channel || !shouldSyncChannel) {
@@ -812,7 +819,25 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
812819
const markReadInternal: ChannelContextValue['markRead'] = throttle(
813820
async (options?: MarkReadFunctionOptions) => {
814821
const { updateChannelUnreadState = true } = options ?? {};
815-
if (!channel || channel?.disconnected || !clientChannelConfig?.read_events) {
822+
if (!channel || channel?.disconnected) {
823+
return;
824+
}
825+
826+
// When read events are disabled (e.g. livestreams) we cannot mark read on the backend. If the
827+
// client opted into a local unread count, reset it locally instead so the user's "caught up"
828+
// state is reflected without a server round trip.
829+
if (!clientChannelConfig?.read_events) {
830+
if (client.options.isLocalUnreadCountEnabled) {
831+
const event = channel.markReadLocally();
832+
if (updateChannelUnreadState && event && lastReadRef.current) {
833+
setChannelUnreadState({
834+
last_read: lastReadRef.current,
835+
last_read_message_id: event.last_read_message_id,
836+
unread_messages: 0,
837+
});
838+
lastReadRef.current = new Date();
839+
}
840+
}
816841
return;
817842
}
818843

@@ -821,13 +846,13 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
821846
} else {
822847
try {
823848
const response = await channel.markRead();
824-
if (updateChannelUnreadState && response && lastRead) {
849+
if (updateChannelUnreadState && response && lastReadRef.current) {
825850
setChannelUnreadState({
826-
last_read: lastRead,
851+
last_read: lastReadRef.current,
827852
last_read_message_id: response?.event.last_read_message_id,
828853
unread_messages: 0,
829854
});
830-
setLastRead(new Date());
855+
lastReadRef.current = new Date();
831856
}
832857
} catch (err) {
833858
console.log('Error marking channel as read:', err);
@@ -1578,7 +1603,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
15781603
hideStickyDateHeader,
15791604
highlightedMessageId,
15801605
isChannelActive: shouldSyncChannel,
1581-
lastRead,
15821606
loadChannelAroundMessage,
15831607
loadChannelAtFirstUnreadMessage,
15841608
loading: channelMessagesState.loading,
@@ -1590,7 +1614,6 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
15901614
reloadChannel,
15911615
scrollToFirstUnreadThreshold,
15921616
setChannelUnreadState,
1593-
setLastRead,
15941617
setTargetedMessage,
15951618
hasPendingInitialTargetLoad,
15961619
targetedMessage,

β€Žpackage/src/components/Channel/hooks/useCreateChannelContext.tsβ€Ž

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ export const useCreateChannelContext = ({
1313
hideStickyDateHeader,
1414
highlightedMessageId,
1515
isChannelActive,
16-
lastRead,
1716
loadChannelAroundMessage,
1817
loadChannelAtFirstUnreadMessage,
1918
loading,
@@ -25,7 +24,6 @@ export const useCreateChannelContext = ({
2524
reloadChannel,
2625
scrollToFirstUnreadThreshold,
2726
setChannelUnreadState,
28-
setLastRead,
2927
setTargetedMessage,
3028
hasPendingInitialTargetLoad,
3129
targetedMessage,
@@ -35,7 +33,6 @@ export const useCreateChannelContext = ({
3533
watchers,
3634
}: ChannelContextValue) => {
3735
const channelId = channel?.id;
38-
const lastReadTime = lastRead?.getTime();
3936
const membersLength = Object.keys(members).length;
4037

4138
const readUsers = Object.values(read);
@@ -56,7 +53,6 @@ export const useCreateChannelContext = ({
5653
hideStickyDateHeader,
5754
highlightedMessageId,
5855
isChannelActive,
59-
lastRead,
6056
loadChannelAroundMessage,
6157
loadChannelAtFirstUnreadMessage,
6258
loading,
@@ -68,7 +64,6 @@ export const useCreateChannelContext = ({
6864
reloadChannel,
6965
scrollToFirstUnreadThreshold,
7066
setChannelUnreadState,
71-
setLastRead,
7267
setTargetedMessage,
7368
hasPendingInitialTargetLoad,
7469
targetedMessage,
@@ -84,7 +79,6 @@ export const useCreateChannelContext = ({
8479
error,
8580
isChannelActive,
8681
highlightedMessageId,
87-
lastReadTime,
8882
loading,
8983
membersLength,
9084
readUsersLength,

β€Žpackage/src/components/ChannelPreview/hooks/useChannelPreviewData.tsβ€Ž

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,14 @@ export const useChannelPreviewData = (
109109
setForceUpdate((prev) => prev + 1);
110110
}
111111
};
112-
const { unsubscribe } = client.on('message.read', handleReadEvent);
113-
return unsubscribe;
112+
const readSubscription = client.on('message.read', handleReadEvent);
113+
// `message.read_locally` is the client-only equivalent emitted by `channel.markReadLocally()` when
114+
// read events are disabled (e.g. livestreams with `isLocalUnreadCountEnabled`).
115+
const localReadSubscription = client.on('message.read_locally', handleReadEvent);
116+
return () => {
117+
readSubscription.unsubscribe();
118+
localReadSubscription.unsubscribe();
119+
};
114120
}, [client, channel]);
115121

116122
/**

β€Žpackage/src/components/MessageList/MessageFlashList.tsxβ€Ž

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -718,9 +718,14 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) =>
718718
const lastReadMessageId = channelUnreadState?.last_read_message_id;
719719
const lastReadMessageVisible = viewableItems.some((item) => item.item.id === lastReadMessageId);
720720

721+
// Channels with disabled `read-events` (i.e livestreams) still surface the unread
722+
// notification when the client opted into a local unread count, so the gate accepts
723+
// either source.
724+
const unreadNotificationSupported = readEvents || client.options.isLocalUnreadCountEnabled;
725+
721726
if (
722727
!viewableItems.length ||
723-
!readEvents ||
728+
!unreadNotificationSupported ||
724729
lastReadMessageVisible ||
725730
attachmentPickerStore.state.getLatestValue().selectedPicker === 'images'
726731
) {

β€Žpackage/src/components/MessageList/MessageList.tsxβ€Ž

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,9 +555,14 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => {
555555
(item) => item.item.message.id === lastReadMessageId,
556556
);
557557

558+
// Channels with disabled `read-events` (i.e livestreams) still surface the unread
559+
// notification when the client opted into a local unread count, so the gate accepts
560+
// either source.
561+
const unreadNotificationSupported = readEvents || client.options.isLocalUnreadCountEnabled;
562+
558563
if (
559564
!viewableItems.length ||
560-
!readEvents ||
565+
!unreadNotificationSupported ||
561566
lastReadMessageVisible ||
562567
attachmentPickerStore.state.getLatestValue().selectedPicker === 'images'
563568
) {

β€Žpackage/src/components/Thread/__tests__/Thread.test.tsxβ€Ž

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ describe('Thread', () => {
142142
threadResponses as unknown as Parameters<typeof channel.state.addMessagesSorted>[0],
143143
);
144144

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

147149
const { getByText, toJSON } = render(
148150
<ChannelsStateProvider>
@@ -161,7 +163,7 @@ describe('Thread', () => {
161163
<Channel channel={channel} thread={thread} threadList>
162164
<ChannelContext.Consumer>
163165
{(c) => {
164-
setLastRead = c.setLastRead;
166+
setChannelUnreadState = c.setChannelUnreadState;
165167
return <Thread />;
166168
}}
167169
</ChannelContext.Consumer>
@@ -178,7 +180,7 @@ describe('Thread', () => {
178180
expect(getByText('Message6')).toBeTruthy();
179181
});
180182

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

183185
const snapshot = toJSON() as unknown as {
184186
children: Array<{

0 commit comments

Comments
Β (0)