diff --git a/examples/vite/src/App.tsx b/examples/vite/src/App.tsx index f8df1ed806..dd52d39be9 100644 --- a/examples/vite/src/App.tsx +++ b/examples/vite/src/App.tsx @@ -14,7 +14,6 @@ import { ThreadList, ChatView, } from 'stream-chat-react'; -import 'stream-chat-react/css/v2/index.css'; const params = (new Proxy(new URLSearchParams(window.location.search), { get: (searchParams, property) => searchParams.get(property as string), @@ -38,7 +37,7 @@ const filters: ChannelFilters = { archived: false, }; const options: ChannelOptions = { limit: 5, presence: true, state: true }; -const sort: ChannelSort = [{ pinned_at: 1 }, { last_message_at: -1 }, { updated_at: -1 }]; +const sort: ChannelSort = { pinned_at: 1, last_message_at: -1, updated_at: -1 }; type LocalAttachmentType = Record; type LocalChannelType = Record; diff --git a/examples/vite/src/index.scss b/examples/vite/src/index.scss index a5b013cd4a..8959f1caa4 100644 --- a/examples/vite/src/index.scss +++ b/examples/vite/src/index.scss @@ -12,6 +12,12 @@ body, height: 100%; } +@layer stream, emoji-replacement; + +@import url('stream-chat-react/css/v2/index.css') layer(stream); +// use in combination with useImageFlagEmojisOnWindows prop on Chat component +// @import url('stream-chat-react/css/v2/emoji-replacement.css') layer(emoji-replacement); + #root { display: flex; height: 100%; diff --git a/package.json b/package.json index c14ae3f7ab..9cd57c2bdc 100644 --- a/package.json +++ b/package.json @@ -132,7 +132,8 @@ "textarea-caret": "^3.1.0", "tslib": "^2.6.2", "unist-builder": "^3.0.0", - "unist-util-visit": "^5.0.0" + "unist-util-visit": "^5.0.0", + "use-sync-external-store": "^1.4.0" }, "optionalDependencies": { "@stream-io/transliterate": "^1.5.5", @@ -145,7 +146,7 @@ "emoji-mart": "^5.4.0", "react": "^18.0.0 || ^17.0.0 || ^16.8.0", "react-dom": "^18.0.0 || ^17.0.0 || ^16.8.0", - "stream-chat": "^8.46.1" + "stream-chat": "^8.50.0" }, "peerDependenciesMeta": { "@breezystack/lamejs": { @@ -207,6 +208,7 @@ "@types/react-image-gallery": "^1.2.4", "@types/react-is": "^18.2.4", "@types/textarea-caret": "3.0.0", + "@types/use-sync-external-store": "^0.0.6", "@types/uuid": "^8.3.0", "@typescript-eslint/eslint-plugin": "5.62.0", "@typescript-eslint/parser": "5.62.0", @@ -257,7 +259,7 @@ "react-dom": "^18.1.0", "react-test-renderer": "^18.1.0", "semantic-release": "^19.0.5", - "stream-chat": "^8.47.1", + "stream-chat": "^8.50.0", "ts-jest": "^29.1.4", "typescript": "^5.4.5" }, diff --git a/src/components/ChannelList/hooks/useChannelListShape.ts b/src/components/ChannelList/hooks/useChannelListShape.ts index b7ba453673..e0cc1424d7 100644 --- a/src/components/ChannelList/hooks/useChannelListShape.ts +++ b/src/components/ChannelList/hooks/useChannelListShape.ts @@ -5,6 +5,7 @@ import { Channel, Event, ExtendableGenerics } from 'stream-chat'; import uniqBy from 'lodash.uniqby'; import { + extractSortValue, findLastPinnedChannelIndex, isChannelArchived, isChannelPinned, @@ -56,7 +57,7 @@ type HandleNotificationAddedToChannelParameters< type HandleMemberUpdatedParameters = BaseParameters & { lockChannelOrder: boolean; -} & Required, 'sort'>>; +} & Required, 'sort' | 'filters'>>; type HandleChannelDeletedParameters = BaseParameters & RepeatedParameters; @@ -112,10 +113,15 @@ export const useChannelListShapeDefaults = () => return customHandler(setChannels, event); } - setChannels((channels) => { - const targetChannelIndex = channels.findIndex((channel) => channel.cid === event.cid); + const channelType = event.channel_type; + const channelId = event.channel_id; + + if (!channelType || !channelId) return; + + setChannels((currentChannels) => { + const targetChannel = client.channel(channelType, channelId); + const targetChannelIndex = currentChannels.indexOf(targetChannel); const targetChannelExistsWithinList = targetChannelIndex >= 0; - const targetChannel = channels[targetChannelIndex]; const isTargetChannelPinned = isChannelPinned(targetChannel); const isTargetChannelArchived = isChannelArchived(targetChannel); @@ -124,35 +130,26 @@ export const useChannelListShapeDefaults = () => const considerPinnedChannels = shouldConsiderPinnedChannels(sort); if ( - // target channel is archived - (isTargetChannelArchived && considerArchivedChannels) || - // target channel is pinned - (isTargetChannelPinned && considerPinnedChannels) || + // filter is defined, target channel is archived and filter option is set to false + (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || + // filter is defined, target channel isn't archived and filter option is set to true + (considerArchivedChannels && !isTargetChannelArchived && filters.archived) || + // sort option is defined, target channel is pinned + (considerPinnedChannels && isTargetChannelPinned) || // list order is locked lockChannelOrder || // target channel is not within the loaded list and loading from cache is disallowed (!targetChannelExistsWithinList && !allowNewMessagesFromUnfilteredChannels) ) { - return channels; - } - - // we either have the channel to move or we pull it from the cache (or instantiate) if it's allowed - const channelToMove: Channel | null = - channels[targetChannelIndex] ?? - (allowNewMessagesFromUnfilteredChannels && event.channel_type - ? client.channel(event.channel_type, event.channel_id) - : null); - - if (channelToMove) { - return moveChannelUpwards({ - channels, - channelToMove, - channelToMoveIndexWithinChannels: targetChannelIndex, - sort, - }); + return currentChannels; } - return channels; + return moveChannelUpwards({ + channels: currentChannels, + channelToMove: targetChannel, + channelToMoveIndexWithinChannels: targetChannelIndex, + sort, + }); }); }, [client], @@ -182,7 +179,7 @@ export const useChannelListShapeDefaults = () => }); const considerArchivedChannels = shouldConsiderArchivedChannels(filters); - if (isChannelArchived(channel) && considerArchivedChannels) { + if (isChannelArchived(channel) && considerArchivedChannels && !filters.archived) { return; } @@ -208,26 +205,38 @@ export const useChannelListShapeDefaults = () => customHandler, event, setChannels, + sort, }: HandleNotificationAddedToChannelParameters) => { if (typeof customHandler === 'function') { return customHandler(setChannels, event); } - if (allowNewMessagesFromUnfilteredChannels && event.channel?.type) { - const channel = await getChannel({ - client, - id: event.channel.id, - members: event.channel.members?.reduce((acc, { user, user_id }) => { - const userId = user_id || user?.id; - if (userId) { - acc.push(userId); - } - return acc; - }, []), - type: event.channel.type, - }); - setChannels((channels) => uniqBy([channel, ...channels], 'cid')); + if (!event.channel || !allowNewMessagesFromUnfilteredChannels) { + return; } + + const channel = await getChannel({ + client, + id: event.channel.id, + members: event.channel.members?.reduce((newMembers, { user, user_id }) => { + const userId = user_id || user?.id; + + if (userId) newMembers.push(userId); + + return newMembers; + }, []), + type: event.channel.type, + }); + + // membership has been reset (target channel shouldn't be pinned nor archived) + setChannels((channels) => + moveChannelUpwards({ + channels, + channelToMove: channel, + channelToMoveIndexWithinChannels: -1, + sort, + }), + ); }, [client], ); @@ -248,19 +257,24 @@ export const useChannelListShapeDefaults = () => ); const handleMemberUpdated = useCallback( - ({ event, lockChannelOrder, setChannels, sort }: HandleMemberUpdatedParameters) => { + ({ + event, + filters, + lockChannelOrder, + setChannels, + sort, + }: HandleMemberUpdatedParameters) => { if (!event.member?.user || event.member.user.id !== client.userID || !event.channel_type) { return; } - const member = event.member; const channelType = event.channel_type; const channelId = event.channel_id; const considerPinnedChannels = shouldConsiderPinnedChannels(sort); + const considerArchivedChannels = shouldConsiderArchivedChannels(filters); - // TODO: extract this and consider single property sort object too - const pinnedAtSort = Array.isArray(sort) ? sort[0]?.pinned_at ?? null : null; + const pinnedAtSort = extractSortValue({ atIndex: 0, sort, targetKey: 'pinned_at' }); setChannels((currentChannels) => { const targetChannel = client.channel(channelType, channelId); @@ -268,6 +282,9 @@ export const useChannelListShapeDefaults = () => const targetChannelIndex = currentChannels.indexOf(targetChannel); const targetChannelExistsWithinList = targetChannelIndex >= 0; + const isTargetChannelArchived = isChannelArchived(targetChannel); + const isTargetChannelPinned = isChannelPinned(targetChannel); + // handle pinning if (!considerPinnedChannels || lockChannelOrder) return currentChannels; @@ -278,7 +295,10 @@ export const useChannelListShapeDefaults = () => } // handle archiving (remove channel) - if (typeof member.archived_at === 'string') { + if ( + (considerArchivedChannels && isTargetChannelArchived && !filters.archived) || + (considerArchivedChannels && !isTargetChannelArchived && filters.archived) + ) { return newChannels; } @@ -287,7 +307,7 @@ export const useChannelListShapeDefaults = () => // calculate last pinned channel index only if `pinned_at` sort is set to // ascending order or if it's in descending order while the pin is being removed, otherwise // we move to the top (index 0) - if (pinnedAtSort === 1 || (pinnedAtSort === -1 && !member.pinned_at)) { + if (pinnedAtSort === 1 || (pinnedAtSort === -1 && !isTargetChannelPinned)) { lastPinnedChannelIndex = findLastPinnedChannelIndex({ channels: newChannels }); } @@ -553,6 +573,7 @@ export const usePrepareShapeHandlers = ({ case 'member.updated': defaults.handleMemberUpdated({ event, + filters, lockChannelOrder, setChannels, sort, diff --git a/src/components/ChannelList/hooks/useChannelMembershipState.ts b/src/components/ChannelList/hooks/useChannelMembershipState.ts index faf48d1b20..fb80c07669 100644 --- a/src/components/ChannelList/hooks/useChannelMembershipState.ts +++ b/src/components/ChannelList/hooks/useChannelMembershipState.ts @@ -1,28 +1,15 @@ -import { useEffect, useState } from 'react'; -import type { Channel, ChannelState, ExtendableGenerics } from 'stream-chat'; - -import { useChatContext } from '../../../context'; - -export const useChannelMembershipState = ( - channel?: Channel, -) => { - const [membership, setMembership] = useState['membership']>( - channel?.state.membership || {}, - ); - - const { client } = useChatContext(); - - useEffect(() => { - if (!channel) return; - - const subscriptions = ['member.updated'].map((v) => - client.on(v, () => { - setMembership(channel.state.membership); - }), - ); - - return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); - }, [client, channel]); - - return membership; -}; +import type { Channel, ChannelMemberResponse, EventTypes, ExtendableGenerics } from 'stream-chat'; +import { useSelectedChannelState } from './useSelectedChannelState'; + +const selector = (c: Channel) => c.state.membership; +const keys: EventTypes[] = ['member.updated']; + +export function useChannelMembershipState( + channel: Channel, +): ChannelMemberResponse; +export function useChannelMembershipState( + channel?: Channel | undefined, +): ChannelMemberResponse | undefined; +export function useChannelMembershipState(channel?: Channel) { + return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys }); +} diff --git a/src/components/ChannelList/hooks/useSelectedChannelState.ts b/src/components/ChannelList/hooks/useSelectedChannelState.ts new file mode 100644 index 0000000000..bf1e677303 --- /dev/null +++ b/src/components/ChannelList/hooks/useSelectedChannelState.ts @@ -0,0 +1,49 @@ +import { useCallback } from 'react'; +import { useSyncExternalStore } from 'use-sync-external-store/shim'; +import type { Channel, EventTypes, ExtendableGenerics } from 'stream-chat'; + +// eslint-disable-next-line @typescript-eslint/no-empty-function +const noop = () => {}; + +export function useSelectedChannelState(_: { + channel: Channel; + selector: (channel: Channel) => O; + stateChangeEventKeys?: EventTypes[]; +}): O; +export function useSelectedChannelState(_: { + selector: (channel: Channel) => O; + channel?: Channel | undefined; + stateChangeEventKeys?: EventTypes[]; +}): O | undefined; +export function useSelectedChannelState({ + channel, + stateChangeEventKeys = ['all'], + selector, +}: { + selector: (channel: Channel) => O; + channel?: Channel; + stateChangeEventKeys?: EventTypes[]; +}): O | undefined { + const subscribe = useCallback( + (onStoreChange: (value: O) => void) => { + if (!channel) return noop; + + const subscriptions = stateChangeEventKeys.map((et) => + channel.on(et, () => { + onStoreChange(selector(channel)); + }), + ); + + return () => subscriptions.forEach((subscription) => subscription.unsubscribe()); + }, + [channel, selector, stateChangeEventKeys], + ); + + const getSnapshot = useCallback(() => { + if (!channel) return undefined; + + return selector(channel); + }, [channel, selector]); + + return useSyncExternalStore(subscribe, getSnapshot); +} diff --git a/src/components/ChannelList/utils.ts b/src/components/ChannelList/utils.ts index b9cfa83a05..5c38301094 100644 --- a/src/components/ChannelList/utils.ts +++ b/src/components/ChannelList/utils.ts @@ -1,5 +1,5 @@ import uniqBy from 'lodash.uniqby'; -import type { Channel, ChannelSort, ExtendableGenerics } from 'stream-chat'; +import type { Channel, ChannelSort, ChannelSortBase, ExtendableGenerics } from 'stream-chat'; import type { DefaultStreamChatGenerics } from '../../types/types'; import type { ChannelListProps } from './ChannelList'; @@ -67,9 +67,6 @@ type MoveChannelUpwardsParams({ @@ -90,8 +87,11 @@ export const moveChannelUpwards = < // receive messages and are not pinned should move upwards but only under the last pinned channel // in the list const considerPinnedChannels = shouldConsiderPinnedChannels(sort); + const isTargetChannelPinned = isChannelPinned(channelToMove); - if (targetChannelAlreadyAtTheTop) return channels; + if (targetChannelAlreadyAtTheTop || (considerPinnedChannels && isTargetChannelPinned)) { + return channels; + } const newChannels = [...channels]; @@ -118,45 +118,84 @@ export const moveChannelUpwards = < }; /** - * Returns true only if `{ pinned_at: -1 }` or `{ pinned_at: 1 }` option is first within the `sort` array. + * Returns `true` only if object with `pinned_at` property is first within the `sort` array + * or if `pinned_at` key of the `sort` object gets picked first when using `for...in` looping mechanism + * and value of the `pinned_at` is either `1` or `-1`. */ export const shouldConsiderPinnedChannels = ( sort: ChannelListProps['sort'], ) => { - if (!sort) return false; + const value = extractSortValue({ atIndex: 0, sort, targetKey: 'pinned_at' }); - if (!Array.isArray(sort)) return false; + if (typeof value !== 'number') return false; - const [option] = sort; + return Math.abs(value) === 1; +}; - if (!option?.pinned_at) return false; +export const extractSortValue = ({ + atIndex, + sort, + targetKey, +}: { + atIndex: number; + targetKey: keyof ChannelSortBase; + sort?: ChannelListProps['sort']; +}) => { + if (!sort) return null; + let option: null | ChannelSort = null; + + if (Array.isArray(sort)) { + option = sort[atIndex] ?? null; + } else { + let index = 0; + for (const key in sort) { + if (index !== atIndex) { + index++; + continue; + } + + if (key !== targetKey) { + return null; + } + + option = sort; + + break; + } + } - return Math.abs(option.pinned_at) === 1; + return option?.[targetKey] ?? null; }; /** - * Returns `true` only if `archived` property is set to `false` within `filters`. + * Returns `true` only if `archived` property is of type `boolean` within `filters` object. */ export const shouldConsiderArchivedChannels = ( filters: ChannelListProps['filters'], ) => { if (!filters) return false; - return !filters.archived; + return typeof filters.archived === 'boolean'; }; +/** + * Returns `true` only if `pinned_at` property is of type `string` within `membership` object. + */ export const isChannelPinned = (channel: Channel) => { if (!channel) return false; - const member = channel.state.membership; + const membership = channel.state.membership; - return !!member?.pinned_at; + return typeof membership.pinned_at === 'string'; }; +/** + * Returns `true` only if `archived_at` property is of type `string` within `membership` object. + */ export const isChannelArchived = (channel: Channel) => { if (!channel) return false; - const member = channel.state.membership; + const membership = channel.state.membership; - return !!member?.archived_at; + return typeof membership.archived_at === 'string'; }; diff --git a/yarn.lock b/yarn.lock index 91a4498fbb..daf33b6781 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2769,6 +2769,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/use-sync-external-store@^0.0.6": + version "0.0.6" + resolved "https://registry.yarnpkg.com/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz#60be8d21baab8c305132eb9cb912ed497852aadc" + integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== + "@types/uuid@^8.3.0": version "8.3.0" resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.0.tgz#215c231dff736d5ba92410e6d602050cce7e273f" @@ -12231,10 +12236,10 @@ statuses@2.0.1: resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== -stream-chat@^8.47.1: - version "8.47.1" - resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.47.1.tgz#5390c87cbb1929e7ca183aa1204dae3ab38469a2" - integrity sha512-raMAGYLT4UCVluMF0TMfdPKH9OUhDjH6e1HQdJIlllAFLaA8oxtG+e/7jyuPmVodLPzYCPqOt2eBH7soAkhV/A== +stream-chat@^8.50.0: + version "8.52.1" + resolved "https://registry.yarnpkg.com/stream-chat/-/stream-chat-8.52.1.tgz#8c73627937a55d24e66685db3e32653d4e8022ba" + integrity sha512-Z17M3xr3KYl/vdko20YiNA5uZ0iKjAut3GOEI8hQ3nNm/wqkPkS6f5zsgu0axrshXlkoOSETI9zvJm13RLXlYA== dependencies: "@babel/runtime" "^7.16.3" "@types/jsonwebtoken" "~9.0.0" @@ -13197,6 +13202,11 @@ use-latest@^1.0.0: dependencies: use-isomorphic-layout-effect "^1.0.0" +use-sync-external-store@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz#adbc795d8eeb47029963016cefdf89dc799fcebc" + integrity sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"