From 672e0609d3e21ae6616fe388ce127630087927b9 Mon Sep 17 00:00:00 2001 From: Khushal Agarwal Date: Tue, 24 Dec 2024 14:56:43 +0530 Subject: [PATCH] feat: mark as unread and mark as read improvements (#2833) * fix: remove netinfo from native handlers and add it as dev dependency of core (#2538) * fix: add expo-clipboard to peer deps of expo-package (#2537) * fix: remove netinfo from native handlers and add it as dev dependency of core * fix: lint issues * fix: revert back isMounted logic * fix: change peer dep * feat: remove usage of flatlist-mvcp in favour of mvcp support in RN >= 0.72 (#2539) * fix: add expo-clipboard to peer deps of expo-package (#2537) * feat: remove usage of flatlist-mvcp in favour of mvcp support in RN >= 0.72 * fix: remove flatlist-mvcp from example apps * chore: update peer dep of react native * fix: change quotedMessage type to MessageType or undefined (#2527) * fix: change quotedMessage type to MessageType or undefined * docs: fix lint issues * fix: change react-native-image-resizer to @bam.tech/react-native-image-resizer (#2534) * fix: reaction list reactions sorting order based on created_at * fix: show reactions using reactions_group * fix: tests * fix: useProcessReactions hook * fix: useProcessReactions hook and Channel message spread * fix: change react-native-image-resizer to @bam.tech/react-native-image-resizer * fix: remove reaction_counts from offline DB * chore: update yarn.lock files * fix: add reanimated and gesture handler as peer deps to package * fix: change old gesture handler useAnimatedGestureHandler to the new API (#2563) * feat: configure ReactionList reaction theme (#2561) * fix: expo sample app keyboardVerticalOffset for channel and thread screen * fix: change old gesture handler useAnimatedGestureHandler to the new API * feat: move react-native-quick-sqlite to op-sqlite (#2541) * feat: move react-native-quick-sqlite to op-sqlite * docs: update docs * fix: podfile changes * docs: add troubleshoot guide * fix: issues with import * fix: update yarn.lock * chore: update deps of expo messaging app * feat: use react-native-blob-util instead of the react-native-fs and make it optional dependency (#2578) * feat: use react-native-blob-util instead of the react-native-fs and make it optional dependency * chore: fix peerdeps and add dcos * fix: deps version on package.json * docs: add migration guide for v5 to v6 * docs: add migration guide for v5 to v6 * docs: add migration guide for v5 to v6 * fix: upgrade cameraroll, documentpicker, haptics feedback and image crop picker to new arch compatible version * fix: change react-native-image-crop-picker to react-native-image-picker and make it optional * docs: add migration guide * fix: make expo-image-picker optional * fix: make expo-image-picker optional * docs: apply docs changes * fix: add check for handlers in native.ts * fix: resolve conflicts from branch develop to v6 (#2621) * fix: resolve conflicts from branch develop to v6 * fix: remove cameraroll on ts messaging app * fix: remove shared folder * chore: bump vale version * chore: bump vale version * chore: update vale version * chore: resolve conflicts from develop * chore: fix vale issues * chore: fix vale issues * fix: react-native-safe-area-context ios build issue * fix: bottom sheet version * fix: remove flipper debugger * fix: issue with rn-svg on android and rn-video fabric compatibility * fix: animations usage in ImageGallery (#2627) * fix: remove rn-sqlite patch * fix: animations laginess on the ImageGallery and MessageOverlay on new architecture (#2633) * chore: resolve conflicts from develop (#2682) * fix: upgrade firebase versions to fix crash (#2646) * fix: audio recording variety bugs (#2648) * fix: variety of bugs in async audio feature * fix: upload of empty messages and console.log cleanup * fix: expo permission race conditions * fix: create pure version of stopRecording() for usage on unmount * fix: remove redundant export * fix: actually amend failing test suites * chore: remove constructors as they are not needed * fix: pr remarks * chore: write some simplistic functionality tests * chore: return class instance rather than class itself for better usability * chore: add more robust tests * fix: linter errors * fix: only display waveform whenever the mic is locked * docs: add Deep Linking guide for the chat SDK (#2651) * fix: change Block user action to Ban user action and UI cookbook for blocking users (#2649) * fix: change Block user action to Ban user action * fix: change Block user action to Ban user action * fix: add back blockUser action and deprecate it * docs: UI Cookbook for Blocking users * chore(release): 5.36.1 * chore(release): 1.28.1 * fix: issue with loading app settings when the connectUser is not called on app (#2654) * fix: issue with loading app settings when the connectUser is not called on app * fix: add comments * fix: add comments * fix: unable to upload file due to special characters in the file name (#2656) * fix: sdk size pr (#2657) * [CI] Update SDK Size (#2653) Co-authored-by: Khushal Agarwal Co-authored-by: Stream Bot * chore(release): 5.36.2 * chore(release): 1.28.2 * feat: add live location sharing cookbook (#2659) * feat: add live location ui cookbook * fix: prettier lint * Update docusaurus/docs/reactnative/guides/live-location-sharing.mdx Co-authored-by: Oliver Lazoroski * lint fixes * fix lint issues * fix locks * move to ruby 3.1 and greater --------- Co-authored-by: Oliver Lazoroski * chore(release): 5.37.0 * chore(release): 1.29.0 * fix: avoid prepending http before native supported url schemes (#2661) * fix: avoid prepending http before native supported url schemes * fix: move check to link parsing module * fix: bad memoisation in window, screen dimension listener hooks (#2664) * fix: bad memoisation in window, screen dimension listener hooks * remove unused variable * feat: add create chat client hook for easy usage (#2660) * feat: add create chat client hook for easy usage * docs: use useCreateChatClient hook for client creation * fix: bump fastlane plugin version (#2665) * fix: add theme properties for EmptyStateIndicator for message list (#2667) * fix: add theme properties for EmptyStateIndicator for message list * fix: update snapshots * fix: apply card cover theme property order * fix: pagination typescript errors and db synchronization bugs (#2669) * fix: pagination typescript errors and db synchronization bugs * chore: write test for db serialization issue * fix: linter issues * [CI] Bump max tolerance for sdk size analysis (#2674) * chore: bump sample app version to v1.30.0 * fix: remove nin and ne operator usage in the SDK and the sample app (#2672) * fix: remove and operator usage in the SDK and the sample app * fix: remove console log * fix: change console log to warn * fix: add improvemnts * fix: import of debouncefunc * fix: import of debouncefunc * fix: restructure queryMembers, queryUsers and ACItriggersettings * fix: error bubbling for suggestions in auto complete input * fix: request image access permissions for iOS only for native image picking (#2677) * fix: properly resolve sendMessage during memoization (#2675) * fix: properly resolve sendMessage during memoization * fix: remedy change so that it does not cause performance issues * chore: revert sendMessage in the dep array * fix: deprecate messageReactions prop and use isMessageActionsVisible instead for messageActions (#2676) * fix: deprecate messageReactions prop and use isMessageActionsVisible instead for messageActions * docs: fix custom message actions * fix: execution logic for showMessageOverlay * chore(release): 5.38.0 * chore(release): 1.29.1 * fix: update yarn.lock for the project (#2681) * fix: copy message action type for message actions (#2679) * chore: update sdk size (#2678) Co-authored-by: Khushal Agarwal Co-authored-by: Stream Bot * chore: resolve conflicts from develop * fix: vale lint issues * fix: new arch project config --------- Co-authored-by: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Co-authored-by: semantic-release-bot Co-authored-by: Alexey Alter-Pesotskiy Co-authored-by: Stream SDK Bot <60655709+Stream-SDK-Bot@users.noreply.github.com> Co-authored-by: Stream Bot Co-authored-by: Santhosh Vaiyapuri <3846977+santhoshvai@users.noreply.github.com> Co-authored-by: Oliver Lazoroski Co-authored-by: Ivan Sekovanikj Co-authored-by: Stream Bot * fix: chat.test.ts * chore: cleanup v3 docs setup and e2e tests from project (#2701) * feat: add new message action list and reaction selector UI (#2686) * feat: remove StreamChatRN in favour of a global context ChatConfigContext that allows providing global values (#2703) * fix: remove deprecated code from v6 branch (#2702) * fix: remove deprecated code from v6 branch * fix: remove deprecated code from v6 branch * docs: add deprecated docs * docs: add deprecated docs * chore: resolve conflicts from base branch * docs: improve deprecated docs * chore: resolve conflicts from develop * feat: new reaction list design and improvements to MessageSimple component (#2700) * feat: add new message action list and reaction selector UI * fix: remove unnecessary props from OverlayContext * docs: add comments for the props * tests: add tests for the components * tests: add tests for the components * fix: chat.test.ts * fix: chat.test.ts * fix: add tests for the code * fix: use rn animated to create new modal and add docs * fix: add opacity for the message action list item when pressed * fix: make modal better and change component names * fix: update test snapdhots * docs: message actions customizations * docs: message actions customizations * fix: theme improvements * fix: message component render improvements * docs: update migration guide * feat: new reaction list design and improvements to MessageSimple component * tests: add tests for the components * docs: reaction list new design docs * docs: add changes to migration guide * docs: add changes to migration guide * docs: add changes to migration guide * chore: resolve conflicts from base branch * chore: resolve conflicts from base branch * fix: sample app overlay backdrop bug * fix: reaction list type * feat: add FlatList for Reaction Picker * feat: add FlatList for Reaction Picker * feat: add FlatList for Reaction Picker * fix: circular dependency issue * fix: upgrade expo to latest in expo messaging app * fix: padding for reaction picker * fix: update dependencies for native cli apps * fix: add Flatlist mvcp as optional package and peer deps versions (#2720) * fix: add Flatlist mvcp as optional package and peer deps versions * fix: add Flatlist mvcp as optional package and peer deps versions * docs: fix native handler docs * docs: fix vale issues * docs: remove native handler docs * fix: lint issues * fix: design issues with ReactionList bottom UI (#2717) * fix: design issues with ReactionList bottom UI * fix: vale issues * fix: add a log that complains for removal for mvcp package on RN version <0.72 (#2724) * fix: add a log that complains for removal for mvcp package on RN version <0.72 * fix: add a log that complains for removal for mvcp package on RN version <0.72 * fix: build issues * fix: add variable under the if block Co-authored-by: Oliver Lazoroski * fix: log --------- Co-authored-by: Oliver Lazoroski * fix: bottom sheet modal improvements * fix: use process reactions improvements for thread list * docs: docusaurus setup for v6 release (#2721) * docs: docusaurus setup for v6 release * docs: docusaurus setup for v6 release * docs: fix vale issues * docs: add v5 docs to v5 folder * chore: setup workflow and improve migration guide for v6 rc release (#2727) * fix: linting issues * featv6 rc release * feat!: v6 rc release * feat!: v6 rc release * BREAKING CHANGE: v6 rc release * fix: upgrade react native version in sample apps to 0.73.10 (#2728) * feat!: rc release v6 (#2729) * feat: styles changes for MessageActionList * feat: styles changes for MessageUserReactions * chore: fix noteKeywords in release config * feat: improve Message.tsx render conditional (#2733) BREAKING CHANGE: Update the Message.tsx conditional in the component to use ternary * feat: remove dry run mode from release config BREAKING CHANGE: Update the release config * fix: way of reading the value from animated shared values in the components (#2738) * fix: way of reading the value from animated shared values in the components * fix: unify styles to one hook * fix: lint issues * fix: upgrade the RN version in example apps to 0.75.4 (#2739) * fix: upgrade the RN version in example apps to 0.74.6 * fix: upgrade the RN version in example apps to 0.75.4 * fix: upgrade the RN version in example apps to 0.75.4 * fix: pod install issue * fix: pod install issue * fix: pod install issue * fix: pod install issue * fix: pod install issue * feat: move react native image resizer native module to the SDK (#2751) * feat: move react native image resizer to the native package natively * fix: android and ios native module * fix: add StreamChatReactNative module for ios * fix: add StreamChatReactNative module for ios and android * fix: add StreamChatReactNative module for ios and android * docs: update docs for the removal of the image resizer package * fix: tests for audio controller * fix: tests for audio controller * fix: update sample app * fix: update sample app * fix: update sample app * fix: attachment picker image picker icon visibility as per dependency * fix: changes after merge * fix: lint issues * fix: revalidate pod cache * chore: revert cache revalidation * fix: sender and receiver message theme colors * fix: sender and receiver message theme colors (#2767) * fix: upgrade op-sqlite version to be compatible with latest react native version (#2761) * fix: upgrade op-sqlite version to be compatible with latest react native version * fix: lint issues * docs: add new architecture guide docs * revert: "docs: add new architecture guide docs" This reverts commit be16a66e7759f5737b174777110eaef798922c0c. * docs: fix migration guide * fix: add back set initial state * chore: backport the polls docs to v5 too * fix: add versioned sidebar update as well * chore: upgrade sample apps to RN 0.76.1 and added new arch docs (#2756) * chore: upgrade sample apps to RN 0.76.1 * docs: add new architecture guide * docs: add new architecture guide * fix: vale issues * chore: update the sample apps * fix: poll offline fixes (#2772) * fix: message disallowed indicator display (#2754) * fix: native image picker poll control (#2762) * fix: check for channel validity before consuming config (#2760) * fix: receiverMessageBackgroundColor hotfix (#2763) * fix: receiverMessageBackgroundColor hotfix * fix: tests * chore(release): 5.41.3 * chore(release): 1.31.4 * fix: theme for the message bubble and replies (#2766) * fix: theme for the message bubble and replies * fix: theme for the message bubble and replies * fix: add null coleasing operator * fix: poll edge cases (#2768) * fix: poll related edge cases with offline storage * feat: offline db for polls wip * fix: reconcile own_votes properly * fix: all underlying offline store issues with polls * fix: properly resolve own_votes and latest_answers * fix: remove faulty poll check * chore: remove commented out code * chore: remove log * fix: multiple answers bug and remove logs * chore: remove index as we have primary key * chore(release): 5.41.4 * chore(release): 1.31.5 * fix: errors during resolving conflicts and yarn.locks * fix: pod cache revalidation * fix: lint issues --------- Co-authored-by: semantic-release-bot Co-authored-by: Khushal Agarwal * fix: revert cache revalidation and introduce dummy change in docs to trigger build * fix: resolve lint issues once again * fix: remove channel constants - isAdmin, isOwner and isModerator * fix: offline mode channel hydration issues * fix: remove channel constants - isAdmin, isOwner and isModerator (#2778) * fix: sender and receiver message theme colors * fix: remove channel constants - isAdmin, isOwner and isModerator * fix: faulty build for sample apps * fix: update yarn.lock files to fix build * fix: android modal size * fix: race conditions on db open and close * fix: move dropTables into check as well * fix: crash in some instances of useIsChannelMuted hook invocation * fix: properly use hook in channel preview * fix: edge cases and test * refactor: remove stale subscriber count logic and types refactor (#2782) * fix: regex state machine stack depth crash * fix: update test * fix: channel hook regressions * fix: expo media library permissions race conditions * feat: new message list pagination implementation * feat: enable moderation v2 on the sdk and sample apps * fix: listen to correct channel read events * fix: add FlatList default for Expo * fix: channel state initial data * fix: revert back the onStartReached change * fix: lint and tests * feat: add support for membership customization * fix: modify deployment workflow of SampleApp so that we get a release * fix: try bumping firebase version * fix: revert FB changes and CODE_SIGN_STYLE regression * fix: PROVISIONING_PROFILE_SPECIFIER specific val * fix: certificate distribution settings * fix: upgrade bottom sheet and fix reanimated errors in new arch (#2806) * fix: upgrade bottom sheet to get rid of warnings * fix: add sample app yarn.lock as well * fix: all remnants of reanimated errors * fix: update peer dependencies * fix: added tests for the hooks and message pagination * fix: podlock file for sample app * fix: channel.state break on going to background * fix: expo media library exceptions in new arch * chore: resolve conflicts from develop on v6 (#2813) * chore(release): 5.42.0 * chore(release): 1.31.6 * fix: android modal size (#2784) * fix: backport crash fix (#2787) * fix: crash in some instances of useIsChannelMuted hook invocation * fix: properly use hook in channel preview * fix: edge cases and test * chore(release): 5.42.1 * chore(release): 1.31.7 * fix: recursion depth on regex parse issue (#2790) * fix: channel hook regressions * fix: expo media library permissions race conditions * fix: listen to correct channel read events * chore(release): 5.42.2 * chore(release): 1.31.8 * feat: moderation v2 support (#2801) * feat: enable moderation v2 on the sdk and sample apps * chore: update yarn.lock files as well * feat: add support for membership customization (#2802) * feat: add support for membership customization * fix: lint issues * chore(release): 5.43.0 * chore(release): 1.32.0 * fix: channel.state break on going to background (#2809) * chore(release): 5.43.1 * chore(release): 1.32.1 * chore: resolve conflicts from develop on v6 --------- Co-authored-by: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Co-authored-by: semantic-release-bot Co-authored-by: Ivan Sekovanikj * fix: camera roll issues * fix: refactor photo resolution to native level * fix: expo media library * fix: properly resolve videos too * chore: remove console.log * chore: update yarn.lock files for example apps * fix: throttle logic for the copy message state * fix: bring back camera-roll to SampleApp * fix: also podfile.lock * fix: remove console.log * fix: issues with video preview * chore: remove console.log * feat: mark as unread and handle markRead in the SDK * chore: resolve conflicts from develop * feat: ai-bot integration poc (#2819) * feat: add StreamingMessageView to kick off ai feature * fix: issues with message view * feat: add AITypingIndicatorView * feat: make send message button react to ai state * fix: improve typewriter animation * fix: improvements in ui and typewriter * chore: add customizations to StreamingMessageView * fix: hook deps * chore: extract logic in hook * fix: custom events * fix: revert the type change in favor of changes in the LLC * feat: codeblock scrollable view * feat: table initial reimpl * feat: finish table impl * fix: horizontal scroll list performance issues * feat: add markdown parsing fixes, optimistic code capture and various improvements * fix: theme prop and theming in general * fix: remove edited lalbel for ai messages * fix: bug with stop streaming button and types * fix: colors in md rendering * fix: rename custom scrollview * chore: translations * fix: remove TODO * chore: extract indicator styles in theme * fix: safeguard if channel does not exist * fix: get rid of enum and introduce proper type * fix: allow only message overrides * chore: update event names as per the changes * fix: bump stream-chat-js version to v8.46.0 * fix: use channel method for sending events * fix: cover background mode case * fix: use type from LLC * chore: add jsdocs * fix: move check to checker fn * fix: add overrides for StreamingMessageView * chore: add override for stop streaming button * fix: message list improvements and channel first message mark unread improvements * fix: lint issues * chore: upgrade expo sample app to expo 52 * fix: remove unnecessary android and ios permissions * chore: update yarn.lock files * tests: add tests for the mark as unread feature * fix: unread message indicator on small message list * fix: date separator for small list * fix: improve scrolling fast scenario for scroll to target message * refactor: remove stale subscriber count logic and types refactor (#2832) * refactor: remove stale subscriber count logic and types refactor (#2782) * fix: lint issues * feat: Message list pagination implementation using hasPrev and hasNext (#2799) * refactor: remove stale subscriber count logic and types refactor (#2782) * feat: new message list pagination implementation * fix: channel state initial data * fix: revert back the onStartReached change * fix: lint and tests * fix: added tests for the hooks and message pagination * fix: podlock file for sample app * fix: throttle logic for the copy message state * fix: add back channel.deleted event * fix: useeffect deps * fix: add theme for the bottom sheet styles and message user reactions item (#2827) * fix: refactor some of the buttons * fix: refactor context usage * fix: solve final cyclical dep * fix: add corrected app.json * tests: add tests * fix: convert all poll buttons to pressables * fix: lint issues (that somehow passed without issues before) * chore: move add comment button to correct file * fix: move vote button to correct file too * fix: scroll to first unread message bug * fix: tests and lint issues * fix: image gallery issues (#2835) * fix: infinite image loading issue * fix: image gallery animations issue * fix: attachment picker unread floating indicator issue * fix: remove unnecessary logs * fix: add tests * fix: add improvements around read channel state * fix: image gallery header and footer safe area view (#2840) * fix: tests and lint issues * fix: tests and lint issues * chore: remove disable if frozen channel (#2841) * chore: write tests for disallowed sending messages (#2839) * chore: write tests for disallowed sending messages * fix: make test name more concise * chore: remove redundant assertion * chore: add test for the editing state as well * fix: only disable message input ui when capabilities change (#2836) * chore: remove disableIfFrozenChannel prop * fix: lint issues * fix: old arch image resizer native module spec file name fix (#2842) * fix: image gallery header and footer safe area view * fix: old arch image resizer native module spec file name fix * chore: fix all of the GH workflow stuff related to v6 in prep for releasing (#2843) * fix: move logic to component * fix: lint issues * fix: issues with handleSyncDb events * fix: dep update * fix: dep update * fiX: message list scroll settime outs * fix: lint issues * fix: broken tests --------- Co-authored-by: Ivan Sekovanikj <31964049+isekovanic@users.noreply.github.com> Co-authored-by: semantic-release-bot Co-authored-by: Alexey Alter-Pesotskiy Co-authored-by: Stream SDK Bot <60655709+Stream-SDK-Bot@users.noreply.github.com> Co-authored-by: Stream Bot Co-authored-by: Santhosh Vaiyapuri <3846977+santhoshvai@users.noreply.github.com> Co-authored-by: Oliver Lazoroski Co-authored-by: Ivan Sekovanikj Co-authored-by: Stream Bot --- examples/SampleApp/ios/Podfile.lock | 10 +- examples/SampleApp/yarn.lock | 8 +- package/src/components/Channel/Channel.tsx | 125 +- .../Channel/__tests__/Channel.test.js | 167 +- .../Channel/__tests__/ownCapabilities.test.js | 26 + .../useMessageListPagination.test.js | 271 +++- .../Channel/hooks/useChannelDataState.ts | 8 + .../Channel/hooks/useCreateChannelContext.ts | 11 + .../Channel/hooks/useCreateMessagesContext.ts | 4 + .../hooks/useMessageListPagination.tsx | 198 ++- .../Channel/hooks/useTargetedMessage.ts | 11 +- .../Chat/hooks/handleEventToSyncDB.ts | 24 +- package/src/components/Message/Message.tsx | 8 + .../Message/hooks/useMessageActionHandlers.ts | 94 +- .../Message/hooks/useMessageActions.tsx | 45 +- .../Message/utils/messageActions.ts | 6 + .../MessageList/InlineUnreadIndicator.tsx | 43 +- .../components/MessageList/MessageList.tsx | 428 +++--- .../UnreadMessagesNotification.tsx | 107 ++ .../MessageList/__tests__/MessageList.test.js | 213 +++ .../MessageMenu/MessageActionListItem.tsx | 3 +- .../__snapshots__/Thread.test.js.snap | 1348 ++++++++--------- .../channelContext/ChannelContext.tsx | 44 +- .../messagesContext/MessagesContext.tsx | 9 +- .../src/contexts/themeContext/utils/theme.ts | 12 + package/src/i18n/en.json | 2 + package/src/i18n/es.json | 2 + package/src/i18n/fr.json | 2 + package/src/i18n/he.json | 2 + package/src/i18n/hi.json | 2 + package/src/i18n/it.json | 2 + package/src/i18n/ja.json | 2 + package/src/i18n/ko.json | 2 + package/src/i18n/nl.json | 2 + package/src/i18n/pt-br.json | 2 + package/src/i18n/ru.json | 2 + package/src/i18n/tr.json | 2 + package/src/icons/UnreadIndicator.tsx | 18 + package/src/icons/index.ts | 1 + package/src/store/SqliteClient.ts | 2 +- package/src/store/schema.ts | 2 + package/src/types/types.ts | 7 +- package/src/utils/utils.ts | 62 +- 43 files changed, 2138 insertions(+), 1201 deletions(-) create mode 100644 package/src/components/MessageList/UnreadMessagesNotification.tsx create mode 100644 package/src/icons/UnreadIndicator.tsx diff --git a/examples/SampleApp/ios/Podfile.lock b/examples/SampleApp/ios/Podfile.lock index 16691dc665..7d7c542c23 100644 --- a/examples/SampleApp/ios/Podfile.lock +++ b/examples/SampleApp/ios/Podfile.lock @@ -2164,7 +2164,7 @@ PODS: - libwebp (~> 1.0) - SDWebImage/Core (~> 5.10) - SocketRocket (0.7.1) - - stream-chat-react-native (6.0.0): + - stream-chat-react-native (6.0.1): - DoubleConversion - glog - hermes-engine @@ -2474,7 +2474,7 @@ EXTERNAL SOURCES: SPEC CHECKSUMS: boost: 1dca942403ed9342f98334bf4c3621f011aa7946 - DoubleConversion: f16ae600a246532c4020132d54af21d0ddb2a385 + DoubleConversion: 76ab83afb40bddeeee456813d9c04f67f78771b5 FBLazyVector: 1bf99bb46c6af9a2712592e707347315f23947aa Firebase: 7a56fe4f56b5ab81b86a6822f5b8f909ae6fc7e2 FirebaseAnalytics: 2f4a11eeb7a0e9c6fcf642d4e6aaca7fa4d38c28 @@ -2488,7 +2488,7 @@ SPEC CHECKSUMS: FirebaseRemoteConfigInterop: e75e348953352a000331eb77caf01e424248e176 FirebaseSessions: b252b3f91a51186188882ea8e7e1730fc1eee391 fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be - glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a + glog: 69ef571f3de08433d766d614c73a9838a06bf7eb GoogleAppMeasurement: ee5c2d2242816773fbf79e5b0563f5355ef1c315 GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d @@ -2576,9 +2576,9 @@ SPEC CHECKSUMS: SDWebImage: a7f831e1a65eb5e285e3fb046a23fcfbf08e696d SDWebImageWebPCoder: 908b83b6adda48effe7667cd2b7f78c897e5111d SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - stream-chat-react-native: 557a9b07b068fea9a1670302ca43f8589d884e33 + stream-chat-react-native: 4b3bb162446ad9b25c745fc8083a2516d363d5eb Yoga: 7548e4449365bf0ef60db4aefe58abff37fcabec PODFILE CHECKSUM: 4f662370295f8f9cee909f1a4c59a614999a209d -COCOAPODS: 1.14.3 +COCOAPODS: 1.16.2 diff --git a/examples/SampleApp/yarn.lock b/examples/SampleApp/yarn.lock index 0a71c04630..da3223de8c 100644 --- a/examples/SampleApp/yarn.lock +++ b/examples/SampleApp/yarn.lock @@ -7952,10 +7952,10 @@ statuses@~1.5.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -stream-chat-react-native-core@6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-6.0.0.tgz#77798c7082877572ef70223e1f799d22f0c78fe7" - integrity sha512-3cFao8iL2MjP7nhVRAl1vi526FbPlqUj4BHnYQ7sUNe+xb4z/HCEL6fKFh8kIfK5MEAacOQO4juPPQktoIf7zg== +stream-chat-react-native-core@6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/stream-chat-react-native-core/-/stream-chat-react-native-core-6.0.1.tgz#a67f14685519cafa58466d28eee8f1edc9dbafcf" + integrity sha512-kyHgGn2PF+JTt7eEKdHMot9Nxzx+yecnlut9oyhi/IJbxOwpjIgB87+rdQXEI5o8SeNwQuAeV3VatxGaxl5Jbw== dependencies: "@gorhom/bottom-sheet" "^5.0.6" dayjs "1.10.5" diff --git a/package/src/components/Channel/Channel.tsx b/package/src/components/Channel/Channel.tsx index 27b4546d52..da10dce43a 100644 --- a/package/src/components/Channel/Channel.tsx +++ b/package/src/components/Channel/Channel.tsx @@ -12,7 +12,6 @@ import { Channel as ChannelType, EventHandler, FormatMessageResponse, - logChatPromiseExecution, MessageResponse, Reaction, SendMessageAPIResponse, @@ -92,7 +91,7 @@ import { isImagePickerAvailable, } from '../../native'; import * as dbApi from '../../store/apis'; -import { DefaultStreamChatGenerics, FileTypes } from '../../types/types'; +import { ChannelUnreadState, DefaultStreamChatGenerics, FileTypes } from '../../types/types'; import { addReactionToLocalState } from '../../utils/addReactionToLocalState'; import { compressedImageURI } from '../../utils/compressImage'; import { DBSyncManager } from '../../utils/DBSyncManager'; @@ -179,6 +178,7 @@ import { ScrollToBottomButton as ScrollToBottomButtonDefault } from '../MessageL import { StickyHeader as StickyHeaderDefault } from '../MessageList/StickyHeader'; import { TypingIndicator as TypingIndicatorDefault } from '../MessageList/TypingIndicator'; import { TypingIndicatorContainer as TypingIndicatorContainerDefault } from '../MessageList/TypingIndicatorContainer'; +import { UnreadMessagesNotification as UnreadMessagesNotificationDefault } from '../MessageList/UnreadMessagesNotification'; import { MessageActionList as MessageActionListDefault } from '../MessageMenu/MessageActionList'; import { MessageActionListItem as MessageActionListItemDefault } from '../MessageMenu/MessageActionListItem'; import { MessageMenu as MessageMenuDefault } from '../MessageMenu/MessageMenu'; @@ -188,6 +188,15 @@ import { MessageUserReactionsAvatar as MessageUserReactionsAvatarDefault } from import { MessageUserReactionsItem as MessageUserReactionsItemDefault } from '../MessageMenu/MessageUserReactionsItem'; import { Reply as ReplyDefault } from '../Reply/Reply'; +export type MarkReadFunctionOptions = { + /** + * Signal, whether the `channelUnreadUiState` should be updated. + * By default, the local state update is prevented when the Channel component is mounted. + * This is in order to keep the UI indicating the original unread state, when the user opens a channel. + */ + updateChannelUnreadState?: boolean; +}; + const styles = StyleSheet.create({ selectChannel: { fontWeight: 'bold', padding: 16 }, }); @@ -301,6 +310,7 @@ export type ChannelPropsWithContext< | 'handleDelete' | 'handleEdit' | 'handleFlag' + | 'handleMarkUnread' | 'handleMute' | 'handlePinMessage' | 'handleReaction' @@ -360,6 +370,7 @@ export type ChannelPropsWithContext< | 'VideoThumbnail' | 'PollContent' | 'hasCreatePoll' + | 'UnreadMessagesNotification' | 'StreamingMessageView' > > & @@ -384,7 +395,10 @@ export type ChannelPropsWithContext< * Overrides the Stream default mark channel read request (Advanced usage only) * @param channel Channel object */ - doMarkReadRequest?: (channel: ChannelType) => void; + doMarkReadRequest?: ( + channel: ChannelType, + setChannelUnreadUiState?: (state: ChannelUnreadState) => void, + ) => void; /** * Overrides the Stream default send message request (Advanced usage only) * @param channelId @@ -433,6 +447,10 @@ export type ChannelPropsWithContext< * Custom loading error indicator to override the Stream default */ LoadingErrorIndicator?: React.ComponentType; + /** + * Boolean flag to enable/disable marking the channel as read on mount + */ + markReadOnMount?: boolean; maxMessageLength?: number; /** * Load the channel at a specified message instead of the most recent message. @@ -529,6 +547,7 @@ const ChannelWithContext = < handleDelete, handleEdit, handleFlag, + handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, @@ -566,6 +585,7 @@ const ChannelWithContext = < loadingMore: loadingMoreProp, loadingMoreRecent: loadingMoreRecentProp, markdownRules, + markReadOnMount = true, maxMessageLength: maxMessageLengthProp, maxNumberOfFiles = 10, maxTimeBetweenGroupedMessages, @@ -647,6 +667,7 @@ const ChannelWithContext = < threadMessages, TypingIndicator = TypingIndicatorDefault, TypingIndicatorContainer = TypingIndicatorContainerDefault, + UnreadMessagesNotification = UnreadMessagesNotificationDefault, UploadProgressIndicator = UploadProgressIndicatorDefault, UrlPreview = CardDefault, VideoThumbnail = VideoThumbnailDefault, @@ -674,10 +695,13 @@ const ChannelWithContext = < const [thread, setThread] = useState | null>(threadProps || null); const [threadHasMore, setThreadHasMore] = useState(true); const [threadLoadingMore, setThreadLoadingMore] = useState(false); + const [channelUnreadState, setChannelUnreadState] = useState( + undefined, + ); const syncingChannelRef = useRef(false); - const { setTargetedMessage, targetedMessage } = useTargetedMessage(); + const { highlightedMessageId, setTargetedMessage, targetedMessage } = useTargetedMessage(); /** * This ref will hold the abort controllers for @@ -692,6 +716,7 @@ const ChannelWithContext = < const { copyStateFromChannel, initStateFromChannel, + setRead, setTyping, state: channelState, } = useChannelDataState(channel); @@ -754,6 +779,22 @@ const ChannelWithContext = < } } + if (event.type === 'notification.mark_unread') { + setChannelUnreadState((prev) => { + if (!(event.last_read_at && event.user)) return prev; + return { + first_unread_message_id: event.first_unread_message_id, + last_read: new Date(event.last_read_at), + last_read_message_id: event.last_read_message_id, + unread_messages: event.unread_messages ?? 0, + }; + }); + } + + if (event.type === 'channel.truncated' && event.cid === channel.cid) { + setChannelUnreadState(undefined); + } + // only update channel state if the events are not the previously subscribed useEffect's subscription events if (channel && channel.initialized) { copyChannelState(); @@ -764,6 +805,8 @@ const ChannelWithContext = < useEffect(() => { let listener: ReturnType; const initChannel = async () => { + setLastRead(new Date()); + const unreadCount = channel.countUnread(); if (!channel || !shouldSyncChannel || channel.offlineMode) return; let errored = false; @@ -782,14 +825,32 @@ const ChannelWithContext = < loadInitialMessagesStateFromChannel(channel, channel.state.messagePagination.hasPrev); } + if (client.user?.id && channel.state.read[client.user.id]) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { user, ...ownReadState } = channel.state.read[client.user.id]; + setChannelUnreadState(ownReadState); + } + if (messageId) { await loadChannelAroundMessage({ messageId, setTargetedMessage }); } else if ( initialScrollToFirstUnreadMessage && - channel.countUnread() > scrollToFirstUnreadThreshold + client.user && + unreadCount > scrollToFirstUnreadThreshold ) { - await loadChannelAtFirstUnreadMessage({ setTargetedMessage }); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { user, ...ownReadState } = channel.state.read[client.user.id]; + await loadChannelAtFirstUnreadMessage({ + channelUnreadState: ownReadState, + setChannelUnreadState, + setTargetedMessage, + }); + } + + if (unreadCount > 0 && markReadOnMount) { + await markRead({ updateChannelUnreadState: false }); } + listener = channel.on(handleEvent); }; @@ -819,12 +880,12 @@ const ChannelWithContext = < */ useEffect(() => { const handleEvent: EventHandler = (event) => { - if (channel.cid === event.cid) copyChannelState(); + if (channel.cid === event.cid) setRead(channel); }; const { unsubscribe } = client.on('notification.mark_read', handleEvent); return unsubscribe; - }, [channel.cid, client, copyChannelState]); + }, [channel, client, setRead]); const threadPropsExists = !!threadProps; @@ -858,23 +919,33 @@ const ChannelWithContext = < /** * CHANNEL METHODS */ - const markRead: ChannelContextValue['markRead'] = useRef( - throttle( - () => { - if (!channel || channel?.disconnected || !clientChannelConfig?.read_events) { - return; - } + const markRead: ChannelContextValue['markRead'] = throttle( + async (options?: MarkReadFunctionOptions) => { + const { updateChannelUnreadState = true } = options ?? {}; + if (!channel || channel?.disconnected || !clientChannelConfig?.read_events) { + return; + } - if (doMarkReadRequest) { - doMarkReadRequest(channel); - } else { - logChatPromiseExecution(channel.markRead(), 'mark read'); + if (doMarkReadRequest) { + doMarkReadRequest(channel, updateChannelUnreadState ? setChannelUnreadState : undefined); + } else { + try { + const response = await channel.markRead(); + if (updateChannelUnreadState && response && lastRead) { + setChannelUnreadState({ + last_read: lastRead, + last_read_message_id: response?.event.last_read_message_id, + unread_messages: 0, + }); + } + } catch (err) { + console.log('Error marking channel as read:', err); } - }, - defaultThrottleInterval, - throttleOptions, - ), - ).current; + } + }, + defaultThrottleInterval, + throttleOptions, + ); const reloadThread = async () => { if (!channel || !thread?.id) return; @@ -1596,8 +1667,9 @@ const ChannelWithContext = < overrideCapabilities: overrideOwnCapabilities, }); - const channelContext = useCreateChannelContext({ + const channelContext = useCreateChannelContext({ channel, + channelUnreadState, disabled: !!channel?.data?.frozen, EmptyStateIndicator, enableMessageGroupingByUser, @@ -1608,9 +1680,11 @@ const ChannelWithContext = < !!(clientChannelConfig?.commands || [])?.some((command) => command.name === 'giphy'), hideDateSeparators, hideStickyDateHeader, + highlightedMessageId, isChannelActive: shouldSyncChannel, lastRead, loadChannelAroundMessage, + loadChannelAtFirstUnreadMessage, loading: channelMessagesState.loading, LoadingIndicator, markRead, @@ -1620,6 +1694,7 @@ const ChannelWithContext = < read: channelState.read ?? {}, reloadChannel, scrollToFirstUnreadThreshold, + setChannelUnreadState, setLastRead, setTargetedMessage, StickyHeader, @@ -1748,6 +1823,7 @@ const ChannelWithContext = < handleDelete, handleEdit, handleFlag, + handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, @@ -1815,6 +1891,7 @@ const ChannelWithContext = < targetedMessage, TypingIndicator, TypingIndicatorContainer, + UnreadMessagesNotification, updateMessage, UrlPreview, VideoThumbnail, diff --git a/package/src/components/Channel/__tests__/Channel.test.js b/package/src/components/Channel/__tests__/Channel.test.js index 4254bcabb0..a7d3b7c39e 100644 --- a/package/src/components/Channel/__tests__/Channel.test.js +++ b/package/src/components/Channel/__tests__/Channel.test.js @@ -29,6 +29,7 @@ import { useChannelDataState, useChannelMessageDataState, } from '../hooks/useChannelDataState'; +import * as MessageListPaginationHooks from '../hooks/useMessageListPagination'; // This component is used for performing effects in a component that consumes ChannelContext, // i.e. making use of the callbacks & values provided by the Channel component. @@ -87,6 +88,7 @@ describe('Channel', () => { const nullChannel = { ...channel, cid: null, + countUnread: () => 0, off: () => {}, on: () => ({ unsubscribe: () => null, @@ -464,79 +466,128 @@ describe('Channel initial load useEffect', () => { ); }); - it("should not call loadChannelAtFirstUnreadMessage if channel's unread count is 0", async () => { - const mockedChannel = generateChannelResponse({ - messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + describe('initialScrollToFirstUnreadMessage', () => { + afterEach(() => { + // Clear all mocks after each test + jest.clearAllMocks(); + // Restore all mocks to their original implementation + jest.restoreAllMocks(); + cleanup(); }); + const mockedHook = (values) => + jest.spyOn(MessageListPaginationHooks, 'useMessageListPagination').mockImplementation(() => ({ + copyMessagesStateFromChannel: jest.fn(), + loadChannelAroundMessage: jest.fn(), + loadChannelAtFirstUnreadMessage: jest.fn(), + loadInitialMessagesStateFromChannel: jest.fn(), + loadLatestMessages: jest.fn(), + loadMore: jest.fn(), + loadMoreRecent: jest.fn(), + state: { ...channelInitialState }, + ...values, + })); + it("should not call loadChannelAtFirstUnreadMessage if channel's unread count is 0", async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.watch(); - const messages = Array.from({ length: 100 }, (_, i) => generateMessage({ id: i })); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + const user = generateUser(); + const read_data = {}; - const loadMessageIntoState = jest.fn(); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - }; - channel.countUnread = jest.fn(() => 0); + read_data[chatClient.user.id] = { + last_read: new Date(), + user, + }; - renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); + channel.state = { + ...channelInitialState, + read: read_data, + }; + channel.countUnread = jest.fn(() => 0); - await waitFor(() => { - expect(loadMessageIntoState).not.toHaveBeenCalled(); - }); - }); + const loadChannelAtFirstUnreadMessageFn = jest.fn(); - it("should call loadChannelAtFirstUnreadMessage if channel's unread count is greater than 0", async () => { - const mockedChannel = generateChannelResponse({ - messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), - }); + mockedHook({ loadChannelAtFirstUnreadMessage: loadChannelAtFirstUnreadMessageFn }); - useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); - const channel = chatClient.channel('messaging', mockedChannel.id); - await channel.watch(); - const messages = Array.from({ length: 100 }, (_, i) => generateMessage({ id: i })); + renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); - let targetedMessageId = 0; - const loadMessageIntoState = jest.fn((id) => { - targetedMessageId = id; - const newMessages = getElementsAround(messages, 'id', id); - channel.state.messages = newMessages; + await waitFor(() => { + expect(loadChannelAtFirstUnreadMessageFn).not.toHaveBeenCalled(); + }); }); - channel.state = { - ...channelInitialState, - loadMessageIntoState, - messagePagination: { - hasNext: true, - hasPrev: true, - }, - messages, - messageSets: [{ isCurrent: true, isLatest: true }], - }; + it("should call loadChannelAtFirstUnreadMessage if channel's unread count is greater than 0", async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); - channel.countUnread = jest.fn(() => 15); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); - renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); + const user = generateUser(); + const numberOfUnreadMessages = 15; + const read_data = {}; - await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(1); + read_data[chatClient.user.id] = { + last_read: new Date(), + unread_messages: numberOfUnreadMessages, + user, + }; + channel.state = { + ...channelInitialState, + read: read_data, + }; + + channel.countUnread = jest.fn(() => numberOfUnreadMessages); + const loadChannelAtFirstUnreadMessageFn = jest.fn(); + + mockedHook({ loadChannelAtFirstUnreadMessage: loadChannelAtFirstUnreadMessageFn }); + + renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); + + await waitFor(() => { + expect(loadChannelAtFirstUnreadMessageFn).toHaveBeenCalled(); + }); }); - const { result: channelMessageState } = renderHook(() => useChannelMessageDataState(channel)); - await waitFor(() => - expect( - channelMessageState.current.state.messages.find( - (message) => message.id === targetedMessageId, - ), - ).toBeDefined(), - ); + it("should not call loadChannelAtFirstUnreadMessage if channel's unread count is greater than 0 lesser than scrollToFirstUnreadThreshold", async () => { + const mockedChannel = generateChannelResponse({ + messages: Array.from({ length: 10 }, (_, i) => generateMessage({ text: `message-${i}` })), + }); + + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + const user = generateUser(); + const numberOfUnreadMessages = 2; + const read_data = {}; + + read_data[chatClient.user.id] = { + last_read: new Date(), + unread_messages: numberOfUnreadMessages, + user, + }; + channel.state = { + ...channelInitialState, + read: read_data, + }; + + channel.countUnread = jest.fn(() => numberOfUnreadMessages); + const loadChannelAtFirstUnreadMessageFn = jest.fn(); + + mockedHook({ loadChannelAtFirstUnreadMessage: loadChannelAtFirstUnreadMessageFn }); + + renderComponent({ channel, initialScrollToFirstUnreadMessage: true }); + + await waitFor(() => { + expect(loadChannelAtFirstUnreadMessageFn).not.toHaveBeenCalled(); + }); + }); }); it('should call resyncChannel when connection changed event is triggered', async () => { diff --git a/package/src/components/Channel/__tests__/ownCapabilities.test.js b/package/src/components/Channel/__tests__/ownCapabilities.test.js index a879746ec7..ac0ef3d9fe 100644 --- a/package/src/components/Channel/__tests__/ownCapabilities.test.js +++ b/package/src/components/Channel/__tests__/ownCapabilities.test.js @@ -236,6 +236,32 @@ describe('Own capabilities', () => { }); }); + describe(`${allOwnCapabilities.readEvents} capability`, () => { + it(`should render "Mark as Unread" action for messages when "${allOwnCapabilities.readEvents}" capability is enabled`, async () => { + await generateChannelWithCapabilities([allOwnCapabilities.readEvents]); + const { queryByLabelText } = await renderChannelAndOpenMessageActionsList(receivedMessage); + expect(!!queryByLabelText('markUnread action list item')).toBeTruthy(); + }); + + it(`should not render "Mark Read" action for received message when "${allOwnCapabilities.readEvents}" capability is disabled`, async () => { + await generateChannelWithCapabilities(); + + const { queryByLabelText } = await renderChannelAndOpenMessageActionsList(receivedMessage); + expect(!!queryByLabelText('markUnread action list item')).toBeFalsy(); + }); + + it('should override capability from "overrideOwnCapability.readEvents" prop', async () => { + await generateChannelWithCapabilities([allOwnCapabilities.readEvents]); + + const { queryByLabelText } = await renderChannelAndOpenMessageActionsList(receivedMessage, { + overrideOwnCapabilities: { + readEvents: false, + }, + }); + expect(!!queryByLabelText('markUnread action list item')).toBeFalsy(); + }); + }); + describe(`${allOwnCapabilities.pinMessage} capability`, () => { it(`should render "Pin Message" action for sent message when "${allOwnCapabilities.pinMessage}" capability is enabled`, async () => { await generateChannelWithCapabilities([allOwnCapabilities.pinMessage]); diff --git a/package/src/components/Channel/__tests__/useMessageListPagination.test.js b/package/src/components/Channel/__tests__/useMessageListPagination.test.js index 3c9dbcca8e..989d13c1e3 100644 --- a/package/src/components/Channel/__tests__/useMessageListPagination.test.js +++ b/package/src/components/Channel/__tests__/useMessageListPagination.test.js @@ -337,10 +337,11 @@ describe('useMessageListPagination', () => { }); it('should not do anything when the unread count is 0', async () => { + const messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ text: `message-${i}` }), + ); const loadMessageIntoState = jest.fn(() => { - channel.state.messages = Array.from({ length: 20 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); + channel.state.messages = messages; channel.state.messagePagination.hasPrev = true; }); channel.state = { @@ -352,68 +353,264 @@ describe('useMessageListPagination', () => { }, }; - channel.countUnread = jest.fn(() => 0); + const user = generateUser(); + const channelUnreadState = { + unread_messages: 0, + user, + }; + + const jumpToMessageFinishedMock = jest.fn(); + mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); const { result } = renderHook(() => useMessageListPagination({ channel })); await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({}); + await result.current.loadChannelAtFirstUnreadMessage({ channelUnreadState }); }); await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(0); + expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes(0); }); }); - function getElementsAround(array, key, id, limit) { - const index = array.findIndex((obj) => obj[key] === id); - - if (index === -1) { - return []; - } - - const start = Math.max(0, index - limit); // 12 before the index - const end = Math.min(array.length, index + limit); // 12 after the index - return array.slice(start, end); - } + const generateMessageArray = (length = 20) => + Array.from({ length }, (_, i) => generateMessage({ id: i, text: `message-${i}` })); + + // Test cases with different scenarios + const testCases = [ + { + channelUnreadState: (messages) => ({ + first_unread_message_id: messages[2].id, + unread_messages: 2, + }), + expectedCalls: { + jumpToMessageFinishedCalls: 1, + loadMessageIntoStateCalls: 0, + setChannelUnreadStateCalls: 0, + setTargetedMessageIdCalls: 1, + targetedMessageId: (messages) => messages[2].id, + }, + initialMessages: generateMessageArray(), + name: 'first_unread_message_id present in current message set', + setupLoadMessageIntoState: null, + }, + { + channelUnreadState: () => ({ + first_unread_message_id: 21, + unread_messages: 2, + }), + expectedCalls: { + jumpToMessageFinishedCalls: 1, + loadMessageIntoStateCalls: 1, + setChannelUnreadStateCalls: 0, + setTargetedMessageIdCalls: 1, + targetedMessageId: () => 21, + }, + initialMessages: generateMessageArray(), + name: 'first_unread_message_id not present in current message set', + setupLoadMessageIntoState: (channel) => { + const loadMessageIntoState = jest.fn(() => { + const newMessages = Array.from({ length: 20 }, (_, i) => + generateMessage({ id: i + 21, text: `message-${i + 21}` }), + ); + channel.state.messages = newMessages; + channel.state.messagePagination.hasPrev = true; + }); + channel.state.loadMessageIntoState = loadMessageIntoState; + return loadMessageIntoState; + }, + }, + { + channelUnreadState: (messages) => ({ + last_read_message_id: messages[2].id, + unread_messages: 2, + }), + expectedCalls: { + jumpToMessageFinishedCalls: 1, + loadMessageIntoStateCalls: 0, + setChannelUnreadStateCalls: 1, + setTargetedMessageIdCalls: 1, + targetedMessageId: (messages) => messages[3].id, + }, + initialMessages: generateMessageArray(), + name: 'last_read_message_id present in current message set', + setupLoadMessageIntoState: null, + }, + { + channelUnreadState: () => ({ + last_read_message_id: 21, + unread_messages: 2, + }), + expectedCalls: { + jumpToMessageFinishedCalls: 1, + loadMessageIntoStateCalls: 1, + setChannelUnreadStateCalls: 1, + setTargetedMessageIdCalls: 1, + targetedMessageId: () => 22, + }, + initialMessages: generateMessageArray(), + name: 'last_read_message_id not present in current message set', + setupLoadMessageIntoState: (channel) => { + const loadMessageIntoState = jest.fn(() => { + const newMessages = Array.from({ length: 20 }, (_, i) => + generateMessage({ id: i + 21, text: `message-${i + 21}` }), + ); + channel.state.messages = newMessages; + channel.state.messagePagination.hasPrev = true; + }); + channel.state.loadMessageIntoState = loadMessageIntoState; + return loadMessageIntoState; + }, + }, + ]; - it('should call the loadMessageIntoState function when the unread count is greater than 0 and set the state', async () => { - const messages = Array.from({ length: 30 }, (_, i) => - generateMessage({ text: `message-${i}` }), - ); - const loadMessageIntoState = jest.fn((messageId) => { - channel.state.messages = getElementsAround(messages, 'id', messageId, 5); - channel.state.messagePagination.hasPrev = true; - }); + it.each(testCases)('$name', async (testCase) => { + // Setup channel state + const messages = testCase.initialMessages; channel.state = { ...channelInitialState, - loadMessageIntoState, messagePagination: { - hasNext: false, + hasNext: true, hasPrev: true, }, messages, - messageSets: [{ isCurrent: true, isLatest: true }], }; - const unreadCount = 5; - channel.countUnread = jest.fn(() => unreadCount); + // Setup additional mocks if needed + const loadMessageIntoStateMock = testCase.setupLoadMessageIntoState + ? testCase.setupLoadMessageIntoState(channel) + : null; + + // Generate user and channel unread state + const user = generateUser(); + const channelUnreadState = { + user, + ...testCase.channelUnreadState(messages), + }; + + // Setup mocks + const jumpToMessageFinishedMock = jest.fn(); + mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); const { result } = renderHook(() => useMessageListPagination({ channel })); + const setChannelUnreadStateMock = jest.fn(); + const setTargetedMessageIdMock = jest.fn((message) => message); + + // Execute the method await act(async () => { - await result.current.loadChannelAtFirstUnreadMessage({}); + await result.current.loadChannelAtFirstUnreadMessage({ + channelUnreadState, + setChannelUnreadState: setChannelUnreadStateMock, + setTargetedMessage: setTargetedMessageIdMock, + }); }); + // Verify expectations await waitFor(() => { - expect(loadMessageIntoState).toHaveBeenCalledTimes(1); - expect(result.current.state.hasMore).toBe(true); - expect(result.current.state.hasMoreNewer).toBe(false); - expect(result.current.state.messages.length).toBe(10); - expect(result.current.state.targetedMessageId).toBe( - messages[messages.length - unreadCount].id, + if (loadMessageIntoStateMock) { + expect(loadMessageIntoStateMock).toHaveBeenCalledTimes( + testCase.expectedCalls.loadMessageIntoStateCalls, + ); + } + + expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes( + testCase.expectedCalls.jumpToMessageFinishedCalls, + ); + + expect(setChannelUnreadStateMock).toHaveBeenCalledTimes( + testCase.expectedCalls.setChannelUnreadStateCalls, ); + + expect(setTargetedMessageIdMock).toHaveBeenCalledTimes( + testCase.expectedCalls.setTargetedMessageIdCalls, + ); + + if (testCase.expectedCalls.targetedMessageId) { + const expectedMessageId = testCase.expectedCalls.targetedMessageId(messages); + expect(setTargetedMessageIdMock).toHaveBeenCalledWith(expectedMessageId); + } }); }); + + const messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ + created_at: new Date(`2021-09-01T00:00:00.000Z`), + id: i, + text: `message-${i}`, + }), + ); + + const user = generateUser(); + + it.each` + scenario | last_read | expectedQueryCalls | expectedJumpToMessageFinishedCalls | expectedSetChannelUnreadStateCalls | expectedSetTargetedMessageCalls | expectedTargetedMessageId + ${'when last_read matches a message'} | ${new Date(messages[10].created_at)} | ${0} | ${1} | ${1} | ${1} | ${10} + ${'when last_read does not match any message'} | ${new Date('2021-09-02T00:00:00.000Z')} | ${1} | ${0} | ${0} | ${0} | ${undefined} + `( + '$scenario', + async ({ + expectedJumpToMessageFinishedCalls, + expectedQueryCalls, + expectedSetChannelUnreadStateCalls, + expectedSetTargetedMessageCalls, + expectedTargetedMessageId, + last_read, + }) => { + // Set up channel state + channel.state = { + ...channelInitialState, + messagePagination: { + hasNext: true, + hasPrev: true, + }, + messages, + }; + + const channelUnreadState = { + last_read, + unread_messages: 2, + user, + }; + + // Mock query if needed + const queryMock = jest.fn(); + channel.query = queryMock; + + // Set up mocks + const jumpToMessageFinishedMock = jest.fn(); + mockedHook(channelInitialState, { jumpToMessageFinished: jumpToMessageFinishedMock }); + const setChannelUnreadStateMock = jest.fn(); + const setTargetedMessageIdMock = jest.fn((message) => message); + + // Render hook + const { result } = renderHook(() => useMessageListPagination({ channel })); + + // Act + await act(async () => { + await result.current.loadChannelAtFirstUnreadMessage({ + channelUnreadState, + setChannelUnreadState: setChannelUnreadStateMock, + setTargetedMessage: setTargetedMessageIdMock, + }); + }); + + // Assert + await waitFor(() => { + expect(queryMock).toHaveBeenCalledTimes(expectedQueryCalls); + expect(jumpToMessageFinishedMock).toHaveBeenCalledTimes( + expectedJumpToMessageFinishedCalls, + ); + expect(setChannelUnreadStateMock).toHaveBeenCalledTimes( + expectedSetChannelUnreadStateCalls, + ); + expect(setTargetedMessageIdMock).toHaveBeenCalledTimes(expectedSetTargetedMessageCalls); + + if (expectedTargetedMessageId !== undefined) { + expect(setTargetedMessageIdMock).toHaveBeenCalledWith(expectedTargetedMessageId); + } + }); + }, + ); }); }); diff --git a/package/src/components/Channel/hooks/useChannelDataState.ts b/package/src/components/Channel/hooks/useChannelDataState.ts index 3fb6c8fdaf..f33a1d87e6 100644 --- a/package/src/components/Channel/hooks/useChannelDataState.ts +++ b/package/src/components/Channel/hooks/useChannelDataState.ts @@ -219,6 +219,13 @@ export const useChannelDataState = < })); }, []); + const setRead = useCallback((channel: Channel) => { + setState((prev) => ({ + ...prev, + read: { ...channel.state.read }, // Synchronize the read state from the channel + })); + }, []); + const setTyping = useCallback((channel: Channel) => { setState((prev) => ({ ...prev, @@ -229,6 +236,7 @@ export const useChannelDataState = < return { copyStateFromChannel, initStateFromChannel, + setRead, setTyping, state, }; diff --git a/package/src/components/Channel/hooks/useCreateChannelContext.ts b/package/src/components/Channel/hooks/useCreateChannelContext.ts index 3857d0749d..8b42af61b8 100644 --- a/package/src/components/Channel/hooks/useCreateChannelContext.ts +++ b/package/src/components/Channel/hooks/useCreateChannelContext.ts @@ -7,6 +7,7 @@ export const useCreateChannelContext = < StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, >({ channel, + channelUnreadState, disabled, EmptyStateIndicator, enableMessageGroupingByUser, @@ -15,9 +16,11 @@ export const useCreateChannelContext = < giphyEnabled, hideDateSeparators, hideStickyDateHeader, + highlightedMessageId, isChannelActive, lastRead, loadChannelAroundMessage, + loadChannelAtFirstUnreadMessage, loading, LoadingIndicator, markRead, @@ -27,6 +30,7 @@ export const useCreateChannelContext = < read, reloadChannel, scrollToFirstUnreadThreshold, + setChannelUnreadState, setLastRead, setTargetedMessage, StickyHeader, @@ -43,10 +47,12 @@ export const useCreateChannelContext = < const readUsers = Object.values(read); const readUsersLength = readUsers.length; const readUsersLastReads = readUsers.map(({ last_read }) => last_read.toISOString()).join(); + const stringifiedChannelUnreadState = JSON.stringify(channelUnreadState); const channelContext: ChannelContextValue = useMemo( () => ({ channel, + channelUnreadState, disabled, EmptyStateIndicator, enableMessageGroupingByUser, @@ -55,9 +61,11 @@ export const useCreateChannelContext = < giphyEnabled, hideDateSeparators, hideStickyDateHeader, + highlightedMessageId, isChannelActive, lastRead, loadChannelAroundMessage, + loadChannelAtFirstUnreadMessage, loading, LoadingIndicator, markRead, @@ -67,6 +75,7 @@ export const useCreateChannelContext = < read, reloadChannel, scrollToFirstUnreadThreshold, + setChannelUnreadState, setLastRead, setTargetedMessage, StickyHeader, @@ -82,11 +91,13 @@ export const useCreateChannelContext = < disabled, error, isChannelActive, + highlightedMessageId, lastReadTime, loading, membersLength, readUsersLength, readUsersLastReads, + stringifiedChannelUnreadState, targetedMessage, threadList, watcherCount, diff --git a/package/src/components/Channel/hooks/useCreateMessagesContext.ts b/package/src/components/Channel/hooks/useCreateMessagesContext.ts index e05a8a2ccd..7849cd85b1 100644 --- a/package/src/components/Channel/hooks/useCreateMessagesContext.ts +++ b/package/src/components/Channel/hooks/useCreateMessagesContext.ts @@ -36,6 +36,7 @@ export const useCreateMessagesContext = < handleDelete, handleEdit, handleFlag, + handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, @@ -102,6 +103,7 @@ export const useCreateMessagesContext = < targetedMessage, TypingIndicator, TypingIndicatorContainer, + UnreadMessagesNotification, updateMessage, UrlPreview, VideoThumbnail, @@ -147,6 +149,7 @@ export const useCreateMessagesContext = < handleDelete, handleEdit, handleFlag, + handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, @@ -213,6 +216,7 @@ export const useCreateMessagesContext = < targetedMessage, TypingIndicator, TypingIndicatorContainer, + UnreadMessagesNotification, updateMessage, UrlPreview, VideoThumbnail, diff --git a/package/src/components/Channel/hooks/useMessageListPagination.tsx b/package/src/components/Channel/hooks/useMessageListPagination.tsx index 8d0d87dd84..97e97989eb 100644 --- a/package/src/components/Channel/hooks/useMessageListPagination.tsx +++ b/package/src/components/Channel/hooks/useMessageListPagination.tsx @@ -1,12 +1,13 @@ import { useRef } from 'react'; import debounce from 'lodash/debounce'; -import { Channel, ChannelState } from 'stream-chat'; +import { Channel, ChannelState, MessageResponse } from 'stream-chat'; import { useChannelMessageDataState } from './useChannelDataState'; import { ChannelContextValue } from '../../../contexts/channelContext/ChannelContext'; import { DefaultStreamChatGenerics } from '../../../types/types'; +import { findInMessagesByDate, findInMessagesById } from '../../../utils/utils'; const defaultDebounceInterval = 500; const debounceOptions = { @@ -153,6 +154,8 @@ export const useMessageListPagination = < setTargetedMessage(messageIdToLoadAround); } } catch (error) { + setLoadingMore(false); + setLoading(false); console.warn( 'Message pagination(fetching messages in the channel around a message id) request failed with error:', error, @@ -162,76 +165,143 @@ export const useMessageListPagination = < }; /** - * Loads channel at first unread message. + * Fetch messages around a specific timestamp. */ - const loadChannelAtFirstUnreadMessage = async ({ - limit = 25, - setTargetedMessage, - }: { - limit?: number; - setTargetedMessage?: (messageId: string) => void; - }) => { - let unreadMessageIdToScrollTo: string | undefined; - const unreadCount = channel.countUnread(); - if (unreadCount === 0) return; - const isLatestMessageSetShown = !!channel.state.messageSets.find( - (set) => set.isCurrent && set.isLatest, - ); - - if (isLatestMessageSetShown && unreadCount <= channel.state.messages.length) { - unreadMessageIdToScrollTo = - channel.state.messages[channel.state.messages.length - unreadCount].id; - if (unreadMessageIdToScrollTo) { - setLoadingMore(true); - await channel.state.loadMessageIntoState(unreadMessageIdToScrollTo, undefined, limit); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - jumpToMessageFinished(channel.state.messagePagination.hasNext, unreadMessageIdToScrollTo); - if (setTargetedMessage) { - setTargetedMessage(unreadMessageIdToScrollTo); - } - } - return; + const fetchMessagesAround = async < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, + >( + channel: Channel, + timestamp: string, + limit: number, + ): Promise[]> => { + try { + const { messages } = await channel.query( + { messages: { created_at_around: timestamp, limit } }, + 'new', + ); + return messages; + } catch (error) { + console.error('Error fetching messages around timestamp:', error); + throw error; } - const lastReadDate = channel.lastRead(); - let messages; - if (lastReadDate) { + }; + + /** + * Loads channel at first unread message. + */ + const loadChannelAtFirstUnreadMessage: ChannelContextValue['loadChannelAtFirstUnreadMessage'] = + async ({ channelUnreadState, limit = 25, setChannelUnreadState, setTargetedMessage }) => { try { - messages = ( - await channel.query( - { - messages: { - created_at_around: lastReadDate, - limit: 30, - }, - watch: true, - }, - 'new', - ) - ).messages; - - unreadMessageIdToScrollTo = messages.find( - (m) => lastReadDate < (m.created_at ? new Date(m.created_at) : new Date()), - )?.id; - if (unreadMessageIdToScrollTo) { - setLoadingMore(true); - await channel.state.loadMessageIntoState(unreadMessageIdToScrollTo, undefined, limit); - loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); - jumpToMessageFinished(channel.state.messagePagination.hasNext, unreadMessageIdToScrollTo); - if (setTargetedMessage) { - setTargetedMessage(unreadMessageIdToScrollTo); + if (!channelUnreadState?.unread_messages) return; + const { first_unread_message_id, last_read, last_read_message_id } = channelUnreadState; + let firstUnreadMessageId = first_unread_message_id; + let lastReadMessageId = last_read_message_id; + let isInCurrentMessageSet = false; + const messagesState = channel.state.messages; + + // If the first unread message is already in the current message set, we don't need to load more messages. + if (firstUnreadMessageId) { + const messageIdx = findInMessagesById(messagesState, firstUnreadMessageId); + isInCurrentMessageSet = messageIdx !== -1; + } + // If the last read message is already in the current message set, we don't need to load more messages, and we set the first unread message id as that is what we want to operate on. + else if (lastReadMessageId) { + const messageIdx = findInMessagesById(messagesState, lastReadMessageId); + isInCurrentMessageSet = messageIdx !== -1; + firstUnreadMessageId = messageIdx > -1 ? messagesState[messageIdx + 1]?.id : undefined; + } else { + const lastReadTimestamp = last_read.getTime(); + const { index: lastReadIdx, message: lastReadMessage } = findInMessagesByDate( + messagesState, + last_read, + ); + if (lastReadMessage) { + lastReadMessageId = lastReadMessage.id; + firstUnreadMessageId = messagesState[lastReadIdx + 1].id; + isInCurrentMessageSet = !!firstUnreadMessageId; + } else { + setLoadingMore(true); + setLoading(true); + let messages; + try { + messages = await fetchMessagesAround(channel, last_read.toISOString(), limit); + } catch (error) { + setLoading(false); + loadMoreFinished(channel.state.messagePagination.hasPrev, messagesState); + console.log('Loading channel at first unread message failed with error:', error); + return; + } + + const firstMessageWithCreationDate = messages.find((msg) => msg.created_at); + if (!firstMessageWithCreationDate) { + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + throw new Error('Failed to jump to first unread message id.'); + } + const firstMessageTimestamp = new Date( + firstMessageWithCreationDate.created_at as string, + ).getTime(); + + if (lastReadTimestamp < firstMessageTimestamp) { + // whole channel is unread + firstUnreadMessageId = firstMessageWithCreationDate.id; + } else { + const result = findInMessagesByDate(messages, last_read); + lastReadMessageId = result.message?.id; + } + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); } } + + // If we still don't have the first and last read message id, we can't proceed. + if (!firstUnreadMessageId && !lastReadMessageId) { + throw new Error('Failed to jump to first unread message id.'); + } + + // If the first unread message is not in the current message set, we need to load message around the id. + if (!isInCurrentMessageSet) { + try { + setLoadingMore(true); + setLoading(true); + const targetedMessage = (firstUnreadMessageId || lastReadMessageId) as string; + await channel.state.loadMessageIntoState(targetedMessage, undefined, limit); + /** + * if the index of the last read message on the page is beyond the half of the page, + * we have arrived to the oldest page of the channel + */ + const indexOfTarget = channel.state.messages.findIndex( + (message) => message.id === targetedMessage, + ); + + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + firstUnreadMessageId = + firstUnreadMessageId ?? channel.state.messages[indexOfTarget + 1].id; + } catch (error) { + setLoading(false); + loadMoreFinished(channel.state.messagePagination.hasPrev, channel.state.messages); + console.log('Loading channel at first unread message failed with error:', error); + return; + } + } + + if (!firstUnreadMessageId) { + throw new Error('Failed to jump to first unread message id.'); + } + if (!first_unread_message_id && setChannelUnreadState) { + setChannelUnreadState({ + ...channelUnreadState, + first_unread_message_id: firstUnreadMessageId, + last_read_message_id: lastReadMessageId, + }); + } + + jumpToMessageFinished(channel.state.messagePagination.hasNext, firstUnreadMessageId); + if (setTargetedMessage) { + setTargetedMessage(firstUnreadMessageId); + } } catch (error) { - console.warn( - 'Message pagination(fetching messages in the channel around unread message) request failed with error:', - error, - ); - return; + console.log('Loading channel at first unread message failed with error:', error); } - } else { - await loadLatestMessages(); - } - }; + }; return { copyMessagesStateFromChannel, diff --git a/package/src/components/Channel/hooks/useTargetedMessage.ts b/package/src/components/Channel/hooks/useTargetedMessage.ts index 1559ccf4d3..8aa84945d7 100644 --- a/package/src/components/Channel/hooks/useTargetedMessage.ts +++ b/package/src/components/Channel/hooks/useTargetedMessage.ts @@ -2,16 +2,21 @@ import { useEffect, useRef, useState } from 'react'; export const useTargetedMessage = (messageId?: string) => { const clearTargetedMessageCall = useRef>(); - const [targetedMessage, setTargetedMessage] = useState(messageId); + const [targetedMessage, setTargetedMessage] = useState(messageId); + const [highlightedMessageId, setHighlightedMessageId] = useState(); const prevTargetedMessageRef = useRef(); useEffect(() => { prevTargetedMessageRef.current = targetedMessage; + if (targetedMessage) { + setHighlightedMessageId(targetedMessage); + } }, [targetedMessage]); useEffect(() => { clearTargetedMessageCall.current = setTimeout(() => { setTargetedMessage(undefined); + setHighlightedMessageId(undefined); }, 3000); return () => { @@ -19,17 +24,19 @@ export const useTargetedMessage = (messageId?: string) => { }; }, []); - const setTargetedMessageTimeoutRef = useRef((messageId: string) => { + const setTargetedMessageTimeoutRef = useRef((messageId: string | undefined) => { clearTargetedMessageCall.current && clearTimeout(clearTargetedMessageCall.current); clearTargetedMessageCall.current = setTimeout(() => { setTargetedMessage(undefined); + setHighlightedMessageId(undefined); }, 3000); setTargetedMessage(messageId); }); return { + highlightedMessageId, prevTargetedMessage: prevTargetedMessageRef.current, setTargetedMessage: setTargetedMessageTimeoutRef.current, targetedMessage, diff --git a/package/src/components/Chat/hooks/handleEventToSyncDB.ts b/package/src/components/Chat/hooks/handleEventToSyncDB.ts index 6ca71eade6..c8c0b774e8 100644 --- a/package/src/components/Chat/hooks/handleEventToSyncDB.ts +++ b/package/src/components/Chat/hooks/handleEventToSyncDB.ts @@ -77,7 +77,7 @@ export const handleEventToSyncDB = async < return createQueries(flush); }; - if (type === 'message.read') { + if (type === 'message.read' || type === 'notification.mark_read') { const cid = event.cid; const user = event.user; if (user?.id && cid) { @@ -88,6 +88,7 @@ export const handleEventToSyncDB = async < reads: [ { last_read: event.received_at as string, + last_read_message_id: event.last_read_message_id, unread_messages: 0, user, }, @@ -97,6 +98,27 @@ export const handleEventToSyncDB = async < } } + if (type === 'notification.mark_unread') { + const cid = event.cid; + const user = event.user; + if (user?.id && cid) { + return await queriesWithChannelGuard((flushOverride) => + upsertReads({ + cid, + flush: flushOverride, + reads: [ + { + last_read: event.received_at as string, + last_read_message_id: event.last_read_message_id, + unread_messages: event.unread_messages, + user, + }, + ], + }), + ); + } + } + if (type === 'message.new') { const message = event.message; diff --git a/package/src/components/Message/Message.tsx b/package/src/components/Message/Message.tsx index 11e798ad7f..3a3b6c11f3 100644 --- a/package/src/components/Message/Message.tsx +++ b/package/src/components/Message/Message.tsx @@ -110,6 +110,7 @@ export type MessageActionHandlers< deleteMessage: () => void; editMessage: () => void; flagMessage: () => void; + markUnread: () => Promise; pinMessage: () => Promise; quotedReply: () => void; resendMessage: () => Promise; @@ -145,6 +146,7 @@ export type MessagePropsWithContext< | 'handleDelete' | 'handleEdit' | 'handleFlag' + | 'handleMarkUnread' | 'handleMute' | 'handlePinMessage' | 'handleQuotedReply' @@ -223,6 +225,7 @@ const MessageWithContext = < handleDelete, handleEdit, handleFlag, + handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, @@ -472,6 +475,7 @@ const MessageWithContext = < handleDeleteMessage, handleEditMessage, handleFlagMessage, + handleMarkUnreadMessage, handleQuotedReplyMessage, handleResendMessage, handleToggleBanUser, @@ -499,6 +503,7 @@ const MessageWithContext = < editMessage, flagMessage, handleReaction, + markUnread, muteUser, pinMessage, quotedReply, @@ -517,6 +522,7 @@ const MessageWithContext = < handleDelete, handleEdit, handleFlag, + handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, @@ -552,6 +558,7 @@ const MessageWithContext = < flagMessage, isMyMessage, isThreadMessage, + markUnread, message, muteUser, ownCapabilities, @@ -568,6 +575,7 @@ const MessageWithContext = < deleteMessage: handleDeleteMessage, editMessage: handleEditMessage, flagMessage: handleFlagMessage, + markUnread: handleMarkUnreadMessage, pinMessage: handleTogglePinMessage, quotedReply: handleQuotedReplyMessage, resendMessage: handleResendMessage, diff --git a/package/src/components/Message/hooks/useMessageActionHandlers.ts b/package/src/components/Message/hooks/useMessageActionHandlers.ts index 1de1bc8939..b05cd4e79f 100644 --- a/package/src/components/Message/hooks/useMessageActionHandlers.ts +++ b/package/src/components/Message/hooks/useMessageActionHandlers.ts @@ -48,27 +48,27 @@ export const useMessageActionHandlers = < ); const handleCopyMessage = () => { - setClipboardString(message.text || ''); + if (!message.text) return; + setClipboardString(message.text); }; const handleDeleteMessage = () => { - if (message.id) { - Alert.alert( - t('Delete Message'), - t('Are you sure you want to permanently delete this message?'), - [ - { style: 'cancel', text: t('Cancel') }, - { - onPress: async () => { - await deleteMessage(message as MessageResponse); - }, - style: 'destructive', - text: t('Delete'), + if (!message.id) return; + Alert.alert( + t('Delete Message'), + t('Are you sure you want to permanently delete this message?'), + [ + { style: 'cancel', text: t('Cancel') }, + { + onPress: async () => { + await deleteMessage(message as MessageResponse); }, - ], - { cancelable: false }, - ); - } + style: 'destructive', + text: t('Delete'), + }, + ], + { cancelable: false }, + ); }; const handleToggleMuteUser = async () => { @@ -110,31 +110,44 @@ export const useMessageActionHandlers = < }; const handleFlagMessage = () => { + if (!message.id) return; + Alert.alert( + t('Flag Message'), + t('Do you want to send a copy of this message to a moderator for further investigation?'), + [ + { style: 'cancel', text: t('Cancel') }, + { + onPress: async () => { + try { + await client.flagMessage(message.id); + Alert.alert(t('Message flagged'), t('The message has been reported to a moderator.')); + } catch (error) { + console.log('Error flagging message:', error); + Alert.alert( + t('Cannot Flag Message'), + t( + 'Flag action failed either due to a network issue or the message is already flagged', + ), + ); + } + }, + text: t('Flag'), + }, + ], + { cancelable: false }, + ); + }; + + const handleMarkUnreadMessage = async () => { + if (!message.id) return; try { - if (message.id) { - Alert.alert( - t('Flag Message'), - t('Do you want to send a copy of this message to a moderator for further investigation?'), - [ - { style: 'cancel', text: t('Cancel') }, - { - onPress: async () => { - await client.flagMessage(message.id); - Alert.alert( - t('Message flagged'), - t('The message has been reported to a moderator.'), - ); - }, - text: t('Flag'), - }, - ], - { cancelable: false }, - ); - } - } catch (_) { + await channel.markUnread({ message_id: message.id }); + } catch (error) { + console.log('Error marking message as unread:', error); Alert.alert( - t('Cannot Flag Message'), - t('Flag action failed either due to a network issue or the message is already flagged'), + t( + 'Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.', + ), ); } }; @@ -173,6 +186,7 @@ export const useMessageActionHandlers = < handleDeleteMessage, handleEditMessage, handleFlagMessage, + handleMarkUnreadMessage, handleQuotedReplyMessage, handleResendMessage, handleToggleBanUser, diff --git a/package/src/components/Message/hooks/useMessageActions.tsx b/package/src/components/Message/hooks/useMessageActions.tsx index 03871b4147..a70b0c6030 100644 --- a/package/src/components/Message/hooks/useMessageActions.tsx +++ b/package/src/components/Message/hooks/useMessageActions.tsx @@ -20,6 +20,7 @@ import { Resend, ThreadReply, Unpin, + UnreadIndicator, UserDelete, } from '../../../icons'; import type { DefaultStreamChatGenerics } from '../../../types/types'; @@ -41,6 +42,7 @@ export type MessageActionsHookProps< | 'handleEdit' | 'handleFlag' | 'handleQuotedReply' + | 'handleMarkUnread' | 'handleMute' | 'handlePinMessage' | 'handleRetry' @@ -77,6 +79,7 @@ export const useMessageActions = < handleDelete, handleEdit, handleFlag, + handleMarkUnread, handleMute, handlePinMessage, handleQuotedReply, @@ -104,6 +107,7 @@ export const useMessageActions = < handleDeleteMessage, handleEditMessage, handleFlagMessage, + handleMarkUnreadMessage, handleQuotedReplyMessage, handleResendMessage, handleToggleBanUser, @@ -195,6 +199,32 @@ export const useMessageActions = < title: t('Edit Message'), }; + const flagMessage: MessageActionType = { + action: () => { + dismissOverlay(); + if (handleFlag) { + handleFlag(message); + } + handleFlagMessage(); + }, + actionType: 'flagMessage', + icon: , + title: t('Flag Message'), + }; + + const markUnread: MessageActionType = { + action: () => { + dismissOverlay(); + if (handleMarkUnread) { + handleMarkUnread(message); + } + handleMarkUnreadMessage(); + }, + actionType: 'markUnread', + icon: , + title: t('Mark as Unread'), + }; + const pinMessage: MessageActionType = { action: () => { dismissOverlay(); @@ -221,20 +251,6 @@ export const useMessageActions = < title: t('Unpin from Conversation'), }; - const flagMessage: MessageActionType = { - action: () => { - dismissOverlay(); - if (handleFlag) { - handleFlag(message); - } - - handleFlagMessage(); - }, - actionType: 'flagMessage', - icon: , - title: t('Flag Message'), - }; - const handleReaction = !error ? selectReaction ? selectReaction(message) @@ -311,6 +327,7 @@ export const useMessageActions = < editMessage, flagMessage, handleReaction, + markUnread, muteUser, pinMessage, quotedReply, diff --git a/package/src/components/Message/utils/messageActions.ts b/package/src/components/Message/utils/messageActions.ts index b17693c351..dc4696e4b4 100644 --- a/package/src/components/Message/utils/messageActions.ts +++ b/package/src/components/Message/utils/messageActions.ts @@ -15,6 +15,7 @@ export type MessageActionsParams< error: boolean | Error; flagMessage: MessageActionType; isThreadMessage: boolean; + markUnread: MessageActionType; muteUser: MessageActionType; ownCapabilities: OwnCapabilitiesContextValue; pinMessage: MessageActionType; @@ -43,6 +44,7 @@ export const messageActions = < flagMessage, isMyMessage, isThreadMessage, + markUnread, message, ownCapabilities, pinMessage, @@ -77,6 +79,10 @@ export const messageActions = < actions.push(editMessage); } + if (ownCapabilities.readEvents && !error && !isThreadMessage) { + actions.push(markUnread); + } + if (isClipboardAvailable() && message.text && !error) { actions.push(copyMessage); } diff --git a/package/src/components/MessageList/InlineUnreadIndicator.tsx b/package/src/components/MessageList/InlineUnreadIndicator.tsx index 67826e9d0e..fc93cdaa1b 100644 --- a/package/src/components/MessageList/InlineUnreadIndicator.tsx +++ b/package/src/components/MessageList/InlineUnreadIndicator.tsx @@ -1,47 +1,38 @@ import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; -import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'; import { useTheme } from '../../contexts/themeContext/ThemeContext'; import { useTranslationContext } from '../../contexts/translationContext/TranslationContext'; -import { useViewport } from '../../hooks/useViewport'; - -const styles = StyleSheet.create({ - container: { - alignItems: 'center', - justifyContent: 'center', - padding: 10, - width: '100%', - }, - text: { - fontSize: 12, - }, -}); export const InlineUnreadIndicator = () => { const { theme: { - colors: { bg_gradient_end, bg_gradient_start, grey }, + colors: { grey, light_gray }, messageList: { inlineUnreadIndicator: { container, text }, }, }, } = useTheme(); const { t } = useTranslationContext(); - const { vw } = useViewport(); return ( - - - - - - - - - - + {t('Unread Messages')} ); }; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + justifyContent: 'center', + marginTop: 2, + padding: 10, + }, + text: { + fontSize: 12, + }, +}); diff --git a/package/src/components/MessageList/MessageList.tsx b/package/src/components/MessageList/MessageList.tsx index fa4325cefb..96dfb38e35 100644 --- a/package/src/components/MessageList/MessageList.tsx +++ b/package/src/components/MessageList/MessageList.tsx @@ -6,6 +6,7 @@ import { ScrollViewProps, StyleSheet, View, + ViewabilityConfig, ViewToken, } from 'react-native'; @@ -54,7 +55,9 @@ import { ThreadContextValue, useThreadContext } from '../../contexts/threadConte import { DefaultStreamChatGenerics, FileTypes } from '../../types/types'; -const WAIT_FOR_SCROLL_TO_OFFSET_TIMEOUT = 150; +// This is just to make sure that the scrolling happens in a different task queue. +// TODO: Think if we really need this and strive to remove it if we can. +const WAIT_FOR_SCROLL_TIMEOUT = 0; const MAX_RETRIES_AFTER_SCROLL_FAILURE = 10; const styles = StyleSheet.create({ container: { @@ -63,7 +66,6 @@ const styles = StyleSheet.create({ width: '100%', }, contentContainer: { - flexGrow: 1, /** * paddingBottom is set to 4 to account for the default date * header and inline indicator alignment. The top margin is 8 @@ -99,7 +101,7 @@ const keyExtractor = < return Date.now().toString(); }; -const flatListViewabilityConfig = { +const flatListViewabilityConfig: ViewabilityConfig = { viewAreaCoveragePercentThreshold: 1, }; @@ -109,9 +111,11 @@ type MessageListPropsWithContext< Pick< ChannelContextValue, | 'channel' + | 'channelUnreadState' | 'disabled' | 'EmptyStateIndicator' | 'hideStickyDateHeader' + | 'highlightedMessageId' | 'loadChannelAroundMessage' | 'loading' | 'LoadingIndicator' @@ -133,7 +137,6 @@ type MessageListPropsWithContext< | 'DateHeader' | 'disableTypingIndicator' | 'FlatList' - | 'initialScrollToFirstUnreadMessage' | 'InlineDateSeparator' | 'InlineUnreadIndicator' | 'legacyImageViewerSwipeBehaviour' @@ -144,6 +147,7 @@ type MessageListPropsWithContext< | 'shouldShowUnreadUnderlay' | 'TypingIndicator' | 'TypingIndicatorContainer' + | 'UnreadMessagesNotification' > & Pick< ThreadContextValue, @@ -228,6 +232,7 @@ const MessageListWithContext = < const { additionalFlatListProps, channel, + channelUnreadState, client, closePicker, DateHeader, @@ -238,7 +243,7 @@ const MessageListWithContext = < FooterComponent = InlineLoadingMoreIndicator, HeaderComponent = LoadingMoreRecentIndicator, hideStickyDateHeader, - initialScrollToFirstUnreadMessage, + highlightedMessageId, InlineDateSeparator, InlineUnreadIndicator, inverted = true, @@ -275,8 +280,9 @@ const MessageListWithContext = < threadList = false, TypingIndicator, TypingIndicatorContainer, + UnreadMessagesNotification, } = props; - + const [isUnreadNotificationOpen, setIsUnreadNotificationOpen] = useState(false); const { theme } = useTheme(); const { @@ -333,12 +339,6 @@ const MessageListWithContext = < const flatListRef = useRef> | null>(null); - /** - * Flag to track if the initial scroll has been set - * If the prop `initialScrollToFirstUnreadMessage` was enabled, then we scroll to the unread msg and set it to true - * If not, the default offset of 0 for flatList means that it has been set already - */ - const [isInitialScrollDone, setInitialScrollDone] = useState(!initialScrollToFirstUnreadMessage); const channelResyncScrollSet = useRef(true); /** @@ -346,11 +346,6 @@ const MessageListWithContext = < */ const scrollToDebounceTimeoutRef = useRef>(); - /** - * The timeout id used to lazier load the initial scroll set flag - */ - const initialScrollSettingTimeoutRef = useRef>(); - /** * The timeout id used to temporarily load the initial scroll set flag */ @@ -375,11 +370,18 @@ const MessageListWithContext = < channelRef.current = channel; const updateStickyHeaderDateIfNeeded = (viewableItems: ViewToken[]) => { - if (viewableItems.length) { - const lastItem = viewableItems.pop() as { - item: MessageType; - }; + if (!viewableItems.length) return; + + const lastItem = viewableItems[viewableItems.length - 1]; + if (lastItem) { + if ( + !channel.state.messagePagination.hasPrev && + processedMessageList[processedMessageList.length - 1].id === lastItem.item.id + ) { + setStickyHeaderDate(undefined); + return; + } const isMessageTypeDeleted = lastItem.item.type === 'deleted'; if ( @@ -395,32 +397,60 @@ const MessageListWithContext = < }; /** - * FlatList doesn't accept changeable function for onViewableItemsChanged prop. - * Thus useRef. + * This function should show or hide the unread indicator depending on the */ - const onViewableItemsChanged = useRef( - ({ viewableItems }: { viewableItems: ViewToken[] | undefined }) => { - /** - * When a new message comes in, list scrolls down to the bottom automatically (using prop `maintainVisibleContentPosition`) - * and we mark the channel as read from handleScroll function. - * Although this logic is dependent on the fact that `onScroll` event gets triggered during this process. - * But for Android, this event is not triggered when messages length is lesser than visible screen height. - * - * And thus we need to check if the message list length is lesser than visible screen height and mark the channel as read. - */ + const updateStickyUnreadIndicator = (viewableItems: ViewToken[]) => { + if (!viewableItems.length) { + setIsUnreadNotificationOpen(false); + return; + } + + if (selectedPicker === 'images') { + setIsUnreadNotificationOpen(false); + return; + } + + const lastItem = viewableItems[viewableItems.length - 1]; + + if (lastItem) { + const lastItemCreatedAt = lastItem.item.created_at; + + const unreadIndicatorDate = channelUnreadState?.last_read.getTime(); + const lastItemDate = lastItemCreatedAt.getTime(); + if ( - Platform.OS === 'android' && - viewableItems?.length && - viewableItems?.length >= messageListLengthBeforeUpdate.current + !channel.state.messagePagination.hasPrev && + processedMessageList[processedMessageList.length - 1].id === lastItem.item.id ) { - channel.markRead(); + setIsUnreadNotificationOpen(false); + return; } - if (viewableItems && !hideStickyDateHeader) { - updateStickyHeaderDateIfNeeded(viewableItems); + if (unreadIndicatorDate && lastItemDate > unreadIndicatorDate) { + setIsUnreadNotificationOpen(true); + } else { + setIsUnreadNotificationOpen(false); } - }, - ); + } + }; + + /** + * FlatList doesn't accept changeable function for onViewableItemsChanged prop. + * Thus useRef. + */ + const onViewableItemsChanged = ({ + viewableItems, + }: { + viewableItems: ViewToken[] | undefined; + }) => { + if (!viewableItems) { + return; + } + if (!hideStickyDateHeader) { + updateStickyHeaderDateIfNeeded(viewableItems); + } + updateStickyUnreadIndicator(viewableItems); + }; /** * Resets the pagination trackers, doing so cancels currently scheduled loading more calls @@ -440,40 +470,19 @@ const MessageListWithContext = < * Effect to mark the channel as read when the user scrolls to the bottom of the message list. */ useEffect(() => { - const getShouldMarkReadAutomatically = (): boolean => { - if (loading || !channel) { - // nothing to do - return false; - } else if (channel.countUnread() > 0) { - if (!initialScrollToFirstUnreadMessage) { - /* - * In this case MessageList won't scroll to first unread message when opened, so we can mark - * the channel as read right after opening. - * */ - return true; - } else { - /* - * In this case MessageList will be opened to first unread message. - * But if there are were not enough unread messages, so that scrollToBottom button was not shown - * then MessageList won't need to scroll up. So we can safely mark the channel as read right after opening. - * - * NOTE: we must ensure that initial scroll is done, otherwise we do not wait till the unread scroll is finished - * */ - if (scrollToBottomButtonVisible) return false; - /* if scrollToBottom button was not visible, wait till - * - initial scroll is done (indicates that if scrolling to index was needed it was triggered) - * */ - return isInitialScrollDone; - } + const listener: ReturnType = channel.on('message.new', (event) => { + const newMessageToCurrentChannel = event.cid === channel.cid; + const mainChannelUpdated = !event.message?.parent_id || event.message?.show_in_channel; + + if (newMessageToCurrentChannel && mainChannelUpdated && !scrollToBottomButtonVisible) { + markRead(); } - return false; - }; + }); - if (getShouldMarkReadAutomatically()) { - markRead(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loading, scrollToBottomButtonVisible, isInitialScrollDone]); + return () => { + listener?.unsubscribe(); + }; + }, [channel, markRead, scrollToBottomButtonVisible]); useEffect(() => { const lastReceivedMessage = getLastReceivedMessage(processedMessageList); @@ -487,6 +496,7 @@ const MessageListWithContext = < if (!client || !channel || rawMessageList.length === 0) { return; } + /** * Condition to check if a message is removed from MessageList. * Eg: This would happen when giphy search is cancelled, etc. @@ -508,7 +518,7 @@ const MessageListWithContext = < flatListRef.current?.scrollToOffset({ offset: 0, }); - }, 50); + }, WAIT_FOR_SCROLL_TIMEOUT); setTimeout(() => { channelResyncScrollSet.current = true; if (channel.countUnread() > 0) { @@ -518,10 +528,9 @@ const MessageListWithContext = < } }; + // TODO: Think about if this is really needed? if (threadList) { scrollToBottomIfNeeded(); - } else { - setScrollToBottomButtonVisible(false); } messageListLengthBeforeUpdate.current = messageListLengthAfterUpdate; @@ -566,12 +575,74 @@ const MessageListWithContext = < animated: true, offset: 0, }); - }, 150); // flatlist might take a bit to update, so a small delay is needed + }, WAIT_FOR_SCROLL_TIMEOUT); // flatlist might take a bit to update, so a small delay is needed } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [rawMessageList, threadList]); + const goToMessage = async (messageId: string) => { + const indexOfParentInMessageList = processedMessageList.findIndex( + (message) => message?.id === messageId, + ); + try { + if (indexOfParentInMessageList === -1) { + await loadChannelAroundMessage({ messageId }); + return; + } else { + if (!flatListRef.current) return; + clearTimeout(failScrollTimeoutId.current); + scrollToIndexFailedRetryCountRef.current = 0; + // keep track of this messageId, so that we dont scroll to again in useEffect for targeted message change + messageIdLastScrolledToRef.current = messageId; + setTargetedMessage(messageId); + // now scroll to it with animated=true + flatListRef.current.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, // try to place message in the center of the screen + }); + return; + } + } catch (e) { + console.warn('Error while scrolling to message', e); + } + }; + + /** + * Check if a messageId needs to be scrolled to after list loads, and scroll to it + * Note: This effect fires on every list change with a small debounce so that scrolling isnt abrupted by an immediate rerender + */ + useEffect(() => { + if (!targetedMessage) return; + scrollToDebounceTimeoutRef.current = setTimeout(async () => { + const indexOfParentInMessageList = processedMessageList.findIndex( + (message) => message?.id === targetedMessage, + ); + + // the message we want to scroll to has not been loaded in the state yet + if (indexOfParentInMessageList === -1) { + await loadChannelAroundMessage({ messageId: targetedMessage, setTargetedMessage }); + } else { + if (!flatListRef.current) return; + // By a fresh scroll we should clear the retries for the previous failed scroll + clearTimeout(scrollToDebounceTimeoutRef.current); + clearTimeout(failScrollTimeoutId.current); + // reset the retry count + scrollToIndexFailedRetryCountRef.current = 0; + // now scroll to it + flatListRef.current.scrollToIndex({ + animated: true, + index: indexOfParentInMessageList, + viewPosition: 0.5, // try to place message in the center of the screen + }); + setTargetedMessage(undefined); + } + }, WAIT_FOR_SCROLL_TIMEOUT); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [targetedMessage]); + // TODO: do not apply on RN 0.73 and above const shouldApplyAndroidWorkaround = inverted && Platform.OS === 'android'; @@ -585,53 +656,20 @@ const MessageListWithContext = < if (!channel || channel.disconnected || (!channel.initialized && !channel.offlineMode)) return null; - const unreadCount = channel.countUnread(); - const lastRead = channel.lastRead(); - - function isMessageUnread(messageArrayIndex: number): boolean { - const isLatestMessageSetShown = !!channel.state.messageSets.find( - (set) => set.isCurrent && set.isLatest, - ); + const createdAtTimestamp = message.created_at && new Date(message.created_at).getTime(); + const lastReadTimestamp = channelUnreadState?.last_read.getTime(); + const isNewestMessage = index === 0; + const isLastReadMessage = + channelUnreadState?.last_read_message_id === message.id || + (!channelUnreadState?.unread_messages && createdAtTimestamp === lastReadTimestamp); - if (!isLatestMessageSetShown) { - const msg = processedMessageList?.[messageArrayIndex]; - if ( - channel.state.latestMessages.length !== 0 && - unreadCount > channel.state.latestMessages.length - ) { - return messageArrayIndex <= unreadCount - channel.state.latestMessages.length - 1; - } - // The `msg` can be undefined here, since `messageArrayIndex` can be out of bounds hence we add a check for `msg`. - else if (lastRead && msg?.created_at) { - return lastRead < msg.created_at; - } - return false; - } else { - return messageArrayIndex <= unreadCount - 1; - } - } + const showUnreadSeparator = + isLastReadMessage && + !isNewestMessage && + // The `channelUnreadState?.first_unread_message_id` is here for sent messages unread label + (!!channelUnreadState?.first_unread_message_id || !!channelUnreadState?.unread_messages); - const isCurrentMessageUnread = isMessageUnread(index); - const showUnreadUnderlay = - !!shouldShowUnreadUnderlay && - !channel.muteStatus().muted && - isCurrentMessageUnread && - scrollToBottomButtonVisible; - const insertInlineUnreadIndicator = showUnreadUnderlay && !isMessageUnread(index + 1); // show only if previous message is read - - if (message.type === 'system') { - return ( - - - - - {insertInlineUnreadIndicator && } - - ); - } + const showUnreadUnderlay = !!shouldShowUnreadUnderlay && showUnreadSeparator; const wrapMessageInTheme = client.userID === message.user?.id && !!myMessageTheme; const renderDateSeperator = isMessageWithStylesReadByAndDateSeparator(message) && @@ -640,7 +678,7 @@ const MessageListWithContext = < ); + return ( - <> - {wrapMessageInTheme ? ( + + {message.type === 'system' ? ( + + ) : wrapMessageInTheme ? ( - - {shouldApplyAndroidWorkaround && renderDateSeperator} + + {renderDateSeperator} {renderMessage} ) : ( - - {shouldApplyAndroidWorkaround && renderDateSeperator} + + {renderDateSeperator} {renderMessage} )} - {!shouldApplyAndroidWorkaround && renderDateSeperator} - {/* Adding indicator below the messages, since the list is inverted */} - - {insertInlineUnreadIndicator && } - - + {showUnreadUnderlay && } + ); }; @@ -803,8 +840,8 @@ const MessageListWithContext = < }; const handleScroll: ScrollViewProps['onScroll'] = (event) => { - const offset = event.nativeEvent.contentOffset.y; const messageListHasMessages = processedMessageList.length > 0; + const offset = event.nativeEvent.contentOffset.y; // Show scrollToBottom button once scroll position goes beyond 150. const isScrollAtBottom = offset <= 150; @@ -821,14 +858,6 @@ const MessageListWithContext = < */ setScrollToBottomButtonVisible(showScrollToBottomButton); - const shouldMarkRead = !threadList && !notLatestSet && offset <= 0 && channel.countUnread() > 0; - - if (shouldMarkRead) { - markRead(); - } - - setInitialScrollDone(false); - if (onListScroll) { onListScroll(event); } @@ -842,14 +871,12 @@ const MessageListWithContext = < await reloadChannel(); } else if (flatListRef.current) { flatListRef.current.scrollToOffset({ + animated: true, offset: 0, }); } setScrollToBottomButtonVisible(false); - if (!threadList) { - markRead(); - } }; const scrollToIndexFailedRetryCountRef = useRef(0); @@ -860,16 +887,12 @@ const MessageListWithContext = < // We got a failure as we tried to scroll to an item that was outside the render length if (!flatListRef.current) return; // we don't know the actual size of all items but we can see the average, so scroll to the closest offset - flatListRef.current.scrollToOffset({ - animated: false, - offset: info.averageItemLength * info.index, - }); // since we used only an average offset... we won't go to the center of the item yet // with a little delay to wait for scroll to offset to complete, we can then scroll to the index failScrollTimeoutId.current = setTimeout(() => { try { flatListRef.current?.scrollToIndex({ - animated: false, + animated: true, index: info.index, viewPosition: 0.5, // try to place message in the center of the screen }); @@ -894,81 +917,12 @@ const MessageListWithContext = < scrollToIndexFailedRetryCountRef.current += 1; onScrollToIndexFailedRef.current(info); } - }, WAIT_FOR_SCROLL_TO_OFFSET_TIMEOUT); + }, WAIT_FOR_SCROLL_TIMEOUT); // Only when index is greater than 0 and in range of items in FlatList // this onScrollToIndexFailed will be called again }); - const goToMessage = async (messageId: string) => { - const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === messageId, - ); - if (indexOfParentInMessageList !== -1 && flatListRef.current) { - clearTimeout(failScrollTimeoutId.current); - scrollToIndexFailedRetryCountRef.current = 0; - // keep track of this messageId, so that we dont scroll to again in useEffect for targeted message change - messageIdLastScrolledToRef.current = messageId; - setTargetedMessage(messageId); - // now scroll to it with animated=true (in useEffect animated=false is used) - flatListRef.current.scrollToIndex({ - animated: true, - index: indexOfParentInMessageList, - viewPosition: 0.5, // try to place message in the center of the screen - }); - return; - } - // the message we want was not loaded yet, so lets load it - await loadChannelAroundMessage({ messageId }); - }; - - /** - * Check if a messageId needs to be scrolled to after list loads, and scroll to it - * Note: This effect fires on every list change with a small debounce so that scrolling isnt abrupted by an immediate rerender - */ - useEffect(() => { - scrollToDebounceTimeoutRef.current = setTimeout(() => { - if (initialScrollToFirstUnreadMessage) { - clearTimeout(initialScrollSettingTimeoutRef.current); - initialScrollSettingTimeoutRef.current = setTimeout(() => { - // small timeout to ensure that handleScroll is called after scrollToIndex to set this flag - setInitialScrollDone(true); - }, 2000); - } - let messageIdToScroll: string | undefined; - if (targetedMessage && messageIdLastScrolledToRef.current !== targetedMessage) { - // if some messageId was targeted but not scrolledTo yet - // we have scroll to there after loading completes - messageIdToScroll = targetedMessage; - } - if (!messageIdToScroll) return; - const indexOfParentInMessageList = processedMessageList.findIndex( - (message) => message?.id === messageIdToScroll, - ); - if (indexOfParentInMessageList !== -1 && flatListRef.current) { - // By a fresh scroll we should clear the retries for the previous failed scroll - clearTimeout(scrollToDebounceTimeoutRef.current); - clearTimeout(failScrollTimeoutId.current); - // keep track of this messageId, so that we dont scroll to again for targeted message change - messageIdLastScrolledToRef.current = messageIdToScroll; - // reset the retry count - scrollToIndexFailedRetryCountRef.current = 0; - // now scroll to it - flatListRef.current.scrollToIndex({ - animated: false, - index: indexOfParentInMessageList, - viewPosition: 0.5, // try to place message in the center of the screen - }); - } - - // the message we want to scroll to has not been loaded in the state yet - if (indexOfParentInMessageList === -1) { - loadChannelAroundMessage({ messageId: messageIdToScroll }); - } - }, 50); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [targetedMessage, initialScrollToFirstUnreadMessage]); - const messagesWithImages = legacyImageViewerSwipeBehaviour && processedMessageList.filter((message) => { @@ -1047,6 +1001,11 @@ const MessageListWithContext = < } }; + const onUnreadNotificationClose = async () => { + await markRead(); + setIsUnreadNotificationOpen(false); + }; + const debugRef = useDebugContext(); const isDebugModeEnabled = __DEV__ && debugRef && debugRef.current; @@ -1155,7 +1114,7 @@ const MessageListWithContext = < onScrollEndDrag={onScrollEndDrag} onScrollToIndexFailed={onScrollToIndexFailedRef.current} onTouchEnd={dismissImagePicker} - onViewableItemsChanged={onViewableItemsChanged.current} + onViewableItemsChanged={onViewableItemsChanged} ref={refCallback} renderItem={renderItem} scrollEnabled={overlay === 'none'} @@ -1187,6 +1146,9 @@ const MessageListWithContext = < unreadCount={threadList ? 0 : channel?.countUnread()} /> + {isUnreadNotificationOpen && !threadList ? ( + + ) : null} ); }; @@ -1203,11 +1165,13 @@ export const MessageList = < const { closePicker, selectedPicker, setSelectedPicker } = useAttachmentPickerContext(); const { channel, + channelUnreadState, disabled, EmptyStateIndicator, enableMessageGroupingByUser, error, hideStickyDateHeader, + highlightedMessageId, isChannelActive, loadChannelAroundMessage, loading, @@ -1227,7 +1191,6 @@ export const MessageList = < DateHeader, disableTypingIndicator, FlatList, - initialScrollToFirstUnreadMessage, InlineDateSeparator, InlineUnreadIndicator, legacyImageViewerSwipeBehaviour, @@ -1238,6 +1201,7 @@ export const MessageList = < shouldShowUnreadUnderlay, TypingIndicator, TypingIndicatorContainer, + UnreadMessagesNotification, } = useMessagesContext(); const { loadMore, loadMoreRecent } = usePaginatedMessageListContext(); const { overlay } = useOverlayContext(); @@ -1248,6 +1212,7 @@ export const MessageList = < void; + /** + * Callback to handle the press event + */ + onPressHandler?: () => Promise; +}; + +export const UnreadMessagesNotification = (props: UnreadMessagesNotificationProps) => { + const { onCloseHandler, onPressHandler } = props; + const { t } = useTranslationContext(); + const { + channelUnreadState, + loadChannelAtFirstUnreadMessage, + markRead, + setChannelUnreadState, + setTargetedMessage, + } = useChannelContext(); + + const handleOnPress = async () => { + if (onPressHandler) { + await onPressHandler(); + } else { + await loadChannelAtFirstUnreadMessage({ + channelUnreadState, + setChannelUnreadState, + setTargetedMessage, + }); + } + }; + + const handleClose = async () => { + if (onCloseHandler) { + await onCloseHandler(); + } else { + await markRead(); + } + }; + + const { + theme: { + colors: { text_low_emphasis, white_snow }, + messageList: { + unreadMessagesNotification: { closeButtonContainer, closeIcon, container, text }, + }, + }, + } = useTheme(); + + return ( + [ + styles.container, + { backgroundColor: text_low_emphasis, opacity: pressed ? 0.8 : 1 }, + container, + ]} + > + {t('Unread Messages')} + [ + { + opacity: pressed ? 0.8 : 1, + }, + closeButtonContainer, + ]} + > + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + alignItems: 'center', + borderRadius: 20, + elevation: 4, + flexDirection: 'row', + paddingHorizontal: 16, + paddingVertical: 8, + position: 'absolute', + shadowColor: '#000', + shadowOffset: { + height: 2, + width: 0, + }, + shadowOpacity: 0.23, + shadowRadius: 2.62, + top: 8, + }, + text: { + fontWeight: '500', + marginRight: 8, + }, +}); diff --git a/package/src/components/MessageList/__tests__/MessageList.test.js b/package/src/components/MessageList/__tests__/MessageList.test.js index e994180744..a481f8eaf1 100644 --- a/package/src/components/MessageList/__tests__/MessageList.test.js +++ b/package/src/components/MessageList/__tests__/MessageList.test.js @@ -1,5 +1,7 @@ import React from 'react'; +import { FlatList } from 'react-native'; + import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react-native'; import { OverlayProvider } from '../../../contexts/overlayContext/OverlayProvider'; @@ -17,6 +19,7 @@ import { Channel } from '../../Channel/Channel'; import { channelInitialState } from '../../Channel/hooks/useChannelDataState'; import * as MessageListPaginationHook from '../../Channel/hooks/useMessageListPagination'; import { Chat } from '../../Chat/Chat'; + import { MessageList } from '../MessageList'; describe('MessageList', () => { @@ -382,6 +385,216 @@ describe('MessageList', () => { expect(() => screen.getByText(latestMessageText)).toThrow(); }); }); + + it("should render the UnreadMessagesIndicator when there's unread messages", async () => { + const user1 = generateUser(); + const user2 = generateUser(); + const messages = Array.from({ length: 10 }, (_, i) => + generateMessage({ id: `${i}`, text: `message-${i}` }), + ); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 }), generateMember({ user: user2 })], + }); + + const chatClient = await getTestClientWithUser({ id: user1.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + const channelUnreadState = { + last_read: new Date(), + last_read_message_id: '5', + unread_messages: 5, + }; + + channel.state = { + ...channelInitialState, + latestMessages: [], + messages, + }; + + const { queryByLabelText } = render( + + + + + + + , + ); + + await waitFor(() => { + expect(queryByLabelText('Inline unread indicator')).toBeTruthy(); + }); + }); + + it("should not render the UnreadMessagesIndicator when there's no unread messages", async () => { + const user1 = generateUser(); + const user2 = generateUser(); + const messages = Array.from({ length: 10 }, (_, i) => + generateMessage({ id: `${i}`, text: `message-${i}` }), + ); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user: user1 }), generateMember({ user: user2 })], + }); + + const chatClient = await getTestClientWithUser({ id: user1.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + const channelUnreadState = { + last_read: new Date(), + unread_messages: 0, + }; + + channel.state = { + ...channelInitialState, + latestMessages: [], + messages, + }; + + const { queryByLabelText } = render( + + + + + + + , + ); + + await waitFor(() => { + expect(queryByLabelText('Inline unread indicator')).not.toBeTruthy(); + }); + }); + + it('should call markRead function when message.new event is dispatched and new messages are received', async () => { + const user = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user })], + }); + + const chatClient = await getTestClientWithUser({ id: user.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + const user2 = generateUser(); + const newMessage = generateMessage({ user: user2 }); + + const markReadFn = jest.fn(); + + render( + + + + + + + , + ); + + act(() => dispatchMessageNewEvent(chatClient, newMessage, mockedChannel.channel)); + + await waitFor(() => { + expect(markReadFn).toHaveBeenCalledTimes(1); + }); + }); + + it("should scroll to the targeted message if it's present in the list", async () => { + const user = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user })], + }); + + const messages = Array.from({ length: 30 }, (_, i) => + generateMessage({ id: `${i}`, text: `message-${i}` }), + ); + + const chatClient = await getTestClientWithUser({ id: user.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + const targetedMessage = messages[15].id; + + channel.state = { + ...channelInitialState, + latestMessages: [], + messages, + }; + + const flatListRefMock = jest + .spyOn(FlatList.prototype, 'scrollToIndex') + .mockImplementation(() => {}); + + render( + + + + + + + , + ); + + await waitFor(() => { + expect(flatListRefMock).toHaveBeenCalledWith({ + animated: true, + index: 14, + viewPosition: 0.5, + }); + }); + }); + + it("should load more messages around the message id if the targeted message isn't present in the list", async () => { + const user = generateUser(); + const mockedChannel = generateChannelResponse({ + members: [generateMember({ user })], + }); + + const messages = Array.from({ length: 20 }, (_, i) => + generateMessage({ id: `${i}`, text: `message-${i}` }), + ); + + const chatClient = await getTestClientWithUser({ id: user.id }); + useMockedApis(chatClient, [getOrCreateChannelApi(mockedChannel)]); + const channel = chatClient.channel('messaging', mockedChannel.id); + await channel.watch(); + + const targetedMessage = 21; + const setTargetedMessage = jest.fn(); + + channel.state = { + ...channelInitialState, + latestMessages: [], + messages, + }; + + const loadChannelAroundMessage = jest.fn(() => Promise.resolve()); + + render( + + + + + + + , + ); + + await waitFor(() => { + expect(loadChannelAroundMessage).toHaveBeenCalledWith({ + messageId: targetedMessage, + setTargetedMessage, + }); + }); + }); }); describe('MessageList pagination', () => { diff --git a/package/src/components/MessageMenu/MessageActionListItem.tsx b/package/src/components/MessageMenu/MessageActionListItem.tsx index ca5f57aa54..52e8f57a31 100644 --- a/package/src/components/MessageMenu/MessageActionListItem.tsx +++ b/package/src/components/MessageMenu/MessageActionListItem.tsx @@ -10,6 +10,7 @@ export type ActionType = | 'deleteMessage' | 'editMessage' | 'flagMessage' + | 'markUnread' | 'muteUser' | 'pinMessage' | 'selectReaction' @@ -26,7 +27,7 @@ export type MessageActionType = { action: () => void; /** * Type of the action performed. - * Eg: 'banUser', 'blockUser', 'copyMessage', 'deleteMessage', 'editMessage', 'flagMessage', 'muteUser', 'pinMessage', 'selectReaction', 'reply', 'retry', 'quotedReply', 'threadReply', 'unpinMessage' + * Eg: 'banUser', 'blockUser', 'copyMessage', 'deleteMessage', 'editMessage', 'flagMessage', 'markUnread , 'muteUser', 'pinMessage', 'selectReaction', 'reply', 'retry', 'quotedReply', 'threadReply', 'unpinMessage' */ actionType: ActionType | string; /** diff --git a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap index 7a7d51d778..213ed3c79f 100644 --- a/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap +++ b/package/src/components/Thread/__tests__/__snapshots__/Thread.test.js.snap @@ -40,7 +40,6 @@ exports[`Thread should match thread snapshot 1`] = ` contentContainerStyle={ [ { - "flexGrow": 1, "paddingBottom": 4, }, undefined, @@ -255,316 +254,320 @@ exports[`Thread should match thread snapshot 1`] = ` testID="message-list-item-0" > - - - + + + > + + - - - + - - Message6 + + Message6 + - + - - - - 2:50 PM - - - ⦁ - - - Edited - + + 2:50 PM + + + ⦁ + + + Edited + + @@ -578,13 +581,6 @@ exports[`Thread should match thread snapshot 1`] = ` ] } /> - - - - + + + > + + - - - + - - Message5 + + Message5 + - + - - - - 2:50 PM - - - ⦁ - - - Edited - + + 2:50 PM + + + ⦁ + + + Edited + + @@ -936,13 +936,6 @@ exports[`Thread should match thread snapshot 1`] = ` ] } /> - - + 05/05/2020 + + + + - - - + + + > + + - - - + - - Message4 + + Message4 + - + - - - - 2:50 PM - - - ⦁ - - - Edited - + + 2:50 PM + + + ⦁ + + + Edited + + - - - 05/05/2020 - - - void; }) => Promise; + /** + * Loads channel at first unread message. + * @param channelUnreadState - The unread state of the channel + * @param limit - The number of messages to load around the first unread message + * @param setChannelUnreadState - Callback to set the channel unread state + */ + loadChannelAtFirstUnreadMessage: ({ + channelUnreadState, + limit, + setTargetedMessage, + }: { + channelUnreadState?: ChannelUnreadState; + limit?: number; + setChannelUnreadState?: React.Dispatch< + React.SetStateAction | undefined> + >; + setTargetedMessage?: (messageId: string) => void; + }) => Promise; + /** * Custom loading indicator to override the Stream default */ LoadingIndicator: React.ComponentType; - markRead: () => void; + markRead: (options?: MarkReadFunctionOptions) => void; /** * * ```json @@ -109,22 +130,27 @@ export type ChannelContextValue< NetworkDownIndicator: React.ComponentType; read: ChannelState['read']; reloadChannel: () => Promise; - /** - * When true, messagelist will be scrolled to first unread message, when opened. - */ scrollToFirstUnreadThreshold: number; + setChannelUnreadState: React.Dispatch< + React.SetStateAction | undefined> + >; setLastRead: React.Dispatch>; - setTargetedMessage: (messageId: string) => void; + setTargetedMessage: (messageId?: string) => void; /** * Abort controller for cancelling async requests made for uploading images/files * Its a map of filename and AbortController */ uploadAbortControllerRef: React.MutableRefObject>; + channelUnreadState?: ChannelUnreadState; disabled?: boolean; enableMessageGroupingByUser?: boolean; + /** + * Id of message, which is highlighted in the channel. + */ + highlightedMessageId?: string; isChannelActive?: boolean; - lastRead?: Date; + lastRead?: Date; loading?: boolean; /** * Maximum time in milliseconds that should occur between messages diff --git a/package/src/contexts/messagesContext/MessagesContext.tsx b/package/src/contexts/messagesContext/MessagesContext.tsx index 0fa18ba41f..5aba809e36 100644 --- a/package/src/contexts/messagesContext/MessagesContext.tsx +++ b/package/src/contexts/messagesContext/MessagesContext.tsx @@ -46,6 +46,7 @@ import type { MessageListProps } from '../../components/MessageList/MessageList' import type { MessageSystemProps } from '../../components/MessageList/MessageSystem'; 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 { MessageActionListProps } from '../../components/MessageMenu/MessageActionList'; import type { @@ -309,6 +310,7 @@ export type MessagesContextValue< * Defaults to: [TypingIndicatorContainer](https://getstream.io/chat/docs/sdk/reactnative/contexts/messages-context/#typingindicatorcontainer) */ TypingIndicatorContainer: React.ComponentType; + UnreadMessagesNotification: React.ComponentType; updateMessage: ( updatedMessage: MessageResponse, extraState?: { @@ -340,6 +342,7 @@ export type MessagesContextValue< * Accepts the same props as Card component. */ CardFooter?: React.ComponentType>; + /** * Custom UI component to override default header of Card component. * Accepts the same props as Card component. @@ -351,18 +354,17 @@ export type MessagesContextValue< * * Please check [cookbook](https://github.com/GetStream/stream-chat-react-native/wiki/Cookbook-v3.0#override-or-intercept-message-actions-edit-delete-reaction-reply-etc) for details. */ - /** Control if the deleted message is visible to both the send and reciever, either of them or none */ deletedMessagesVisibilityType?: DeletedMessagesVisibilityType; disableTypingIndicator?: boolean; - /** * Whether messages should be aligned to right or left part of screen. * By default, messages will be received messages will be aligned to left and * sent messages will be aligned to right. */ forceAlignMessages?: Alignment | boolean; + getMessagesGroupStyles?: typeof getGroupStyles; /** * Handler to access when a ban user action is invoked. @@ -377,6 +379,8 @@ export type MessagesContextValue< handleEdit?: (message: MessageType) => void; /** Handler to access when a flag message action is invoked */ handleFlag?: (message: MessageType) => Promise; + /** Handler to access when a mark unread action is invoked */ + handleMarkUnread?: (message: MessageType) => Promise; /** Handler to access when a mute user action is invoked */ handleMute?: (message: MessageType) => Promise; /** Handler to access when a pin/unpin user action is invoked*/ @@ -440,6 +444,7 @@ export type MessagesContextValue< * deleteMessage, * editMessage, * flagMessage, + * markUnread, * muteUser, * quotedReply, * retry, diff --git a/package/src/contexts/themeContext/utils/theme.ts b/package/src/contexts/themeContext/utils/theme.ts index 21e5468f61..a538d116eb 100644 --- a/package/src/contexts/themeContext/utils/theme.ts +++ b/package/src/contexts/themeContext/utils/theme.ts @@ -428,6 +428,12 @@ export type Theme = { chevronColor?: ColorValue; }; typingIndicatorContainer: ViewStyle; + unreadMessagesNotification: { + closeButtonContainer: ViewStyle; + closeIcon: IconProps; + container: ViewStyle; + text: TextStyle; + }; }; messageMenu: { actionList: { @@ -1168,6 +1174,12 @@ export const defaultTheme: Theme = { wrapper: {}, }, typingIndicatorContainer: {}, + unreadMessagesNotification: { + closeButtonContainer: {}, + closeIcon: {}, + container: {}, + text: {}, + }, }, messageMenu: { actionList: { diff --git a/package/src/i18n/en.json b/package/src/i18n/en.json index dd0fd419c7..b8972f3ebe 100644 --- a/package/src/i18n/en.json +++ b/package/src/i18n/en.json @@ -32,6 +32,7 @@ "Error loading": "Error loading", "Error loading channel list...": "Error loading channel list...", "Error loading messages for this channel...": "Error loading messages for this channel...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.", "Error while loading, please reload/refresh": "Error while loading, please reload/refresh", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "File is too large: {{ size }}, maximum upload size is {{ limit }}", "File type not supported": "File type not supported", @@ -48,6 +49,7 @@ "Loading messages...": "Loading messages...", "Loading threads...": "Loading threads...", "Loading...": "Loading...", + "Mark as Unread": "Mark as Unread", "Maximum number of files reached": "Maximum number of files reached", "Maximum votes per person": "Maximum votes per person", "Message Reactions": "Message Reactions", diff --git a/package/src/i18n/es.json b/package/src/i18n/es.json index d8fe2ba9f7..e6523ef91a 100644 --- a/package/src/i18n/es.json +++ b/package/src/i18n/es.json @@ -32,6 +32,7 @@ "Error loading": "Error al cargar", "Error loading channel list...": "Error al cargar la lista de canales...", "Error loading messages for this channel...": "Error al cargar los mensajes de este canal...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Error al marcar el mensaje como no leído. No se pueden marcar mensajes no leídos más antiguos que los 100 mensajes más recientes del canal.", "Error while loading, please reload/refresh": "Error al cargar, por favor recarga/actualiza", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "El archivo es demasiado grande: {{ size }}, el tamaño máximo de carga es de {{ limit }}", "File type not supported": "Tipo de archivo no admitido", @@ -48,6 +49,7 @@ "Loading messages...": "Cargando mensajes...", "Loading threads...": "Cargando hilos...", "Loading...": "Cargando...", + "Mark as Unread": "Marcar como no leído", "Maximum number of files reached": "Número máximo de archivos alcanzado", "Maximum votes per person": "Máximo de votos por persona", "Message Reactions": "Reacciones al mensaje", diff --git a/package/src/i18n/fr.json b/package/src/i18n/fr.json index ab4d3bf426..e59f6ba349 100644 --- a/package/src/i18n/fr.json +++ b/package/src/i18n/fr.json @@ -32,6 +32,7 @@ "Error loading": "Erreur lors du chargement", "Error loading channel list...": "Erreur lors du chargement de la liste de canaux...", "Error loading messages for this channel...": "Erreur lors du chargement des messages de ce canal...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Erreur lors du marquage du message comme non lu. Impossible de marquer les messages non lus plus anciens que les 100 derniers messages du canal.", "Error while loading, please reload/refresh": "Erreur lors du chargement, veuillez recharger/rafraîchir", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Le fichier est trop volumineux : {{ size }}, la taille de téléchargement maximale est de {{ limit }}", "File type not supported": "Le type de fichier n'est pas pris en charge", @@ -48,6 +49,7 @@ "Loading messages...": "Chargement des messages...", "Loading threads...": "Chargement des fils...", "Loading...": "Chargement...", + "Mark as Unread": "Marquer comme non lu", "Maximum number of files reached": "Nombre maximal de fichiers atteint", "Maximum votes per person": "Maximum de votes par personne", "Message Reactions": "Réactions aux messages", diff --git a/package/src/i18n/he.json b/package/src/i18n/he.json index b2bf283eee..ed659d051b 100644 --- a/package/src/i18n/he.json +++ b/package/src/i18n/he.json @@ -32,6 +32,7 @@ "Error loading": "שגיאה ארעה בעת הטעינה", "Error loading channel list...": "שגיאה ארעה בטעינת השיחות...", "Error loading messages for this channel...": "שגיאה ארעה בטעינת הודעות עבור שיחה זאת...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "שגיאה ארעה בסימון ההודעה כלא נקרא. אין אפשרות לסמן הודעות כלא נקראות שהן ישנות מה-100 ההודעות האחרונות בשיחה.", "Error while loading, please reload/refresh": "שגיאה ארעה בזמן הטעינה, אנא טען מחדש/רענן", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "הקובץ גדול מדי: {{ size }}, גודל העלאה מקסימלי הוא {{ limit }}", "File type not supported": "סוג הקובץ אינו נתמך", @@ -48,6 +49,7 @@ "Loading messages...": "ההודעות בטעינה..", "Loading threads...": "טוען שרשורים...", "Loading...": "טוען...", + "Mark as Unread": "סמן כלא נקרא", "Maximum number of files reached": "הגעת למספר המרבי של קבצים", "Maximum votes per person": "מקסימום הצבעות לאדם", "Message Reactions": "תגובות להודעה", diff --git a/package/src/i18n/hi.json b/package/src/i18n/hi.json index a7bdd4f5ae..686c5dae6a 100644 --- a/package/src/i18n/hi.json +++ b/package/src/i18n/hi.json @@ -32,6 +32,7 @@ "Error loading": "लोड होने मे त्रुटि", "Error loading channel list...": "चैनल सूची लोड करने में त्रुटि...", "Error loading messages for this channel...": "इस चैनल के लिए मेसेजेस लोड करने में त्रुटि हुई...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "संदेश को अनरीड चिह्नित करने में त्रुटि। चैनल के नवीनतम 100 संदेशों से पुराने संदेशों को अनरीड चिह्नित नहीं किया जा सकता।", "Error while loading, please reload/refresh": "एरर, रिफ्रेश करे", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "फ़ाइल बहुत बड़ी है: {{ size }}, अधिकतम अपलोड साइज़ {{ limit }} है", "File type not supported": "फ़ाइल प्रकार समर्थित नहीं है", @@ -48,6 +49,7 @@ "Loading messages...": "मेसेजस लोड हो रहे हैं...", "Loading threads...": "थ्रेड्स लोड हो रहे हैं...", "Loading...": "लोड हो रहा है...", + "Mark as Unread": "अपठित मार्क करें", "Maximum number of files reached": "फ़ाइलों की अधिकतम संख्या पहुँच गई", "Maximum votes per person": "प्रति व्यक्ति अधिकतम वोट", "Message Reactions": "संदेश प्रतिक्रियाएँ", diff --git a/package/src/i18n/it.json b/package/src/i18n/it.json index e6621a2ed3..422a57601e 100644 --- a/package/src/i18n/it.json +++ b/package/src/i18n/it.json @@ -32,6 +32,7 @@ "Error loading": "Errore di caricamento", "Error loading channel list...": "Errore durante il caricamento della lista dei canali...", "Error loading messages for this channel...": "Errore durante il caricamento dei messaggi per questo canale...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Errore durante il contrassegno del messaggio come non letto. Non è possibile contrassegnare i messaggi non letti più vecchi dei 100 messaggi più recenti del canale.", "Error while loading, please reload/refresh": "Errore durante il caricamento, per favore ricarica la pagina", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Il file è troppo grande: {{ size }}, la dimensione massima di caricamento è {{ limit }}", "File type not supported": "Tipo di file non supportato", @@ -48,6 +49,7 @@ "Loading messages...": "Caricamento messaggi...", "Loading threads...": "Caricamento dei thread...", "Loading...": "Caricamento...", + "Mark as Unread": "Segna come non letto", "Maximum number of files reached": "Numero massimo di file raggiunto", "Maximum votes per person": "Massimo voti per persona", "Message Reactions": "Reazioni ai Messaggi", diff --git a/package/src/i18n/ja.json b/package/src/i18n/ja.json index 047978bdf9..2e5ede033a 100644 --- a/package/src/i18n/ja.json +++ b/package/src/i18n/ja.json @@ -32,6 +32,7 @@ "Error loading": "読み込みエラー", "Error loading channel list...": "チャネルリストの読み込み中にエラーが発生しました。。。", "Error loading messages for this channel...": "このチャネルのメッセージの読み込み中にエラーが発生しました。。。", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "メッセージを未読にする際にエラーが発生しました。最新の100件のチャネルメッセージより古い未読メッセージはマークできません。", "Error while loading, please reload/refresh": "ロード中にエラーが発生しました。更新してください", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "ファイルが大きすぎます:{{ size }}、最大アップロードサイズは{{ limit }}です", "File type not supported": "サポートされていないファイルです", @@ -48,6 +49,7 @@ "Loading messages...": "メッセージを読み込み中。。。", "Loading threads...": "スレッドを読み込み中...", "Loading...": "読み込み中。。。", + "Mark as Unread": "未読としてマーク", "Maximum number of files reached": "ファイルの最大数に達しました", "Maximum votes per person": "1人あたりの最大投票数", "Message Reactions": "メッセージのリアクション", diff --git a/package/src/i18n/ko.json b/package/src/i18n/ko.json index 5ecab344c7..5524510d94 100644 --- a/package/src/i18n/ko.json +++ b/package/src/i18n/ko.json @@ -32,6 +32,7 @@ "Error loading": "로드 오류", "Error loading channel list...": "채널리스트 을로드하는 동안 오류가 발생했습니다...", "Error loading messages for this channel...": "이 채널의 메시지를로드하는 동안 오류가 발생했습니다...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "메시지를 읽지 않음으로 표시하는 중 오류가 발생했습니다. 최신 100개의 채널 메시지보다 오래된 읽지 않은 메시지는 표시할 수 없습니다.", "Error while loading, please reload/refresh": "로드하는 동안 오류가 발생했습니다. 다시로드하십시오", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "파일이 너무 큽니다: {{ size }}, 최대 업로드 크기는 {{ limit }}입니다", "File type not supported": "지원하지 않는 파일입니다.", @@ -48,6 +49,7 @@ "Loading messages...": "메시지를 로딩 중...", "Loading threads...": "스레드 로딩 중...", "Loading...": "로딩 중...", + "Mark as Unread": "읽지 않음으로 표시", "Maximum number of files reached": "최대 파일 수에 도달했습니다", "Maximum votes per person": "사람당 최대 투표 수", "Message Reactions": "메시지의 리액션", diff --git a/package/src/i18n/nl.json b/package/src/i18n/nl.json index ce43d7f80a..8f20f8f574 100644 --- a/package/src/i18n/nl.json +++ b/package/src/i18n/nl.json @@ -32,6 +32,7 @@ "Error loading": "Probleem bij het laden", "Error loading channel list...": "Probleem bij het laden van de kanalen...", "Error loading messages for this channel...": "Probleem bij het laden van de berichten in dit kanaal...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Fout bij markeren als ongelezen. Kan ongelezen berichten ouder dan de nieuwste 100 kanaalberichten niet markeren.", "Error while loading, please reload/refresh": "Probleem bij het laden, probeer opnieuw", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Bestand is te groot: {{ size }}, maximale uploadgrootte is {{ limit }}", "File type not supported": "Bestandstype niet ondersteund", @@ -48,6 +49,7 @@ "Loading messages...": "Berichten aan het laden...", "Loading threads...": "Threads laden...", "Loading...": "Aan het laden...", + "Mark as Unread": "Markeer als ongelezen", "Maximum number of files reached": "Maximaal aantal bestanden bereikt", "Maximum votes per person": "Maximaal aantal stemmen per persoon", "Message Reactions": "Bericht Reacties", diff --git a/package/src/i18n/pt-br.json b/package/src/i18n/pt-br.json index c6f24b0c2d..04ad7fb7d7 100644 --- a/package/src/i18n/pt-br.json +++ b/package/src/i18n/pt-br.json @@ -32,6 +32,7 @@ "Error loading": "Erro ao carregar", "Error loading channel list...": "Erro ao carregar lista de canais...", "Error loading messages for this channel...": "Erro ao carregar mensagens para este canal...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Erro ao marcar mensagem como não lida. Não é possível marcar mensagens não lidas mais antigas que as 100 mensagens mais recentes do canal.", "Error while loading, please reload/refresh": "Erro ao carregar, por favor recarregue/atualize", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "O arquivo é muito grande: {{ size }}, o tamanho máximo de upload é {{ limit }}", "File type not supported": "Tipo de arquivo não suportado", @@ -48,6 +49,7 @@ "Loading messages...": "Carregando mensagens...", "Loading threads...": "Carregando tópicos...", "Loading...": "Carregando...", + "Mark as Unread": "Marcar como não lido", "Maximum number of files reached": "Número máximo de arquivos atingido", "Maximum votes per person": "Máximo de votos por pessoa", "Message Reactions": "Reações à Mensagem", diff --git a/package/src/i18n/ru.json b/package/src/i18n/ru.json index 6ad23a565d..f91599365c 100644 --- a/package/src/i18n/ru.json +++ b/package/src/i18n/ru.json @@ -32,6 +32,7 @@ "Error loading": "Ошибка при загрузке", "Error loading channel list...": "Ошибка загрузки списка каналов...", "Error loading messages for this channel...": "Ошибка загрузки сообщений для этого канала...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Ошибка при отметке сообщения как непрочитанного. Невозможно отметить непрочитанные сообщения старше новейших 100 сообщений канала.", "Error while loading, please reload/refresh": "Ошибка загрузки, пожалуйста перезагрузите или обновите", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Файл слишком большой: {{ size }}, максимальный размер загрузки составляет {{ limit }}", "File type not supported": "Тип файла не поддерживается", @@ -48,6 +49,7 @@ "Loading messages...": "Загружаю сообщения...", "Loading threads...": "Загрузка потоков...", "Loading...": "Загружаю...", + "Mark as Unread": "Отметить как непрочитанное", "Maximum number of files reached": "Достигнуто максимальное количество файлов", "Maximum votes per person": "Максимальное количество голосов на человека", "Message Reactions": "Сообщения Реакции", diff --git a/package/src/i18n/tr.json b/package/src/i18n/tr.json index f1bd61a28c..4d083dc3d8 100644 --- a/package/src/i18n/tr.json +++ b/package/src/i18n/tr.json @@ -32,6 +32,7 @@ "Error loading": "Yükleme hatası", "Error loading channel list...": "Kanal listesi yüklenirken hata oluştu...", "Error loading messages for this channel...": "Bu kanal için mesajlar yüklenirken hata oluştu...", + "Error marking message unread. Cannot mark unread messages older than the newest 100 channel messages.": "Okunmamış olarak işaretlenen mesajda hata oluştu. En yeni 100 kanal mesajından daha eski okunmamış mesajları işaretleyemezsiniz.", "Error while loading, please reload/refresh": "Yüklenirken hata oluştu, lütfen tekrar deneyiniz", "File is too large: {{ size }}, maximum upload size is {{ limit }}": "Dosya çok büyük: {{ size }}, maksimum yükleme boyutu {{ limit }}", "File type not supported": "Dosya türü desteklenmiyor", @@ -48,6 +49,7 @@ "Loading messages...": "Mesajlar yükleniyor...", "Loading threads...": "Akışlar yükleniyor...", "Loading...": "Yükleniyor...", + "Mark as Unread": "Okunmamış olarak işaretle", "Maximum number of files reached": "Maksimum dosya sayısına ulaşıldı", "Maximum votes per person": "Kişi başına maksimum oy", "Message Reactions": "Mesaj Tepkileri", diff --git a/package/src/icons/UnreadIndicator.tsx b/package/src/icons/UnreadIndicator.tsx new file mode 100644 index 0000000000..ec7e641127 --- /dev/null +++ b/package/src/icons/UnreadIndicator.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +import Svg, { Path } from 'react-native-svg'; + +import { IconProps } from './utils/base'; + +type Props = IconProps & { + size: number; +}; + +export const UnreadIndicator = ({ size, ...rest }: Props) => ( + + + +); diff --git a/package/src/icons/index.ts b/package/src/icons/index.ts index a82fda783f..76240edd20 100644 --- a/package/src/icons/index.ts +++ b/package/src/icons/index.ts @@ -100,3 +100,4 @@ export * from './PollThumbnail'; export * from './DragHandle'; export * from './Back'; export * from './SendPoll'; +export * from './UnreadIndicator'; diff --git a/package/src/store/SqliteClient.ts b/package/src/store/SqliteClient.ts index 270a7309f6..5a3b0f725a 100644 --- a/package/src/store/SqliteClient.ts +++ b/package/src/store/SqliteClient.ts @@ -28,7 +28,7 @@ import type { PreparedQueries, Table } from './types'; * This way usage @op-engineering/op-sqlite package is scoped to a single class/file. */ export class SqliteClient { - static dbVersion = 7; + static dbVersion = 8; static dbName = DB_NAME; static dbLocation = DB_LOCATION; diff --git a/package/src/store/schema.ts b/package/src/store/schema.ts index be894f1667..bcfbf723f1 100644 --- a/package/src/store/schema.ts +++ b/package/src/store/schema.ts @@ -194,6 +194,7 @@ export const tables: Tables = { columns: { cid: 'TEXT NOT NULL', lastRead: 'TEXT NOT NULL', + lastReadMessageId: 'TEXT', unreadMessages: 'INTEGER DEFAULT 0', userId: 'TEXT', }, @@ -340,6 +341,7 @@ export type Schema = { reads: { cid: string; lastRead: string; + lastReadMessageId?: string; unreadMessages?: number; userId?: string; }; diff --git a/package/src/types/types.ts b/package/src/types/types.ts index 53443dabd4..53ca8ac569 100644 --- a/package/src/types/types.ts +++ b/package/src/types/types.ts @@ -1,4 +1,4 @@ -import type { ExtendableGenerics, LiteralStringForUnion } from 'stream-chat'; +import type { ChannelState, ExtendableGenerics, LiteralStringForUnion } from 'stream-chat'; import type { FileStateValue } from '../utils/utils'; @@ -101,8 +101,11 @@ export type UnknownType = Record; export type ValueOf = T[keyof T]; -// ASYNC AUDIO EXPO: +export type ChannelUnreadState< + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +> = Omit['read']>, 'user'>; +// ASYNC AUDIO EXPO: export enum AndroidOutputFormat { DEFAULT = 0, THREE_GPP = 1, diff --git a/package/src/utils/utils.ts b/package/src/utils/utils.ts index 18434107aa..657541e47d 100644 --- a/package/src/utils/utils.ts +++ b/package/src/utils/utils.ts @@ -2,7 +2,7 @@ import type React from 'react'; import dayjs from 'dayjs'; import EmojiRegex from 'emoji-regex'; -import type { FormatMessageResponse, MessageResponse } from 'stream-chat'; +import type { ChannelState, FormatMessageResponse, MessageResponse } from 'stream-chat'; import { IconProps } from '../../src/icons/utils/base'; import { MessageType } from '../components/MessageList/hooks/useMessageList'; @@ -275,3 +275,63 @@ export const getDurationLabelFromDuration = (duration: number) => { export function escapeRegExp(text: string) { return text.replace(/[-[\]{}()*+?.,/\\^$|#]/g, '\\$&'); } + +/** + * Utility to find the index of a message in the messages array by id. + * @param messages + * @param targetId + * @returns number + */ +export const findInMessagesById = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + messages: ChannelState['messages'], + targetId: string, +) => { + const idx = messages.findIndex((message) => message.id === targetId); + return idx; +}; + +/** + * Utility to find the index of a message in the messages array by date. + * @param messages + * @param targetDate + * @returns an object with the index and the message object + */ +export const findInMessagesByDate = < + StreamChatGenerics extends DefaultStreamChatGenerics = DefaultStreamChatGenerics, +>( + messages: MessageResponse[] | ChannelState['messages'], + targetDate: Date, +) => { + // Binary search + const targetTimestamp = targetDate.getTime(); + let left = 0; + let right = messages.length - 1; + let middle = 0; + while (left <= right) { + middle = Math.floor(left + (right - left) / 2); + const middleTimestamp = new Date(messages[middle].created_at as string | Date).getTime(); + const middleLeftTimestamp = + messages[middle - 1]?.created_at && + new Date(messages[middle - 1].created_at as string | Date).getTime(); + const middleRightTimestamp = + messages[middle + 1]?.created_at && + new Date(messages[middle + 1].created_at as string | Date).getTime(); + if ( + middleTimestamp === targetTimestamp || + (middleLeftTimestamp && + middleRightTimestamp && + middleLeftTimestamp < targetTimestamp && + middleRightTimestamp > targetTimestamp) + ) { + return { index: middle, message: messages[middle] }; + } else if (middleTimestamp < targetTimestamp) { + left = middle + 1; + } else { + right = middle - 1; + } + } + + return { index: -1 }; +};