Skip to content

Commit 9f41a4c

Browse files
fix: channel-pinning related improvements (#2602)
### 🎯 Goal Adjust certain conditions to match behavior of the RN implementation, add missing handler for the `notification.added_to_channel`. #### Notable Changes - `notification.added_to_channel` is being handled properly (considers pinned channels) - if `sort` is an object and `pinned_at` is first in chronological order of property creation `{ pinned_at: 1 | -1 }` then pinned channels are considered (#2595 (comment)) Related PR: GetStream/stream-chat-js#1430
1 parent 8b686fe commit 9f41a4c

File tree

8 files changed

+214
-101
lines changed

8 files changed

+214
-101
lines changed

examples/vite/src/App.tsx

+1-2
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import {
1414
ThreadList,
1515
ChatView,
1616
} from 'stream-chat-react';
17-
import 'stream-chat-react/css/v2/index.css';
1817

1918
const params = (new Proxy(new URLSearchParams(window.location.search), {
2019
get: (searchParams, property) => searchParams.get(property as string),
@@ -38,7 +37,7 @@ const filters: ChannelFilters = {
3837
archived: false,
3938
};
4039
const options: ChannelOptions = { limit: 5, presence: true, state: true };
41-
const sort: ChannelSort = [{ pinned_at: 1 }, { last_message_at: -1 }, { updated_at: -1 }];
40+
const sort: ChannelSort = { pinned_at: 1, last_message_at: -1, updated_at: -1 };
4241

4342
type LocalAttachmentType = Record<string, unknown>;
4443
type LocalChannelType = Record<string, unknown>;

examples/vite/src/index.scss

+6
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ body,
1212
height: 100%;
1313
}
1414

15+
@layer stream, emoji-replacement;
16+
17+
@import url('stream-chat-react/css/v2/index.css') layer(stream);
18+
// use in combination with useImageFlagEmojisOnWindows prop on Chat component
19+
// @import url('stream-chat-react/css/v2/emoji-replacement.css') layer(emoji-replacement);
20+
1521
#root {
1622
display: flex;
1723
height: 100%;

package.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,8 @@
132132
"textarea-caret": "^3.1.0",
133133
"tslib": "^2.6.2",
134134
"unist-builder": "^3.0.0",
135-
"unist-util-visit": "^5.0.0"
135+
"unist-util-visit": "^5.0.0",
136+
"use-sync-external-store": "^1.4.0"
136137
},
137138
"optionalDependencies": {
138139
"@stream-io/transliterate": "^1.5.5",
@@ -145,7 +146,7 @@
145146
"emoji-mart": "^5.4.0",
146147
"react": "^18.0.0 || ^17.0.0 || ^16.8.0",
147148
"react-dom": "^18.0.0 || ^17.0.0 || ^16.8.0",
148-
"stream-chat": "^8.46.1"
149+
"stream-chat": "^8.50.0"
149150
},
150151
"peerDependenciesMeta": {
151152
"@breezystack/lamejs": {
@@ -207,6 +208,7 @@
207208
"@types/react-image-gallery": "^1.2.4",
208209
"@types/react-is": "^18.2.4",
209210
"@types/textarea-caret": "3.0.0",
211+
"@types/use-sync-external-store": "^0.0.6",
210212
"@types/uuid": "^8.3.0",
211213
"@typescript-eslint/eslint-plugin": "5.62.0",
212214
"@typescript-eslint/parser": "5.62.0",
@@ -257,7 +259,7 @@
257259
"react-dom": "^18.1.0",
258260
"react-test-renderer": "^18.1.0",
259261
"semantic-release": "^19.0.5",
260-
"stream-chat": "^8.47.1",
262+
"stream-chat": "^8.50.0",
261263
"ts-jest": "^29.1.4",
262264
"typescript": "^5.4.5"
263265
},

src/components/ChannelList/hooks/useChannelListShape.ts

+68-47
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Channel, Event, ExtendableGenerics } from 'stream-chat';
55
import uniqBy from 'lodash.uniqby';
66

77
import {
8+
extractSortValue,
89
findLastPinnedChannelIndex,
910
isChannelArchived,
1011
isChannelPinned,
@@ -56,7 +57,7 @@ type HandleNotificationAddedToChannelParameters<
5657

5758
type HandleMemberUpdatedParameters<SCG extends ExtendableGenerics> = BaseParameters<SCG> & {
5859
lockChannelOrder: boolean;
59-
} & Required<Pick<ChannelListProps<SCG>, 'sort'>>;
60+
} & Required<Pick<ChannelListProps<SCG>, 'sort' | 'filters'>>;
6061

6162
type HandleChannelDeletedParameters<SCG extends ExtendableGenerics> = BaseParameters<SCG> &
6263
RepeatedParameters<SCG>;
@@ -112,10 +113,15 @@ export const useChannelListShapeDefaults = <SCG extends ExtendableGenerics>() =>
112113
return customHandler(setChannels, event);
113114
}
114115

115-
setChannels((channels) => {
116-
const targetChannelIndex = channels.findIndex((channel) => channel.cid === event.cid);
116+
const channelType = event.channel_type;
117+
const channelId = event.channel_id;
118+
119+
if (!channelType || !channelId) return;
120+
121+
setChannels((currentChannels) => {
122+
const targetChannel = client.channel(channelType, channelId);
123+
const targetChannelIndex = currentChannels.indexOf(targetChannel);
117124
const targetChannelExistsWithinList = targetChannelIndex >= 0;
118-
const targetChannel = channels[targetChannelIndex];
119125

120126
const isTargetChannelPinned = isChannelPinned(targetChannel);
121127
const isTargetChannelArchived = isChannelArchived(targetChannel);
@@ -124,35 +130,26 @@ export const useChannelListShapeDefaults = <SCG extends ExtendableGenerics>() =>
124130
const considerPinnedChannels = shouldConsiderPinnedChannels(sort);
125131

126132
if (
127-
// target channel is archived
128-
(isTargetChannelArchived && considerArchivedChannels) ||
129-
// target channel is pinned
130-
(isTargetChannelPinned && considerPinnedChannels) ||
133+
// filter is defined, target channel is archived and filter option is set to false
134+
(considerArchivedChannels && isTargetChannelArchived && !filters.archived) ||
135+
// filter is defined, target channel isn't archived and filter option is set to true
136+
(considerArchivedChannels && !isTargetChannelArchived && filters.archived) ||
137+
// sort option is defined, target channel is pinned
138+
(considerPinnedChannels && isTargetChannelPinned) ||
131139
// list order is locked
132140
lockChannelOrder ||
133141
// target channel is not within the loaded list and loading from cache is disallowed
134142
(!targetChannelExistsWithinList && !allowNewMessagesFromUnfilteredChannels)
135143
) {
136-
return channels;
137-
}
138-
139-
// we either have the channel to move or we pull it from the cache (or instantiate) if it's allowed
140-
const channelToMove: Channel<SCG> | null =
141-
channels[targetChannelIndex] ??
142-
(allowNewMessagesFromUnfilteredChannels && event.channel_type
143-
? client.channel(event.channel_type, event.channel_id)
144-
: null);
145-
146-
if (channelToMove) {
147-
return moveChannelUpwards({
148-
channels,
149-
channelToMove,
150-
channelToMoveIndexWithinChannels: targetChannelIndex,
151-
sort,
152-
});
144+
return currentChannels;
153145
}
154146

155-
return channels;
147+
return moveChannelUpwards({
148+
channels: currentChannels,
149+
channelToMove: targetChannel,
150+
channelToMoveIndexWithinChannels: targetChannelIndex,
151+
sort,
152+
});
156153
});
157154
},
158155
[client],
@@ -182,7 +179,7 @@ export const useChannelListShapeDefaults = <SCG extends ExtendableGenerics>() =>
182179
});
183180

