Skip to content

Commit 1bc55d9

Browse files
committed
feat: allow to boost paginator items and lock item order
1 parent 9657f53 commit 1bc55d9

File tree

4 files changed

+680
-103
lines changed

4 files changed

+680
-103
lines changed

src/ChannelPaginatorsOrchestrator.ts

Lines changed: 41 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type {
1313
import { getChannel } from './pagination/utility.queryChannel';
1414
import type { Channel } from './channel';
1515

16-
type ChannelPaginatorsOrchestratorEventHandlerContext = {
16+
export type ChannelPaginatorsOrchestratorEventHandlerContext = {
1717
orchestrator: ChannelPaginatorsOrchestrator;
1818
};
1919

@@ -73,12 +73,20 @@ const updateLists: EventHandlerPipelineHandler<
7373

7474
if (!channel) return;
7575

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?
7876
orchestrator.paginators.forEach((paginator) => {
7977
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?
78+
const channelBoost = paginator.getBoost(channel.cid);
79+
if (
80+
[
81+
'message.new',
82+
'notification.message_new',
83+
'notification.added_to_channel',
84+
'channel.visible',
85+
].includes(event.type) &&
86+
(!channelBoost || channelBoost.seq < paginator.maxBoostSeq)
87+
) {
88+
paginator.boost(channel.cid, { seq: paginator.maxBoostSeq + 1 });
89+
}
8290
paginator.ingestItem(channel);
8391
} else {
8492
// remove if it does not match the filter anymore
@@ -87,20 +95,13 @@ const updateLists: EventHandlerPipelineHandler<
8795
});
8896
};
8997

90-
// todo: we have to make sure that client.activeChannels is always up-to-date
98+
// we have to make sure that client.activeChannels is always up-to-date
9199
const channelDeletedHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
92100
{
93101
handle: removeItem,
94102
id: 'ChannelPaginatorsOrchestrator:default-handler:channel.deleted',
95103
};
96104

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-
104105
// fixme: this handler should not be handled by the orchestrator but as Channel does not have reactive state,
105106
// we need to re-emit the whole list to reflect the changes
106107
const channelUpdatedHandler: LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext> =
@@ -188,7 +189,7 @@ export type ChannelPaginatorsOrchestratorState = {
188189
paginators: ChannelPaginator[];
189190
};
190191

191-
type EventHandlers = Partial<
192+
export type ChannelPaginatorsOrchestratorEventHandlers = Partial<
192193
Record<
193194
SupportedEventType,
194195
LabeledEventHandler<ChannelPaginatorsOrchestratorEventHandlerContext>[]
@@ -198,7 +199,7 @@ type EventHandlers = Partial<
198199
export type ChannelPaginatorsOrchestratorOptions = {
199200
client: StreamChat;
200201
paginators?: ChannelPaginator[];
201-
eventHandlers?: EventHandlers;
202+
eventHandlers?: ChannelPaginatorsOrchestratorEventHandlers;
202203
};
203204

204205
export class ChannelPaginatorsOrchestrator extends WithSubscriptions {
@@ -209,19 +210,19 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions {
209210
EventHandlerPipeline<ChannelPaginatorsOrchestratorEventHandlerContext>
210211
>();
211212

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-
};
213+
protected static readonly defaultEventHandlers: ChannelPaginatorsOrchestratorEventHandlers =
214+
{
215+
'channel.deleted': [channelDeletedHandler],
216+
'channel.updated': [channelUpdatedHandler],
217+
'channel.truncated': [channelTruncatedHandler],
218+
'channel.visible': [channelVisibleHandler],
219+
'member.updated': [memberUpdatedHandler],
220+
'message.new': [messageNewHandler],
221+
'notification.added_to_channel': [notificationAddedToChannelHandler],
222+
'notification.message_new': [notificationMessageNewHandler],
223+
'notification.removed_from_channel': [notificationRemovedFromChannelHandler],
224+
'user.presence.changed': [userPresenceChangedHandler],
225+
};
225226

226227
constructor({
227228
client,
@@ -242,13 +243,17 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions {
242243
return this.state.getLatestValue().paginators;
243244
}
244245

246+
private get ctx(): ChannelPaginatorsOrchestratorEventHandlerContext {
247+
return { orchestrator: this };
248+
}
249+
245250
/**
246251
* Returns deep copy of default handlers mapping.
247252
* The defaults can be enriched with custom handlers or the custom handlers can be replaced.
248253
*/
249-
static getDefaultHandlers(): EventHandlers {
254+
static getDefaultHandlers(): ChannelPaginatorsOrchestratorEventHandlers {
250255
const src = ChannelPaginatorsOrchestrator.defaultEventHandlers;
251-
const out: EventHandlers = {};
256+
const out: ChannelPaginatorsOrchestratorEventHandlers = {};
252257
for (const [type, handlers] of Object.entries(src)) {
253258
if (!handlers) continue;
254259
out[type as SupportedEventType] = [...handlers];
@@ -321,7 +326,10 @@ export class ChannelPaginatorsOrchestrator extends WithSubscriptions {
321326
return pipe;
322327
}
323328

324-
private get ctx(): ChannelPaginatorsOrchestratorEventHandlerContext {
325-
return { orchestrator: this };
326-
}
329+
reload = async () =>
330+
await Promise.allSettled(
331+
this.paginators.map(async (paginator) => {
332+
await paginator.reload();
333+
}),
334+
);
327335
}

src/pagination/BasePaginator.ts

Lines changed: 108 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,19 @@ export type PaginatorState<T = any> = {
3535
export type PaginatorOptions = {
3636
/** The number of milliseconds to debounce the search query. The default interval is 300ms. */
3737
debounceMs?: number;
38+
/** Will prevent changing the index of existing items */
39+
lockItemOrder?: boolean;
3840
pageSize?: number;
3941
};
4042
export const DEFAULT_PAGINATION_OPTIONS: Required<PaginatorOptions> = {
4143
debounceMs: 300,
44+
lockItemOrder: false,
4245
pageSize: 10,
4346
} as const;
4447

4548
export abstract class BasePaginator<T> {
4649
state: StateStore<PaginatorState<T>>;
47-
pageSize: number;
50+
config: Required<PaginatorOptions>;
4851
protected _executeQueryDebounced!: DebouncedExecQueryFunction;
4952
protected _isCursorPagination = false;
5053
/**
@@ -72,10 +75,16 @@ export abstract class BasePaginator<T> {
7275
* @protected
7376
*/
7477
protected _filterFieldToDataResolvers: FieldToDataResolver<T>[];
78+
/**
79+
* Ephemeral priority for attention UX without breaking sort invariants
80+
* @protected
81+
*/
82+
protected boosts = new Map<string, { until: number; seq: number }>();
83+
protected _maxBoostSeq: number = 0;
7584

7685
protected constructor(options?: PaginatorOptions) {
77-
const { debounceMs, pageSize } = { ...DEFAULT_PAGINATION_OPTIONS, ...options };
78-
this.pageSize = pageSize;
86+
this.config = { ...DEFAULT_PAGINATION_OPTIONS, ...options };
87+
const { debounceMs } = this.config;
7988
this.state = new StateStore<PaginatorState<T>>(this.initialState);
8089
this.setDebounceOptions({ debounceMs });
8190
this.sortComparator = noOrderChange;
@@ -126,6 +135,19 @@ export abstract class BasePaginator<T> {
126135
return this.state.getLatestValue().offset;
127136
}
128137

138+
get pageSize() {
139+
return this.config.pageSize;
140+
}
141+
142+
/** Single point of truth: always use the effective comparator */
143+
get effectiveComparator() {
144+
return this.boostComparator;
145+
}
146+
147+
get maxBoostSeq() {
148+
return this._maxBoostSeq;
149+
}
150+
129151
abstract query(params: PaginationQueryParams): Promise<PaginationQueryReturnValue<T>>;
130152

131153
abstract filterQueryResults(items: T[]): T[] | Promise<T[]>;
@@ -149,37 +171,98 @@ export abstract class BasePaginator<T> {
149171
});
150172
}
151173

174+
protected clearExpiredBoosts(now = Date.now()) {
175+
for (const [id, b] of this.boosts) if (now > b.until) this.boosts.delete(id);
176+
this._maxBoostSeq = Math.max(
177+
...Array.from(this.boosts.values()).map((boost) => boost.seq),
178+
0,
179+
);
180+
}
181+
182+
/** Comparator that consults boosts first, then falls back to sortComparator */
183+
protected boostComparator = (a: T, b: T): number => {
184+
const now = Date.now();
185+
this.clearExpiredBoosts(now);
186+
187+
const idA = this.getItemId(a);
188+
const idB = this.getItemId(b);
189+
const boostA = this.getBoost(idA);
190+
const boostB = this.getBoost(idB);
191+
192+
const aIsBoosted = !!(boostA && now <= boostA.until);
193+
const bIsBoosted = !!(boostB && now <= boostB.until);
194+
195+
if (aIsBoosted && !bIsBoosted) return -1;
196+
if (!aIsBoosted && bIsBoosted) return 1;
197+
198+
if (aIsBoosted && bIsBoosted) {
199+
// higher seq wins
200+
const seqDistance = (boostB.seq ?? 0) - (boostA.seq ?? 0);
201+
if (seqDistance !== 0) return seqDistance > 0 ? 1 : -1;
202+
// fall through to normal comparator for stability
203+
}
204+
return this.sortComparator(a, b);
205+
};
206+
207+
/** Public API to manage boosts */
208+
boost(id: string, opts?: { ttlMs?: number; until?: number; seq?: number }) {
209+
const now = Date.now();
210+
const until = opts?.until ?? (opts?.ttlMs != null ? now + opts.ttlMs : now + 15000); // default 15s
211+
212+
if (typeof opts?.seq === 'number' && opts.seq > this._maxBoostSeq) {
213+
this._maxBoostSeq = opts.seq;
214+
}
215+
216+
const seq = opts?.seq ?? 0;
217+
this.boosts.set(id, { until, seq });
218+
}
219+
220+
getBoost(id: string) {
221+
return this.boosts.get(id);
222+
}
223+
224+
removeBoost(id: string) {
225+
this.boosts.delete(id);
226+
this._maxBoostSeq = Math.max(
227+
...Array.from(this.boosts.values()).map((boost) => boost.seq),
228+
0,
229+
);
230+
}
231+
232+
isBoosted(id: string) {
233+
const boost = this.getBoost(id);
234+
return !!(boost && Date.now() <= boost.until);
235+
}
236+
152237
ingestItem(ingestedItem: T): boolean {
153238
const items = this.items ?? [];
154239
const id = this.getItemId(ingestedItem);
155-
240+
const next = items.slice();
156241
// If it doesn't match this paginator's filters, remove if present and exit.
157242
const existingIndex = items.findIndex((ch) => this.getItemId(ch) === id);
158243
if (!this.matchesFilter(ingestedItem)) {
159244
if (existingIndex >= 0) {
160-
const next = items.slice();
161245
next.splice(existingIndex, 1);
162246
this.state.partialNext({ items: next });
163247
return true; // list changed (item removed)
164248
}
165249
return false; // no change
166250
}
167251

168-
// Build comparator once per call (you can cache it when sort changes).
169-
170-
const next = items.slice();
171-
172252
if (existingIndex >= 0) {
173253
// Update existing: remove then re-insert at the correct position
174254
next.splice(existingIndex, 1);
175255
}
176256

177-
// Find insertion index via binary search: first index where existing > ingestionItem
178-
const insertAt = binarySearchInsertIndex({
179-
needle: ingestedItem,
180-
sortedArray: next,
181-
compare: this.sortComparator,
182-
});
257+
const insertAt =
258+
this.config.lockItemOrder && existingIndex >= 0
259+
? existingIndex
260+
: // Find insertion index via binary search: first index where existing > ingestionItem
261+
binarySearchInsertIndex({
262+
needle: ingestedItem,
263+
sortedArray: next,
264+
compare: this.effectiveComparator,
265+
});
183266

184267
next.splice(insertAt, 0, ingestedItem);
185268
this.state.partialNext({ items: next });
@@ -246,18 +329,18 @@ export abstract class BasePaginator<T> {
246329
const insertionIndex = binarySearchInsertIndex({
247330
needle,
248331
sortedArray: items,
249-
compare: this.sortComparator,
332+
compare: this.effectiveComparator,
250333
});
251334

252335
// quick neighbor checks
253336
const id = this.getItemId(needle);
254337
const left = insertionIndex - 1;
255-
if (left >= 0 && this.sortComparator(items[left], needle) === 0) {
338+
if (left >= 0 && this.effectiveComparator(items[left], needle) === 0) {
256339
if (this.getItemId(items[left]) === id) return { index: left, insertionIndex };
257340
}
258341
if (
259342
insertionIndex < items.length &&
260-
this.sortComparator(items[insertionIndex], needle) === 0
343+
this.effectiveComparator(items[insertionIndex], needle) === 0
261344
) {
262345
if (this.getItemId(items[insertionIndex]) === id)
263346
return { index: insertionIndex, insertionIndex };
@@ -269,14 +352,14 @@ export abstract class BasePaginator<T> {
269352
? locateOnPlateauAlternating(
270353
items,
271354
needle,
272-
this.sortComparator,
355+
this.effectiveComparator,
273356
this.getItemId.bind(this),
274357
insertionIndex,
275358
)
276359
: locateOnPlateauScanOneSide(
277360
items,
278361
needle,
279-
this.sortComparator,
362+
this.effectiveComparator,
280363
this.getItemId.bind(this),
281364
insertionIndex,
282365
);
@@ -381,4 +464,9 @@ export abstract class BasePaginator<T> {
381464
prevDebounced = () => {
382465
this._executeQueryDebounced({ direction: 'prev' });
383466
};
467+
468+
reload = async () => {
469+
this.resetState();
470+
await this.next();
471+
};
384472
}

0 commit comments

Comments
 (0)