Skip to content

Commit db77d0b

Browse files
committed
feat: support multiple channel lists with ChannelPaginatorsOrchestrator
1 parent 13d295a commit db77d0b

22 files changed

+4094
-5
lines changed
Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import { EventHandlerPipeline } from './EventHandlerPipeline';
2+
import { WithSubscriptions } from './utils/WithSubscriptions';
3+
import type { Event, EventTypes } from './types';
4+
import type { ChannelPaginator } from './pagination';
5+
import type { StreamChat } from './client';
6+
import type { Unsubscribe } from './store';
7+
import { StateStore } from './store';
8+
import type {
9+
EventHandlerPipelineHandler,
10+
InsertEventHandlerPayload,
11+
LabeledEventHandler,
12+
} from './EventHandlerPipeline';
13+
import { getChannel } from './pagination/utility.queryChannel';
14+
import type { Channel } from './channel';
15+
16+
type ChannelPaginatorsOrchestratorEventHandlerContext = {
17+
orchestrator: ChannelPaginatorsOrchestrator;
18+
};
19+
20+
type SupportedEventType = EventTypes | (string & {});
21+
22+
const reEmit: EventHandlerPipelineHandler<
23+
ChannelPaginatorsOrchestratorEventHandlerContext
24+
> = ({ event, ctx: { orchestrator } }) => {
25+
if (!event.cid) return;
26+
const channel = orchestrator.client.activeChannels[event.cid];
27+
if (!channel) return;
28+
orchestrator.paginators.forEach((paginator) => {
29+
const items = paginator.items;
30+
if (paginator.findItem(channel) && items) {
31+
paginator.state.partialNext({ items: [...items] });
32+
}
33+
});
34+
};
35+
36+
const removeItem: EventHandlerPipelineHandler<
37+
ChannelPaginatorsOrchestratorEventHandlerContext
38+
> = ({ event, ctx: { orchestrator } }) => {
39+
if (!event.cid) return;
40+
const channel = orchestrator.client.activeChannels[event.cid];
41+
orchestrator.paginators.forEach((paginator) => {
42+
paginator.removeItem({ id: event.cid, item: channel });
43+
});
44+
};
45+
46+
const updateLists: EventHandlerPipelineHandler<
47+
ChannelPaginatorsOrchestratorEventHandlerContext
48+
> = async ({ event, ctx: { orchestrator } }) => {
49+
let channel: Channel | undefined = undefined;
50+
if (event.cid) {
51+
channel = orchestrator.client.activeChannels[event.cid];
52+
} else if (event.channel_id && event.channel_type) {
53+
// todo: is there a central method to construct the cid from type and channel id?
54+
channel =
55+
orchestrator.client.activeChannels[`${event.channel_type}:${event.channel_id}`];
56+
} else if (event.channel) {
57+
channel = orchestrator.client.activeChannels[event.channel.cid];
58+
} else {
59+
return;
60+
}
61+
62+
if (!channel) {
63+
const [type, id] = event.cid
64+
? event.cid.split(':')
65+
: [event.channel_type, event.channel_id];
66+
67+
channel = await getChannel({
68+
client: orchestrator.client,
69+
id,
70+
type,
71+
});
72+
}
73+
74+
if (!channel) return;
75+
76+
// todo: can these state updates be made atomic across all the paginators?
77+
// maybe we could add to state store API that would allow to queue changes and then commit?
78+
orchestrator.paginators.forEach((paginator) => {
79+
if (paginator.matchesFilter(channel)) {
80+
// todo: does it make sense to move channel at the top of the items array (original implementation)
81+
// if items are supposed to be ordered by the sort object?
82+
paginator.ingestItem(channel);
83+
} else {
84+
// remove if it does not match the filter anymore
85+
paginator.removeItem({ item: channel });
86+
}
87+
});
88+
};
89+
90+
// todo: we have to make sure that client.activeChannels is always up-to-date
91+
const channelDeletedHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
92+
{
93+
handle: removeItem,
94+
id: 'ChannelPaginatorsOrchestrator:default-handler:channel.deleted',
95+
};
96+
97+
// fixme: is it ok, remove item just because its property hidden is switched to hidden: true? What about offset cursor, should we update it?
98+
const channelHiddenHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
99+
{
100+
handle: removeItem,
101+
id: 'ChannelPaginatorsOrchestrator:default-handler:channel.hidden',
102+
};
103+
104+
// fixme: this handler should not be handled by the orchestrator but as Channel does not have reactive state,
105+
// we need to re-emit the whole list to reflect the changes
106+
const channelUpdatedHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
107+
{
108+
handle: reEmit,
109+
id: 'ChannelPaginatorsOrchestrator:default-handler:channel.updated',
110+
};
111+
112+
// fixme: this handler should not be handled by the orchestrator but as Channel does not have reactive state,
113+
// we need to re-emit the whole list to reflect the changes
114+
const channelTruncatedHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
115+
{
116+
handle: reEmit,
117+
id: 'ChannelPaginatorsOrchestrator:default-handler:channel.truncated',
118+
};
119+
120+
const channelVisibleHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
121+
{
122+
handle: updateLists,
123+
id: 'ChannelPaginatorsOrchestrator:default-handler:channel.visible',
124+
};
125+
126+
// members filter - should not be impacted as id is stable - cannot be updated
127+
// member.user.name - can be impacted
128+
const memberUpdatedHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
129+
{
130+
handle: updateLists,
131+
id: 'ChannelPaginatorsOrchestrator:default-handler:member.updated',
132+
};
133+
134+
const messageNewHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
135+
{
136+
handle: updateLists,
137+
id: 'ChannelPaginatorsOrchestrator:default-handler:message.new',
138+
};
139+
140+
const notificationAddedToChannelHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
141+
{
142+
handle: updateLists,
143+
id: 'ChannelPaginatorsOrchestrator:default-handler:notification.added_to_channel',
144+
};
145+
146+
const notificationMessageNewHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
147+
{
148+
handle: updateLists,
149+
id: 'ChannelPaginatorsOrchestrator:default-handler:notification.message_new',
150+
};
151+
152+
const notificationRemovedFromChannelHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
153+
{
154+
handle: removeItem,
155+
id: 'ChannelPaginatorsOrchestrator:default-handler:notification.removed_from_channel',
156+
};
157+
158+
// fixme: updates users for member object in all the channels which are loaded with that member - normalization would be beneficial
159+
const userPresenceChangedHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
160+
{
161+
handle: ({ event, ctx: { orchestrator } }) => {
162+
const eventUser = event.user;
163+
if (!eventUser?.id) return;
164+
orchestrator.paginators.forEach((paginator) => {
165+
const paginatorItems = paginator.items;
166+
if (!paginatorItems) return;
167+
let updated = false;
168+
paginatorItems.forEach((channel) => {
169+
if (channel.state.members[eventUser.id]) {
170+
channel.state.members[eventUser.id].user = event.user;
171+
updated = true;
172+
}
173+
if (channel.state.membership.user?.id === eventUser.id) {
174+
channel.state.membership.user = eventUser;
175+
updated = true;
176+
}
177+
});
178+
if (updated) {
179+
// fixme: user is not reactive and so the whole list has to be re-rendered
180+
paginator.state.partialNext({ items: [...paginatorItems] });
181+
}
182+
});
183+
},
184+
id: 'ChannelPaginatorsOrchestrator:default-handler:user.presence.changed',
185+
};
186+
187+
export type ChannelPaginatorsOrchestratorState = {
188+
paginators: ChannelPaginator[];
189+
};
190+
191+
type EventHandlers = Partial<
192+
Record<
193+
SupportedEventType,
194+
LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext>[]
195+
>
196+
>;
197+
198+
export type ChannelPaginatorsOrchestratorOptions = {
199+
client: StreamChat;
200+
paginators?: ChannelPaginator[];
201+
eventHandlers?: EventHandlers;
202+
};
203+
204+
export class ChannelPaginatorsOrchestrator extends WithSubscriptions {
205+
client: StreamChat;
206+
state: StateStore<ChannelPaginatorsOrchestratorState>;
207+
protected pipelines = new Map<
208+
SupportedEventType,
209+
EventHandlerPipeline<ChannelPaginatorsOrchestratorEventHandlerContext>
210+
>();
211+
212+
protected static readonly defaultEventHandlers: EventHandlers = {
213+
'channel.deleted': [channelDeletedHandler],
214+
'channel.hidden': [channelHiddenHandler],
215+
'channel.updated': [channelUpdatedHandler],
216+
'channel.truncated': [channelTruncatedHandler],
217+
'channel.visible': [channelVisibleHandler],
218+
'member.updated': [memberUpdatedHandler],
219+
'message.new': [messageNewHandler],
220+
'notification.added_to_channel': [notificationAddedToChannelHandler],
221+
'notification.message_new': [notificationMessageNewHandler],
222+
'notification.removed_from_channel': [notificationRemovedFromChannelHandler],
223+
'user.presence.changed': [userPresenceChangedHandler],
224+
};
225+
226+
constructor({
227+
client,
228+
eventHandlers,
229+
paginators,
230+
}: ChannelPaginatorsOrchestratorOptions) {
231+
super();
232+
this.client = client;
233+
this.state = new StateStore({ paginators: paginators ?? [] });
234+
const finalEventHandlers =
235+
eventHandlers ?? ChannelPaginatorsOrchestrator.getDefaultHandlers();
236+
for (const [type, handlers] of Object.entries(finalEventHandlers)) {
237+
if (handlers) this.ensurePipeline(type).replaceAll(handlers);
238+
}
239+
}
240+
241+
get paginators(): ChannelPaginator[] {
242+
return this.state.getLatestValue().paginators;
243+
}
244+
245+
/**
246+
* Returns deep copy of default handlers mapping.
247+
* The defaults can be enriched with custom handlers or the custom handlers can be replaced.
248+
*/
249+
static getDefaultHandlers(): EventHandlers {
250+
const src = ChannelPaginatorsOrchestrator.defaultEventHandlers;
251+
const out: EventHandlers = {};
252+
for (const [type, handlers] of Object.entries(src)) {
253+
if (!handlers) continue;
254+
out[type as SupportedEventType] = [...handlers];
255+
}
256+
return out;
257+
}
258+
259+
getPaginatorById(id: string) {
260+
return this.paginators.find((p) => p.id === id);
261+
}
262+
263+
/**
264+
* If paginator already exists → remove old, reinsert at new index.
265+
* If index not provided → append at the end.
266+
* If index provided → insert (or move) at that index.
267+
* @param paginator
268+
* @param index
269+
*/
270+
insertPaginator({ paginator, index }: { paginator: ChannelPaginator; index?: number }) {
271+
const paginators = [...this.paginators];
272+
const existingIndex = paginators.findIndex((p) => p.id === paginator.id);
273+
if (existingIndex > -1) {
274+
paginators.splice(existingIndex, 1);
275+
}
276+
const validIndex = Math.max(
277+
0,
278+
Math.min(index ?? paginators.length, paginators.length),
279+
);
280+
paginators.splice(validIndex, 0, paginator);
281+
this.state.partialNext({ paginators });
282+
}
283+
284+
addEventHandler({
285+
eventType,
286+
...payload
287+
}: {
288+
eventType: SupportedEventType;
289+
} & InsertEventHandlerPayload<ChannelPaginatorsOrchestratorEventHandlerContext>): Unsubscribe {
290+
return this.ensurePipeline(eventType).insert(payload);
291+
}
292+
293+
/** Subscribe to WS (and more buses via attachBus) */
294+
registerSubscriptions(): Unsubscribe {
295+
if (!this.hasSubscriptions) {
296+
this.addUnsubscribeFunction(
297+
// todo: maybe we should have a wrapper here to decide, whether the event is a LocalEventBus event or else supported by client
298+
this.client.on((event: Event) => {
299+
const pipe = this.pipelines.get(event.type);
300+
if (pipe) {
301+
pipe.run(event, this.ctx);
302+
}
303+
}).unsubscribe,
304+
);
305+
}
306+
307+
this.incrementRefCount();
308+
return () => this.unregisterSubscriptions();
309+
}
310+
311+
ensurePipeline(
312+
eventType: SupportedEventType,
313+
): EventHandlerPipeline<ChannelPaginatorsOrchestratorEventHandlerContext> {
314+
let pipe = this.pipelines.get(eventType);
315+
if (!pipe) {
316+
pipe = new EventHandlerPipeline<ChannelPaginatorsOrchestratorEventHandlerContext>({
317+
id: `ChannelPaginatorsOrchestrator:${eventType}`,
318+
});
319+
this.pipelines.set(eventType, pipe);
320+
}
321+
return pipe;
322+
}
323+
324+
private get ctx(): ChannelPaginatorsOrchestratorEventHandlerContext {
325+
return { orchestrator: this };
326+
}
327+
}

0 commit comments

Comments
 (0)