184181
const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
185-
if (isChannelArchived(channel) && considerArchivedChannels) {
182+
if (isChannelArchived(channel) && considerArchivedChannels && !filters.archived) {
186183
return;
187184
}
188185

@@ -208,26 +205,38 @@ export const useChannelListShapeDefaults = <SCG extends ExtendableGenerics>() =>
208205
customHandler,
209206
event,
210207
setChannels,
208+
sort,
211209
}: HandleNotificationAddedToChannelParameters<SCG>) => {
212210
if (typeof customHandler === 'function') {
213211
return customHandler(setChannels, event);
214212
}
215213

216-
if (allowNewMessagesFromUnfilteredChannels && event.channel?.type) {
217-
const channel = await getChannel({
218-
client,
219-
id: event.channel.id,
220-
members: event.channel.members?.reduce<string[]>((acc, { user, user_id }) => {
221-
const userId = user_id || user?.id;
222-
if (userId) {
223-
acc.push(userId);
224-
}
225-
return acc;
226-
}, []),
227-
type: event.channel.type,
228-
});
229-
setChannels((channels) => uniqBy([channel, ...channels], 'cid'));
214+
if (!event.channel || !allowNewMessagesFromUnfilteredChannels) {
215+
return;
230216
}
217+
218+
const channel = await getChannel({
219+
client,
220+
id: event.channel.id,
221+
members: event.channel.members?.reduce<string[]>((newMembers, { user, user_id }) => {
222+
const userId = user_id || user?.id;
223+
224+
if (userId) newMembers.push(userId);
225+
226+
return newMembers;
227+
}, []),
228+
type: event.channel.type,
229+
});
230+
231+
// membership has been reset (target channel shouldn't be pinned nor archived)
232+
setChannels((channels) =>
233+
moveChannelUpwards({
234+
channels,
235+
channelToMove: channel,
236+
channelToMoveIndexWithinChannels: -1,
237+
sort,
238+
}),
239+
);
231240
},
232241
[client],
233242
);
@@ -248,26 +257,34 @@ export const useChannelListShapeDefaults = <SCG extends ExtendableGenerics>() =>
248257
);
249258

