Skip to content

Commit aeaa92d

Browse files
committed
feat: allow to keep channels in certain matching paginators and not in other matching paginators
1 parent f4892c6 commit aeaa92d

File tree

2 files changed

+251
-17
lines changed

2 files changed

+251
-17
lines changed

src/ChannelPaginatorsOrchestrator.ts

Lines changed: 103 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,52 @@ type EventHandlerContext = ChannelPaginatorsOrchestratorEventHandlerContext;
2222

2323
type SupportedEventType = EventTypes | (string & {});
2424

25+
/**
26+
* Resolves which paginators should be the "owners" of a channel
27+
* when the channel matches multiple paginator filters.
28+
*
29+
* Return a set of paginator ids that should keep/own the item.
30+
* Returning an empty set means the channel will be removed everywhere.
31+
*/
32+
export type PaginatorOwnershipResolver = (args: {
33+
channel: Channel;
34+
matchingPaginators: ChannelPaginator[];
35+
}) => string[];
36+
37+
/**
38+
* Convenience factory for a priority-based ownership resolver.
39+
* - Provide an ordered list of paginator ids from highest to lowest priority.
40+
* - If two or more paginators match a channel, the one with the highest priority wins.
41+
* - If none of the matching paginator ids are in the priority list, all matches are kept (back-compat).
42+
*/
43+
export const createPriorityOwnershipResolver = (
44+
priority?: string[],
45+
): PaginatorOwnershipResolver => {
46+
if (!priority) {
47+
return ({ matchingPaginators }) => matchingPaginators.map((p) => p.id);
48+
}
49+
const rank = new Map<string, number>(priority.map((id, index) => [id, index]));
50+
return ({ matchingPaginators }) => {
51+
if (matchingPaginators.length <= 1) {
52+
return matchingPaginators.map((p) => p.id);
53+
}
54+
// The winner is the first item in the sorted array of matching paginators
55+
const winner = [...matchingPaginators].sort((a, b) => {
56+
const rankA = rank.get(a.id);
57+
const rankB = rank.get(b.id);
58+
const valueA = rankA === undefined ? Number.POSITIVE_INFINITY : rankA;
59+
const valueB = rankB === undefined ? Number.POSITIVE_INFINITY : rankB;
60+
return valueA - valueB;
61+
})[0];
62+
const winnerValue = rank.get(winner.id);
63+
// If no explicit priority is set for any, keep all (preserve current behavior)
64+
if (winnerValue === undefined) {
65+
return matchingPaginators.map((p) => p.id);
66+
}
67+
return [winner.id];
68+
};
69+
};
70+
2571
const getCachedChannelFromEvent = (
2672
event: Event,
2773
cache: Record<string, Channel>,
@@ -101,25 +147,41 @@ const updateLists: EventHandlerPipelineHandler<EventHandlerContext> = async ({
101147

102148
if (!channel) return;
103149

150+
const matchingPaginators = orchestrator.paginators.filter((p) =>
151+
p.matchesFilter(channel),
152+
);
153+
const matchingIds = new Set(matchingPaginators.map((p) => p.id));
154+
155+
const ownerIds = orchestrator.resolveOwnership(channel, matchingPaginators);
156+
104157
orchestrator.paginators.forEach((paginator) => {
105-
if (paginator.matchesFilter(channel)) {
106-
const channelBoost = paginator.getBoost(channel.cid);
107-
if (
108-
[
109-
'message.new',
110-
'notification.message_new',
111-
'notification.added_to_channel',
112-
'channel.visible',
113-
].includes(event.type) &&
114-
(!channelBoost || channelBoost.seq < paginator.maxBoostSeq)
115-
) {
116-
paginator.boost(channel.cid, { seq: paginator.maxBoostSeq + 1 });
117-
}
118-
paginator.ingestItem(channel);
119-
} else {
158+
if (!matchingIds.has(paginator.id)) {
120159
// remove if it does not match the filter anymore
121160
paginator.removeItem({ item: channel });
161+
return;
162+
}
163+
164+
// Only if owners are specified, the items is removed from the non-owner matching paginators
165+
if (ownerIds.size > 0 && !ownerIds.has(paginator.id)) {
166+
// matched, but not selected to own - remove to enforce exclusivity
167+
paginator.removeItem({ item: channel });
168+
return;
169+
}
170+
171+
// Selected owner: optionally boost then ingest
172+
const channelBoost = paginator.getBoost(channel.cid);
173+
if (
174+
[
175+
'message.new',
176+
'notification.message_new',
177+
'notification.added_to_channel',
178+
'channel.visible',
179+
].includes(event.type) &&
180+
(!channelBoost || channelBoost.seq < paginator.maxBoostSeq)
181+
) {
182+
paginator.boost(channel.cid, { seq: paginator.maxBoostSeq + 1 });
122183
}
184+
paginator.ingestItem(channel);
123185
});
124186
};
125187

@@ -215,6 +277,13 @@ export type ChannelPaginatorsOrchestratorOptions = {
215277
client: StreamChat;
216278
paginators?: ChannelPaginator[];
217279
eventHandlers?: ChannelPaginatorsOrchestratorEventHandlers;
280+
/**
281+
* Decide which paginator(s) should own a channel when multiple match.
282+
* Defaults to keeping the channel in all matching paginators.
283+
* Channels are kept only in the paginators that are listed in the ownershipResolver array.
284+
* Empty ownershipResolver array means that the channel is kept in all matching paginators.
285+
*/
286+
ownershipResolver?: PaginatorOwnershipResolver | string[];
218287
};
219288

220289
export class ChannelPaginatorsOrchestrator extends WithSubscriptions {
@@ -224,6 +293,7 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions {
224293
SupportedEventType,
225294
EventHandlerPipeline<EventHandlerContext>
226295
>();
296+
protected ownershipResolver?: PaginatorOwnershipResolver;
227297

228298
protected static readonly defaultEventHandlers: ChannelPaginatorsOrchestratorEventHandlers =
229299
{
@@ -244,10 +314,17 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions {
244314
client,
245315
eventHandlers,
246316
paginators,
317+
ownershipResolver,
247318
}: ChannelPaginatorsOrchestratorOptions) {
248319
super();
249320
this.client = client;
250321
this.state = new StateStore({ paginators: paginators ?? [] });
322+
if (ownershipResolver) {
323+
this.ownershipResolver = Array.isArray(ownershipResolver)
324+
? createPriorityOwnershipResolver(ownershipResolver)
325+
: ownershipResolver;
326+
}
327+
251328
const finalEventHandlers =
252329
eventHandlers ?? ChannelPaginatorsOrchestrator.getDefaultHandlers();
253330
for (const [type, handlers] of Object.entries(finalEventHandlers)) {
@@ -281,6 +358,17 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions {
281358
return out;
282359
}
283360

361+
/**
362+
* Which paginators should own the channel among the ones that matched.
363+
* Default behavior keeps the channel in all matching paginators.
364+
*/
365+
resolveOwnership(
366+
channel: Channel,
367+
matchingPaginators: ChannelPaginator[],
368+
): Set<string> {
369+
return new Set(this.ownershipResolver?.({ channel, matchingPaginators }) ?? []);
370+
}
371+
284372
getPaginatorById(id: string) {
285373
return this.paginators.find((p) => p.id === id);
286374
}

test/unit/ChannelPaginatorsOrchestrator.test.ts

Lines changed: 148 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ import {
66
EventTypes,
77
type StreamChat,
88
} from '../../src';
9-
import { ChannelPaginatorsOrchestrator } from '../../src/ChannelPaginatorsOrchestrator';
9+
import {
10+
ChannelPaginatorsOrchestrator,
11+
createPriorityOwnershipResolver,
12+
} from '../../src/ChannelPaginatorsOrchestrator';
1013
vi.mock('../../src/pagination/utility.queryChannel', async () => {
1114
return {
1215
getChannel: vi.fn(async ({ client, id, type }) => {
@@ -24,6 +27,146 @@ describe('ChannelPaginatorsOrchestrator', () => {
2427
vi.clearAllMocks();
2528
});
2629

30+
describe('ownershipResolver', () => {
31+
it('keeps channel in all matching paginators by default', async () => {
32+
const ch = makeChannel('messaging:100');
33+
client.activeChannels[ch.cid] = ch;
34+
35+
const p1 = new ChannelPaginator({ client, filters: { type: 'messaging' } });
36+
const p2 = new ChannelPaginator({ client, filters: { type: 'messaging' } });
37+
38+
const orchestrator = new ChannelPaginatorsOrchestrator({
39+
client,
40+
paginators: [p1, p2],
41+
});
42+
orchestrator.registerSubscriptions();
43+
44+
client.dispatchEvent({ type: 'message.new', cid: ch.cid });
45+
await vi.waitFor(() => {
46+
expect(orchestrator.getPaginatorById(p1.id)).toStrictEqual(p1);
47+
expect(orchestrator.getPaginatorById(p2.id)).toStrictEqual(p2);
48+
expect(p1.items).toHaveLength(1);
49+
expect(p1.items![0]).toStrictEqual(ch);
50+
expect(p2.items).toHaveLength(1);
51+
expect(p2.items![0]).toStrictEqual(ch);
52+
});
53+
});
54+
55+
it('keeps channel only in highest-priority matching paginator when resolver provided', async () => {
56+
const pHigh = new ChannelPaginator({ client, filters: { type: 'messaging' } });
57+
const pLow = new ChannelPaginator({ client, filters: { type: 'messaging' } });
58+
const orchestrator = new ChannelPaginatorsOrchestrator({
59+
client,
60+
paginators: [pLow, pHigh],
61+
ownershipResolver: createPriorityOwnershipResolver([pHigh.id, pLow.id]),
62+
});
63+
64+
const ch = makeChannel('messaging:101');
65+
client.activeChannels[ch.cid] = ch;
66+
67+
orchestrator.registerSubscriptions();
68+
client.dispatchEvent({ type: 'message.new', cid: ch.cid });
69+
70+
await vi.waitFor(() => {
71+
expect(pHigh.items).toHaveLength(1);
72+
expect(pHigh.items![0]).toStrictEqual(ch);
73+
expect(pLow.items).toBeUndefined();
74+
});
75+
});
76+
77+
it('keeps item in all priority ownership paginators when resolver returns multiple ids', async () => {
78+
const pHigh = new ChannelPaginator({ client, filters: { type: 'messaging' } });
79+
const pLow = new ChannelPaginator({ client, filters: { type: 'messaging' } });
80+
const orchestrator = new ChannelPaginatorsOrchestrator({
81+
client,
82+
paginators: [pLow, pHigh],
83+
ownershipResolver: () => [pHigh.id, pLow.id],
84+
});
85+
86+
const ch = makeChannel('messaging:101');
87+
client.activeChannels[ch.cid] = ch;
88+
89+
orchestrator.registerSubscriptions();
90+
client.dispatchEvent({ type: 'message.new', cid: ch.cid });
91+
92+
await vi.waitFor(() => {
93+
expect(pHigh.items).toHaveLength(1);
94+
expect(pHigh.items![0]).toStrictEqual(ch);
95+
expect(pLow.items).toHaveLength(1);
96+
expect(pLow.items![0]).toStrictEqual(ch);
97+
});
98+
});
99+
100+
it('accepts ownershipResolver as array of ids and applies priority', async () => {
101+
const pLow = new ChannelPaginator({ client, filters: { type: 'messaging' } });
102+
const pHigh = new ChannelPaginator({ client, filters: { type: 'messaging' } });
103+
const orchestrator = new ChannelPaginatorsOrchestrator({
104+
client,
105+
paginators: [pLow, pHigh],
106+
ownershipResolver: [pHigh.id, pLow.id],
107+
});
108+
109+
const ch = makeChannel('messaging:102');
110+
client.activeChannels[ch.cid] = ch;
111+
112+
orchestrator.registerSubscriptions();
113+
client.dispatchEvent({ type: 'message.new', cid: ch.cid });
114+
115+
await vi.waitFor(() => {
116+
expect(pHigh.items).toHaveLength(1);
117+
expect(pHigh.items![0]).toStrictEqual(ch);
118+
expect(pLow.items).toBeUndefined();
119+
});
120+
});
121+
122+
it('keeps items only in owner paginators if some matching paginators are not listed in ownershipResolver array', async () => {
123+
const pLow = new ChannelPaginator({ client, filters: { type: 'messaging' } });
124+
const pHigh = new ChannelPaginator({ client, filters: { type: 'messaging' } });
125+
const orchestrator = new ChannelPaginatorsOrchestrator({
126+
client,
127+
paginators: [pLow, pHigh],
128+
ownershipResolver: [pHigh.id],
129+
});
130+
131+
const ch = makeChannel('messaging:102');
132+
client.activeChannels[ch.cid] = ch;
133+
134+
orchestrator.registerSubscriptions();
135+
client.dispatchEvent({ type: 'message.new', cid: ch.cid });
136+
137+
await vi.waitFor(() => {
138+
expect(pHigh.items).toHaveLength(1);
139+
expect(pHigh.items![0]).toStrictEqual(ch);
140+
expect(pLow.items).toBeUndefined();
141+
});
142+
});
143+
144+
it('keeps items only in matching paginators if owner paginators are not matching', async () => {
145+
const p1 = new ChannelPaginator({ client, filters: { type: 'messaging' } });
146+
const p2 = new ChannelPaginator({ client, filters: { type: 'messaging' } });
147+
const p3 = new ChannelPaginator({ client, filters: { type: 'messagingX' } });
148+
const orchestrator = new ChannelPaginatorsOrchestrator({
149+
client,
150+
paginators: [p1, p2, p3],
151+
ownershipResolver: [p3.id],
152+
});
153+
154+
const ch = makeChannel('messaging:102');
155+
client.activeChannels[ch.cid] = ch;
156+
157+
orchestrator.registerSubscriptions();
158+
client.dispatchEvent({ type: 'message.new', cid: ch.cid });
159+
160+
await vi.waitFor(() => {
161+
expect(p1.items).toHaveLength(1);
162+
expect(p1.items![0]).toStrictEqual(ch);
163+
expect(p2.items).toHaveLength(1);
164+
expect(p2.items![0]).toStrictEqual(ch);
165+
expect(p3.items).toBeUndefined();
166+
});
167+
});
168+
});
169+
27170
describe('constructor', () => {
28171
it('initiates with default options', () => {
29172
// @ts-expect-error accessing protected property
@@ -381,7 +524,10 @@ describe('ChannelPaginatorsOrchestrator', () => {
381524
// Helper to create a minimal channel with needed state
382525
function makeChannel(cid: string) {
383526
const [type, id] = cid.split(':');
384-
return client.channel(type, id);
527+
const channel = client.channel(type, id);
528+
channel.data!.type = type;
529+
channel.data!.id = id;
530+
return channel;
385531
}
386532

387533
describe.each(['channel.deleted', 'channel.hidden'] as EventTypes[])(

0 commit comments

Comments
 (0)