From 743b5875760a5f1f9a2895ac6efb21f118b7a49c Mon Sep 17 00:00:00 2001 From: Stream SDK Bot <60655709+Stream-SDK-Bot@users.noreply.github.com> Date: Tue, 16 Dec 2025 07:10:17 +0100 Subject: [PATCH 1/3] chore: update sdk size (#3332) This PR was created automatically by CI. Co-authored-by: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Co-authored-by: Stream Bot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8a31481367..a93c62eca5 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-292%20KB-blue) +![JS Bundle Size](https://img.shields.io/badge/js_bundle_size-302%20KB-blue) From dd374e2866aa2a1b7c01ac813b6755ba6d51f300 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 18 Dec 2025 14:43:10 +0530 Subject: [PATCH 2/3] feat: issue sending message and edit message when moderation bounced it and add blocked component (#3323) --- examples/ExpoMessaging/app/index.tsx | 1 + examples/ExpoMessaging/yarn.lock | 43 ++++++++++-- examples/SampleApp/yarn.lock | 70 ++++++++++++------- examples/TypeScriptMessaging/yarn.lock | 70 ++++++++++++------- package/package.json | 2 +- package/src/components/Channel/Channel.tsx | 6 ++ .../Channel/hooks/useCreateMessagesContext.ts | 2 + package/src/components/Message/Message.tsx | 6 ++ .../Message/MessageSimple/MessageBlocked.tsx | 67 ++++++++++++++++++ package/src/components/index.ts | 1 + .../MessageInputContext.tsx | 5 ++ .../messagesContext/MessagesContext.tsx | 6 ++ .../src/contexts/themeContext/utils/theme.ts | 12 ++++ package/src/store/apis/getChannelMessages.ts | 21 +++--- package/src/utils/utils.ts | 17 ++--- package/yarn.lock | 42 +++++------ 16 files changed, 270 insertions(+), 101 deletions(-) create mode 100644 package/src/components/Message/MessageSimple/MessageBlocked.tsx diff --git a/examples/ExpoMessaging/app/index.tsx b/examples/ExpoMessaging/app/index.tsx index e484cccbfa..bfefd0dee0 100644 --- a/examples/ExpoMessaging/app/index.tsx +++ b/examples/ExpoMessaging/app/index.tsx @@ -1,3 +1,4 @@ +import React from 'react'; import { Alert, Image, Pressable, StyleSheet, View } from 'react-native'; import { ChannelList, SqliteClient } from 'stream-chat-expo'; import { useCallback, useContext, useMemo } from 'react'; diff --git a/examples/ExpoMessaging/yarn.lock b/examples/ExpoMessaging/yarn.lock index b0654bc72e..104da149ca 100644 --- a/examples/ExpoMessaging/yarn.lock +++ b/examples/ExpoMessaging/yarn.lock @@ -4272,6 +4272,22 @@ jsonwebtoken@^9.0.2: ms "^2.1.1" semver "^7.5.4" +jsonwebtoken@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + jwa@^1.4.1: version "1.4.2" resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.2.tgz#16011ac6db48de7b102777e57897901520eec7b9" @@ -4281,6 +4297,15 @@ jwa@^1.4.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + jws@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" @@ -4289,6 +4314,14 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + kleur@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" @@ -6019,10 +6052,10 @@ stream-chat-react-native-core@8.1.0: version "0.0.0" uid "" -stream-chat@^9.23.0: - version "9.23.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.23.0.tgz#e7e5cf729861597e7198907c1cab22a57d68a2fc" - integrity sha512-UW112HYsLnYb4RMIXBtAouNQCCe0weVzNivjezsw+JKK1b/TX0JLBi+wK25mBUEO+coOGKfXiye6IB3gao8ipw== +stream-chat@^9.27.2: + version "9.27.2" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.27.2.tgz#5b41173e513f3606c47c93f391693b589e663968" + integrity sha512-OdALDzg8lO8CAdl8deydJ1+O4wJ7mM9dPLeCwDppq/OQ4aFIS9X38P+IdXPcOCsgSS97UoVUuxD2/excC5PEeg== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -6030,7 +6063,7 @@ stream-chat@^9.23.0: base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0" - jsonwebtoken "^9.0.2" + jsonwebtoken "^9.0.3" linkifyjs "^4.3.2" ws "^8.18.1" diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 3737289497..5d203517a1 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -3819,7 +3819,7 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -buffer-equal-constant-time@1.0.1: +buffer-equal-constant-time@1.0.1, buffer-equal-constant-time@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== @@ -6476,6 +6476,22 @@ jsonwebtoken@^9.0.2: ms "^2.1.1" semver "^7.5.4" +jsonwebtoken@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + "jsx-ast-utils@^2.4.1 || ^3.0.0": version "3.3.5" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" @@ -6495,6 +6511,15 @@ jwa@^1.4.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + jws@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" @@ -6503,6 +6528,14 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -8280,34 +8313,17 @@ stream-chat-react-native-core@8.1.0: use-sync-external-store "^1.5.0" "stream-chat-react-native-core@link:../../package": - version "8.1.0" - dependencies: - "@gorhom/bottom-sheet" "^5.1.8" - "@ungap/structured-clone" "^1.3.0" - dayjs "1.11.13" - emoji-regex "^10.4.0" - i18next "^25.2.1" - intl-pluralrules "^2.0.1" - linkifyjs "^4.3.1" - lodash-es "4.17.21" - mime-types "^2.1.35" - path "0.12.7" - react-native-markdown-package "1.8.2" - react-native-url-polyfill "^2.0.0" - stream-chat "^9.23.0" - use-sync-external-store "^1.5.0" + version "0.0.0" + uid "" "stream-chat-react-native@link:../../package/native-package": - version "8.1.0" - dependencies: - es6-symbol "^3.1.3" - mime "^4.0.7" - stream-chat-react-native-core "8.1.0" + version "0.0.0" + uid "" -stream-chat@^9.23.0: - version "9.23.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.23.0.tgz#e7e5cf729861597e7198907c1cab22a57d68a2fc" - integrity sha512-UW112HYsLnYb4RMIXBtAouNQCCe0weVzNivjezsw+JKK1b/TX0JLBi+wK25mBUEO+coOGKfXiye6IB3gao8ipw== +stream-chat@^9.27.2: + version "9.27.2" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.27.2.tgz#5b41173e513f3606c47c93f391693b589e663968" + integrity sha512-OdALDzg8lO8CAdl8deydJ1+O4wJ7mM9dPLeCwDppq/OQ4aFIS9X38P+IdXPcOCsgSS97UoVUuxD2/excC5PEeg== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -8315,7 +8331,7 @@ stream-chat@^9.23.0: base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0" - jsonwebtoken "^9.0.2" + jsonwebtoken "^9.0.3" linkifyjs "^4.3.2" ws "^8.18.1" diff --git a/examples/TypeScriptMessaging/yarn.lock b/examples/TypeScriptMessaging/yarn.lock index 403761eb4d..747c18b03c 100644 --- a/examples/TypeScriptMessaging/yarn.lock +++ b/examples/TypeScriptMessaging/yarn.lock @@ -3045,7 +3045,7 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -buffer-equal-constant-time@1.0.1: +buffer-equal-constant-time@1.0.1, buffer-equal-constant-time@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== @@ -5646,6 +5646,22 @@ jsonwebtoken@^9.0.2: ms "^2.1.1" semver "^7.5.4" +jsonwebtoken@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== + dependencies: + jws "^4.0.1" + lodash.includes "^4.3.0" + lodash.isboolean "^3.0.3" + lodash.isinteger "^4.0.4" + lodash.isnumber "^3.0.3" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.once "^4.0.0" + ms "^2.1.1" + semver "^7.5.4" + "jsx-ast-utils@^2.4.1 || ^3.0.0": version "3.3.5" resolved "https://registry.yarnpkg.com/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz#4766bd05a8e2a11af222becd19e15575e52a853a" @@ -5665,6 +5681,15 @@ jwa@^1.4.1: ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== + dependencies: + buffer-equal-constant-time "^1.0.1" + ecdsa-sig-formatter "1.0.11" + safe-buffer "^5.0.1" + jws@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" @@ -5673,6 +5698,14 @@ jws@^3.2.2: jwa "^1.4.1" safe-buffer "^5.0.1" +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== + dependencies: + jwa "^2.0.1" + safe-buffer "^5.0.1" + keyv@^4.5.4: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" @@ -7381,34 +7414,17 @@ stream-chat-react-native-core@8.1.0: use-sync-external-store "^1.5.0" "stream-chat-react-native-core@link:../../package": - version "8.1.0" - dependencies: - "@gorhom/bottom-sheet" "^5.1.8" - "@ungap/structured-clone" "^1.3.0" - dayjs "1.11.13" - emoji-regex "^10.4.0" - i18next "^25.2.1" - intl-pluralrules "^2.0.1" - linkifyjs "^4.3.1" - lodash-es "4.17.21" - mime-types "^2.1.35" - path "0.12.7" - react-native-markdown-package "1.8.2" - react-native-url-polyfill "^2.0.0" - stream-chat "^9.23.0" - use-sync-external-store "^1.5.0" + version "0.0.0" + uid "" "stream-chat-react-native@link:../../package/native-package": - version "8.1.0" - dependencies: - es6-symbol "^3.1.3" - mime "^4.0.7" - stream-chat-react-native-core "8.1.0" + version "0.0.0" + uid "" -stream-chat@^9.23.0: - version "9.24.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.24.0.tgz#e6af5d4b0eb396e24e0ab7f852719581c39f18bc" - integrity sha512-zLtguYRqxeEc/Cjw8Zp00u/wTrqFg4gFPKdj3mvl/Jq1Pt95mY9nMc38KW0GOu/2quIAAar0NNMq8fsXl4jupQ== +stream-chat@^9.27.2: + version "9.27.2" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.27.2.tgz#5b41173e513f3606c47c93f391693b589e663968" + integrity sha512-OdALDzg8lO8CAdl8deydJ1+O4wJ7mM9dPLeCwDppq/OQ4aFIS9X38P+IdXPcOCsgSS97UoVUuxD2/excC5PEeg== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -7416,7 +7432,7 @@ stream-chat@^9.23.0: base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0" - jsonwebtoken "^9.0.2" + jsonwebtoken "^9.0.3" linkifyjs "^4.3.2" ws "^8.18.1" diff --git a/package/package.json b/package/package.json index 6766b0bc53..0f4028fa85 100644 --- a/package/package.json +++ b/package/package.json @@ -80,7 +80,7 @@ "path": "0.12.7", "react-native-markdown-package": "1.8.2", "react-native-url-polyfill": "^2.0.0", - "stream-chat": "^9.26.0", + "stream-chat": "^9.27.2", "use-sync-external-store": "^1.5.0" }, "peerDependencies": { diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 5685271ac9..50e02affb4 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -149,6 +149,7 @@ import { LoadingIndicator as LoadingIndicatorDefault } from '../Indicators/Loadi import { KeyboardCompatibleView as KeyboardCompatibleViewDefault } from '../KeyboardCompatibleView/KeyboardCompatibleView'; import { Message as MessageDefault } from '../Message/Message'; import { MessageAvatar as MessageAvatarDefault } from '../Message/MessageSimple/MessageAvatar'; +import { MessageBlocked as MessageBlockedDefault } from '../Message/MessageSimple/MessageBlocked'; import { MessageBounce as MessageBounceDefault } from '../Message/MessageSimple/MessageBounce'; import { MessageContent as MessageContentDefault } from '../Message/MessageSimple/MessageContent'; import { MessageDeleted as MessageDeletedDefault } from '../Message/MessageSimple/MessageDeleted'; @@ -354,6 +355,7 @@ export type ChannelPropsWithContext = Pick & | 'messageActions' | 'MessageAvatar' | 'MessageBounce' + | 'MessageBlocked' | 'MessageContent' | 'messageContentOrder' | 'MessageDeleted' @@ -674,6 +676,7 @@ const ChannelWithContext = (props: PropsWithChildren) = MessageActionListItem = MessageActionListItemDefault, messageActions, MessageAvatar = MessageAvatarDefault, + MessageBlocked = MessageBlockedDefault, MessageBounce = MessageBounceDefault, MessageContent = MessageContentDefault, messageContentOrder = [ @@ -1433,6 +1436,8 @@ const ChannelWithContext = (props: PropsWithChildren) = ...message, attachments, text: patchMessageTextCommand(text ?? '', mentioned_users ?? []), + // We cannot send an error message, so we convert it to a regular message. + type: message.type === 'error' ? 'regular' : message.type, } as StreamMessage; let messageResponse = {} as SendMessageAPIResponse; @@ -1949,6 +1954,7 @@ const ChannelWithContext = (props: PropsWithChildren) = MessageActionListItem, messageActions, MessageAvatar, + MessageBlocked, MessageBounce, MessageContent, messageContentOrder, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index 3ac1e7236d..fad7d91b0a 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -59,6 +59,7 @@ export const useCreateMessagesContext = ({ MessageActionListItem, messageActions, MessageAvatar, + MessageBlocked, MessageBounce, MessageContent, messageContentOrder, @@ -177,6 +178,7 @@ export const useCreateMessagesContext = ({ MessageActionListItem, messageActions, MessageAvatar, + MessageBlocked, MessageBounce, MessageContent, messageContentOrder, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index d1460cb61d..3fe829893e 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -180,6 +180,7 @@ export type MessagePropsWithContext = Pick< | 'messageActions' | 'messageContentOrder' | 'MessageBounce' + | 'MessageBlocked' | 'MessageSimple' | 'onLongPressMessage' | 'onPressInMessage' @@ -256,6 +257,7 @@ const MessageWithContext = (props: MessagePropsWithContext) => { members, message, messageActions: messageActionsProp = defaultMessageActions, + MessageBlocked, MessageBounce, messageContentOrder: messageContentOrderProp, MessageMenu, @@ -732,6 +734,10 @@ const MessageWithContext = (props: MessagePropsWithContext) => { return null; } + if (isBlockedMessage(message)) { + return ; + } + return ( ; +}; + +/** + * A component to display blocked message. e.g, when a message is blocked by moderation policies. + */ +export const MessageBlocked = (props: MessageBlockedProps) => { + const { message, style } = props; + + const { + theme: { + colors: { grey, grey_whisper }, + messageSimple: { + messageBlocked: { container, line, text, textContainer }, + }, + }, + } = useTheme(); + + return ( + + + + + {message.text?.toUpperCase() || ''} + + + + + ); +}; + +MessageBlocked.displayName = 'MessageBlocked{messageList{messageBlocked}}'; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + flexDirection: 'row', + justifyContent: 'center', + marginBottom: 10, + }, + line: { + flex: 1, + height: 0.5, + }, + text: { + fontSize: 10, + fontWeight: 'bold', + textAlign: 'center', + }, + textContainer: { + flex: 3, + marginTop: 10, + }, +}); diff --git a/package/src/components/index.ts b/package/src/components/index.ts index 407bcd1893..0c4841829b 100644 --- a/package/src/components/index.ts +++ b/package/src/components/index.ts @@ -99,6 +99,7 @@ export * from './Message/hooks/useMessageReadData'; export * from './Message/Message'; export * from './Message/MessageSimple/MessageAvatar'; export * from './Message/MessageSimple/MessageBounce'; +export * from './Message/MessageSimple/MessageBlocked'; export * from './Message/MessageSimple/MessageContent'; export * from './Message/MessageSimple/MessageDeleted'; export * from './Message/MessageSimple/MessageEditedTimestamp'; diff --git a/package/src/contexts/messageInputContext/MessageInputContext.tsx b/package/src/contexts/messageInputContext/MessageInputContext.tsx index 9a59b95bda..dca13d3320 100644 --- a/package/src/contexts/messageInputContext/MessageInputContext.tsx +++ b/package/src/contexts/messageInputContext/MessageInputContext.tsx @@ -603,6 +603,7 @@ export const MessageInputProvider = ({ return; } + // MODERATION: This is for the case where the message is of type 'error' and if you try to edit it, it will throw an error. if (editedMessage && editedMessage.type !== 'error') { try { clearEditingState(); @@ -624,6 +625,10 @@ export const MessageInputProvider = ({ } else { messageComposer.clear(); } + // Even though we edit, but we eventually send the message as a regular message, so we need to clear the editing state. + if (editedMessage) { + clearEditingState(); + } await value.sendMessage({ localMessage, message, diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 3e5441292c..869047524b 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -31,6 +31,7 @@ import type { MessageProps, } from '../../components/Message/Message'; import type { MessageAvatarProps } from '../../components/Message/MessageSimple/MessageAvatar'; +import type { MessageBlockedProps } from '../../components/Message/MessageSimple/MessageBlocked'; import type { MessageBounceProps } from '../../components/Message/MessageSimple/MessageBounce'; import type { MessageContentProps } from '../../components/Message/MessageSimple/MessageContent'; import type { MessageDeletedProps } from '../../components/Message/MessageSimple/MessageDeleted'; @@ -206,6 +207,11 @@ export type MessagesContextValue = Pick; + /** + * UI component for MessageBlocked + * Defaults to: [MessageBlocked](https://github.com/GetStream/stream-chat-react-native/blob/main/package/src/components/Message/MessageSimple/MessageBlocked.tsx) + */ + MessageBlocked: React.ComponentType; /** * UI Component for MessageBounce */ diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 38c5f1907b..f1b26969d9 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -655,6 +655,12 @@ export type Theme = { }; messageGroupedSingleOrBottomContainer: ViewStyle; messageGroupedTopContainer: ViewStyle; + messageBlocked: { + container: ViewStyle; + line: ViewStyle; + text: TextStyle; + textContainer: ViewStyle; + }; pinnedHeader: { container: ViewStyle; label: TextStyle; @@ -1469,6 +1475,12 @@ export const defaultTheme: Theme = { container: {}, roundedView: {}, }, + messageBlocked: { + container: {}, + line: {}, + text: {}, + textContainer: {}, + }, messageGroupedSingleOrBottomContainer: {}, messageGroupedTopContainer: {}, pinnedHeader: { diff --git a/package/src/store/apis/getChannelMessages.ts b/package/src/store/apis/getChannelMessages.ts index 932bb706da..87ae9641ed 100644 --- a/package/src/store/apis/getChannelMessages.ts +++ b/package/src/store/apis/getChannelMessages.ts @@ -4,7 +4,6 @@ import { selectMessagesForChannels } from './queries/selectMessagesForChannels'; import { selectReactionsForMessages } from './queries/selectReactionsForMessages'; -import { isBlockedMessage } from '../../utils/utils'; import { mapStorableToMessage } from '../mappers/mapStorableToMessage'; import { createSelectQuery } from '../sqlite-utils/createSelectQuery'; import { SqliteClient } from '../SqliteClient'; @@ -82,17 +81,15 @@ export const getChannelMessages = async ({ cidVsMessages[m.cid] = []; } - if (!isBlockedMessage(m)) { - cidVsMessages[m.cid].push( - mapStorableToMessage({ - currentUserId, - messageRow: m, - pollRow: messageIdsVsPolls[m.poll_id], - reactionRows: messageIdVsReactions[m.id], - reminderRow: messageIdsVsReminders[m.id], - }), - ); - } + cidVsMessages[m.cid].push( + mapStorableToMessage({ + currentUserId, + messageRow: m, + pollRow: messageIdsVsPolls[m.poll_id], + reactionRows: messageIdVsReactions[m.id], + reminderRow: messageIdsVsReminders[m.id], + }), + ); }); return cidVsMessages; diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 120056db43..9d26951c21 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -10,7 +10,6 @@ import type { } from 'stream-chat'; import { IconProps } from '../../src/icons/utils/base'; -import type { TableRowJoinedUser } from '../store/types'; import { ValueOf } from '../types/types'; export type ReactionData = { @@ -74,10 +73,12 @@ export const getIndicatorTypeForFileState = ( * @param message * @returns boolean */ -export const isBlockedMessage = (message: LocalMessage | TableRowJoinedUser<'messages'>) => { - // The only indicator for the blocked message is its message type is error and that the message text contains "Message was blocked by moderation policies". - const pattern = /\bMessage was blocked by moderation policies\b/; - return message.type === 'error' && message.text && pattern.test(message.text); +export const isBlockedMessage = (message: LocalMessage) => { + return ( + message.type === 'error' && + (message.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_REMOVE' || + message.moderation?.action === 'remove') + ); }; /** @@ -86,9 +87,9 @@ export const isBlockedMessage = (message: LocalMessage | TableRowJoinedUser<'mes * @returns boolean */ export const isBouncedMessage = (message: LocalMessage) => - (message.type === 'error' && - message?.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_BOUNCE') || - message?.moderation?.action === 'bounce'; + message.type === 'error' && + (message?.moderation_details?.action === 'MESSAGE_RESPONSE_ACTION_BOUNCE' || + message?.moderation?.action === 'bounce'); /** * Utility to check if the message is a edited message. diff --git a/package/yarn.lock b/package/yarn.lock index 05213830d1..100f186f7e 100644 --- a/package/yarn.lock +++ b/package/yarn.lock @@ -3363,7 +3363,7 @@ bser@2.1.1: dependencies: node-int64 "^0.4.0" -buffer-equal-constant-time@1.0.1: +buffer-equal-constant-time@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" integrity sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA== @@ -6234,12 +6234,12 @@ jsonfile@^6.0.1: optionalDependencies: graceful-fs "^4.1.6" -jsonwebtoken@^9.0.2: - version "9.0.2" - resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz#65ff91f4abef1784697d40952bb1998c504caaf3" - integrity sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ== +jsonwebtoken@^9.0.3: + version "9.0.3" + resolved "https://registry.yarnpkg.com/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz#6cd57ab01e9b0ac07cb847d53d3c9b6ee31f7ae2" + integrity sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g== dependencies: - jws "^3.2.2" + jws "^4.0.1" lodash.includes "^4.3.0" lodash.isboolean "^3.0.3" lodash.isinteger "^4.0.4" @@ -6260,21 +6260,21 @@ jsonwebtoken@^9.0.2: object.assign "^4.1.4" object.values "^1.1.6" -jwa@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" - integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== +jwa@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.1.tgz#bf8176d1ad0cd72e0f3f58338595a13e110bc804" + integrity sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg== dependencies: - buffer-equal-constant-time "1.0.1" + buffer-equal-constant-time "^1.0.1" ecdsa-sig-formatter "1.0.11" safe-buffer "^5.0.1" -jws@^3.2.2: - version "3.2.2" - resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" - integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== +jws@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.1.tgz#07edc1be8fac20e677b283ece261498bd38f0690" + integrity sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA== dependencies: - jwa "^1.4.1" + jwa "^2.0.1" safe-buffer "^5.0.1" keyv@^4.5.4: @@ -8340,10 +8340,10 @@ stdin-discarder@^0.2.2: resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== -stream-chat@^9.26.0: - version "9.26.0" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.26.0.tgz#92cd0dd5c59bfed05fb4fd74e9a19e8cce726175" - integrity sha512-hTeKWDyWjiM3R7GcvgqNCO6zTPZ1oyJbwbpJR2MyEReDVYSKeZge009m/queMxUGJHNuyiRKY0sjQocw4ceWUQ== +stream-chat@^9.27.2: + version "9.27.2" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-9.27.2.tgz#5b41173e513f3606c47c93f391693b589e663968" + integrity sha512-OdALDzg8lO8CAdl8deydJ1+O4wJ7mM9dPLeCwDppq/OQ4aFIS9X38P+IdXPcOCsgSS97UoVUuxD2/excC5PEeg== dependencies: "@types/jsonwebtoken" "^9.0.8" "@types/ws" "^8.5.14" @@ -8351,7 +8351,7 @@ stream-chat@^9.26.0: base64-js "^1.5.1" form-data "^4.0.4" isomorphic-ws "^5.0.0" - jsonwebtoken "^9.0.2" + jsonwebtoken "^9.0.3" linkifyjs "^4.3.2" ws "^8.18.1" From a7f9dabcd40f191aa82d2fd7f2141ec6385f383e Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Thu, 18 Dec 2025 14:44:20 +0530 Subject: [PATCH 3/3] fix: performance improvement for message list render item (#3306) --- package/src/components/Channel/Channel.tsx | 41 +-- .../Channel/hooks/useCreateChannelContext.ts | 4 +- .../Channel/hooks/useCreateMessagesContext.ts | 2 + package/src/components/Message/Message.tsx | 15 -- .../Message/MessageSimple/MessageContent.tsx | 2 - .../Message/MessageSimple/MessageWrapper.tsx | 147 +++++++++++ .../__tests__/MessageStatus.test.js | 6 +- .../Message/hooks/useCreateMessageContext.ts | 3 - .../MessageList/MessageFlashList.tsx | 224 ++++++---------- .../components/MessageList/MessageList.tsx | 248 ++++++------------ .../UnreadMessagesNotification.tsx | 4 +- .../MessageList/__tests__/MessageList.test.js | 18 +- .../__tests__/useMessageDateSeparator.test.ts | 83 ++++++ .../hooks/useMessageDateSeparator.ts | 66 +++++ .../hooks/useMessageGroupStyles.ts | 75 ++++++ .../MessageList/hooks/useMessageList.ts | 104 +++++--- .../utils/__tests__/getDateSeparators.test.ts | 71 ----- .../MessageList/utils/getDateSeparators.ts | 36 ++- .../MessageList/utils/getGroupStyles.ts | 77 +++--- .../utils/getLastReceivedMessage.ts | 5 +- .../__snapshots__/Thread.test.js.snap | 12 +- package/src/components/index.ts | 2 + .../channelContext/ChannelContext.tsx | 17 +- .../messageContext/MessageContext.tsx | 5 +- .../MessageListItemContext.tsx | 63 +++++ .../messagesContext/MessagesContext.tsx | 11 +- .../src/state-store/channel-unread-state.ts | 28 ++ .../message-list-prev-next-state.ts | 58 ++++ 28 files changed, 890 insertions(+), 537 deletions(-) create mode 100644 package/src/components/Message/MessageSimple/MessageWrapper.tsx create mode 100644 package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts create mode 100644 package/src/components/MessageList/hooks/useMessageDateSeparator.ts create mode 100644 package/src/components/MessageList/hooks/useMessageGroupStyles.ts delete mode 100644 package/src/components/MessageList/utils/__tests__/getDateSeparators.test.ts create mode 100644 package/src/contexts/messageListItemContext/MessageListItemContext.tsx create mode 100644 package/src/state-store/channel-unread-state.ts create mode 100644 package/src/state-store/message-list-prev-next-state.ts diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 50e02affb4..723cd9b907 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -106,7 +106,11 @@ import { isImagePickerAvailable, NativeHandlers, } from '../../native'; -import { ChannelUnreadState, FileTypes } from '../../types/types'; +import { + ChannelUnreadStateStore, + ChannelUnreadStateStoreType, +} from '../../state-store/channel-unread-state'; +import { FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { compressedImageURI } from '../../utils/compressImage'; import { patchMessageTextCommand } from '../../utils/patchMessageTextCommand'; @@ -326,6 +330,7 @@ export type ChannelPropsWithContext = Pick & | 'forceAlignMessages' | 'Gallery' | 'getMessagesGroupStyles' + | 'getMessageGroupStyle' | 'Giphy' | 'giphyVersion' | 'handleBan' @@ -426,7 +431,7 @@ export type ChannelPropsWithContext = Pick & */ doMarkReadRequest?: ( channel: ChannelType, - setChannelUnreadUiState?: (state: ChannelUnreadState) => void, + setChannelUnreadUiState?: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void, ) => void; /** * Overrides the Stream default send message request (Advanced usage only) @@ -622,6 +627,7 @@ const ChannelWithContext = (props: PropsWithChildren) = forceAlignMessages, Gallery = GalleryDefault, getMessagesGroupStyles, + getMessageGroupStyle, Giphy = GiphyDefault, giphyVersion = 'fixed_height', handleAttachButtonPress, @@ -778,10 +784,13 @@ const ChannelWithContext = (props: PropsWithChildren) = const [thread, setThread] = useState(threadProps || null); const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); - const [channelUnreadState, setChannelUnreadState] = useState( - undefined, + const [channelUnreadStateStore] = useState(new ChannelUnreadStateStore()); + const setChannelUnreadState = useCallback( + (data: ChannelUnreadStateStoreType['channelUnreadState']) => { + channelUnreadStateStore.channelUnreadState = data; + }, + [channelUnreadStateStore], ); - const { bottomSheetRef, closePicker, openPicker } = useAttachmentPickerBottomSheet(); const syncingChannelRef = useRef(false); @@ -906,16 +915,14 @@ const ChannelWithContext = (props: PropsWithChildren) = } 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.last_read_at && event.user)) { + return; + } + setChannelUnreadState({ + 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, }); } @@ -1773,7 +1780,8 @@ const ChannelWithContext = (props: PropsWithChildren) = const channelContext = useCreateChannelContext({ channel, - channelUnreadState, + channelUnreadState: channelUnreadStateStore.channelUnreadState, + channelUnreadStateStore, disabled: !!channel?.data?.frozen, EmptyStateIndicator, enableMessageGroupingByUser, @@ -1921,6 +1929,7 @@ const ChannelWithContext = (props: PropsWithChildren) = FlatList, forceAlignMessages, Gallery, + getMessageGroupStyle, getMessagesGroupStyles, Giphy, giphyVersion, diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index b58b0dad60..d47c70fc5d 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -5,6 +5,7 @@ import type { ChannelContextValue } from '../../../contexts/channelContext/Chann export const useCreateChannelContext = ({ channel, channelUnreadState, + channelUnreadStateStore, disabled, EmptyStateIndicator, enableMessageGroupingByUser, @@ -46,12 +47,12 @@ export const useCreateChannelContext = ({ const readUsersLastReads = readUsers .map(({ last_read }) => last_read?.toISOString() ?? '') .join(); - const stringifiedChannelUnreadState = JSON.stringify(channelUnreadState); const channelContext: ChannelContextValue = useMemo( () => ({ channel, channelUnreadState, + channelUnreadStateStore, disabled, EmptyStateIndicator, enableMessageGroupingByUser, @@ -96,7 +97,6 @@ export const useCreateChannelContext = ({ 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 fad7d91b0a..690a34a23d 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -27,6 +27,7 @@ export const useCreateMessagesContext = ({ FlatList, forceAlignMessages, Gallery, + getMessageGroupStyle, getMessagesGroupStyles, Giphy, giphyVersion, @@ -146,6 +147,7 @@ export const useCreateMessagesContext = ({ FlatList, forceAlignMessages, Gallery, + getMessageGroupStyle, getMessagesGroupStyles, Giphy, giphyVersion, diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 3fe829893e..1949d3d6bb 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -253,7 +253,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { handleRetry, handleThreadReply, isTargetedMessage, - lastReceivedId, members, message, messageActions: messageActionsProp = defaultMessageActions, @@ -652,7 +651,6 @@ const MessageWithContext = (props: MessagePropsWithContext) => { isMessageAIGenerated, isMyMessage, lastGroupMessage: groupStyles?.[0] === 'single' || groupStyles?.[0] === 'bottom', - lastReceivedId, members, message, messageContentOrder, @@ -789,7 +787,6 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit groupStyles: prevGroupStyles, isAttachmentEqual, isTargetedMessage: prevIsTargetedMessage, - lastReceivedId: prevLastReceivedId, members: prevMembers, message: prevMessage, messagesContext: prevMessagesContext, @@ -803,7 +800,6 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit goToMessage: nextGoToMessage, groupStyles: nextGroupStyles, isTargetedMessage: nextIsTargetedMessage, - lastReceivedId: nextLastReceivedId, members: nextMembers, message: nextMessage, messagesContext: nextMessagesContext, @@ -832,17 +828,6 @@ const areEqual = (prevProps: MessagePropsWithContext, nextProps: MessagePropsWit return false; } - const lastReceivedIdChangedAndMatters = - prevLastReceivedId !== nextLastReceivedId && - (prevLastReceivedId === prevMessage.id || - prevLastReceivedId === nextMessage.id || - nextLastReceivedId === prevMessage.id || - nextLastReceivedId === nextMessage.id); - - if (lastReceivedIdChangedAndMatters) { - return false; - } - const goToMessageChangedAndMatters = nextMessage.quoted_message_id && prevGoToMessage !== nextGoToMessage; diff --git a/package/src/components/Message/MessageSimple/MessageContent.tsx b/package/src/components/Message/MessageSimple/MessageContent.tsx index 6c7ca849bd..aa7efb1af0 100644 --- a/package/src/components/Message/MessageSimple/MessageContent.tsx +++ b/package/src/components/Message/MessageSimple/MessageContent.tsx @@ -535,7 +535,6 @@ export const MessageContent = (props: MessageContentProps) => { isEditedMessageOpen, isMessageAIGenerated, isMyMessage, - lastReceivedId, message, messageContentOrder, onLongPress, @@ -575,7 +574,6 @@ export const MessageContent = (props: MessageContentProps) => { isEditedMessageOpen, isMessageAIGenerated, isMyMessage, - lastReceivedId, message, messageContentOrder, MessageError, diff --git a/package/src/components/Message/MessageSimple/MessageWrapper.tsx b/package/src/components/Message/MessageSimple/MessageWrapper.tsx new file mode 100644 index 0000000000..39a4a36f22 --- /dev/null +++ b/package/src/components/Message/MessageSimple/MessageWrapper.tsx @@ -0,0 +1,147 @@ +import React, { useCallback } from 'react'; + +import { View } from 'react-native'; + +import { LocalMessage } from 'stream-chat'; + +import { useMessageDateSeparator } from '../../../components/MessageList/hooks/useMessageDateSeparator'; +import { useMessageGroupStyles } from '../../../components/MessageList/hooks/useMessageGroupStyles'; +import { useChannelContext } from '../../../contexts/channelContext/ChannelContext'; +import { useChatContext } from '../../../contexts/chatContext/ChatContext'; +import { useMessageListItemContext } from '../../../contexts/messageListItemContext/MessageListItemContext'; +import { useMessagesContext } from '../../../contexts/messagesContext/MessagesContext'; +import { ThemeProvider, useTheme } from '../../../contexts/themeContext/ThemeContext'; + +import { useStateStore } from '../../../hooks/useStateStore'; +import { ChannelUnreadStateStoreType } from '../../../state-store/channel-unread-state'; +import { MessagePreviousAndNextMessageStoreType } from '../../../state-store/message-list-prev-next-state'; + +const channelUnreadStateSelector = (state: ChannelUnreadStateStoreType) => ({ + first_unread_message_id: state.channelUnreadState?.first_unread_message_id, + last_read_message_id: state.channelUnreadState?.last_read_message_id, + last_read_timestamp: state.channelUnreadState?.last_read?.getTime(), + unread_messages: state.channelUnreadState?.unread_messages, +}); + +export type MessageWrapperProps = { + message: LocalMessage; +}; + +export const MessageWrapper = React.memo((props: MessageWrapperProps) => { + const { message } = props; + const { client } = useChatContext(); + const { + channelUnreadStateStore, + channel, + hideDateSeparators, + highlightedMessageId, + maxTimeBetweenGroupedMessages, + threadList, + } = useChannelContext(); + const { + getMessageGroupStyle, + InlineDateSeparator, + InlineUnreadIndicator, + Message, + MessageSystem, + myMessageTheme, + shouldShowUnreadUnderlay, + } = useMessagesContext(); + const { + goToMessage, + onThreadSelect, + noGroupByUser, + modifiedTheme, + messageListPreviousAndNextMessageStore, + } = useMessageListItemContext(); + + const dateSeparatorDate = useMessageDateSeparator({ + hideDateSeparators, + message, + messageListPreviousAndNextMessageStore, + }); + + const selector = useCallback( + (state: MessagePreviousAndNextMessageStoreType) => ({ + nextMessage: state.messageList[message.id]?.nextMessage, + }), + [message.id], + ); + const { nextMessage } = useStateStore(messageListPreviousAndNextMessageStore.state, selector); + const isNewestMessage = nextMessage === undefined; + const groupStyles = useMessageGroupStyles({ + dateSeparatorDate, + getMessageGroupStyle, + maxTimeBetweenGroupedMessages, + message, + messageListPreviousAndNextMessageStore, + noGroupByUser, + }); + + const { first_unread_message_id, last_read_timestamp, last_read_message_id, unread_messages } = + useStateStore(channelUnreadStateStore.state, channelUnreadStateSelector); + const { + theme: { + messageList: { messageContainer }, + screenPadding, + }, + } = useTheme(); + if (!channel || channel.disconnected) { + return null; + } + + const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); + const isLastReadMessage = + last_read_message_id === message.id || + (!unread_messages && createdAtTimestamp === last_read_timestamp); + + const showUnreadSeparator = + isLastReadMessage && + !isNewestMessage && + // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label + (!!first_unread_message_id || !!unread_messages); + + const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; + + const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; + const renderDateSeperator = dateSeparatorDate ? ( + + ) : null; + + const renderMessage = ( + + ); + + return ( + + {message.type === 'system' ? ( + + ) : wrapMessageInTheme ? ( + + + {renderDateSeperator} + {renderMessage} + + + ) : ( + + {renderDateSeperator} + {renderMessage} + + )} + {showUnreadUnderlay && } + + ); +}); diff --git a/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js b/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js index 36958bfe38..e741c75844 100644 --- a/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js +++ b/package/src/components/Message/MessageSimple/__tests__/MessageStatus.test.js @@ -81,11 +81,7 @@ describe('MessageStatus', () => { - + , diff --git a/package/src/components/Message/hooks/useCreateMessageContext.ts b/package/src/components/Message/hooks/useCreateMessageContext.ts index d2bb4d994f..b44fa768e5 100644 --- a/package/src/components/Message/hooks/useCreateMessageContext.ts +++ b/package/src/components/Message/hooks/useCreateMessageContext.ts @@ -22,7 +22,6 @@ export const useCreateMessageContext = ({ isMessageAIGenerated, isMyMessage, lastGroupMessage, - lastReceivedId, members, message, messageContentOrder, @@ -74,7 +73,6 @@ export const useCreateMessageContext = ({ isMessageAIGenerated, isMyMessage, lastGroupMessage, - lastReceivedId, members, message, messageContentOrder, @@ -105,7 +103,6 @@ export const useCreateMessageContext = ({ hasReactions, isEditedMessageOpen, lastGroupMessage, - lastReceivedId, membersValue, myMessageThemeString, reactionsValue, diff --git a/package/src/components/MessageList/MessageFlashList.tsx b/package/src/components/MessageList/MessageFlashList.tsx index efd775cd99..842a3f355a 100644 --- a/package/src/components/MessageList/MessageFlashList.tsx +++ b/package/src/components/MessageList/MessageFlashList.tsx @@ -17,8 +17,6 @@ import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator'; import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator'; import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator'; -import { getLastReceivedMessageFlashList } from './utils/getLastReceivedMessageFlashList'; - import { AttachmentPickerContextValue, useAttachmentPickerContext, @@ -32,6 +30,10 @@ import { ImageGalleryContextValue, useImageGalleryContext, } from '../../contexts/imageGalleryContext/ImageGalleryContext'; +import { + MessageListItemContextValue, + MessageListItemProvider, +} from '../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, useMessagesContext, @@ -44,11 +46,12 @@ import { PaginatedMessageListContextValue, usePaginatedMessageListContext, } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContext/ThemeContext'; +import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../hooks'; import { FileTypes } from '../../types/types'; +import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; let FlashList; @@ -102,6 +105,7 @@ type MessageFlashListPropsWithContext = Pick< ChannelContextValue, | 'channel' | 'channelUnreadState' + | 'channelUnreadStateStore' | 'disabled' | 'EmptyStateIndicator' | 'hideStickyDateHeader' @@ -250,6 +254,10 @@ const getItemTypeInternal = (message: LocalMessage) => { return 'generic-message'; }; +const renderItem = ({ item: message }: { item: LocalMessage }) => { + return ; +}; + const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => { const LoadingMoreRecentIndicator = props.threadList ? InlineLoadingMoreRecentThreadIndicator @@ -257,7 +265,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const { additionalFlashListProps, channel, - channelUnreadState, + channelUnreadStateStore, client, closePicker, DateHeader, @@ -268,9 +276,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => FooterComponent = LoadingMoreRecentIndicator, HeaderComponent = InlineLoadingMoreIndicator, hideStickyDateHeader, - highlightedMessageId, - InlineDateSeparator, - InlineUnreadIndicator, isListActive = false, isLiveStreaming = false, legacyImageViewerSwipeBehaviour, @@ -283,8 +288,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => loadMoreThread, markRead, maximumMessageLimit, - Message, - MessageSystem, myMessageTheme, readEvents, NetworkDownIndicator, @@ -299,7 +302,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => setMessages, setSelectedPicker, setTargetedMessage, - shouldShowUnreadUnderlay, StickyHeader, targetedMessage, thread, @@ -349,8 +351,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => ); const { - dateSeparatorsRef, - messageGroupStylesRef, + messageListPreviousAndNextMessageStore, processedMessageList, rawMessageList, viewabilityChangedCallback, @@ -378,11 +379,6 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => client.userID, ); - const lastReceivedId = useMemo( - () => getLastReceivedMessageFlashList(processedMessageList)?.id, - [processedMessageList], - ); - const [autoscrollToRecent, setAutoscrollToRecent] = useState(true); useEffect(() => { @@ -558,6 +554,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => */ useEffect(() => { const shouldMarkRead = () => { + const channelUnreadState = channelUnreadStateStore.channelUnreadState; return ( !channelUnreadState?.first_unread_message_id && !scrollToBottomButtonVisible && @@ -569,23 +566,22 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const handleEvent = async (event: Event) => { const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; const isMyOwnMessage = event.message?.user?.id === client.user?.id; + const channelUnreadState = channelUnreadStateStore.channelUnreadState; // When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState when its a received message. if ( (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && !isMyOwnMessage ) { - setChannelUnreadState((prev) => { - const previousUnreadCount = prev?.unread_messages ?? 0; - const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); - return { - ...(prev || {}), - last_read: - prev?.last_read ?? - (previousUnreadCount === 0 && previousLastMessage?.created_at - ? new Date(previousLastMessage.created_at) - : new Date(0)), // not having information about the last read message means the whole channel is unread, - unread_messages: previousUnreadCount + 1, - }; + const previousUnreadCount = channelUnreadState?.unread_messages ?? 0; + const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); + setChannelUnreadState({ + ...channelUnreadState, + last_read: + channelUnreadState?.last_read ?? + (previousUnreadCount === 0 && previousLastMessage?.created_at + ? new Date(previousLastMessage.created_at) + : new Date(0)), // not having information about the last read message means the whole channel is unread, + unread_messages: previousUnreadCount + 1, }); } else if (mainChannelUpdated && shouldMarkRead()) { await markRead(); @@ -599,7 +595,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => }; }, [ channel, - channelUnreadState?.first_unread_message_id, + channelUnreadStateStore, client.user?.id, markRead, scrollToBottomButtonVisible, @@ -640,12 +636,19 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => * This function should show or hide the unread indicator depending on the */ const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { - if (!viewableItems.length || !readEvents) { - setIsUnreadNotificationOpen(false); - return; - } + const channelUnreadState = channelUnreadStateStore.channelUnreadState; + // we need this check to make sure that regular list change do not trigger + // the unread notification to appear (for example if the old last read messages + // go out of the viewport). + const lastReadMessageId = channelUnreadState?.last_read_message_id; + const lastReadMessageVisible = viewableItems.some((item) => item.item.id === lastReadMessageId); - if (selectedPicker === 'images') { + if ( + !viewableItems.length || + !readEvents || + lastReadMessageVisible || + selectedPicker === 'images' + ) { setIsUnreadNotificationOpen(false); return; } @@ -657,7 +660,7 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => const lastItemMessage = lastItem.item; const lastItemCreatedAt = lastItemMessage.created_at; - const unreadIndicatorDate = channelUnreadState?.last_read.getTime(); + const unreadIndicatorDate = channelUnreadState?.last_read?.getTime(); const lastItemDate = lastItemCreatedAt.getTime(); if ( @@ -716,89 +719,20 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => [], ); - const renderItem = useCallback( - ({ index, item: message }: { index: number; item: LocalMessage }) => { - if (!channel || channel.disconnected) { - return null; - } - - 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); - - 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 showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; - - const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; - const renderDateSeperator = dateSeparatorsRef.current[message.id] && ( - - ); - - const renderMessage = ( - - ); - - return ( - - {message.type === 'system' ? ( - - ) : wrapMessageInTheme ? ( - - - {renderDateSeperator} - {renderMessage} - - - ) : ( - - {renderDateSeperator} - {renderMessage} - - )} - {showUnreadUnderlay && } - - ); - }, + const messageListItemContextValue: MessageListItemContextValue = useMemo( + () => ({ + goToMessage, + messageListPreviousAndNextMessageStore, + modifiedTheme, + noGroupByUser, + onThreadSelect, + }), [ - InlineDateSeparator, - InlineUnreadIndicator, - Message, - MessageSystem, - channel, - channelUnreadState?.first_unread_message_id, - channelUnreadState?.last_read, - channelUnreadState?.last_read_message_id, - channelUnreadState?.unread_messages, - client.userID, - dateSeparatorsRef, goToMessage, - highlightedMessageId, - lastReceivedId, - messageGroupStylesRef, + messageListPreviousAndNextMessageStore, modifiedTheme, - myMessageTheme, + noGroupByUser, onThreadSelect, - shouldShowUnreadUnderlay, - threadList, ], ); @@ -1129,34 +1063,36 @@ const MessageFlashListWithContext = (props: MessageFlashListPropsWithContext) => {EmptyStateIndicator ? : null} ) : ( - + + + )} {messageListLengthAfterUpdate && StickyHeader ? ( @@ -1194,6 +1130,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { const { channel, channelUnreadState, + channelUnreadStateStore, disabled, EmptyStateIndicator, enableMessageGroupingByUser, @@ -1242,6 +1179,7 @@ export const MessageFlashList = (props: MessageFlashListProps) => { {...{ channel, channelUnreadState, + channelUnreadStateStore, client, closePicker, DateHeader, diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index d6ed5a9285..a86fd62e47 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -17,7 +17,6 @@ import { useShouldScrollToRecentOnNewOwnMessage } from './hooks/useShouldScrollT import { InlineLoadingMoreIndicator } from './InlineLoadingMoreIndicator'; import { InlineLoadingMoreRecentIndicator } from './InlineLoadingMoreRecentIndicator'; import { InlineLoadingMoreRecentThreadIndicator } from './InlineLoadingMoreRecentThreadIndicator'; -import { getLastReceivedMessage } from './utils/getLastReceivedMessage'; import { AttachmentPickerContextValue, @@ -33,6 +32,10 @@ import { ImageGalleryContextValue, useImageGalleryContext, } from '../../contexts/imageGalleryContext/ImageGalleryContext'; +import { + MessageListItemContextValue, + MessageListItemProvider, +} from '../../contexts/messageListItemContext/MessageListItemContext'; import { MessagesContextValue, useMessagesContext, @@ -45,11 +48,12 @@ import { PaginatedMessageListContextValue, usePaginatedMessageListContext, } from '../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import { mergeThemes, ThemeProvider, useTheme } from '../../contexts/themeContext/ThemeContext'; +import { mergeThemes, useTheme } from '../../contexts/themeContext/ThemeContext'; import { ThreadContextValue, useThreadContext } from '../../contexts/threadContext/ThreadContext'; import { useStableCallback } from '../../hooks'; import { FileTypes } from '../../types/types'; +import { MessageWrapper } from '../Message/MessageSimple/MessageWrapper'; // 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. @@ -124,10 +128,10 @@ type MessageListPropsWithContext = Pick< ChannelContextValue, | 'channel' | 'channelUnreadState' + | 'channelUnreadStateStore' | 'disabled' | 'EmptyStateIndicator' | 'hideStickyDateHeader' - | 'highlightedMessageId' | 'loadChannelAroundMessage' | 'loading' | 'LoadingIndicator' @@ -150,14 +154,9 @@ type MessageListPropsWithContext = Pick< | 'DateHeader' | 'disableTypingIndicator' | 'FlatList' - | 'InlineDateSeparator' - | 'InlineUnreadIndicator' | 'legacyImageViewerSwipeBehaviour' - | 'Message' | 'ScrollToBottomButton' - | 'MessageSystem' | 'myMessageTheme' - | 'shouldShowUnreadUnderlay' | 'TypingIndicator' | 'TypingIndicatorContainer' | 'UnreadMessagesNotification' @@ -234,6 +233,10 @@ type MessageListPropsWithContext = Pick< isLiveStreaming?: boolean; }; +const renderItem = ({ item: message }: { item: LocalMessage }) => { + return ; +}; + /** * The message list component renders a list of messages. It consumes the following contexts: * @@ -250,7 +253,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const { additionalFlatListProps, channel, - channelUnreadState, + channelUnreadStateStore, client, closePicker, DateHeader, @@ -261,9 +264,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { FooterComponent = InlineLoadingMoreIndicator, HeaderComponent = LoadingMoreRecentIndicator, hideStickyDateHeader, - highlightedMessageId, - InlineDateSeparator, - InlineUnreadIndicator, inverted = true, isListActive = false, isLiveStreaming = false, @@ -277,8 +277,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { loadMoreThread, markRead, maximumMessageLimit, - Message, - MessageSystem, myMessageTheme, NetworkDownIndicator, noGroupByUser, @@ -293,7 +291,6 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { setMessages, setSelectedPicker, setTargetedMessage, - shouldShowUnreadUnderlay, StickyHeader, targetedMessage, thread, @@ -308,8 +305,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const { colors: { white_snow }, - messageList: { container, contentContainer, listContainer, messageContainer }, - screenPadding, + messageList: { container, contentContainer, listContainer }, } = theme; const myMessageThemeString = useMemo(() => JSON.stringify(myMessageTheme), [myMessageTheme]); @@ -325,8 +321,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { * processedMessageList changes on any state change */ const { - dateSeparatorsRef, - messageGroupStylesRef, + messageListPreviousAndNextMessageStore, processedMessageList, rawMessageList, viewabilityChangedCallback, @@ -395,10 +390,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { */ const messageIdLastScrolledToRef = useRef(undefined); const [hasMoved, setHasMoved] = useState(false); - const lastReceivedId = useMemo( - () => getLastReceivedMessage(processedMessageList)?.id, - [processedMessageList], - ); + const [scrollToBottomButtonVisible, setScrollToBottomButtonVisible] = useState(false); const [stickyHeaderDate, setStickyHeaderDate] = useState(); @@ -437,26 +429,23 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { } }); - const messagesLength = useRef(processedMessageList.length); - /** * This function should show or hide the unread indicator depending on the */ const updateStickyUnreadIndicator = useStableCallback((viewableItems: ViewToken[]) => { + const channelUnreadState = channelUnreadStateStore.channelUnreadState; // we need this check to make sure that regular list change do not trigger // the unread notification to appear (for example if the old last read messages // go out of the viewport). - if (processedMessageList.length !== messagesLength.current) { - return; - } - messagesLength.current = processedMessageList.length; + const lastReadMessageId = channelUnreadState?.last_read_message_id; + const lastReadMessageVisible = viewableItems.some((item) => item.item.id === lastReadMessageId); - if (!viewableItems.length || !readEvents) { - setIsUnreadNotificationOpen(false); - return; - } - - if (selectedPicker === 'images') { + if ( + !viewableItems.length || + !readEvents || + lastReadMessageVisible || + selectedPicker === 'images' + ) { setIsUnreadNotificationOpen(false); return; } @@ -467,7 +456,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const lastItemMessage = lastItem.item; const lastItemCreatedAt = lastItemMessage.created_at; - const unreadIndicatorDate = channelUnreadState?.last_read.getTime(); + const unreadIndicatorDate = channelUnreadState?.last_read?.getTime(); const lastItemDate = lastItemCreatedAt.getTime(); if ( @@ -547,6 +536,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { */ useEffect(() => { const shouldMarkRead = () => { + const channelUnreadState = channelUnreadStateStore.channelUnreadState; return ( !channelUnreadState?.first_unread_message_id && !scrollToBottomButtonVisible && @@ -558,23 +548,22 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { const handleEvent = async (event: Event) => { const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; const isMyOwnMessage = event.message?.user?.id === client.user?.id; + const channelUnreadState = channelUnreadStateStore.channelUnreadState; // When the scrollToBottomButtonVisible is true, we need to manually update the channelUnreadState when its a received message. if ( (scrollToBottomButtonVisible || channelUnreadState?.first_unread_message_id) && !isMyOwnMessage ) { - setChannelUnreadState((prev) => { - const previousUnreadCount = prev?.unread_messages ?? 0; - const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); - return { - ...(prev || {}), - last_read: - prev?.last_read ?? - (previousUnreadCount === 0 && previousLastMessage?.created_at - ? new Date(previousLastMessage.created_at) - : new Date(0)), // not having information about the last read message means the whole channel is unread, - unread_messages: previousUnreadCount + 1, - }; + const previousUnreadCount = channelUnreadState?.unread_messages ?? 0; + const previousLastMessage = getPreviousLastMessage(channel.state.messages, event.message); + setChannelUnreadState({ + ...channelUnreadState, + last_read: + channelUnreadState?.last_read ?? + (previousUnreadCount === 0 && previousLastMessage?.created_at + ? new Date(previousLastMessage.created_at) + : new Date(0)), // not having information about the last read message means the whole channel is unread, + unread_messages: previousUnreadCount + 1, }); } else if (mainChannelUpdated && shouldMarkRead()) { await markRead(); @@ -588,7 +577,7 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { }; }, [ channel, - channelUnreadState?.first_unread_message_id, + channelUnreadStateStore, client.user?.id, markRead, scrollToBottomButtonVisible, @@ -779,95 +768,20 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [targetedMessage]); - const renderItem = useCallback( - ({ index, item: message }: { index: number; item: LocalMessage }) => { - if (!channel || channel.disconnected) { - return null; - } - - 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); - - 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 showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; - - const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; - const renderDateSeperator = dateSeparatorsRef.current[message.id] && ( - - ); - - const renderMessage = ( - - ); - - return ( - - {message.type === 'system' ? ( - - ) : wrapMessageInTheme ? ( - - - {renderDateSeperator} - {renderMessage} - - - ) : ( - - {renderDateSeperator} - {renderMessage} - - )} - {showUnreadUnderlay && } - - ); - }, + const messageListItemContextValue: MessageListItemContextValue = useMemo( + () => ({ + goToMessage, + messageListPreviousAndNextMessageStore, + modifiedTheme, + noGroupByUser, + onThreadSelect, + }), [ - InlineDateSeparator, - InlineUnreadIndicator, - Message, - MessageSystem, - channel, - channelUnreadState?.first_unread_message_id, - channelUnreadState?.last_read, - channelUnreadState?.last_read_message_id, - channelUnreadState?.unread_messages, - client.userID, - dateSeparatorsRef, goToMessage, - highlightedMessageId, - lastReceivedId, - messageContainer, - messageGroupStylesRef, + messageListPreviousAndNextMessageStore, modifiedTheme, - myMessageTheme, + noGroupByUser, onThreadSelect, - screenPadding, - shouldShowUnreadUnderlay, - threadList, ], ); @@ -1232,42 +1146,44 @@ const MessageListWithContext = (props: MessageListPropsWithContext) => { {EmptyStateIndicator ? : null} ) : ( - + + maintainVisibleContentPosition={maintainVisibleContentPosition} + maxToRenderPerBatch={30} + onMomentumScrollEnd={onUserScrollEvent} + onScroll={handleScroll} + onScrollBeginDrag={onScrollBeginDrag} + onScrollEndDrag={onScrollEndDrag} + onScrollToIndexFailed={onScrollToIndexFailedRef.current} + onTouchEnd={dismissImagePicker} + onViewableItemsChanged={stableOnViewableItemsChanged} + ref={refCallback} + renderItem={renderItem} + scrollEventThrottle={isLiveStreaming ? 16 : undefined} + showsVerticalScrollIndicator={false} + // @ts-expect-error react-native internal + strictMode={isLiveStreaming} + style={flatListStyle} + testID='message-flat-list' + viewabilityConfig={flatListViewabilityConfig} + {...additionalFlatListPropsExcludingStyle} + /> + )} {messageListLengthAfterUpdate && StickyHeader ? ( @@ -1299,6 +1215,7 @@ export const MessageList = (props: MessageListProps) => { const { channel, channelUnreadState, + channelUnreadStateStore, disabled, EmptyStateIndicator, enableMessageGroupingByUser, @@ -1347,6 +1264,7 @@ export const MessageList = (props: MessageListProps) => { {...{ channel, channelUnreadState, + channelUnreadStateStore, client, closePicker, DateHeader, diff --git a/package/src/components/MessageList/UnreadMessagesNotification.tsx b/package/src/components/MessageList/UnreadMessagesNotification.tsx index bca539e3be..47192ee192 100644 --- a/package/src/components/MessageList/UnreadMessagesNotification.tsx +++ b/package/src/components/MessageList/UnreadMessagesNotification.tsx @@ -21,7 +21,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp const { onCloseHandler, onPressHandler } = props; const { t } = useTranslationContext(); const { - channelUnreadState, + channelUnreadStateStore, loadChannelAtFirstUnreadMessage, markRead, setChannelUnreadState, @@ -33,7 +33,7 @@ export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProp await onPressHandler(); } else { await loadChannelAtFirstUnreadMessage({ - channelUnreadState, + channelUnreadState: channelUnreadStateStore.channelUnreadState, setChannelUnreadState, setTargetedMessage, }); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.js b/package/src/components/MessageList/__tests__/MessageList.test.js index 116c36a57d..066d148c74 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.js +++ b/package/src/components/MessageList/__tests__/MessageList.test.js @@ -382,12 +382,19 @@ describe('MessageList', () => { }); }); - it("should render the UnreadMessagesIndicator when there's unread messages", async () => { + it("should render the InlineUnreadIndicator 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 read_data = { + [user1.id]: { + last_read: new Date(), + last_read_message_id: '5', + unread_messages: 5, + }, + }; const mockedChannel = generateChannelResponse({ members: [generateMember({ user: user1 }), generateMember({ user: user2 })], }); @@ -397,23 +404,18 @@ describe('MessageList', () => { 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, + read: read_data, }; const { queryByLabelText } = render( - + , diff --git a/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts new file mode 100644 index 0000000000..07203353a6 --- /dev/null +++ b/package/src/components/MessageList/__tests__/useMessageDateSeparator.test.ts @@ -0,0 +1,83 @@ +import { renderHook } from '@testing-library/react-native'; + +import { LocalMessage } from 'stream-chat'; + +import { MessagePreviousAndNextMessageStore } from '../../../state-store/message-list-prev-next-state'; +import { useMessageDateSeparator } from '../hooks/useMessageDateSeparator'; + +describe('useMessageDateSeparator', () => { + let messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; + let messages: LocalMessage[]; + + beforeEach(() => { + messageListPreviousAndNextMessageStore = new MessagePreviousAndNextMessageStore(); + messages = [ + { + created_at: new Date('2020-01-01T00:00:00.000Z'), + id: '1', + text: 'Hello', + }, + { + created_at: new Date('2020-01-02T00:00:00.000Z'), + id: '2', + text: 'World', + }, + { + created_at: new Date('2020-01-03T00:00:00.000Z'), + id: '3', + text: 'Hello World', + }, + ] as LocalMessage[]; + messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage({ messages }); + }); + + it('should return undefined if no message is passed', () => { + const { result } = renderHook(() => + useMessageDateSeparator({ message: undefined, messageListPreviousAndNextMessageStore }), + ); + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if the hideDateSeparators prop is true', () => { + const { result } = renderHook(() => + useMessageDateSeparator({ + hideDateSeparators: true, + message: messages[1], + messageListPreviousAndNextMessageStore, + }), + ); + expect(result.current).toBeUndefined(); + }); + + it('should return the date separator for a message if previous message is not the same day', () => { + const { result } = renderHook(() => + useMessageDateSeparator({ message: messages[1], messageListPreviousAndNextMessageStore }), + ); + expect(result.current).toBe(messages[1].created_at); + }); + + it('should return undefined if the message is the same day as the previous message', () => { + const messages = [ + { + created_at: new Date('2020-01-01T01:00:00.000Z'), + id: '1', + text: 'Hello', + }, + { + created_at: new Date('2020-01-01T02:00:00.000Z'), + id: '2', + text: 'World', + }, + ] as LocalMessage[]; + const messageListPreviousAndNextMessageStore = new MessagePreviousAndNextMessageStore(); + messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage({ messages }); + const { result: resultOfFirstMessage } = renderHook(() => + useMessageDateSeparator({ message: messages[0], messageListPreviousAndNextMessageStore }), + ); + expect(resultOfFirstMessage.current).toBe(messages[0].created_at); + const { result: resultOfSecondMessage } = renderHook(() => + useMessageDateSeparator({ message: messages[1], messageListPreviousAndNextMessageStore }), + ); + expect(resultOfSecondMessage.current).toBeUndefined(); + }); +}); diff --git a/package/src/components/MessageList/hooks/useMessageDateSeparator.ts b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts new file mode 100644 index 0000000000..80957b0b1a --- /dev/null +++ b/package/src/components/MessageList/hooks/useMessageDateSeparator.ts @@ -0,0 +1,66 @@ +import { useCallback, useMemo } from 'react'; + +import { LocalMessage } from 'stream-chat'; + +import { useStateStore } from '../../../hooks/useStateStore'; +import { + MessagePreviousAndNextMessageStore, + MessagePreviousAndNextMessageStoreType, +} from '../../../state-store/message-list-prev-next-state'; + +export const getDateSeparatorValue = ({ + hideDateSeparators, + message, + previousMessage, +}: { + hideDateSeparators?: boolean; + message?: LocalMessage; + previousMessage?: LocalMessage; +}) => { + if (hideDateSeparators) { + return undefined; + } + + const previousMessageDate = previousMessage?.created_at.toDateString(); + const messageDate = message?.created_at.toDateString(); + + if (previousMessageDate !== messageDate) { + return message?.created_at; + } + + return undefined; +}; + +/** + * Hook to get whether a message should have a date separator above it + */ +export const useMessageDateSeparator = ({ + hideDateSeparators, + message, + messageListPreviousAndNextMessageStore, +}: { + hideDateSeparators?: boolean; + message?: LocalMessage; + messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; +}) => { + const selector = useCallback( + (state: MessagePreviousAndNextMessageStoreType) => ({ + previousMessage: message ? state.messageList[message.id]?.previousMessage : undefined, + }), + [message], + ); + const { previousMessage } = useStateStore(messageListPreviousAndNextMessageStore.state, selector); + + const dateSeparatorDate = useMemo(() => { + if (!message && !previousMessage) { + return undefined; + } + return getDateSeparatorValue({ + hideDateSeparators, + message, + previousMessage, + }); + }, [hideDateSeparators, message, previousMessage]); + + return dateSeparatorDate; +}; diff --git a/package/src/components/MessageList/hooks/useMessageGroupStyles.ts b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts new file mode 100644 index 0000000000..4d4d286bc8 --- /dev/null +++ b/package/src/components/MessageList/hooks/useMessageGroupStyles.ts @@ -0,0 +1,75 @@ +import { useCallback, useMemo } from 'react'; + +import { LocalMessage } from 'stream-chat'; + +import { useMessageDateSeparator } from './useMessageDateSeparator'; + +import { MessagesContextValue } from '../../../contexts/messagesContext/MessagesContext'; +import { useStateStore } from '../../../hooks/useStateStore'; +import { + MessagePreviousAndNextMessageStore, + MessagePreviousAndNextMessageStoreType, +} from '../../../state-store/message-list-prev-next-state'; +import { getGroupStyle } from '../utils/getGroupStyles'; + +/** + * Hook to get the group styles for a message + */ +export const useMessageGroupStyles = ({ + noGroupByUser, + dateSeparatorDate, + maxTimeBetweenGroupedMessages, + message, + messageListPreviousAndNextMessageStore, + getMessageGroupStyle = getGroupStyle, +}: { + noGroupByUser?: boolean; + getMessageGroupStyle: MessagesContextValue['getMessageGroupStyle']; + dateSeparatorDate?: Date; + maxTimeBetweenGroupedMessages?: number; + message: LocalMessage; + messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; +}) => { + const selector = useCallback( + (state: MessagePreviousAndNextMessageStoreType) => ({ + nextMessage: state.messageList[message.id]?.nextMessage, + previousMessage: state.messageList[message.id]?.previousMessage, + }), + [message.id], + ); + const { previousMessage, nextMessage } = useStateStore( + messageListPreviousAndNextMessageStore.state, + selector, + ); + + // This is needed to calculate the group styles for the next message + const nextMessageDateSeparatorDate = useMessageDateSeparator({ + message: nextMessage, + messageListPreviousAndNextMessageStore, + }); + + const groupStyles = useMemo(() => { + if (noGroupByUser) { + return []; + } + return getMessageGroupStyle({ + dateSeparatorDate, + maxTimeBetweenGroupedMessages, + message, + nextMessage, + nextMessageDateSeparatorDate, + previousMessage, + }); + }, [ + noGroupByUser, + getMessageGroupStyle, + dateSeparatorDate, + maxTimeBetweenGroupedMessages, + message, + nextMessage, + nextMessageDateSeparatorDate, + previousMessage, + ]); + + return groupStyles; +}; diff --git a/package/src/components/MessageList/hooks/useMessageList.ts b/package/src/components/MessageList/hooks/useMessageList.ts index 01969ce221..75366278ea 100644 --- a/package/src/components/MessageList/hooks/useMessageList.ts +++ b/package/src/components/MessageList/hooks/useMessageList.ts @@ -1,4 +1,4 @@ -import { useMemo, useRef } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import type { LocalMessage } from 'stream-chat'; @@ -12,17 +12,24 @@ import { usePaginatedMessageListContext } from '../../../contexts/paginatedMessa import { useThreadContext } from '../../../contexts/threadContext/ThreadContext'; import { useRAFCoalescedValue } from '../../../hooks'; +import { MessagePreviousAndNextMessageStore } from '../../../state-store/message-list-prev-next-state'; import { DateSeparators, getDateSeparators } from '../utils/getDateSeparators'; import { getGroupStyles } from '../utils/getGroupStyles'; export type UseMessageListParams = { deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; + /** + * @deprecated + */ noGroupByUser?: boolean; threadList?: boolean; isLiveStreaming?: boolean; isFlashList?: boolean; }; +/** + * FIXME: To change it to a more specific type. + */ export type GroupType = string; export type MessageGroupStyles = { @@ -35,23 +42,27 @@ export const shouldIncludeMessageInList = ( ) => { const { deletedMessagesVisibilityType, userId } = options; const isMessageTypeDeleted = message.type === 'deleted'; + const isSender = message.user?.id === userId; + + if (!isMessageTypeDeleted) { + return true; + } + switch (deletedMessagesVisibilityType) { + case 'always': + return true; case 'sender': - return !isMessageTypeDeleted || message.user?.id === userId; - + return isSender; case 'receiver': - return !isMessageTypeDeleted || message.user?.id !== userId; - + return !isSender; case 'never': - return !isMessageTypeDeleted; - default: - return !!message; + return false; } }; export const useMessageList = (params: UseMessageListParams) => { - const { noGroupByUser, threadList, isLiveStreaming, isFlashList } = params; + const { noGroupByUser, threadList, isLiveStreaming, isFlashList = false } = params; const { client } = useChatContext(); const { hideDateSeparators, maxTimeBetweenGroupedMessages } = useChannelContext(); const { deletedMessagesVisibilityType, getMessagesGroupStyles = getGroupStyles } = @@ -59,64 +70,84 @@ export const useMessageList = (params: UseMessageListParams) => { const { messages, viewabilityChangedCallback } = usePaginatedMessageListContext(); const { threadMessages } = useThreadContext(); const messageList = threadList ? threadMessages : messages; + const [messageListPreviousAndNextMessageStore] = useState( + () => new MessagePreviousAndNextMessageStore(), + ); + const processedMessageList = useMemo(() => { + const newMessageList = []; + for (const message of messageList) { + if ( + !shouldIncludeMessageInList(message, { + deletedMessagesVisibilityType, + userId: client.userID, + }) + ) { + continue; + } + if (isFlashList) { + newMessageList.push(message); + } else { + newMessageList.unshift(message); + } + } + return newMessageList; + }, [messageList, deletedMessagesVisibilityType, client.userID, isFlashList]); + + useEffect(() => { + messageListPreviousAndNextMessageStore.setMessageListPreviousAndNextMessage({ + isFlashList, + messages: processedMessageList, + }); + }, [processedMessageList, messageListPreviousAndNextMessageStore, isFlashList]); + + /** + * @deprecated use `useDateSeparator` hook instead directly in the Message. + */ const dateSeparators = useMemo( () => getDateSeparators({ - deletedMessagesVisibilityType, hideDateSeparators, - messages: messageList, - userId: client.userID, + messages: processedMessageList, }), - [deletedMessagesVisibilityType, hideDateSeparators, messageList, client.userID], + [hideDateSeparators, processedMessageList], ); + /** + * @deprecated use `useDateSeparator` hook instead directly in the Message. + */ const dateSeparatorsRef = useRef(dateSeparators); dateSeparatorsRef.current = dateSeparators; + /** + * @deprecated use `useMessageGroupStyles` hook instead directly in the Message. + */ const messageGroupStyles = useMemo( () => getMessagesGroupStyles({ dateSeparators: dateSeparatorsRef.current, hideDateSeparators, maxTimeBetweenGroupedMessages, - messages: messageList, + messages: processedMessageList, noGroupByUser, userId: client.userID, }), [ - dateSeparatorsRef, getMessagesGroupStyles, hideDateSeparators, maxTimeBetweenGroupedMessages, - messageList, + processedMessageList, noGroupByUser, client.userID, ], ); + /** + * @deprecated use `useMessageGroupStyles` hook instead directly in the Message. + */ const messageGroupStylesRef = useRef(messageGroupStyles); messageGroupStylesRef.current = messageGroupStyles; - const processedMessageList = useMemo(() => { - const newMessageList = []; - for (const message of messageList) { - if ( - shouldIncludeMessageInList(message, { - deletedMessagesVisibilityType, - userId: client.userID, - }) - ) { - if (isFlashList) { - newMessageList.push(message); - } else { - newMessageList.unshift(message); - } - } - } - return newMessageList; - }, [client.userID, deletedMessagesVisibilityType, isFlashList, messageList]); - const data = useRAFCoalescedValue(processedMessageList, isLiveStreaming); return useMemo( @@ -125,12 +156,13 @@ export const useMessageList = (params: UseMessageListParams) => { dateSeparatorsRef, /** Message group styles */ messageGroupStylesRef, + messageListPreviousAndNextMessageStore, /** Messages enriched with dates/readby/groups and also reversed in order */ processedMessageList: data, /** Raw messages from the channel state */ rawMessageList: messageList, viewabilityChangedCallback, }), - [data, messageList, viewabilityChangedCallback], + [data, messageList, messageListPreviousAndNextMessageStore, viewabilityChangedCallback], ); }; diff --git a/package/src/components/MessageList/utils/__tests__/getDateSeparators.test.ts b/package/src/components/MessageList/utils/__tests__/getDateSeparators.test.ts deleted file mode 100644 index c6ee0e346e..0000000000 --- a/package/src/components/MessageList/utils/__tests__/getDateSeparators.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { PaginatedMessageListContextValue } from '../../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; -import { getDateSeparators } from '../getDateSeparators'; - -describe('getDateSeparators', () => { - it('should return an empty object if no messages are passed', () => { - expect(getDateSeparators({ messages: [] })).toEqual({}); - }); - - it('should return a date separator for each message in a new day', () => { - const firstDate = new Date('2020-01-01T00:00:00.000Z'); - const secondDate = new Date('2020-01-02T00:00:00.000Z'); - const messages = [ - { - created_at: firstDate, - id: '1', - text: 'foo', - }, - { - created_at: secondDate, - id: '2', - text: 'bar', - }, - { - created_at: secondDate, - id: '3', - text: 'baz', - }, - ] as PaginatedMessageListContextValue['messages']; - - expect(getDateSeparators({ messages })).toEqual({ - 1: firstDate, - 2: secondDate, - }); - }); - - it('should return a date separator for the visible message in a day if deleted messages are not visible to the user', () => { - const firstDate = new Date('2020-01-01T00:00:00.000Z'); - const secondDate = new Date('2020-01-02T00:00:00.000Z'); - - const messages = [ - { - created_at: firstDate, - id: '1', - text: 'foo', - type: 'regular', - }, - { - created_at: secondDate, - id: '2', - text: 'bar', - type: 'deleted', - }, - { - created_at: secondDate, - id: '3', - text: 'baz', - type: 'regular', - }, - ] as PaginatedMessageListContextValue['messages']; - - expect(getDateSeparators({ deletedMessagesVisibilityType: 'never', messages })).toEqual({ - 1: firstDate, - 3: secondDate, - }); - - expect(getDateSeparators({ deletedMessagesVisibilityType: 'receiver', messages })).toEqual({ - 1: firstDate, - 3: secondDate, - }); - }); -}); diff --git a/package/src/components/MessageList/utils/getDateSeparators.ts b/package/src/components/MessageList/utils/getDateSeparators.ts index 90f35d5c32..575debf957 100644 --- a/package/src/components/MessageList/utils/getDateSeparators.ts +++ b/package/src/components/MessageList/utils/getDateSeparators.ts @@ -2,10 +2,19 @@ import type { DeletedMessagesVisibilityType } from '../../../contexts/messagesCo import type { PaginatedMessageListContextValue } from '../../../contexts/paginatedMessageListContext/PaginatedMessageListContext'; import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadContext'; +/** + * @deprecated in favor of `useDateSeparator` hook instead directly in the Message. + */ export type GetDateSeparatorsParams = { messages: PaginatedMessageListContextValue['messages'] | ThreadContextValue['threadMessages']; + /** + * @deprecated The computations are done ahead of time in the useMessageList hook so this parameter is no longer needed. + */ deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; hideDateSeparators?: boolean; + /** + * @deprecated The computations are done ahead of time in the useMessageList hook so this parameter is no longer needed. + */ userId?: string; }; @@ -13,33 +22,20 @@ export type DateSeparators = { [key: string]: Date; }; +/** + * @deprecated in favor of `useDateSeparator` hook instead directly in the Message. + */ export const getDateSeparators = (params: GetDateSeparatorsParams) => { - const { deletedMessagesVisibilityType, hideDateSeparators, messages, userId } = params; + const { hideDateSeparators, messages } = params; const dateSeparators: DateSeparators = {}; if (hideDateSeparators) { return dateSeparators; } - const messagesWithoutDeleted = messages.filter((message) => { - const isMessageTypeDeleted = message.type === 'deleted'; - - const isDeletedMessageVisibleToSender = - deletedMessagesVisibilityType === 'sender' || deletedMessagesVisibilityType === 'always'; - - const isDeletedMessageVisibleToReceiver = - deletedMessagesVisibilityType === 'receiver' || deletedMessagesVisibilityType === 'always'; - - return ( - !isMessageTypeDeleted || - (userId === message.user?.id && isDeletedMessageVisibleToSender) || - (userId !== message.user?.id && isDeletedMessageVisibleToReceiver) - ); - }); - - for (let i = 0; i < messagesWithoutDeleted.length; i++) { - const previousMessage = messagesWithoutDeleted[i - 1]; - const message = messagesWithoutDeleted[i]; + for (let i = 0; i < messages.length; i++) { + const previousMessage = messages[i - 1]; + const message = messages[i]; const messageDate = message.created_at.toDateString(); diff --git a/package/src/components/MessageList/utils/getGroupStyles.ts b/package/src/components/MessageList/utils/getGroupStyles.ts index 56de390307..55630bd6f2 100644 --- a/package/src/components/MessageList/utils/getGroupStyles.ts +++ b/package/src/components/MessageList/utils/getGroupStyles.ts @@ -8,25 +8,46 @@ import type { ThreadContextValue } from '../../../contexts/threadContext/ThreadC import { isEditedMessage } from '../../../utils/utils'; import type { GroupType } from '../hooks/useMessageList'; +export type MessageGroupStylesParams = { + message: LocalMessage; + previousMessage: LocalMessage; + nextMessage: LocalMessage; + maxTimeBetweenGroupedMessages?: number; + dateSeparatorDate?: Date; + nextMessageDateSeparatorDate?: Date; +}; + +/** + * @deprecated in favor of `useMessageGroupStyles` hook instead directly in the Message. + */ export type GetGroupStylesParams = { dateSeparators: DateSeparators; messages: PaginatedMessageListContextValue['messages'] | ThreadContextValue['threadMessages']; + /** + * @deprecated in favor of `useDateSeparator` hook instead directly in the Message. + */ hideDateSeparators?: boolean; maxTimeBetweenGroupedMessages?: number; noGroupByUser?: boolean; + /** + * @deprecated + */ userId?: string; }; export type GroupStyle = '' | 'middle' | 'top' | 'bottom' | 'single'; -const getGroupStyle = ( - dateSeparators: DateSeparators, - message: LocalMessage, - previousMessage: LocalMessage, - nextMessage: LocalMessage, - hideDateSeparators?: boolean, - maxTimeBetweenGroupedMessages?: number, -): GroupStyle[] => { +/** + * Get the group styles for a message + */ +export const getGroupStyle = ({ + message, + previousMessage, + nextMessage, + maxTimeBetweenGroupedMessages, + nextMessageDateSeparatorDate, + dateSeparatorDate, +}: MessageGroupStylesParams): GroupStyle[] => { const groupStyles: GroupStyle[] = []; const isPrevMessageTypeDeleted = previousMessage?.type === 'deleted'; @@ -41,7 +62,7 @@ const getGroupStyle = ( userId !== previousMessage?.user?.id || !!isPrevMessageTypeDeleted || // NOTE: This is needed for the group styles to work after the message separated by date. - (!hideDateSeparators && dateSeparators[message.id]) || + dateSeparatorDate || isEditedMessage(previousMessage); const isBottomMessage = @@ -50,7 +71,7 @@ const getGroupStyle = ( nextMessage.type === 'error' || userId !== nextMessage?.user?.id || !!isNextMessageTypeDeleted || - (!hideDateSeparators && dateSeparators[nextMessage.id]) || + nextMessageDateSeparatorDate || (maxTimeBetweenGroupedMessages !== undefined && (nextMessage.created_at as Date).getTime() - (message.created_at as Date).getTime() > maxTimeBetweenGroupedMessages) || @@ -98,15 +119,11 @@ const getGroupStyle = ( return groupStyles; }; +/** + * @deprecated in favor of `useMessageGroupStyles` hook instead directly in the Message. + */ export const getGroupStyles = (params: GetGroupStylesParams) => { - const { - dateSeparators, - hideDateSeparators, - maxTimeBetweenGroupedMessages, - messages, - noGroupByUser, - userId, - } = params; + const { dateSeparators, maxTimeBetweenGroupedMessages, messages, noGroupByUser } = params; if (noGroupByUser) { return {}; @@ -114,25 +131,19 @@ export const getGroupStyles = (params: GetGroupStylesParams) => { const messageGroupStyles: { [key: string]: GroupType[] } = {}; - const messagesFilteredForNonUser = messages.filter((message) => { - const isMessageTypeDeleted = message.type === 'deleted'; - return !isMessageTypeDeleted || userId === message.user?.id; - }); - - for (let i = 0; i < messagesFilteredForNonUser.length; i++) { - const previousMessage = messagesFilteredForNonUser[i - 1]; - const message = messagesFilteredForNonUser[i]; - const nextMessage = messagesFilteredForNonUser[i + 1]; + for (let i = 0; i < messages.length; i++) { + const previousMessage = messages[i - 1]; + const message = messages[i]; + const nextMessage = messages[i + 1]; if (message.id) { - messageGroupStyles[message.id] = getGroupStyle( - dateSeparators, + messageGroupStyles[message.id] = getGroupStyle({ + dateSeparatorDate: dateSeparators[message.id], + maxTimeBetweenGroupedMessages, message, - previousMessage, nextMessage, - hideDateSeparators, - maxTimeBetweenGroupedMessages, - ); + previousMessage, + }); } } diff --git a/package/src/components/MessageList/utils/getLastReceivedMessage.ts b/package/src/components/MessageList/utils/getLastReceivedMessage.ts index 2752950258..d53e15c7d8 100644 --- a/package/src/components/MessageList/utils/getLastReceivedMessage.ts +++ b/package/src/components/MessageList/utils/getLastReceivedMessage.ts @@ -7,10 +7,7 @@ export const getLastReceivedMessage = (messages: LocalMessage[]) => { * There are no status on dates so they will be skipped */ for (const message of messages) { - if ( - message?.status === MessageStatusTypes.RECEIVED || - message?.status === MessageStatusTypes.SENDING - ) { + if (message?.status !== MessageStatusTypes.FAILED) { return message; } } 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 a445b548d4..a917135ec3 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -232,10 +232,10 @@ exports[`Thread should match thread snapshot 1`] = ` } > >; + setChannelUnreadState?: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void; setTargetedMessage?: (messageId: string) => void; }) => Promise; @@ -123,7 +127,7 @@ export type ChannelContextValue = { read: ChannelState['read']; reloadChannel: () => Promise; scrollToFirstUnreadThreshold: number; - setChannelUnreadState: React.Dispatch>; + setChannelUnreadState: (data: ChannelUnreadStateStoreType['channelUnreadState']) => void; setLastRead: React.Dispatch>; setTargetedMessage: (messageId?: string) => void; /** @@ -131,7 +135,12 @@ export type ChannelContextValue = { * Its a map of filename and AbortController */ uploadAbortControllerRef: React.MutableRefObject>; + /** + * Channel unread data + * @deprecated Use channelUnreadStateStore instead + */ channelUnreadState?: ChannelUnreadState; + channelUnreadStateStore: ChannelUnreadStateStore; disabled?: boolean; enableMessageGroupingByUser?: boolean; /** diff --git a/package/src/contexts/messageContext/MessageContext.tsx b/package/src/contexts/messageContext/MessageContext.tsx index 70c44865b3..45e7f0e51c 100644 --- a/package/src/contexts/messageContext/MessageContext.tsx +++ b/package/src/contexts/messageContext/MessageContext.tsx @@ -112,7 +112,10 @@ export type MessageContextValue = { * @returns */ handleReaction?: (reactionType: string) => Promise; - /** Latest message id on current channel */ + /** + * Latest message id on current channel + * @deprecated and will be removed in the future. This is pretty much accessible through the message-list itself. + */ lastReceivedId?: string; /** * Theme provided only to messages that are the current users diff --git a/package/src/contexts/messageListItemContext/MessageListItemContext.tsx b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx new file mode 100644 index 0000000000..90fb5d4872 --- /dev/null +++ b/package/src/contexts/messageListItemContext/MessageListItemContext.tsx @@ -0,0 +1,63 @@ +import React, { createContext, PropsWithChildren, useContext } from 'react'; + +import { MessageListProps } from '../../components/MessageList/MessageList'; +import { MessagePreviousAndNextMessageStore } from '../../state-store/message-list-prev-next-state'; + +import { Theme } from '../themeContext/utils/theme'; +import { DEFAULT_BASE_CONTEXT_VALUE } from '../utils/defaultBaseContextValue'; +import { isTestEnvironment } from '../utils/isTestEnvironment'; + +export type MessageListItemContextValue = { + /** + * Handler to go to a particular message when its quoted. + * + * @param messageId The id of the message to go to. + * @returns void + */ + goToMessage: (messageId: string) => void; + /** + * Store to get the previous and next message in the message list + */ + messageListPreviousAndNextMessageStore: MessagePreviousAndNextMessageStore; + /** + * Theme to use for the message list item + */ + modifiedTheme: Theme; + /** + * Whether to group messages by user + */ + noGroupByUser?: boolean; + /** + * Handler to open the thread on message. This is callback for touch event for replies button. + * + * @param message A message object to open the thread upon. + */ + onThreadSelect: MessageListProps['onThreadSelect']; +}; + +export const MessageListItemContext = createContext( + DEFAULT_BASE_CONTEXT_VALUE as MessageListItemContextValue, +); + +export const MessageListItemProvider = ({ + children, + value, +}: PropsWithChildren<{ + value?: MessageListItemContextValue; +}>) => ( + + {children} + +); + +export const useMessageListItemContext = () => { + const contextValue = useContext(MessageListItemContext) as unknown as MessageListItemContextValue; + + if (contextValue === DEFAULT_BASE_CONTEXT_VALUE && !isTestEnvironment()) { + throw new Error( + 'The useMessageListItemContext hook was called outside of the MessageListItemContext provider. Make sure you have configured MessageListItem component correctly - https://getstream.io/chat/docs/sdk/reactnative/basics/hello_stream_chat/#message-list', + ); + } + + return contextValue; +}; diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 869047524b..f0158ef0a5 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -56,7 +56,11 @@ import type { MessageSystemProps } from '../../components/MessageList/MessageSys import type { ScrollToBottomButtonProps } from '../../components/MessageList/ScrollToBottomButton'; import { TypingIndicatorContainerProps } from '../../components/MessageList/TypingIndicatorContainer'; import { UnreadMessagesNotificationProps } from '../../components/MessageList/UnreadMessagesNotification'; -import type { getGroupStyles } from '../../components/MessageList/utils/getGroupStyles'; +import type { + getGroupStyles, + GroupStyle, + MessageGroupStylesParams, +} from '../../components/MessageList/utils/getGroupStyles'; import { MessageActionListProps } from '../../components/MessageMenu/MessageActionList'; import type { MessageActionListItemProps, @@ -407,7 +411,12 @@ export type MessagesContextValue = Pick GroupStyle[]; /** * Handler to access when a ban user action is invoked. * @param message diff --git a/package/src/state-store/channel-unread-state.ts b/package/src/state-store/channel-unread-state.ts new file mode 100644 index 0000000000..2e938dec5b --- /dev/null +++ b/package/src/state-store/channel-unread-state.ts @@ -0,0 +1,28 @@ +import { StateStore } from 'stream-chat'; + +import type { ChannelUnreadState as ChannelUnreadStateType } from '../types/types'; + +export type ChannelUnreadStateStoreType = { + channelUnreadState?: ChannelUnreadStateType; +}; + +const INITIAL_STATE: ChannelUnreadStateStoreType = { + channelUnreadState: undefined, +}; + +export class ChannelUnreadStateStore { + public state: StateStore; + + constructor() { + this.state = new StateStore(INITIAL_STATE); + } + + set channelUnreadState(data: ChannelUnreadStateStoreType['channelUnreadState']) { + this.state.next({ channelUnreadState: data }); + } + + get channelUnreadState() { + const { channelUnreadState } = this.state.getLatestValue(); + return channelUnreadState; + } +} diff --git a/package/src/state-store/message-list-prev-next-state.ts b/package/src/state-store/message-list-prev-next-state.ts new file mode 100644 index 0000000000..3cde61704c --- /dev/null +++ b/package/src/state-store/message-list-prev-next-state.ts @@ -0,0 +1,58 @@ +import { LocalMessage, StateStore } from 'stream-chat'; + +export type MessagePreviousAndNextMessageStoreType = { + messageList: Record< + string, + { + previousMessage: LocalMessage; + nextMessage: LocalMessage; + } + >; +}; + +const INITIAL_STATE: MessagePreviousAndNextMessageStoreType = { + messageList: {}, +}; + +export class MessagePreviousAndNextMessageStore { + public state: StateStore; + + constructor() { + this.state = new StateStore(INITIAL_STATE); + } + + // The default value of isFlashList is true as the logic in the function makes more sense when the list is not reversed. + public setMessageListPreviousAndNextMessage({ + messages, + isFlashList = true, + }: { + messages: LocalMessage[]; + isFlashList?: boolean; + }) { + const currentValue = this.state.getLatestValue(); + const prevMessageList: MessagePreviousAndNextMessageStoreType['messageList'] = + currentValue.messageList; + const newMessageList: MessagePreviousAndNextMessageStoreType['messageList'] = {}; + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + const previousMessage = isFlashList ? messages[i - 1] : messages[i + 1]; + const nextMessage = isFlashList ? messages[i + 1] : messages[i - 1]; + + const existing = prevMessageList[message.id]; + + if ( + existing && + existing.previousMessage === previousMessage && + existing.nextMessage === nextMessage + ) { + newMessageList[message.id] = existing; + } else { + newMessageList[message.id] = { + nextMessage, + previousMessage, + }; + } + } + this.state.partialNext({ messageList: newMessageList }); + } +}