Skip to content

Commit f5a6b34

Browse files
committed
fix: unread indicator label presence in message list
1 parent 765ef24 commit f5a6b34

File tree

2 files changed

+106
-15
lines changed

2 files changed

+106
-15
lines changed

Diff for: package/src/components/Channel/Channel.tsx

+6-5
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,7 @@ const ChannelWithContext = <
694694
const [deleted, setDeleted] = useState<boolean>(false);
695695
const [editing, setEditing] = useState<MessageType<StreamChatGenerics> | undefined>(undefined);
696696
const [error, setError] = useState<Error | boolean>(false);
697-
const lastRead = useRef<Date | undefined>(new Date());
697+
const [lastRead, setLastRead] = useState<Date | undefined>();
698698

699699
const [quotedMessage, setQuotedMessage] = useState<MessageType<StreamChatGenerics> | undefined>(
700700
undefined,
@@ -825,6 +825,7 @@ const ChannelWithContext = <
825825
useEffect(() => {
826826
let listener: ReturnType<typeof channel.on>;
827827
const initChannel = async () => {
828+
setLastRead(new Date());
828829
const unreadCount = channel.countUnread();
829830
if (!channel || !shouldSyncChannel || channel.offlineMode) {
830831
return;
@@ -950,18 +951,18 @@ const ChannelWithContext = <
950951
return;
951952
}
952953

953-
lastRead.current = new Date();
954954
if (doMarkReadRequest) {
955955
doMarkReadRequest(channel, updateChannelUnreadState ? setChannelUnreadState : undefined);
956956
} else {
957957
try {
958958
const response = await channel.markRead();
959959
if (updateChannelUnreadState && response && lastRead) {
960960
setChannelUnreadState({
961-
last_read: lastRead.current,
961+
last_read: lastRead,
962962
last_read_message_id: response?.event.last_read_message_id,
963963
unread_messages: 0,
964964
});
965+
setLastRead(new Date());
965966
}
966967
} catch (err) {
967968
console.log('Error marking channel as read:', err);
@@ -1726,7 +1727,7 @@ const ChannelWithContext = <
17261727
hideStickyDateHeader,
17271728
highlightedMessageId,
17281729
isChannelActive: shouldSyncChannel,
1729-
lastRead: lastRead.current,
1730+
lastRead,
17301731
loadChannelAroundMessage,
17311732
loadChannelAtFirstUnreadMessage,
17321733
loading: channelMessagesState.loading,
@@ -1739,7 +1740,7 @@ const ChannelWithContext = <
17391740
reloadChannel,
17401741
scrollToFirstUnreadThreshold,
17411742
setChannelUnreadState,
1742-
setLastRead: () => {},
1743+
setLastRead,
17431744
setTargetedMessage,
17441745
StickyHeader,
17451746
targetedMessage,

Diff for: package/src/components/MessageList/MessageList.tsx

+100-10
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
ViewToken,
1111
} from 'react-native';
1212

13-
import type { FormatMessageResponse } from 'stream-chat';
13+
import type { Channel, Event, FormatMessageResponse, MessageResponse } from 'stream-chat';
1414

1515
import {
1616
isMessageWithStylesReadByAndDateSeparator,
@@ -108,6 +108,36 @@ const flatListViewabilityConfig: ViewabilityConfig = {
108108
viewAreaCoveragePercentThreshold: 1,
109109
};
110110

111+
const hasReadLastMessage = <
112+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
113+
>(
114+
channel: Channel<StreamChatGenerics>,
115+
userId: string,
116+
) => {
117+
const latestMessageIdInChannel = channel.state.latestMessages.slice(-1)[0]?.id;
118+
const lastReadMessageIdServer = channel.state.read[userId]?.last_read_message_id;
119+
return latestMessageIdInChannel === lastReadMessageIdServer;
120+
};
121+
122+
const getPreviousLastMessage = <
123+
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
124+
>(
125+
messages: MessageType<StreamChatGenerics>[],
126+
newMessage?: MessageResponse<StreamChatGenerics>,
127+
) => {
128+
if (!newMessage) return;
129+
let previousLastMessage;
130+
for (let i = messages.length - 1; i >= 0; i--) {
131+
const msg = messages[i];
132+
if (!msg?.id) break;
133+
if (msg.id !== newMessage.id) {
134+
previousLastMessage = msg;
135+
break;
136+
}
137+
}
138+
return previousLastMessage;
139+
};
140+
111141
type MessageListPropsWithContext<
112142
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
113143
> = Pick<AttachmentPickerContextValue, 'closePicker' | 'selectedPicker' | 'setSelectedPicker'> &
@@ -126,6 +156,7 @@ type MessageListPropsWithContext<
126156
| 'NetworkDownIndicator'
127157
| 'reloadChannel'
128158
| 'scrollToFirstUnreadThreshold'
159+
| 'setChannelUnreadState'
129160
| 'setTargetedMessage'
130161
| 'StickyHeader'
131162
| 'targetedMessage'
@@ -271,6 +302,7 @@ const MessageListWithContext = <
271302
reloadChannel,
272303
ScrollToBottomButton,
273304
selectedPicker,
305+
setChannelUnreadState,
274306
setFlatListRef,
275307
setMessages,
276308
setSelectedPicker,
@@ -418,14 +450,28 @@ const MessageListWithContext = <
418450
const lastItem = viewableItems[viewableItems.length - 1];
419451

420452
if (lastItem) {
421-
const lastItemCreatedAt = lastItem.item.created_at;
453+
const lastItemMessage = lastItem.item;
454+
const lastItemCreatedAt = lastItemMessage.created_at;
422455

423456
const unreadIndicatorDate = channelUnreadState?.last_read.getTime();
424457
const lastItemDate = lastItemCreatedAt.getTime();
425458

426459
if (
427460
!channel.state.messagePagination.hasPrev &&
428-
processedMessageList[processedMessageList.length - 1].id === lastItem.item.id
461+
processedMessageList[processedMessageList.length - 1].id === lastItemMessage.id
462+
) {
463+
setIsUnreadNotificationOpen(false);
464+
return;
465+
}
466+
/**
467+
* This is a special case where there is a single long message by the sender.
468+
* When a message is sent, we mark it as read before it actually has a `created_at` timestamp.
469+
* This is a workaround to prevent the unread indicator from showing when the message is sent.
470+
*/
471+
if (
472+
viewableItems.length === 1 &&
473+
channel.countUnread() === 0 &&
474+
lastItemMessage.user.id === client.userID
429475
) {
430476
setIsUnreadNotificationOpen(false);
431477
return;
@@ -484,20 +530,55 @@ const MessageListWithContext = <
484530
* Effect to mark the channel as read when the user scrolls to the bottom of the message list.
485531
*/
486532
useEffect(() => {
487-
const listener: ReturnType<typeof channel.on> = channel.on('message.new', async (event) => {
488-
const newMessageToCurrentChannel = event.cid === channel.cid;
489-
const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel;
533+
const shouldMarkRead = () => {
534+
return (
535+
!channelUnreadState?.first_unread_message_id &&
536+
!threadList &&
537+
!scrollToBottomButtonVisible &&
538+
client.user?.id &&
539+
!hasReadLastMessage(channel, client.user?.id)
540+
);
541+
};
490542

491-
if (newMessageToCurrentChannel && mainChannelUpdated && !scrollToBottomButtonVisible) {
492-
console.log('markRead');
543+
const handleEvent = async (event: Event<StreamChatGenerics>) => {
544+
const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel;
545+
// When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState.
546+
if (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) {
547+
setChannelUnreadState((prev) => {
548+
const previousUnreadCount = prev?.unread_messages ?? 0;
549+
const previousLastMessage = getPreviousLastMessage<StreamChatGenerics>(
550+
channel.state.messages,
551+
event.message,
552+
);
553+
return {
554+
...(prev || {}),
555+
last_read:
556+
prev?.last_read ??
557+
(previousUnreadCount === 0 && previousLastMessage?.created_at
558+
? new Date(previousLastMessage.created_at)
559+
: new Date(0)), // not having information about the last read message means the whole channel is unread,
560+
unread_messages: previousUnreadCount + 1,
561+
};
562+
});
563+
} else if (mainChannelUpdated && shouldMarkRead()) {
493564
await markRead();
494565
}
495-
});
566+
};
567+
568+
const listener: ReturnType<typeof channel.on> = channel.on('message.new', handleEvent);
496569

497570
return () => {
498571
listener?.unsubscribe();
499572
};
500-
}, [channel, markRead, scrollToBottomButtonVisible]);
573+
}, [
574+
channel,
575+
channelUnreadState?.first_unread_message_id,
576+
client.user?.id,
577+
markRead,
578+
scrollToBottomButtonVisible,
579+
setChannelUnreadState,
580+
threadList,
581+
]);
501582

502583
useEffect(() => {
503584
const lastReceivedMessage = getLastReceivedMessage(processedMessageList);
@@ -901,6 +982,13 @@ const MessageListWithContext = <
901982
}
902983

903984
setScrollToBottomButtonVisible(false);
985+
/**
986+
* When we are not in the bottom of the list, and we receive new messages, we need to mark the channel as read.
987+
We would still need to show the unread label, where the first unread message appeared so we don't update the channelUnreadState.
988+
*/
989+
await markRead({
990+
updateChannelUnreadState: false,
991+
});
904992
};
905993

906994
const scrollToIndexFailedRetryCountRef = useRef<number>(0);
@@ -1212,6 +1300,7 @@ export const MessageList = <
12121300
NetworkDownIndicator,
12131301
reloadChannel,
12141302
scrollToFirstUnreadThreshold,
1303+
setChannelUnreadState,
12151304
setTargetedMessage,
12161305
StickyHeader,
12171306
targetedMessage,
@@ -1277,6 +1366,7 @@ export const MessageList = <
12771366
ScrollToBottomButton,
12781367
scrollToFirstUnreadThreshold,
12791368
selectedPicker,
1369+
setChannelUnreadState,
12801370
setMessages,
12811371
setSelectedPicker,
12821372
setTargetedMessage,

0 commit comments

Comments
 (0)