Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: channel-pinning related improvements #2602

Merged
merged 5 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions examples/vite/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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<string, unknown>;
type LocalChannelType = Record<string, unknown>;
Expand Down
6 changes: 6 additions & 0 deletions examples/vite/src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
Expand Down
8 changes: 5 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
115 changes: 68 additions & 47 deletions src/components/ChannelList/hooks/useChannelListShape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import uniqBy from 'lodash.uniqby';

import {
extractSortValue,
findLastPinnedChannelIndex,
isChannelArchived,
isChannelPinned,
Expand Down Expand Up @@ -56,7 +57,7 @@

type HandleMemberUpdatedParameters<SCG extends ExtendableGenerics> = BaseParameters<SCG> & {
lockChannelOrder: boolean;
} & Required<Pick<ChannelListProps<SCG>, 'sort'>>;
} & Required<Pick<ChannelListProps<SCG>, 'sort' | 'filters'>>;

type HandleChannelDeletedParameters<SCG extends ExtendableGenerics> = BaseParameters<SCG> &
RepeatedParameters<SCG>;
Expand Down Expand Up @@ -112,10 +113,15 @@
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);
Expand All @@ -124,35 +130,26 @@
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) ||
arnautov-anton marked this conversation as resolved.
Show resolved Hide resolved
// 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<SCG> | 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],
Expand Down Expand Up @@ -182,7 +179,7 @@
});

const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
if (isChannelArchived(channel) && considerArchivedChannels) {
if (isChannelArchived(channel) && considerArchivedChannels && !filters.archived) {
return;
}

Expand All @@ -208,26 +205,38 @@
customHandler,
event,
setChannels,
sort,
}: HandleNotificationAddedToChannelParameters<SCG>) => {
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<string[]>((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;

Check warning on line 215 in src/components/ChannelList/hooks/useChannelListShape.ts

View check run for this annotation

Codecov / codecov/patch

src/components/ChannelList/hooks/useChannelListShape.ts#L215

Added line #L215 was not covered by tests
}

const channel = await getChannel({
client,
id: event.channel.id,
members: event.channel.members?.reduce<string[]>((newMembers, { user, user_id }) => {
const userId = user_id || user?.id;

if (userId) newMembers.push(userId);

return newMembers;

Check warning on line 226 in src/components/ChannelList/hooks/useChannelListShape.ts

View check run for this annotation

Codecov / codecov/patch

src/components/ChannelList/hooks/useChannelListShape.ts#L226

Added line #L226 was not covered by tests
}, []),
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],
);
Expand All @@ -248,26 +257,34 @@
);

const handleMemberUpdated = useCallback(
({ event, lockChannelOrder, setChannels, sort }: HandleMemberUpdatedParameters<SCG>) => {
({
event,
filters,
lockChannelOrder,
setChannels,
sort,
}: HandleMemberUpdatedParameters<SCG>) => {
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);

Check warning on line 275 in src/components/ChannelList/hooks/useChannelListShape.ts

View check run for this annotation

Codecov / codecov/patch

src/components/ChannelList/hooks/useChannelListShape.ts#L275

Added line #L275 was not covered by tests

// 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' });

Check warning on line 277 in src/components/ChannelList/hooks/useChannelListShape.ts

View check run for this annotation

Codecov / codecov/patch

src/components/ChannelList/hooks/useChannelListShape.ts#L277

Added line #L277 was not covered by tests

setChannels((currentChannels) => {
const targetChannel = client.channel(channelType, channelId);
// assumes that channel instances are not changing
const targetChannelIndex = currentChannels.indexOf(targetChannel);
const targetChannelExistsWithinList = targetChannelIndex >= 0;

const isTargetChannelArchived = isChannelArchived(targetChannel);
const isTargetChannelPinned = isChannelPinned(targetChannel);

Check warning on line 286 in src/components/ChannelList/hooks/useChannelListShape.ts

View check run for this annotation

Codecov / codecov/patch

src/components/ChannelList/hooks/useChannelListShape.ts#L285-L286

Added lines #L285 - L286 were not covered by tests

// handle pinning
if (!considerPinnedChannels || lockChannelOrder) return currentChannels;

Expand All @@ -278,7 +295,10 @@
}

// handle archiving (remove channel)
if (typeof member.archived_at === 'string') {
if (
(considerArchivedChannels && isTargetChannelArchived && !filters.archived) ||
(considerArchivedChannels && !isTargetChannelArchived && filters.archived)
) {
return newChannels;
}

Expand All @@ -287,7 +307,7 @@
// 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 });
}

Expand Down Expand Up @@ -553,6 +573,7 @@
case 'member.updated':
defaults.handleMemberUpdated({
event,
filters,
lockChannelOrder,
setChannels,
sort,
Expand Down
43 changes: 15 additions & 28 deletions src/components/ChannelList/hooks/useChannelMembershipState.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,15 @@
import { useEffect, useState } from 'react';
import type { Channel, ChannelState, ExtendableGenerics } from 'stream-chat';

import { useChatContext } from '../../../context';

export const useChannelMembershipState = <SCG extends ExtendableGenerics>(
channel?: Channel<SCG>,
) => {
const [membership, setMembership] = useState<ChannelState<SCG>['membership']>(
channel?.state.membership || {},
);

const { client } = useChatContext<SCG>();

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 = <SCG extends ExtendableGenerics>(c: Channel<SCG>) => c.state.membership;
const keys: EventTypes[] = ['member.updated'];

export function useChannelMembershipState<SCG extends ExtendableGenerics>(
channel: Channel<SCG>,
): ChannelMemberResponse<SCG>;
export function useChannelMembershipState<SCG extends ExtendableGenerics>(
channel?: Channel<SCG> | undefined,
): ChannelMemberResponse<SCG> | undefined;
export function useChannelMembershipState<SCG extends ExtendableGenerics>(channel?: Channel<SCG>) {
return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys });
}
49 changes: 49 additions & 0 deletions src/components/ChannelList/hooks/useSelectedChannelState.ts
Original file line number Diff line number Diff line change
@@ -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<SCG extends ExtendableGenerics, O>(_: {
channel: Channel<SCG>;
selector: (channel: Channel<SCG>) => O;
stateChangeEventKeys?: EventTypes[];
}): O;
export function useSelectedChannelState<SCG extends ExtendableGenerics, O>(_: {
selector: (channel: Channel<SCG>) => O;
channel?: Channel<SCG> | undefined;
stateChangeEventKeys?: EventTypes[];
}): O | undefined;
export function useSelectedChannelState<SCG extends ExtendableGenerics, O>({
channel,
stateChangeEventKeys = ['all'],
selector,
}: {
selector: (channel: Channel<SCG>) => O;
channel?: Channel<SCG>;
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));

Check warning on line 33 in src/components/ChannelList/hooks/useSelectedChannelState.ts

View check run for this annotation

Codecov / codecov/patch

src/components/ChannelList/hooks/useSelectedChannelState.ts#L33

Added line #L33 was not covered by tests
}),
);

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);
}
Loading
Loading