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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-1974%20KB-blue)
![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-1975%20KB-blue)

<img align="right" src="https://getstream.imgix.net/images/ios-chat-tutorial/iphone_chat_art@3x.png?auto=format,enhance" width="50%" />

Expand Down
2 changes: 1 addition & 1 deletion examples/ExpoMessaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"react-native-teleport": "^1.0.2",
"react-native-web": "^0.21.0",
"react-native-worklets": "0.8.3",
"stream-chat": "^9.48.0",
"stream-chat": "^9.50.0",
"stream-chat-expo": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/SampleApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"react-native-teleport": "^1.1.7",
"react-native-video": "^6.19.2",
"react-native-worklets": "^0.8.3",
"stream-chat": "^9.48.0",
"stream-chat": "^9.50.0",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/TypeScriptMessaging/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"react-native-svg": "^15.12.0",
"react-native-video": "^6.16.1",
"react-native-worklets": "^0.4.1",
"stream-chat": "^9.48.0",
"stream-chat": "^9.50.0",
"stream-chat-react-native": "workspace:^",
"stream-chat-react-native-core": "workspace:^"
},
Expand Down
11 changes: 9 additions & 2 deletions package/expo-package/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ import {
Video,
} from './optionalDependencies';

registerNativeHandlers({
/**
* The default native handlers this package registers with the core SDK.
*/
export const defaultNativeHandlers = {
Audio,
compressImage,
deleteFile,
Expand All @@ -39,13 +42,17 @@ registerNativeHandlers({
pickDocument,
pickImage,
saveFile,
SDK: 'stream-chat-expo',
setClipboardString,
shareImage,
Sound,
takePhoto,
triggerHaptic,
Video,
};

registerNativeHandlers({
...defaultNativeHandlers,
SDK: 'stream-chat-expo',
});

export * from 'stream-chat-react-native-core';
29 changes: 29 additions & 0 deletions package/expo-package/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,30 @@
import { registerNativeHandlers } from 'stream-chat-react-native-core';

export * from 'stream-chat-react-native-core';

/**
* The default native handlers this package registers with the core SDK.
*
* Exposed so integrators can compose or wrap a single handler (for example to
* force `takePhoto` to capture images only) without reimplementing it or
* reaching into internal module paths. Register your override *after* importing
* this package so it takes precedence.
*
* Example:
*
* ```ts
* import { registerNativeHandlers, defaultNativeHandlers } from 'stream-chat-expo';
*
* const localTakePhoto = defaultNativeHandlers.takePhoto;
*
* registerNativeHandlers({
* takePhoto: localTakePhoto
* ? (options) => {
* console.log('[#3379 demo] wrapped takePhoto — forcing mediaType "image"', options);
* return localTakePhoto({ ...options, mediaType: 'image' });
* }
* : undefined,
* });
* ```
*/
export declare const defaultNativeHandlers: Parameters<typeof registerNativeHandlers>[0];
11 changes: 9 additions & 2 deletions package/native-package/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,10 @@ import {
Video,
} from './optionalDependencies';

registerNativeHandlers({
/**
* The default native handlers this package registers with the core SDK.
*/
export const defaultNativeHandlers = {
Audio,
compressImage,
deleteFile,
Expand All @@ -40,13 +43,17 @@ registerNativeHandlers({
pickDocument,
pickImage,
saveFile,
SDK: 'stream-chat-react-native',
setClipboardString,
shareImage,
Sound,
takePhoto,
triggerHaptic,
Video,
};

registerNativeHandlers({
...defaultNativeHandlers,
SDK: 'stream-chat-react-native',
});

if (Platform.OS === 'android') {
Expand Down
29 changes: 29 additions & 0 deletions package/native-package/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,30 @@
import { registerNativeHandlers } from 'stream-chat-react-native-core';

export * from 'stream-chat-react-native-core';

/**
* The default native handlers this package registers with the core SDK.
*
* Exposed so integrators can compose or wrap a single handler (for example to
* force `takePhoto` to capture images only) without reimplementing it or
* reaching into internal module paths. Register your override *after* importing
* this package so it takes precedence.
*
* Example:
*
* ```ts
* import { registerNativeHandlers, defaultNativeHandlers } from 'stream-chat-expo';
*
* const localTakePhoto = defaultNativeHandlers.takePhoto;
*
* registerNativeHandlers({
* takePhoto: localTakePhoto
* ? (options) => {
* console.log('[#3379 demo] wrapped takePhoto — forcing mediaType "image"', options);
* return localTakePhoto({ ...options, mediaType: 'image' });
* }
* : undefined,
* });
* ```
*/
export declare const defaultNativeHandlers: Parameters<typeof registerNativeHandlers>[0];
2 changes: 1 addition & 1 deletion package/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"path": "0.12.7",
"react-native-markdown-package": "1.8.2",
"react-native-url-polyfill": "^2.0.0",
"stream-chat": "^9.48.0",
"stream-chat": "^9.50.0",
"use-sync-external-store": "^1.5.0"
},
"peerDependencies": {
Expand Down
43 changes: 33 additions & 10 deletions package/src/components/Channel/Channel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -511,12 +511,12 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
const styles = useStyles();
const [deleted, setDeleted] = useState<boolean>(false);
const [error, setError] = useState<Error | boolean>(false);
const [lastRead, setLastRead] = useState<Date | undefined>();
const lastReadRef = useRef<Date | undefined>(undefined);
const [thread, setThread] = useState<LocalMessage | null>(threadProps || null);
const [threadHasMore, setThreadHasMore] = useState(true);
const [threadLoadingMore, setThreadLoadingMore] = useState(false);
const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore());
const [messageInputHeightStore] = useState(new MessageInputHeightStore());
const [channelUnreadStateStore] = useState(() => new ChannelUnreadStateStore());
const [messageInputHeightStore] = useState(() => new MessageInputHeightStore());
// TODO: Think if we can remove this and just rely on the channelUnreadStateStore everywhere.
const setChannelUnreadState = useCallback(
(data: ChannelUnreadStateStoreType['channelUnreadState']) => {
Expand Down Expand Up @@ -690,6 +690,13 @@ const ChannelWithContext = (props: PropsWithChildren<ChannelPropsWithContext>) =
return;
}

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

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

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

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

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

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

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

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

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

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

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

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

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

const snapshot = toJSON() as unknown as {
children: Array<{
Expand Down
Loading