250259
const handleMemberUpdated = useCallback(
251-
({ event, lockChannelOrder, setChannels, sort }: HandleMemberUpdatedParameters<SCG>) => {
260+
({
261+
event,
262+
filters,
263+
lockChannelOrder,
264+
setChannels,
265+
sort,
266+
}: HandleMemberUpdatedParameters<SCG>) => {
252267
if (!event.member?.user || event.member.user.id !== client.userID || !event.channel_type) {
253268
return;
254269
}
255270

256-
const member = event.member;
257271
const channelType = event.channel_type;
258272
const channelId = event.channel_id;
259273

260274
const considerPinnedChannels = shouldConsiderPinnedChannels(sort);
275+
const considerArchivedChannels = shouldConsiderArchivedChannels(filters);
261276

262-
// TODO: extract this and consider single property sort object too
263-
const pinnedAtSort = Array.isArray(sort) ? sort[0]?.pinned_at ?? null : null;
277+
const pinnedAtSort = extractSortValue({ atIndex: 0, sort, targetKey: 'pinned_at' });
264278

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

285+
const isTargetChannelArchived = isChannelArchived(targetChannel);
286+
const isTargetChannelPinned = isChannelPinned(targetChannel);
287+
271288
// handle pinning
272289
if (!considerPinnedChannels || lockChannelOrder) return currentChannels;
273290

@@ -278,7 +295,10 @@ export const useChannelListShapeDefaults = <SCG extends ExtendableGenerics>() =>
278295
}
279296

280297
// handle archiving (remove channel)
281-
if (typeof member.archived_at === 'string') {
298+
if (
299+
(considerArchivedChannels && isTargetChannelArchived && !filters.archived) ||
300+
(considerArchivedChannels && !isTargetChannelArchived && filters.archived)
301+
) {
282302
return newChannels;
283303
}
284304

@@ -287,7 +307,7 @@ export const useChannelListShapeDefaults = <SCG extends ExtendableGenerics>() =>
287307
// calculate last pinned channel index only if `pinned_at` sort is set to
288308
// ascending order or if it's in descending order while the pin is being removed, otherwise
289309
// we move to the top (index 0)
290-
if (pinnedAtSort === 1 || (pinnedAtSort === -1 && !member.pinned_at)) {
310+
if (pinnedAtSort === 1 || (pinnedAtSort === -1 && !isTargetChannelPinned)) {
291311
lastPinnedChannelIndex = findLastPinnedChannelIndex({ channels: newChannels });
292312
}
293313

@@ -553,6 +573,7 @@ export const usePrepareShapeHandlers = <SCG extends ExtendableGenerics>({
553573
case 'member.updated':
554574
defaults.handleMemberUpdated({
555575
event,
576+
filters,
556577
lockChannelOrder,
557578
setChannels,
558579
sort,
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,15 @@
1-
import { useEffect, useState } from 'react';
2-
import type { Channel, ChannelState, ExtendableGenerics } from 'stream-chat';
3-
4-
import { useChatContext } from '../../../context';
5-
6-
export const useChannelMembershipState = <SCG extends ExtendableGenerics>(
7-
channel?: Channel<SCG>,
8-
) => {
9-
const [membership, setMembership] = useState<ChannelState<SCG>['membership']>(
10-
channel?.state.membership || {},
11-
);
12-
13-
const { client } = useChatContext<SCG>();
14-
15-
useEffect(() => {
16-
if (!channel) return;
17-
18-
const subscriptions = ['member.updated'].map((v) =>
19-
client.on(v, () => {
20-
setMembership(channel.state.membership);
21-
}),
22-
);
23-
24-
return () => subscriptions.forEach((subscription) => subscription.unsubscribe());
25-
}, [client, channel]);
26-
27-
return membership;
28-
};
1+
import type { Channel, ChannelMemberResponse, EventTypes, ExtendableGenerics } from 'stream-chat';
2+
import { useSelectedChannelState } from './useSelectedChannelState';
3+
4+
const selector = <SCG extends ExtendableGenerics>(c: Channel<SCG>) => c.state.membership;
5+
const keys: EventTypes[] = ['member.updated'];
6+
7+
export function useChannelMembershipState<SCG extends ExtendableGenerics>(
8+
channel: Channel<SCG>,
9+
): ChannelMemberResponse<SCG>;
10+
export function useChannelMembershipState<SCG extends ExtendableGenerics>(
11+
channel?: Channel<SCG> | undefined,
12+
): ChannelMemberResponse<SCG> | undefined;
13+
export function useChannelMembershipState<SCG extends ExtendableGenerics>(channel?: Channel<SCG>) {
14+
return useSelectedChannelState({ channel, selector, stateChangeEventKeys: keys });
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { useCallback } from 'react';
2+
import { useSyncExternalStore } from 'use-sync-external-store/shim';
3+
import type { Channel, EventTypes, ExtendableGenerics } from 'stream-chat';
4+
5+
// eslint-disable-next-line @typescript-eslint/no-empty-function
6+
const noop = () => {};
7+
8+
export function useSelectedChannelState<SCG extends ExtendableGenerics, O>(_: {
9+
channel: Channel<SCG>;
10+
selector: (channel: Channel<SCG>) => O;
11+
stateChangeEventKeys?: EventTypes[];
12+
}): O;
13+
export function useSelectedChannelState<SCG extends ExtendableGenerics, O>(_: {
14+
selector: (channel: Channel<SCG>) => O;
15+
channel?: Channel<SCG> | undefined;
16+
stateChangeEventKeys?: EventTypes[];
17+
}): O | undefined;
18+
export function useSelectedChannelState<SCG extends ExtendableGenerics, O>({
19+
channel,
20+
stateChangeEventKeys = ['all'],
21+
selector,
22+
}: {
23+
selector: (channel: Channel<SCG>) => O;
24+
channel?: Channel<SCG>;
25+
stateChangeEventKeys?: EventTypes[];
26+
}): O | undefined {
27+
const subscribe = useCallback(
28+
(onStoreChange: (value: O) => void) => {
29+
if (!channel) return noop;
30+
31+
const subscriptions = stateChangeEventKeys.map((et) =>
32+
channel.on(et, () => {
33+
onStoreChange(selector(channel));
34+
}),
35+
);
36+
37+
return () => subscriptions.forEach((subscription) => subscription.unsubscribe());
38+
},
39+
[channel, selector, stateChangeEventKeys],
40+
);
41+
42+
const getSnapshot = useCallback(() => {
43+
if (!channel) return undefined;
44+
45+
return selector(channel);
46+
}, [channel, selector]);
47+
48+
return useSyncExternalStore(subscribe, getSnapshot);
49+
}

0 commit comments

Comments
 (0)