diff --git a/README.md b/README.md
index f2bea85004..db94d5547b 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
[![NPM](https://img.shields.io/npm/v/stream-chat-react-native.svg)](https://www.npmjs.com/package/stream-chat-react-native)
[![Build Status](https://github.com/GetStream/stream-chat-react-native/actions/workflows/release.yml/badge.svg)](https://github.com/GetStream/stream-chat-react-native/actions)
[![Component Reference](https://img.shields.io/badge/docs-component%20reference-blue.svg)](https://getstream.io/chat/docs/sdk/reactnative)
-![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-456%20KB-blue)
+![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-460%20KB-blue)
diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock
index 16691dc665..7d7c542c23 100644
--- a/examples/SampleApp/ios/Podfile.lock
+++ b/examples/SampleApp/ios/Podfile.lock
@@ -2164,7 +2164,7 @@ PODS:
- libwebp (~> 1.0)
- SDWebImage/Core (~> 5.10)
- SocketRocket (0.7.1)
- - stream-chat-react-native (6.0.0):
+ - stream-chat-react-native (6.0.1):
- DoubleConversion
- glog
- hermes-engine
@@ -2474,7 +2474,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
boost: 1dca942403ed9342f98334bf4c3621f011aa7946
- DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385
+ DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5
FBLazyVector: 1bf99bb46c6af9a2712592e707347315f23947aa
Firebase: 7a56fe4f56b5ab81b86a6822f5b8f909ae6fc7e2
FirebaseAnalytics: 2f4a11eeb7a0e9c6fcf642d4e6aaca7fa4d38c28
@@ -2488,7 +2488,7 @@ SPEC CHECKSUMS:
FirebaseRemoteConfigInterop: e75e348953352a000331eb77caf01e424248e176
FirebaseSessions: b252b3f91a51186188882ea8e7e1730fc1eee391
fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be
- glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
+ glog: 69ef571f3de08433d766d614c73a9838a06bf7eb
GoogleAppMeasurement: ee5c2d2242816773fbf79e5b0563f5355ef1c315
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
@@ -2576,9 +2576,9 @@ SPEC CHECKSUMS:
SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d
SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
- stream-chat-react-native: 557a9b07b068fea9a1670302ca43f8589d884e33
+ stream-chat-react-native: 4b3bb162446ad9b25c745fc8083a2516d363d5eb
Yoga: 7548e4449365bf0ef60db4aefe58abff37fcabec
PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d
-COCOAPODS: 1.14.3
+COCOAPODS: 1.16.2
diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock
index 0a71c04630..da3223de8c 100644
--- a/examples/SampleApp/yarn.lock
+++ b/examples/SampleApp/yarn.lock
@@ -7952,10 +7952,10 @@ statuses@~1.5.0:
resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
-stream-chat-react-native-core@6.0.0:
- version "6.0.0"
- resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-6.0.0.tgz#77798c7082877572ef70223e1f799d22f0c78fe7"
- integrity sha512-3cFao8iL2MjP7nhVRAl1vi526FbPlqUj4BHnYQ7sUNe+xb4z/HCEL6fKFh8kIfK5MEAacOQO4juPPQktoIf7zg==
+stream-chat-react-native-core@6.0.1:
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-6.0.1.tgz#a67f14685519cafa58466d28eee8f1edc9dbafcf"
+ integrity sha512-kyHgGn2PF+JTt7eEKdHMot9Nxzx+yecnlut9oyhi/IJbxOwpjIgB87+rdQXEI5o8SeNwQuAeV3VatxGaxl5Jbw==
dependencies:
"@gorhom/bottom-sheet" "^5.0.6"
dayjs "1.10.5"
diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx
index 27b4546d52..87da8bbc47 100644
--- a/package/src/components/Channel/Channel.tsx
+++ b/package/src/components/Channel/Channel.tsx
@@ -12,7 +12,6 @@ import {
Channel as ChannelType,
EventHandler,
FormatMessageResponse,
- logChatPromiseExecution,
MessageResponse,
Reaction,
SendMessageAPIResponse,
@@ -92,7 +91,7 @@ import {
isImagePickerAvailable,
} from '../../native';
import * as dbApi from '../../store/apis';
-import { DefaultStreamChatGenerics, FileTypes } from '../../types/types';
+import { ChannelUnreadState, DefaultStreamChatGenerics, FileTypes } from '../../types/types';
import { addReactionToLocalState } from '../../utils/addReactionToLocalState';
import { compressedImageURI } from '../../utils/compressImage';
import { DBSyncManager } from '../../utils/DBSyncManager';
@@ -179,6 +178,7 @@ import { ScrollToBottomButton as ScrollToBottomButtonDefault } from '../MessageL
import { StickyHeader as StickyHeaderDefault } from '../MessageList/StickyHeader';
import { TypingIndicator as TypingIndicatorDefault } from '../MessageList/TypingIndicator';
import { TypingIndicatorContainer as TypingIndicatorContainerDefault } from '../MessageList/TypingIndicatorContainer';
+import { UnreadMessagesNotification as UnreadMessagesNotificationDefault } from '../MessageList/UnreadMessagesNotification';
import { MessageActionList as MessageActionListDefault } from '../MessageMenu/MessageActionList';
import { MessageActionListItem as MessageActionListItemDefault } from '../MessageMenu/MessageActionListItem';
import { MessageMenu as MessageMenuDefault } from '../MessageMenu/MessageMenu';
@@ -188,6 +188,15 @@ import { MessageUserReactionsAvatar as MessageUserReactionsAvatarDefault } from
import { MessageUserReactionsItem as MessageUserReactionsItemDefault } from '../MessageMenu/MessageUserReactionsItem';
import { Reply as ReplyDefault } from '../Reply/Reply';
+export type MarkReadFunctionOptions = {
+ /**
+ * Signal, whether the `channelUnreadUiState` should be updated.
+ * By default, the local state update is prevented when the Channel component is mounted.
+ * This is in order to keep the UI indicating the original unread state, when the user opens a channel.
+ */
+ updateChannelUnreadState?: boolean;
+};
+
const styles = StyleSheet.create({
selectChannel: { fontWeight: 'bold', padding: 16 },
});
@@ -301,6 +310,7 @@ export type ChannelPropsWithContext<
| 'handleDelete'
| 'handleEdit'
| 'handleFlag'
+ | 'handleMarkUnread'
| 'handleMute'
| 'handlePinMessage'
| 'handleReaction'
@@ -360,6 +370,7 @@ export type ChannelPropsWithContext<
| 'VideoThumbnail'
| 'PollContent'
| 'hasCreatePoll'
+ | 'UnreadMessagesNotification'
| 'StreamingMessageView'
>
> &
@@ -384,7 +395,10 @@ export type ChannelPropsWithContext<
* Overrides the Stream default mark channel read request (Advanced usage only)
* @param channel Channel object
*/
- doMarkReadRequest?: (channel: ChannelType) => void;
+ doMarkReadRequest?: (
+ channel: ChannelType,
+ setChannelUnreadUiState?: (state: ChannelUnreadState) => void,
+ ) => void;
/**
* Overrides the Stream default send message request (Advanced usage only)
* @param channelId
@@ -433,6 +447,10 @@ export type ChannelPropsWithContext<
* Custom loading error indicator to override the Stream default
*/
LoadingErrorIndicator?: React.ComponentType;
+ /**
+ * Boolean flag to enable/disable marking the channel as read on mount
+ */
+ markReadOnMount?: boolean;
maxMessageLength?: number;
/**
* Load the channel at a specified message instead of the most recent message.
@@ -529,6 +547,7 @@ const ChannelWithContext = <
handleDelete,
handleEdit,
handleFlag,
+ handleMarkUnread,
handleMute,
handlePinMessage,
handleQuotedReply,
@@ -566,6 +585,7 @@ const ChannelWithContext = <
loadingMore: loadingMoreProp,
loadingMoreRecent: loadingMoreRecentProp,
markdownRules,
+ markReadOnMount = true,
maxMessageLength: maxMessageLengthProp,
maxNumberOfFiles = 10,
maxTimeBetweenGroupedMessages,
@@ -647,6 +667,7 @@ const ChannelWithContext = <
threadMessages,
TypingIndicator = TypingIndicatorDefault,
TypingIndicatorContainer = TypingIndicatorContainerDefault,
+ UnreadMessagesNotification = UnreadMessagesNotificationDefault,
UploadProgressIndicator = UploadProgressIndicatorDefault,
UrlPreview = CardDefault,
VideoThumbnail = VideoThumbnailDefault,
@@ -674,10 +695,13 @@ const ChannelWithContext = <
const [thread, setThread] = useState | null>(threadProps || null);
const [threadHasMore, setThreadHasMore] = useState(true);
const [threadLoadingMore, setThreadLoadingMore] = useState(false);
+ const [channelUnreadState, setChannelUnreadState] = useState(
+ undefined,
+ );
const syncingChannelRef = useRef(false);
- const { setTargetedMessage, targetedMessage } = useTargetedMessage();
+ const { highlightedMessageId, setTargetedMessage, targetedMessage } = useTargetedMessage();
/**
* This ref will hold the abort controllers for
@@ -692,6 +716,7 @@ const ChannelWithContext = <
const {
copyStateFromChannel,
initStateFromChannel,
+ setRead,
setTyping,
state: channelState,
} = useChannelDataState(channel);
@@ -754,6 +779,22 @@ const ChannelWithContext = <
}
}
+ if (event.type === 'notification.mark_unread') {
+ setChannelUnreadState((prev) => {
+ if (!(event.last_read_at && event.user)) return prev;
+ return {
+ first_unread_message_id: event.first_unread_message_id,
+ last_read: new Date(event.last_read_at),
+ last_read_message_id: event.last_read_message_id,
+ unread_messages: event.unread_messages ?? 0,
+ };
+ });
+ }
+
+ if (event.type === 'channel.truncated' && event.cid === channel.cid) {
+ setChannelUnreadState(undefined);
+ }
+
// only update channel state if the events are not the previously subscribed useEffect's subscription events
if (channel && channel.initialized) {
copyChannelState();
@@ -764,6 +805,8 @@ const ChannelWithContext = <
useEffect(() => {
let listener: ReturnType;
const initChannel = async () => {
+ setLastRead(new Date());
+ const unreadCount = channel.countUnread();
if (!channel || !shouldSyncChannel || channel.offlineMode) return;
let errored = false;
@@ -782,14 +825,33 @@ const ChannelWithContext = <
loadInitialMessagesStateFromChannel(channel, channel.state.messagePagination.hasPrev);
}
+ if (client.user?.id && channel.state.read[client.user.id]) {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { user, ...ownReadState } = channel.state.read[client.user.id];
+ setChannelUnreadState(ownReadState);
+ }
+
if (messageId) {
await loadChannelAroundMessage({ messageId, setTargetedMessage });
} else if (
initialScrollToFirstUnreadMessage &&
- channel.countUnread() > scrollToFirstUnreadThreshold
+ client.user &&
+ unreadCount > scrollToFirstUnreadThreshold
) {
- await loadChannelAtFirstUnreadMessage({ setTargetedMessage });
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const { user, ...ownReadState } = channel.state.read[client.user.id];
+
+ await loadChannelAtFirstUnreadMessage({
+ channelUnreadState: ownReadState,
+ setChannelUnreadState,
+ setTargetedMessage,
+ });
+ }
+
+ if (unreadCount > 0 && markReadOnMount) {
+ await markRead({ updateChannelUnreadState: false });
}
+
listener = channel.on(handleEvent);
};
@@ -819,12 +881,12 @@ const ChannelWithContext = <
*/
useEffect(() => {
const handleEvent: EventHandler = (event) => {
- if (channel.cid === event.cid) copyChannelState();
+ if (channel.cid === event.cid) setRead(channel);
};
const { unsubscribe } = client.on('notification.mark_read', handleEvent);
return unsubscribe;
- }, [channel.cid, client, copyChannelState]);
+ }, [channel, client, setRead]);
const threadPropsExists = !!threadProps;
@@ -858,23 +920,33 @@ const ChannelWithContext = <
/**
* CHANNEL METHODS
*/
- const markRead: ChannelContextValue['markRead'] = useRef(
- throttle(
- () => {
- if (!channel || channel?.disconnected || !clientChannelConfig?.read_events) {
- return;
- }
+ const markRead: ChannelContextValue['markRead'] = throttle(
+ async (options?: MarkReadFunctionOptions) => {
+ const { updateChannelUnreadState = true } = options ?? {};
+ if (!channel || channel?.disconnected || !clientChannelConfig?.read_events) {
+ return;
+ }
- if (doMarkReadRequest) {
- doMarkReadRequest(channel);
- } else {
- logChatPromiseExecution(channel.markRead(), 'mark read');
+ if (doMarkReadRequest) {
+ doMarkReadRequest(channel, updateChannelUnreadState ? setChannelUnreadState : undefined);
+ } else {
+ try {
+ const response = await channel.markRead();
+ if (updateChannelUnreadState && response && lastRead) {
+ setChannelUnreadState({
+ last_read: lastRead,
+ last_read_message_id: response?.event.last_read_message_id,
+ unread_messages: 0,
+ });
+ }
+ } catch (err) {
+ console.log('Error marking channel as read:', err);
}
- },
- defaultThrottleInterval,
- throttleOptions,
- ),
- ).current;
+ }
+ },
+ defaultThrottleInterval,
+ throttleOptions,
+ );
const reloadThread = async () => {
if (!channel || !thread?.id) return;
@@ -1596,8 +1668,9 @@ const ChannelWithContext = <
overrideCapabilities: overrideOwnCapabilities,
});
- const channelContext = useCreateChannelContext({
+ const channelContext = useCreateChannelContext({
channel,
+ channelUnreadState,
disabled: !!channel?.data?.frozen,
EmptyStateIndicator,
enableMessageGroupingByUser,
@@ -1608,9 +1681,11 @@ const ChannelWithContext = <
!!(clientChannelConfig?.commands || [])?.some((command) => command.name === 'giphy'),
hideDateSeparators,
hideStickyDateHeader,
+ highlightedMessageId,
isChannelActive: shouldSyncChannel,
lastRead,
loadChannelAroundMessage,
+ loadChannelAtFirstUnreadMessage,
loading: channelMessagesState.loading,
LoadingIndicator,
markRead,
@@ -1620,6 +1695,7 @@ const ChannelWithContext = <
read: channelState.read ?? {},
reloadChannel,
scrollToFirstUnreadThreshold,
+ setChannelUnreadState,
setLastRead,
setTargetedMessage,
StickyHeader,
@@ -1748,6 +1824,7 @@ const ChannelWithContext = <
handleDelete,
handleEdit,
handleFlag,
+ handleMarkUnread,
handleMute,
handlePinMessage,
handleQuotedReply,
@@ -1815,6 +1892,7 @@ const ChannelWithContext = <
targetedMessage,
TypingIndicator,
TypingIndicatorContainer,
+ UnreadMessagesNotification,
updateMessage,
UrlPreview,
VideoThumbnail,
diff --git a/package/src/components/Channel/__tests__/Channel.test.js b/package/src/components/Channel/__tests__/Channel.test.js
index 4254bcabb0..a7d3b7c39e 100644
--- a/package/src/components/Channel/__tests__/Channel.test.js
+++ b/package/src/components/Channel/__tests__/Channel.test.js
@@ -29,6 +29,7 @@ import {
useChannelDataState,
useChannelMessageDataState,
} from '../hooks/useChannelDataState';
+import * as MessageListPaginationHooks from '../hooks/useMessageListPagination';
// This component is used for performing effects in a component that consumes ChannelContext,
// i.e. making use of the callbacks & values provided by the Channel component.
@@ -87,6 +88,7 @@ describe('Channel', () => {
const nullChannel = {
...channel,
cid: null,
+ countUnread: () => 0,
off: () => {},
on: () => ({
unsubscribe: () => null,
@@ -464,79 +466,128 @@ describe('Channel initial load useEffect', () => {
);
});
- it("should not call loadChannelAtFirstUnreadMessage if channel's unread count is 0", async () => {
- const mockedChannel = generateChannelResponse({
- messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })),
+ describe('initialScrollToFirstUnreadMessage', () => {
+ afterEach(() => {
+ // Clear all mocks after each test
+ jest.clearAllMocks();
+ // Restore all mocks to their original implementation
+ jest.restoreAllMocks();
+ cleanup();
});
+ const mockedHook = (values) =>
+ jest.spyOn(MessageListPaginationHooks, 'useMessageListPagination').mockImplementation(() => ({
+ copyMessagesStateFromChannel: jest.fn(),
+ loadChannelAroundMessage: jest.fn(),
+ loadChannelAtFirstUnreadMessage: jest.fn(),
+ loadInitialMessagesStateFromChannel: jest.fn(),
+ loadLatestMessages: jest.fn(),
+ loadMore: jest.fn(),
+ loadMoreRecent: jest.fn(),
+ state: { ...channelInitialState },
+ ...values,
+ }));
+ it("should not call loadChannelAtFirstUnreadMessage if channel's unread count is 0", async () => {
+ const mockedChannel = generateChannelResponse({
+ messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })),
+ });
- useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
- const channel = chatClient.channel('messaging', mockedChannel.id);
- await channel.watch();
- const messages = Array.from({ length: 100 }, (_, i) => generateMessage({ id: i }));
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
+ const channel = chatClient.channel('messaging', mockedChannel.id);
+ await channel.watch();
+ const user = generateUser();
+ const read_data = {};
- const loadMessageIntoState = jest.fn();
- channel.state = {
- ...channelInitialState,
- loadMessageIntoState,
- messagePagination: {
- hasNext: true,
- hasPrev: true,
- },
- messages,
- };
- channel.countUnread = jest.fn(() => 0);
+ read_data[chatClient.user.id] = {
+ last_read: new Date(),
+ user,
+ };
- renderComponent({ channel, initialScrollToFirstUnreadMessage: true });
+ channel.state = {
+ ...channelInitialState,
+ read: read_data,
+ };
+ channel.countUnread = jest.fn(() => 0);
- await waitFor(() => {
- expect(loadMessageIntoState).not.toHaveBeenCalled();
- });
- });
+ const loadChannelAtFirstUnreadMessageFn = jest.fn();
- it("should call loadChannelAtFirstUnreadMessage if channel's unread count is greater than 0", async () => {
- const mockedChannel = generateChannelResponse({
- messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })),
- });
+ mockedHook({ loadChannelAtFirstUnreadMessage: loadChannelAtFirstUnreadMessageFn });
- useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
- const channel = chatClient.channel('messaging', mockedChannel.id);
- await channel.watch();
- const messages = Array.from({ length: 100 }, (_, i) => generateMessage({ id: i }));
+ renderComponent({ channel, initialScrollToFirstUnreadMessage: true });
- let targetedMessageId = 0;
- const loadMessageIntoState = jest.fn((id) => {
- targetedMessageId = id;
- const newMessages = getElementsAround(messages, 'id', id);
- channel.state.messages = newMessages;
+ await waitFor(() => {
+ expect(loadChannelAtFirstUnreadMessageFn).not.toHaveBeenCalled();
+ });
});
- channel.state = {
- ...channelInitialState,
- loadMessageIntoState,
- messagePagination: {
- hasNext: true,
- hasPrev: true,
- },
- messages,
- messageSets: [{ isCurrent: true, isLatest: true }],
- };
+ it("should call loadChannelAtFirstUnreadMessage if channel's unread count is greater than 0", async () => {
+ const mockedChannel = generateChannelResponse({
+ messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })),
+ });
- channel.countUnread = jest.fn(() => 15);
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
+ const channel = chatClient.channel('messaging', mockedChannel.id);
+ await channel.watch();
- renderComponent({ channel, initialScrollToFirstUnreadMessage: true });
+ const user = generateUser();
+ const numberOfUnreadMessages = 15;
+ const read_data = {};
- await waitFor(() => {
- expect(loadMessageIntoState).toHaveBeenCalledTimes(1);
+ read_data[chatClient.user.id] = {
+ last_read: new Date(),
+ unread_messages: numberOfUnreadMessages,
+ user,
+ };
+ channel.state = {
+ ...channelInitialState,
+ read: read_data,
+ };
+
+ channel.countUnread = jest.fn(() => numberOfUnreadMessages);
+ const loadChannelAtFirstUnreadMessageFn = jest.fn();
+
+ mockedHook({ loadChannelAtFirstUnreadMessage: loadChannelAtFirstUnreadMessageFn });
+
+ renderComponent({ channel, initialScrollToFirstUnreadMessage: true });
+
+ await waitFor(() => {
+ expect(loadChannelAtFirstUnreadMessageFn).toHaveBeenCalled();
+ });
});
- const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel));
- await waitFor(() =>
- expect(
- channelMessageState.current.state.messages.find(
- (message) => message.id === targetedMessageId,
- ),
- ).toBeDefined(),
- );
+ it("should not call loadChannelAtFirstUnreadMessage if channel's unread count is greater than 0 lesser than scrollToFirstUnreadThreshold", async () => {
+ const mockedChannel = generateChannelResponse({
+ messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })),
+ });
+
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
+ const channel = chatClient.channel('messaging', mockedChannel.id);
+ await channel.watch();
+
+ const user = generateUser();
+ const numberOfUnreadMessages = 2;
+ const read_data = {};
+
+ read_data[chatClient.user.id] = {
+ last_read: new Date(),
+ unread_messages: numberOfUnreadMessages,
+ user,
+ };
+ channel.state = {
+ ...channelInitialState,
+ read: read_data,
+ };
+
+ channel.countUnread = jest.fn(() => numberOfUnreadMessages);
+ const loadChannelAtFirstUnreadMessageFn = jest.fn();
+
+ mockedHook({ loadChannelAtFirstUnreadMessage: loadChannelAtFirstUnreadMessageFn });
+
+ renderComponent({ channel, initialScrollToFirstUnreadMessage: true });
+
+ await waitFor(() => {
+ expect(loadChannelAtFirstUnreadMessageFn).not.toHaveBeenCalled();
+ });
+ });
});
it('should call resyncChannel when connection changed event is triggered', async () => {
diff --git a/package/src/components/Channel/__tests__/ownCapabilities.test.js b/package/src/components/Channel/__tests__/ownCapabilities.test.js
index a879746ec7..ac0ef3d9fe 100644
--- a/package/src/components/Channel/__tests__/ownCapabilities.test.js
+++ b/package/src/components/Channel/__tests__/ownCapabilities.test.js
@@ -236,6 +236,32 @@ describe('Own capabilities', () => {
});
});
+ describe(`${allOwnCapabilities.readEvents} capability`, () => {
+ it(`should render "Mark as Unread" action for messages when "${allOwnCapabilities.readEvents}" capability is enabled`, async () => {
+ await generateChannelWithCapabilities([allOwnCapabilities.readEvents]);
+ const { queryByLabelText } = await renderChannelAndOpenMessageActionsList(receivedMessage);
+ expect(!!queryByLabelText('markUnread action list item')).toBeTruthy();
+ });
+
+ it(`should not render "Mark Read" action for received message when "${allOwnCapabilities.readEvents}" capability is disabled`, async () => {
+ await generateChannelWithCapabilities();
+
+ const { queryByLabelText } = await renderChannelAndOpenMessageActionsList(receivedMessage);
+ expect(!!queryByLabelText('markUnread action list item')).toBeFalsy();
+ });
+
+ it('should override capability from "overrideOwnCapability.readEvents" prop', async () => {
+ await generateChannelWithCapabilities([allOwnCapabilities.readEvents]);
+
+ const { queryByLabelText } = await renderChannelAndOpenMessageActionsList(receivedMessage, {
+ overrideOwnCapabilities: {
+ readEvents: false,
+ },
+ });
+ expect(!!queryByLabelText('markUnread action list item')).toBeFalsy();
+ });
+ });
+
describe(`${allOwnCapabilities.pinMessage} capability`, () => {
it(`should render "Pin Message" action for sent message when "${allOwnCapabilities.pinMessage}" capability is enabled`, async () => {
await generateChannelWithCapabilities([allOwnCapabilities.pinMessage]);
diff --git a/package/src/components/Channel/__tests__/useMessageListPagination.test.js b/package/src/components/Channel/__tests__/useMessageListPagination.test.js
index 3c9dbcca8e..989d13c1e3 100644
--- a/package/src/components/Channel/__tests__/useMessageListPagination.test.js
+++ b/package/src/components/Channel/__tests__/useMessageListPagination.test.js
@@ -337,10 +337,11 @@ describe('useMessageListPagination', () => {
});
it('should not do anything when the unread count is 0', async () => {
+ const messages = Array.from({ length: 20 }, (_, i) =>
+ generateMessage({ text: `message-${i}` }),
+ );
const loadMessageIntoState = jest.fn(() => {
- channel.state.messages = Array.from({ length: 20 }, (_, i) =>
- generateMessage({ text: `message-${i}` }),
- );
+ channel.state.messages = messages;
channel.state.messagePagination.hasPrev = true;
});
channel.state = {
@@ -352,68 +353,264 @@ describe('useMessageListPagination', () => {
},
};
- channel.countUnread = jest.fn(() => 0);
+ const user = generateUser();
+ const channelUnreadState = {
+ unread_messages: 0,
+ user,
+ };
+
+ const jumpToMessageFinishedMock = jest.fn();
+ mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock });
const { result } = renderHook(() => useMessageListPagination({ channel }));
await act(async () => {
- await result.current.loadChannelAtFirstUnreadMessage({});
+ await result.current.loadChannelAtFirstUnreadMessage({ channelUnreadState });
});
await waitFor(() => {
- expect(loadMessageIntoState).toHaveBeenCalledTimes(0);
+ expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes(0);
});
});
- function getElementsAround(array, key, id, limit) {
- const index = array.findIndex((obj) => obj[key] === id);
-
- if (index === -1) {
- return [];
- }
-
- const start = Math.max(0, index - limit); // 12 before the index
- const end = Math.min(array.length, index + limit); // 12 after the index
- return array.slice(start, end);
- }
+ const generateMessageArray = (length = 20) =>
+ Array.from({ length }, (_, i) => generateMessage({ id: i, text: `message-${i}` }));
+
+ // Test cases with different scenarios
+ const testCases = [
+ {
+ channelUnreadState: (messages) => ({
+ first_unread_message_id: messages[2].id,
+ unread_messages: 2,
+ }),
+ expectedCalls: {
+ jumpToMessageFinishedCalls: 1,
+ loadMessageIntoStateCalls: 0,
+ setChannelUnreadStateCalls: 0,
+ setTargetedMessageIdCalls: 1,
+ targetedMessageId: (messages) => messages[2].id,
+ },
+ initialMessages: generateMessageArray(),
+ name: 'first_unread_message_id present in current message set',
+ setupLoadMessageIntoState: null,
+ },
+ {
+ channelUnreadState: () => ({
+ first_unread_message_id: 21,
+ unread_messages: 2,
+ }),
+ expectedCalls: {
+ jumpToMessageFinishedCalls: 1,
+ loadMessageIntoStateCalls: 1,
+ setChannelUnreadStateCalls: 0,
+ setTargetedMessageIdCalls: 1,
+ targetedMessageId: () => 21,
+ },
+ initialMessages: generateMessageArray(),
+ name: 'first_unread_message_id not present in current message set',
+ setupLoadMessageIntoState: (channel) => {
+ const loadMessageIntoState = jest.fn(() => {
+ const newMessages = Array.from({ length: 20 }, (_, i) =>
+ generateMessage({ id: i + 21, text: `message-${i + 21}` }),
+ );
+ channel.state.messages = newMessages;
+ channel.state.messagePagination.hasPrev = true;
+ });
+ channel.state.loadMessageIntoState = loadMessageIntoState;
+ return loadMessageIntoState;
+ },
+ },
+ {
+ channelUnreadState: (messages) => ({
+ last_read_message_id: messages[2].id,
+ unread_messages: 2,
+ }),
+ expectedCalls: {
+ jumpToMessageFinishedCalls: 1,
+ loadMessageIntoStateCalls: 0,
+ setChannelUnreadStateCalls: 1,
+ setTargetedMessageIdCalls: 1,
+ targetedMessageId: (messages) => messages[3].id,
+ },
+ initialMessages: generateMessageArray(),
+ name: 'last_read_message_id present in current message set',
+ setupLoadMessageIntoState: null,
+ },
+ {
+ channelUnreadState: () => ({
+ last_read_message_id: 21,
+ unread_messages: 2,
+ }),
+ expectedCalls: {
+ jumpToMessageFinishedCalls: 1,
+ loadMessageIntoStateCalls: 1,
+ setChannelUnreadStateCalls: 1,
+ setTargetedMessageIdCalls: 1,
+ targetedMessageId: () => 22,
+ },
+ initialMessages: generateMessageArray(),
+ name: 'last_read_message_id not present in current message set',
+ setupLoadMessageIntoState: (channel) => {
+ const loadMessageIntoState = jest.fn(() => {
+ const newMessages = Array.from({ length: 20 }, (_, i) =>
+ generateMessage({ id: i + 21, text: `message-${i + 21}` }),
+ );
+ channel.state.messages = newMessages;
+ channel.state.messagePagination.hasPrev = true;
+ });
+ channel.state.loadMessageIntoState = loadMessageIntoState;
+ return loadMessageIntoState;
+ },
+ },
+ ];
- it('should call the loadMessageIntoState function when the unread count is greater than 0 and set the state', async () => {
- const messages = Array.from({ length: 30 }, (_, i) =>
- generateMessage({ text: `message-${i}` }),
- );
- const loadMessageIntoState = jest.fn((messageId) => {
- channel.state.messages = getElementsAround(messages, 'id', messageId, 5);
- channel.state.messagePagination.hasPrev = true;
- });
+ it.each(testCases)('$name', async (testCase) => {
+ // Setup channel state
+ const messages = testCase.initialMessages;
channel.state = {
...channelInitialState,
- loadMessageIntoState,
messagePagination: {
- hasNext: false,
+ hasNext: true,
hasPrev: true,
},
messages,
- messageSets: [{ isCurrent: true, isLatest: true }],
};
- const unreadCount = 5;
- channel.countUnread = jest.fn(() => unreadCount);
+ // Setup additional mocks if needed
+ const loadMessageIntoStateMock = testCase.setupLoadMessageIntoState
+ ? testCase.setupLoadMessageIntoState(channel)
+ : null;
+
+ // Generate user and channel unread state
+ const user = generateUser();
+ const channelUnreadState = {
+ user,
+ ...testCase.channelUnreadState(messages),
+ };
+
+ // Setup mocks
+ const jumpToMessageFinishedMock = jest.fn();
+ mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock });
const { result } = renderHook(() => useMessageListPagination({ channel }));
+ const setChannelUnreadStateMock = jest.fn();
+ const setTargetedMessageIdMock = jest.fn((message) => message);
+
+ // Execute the method
await act(async () => {
- await result.current.loadChannelAtFirstUnreadMessage({});
+ await result.current.loadChannelAtFirstUnreadMessage({
+ channelUnreadState,
+ setChannelUnreadState: setChannelUnreadStateMock,
+ setTargetedMessage: setTargetedMessageIdMock,
+ });
});
+ // Verify expectations
await waitFor(() => {
- expect(loadMessageIntoState).toHaveBeenCalledTimes(1);
- expect(result.current.state.hasMore).toBe(true);
- expect(result.current.state.hasMoreNewer).toBe(false);
- expect(result.current.state.messages.length).toBe(10);
- expect(result.current.state.targetedMessageId).toBe(
- messages[messages.length - unreadCount].id,
+ if (loadMessageIntoStateMock) {
+ expect(loadMessageIntoStateMock).toHaveBeenCalledTimes(
+ testCase.expectedCalls.loadMessageIntoStateCalls,
+ );
+ }
+
+ expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes(
+ testCase.expectedCalls.jumpToMessageFinishedCalls,
+ );
+
+ expect(setChannelUnreadStateMock).toHaveBeenCalledTimes(
+ testCase.expectedCalls.setChannelUnreadStateCalls,
);
+
+ expect(setTargetedMessageIdMock).toHaveBeenCalledTimes(
+ testCase.expectedCalls.setTargetedMessageIdCalls,
+ );
+
+ if (testCase.expectedCalls.targetedMessageId) {
+ const expectedMessageId = testCase.expectedCalls.targetedMessageId(messages);
+ expect(setTargetedMessageIdMock).toHaveBeenCalledWith(expectedMessageId);
+ }
});
});
+
+ const messages = Array.from({ length: 20 }, (_, i) =>
+ generateMessage({
+ created_at: new Date(`2021-09-01T00:00:00.000Z`),
+ id: i,
+ text: `message-${i}`,
+ }),
+ );
+
+ const user = generateUser();
+
+ it.each`
+ scenario | last_read | expectedQueryCalls | expectedJumpToMessageFinishedCalls | expectedSetChannelUnreadStateCalls | expectedSetTargetedMessageCalls | expectedTargetedMessageId
+ ${'when last_read matches a message'} | ${new Date(messages[10].created_at)} | ${0} | ${1} | ${1} | ${1} | ${10}
+ ${'when last_read does not match any message'} | ${new Date('2021-09-02T00:00:00.000Z')} | ${1} | ${0} | ${0} | ${0} | ${undefined}
+ `(
+ '$scenario',
+ async ({
+ expectedJumpToMessageFinishedCalls,
+ expectedQueryCalls,
+ expectedSetChannelUnreadStateCalls,
+ expectedSetTargetedMessageCalls,
+ expectedTargetedMessageId,
+ last_read,
+ }) => {
+ // Set up channel state
+ channel.state = {
+ ...channelInitialState,
+ messagePagination: {
+ hasNext: true,
+ hasPrev: true,
+ },
+ messages,
+ };
+
+ const channelUnreadState = {
+ last_read,
+ unread_messages: 2,
+ user,
+ };
+
+ // Mock query if needed
+ const queryMock = jest.fn();
+ channel.query = queryMock;
+
+ // Set up mocks
+ const jumpToMessageFinishedMock = jest.fn();
+ mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock });
+ const setChannelUnreadStateMock = jest.fn();
+ const setTargetedMessageIdMock = jest.fn((message) => message);
+
+ // Render hook
+ const { result } = renderHook(() => useMessageListPagination({ channel }));
+
+ // Act
+ await act(async () => {
+ await result.current.loadChannelAtFirstUnreadMessage({
+ channelUnreadState,
+ setChannelUnreadState: setChannelUnreadStateMock,
+ setTargetedMessage: setTargetedMessageIdMock,
+ });
+ });
+
+ // Assert
+ await waitFor(() => {
+ expect(queryMock).toHaveBeenCalledTimes(expectedQueryCalls);
+ expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes(
+ expectedJumpToMessageFinishedCalls,
+ );
+ expect(setChannelUnreadStateMock).toHaveBeenCalledTimes(
+ expectedSetChannelUnreadStateCalls,
+ );
+ expect(setTargetedMessageIdMock).toHaveBeenCalledTimes(expectedSetTargetedMessageCalls);
+
+ if (expectedTargetedMessageId !== undefined) {
+ expect(setTargetedMessageIdMock).toHaveBeenCalledWith(expectedTargetedMessageId);
+ }
+ });
+ },
+ );
});
});
diff --git a/package/src/components/Channel/hooks/useChannelDataState.ts b/package/src/components/Channel/hooks/useChannelDataState.ts
index 3fb6c8fdaf..f33a1d87e6 100644
--- a/package/src/components/Channel/hooks/useChannelDataState.ts
+++ b/package/src/components/Channel/hooks/useChannelDataState.ts
@@ -219,6 +219,13 @@ export const useChannelDataState = <
}));
}, []);
+ const setRead = useCallback((channel: Channel) => {
+ setState((prev) => ({
+ ...prev,
+ read: { ...channel.state.read }, // Synchronize the read state from the channel
+ }));
+ }, []);
+
const setTyping = useCallback((channel: Channel) => {
setState((prev) => ({
...prev,
@@ -229,6 +236,7 @@ export const useChannelDataState = <
return {
copyStateFromChannel,
initStateFromChannel,
+ setRead,
setTyping,
state,
};
diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts
index 3857d0749d..8b42af61b8 100644
--- a/package/src/components/Channel/hooks/useCreateChannelContext.ts
+++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts
@@ -7,6 +7,7 @@ export const useCreateChannelContext = <
StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
>({
channel,
+ channelUnreadState,
disabled,
EmptyStateIndicator,
enableMessageGroupingByUser,
@@ -15,9 +16,11 @@ export const useCreateChannelContext = <
giphyEnabled,
hideDateSeparators,
hideStickyDateHeader,
+ highlightedMessageId,
isChannelActive,
lastRead,
loadChannelAroundMessage,
+ loadChannelAtFirstUnreadMessage,
loading,
LoadingIndicator,
markRead,
@@ -27,6 +30,7 @@ export const useCreateChannelContext = <
read,
reloadChannel,
scrollToFirstUnreadThreshold,
+ setChannelUnreadState,
setLastRead,
setTargetedMessage,
StickyHeader,
@@ -43,10 +47,12 @@ export const useCreateChannelContext = <
const readUsers = Object.values(read);
const readUsersLength = readUsers.length;
const readUsersLastReads = readUsers.map(({ last_read }) => last_read.toISOString()).join();
+ const stringifiedChannelUnreadState = JSON.stringify(channelUnreadState);
const channelContext: ChannelContextValue = useMemo(
() => ({
channel,
+ channelUnreadState,
disabled,
EmptyStateIndicator,
enableMessageGroupingByUser,
@@ -55,9 +61,11 @@ export const useCreateChannelContext = <
giphyEnabled,
hideDateSeparators,
hideStickyDateHeader,
+ highlightedMessageId,
isChannelActive,
lastRead,
loadChannelAroundMessage,
+ loadChannelAtFirstUnreadMessage,
loading,
LoadingIndicator,
markRead,
@@ -67,6 +75,7 @@ export const useCreateChannelContext = <
read,
reloadChannel,
scrollToFirstUnreadThreshold,
+ setChannelUnreadState,
setLastRead,
setTargetedMessage,
StickyHeader,
@@ -82,11 +91,13 @@ export const useCreateChannelContext = <
disabled,
error,
isChannelActive,
+ highlightedMessageId,
lastReadTime,
loading,
membersLength,
readUsersLength,
readUsersLastReads,
+ stringifiedChannelUnreadState,
targetedMessage,
threadList,
watcherCount,
diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts
index e05a8a2ccd..7849cd85b1 100644
--- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts
+++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts
@@ -36,6 +36,7 @@ export const useCreateMessagesContext = <
handleDelete,
handleEdit,
handleFlag,
+ handleMarkUnread,
handleMute,
handlePinMessage,
handleQuotedReply,
@@ -102,6 +103,7 @@ export const useCreateMessagesContext = <
targetedMessage,
TypingIndicator,
TypingIndicatorContainer,
+ UnreadMessagesNotification,
updateMessage,
UrlPreview,
VideoThumbnail,
@@ -147,6 +149,7 @@ export const useCreateMessagesContext = <
handleDelete,
handleEdit,
handleFlag,
+ handleMarkUnread,
handleMute,
handlePinMessage,
handleQuotedReply,
@@ -213,6 +216,7 @@ export const useCreateMessagesContext = <
targetedMessage,
TypingIndicator,
TypingIndicatorContainer,
+ UnreadMessagesNotification,
updateMessage,
UrlPreview,
VideoThumbnail,
diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx
index 8d0d87dd84..97e97989eb 100644
--- a/package/src/components/Channel/hooks/useMessageListPagination.tsx
+++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx
@@ -1,12 +1,13 @@
import { useRef } from 'react';
import debounce from 'lodash/debounce';
-import { Channel, ChannelState } from 'stream-chat';
+import { Channel, ChannelState, MessageResponse } from 'stream-chat';
import { useChannelMessageDataState } from './useChannelDataState';
import { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext';
import { DefaultStreamChatGenerics } from '../../../types/types';
+import { findInMessagesByDate, findInMessagesById } from '../../../utils/utils';
const defaultDebounceInterval = 500;
const debounceOptions = {
@@ -153,6 +154,8 @@ export const useMessageListPagination = <
setTargetedMessage(messageIdToLoadAround);
}
} catch (error) {
+ setLoadingMore(false);
+ setLoading(false);
console.warn(
'Message pagination(fetching messages in the channel around a message id) request failed with error:',
error,
@@ -162,76 +165,143 @@ export const useMessageListPagination = <
};
/**
- * Loads channel at first unread message.
+ * Fetch messages around a specific timestamp.
*/
- const loadChannelAtFirstUnreadMessage = async ({
- limit = 25,
- setTargetedMessage,
- }: {
- limit?: number;
- setTargetedMessage?: (messageId: string) => void;
- }) => {
- let unreadMessageIdToScrollTo: string | undefined;
- const unreadCount = channel.countUnread();
- if (unreadCount === 0) return;
- const isLatestMessageSetShown = !!channel.state.messageSets.find(
- (set) => set.isCurrent && set.isLatest,
- );
-
- if (isLatestMessageSetShown && unreadCount <= channel.state.messages.length) {
- unreadMessageIdToScrollTo =
- channel.state.messages[channel.state.messages.length - unreadCount].id;
- if (unreadMessageIdToScrollTo) {
- setLoadingMore(true);
- await channel.state.loadMessageIntoState(unreadMessageIdToScrollTo, undefined, limit);
- loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages);
- jumpToMessageFinished(channel.state.messagePagination.hasNext, unreadMessageIdToScrollTo);
- if (setTargetedMessage) {
- setTargetedMessage(unreadMessageIdToScrollTo);
- }
- }
- return;
+ const fetchMessagesAround = async <
+ StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics,
+ >(
+ channel: Channel,
+ timestamp: string,
+ limit: number,
+ ): Promise[]> => {
+ try {
+ const { messages } = await channel.query(
+ { messages: { created_at_around: timestamp, limit } },
+ 'new',
+ );
+ return messages;
+ } catch (error) {
+ console.error('Error fetching messages around timestamp:', error);
+ throw error;
}
- const lastReadDate = channel.lastRead();
- let messages;
- if (lastReadDate) {
+ };
+
+ /**
+ * Loads channel at first unread message.
+ */
+ const loadChannelAtFirstUnreadMessage: ChannelContextValue['loadChannelAtFirstUnreadMessage'] =
+ async ({ channelUnreadState, limit = 25, setChannelUnreadState, setTargetedMessage }) => {
try {
- messages = (
- await channel.query(
- {
- messages: {
- created_at_around: lastReadDate,
- limit: 30,
- },
- watch: true,
- },
- 'new',
- )
- ).messages;
-
- unreadMessageIdToScrollTo = messages.find(
- (m) => lastReadDate < (m.created_at ? new Date(m.created_at) : new Date()),
- )?.id;
- if (unreadMessageIdToScrollTo) {
- setLoadingMore(true);
- await channel.state.loadMessageIntoState(unreadMessageIdToScrollTo, undefined, limit);
- loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages);
- jumpToMessageFinished(channel.state.messagePagination.hasNext, unreadMessageIdToScrollTo);
- if (setTargetedMessage) {
- setTargetedMessage(unreadMessageIdToScrollTo);
+ if (!channelUnreadState?.unread_messages) return;
+ const { first_unread_message_id, last_read, last_read_message_id } = channelUnreadState;
+ let firstUnreadMessageId = first_unread_message_id;
+ let lastReadMessageId = last_read_message_id;
+ let isInCurrentMessageSet = false;
+ const messagesState = channel.state.messages;
+
+ // If the first unread message is already in the current message set, we don't need to load more messages.
+ if (firstUnreadMessageId) {
+ const messageIdx = findInMessagesById(messagesState, firstUnreadMessageId);
+ isInCurrentMessageSet = messageIdx !== -1;
+ }
+ // If the last read message is already in the current message set, we don't need to load more messages, and we set the first unread message id as that is what we want to operate on.
+ else if (lastReadMessageId) {
+ const messageIdx = findInMessagesById(messagesState, lastReadMessageId);
+ isInCurrentMessageSet = messageIdx !== -1;
+ firstUnreadMessageId = messageIdx > -1 ? messagesState[messageIdx + 1]?.id : undefined;
+ } else {
+ const lastReadTimestamp = last_read.getTime();
+ const { index: lastReadIdx, message: lastReadMessage } = findInMessagesByDate(
+ messagesState,
+ last_read,
+ );
+ if (lastReadMessage) {
+ lastReadMessageId = lastReadMessage.id;
+ firstUnreadMessageId = messagesState[lastReadIdx + 1].id;
+ isInCurrentMessageSet = !!firstUnreadMessageId;
+ } else {
+ setLoadingMore(true);
+ setLoading(true);
+ let messages;
+ try {
+ messages = await fetchMessagesAround(channel, last_read.toISOString(), limit);
+ } catch (error) {
+ setLoading(false);
+ loadMoreFinished(channel.state.messagePagination.hasPrev, messagesState);
+ console.log('Loading channel at first unread message failed with error:', error);
+ return;
+ }
+
+ const firstMessageWithCreationDate = messages.find((msg) => msg.created_at);
+ if (!firstMessageWithCreationDate) {
+ loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages);
+ throw new Error('Failed to jump to first unread message id.');
+ }
+ const firstMessageTimestamp = new Date(
+ firstMessageWithCreationDate.created_at as string,
+ ).getTime();
+
+ if (lastReadTimestamp < firstMessageTimestamp) {
+ // whole channel is unread
+ firstUnreadMessageId = firstMessageWithCreationDate.id;
+ } else {
+ const result = findInMessagesByDate(messages, last_read);
+ lastReadMessageId = result.message?.id;
+ }
+ loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages);
}
}
+
+ // If we still don't have the first and last read message id, we can't proceed.
+ if (!firstUnreadMessageId && !lastReadMessageId) {
+ throw new Error('Failed to jump to first unread message id.');
+ }
+
+ // If the first unread message is not in the current message set, we need to load message around the id.
+ if (!isInCurrentMessageSet) {
+ try {
+ setLoadingMore(true);
+ setLoading(true);
+ const targetedMessage = (firstUnreadMessageId || lastReadMessageId) as string;
+ await channel.state.loadMessageIntoState(targetedMessage, undefined, limit);
+ /**
+ * if the index of the last read message on the page is beyond the half of the page,
+ * we have arrived to the oldest page of the channel
+ */
+ const indexOfTarget = channel.state.messages.findIndex(
+ (message) => message.id === targetedMessage,
+ );
+
+ loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages);
+ firstUnreadMessageId =
+ firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1].id;
+ } catch (error) {
+ setLoading(false);
+ loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages);
+ console.log('Loading channel at first unread message failed with error:', error);
+ return;
+ }
+ }
+
+ if (!firstUnreadMessageId) {
+ throw new Error('Failed to jump to first unread message id.');
+ }
+ if (!first_unread_message_id && setChannelUnreadState) {
+ setChannelUnreadState({
+ ...channelUnreadState,
+ first_unread_message_id: firstUnreadMessageId,
+ last_read_message_id: lastReadMessageId,
+ });
+ }
+
+ jumpToMessageFinished(channel.state.messagePagination.hasNext, firstUnreadMessageId);
+ if (setTargetedMessage) {
+ setTargetedMessage(firstUnreadMessageId);
+ }
} catch (error) {
- console.warn(
- 'Message pagination(fetching messages in the channel around unread message) request failed with error:',
- error,
- );
- return;
+ console.log('Loading channel at first unread message failed with error:', error);
}
- } else {
- await loadLatestMessages();
- }
- };
+ };
return {
copyMessagesStateFromChannel,
diff --git a/package/src/components/Channel/hooks/useTargetedMessage.ts b/package/src/components/Channel/hooks/useTargetedMessage.ts
index 1559ccf4d3..8aa84945d7 100644
--- a/package/src/components/Channel/hooks/useTargetedMessage.ts
+++ b/package/src/components/Channel/hooks/useTargetedMessage.ts
@@ -2,16 +2,21 @@ import { useEffect, useRef, useState } from 'react';
export const useTargetedMessage = (messageId?: string) => {
const clearTargetedMessageCall = useRef>();
- const [targetedMessage, setTargetedMessage] = useState(messageId);
+ const [targetedMessage, setTargetedMessage] = useState(messageId);
+ const [highlightedMessageId, setHighlightedMessageId] = useState();
const prevTargetedMessageRef = useRef();
useEffect(() => {
prevTargetedMessageRef.current = targetedMessage;
+ if (targetedMessage) {
+ setHighlightedMessageId(targetedMessage);
+ }
}, [targetedMessage]);
useEffect(() => {
clearTargetedMessageCall.current = setTimeout(() => {
setTargetedMessage(undefined);
+ setHighlightedMessageId(undefined);
}, 3000);
return () => {
@@ -19,17 +24,19 @@ export const useTargetedMessage = (messageId?: string) => {
};
}, []);
- const setTargetedMessageTimeoutRef = useRef((messageId: string) => {
+ const setTargetedMessageTimeoutRef = useRef((messageId: string | undefined) => {
clearTargetedMessageCall.current && clearTimeout(clearTargetedMessageCall.current);
clearTargetedMessageCall.current = setTimeout(() => {
setTargetedMessage(undefined);
+ setHighlightedMessageId(undefined);
}, 3000);
setTargetedMessage(messageId);
});
return {
+ highlightedMessageId,
prevTargetedMessage: prevTargetedMessageRef.current,
setTargetedMessage: setTargetedMessageTimeoutRef.current,
targetedMessage,
diff --git a/package/src/components/Chat/hooks/handleEventToSyncDB.ts b/package/src/components/Chat/hooks/handleEventToSyncDB.ts
index 6ca71eade6..c8c0b774e8 100644
--- a/package/src/components/Chat/hooks/handleEventToSyncDB.ts
+++ b/package/src/components/Chat/hooks/handleEventToSyncDB.ts
@@ -77,7 +77,7 @@ export const handleEventToSyncDB = async <
return createQueries(flush);
};
- if (type === 'message.read') {
+ if (type === 'message.read' || type === 'notification.mark_read') {
const cid = event.cid;
const user = event.user;
if (user?.id && cid) {
@@ -88,6 +88,7 @@ export const handleEventToSyncDB = async <
reads: [
{
last_read: event.received_at as string,
+ last_read_message_id: event.last_read_message_id,
unread_messages: 0,
user,
},
@@ -97,6 +98,27 @@ export const handleEventToSyncDB = async <
}
}
+ if (type === 'notification.mark_unread') {
+ const cid = event.cid;
+ const user = event.user;
+ if (user?.id && cid) {
+ return await queriesWithChannelGuard((flushOverride) =>
+ upsertReads({
+ cid,
+ flush: flushOverride,
+ reads: [
+ {
+ last_read: event.received_at as string,
+ last_read_message_id: event.last_read_message_id,
+ unread_messages: event.unread_messages,
+ user,
+ },
+ ],
+ }),
+ );
+ }
+ }
+
if (type === 'message.new') {
const message = event.message;
diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx
index 11e798ad7f..3a3b6c11f3 100644
--- a/package/src/components/Message/Message.tsx
+++ b/package/src/components/Message/Message.tsx
@@ -110,6 +110,7 @@ export type MessageActionHandlers<
deleteMessage: () => void;
editMessage: () => void;
flagMessage: () => void;
+ markUnread: () => Promise;
pinMessage: () => Promise;
quotedReply: () => void;
resendMessage: () => Promise;
@@ -145,6 +146,7 @@ export type MessagePropsWithContext<
| 'handleDelete'
| 'handleEdit'
| 'handleFlag'
+ | 'handleMarkUnread'
| 'handleMute'
| 'handlePinMessage'
| 'handleQuotedReply'
@@ -223,6 +225,7 @@ const MessageWithContext = <
handleDelete,
handleEdit,
handleFlag,
+ handleMarkUnread,
handleMute,
handlePinMessage,
handleQuotedReply,
@@ -472,6 +475,7 @@ const MessageWithContext = <
handleDeleteMessage,
handleEditMessage,
handleFlagMessage,
+ handleMarkUnreadMessage,
handleQuotedReplyMessage,
handleResendMessage,
handleToggleBanUser,
@@ -499,6 +503,7 @@ const MessageWithContext = <
editMessage,
flagMessage,
handleReaction,
+ markUnread,
muteUser,
pinMessage,
quotedReply,
@@ -517,6 +522,7 @@ const MessageWithContext = <
handleDelete,
handleEdit,
handleFlag,
+ handleMarkUnread,
handleMute,
handlePinMessage,
handleQuotedReply,
@@ -552,6 +558,7 @@ const MessageWithContext = <
flagMessage,
isMyMessage,
isThreadMessage,
+ markUnread,
message,
muteUser,
ownCapabilities,
@@ -568,6 +575,7 @@ const MessageWithContext = <
deleteMessage: handleDeleteMessage,
editMessage: handleEditMessage,
flagMessage: handleFlagMessage,
+ markUnread: handleMarkUnreadMessage,
pinMessage: handleTogglePinMessage,
quotedReply: handleQuotedReplyMessage,
resendMessage: handleResendMessage,
diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts
index 1de1bc8939..b05cd4e79f 100644
--- a/package/src/components/Message/hooks/useMessageActionHandlers.ts
+++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts
@@ -48,27 +48,27 @@ export const useMessageActionHandlers = <
);
const handleCopyMessage = () => {
- setClipboardString(message.text || '');
+ if (!message.text) return;
+ setClipboardString(message.text);
};
const handleDeleteMessage = () => {
- if (message.id) {
- Alert.alert(
- t('Delete Message'),
- t('Are you sure you want to permanently delete this message?'),
- [
- { style: 'cancel', text: t('Cancel') },
- {
- onPress: async () => {
- await deleteMessage(message as MessageResponse);
- },
- style: 'destructive',
- text: t('Delete'),
+ if (!message.id) return;
+ Alert.alert(
+ t('Delete Message'),
+ t('Are you sure you want to permanently delete this message?'),
+ [
+ { style: 'cancel', text: t('Cancel') },
+ {
+ onPress: async () => {
+ await deleteMessage(message as MessageResponse);
},
- ],
- { cancelable: false },
- );
- }
+ style: 'destructive',
+ text: t('Delete'),
+ },
+ ],
+ { cancelable: false },
+ );
};
const handleToggleMuteUser = async () => {
@@ -110,31 +110,44 @@ export const useMessageActionHandlers = <
};
const handleFlagMessage = () => {
+ if (!message.id) return;
+ Alert.alert(
+ t('Flag Message'),
+ t('Do you want to send a copy of this message to a moderator for further investigation?'),
+ [
+ { style: 'cancel', text: t('Cancel') },
+ {
+ onPress: async () => {
+ try {
+ await client.flagMessage(message.id);
+ Alert.alert(t('Message flagged'), t('The message has been reported to a moderator.'));
+ } catch (error) {
+ console.log('Error flagging message:', error);
+ Alert.alert(
+ t('Cannot Flag Message'),
+ t(
+ 'Flag action failed either due to a network issue or the message is already flagged',
+ ),
+ );
+ }
+ },
+ text: t('Flag'),
+ },
+ ],
+ { cancelable: false },
+ );
+ };
+
+ const handleMarkUnreadMessage = async () => {
+ if (!message.id) return;
try {
- if (message.id) {
- Alert.alert(
- t('Flag Message'),
- t('Do you want to send a copy of this message to a moderator for further investigation?'),
- [
- { style: 'cancel', text: t('Cancel') },
- {
- onPress: async () => {
- await client.flagMessage(message.id);
- Alert.alert(
- t('Message flagged'),
- t('The message has been reported to a moderator.'),
- );
- },
- text: t('Flag'),
- },
- ],
- { cancelable: false },
- );
- }
- } catch (_) {
+ await channel.markUnread({ message_id: message.id });
+ } catch (error) {
+ console.log('Error marking message as unread:', error);
Alert.alert(
- t('Cannot Flag Message'),
- t('Flag action failed either due to a network issue or the message is already flagged'),
+ t(
+ 'Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.',
+ ),
);
}
};
@@ -173,6 +186,7 @@ export const useMessageActionHandlers = <
handleDeleteMessage,
handleEditMessage,
handleFlagMessage,
+ handleMarkUnreadMessage,
handleQuotedReplyMessage,
handleResendMessage,
handleToggleBanUser,
diff --git a/package/src/components/Message/hooks/useMessageActions.tsx b/package/src/components/Message/hooks/useMessageActions.tsx
index 03871b4147..a70b0c6030 100644
--- a/package/src/components/Message/hooks/useMessageActions.tsx
+++ b/package/src/components/Message/hooks/useMessageActions.tsx
@@ -20,6 +20,7 @@ import {
Resend,
ThreadReply,
Unpin,
+ UnreadIndicator,
UserDelete,
} from '../../../icons';
import type { DefaultStreamChatGenerics } from '../../../types/types';
@@ -41,6 +42,7 @@ export type MessageActionsHookProps<
| 'handleEdit'
| 'handleFlag'
| 'handleQuotedReply'
+ | 'handleMarkUnread'
| 'handleMute'
| 'handlePinMessage'
| 'handleRetry'
@@ -77,6 +79,7 @@ export const useMessageActions = <
handleDelete,
handleEdit,
handleFlag,
+ handleMarkUnread,
handleMute,
handlePinMessage,
handleQuotedReply,
@@ -104,6 +107,7 @@ export const useMessageActions = <
handleDeleteMessage,
handleEditMessage,
handleFlagMessage,
+ handleMarkUnreadMessage,
handleQuotedReplyMessage,
handleResendMessage,
handleToggleBanUser,
@@ -195,6 +199,32 @@ export const useMessageActions = <
title: t('Edit Message'),
};
+ const flagMessage: MessageActionType = {
+ action: () => {
+ dismissOverlay();
+ if (handleFlag) {
+ handleFlag(message);
+ }
+ handleFlagMessage();
+ },
+ actionType: 'flagMessage',
+ icon: ,
+ title: t('Flag Message'),
+ };
+
+ const markUnread: MessageActionType = {
+ action: () => {
+ dismissOverlay();
+ if (handleMarkUnread) {
+ handleMarkUnread(message);
+ }
+ handleMarkUnreadMessage();
+ },
+ actionType: 'markUnread',
+ icon: ,
+ title: t('Mark as Unread'),
+ };
+
const pinMessage: MessageActionType = {
action: () => {
dismissOverlay();
@@ -221,20 +251,6 @@ export const useMessageActions = <
title: t('Unpin from Conversation'),
};
- const flagMessage: MessageActionType = {
- action: () => {
- dismissOverlay();
- if (handleFlag) {
- handleFlag(message);
- }
-
- handleFlagMessage();
- },
- actionType: 'flagMessage',
- icon: ,
- title: t('Flag Message'),
- };
-
const handleReaction = !error
? selectReaction
? selectReaction(message)
@@ -311,6 +327,7 @@ export const useMessageActions = <
editMessage,
flagMessage,
handleReaction,
+ markUnread,
muteUser,
pinMessage,
quotedReply,
diff --git a/package/src/components/Message/utils/messageActions.ts b/package/src/components/Message/utils/messageActions.ts
index b17693c351..dc4696e4b4 100644
--- a/package/src/components/Message/utils/messageActions.ts
+++ b/package/src/components/Message/utils/messageActions.ts
@@ -15,6 +15,7 @@ export type MessageActionsParams<
error: boolean | Error;
flagMessage: MessageActionType;
isThreadMessage: boolean;
+ markUnread: MessageActionType;
muteUser: MessageActionType;
ownCapabilities: OwnCapabilitiesContextValue;
pinMessage: MessageActionType;
@@ -43,6 +44,7 @@ export const messageActions = <
flagMessage,
isMyMessage,
isThreadMessage,
+ markUnread,
message,
ownCapabilities,
pinMessage,
@@ -77,6 +79,10 @@ export const messageActions = <
actions.push(editMessage);
}
+ if (ownCapabilities.readEvents && !error && !isThreadMessage) {
+ actions.push(markUnread);
+ }
+
if (isClipboardAvailable() && message.text && !error) {
actions.push(copyMessage);
}
diff --git a/package/src/components/MessageList/InlineUnreadIndicator.tsx b/package/src/components/MessageList/InlineUnreadIndicator.tsx
index 67826e9d0e..fc93cdaa1b 100644
--- a/package/src/components/MessageList/InlineUnreadIndicator.tsx
+++ b/package/src/components/MessageList/InlineUnreadIndicator.tsx
@@ -1,47 +1,38 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
-import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg';
import { useTheme } from '../../contexts/themeContext/ThemeContext';
import { useTranslationContext } from '../../contexts/translationContext/TranslationContext';
-import { useViewport } from '../../hooks/useViewport';
-
-const styles = StyleSheet.create({
- container: {
- alignItems: 'center',
- justifyContent: 'center',
- padding: 10,
- width: '100%',
- },
- text: {
- fontSize: 12,
- },
-});
export const InlineUnreadIndicator = () => {
const {
theme: {
- colors: { bg_gradient_end, bg_gradient_start, grey },
+ colors: { grey, light_gray },
messageList: {
inlineUnreadIndicator: { container, text },
},
},
} = useTheme();
const { t } = useTranslationContext();
- const { vw } = useViewport();
return (
-
-
+
{t('Unread Messages')}
);
};
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 2,
+ padding: 10,
+ },
+ text: {
+ fontSize: 12,
+ },
+});
diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx
index fa4325cefb..96dfb38e35 100644
--- a/package/src/components/MessageList/MessageList.tsx
+++ b/package/src/components/MessageList/MessageList.tsx
@@ -6,6 +6,7 @@ import {
ScrollViewProps,
StyleSheet,
View,
+ ViewabilityConfig,
ViewToken,
} from 'react-native';
@@ -54,7 +55,9 @@ import { ThreadContextValue, useThreadContext } from '../../contexts/threadConte
import { DefaultStreamChatGenerics, FileTypes } from '../../types/types';
-const WAIT_FOR_SCROLL_TO_OFFSET_TIMEOUT = 150;
+// This is just to make sure that the scrolling happens in a different task queue.
+// TODO: Think if we really need this and strive to remove it if we can.
+const WAIT_FOR_SCROLL_TIMEOUT = 0;
const MAX_RETRIES_AFTER_SCROLL_FAILURE = 10;
const styles = StyleSheet.create({
container: {
@@ -63,7 +66,6 @@ const styles = StyleSheet.create({
width: '100%',
},
contentContainer: {
- flexGrow: 1,
/**
* paddingBottom is set to 4 to account for the default date
* header and inline indicator alignment. The top margin is 8
@@ -99,7 +101,7 @@ const keyExtractor = <
return Date.now().toString();
};
-const flatListViewabilityConfig = {
+const flatListViewabilityConfig: ViewabilityConfig = {
viewAreaCoveragePercentThreshold: 1,
};
@@ -109,9 +111,11 @@ type MessageListPropsWithContext<
Pick<
ChannelContextValue,
| 'channel'
+ | 'channelUnreadState'
| 'disabled'
| 'EmptyStateIndicator'
| 'hideStickyDateHeader'
+ | 'highlightedMessageId'
| 'loadChannelAroundMessage'
| 'loading'
| 'LoadingIndicator'
@@ -133,7 +137,6 @@ type MessageListPropsWithContext<
| 'DateHeader'
| 'disableTypingIndicator'
| 'FlatList'
- | 'initialScrollToFirstUnreadMessage'
| 'InlineDateSeparator'
| 'InlineUnreadIndicator'
| 'legacyImageViewerSwipeBehaviour'
@@ -144,6 +147,7 @@ type MessageListPropsWithContext<
| 'shouldShowUnreadUnderlay'
| 'TypingIndicator'
| 'TypingIndicatorContainer'
+ | 'UnreadMessagesNotification'
> &
Pick<
ThreadContextValue,
@@ -228,6 +232,7 @@ const MessageListWithContext = <
const {
additionalFlatListProps,
channel,
+ channelUnreadState,
client,
closePicker,
DateHeader,
@@ -238,7 +243,7 @@ const MessageListWithContext = <
FooterComponent = InlineLoadingMoreIndicator,
HeaderComponent = LoadingMoreRecentIndicator,
hideStickyDateHeader,
- initialScrollToFirstUnreadMessage,
+ highlightedMessageId,
InlineDateSeparator,
InlineUnreadIndicator,
inverted = true,
@@ -275,8 +280,9 @@ const MessageListWithContext = <
threadList = false,
TypingIndicator,
TypingIndicatorContainer,
+ UnreadMessagesNotification,
} = props;
-
+ const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false);
const { theme } = useTheme();
const {
@@ -333,12 +339,6 @@ const MessageListWithContext = <
const flatListRef = useRef> | null>(null);
- /**
- * Flag to track if the initial scroll has been set
- * If the prop `initialScrollToFirstUnreadMessage` was enabled, then we scroll to the unread msg and set it to true
- * If not, the default offset of 0 for flatList means that it has been set already
- */
- const [isInitialScrollDone, setInitialScrollDone] = useState(!initialScrollToFirstUnreadMessage);
const channelResyncScrollSet = useRef(true);
/**
@@ -346,11 +346,6 @@ const MessageListWithContext = <
*/
const scrollToDebounceTimeoutRef = useRef>();
- /**
- * The timeout id used to lazier load the initial scroll set flag
- */
- const initialScrollSettingTimeoutRef = useRef>();
-
/**
* The timeout id used to temporarily load the initial scroll set flag
*/
@@ -375,11 +370,18 @@ const MessageListWithContext = <
channelRef.current = channel;
const updateStickyHeaderDateIfNeeded = (viewableItems: ViewToken[]) => {
- if (viewableItems.length) {
- const lastItem = viewableItems.pop() as {
- item: MessageType;
- };
+ if (!viewableItems.length) return;
+
+ const lastItem = viewableItems[viewableItems.length - 1];
+ if (lastItem) {
+ if (
+ !channel.state.messagePagination.hasPrev &&
+ processedMessageList[processedMessageList.length - 1].id === lastItem.item.id
+ ) {
+ setStickyHeaderDate(undefined);
+ return;
+ }
const isMessageTypeDeleted = lastItem.item.type === 'deleted';
if (
@@ -395,32 +397,60 @@ const MessageListWithContext = <
};
/**
- * FlatList doesn't accept changeable function for onViewableItemsChanged prop.
- * Thus useRef.
+ * This function should show or hide the unread indicator depending on the
*/
- const onViewableItemsChanged = useRef(
- ({ viewableItems }: { viewableItems: ViewToken[] | undefined }) => {
- /**
- * When a new message comes in, list scrolls down to the bottom automatically (using prop `maintainVisibleContentPosition`)
- * and we mark the channel as read from handleScroll function.
- * Although this logic is dependent on the fact that `onScroll` event gets triggered during this process.
- * But for Android, this event is not triggered when messages length is lesser than visible screen height.
- *
- * And thus we need to check if the message list length is lesser than visible screen height and mark the channel as read.
- */
+ const updateStickyUnreadIndicator = (viewableItems: ViewToken[]) => {
+ if (!viewableItems.length) {
+ setIsUnreadNotificationOpen(false);
+ return;
+ }
+
+ if (selectedPicker === 'images') {
+ setIsUnreadNotificationOpen(false);
+ return;
+ }
+
+ const lastItem = viewableItems[viewableItems.length - 1];
+
+ if (lastItem) {
+ const lastItemCreatedAt = lastItem.item.created_at;
+
+ const unreadIndicatorDate = channelUnreadState?.last_read.getTime();
+ const lastItemDate = lastItemCreatedAt.getTime();
+
if (
- Platform.OS === 'android' &&
- viewableItems?.length &&
- viewableItems?.length >= messageListLengthBeforeUpdate.current
+ !channel.state.messagePagination.hasPrev &&
+ processedMessageList[processedMessageList.length - 1].id === lastItem.item.id
) {
- channel.markRead();
+ setIsUnreadNotificationOpen(false);
+ return;
}
- if (viewableItems && !hideStickyDateHeader) {
- updateStickyHeaderDateIfNeeded(viewableItems);
+ if (unreadIndicatorDate && lastItemDate > unreadIndicatorDate) {
+ setIsUnreadNotificationOpen(true);
+ } else {
+ setIsUnreadNotificationOpen(false);
}
- },
- );
+ }
+ };
+
+ /**
+ * FlatList doesn't accept changeable function for onViewableItemsChanged prop.
+ * Thus useRef.
+ */
+ const onViewableItemsChanged = ({
+ viewableItems,
+ }: {
+ viewableItems: ViewToken[] | undefined;
+ }) => {
+ if (!viewableItems) {
+ return;
+ }
+ if (!hideStickyDateHeader) {
+ updateStickyHeaderDateIfNeeded(viewableItems);
+ }
+ updateStickyUnreadIndicator(viewableItems);
+ };
/**
* Resets the pagination trackers, doing so cancels currently scheduled loading more calls
@@ -440,40 +470,19 @@ const MessageListWithContext = <
* Effect to mark the channel as read when the user scrolls to the bottom of the message list.
*/
useEffect(() => {
- const getShouldMarkReadAutomatically = (): boolean => {
- if (loading || !channel) {
- // nothing to do
- return false;
- } else if (channel.countUnread() > 0) {
- if (!initialScrollToFirstUnreadMessage) {
- /*
- * In this case MessageList won't scroll to first unread message when opened, so we can mark
- * the channel as read right after opening.
- * */
- return true;
- } else {
- /*
- * In this case MessageList will be opened to first unread message.
- * But if there are were not enough unread messages, so that scrollToBottom button was not shown
- * then MessageList won't need to scroll up. So we can safely mark the channel as read right after opening.
- *
- * NOTE: we must ensure that initial scroll is done, otherwise we do not wait till the unread scroll is finished
- * */
- if (scrollToBottomButtonVisible) return false;
- /* if scrollToBottom button was not visible, wait till
- * - initial scroll is done (indicates that if scrolling to index was needed it was triggered)
- * */
- return isInitialScrollDone;
- }
+ const listener: ReturnType = channel.on('message.new', (event) => {
+ const newMessageToCurrentChannel = event.cid === channel.cid;
+ const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel;
+
+ if (newMessageToCurrentChannel && mainChannelUpdated && !scrollToBottomButtonVisible) {
+ markRead();
}
- return false;
- };
+ });
- if (getShouldMarkReadAutomatically()) {
- markRead();
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [loading, scrollToBottomButtonVisible, isInitialScrollDone]);
+ return () => {
+ listener?.unsubscribe();
+ };
+ }, [channel, markRead, scrollToBottomButtonVisible]);
useEffect(() => {
const lastReceivedMessage = getLastReceivedMessage(processedMessageList);
@@ -487,6 +496,7 @@ const MessageListWithContext = <
if (!client || !channel || rawMessageList.length === 0) {
return;
}
+
/**
* Condition to check if a message is removed from MessageList.
* Eg: This would happen when giphy search is cancelled, etc.
@@ -508,7 +518,7 @@ const MessageListWithContext = <
flatListRef.current?.scrollToOffset({
offset: 0,
});
- }, 50);
+ }, WAIT_FOR_SCROLL_TIMEOUT);
setTimeout(() => {
channelResyncScrollSet.current = true;
if (channel.countUnread() > 0) {
@@ -518,10 +528,9 @@ const MessageListWithContext = <
}
};
+ // TODO: Think about if this is really needed?
if (threadList) {
scrollToBottomIfNeeded();
- } else {
- setScrollToBottomButtonVisible(false);
}
messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate;
@@ -566,12 +575,74 @@ const MessageListWithContext = <
animated: true,
offset: 0,
});
- }, 150); // flatlist might take a bit to update, so a small delay is needed
+ }, WAIT_FOR_SCROLL_TIMEOUT); // flatlist might take a bit to update, so a small delay is needed
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [rawMessageList, threadList]);
+ const goToMessage = async (messageId: string) => {
+ const indexOfParentInMessageList = processedMessageList.findIndex(
+ (message) => message?.id === messageId,
+ );
+ try {
+ if (indexOfParentInMessageList === -1) {
+ await loadChannelAroundMessage({ messageId });
+ return;
+ } else {
+ if (!flatListRef.current) return;
+ clearTimeout(failScrollTimeoutId.current);
+ scrollToIndexFailedRetryCountRef.current = 0;
+ // keep track of this messageId, so that we dont scroll to again in useEffect for targeted message change
+ messageIdLastScrolledToRef.current = messageId;
+ setTargetedMessage(messageId);
+ // now scroll to it with animated=true
+ flatListRef.current.scrollToIndex({
+ animated: true,
+ index: indexOfParentInMessageList,
+ viewPosition: 0.5, // try to place message in the center of the screen
+ });
+ return;
+ }
+ } catch (e) {
+ console.warn('Error while scrolling to message', e);
+ }
+ };
+
+ /**
+ * Check if a messageId needs to be scrolled to after list loads, and scroll to it
+ * Note: This effect fires on every list change with a small debounce so that scrolling isnt abrupted by an immediate rerender
+ */
+ useEffect(() => {
+ if (!targetedMessage) return;
+ scrollToDebounceTimeoutRef.current = setTimeout(async () => {
+ const indexOfParentInMessageList = processedMessageList.findIndex(
+ (message) => message?.id === targetedMessage,
+ );
+
+ // the message we want to scroll to has not been loaded in the state yet
+ if (indexOfParentInMessageList === -1) {
+ await loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage });
+ } else {
+ if (!flatListRef.current) return;
+ // By a fresh scroll we should clear the retries for the previous failed scroll
+ clearTimeout(scrollToDebounceTimeoutRef.current);
+ clearTimeout(failScrollTimeoutId.current);
+ // reset the retry count
+ scrollToIndexFailedRetryCountRef.current = 0;
+ // now scroll to it
+ flatListRef.current.scrollToIndex({
+ animated: true,
+ index: indexOfParentInMessageList,
+ viewPosition: 0.5, // try to place message in the center of the screen
+ });
+ setTargetedMessage(undefined);
+ }
+ }, WAIT_FOR_SCROLL_TIMEOUT);
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [targetedMessage]);
+
// TODO: do not apply on RN 0.73 and above
const shouldApplyAndroidWorkaround = inverted && Platform.OS === 'android';
@@ -585,53 +656,20 @@ const MessageListWithContext = <
if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode))
return null;
- const unreadCount = channel.countUnread();
- const lastRead = channel.lastRead();
-
- function isMessageUnread(messageArrayIndex: number): boolean {
- const isLatestMessageSetShown = !!channel.state.messageSets.find(
- (set) => set.isCurrent && set.isLatest,
- );
+ const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime();
+ const lastReadTimestamp = channelUnreadState?.last_read.getTime();
+ const isNewestMessage = index === 0;
+ const isLastReadMessage =
+ channelUnreadState?.last_read_message_id === message.id ||
+ (!channelUnreadState?.unread_messages && createdAtTimestamp === lastReadTimestamp);
- if (!isLatestMessageSetShown) {
- const msg = processedMessageList?.[messageArrayIndex];
- if (
- channel.state.latestMessages.length !== 0 &&
- unreadCount > channel.state.latestMessages.length
- ) {
- return messageArrayIndex <= unreadCount - channel.state.latestMessages.length - 1;
- }
- // The `msg` can be undefined here, since `messageArrayIndex` can be out of bounds hence we add a check for `msg`.
- else if (lastRead && msg?.created_at) {
- return lastRead < msg.created_at;
- }
- return false;
- } else {
- return messageArrayIndex <= unreadCount - 1;
- }
- }
+ const showUnreadSeparator =
+ isLastReadMessage &&
+ !isNewestMessage &&
+ // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label
+ (!!channelUnreadState?.first_unread_message_id || !!channelUnreadState?.unread_messages);
- const isCurrentMessageUnread = isMessageUnread(index);
- const showUnreadUnderlay =
- !!shouldShowUnreadUnderlay &&
- !channel.muteStatus().muted &&
- isCurrentMessageUnread &&
- scrollToBottomButtonVisible;
- const insertInlineUnreadIndicator = showUnreadUnderlay && !isMessageUnread(index + 1); // show only if previous message is read
-
- if (message.type === 'system') {
- return (
-
-
-
-
- {insertInlineUnreadIndicator && }
-
- );
- }
+ const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator;
const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme;
const renderDateSeperator = isMessageWithStylesReadByAndDateSeparator(message) &&
@@ -640,7 +678,7 @@ const MessageListWithContext = <
);
+
return (
- <>
- {wrapMessageInTheme ? (
+
+ {message.type === 'system' ? (
+
+ ) : wrapMessageInTheme ? (
-
- {shouldApplyAndroidWorkaround && renderDateSeperator}
+
+ {renderDateSeperator}
{renderMessage}
) : (
-
- {shouldApplyAndroidWorkaround && renderDateSeperator}
+
+ {renderDateSeperator}
{renderMessage}
)}
- {!shouldApplyAndroidWorkaround && renderDateSeperator}
- {/* Adding indicator below the messages, since the list is inverted */}
-
- {insertInlineUnreadIndicator && }
-
- >
+ {showUnreadUnderlay && }
+
);
};
@@ -803,8 +840,8 @@ const MessageListWithContext = <
};
const handleScroll: ScrollViewProps['onScroll'] = (event) => {
- const offset = event.nativeEvent.contentOffset.y;
const messageListHasMessages = processedMessageList.length > 0;
+ const offset = event.nativeEvent.contentOffset.y;
// Show scrollToBottom button once scroll position goes beyond 150.
const isScrollAtBottom = offset <= 150;
@@ -821,14 +858,6 @@ const MessageListWithContext = <
*/
setScrollToBottomButtonVisible(showScrollToBottomButton);
- const shouldMarkRead = !threadList && !notLatestSet && offset <= 0 && channel.countUnread() > 0;
-
- if (shouldMarkRead) {
- markRead();
- }
-
- setInitialScrollDone(false);
-
if (onListScroll) {
onListScroll(event);
}
@@ -842,14 +871,12 @@ const MessageListWithContext = <
await reloadChannel();
} else if (flatListRef.current) {
flatListRef.current.scrollToOffset({
+ animated: true,
offset: 0,
});
}
setScrollToBottomButtonVisible(false);
- if (!threadList) {
- markRead();
- }
};
const scrollToIndexFailedRetryCountRef = useRef(0);
@@ -860,16 +887,12 @@ const MessageListWithContext = <
// We got a failure as we tried to scroll to an item that was outside the render length
if (!flatListRef.current) return;
// we don't know the actual size of all items but we can see the average, so scroll to the closest offset
- flatListRef.current.scrollToOffset({
- animated: false,
- offset: info.averageItemLength * info.index,
- });
// since we used only an average offset... we won't go to the center of the item yet
// with a little delay to wait for scroll to offset to complete, we can then scroll to the index
failScrollTimeoutId.current = setTimeout(() => {
try {
flatListRef.current?.scrollToIndex({
- animated: false,
+ animated: true,
index: info.index,
viewPosition: 0.5, // try to place message in the center of the screen
});
@@ -894,81 +917,12 @@ const MessageListWithContext = <
scrollToIndexFailedRetryCountRef.current += 1;
onScrollToIndexFailedRef.current(info);
}
- }, WAIT_FOR_SCROLL_TO_OFFSET_TIMEOUT);
+ }, WAIT_FOR_SCROLL_TIMEOUT);
// Only when index is greater than 0 and in range of items in FlatList
// this onScrollToIndexFailed will be called again
});
- const goToMessage = async (messageId: string) => {
- const indexOfParentInMessageList = processedMessageList.findIndex(
- (message) => message?.id === messageId,
- );
- if (indexOfParentInMessageList !== -1 && flatListRef.current) {
- clearTimeout(failScrollTimeoutId.current);
- scrollToIndexFailedRetryCountRef.current = 0;
- // keep track of this messageId, so that we dont scroll to again in useEffect for targeted message change
- messageIdLastScrolledToRef.current = messageId;
- setTargetedMessage(messageId);
- // now scroll to it with animated=true (in useEffect animated=false is used)
- flatListRef.current.scrollToIndex({
- animated: true,
- index: indexOfParentInMessageList,
- viewPosition: 0.5, // try to place message in the center of the screen
- });
- return;
- }
- // the message we want was not loaded yet, so lets load it
- await loadChannelAroundMessage({ messageId });
- };
-
- /**
- * Check if a messageId needs to be scrolled to after list loads, and scroll to it
- * Note: This effect fires on every list change with a small debounce so that scrolling isnt abrupted by an immediate rerender
- */
- useEffect(() => {
- scrollToDebounceTimeoutRef.current = setTimeout(() => {
- if (initialScrollToFirstUnreadMessage) {
- clearTimeout(initialScrollSettingTimeoutRef.current);
- initialScrollSettingTimeoutRef.current = setTimeout(() => {
- // small timeout to ensure that handleScroll is called after scrollToIndex to set this flag
- setInitialScrollDone(true);
- }, 2000);
- }
- let messageIdToScroll: string | undefined;
- if (targetedMessage && messageIdLastScrolledToRef.current !== targetedMessage) {
- // if some messageId was targeted but not scrolledTo yet
- // we have scroll to there after loading completes
- messageIdToScroll = targetedMessage;
- }
- if (!messageIdToScroll) return;
- const indexOfParentInMessageList = processedMessageList.findIndex(
- (message) => message?.id === messageIdToScroll,
- );
- if (indexOfParentInMessageList !== -1 && flatListRef.current) {
- // By a fresh scroll we should clear the retries for the previous failed scroll
- clearTimeout(scrollToDebounceTimeoutRef.current);
- clearTimeout(failScrollTimeoutId.current);
- // keep track of this messageId, so that we dont scroll to again for targeted message change
- messageIdLastScrolledToRef.current = messageIdToScroll;
- // reset the retry count
- scrollToIndexFailedRetryCountRef.current = 0;
- // now scroll to it
- flatListRef.current.scrollToIndex({
- animated: false,
- index: indexOfParentInMessageList,
- viewPosition: 0.5, // try to place message in the center of the screen
- });
- }
-
- // the message we want to scroll to has not been loaded in the state yet
- if (indexOfParentInMessageList === -1) {
- loadChannelAroundMessage({ messageId: messageIdToScroll });
- }
- }, 50);
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [targetedMessage, initialScrollToFirstUnreadMessage]);
-
const messagesWithImages =
legacyImageViewerSwipeBehaviour &&
processedMessageList.filter((message) => {
@@ -1047,6 +1001,11 @@ const MessageListWithContext = <
}
};
+ const onUnreadNotificationClose = async () => {
+ await markRead();
+ setIsUnreadNotificationOpen(false);
+ };
+
const debugRef = useDebugContext();
const isDebugModeEnabled = __DEV__ && debugRef && debugRef.current;
@@ -1155,7 +1114,7 @@ const MessageListWithContext = <
onScrollEndDrag={onScrollEndDrag}
onScrollToIndexFailed={onScrollToIndexFailedRef.current}
onTouchEnd={dismissImagePicker}
- onViewableItemsChanged={onViewableItemsChanged.current}
+ onViewableItemsChanged={onViewableItemsChanged}
ref={refCallback}
renderItem={renderItem}
scrollEnabled={overlay === 'none'}
@@ -1187,6 +1146,9 @@ const MessageListWithContext = <
unreadCount={threadList ? 0 : channel?.countUnread()}
/>
+ {isUnreadNotificationOpen && !threadList ? (
+
+ ) : null}
);
};
@@ -1203,11 +1165,13 @@ export const MessageList = <
const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext();
const {
channel,
+ channelUnreadState,
disabled,
EmptyStateIndicator,
enableMessageGroupingByUser,
error,
hideStickyDateHeader,
+ highlightedMessageId,
isChannelActive,
loadChannelAroundMessage,
loading,
@@ -1227,7 +1191,6 @@ export const MessageList = <
DateHeader,
disableTypingIndicator,
FlatList,
- initialScrollToFirstUnreadMessage,
InlineDateSeparator,
InlineUnreadIndicator,
legacyImageViewerSwipeBehaviour,
@@ -1238,6 +1201,7 @@ export const MessageList = <
shouldShowUnreadUnderlay,
TypingIndicator,
TypingIndicatorContainer,
+ UnreadMessagesNotification,
} = useMessagesContext();
const { loadMore, loadMoreRecent } = usePaginatedMessageListContext();
const { overlay } = useOverlayContext();
@@ -1248,6 +1212,7 @@ export const MessageList = <
void;
+ /**
+ * Callback to handle the press event
+ */
+ onPressHandler?: () => Promise;
+};
+
+export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProps) => {
+ const { onCloseHandler, onPressHandler } = props;
+ const { t } = useTranslationContext();
+ const {
+ channelUnreadState,
+ loadChannelAtFirstUnreadMessage,
+ markRead,
+ setChannelUnreadState,
+ setTargetedMessage,
+ } = useChannelContext();
+
+ const handleOnPress = async () => {
+ if (onPressHandler) {
+ await onPressHandler();
+ } else {
+ await loadChannelAtFirstUnreadMessage({
+ channelUnreadState,
+ setChannelUnreadState,
+ setTargetedMessage,
+ });
+ }
+ };
+
+ const handleClose = async () => {
+ if (onCloseHandler) {
+ await onCloseHandler();
+ } else {
+ await markRead();
+ }
+ };
+
+ const {
+ theme: {
+ colors: { text_low_emphasis, white_snow },
+ messageList: {
+ unreadMessagesNotification: { closeButtonContainer, closeIcon, container, text },
+ },
+ },
+ } = useTheme();
+
+ return (
+ [
+ styles.container,
+ { backgroundColor: text_low_emphasis, opacity: pressed ? 0.8 : 1 },
+ container,
+ ]}
+ >
+ {t('Unread Messages')}
+ [
+ {
+ opacity: pressed ? 0.8 : 1,
+ },
+ closeButtonContainer,
+ ]}
+ >
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ alignItems: 'center',
+ borderRadius: 20,
+ elevation: 4,
+ flexDirection: 'row',
+ paddingHorizontal: 16,
+ paddingVertical: 8,
+ position: 'absolute',
+ shadowColor: '#000',
+ shadowOffset: {
+ height: 2,
+ width: 0,
+ },
+ shadowOpacity: 0.23,
+ shadowRadius: 2.62,
+ top: 8,
+ },
+ text: {
+ fontWeight: '500',
+ marginRight: 8,
+ },
+});
diff --git a/package/src/components/MessageList/__tests__/MessageList.test.js b/package/src/components/MessageList/__tests__/MessageList.test.js
index e994180744..a481f8eaf1 100644
--- a/package/src/components/MessageList/__tests__/MessageList.test.js
+++ b/package/src/components/MessageList/__tests__/MessageList.test.js
@@ -1,5 +1,7 @@
import React from 'react';
+import { FlatList } from 'react-native';
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native';
import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider';
@@ -17,6 +19,7 @@ import { Channel } from '../../Channel/Channel';
import { channelInitialState } from '../../Channel/hooks/useChannelDataState';
import * as MessageListPaginationHook from '../../Channel/hooks/useMessageListPagination';
import { Chat } from '../../Chat/Chat';
+
import { MessageList } from '../MessageList';
describe('MessageList', () => {
@@ -382,6 +385,216 @@ describe('MessageList', () => {
expect(() => screen.getByText(latestMessageText)).toThrow();
});
});
+
+ it("should render the UnreadMessagesIndicator when there's unread messages", async () => {
+ const user1 = generateUser();
+ const user2 = generateUser();
+ const messages = Array.from({ length: 10 }, (_, i) =>
+ generateMessage({ id: `${i}`, text: `message-${i}` }),
+ );
+ const mockedChannel = generateChannelResponse({
+ members: [generateMember({ user: user1 }), generateMember({ user: user2 })],
+ });
+
+ const chatClient = await getTestClientWithUser({ id: user1.id });
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
+ const channel = chatClient.channel('messaging', mockedChannel.id);
+ await channel.watch();
+
+ const channelUnreadState = {
+ last_read: new Date(),
+ last_read_message_id: '5',
+ unread_messages: 5,
+ };
+
+ channel.state = {
+ ...channelInitialState,
+ latestMessages: [],
+ messages,
+ };
+
+ const { queryByLabelText } = render(
+
+
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(queryByLabelText('Inline unread indicator')).toBeTruthy();
+ });
+ });
+
+ it("should not render the UnreadMessagesIndicator when there's no unread messages", async () => {
+ const user1 = generateUser();
+ const user2 = generateUser();
+ const messages = Array.from({ length: 10 }, (_, i) =>
+ generateMessage({ id: `${i}`, text: `message-${i}` }),
+ );
+ const mockedChannel = generateChannelResponse({
+ members: [generateMember({ user: user1 }), generateMember({ user: user2 })],
+ });
+
+ const chatClient = await getTestClientWithUser({ id: user1.id });
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
+ const channel = chatClient.channel('messaging', mockedChannel.id);
+ await channel.watch();
+
+ const channelUnreadState = {
+ last_read: new Date(),
+ unread_messages: 0,
+ };
+
+ channel.state = {
+ ...channelInitialState,
+ latestMessages: [],
+ messages,
+ };
+
+ const { queryByLabelText } = render(
+
+
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(queryByLabelText('Inline unread indicator')).not.toBeTruthy();
+ });
+ });
+
+ it('should call markRead function when message.new event is dispatched and new messages are received', async () => {
+ const user = generateUser();
+ const mockedChannel = generateChannelResponse({
+ members: [generateMember({ user })],
+ });
+
+ const chatClient = await getTestClientWithUser({ id: user.id });
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
+ const channel = chatClient.channel('messaging', mockedChannel.id);
+ await channel.watch();
+
+ const user2 = generateUser();
+ const newMessage = generateMessage({ user: user2 });
+
+ const markReadFn = jest.fn();
+
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ act(() => dispatchMessageNewEvent(chatClient, newMessage, mockedChannel.channel));
+
+ await waitFor(() => {
+ expect(markReadFn).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ it("should scroll to the targeted message if it's present in the list", async () => {
+ const user = generateUser();
+ const mockedChannel = generateChannelResponse({
+ members: [generateMember({ user })],
+ });
+
+ const messages = Array.from({ length: 30 }, (_, i) =>
+ generateMessage({ id: `${i}`, text: `message-${i}` }),
+ );
+
+ const chatClient = await getTestClientWithUser({ id: user.id });
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
+ const channel = chatClient.channel('messaging', mockedChannel.id);
+ await channel.watch();
+
+ const targetedMessage = messages[15].id;
+
+ channel.state = {
+ ...channelInitialState,
+ latestMessages: [],
+ messages,
+ };
+
+ const flatListRefMock = jest
+ .spyOn(FlatList.prototype, 'scrollToIndex')
+ .mockImplementation(() => {});
+
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(flatListRefMock).toHaveBeenCalledWith({
+ animated: true,
+ index: 14,
+ viewPosition: 0.5,
+ });
+ });
+ });
+
+ it("should load more messages around the message id if the targeted message isn't present in the list", async () => {
+ const user = generateUser();
+ const mockedChannel = generateChannelResponse({
+ members: [generateMember({ user })],
+ });
+
+ const messages = Array.from({ length: 20 }, (_, i) =>
+ generateMessage({ id: `${i}`, text: `message-${i}` }),
+ );
+
+ const chatClient = await getTestClientWithUser({ id: user.id });
+ useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]);
+ const channel = chatClient.channel('messaging', mockedChannel.id);
+ await channel.watch();
+
+ const targetedMessage = 21;
+ const setTargetedMessage = jest.fn();
+
+ channel.state = {
+ ...channelInitialState,
+ latestMessages: [],
+ messages,
+ };
+
+ const loadChannelAroundMessage = jest.fn(() => Promise.resolve());
+
+ render(
+
+
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(loadChannelAroundMessage).toHaveBeenCalledWith({
+ messageId: targetedMessage,
+ setTargetedMessage,
+ });
+ });
+ });
});
describe('MessageList pagination', () => {
diff --git a/package/src/components/MessageMenu/MessageActionListItem.tsx b/package/src/components/MessageMenu/MessageActionListItem.tsx
index ca5f57aa54..52e8f57a31 100644
--- a/package/src/components/MessageMenu/MessageActionListItem.tsx
+++ b/package/src/components/MessageMenu/MessageActionListItem.tsx
@@ -10,6 +10,7 @@ export type ActionType =
| 'deleteMessage'
| 'editMessage'
| 'flagMessage'
+ | 'markUnread'
| 'muteUser'
| 'pinMessage'
| 'selectReaction'
@@ -26,7 +27,7 @@ export type MessageActionType = {
action: () => void;
/**
* Type of the action performed.
- * Eg: 'banUser', 'blockUser', 'copyMessage', 'deleteMessage', 'editMessage', 'flagMessage', 'muteUser', 'pinMessage', 'selectReaction', 'reply', 'retry', 'quotedReply', 'threadReply', 'unpinMessage'
+ * Eg: 'banUser', 'blockUser', 'copyMessage', 'deleteMessage', 'editMessage', 'flagMessage', 'markUnread , 'muteUser', 'pinMessage', 'selectReaction', 'reply', 'retry', 'quotedReply', 'threadReply', 'unpinMessage'
*/
actionType: ActionType | string;
/**
diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap
index 7a7d51d778..213ed3c79f 100644
--- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap
+++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap
@@ -40,7 +40,6 @@ exports[`Thread should match thread snapshot 1`] = `
contentContainerStyle={
[
{
- "flexGrow": 1,
"paddingBottom": 4,
},
undefined,
@@ -255,316 +254,320 @@ exports[`Thread should match thread snapshot 1`] = `
testID="message-list-item-0"
>
-
-
-
+
+
+ >
+
+
-
-
-
+
-
- Message6
+
+ Message6
+
-
+
-
-
-
- 2:50 PM
-
-
- ⦁
-
-
- Edited
-
+
+ 2:50 PM
+
+
+ ⦁
+
+
+ Edited
+
+
@@ -578,13 +581,6 @@ exports[`Thread should match thread snapshot 1`] = `
]
}
/>
-
-
-
-
+
+
+ >
+
+
-
-
-
+
-
- Message5
+
+ Message5
+
-
+
-
-
-
- 2:50 PM
-
-
- ⦁
-
-
- Edited
-
+
+ 2:50 PM
+
+
+ ⦁
+
+
+ Edited
+
+
@@ -936,13 +936,6 @@ exports[`Thread should match thread snapshot 1`] = `
]
}
/>
-
-
+ 05/05/2020
+
+
+
+
-
-
-
+
+
+ >
+
+
-
-
-
+
-
- Message4
+
+ Message4
+
-
+
-
-
-
- 2:50 PM
-
-
- ⦁
-
-
- Edited
-
+
+ 2:50 PM
+
+
+ ⦁
+
+
+ Edited
+
+
-
-
- 05/05/2020
-
-
-
void;
}) => Promise;
+ /**
+ * Loads channel at first unread message.
+ * @param channelUnreadState - The unread state of the channel
+ * @param limit - The number of messages to load around the first unread message
+ * @param setChannelUnreadState - Callback to set the channel unread state
+ */
+ loadChannelAtFirstUnreadMessage: ({
+ channelUnreadState,
+ limit,
+ setTargetedMessage,
+ }: {
+ channelUnreadState?: ChannelUnreadState;
+ limit?: number;
+ setChannelUnreadState?: React.Dispatch<
+ React.SetStateAction | undefined>
+ >;
+ setTargetedMessage?: (messageId: string) => void;
+ }) => Promise;
+
/**
* Custom loading indicator to override the Stream default
*/
LoadingIndicator: React.ComponentType;
- markRead: () => void;
+ markRead: (options?: MarkReadFunctionOptions) => void;
/**
*
* ```json
@@ -109,22 +130,27 @@ export type ChannelContextValue<
NetworkDownIndicator: React.ComponentType;
read: ChannelState['read'];
reloadChannel: () => Promise;
- /**
- * When true, messagelist will be scrolled to first unread message, when opened.
- */
scrollToFirstUnreadThreshold: number;
+ setChannelUnreadState: React.Dispatch<
+ React.SetStateAction | undefined>
+ >;
setLastRead: React.Dispatch>;
- setTargetedMessage: (messageId: string) => void;
+ setTargetedMessage: (messageId?: string) => void;
/**
* Abort controller for cancelling async requests made for uploading images/files
* Its a map of filename and AbortController
*/
uploadAbortControllerRef: React.MutableRefObject