@recursiv/sdk 0.7.70 → 0.7.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,479 @@
1
+ import { isSensitivePost, linksAdultSite } from './discovery-safety.js';
2
+ // ──────────────────────────────────────────────────────────────────────────
3
+ // How a Discover client merges its sources' posts: by engagement, with the
4
+ // network mix as a soft constraint, never by taking turns. Ported from the
5
+ // Minds app (lib/feedMerge, 2026-10-04).
6
+ //
7
+ // A progressive feed reads Minds and each open network as its own session
8
+ // (createProgressiveFeed), so something has to put their pages together.
9
+ // Until 2026-10-04 the Minds app took turns, one row per network: live,
10
+ // Politics' rows 3-21 cycled Minds, Bluesky, Nostr and ActivityPub, the Nostr
11
+ // turns holding posts with no engagement beside 150-like posts.
12
+ //
13
+ // Each post module gets a score, and rows are filled best first:
14
+ //
15
+ // - engagement: likes, reposts (x2), replies (x3), quotes and zappers (x2).
16
+ // Networks count on different scales, so the points are normalised twice
17
+ // and the two halves added: against the median of the post's own network
18
+ // in the batch, and against the median of the whole batch, both on a log
19
+ // scale with the medians held to at least DISCOVERY_REFERENCE_FLOOR;
20
+ // - recency: the score halves every DISCOVERY_HALF_LIFE_HOURS of age;
21
+ // - format: a video that can play scores DISCOVERY_VIDEO_BOOST times, a
22
+ // picture DISCOVERY_PICTURE_BOOST times (neither when it would be masked);
23
+ // - a post nobody engaged with (counts known, all zero) goes below every post
24
+ // with engagement unless it is fresh (under DISCOVERY_FRESH_MS): then it
25
+ // scores DISCOVERY_FRESH_NORM. A post whose network reports no counts is
26
+ // unknown, not zero: it scores DISCOVERY_UNKNOWN_NORM.
27
+ //
28
+ // The network mix is a soft constraint: no network takes more than
29
+ // DISCOVERY_NETWORK_ROWS_MAX of any DISCOVERY_NETWORK_WINDOW rows, nor more
30
+ // than DISCOVERY_NETWORK_RUN_MAX in a row, and a titled module waits until
31
+ // DISCOVERY_TITLE_SCREEN_ROWS rows separate it from the last one titled the
32
+ // same. When nothing left fits, the run gives way first, then the title, then
33
+ // the share: nothing is dropped. Minds members have a floor: no more than
34
+ // DISCOVERY_MINDS_GAP_MAX rows in a row without one of their posts while they
35
+ // have one of the same standing left.
36
+ //
37
+ // The galleries a batch holds are pooled into one, its cards best first, at
38
+ // most DISCOVERY_GALLERY_NETWORK_MAX from one network; the cards that do not
39
+ // fit go in as posts of their own.
40
+ // ──────────────────────────────────────────────────────────────────────────
41
+ /** A radar story's lane (discoveryModuleLane). */
42
+ export const DISCOVERY_STORY_LANE = 'story';
43
+ /** A suggestion rail's lane (groups, people, tags). */
44
+ export const DISCOVERY_RAIL_LANE = 'rail';
45
+ /** Rows the network share is counted over... */
46
+ export const DISCOVERY_NETWORK_WINDOW = 20;
47
+ /** ...and the most of them one network may take (40%). */
48
+ export const DISCOVERY_NETWORK_ROWS_MAX = 8;
49
+ /** The most rows in a row one network may take. */
50
+ export const DISCOVERY_NETWORK_RUN_MAX = 3;
51
+ /** The home network's floor: after this many rows in a row with no Minds member's post, the next row is Minds' best of the tier. */
52
+ export const DISCOVERY_MINDS_GAP_MAX = 5;
53
+ /** Rows that separate two modules with the same title: more than a screen. */
54
+ export const DISCOVERY_TITLE_SCREEN_ROWS = 6;
55
+ /** A post younger than this with no engagement yet is fresh: it scores DISCOVERY_FRESH_NORM, not zero. */
56
+ export const DISCOVERY_FRESH_MS = 2 * 60 * 60_000;
57
+ /** The score halves with every this many hours of a post's age. */
58
+ export const DISCOVERY_HALF_LIFE_HOURS = 24;
59
+ /** A video that can play, and a picture, against a text post with the same engagement. */
60
+ export const DISCOVERY_VIDEO_BOOST = 1.5;
61
+ export const DISCOVERY_PICTURE_BOOST = 1.15;
62
+ /** The least a network's (or the batch's) typical engagement is taken to be, in points. */
63
+ export const DISCOVERY_REFERENCE_FLOOR = 5;
64
+ /** A fresh post with no engagement yet, and a post whose network reports no counts: against a typical post's 1. */
65
+ export const DISCOVERY_FRESH_NORM = 0.5;
66
+ export const DISCOVERY_UNKNOWN_NORM = 0.5;
67
+ /** A pooled gallery's cards, and the most of them from one network. */
68
+ export const DISCOVERY_POOLED_GALLERY_MAX = 12;
69
+ export const DISCOVERY_GALLERY_NETWORK_MAX = 5;
70
+ const POINTS = { likes: 1, reposts: 2, replies: 3, quotes: 2, zappers: 2 };
71
+ const TITLED = new Set(['visual', 'video', 'conversation', 'thread', 'longread']);
72
+ const RAILS = new Set(['groups', 'people', 'tags']);
73
+ const isPostItem = (item) => item.kind === 'minds' || item.kind === 'social';
74
+ /**
75
+ * The network a post in Minds' own lane comes from. Minds' feeds carry
76
+ * accounts that fediverse servers federated into legacy minds.com, stored as
77
+ * ordinary accounts with a `name@host` handle; a post by one is ActivityPub's.
78
+ * The server's `isFederatedHandle` rule: a Minds username never carries an @.
79
+ */
80
+ export function mindsPostNetwork(post) {
81
+ const username = post?.author?.username?.trim().replace(/^@/, '');
82
+ return username?.includes('@') ? 'activitypub' : 'minds';
83
+ }
84
+ /** The network a post item comes from: 'minds' only for a Minds member's own post. */
85
+ export function discoveryPostNetwork(item) {
86
+ return item.kind === 'minds' ? mindsPostNetwork(item.post) : item.post.protocol;
87
+ }
88
+ /** Whose row a module is: the network of the post it leads with ('minds' for a Minds member), the story lane or the rail lane. */
89
+ export function discoveryModuleLane(module) {
90
+ const lead = module.items[0];
91
+ if (!lead)
92
+ return DISCOVERY_RAIL_LANE;
93
+ if (lead.kind === 'cluster')
94
+ return DISCOVERY_STORY_LANE;
95
+ if (!isPostItem(lead))
96
+ return DISCOVERY_RAIL_LANE;
97
+ return discoveryPostNetwork(lead);
98
+ }
99
+ const isModule = (row) => !!row && typeof row === 'object' && 'presentation' in row && 'items' in row && 'spec' in row;
100
+ /** A module of posts (a post, a gallery, a conversation, a strip of clips): what the merge orders. */
101
+ export function isDiscoveryPostModule(row) {
102
+ if (!isModule(row))
103
+ return false;
104
+ const lane = discoveryModuleLane(row);
105
+ return lane !== DISCOVERY_STORY_LANE && lane !== DISCOVERY_RAIL_LANE;
106
+ }
107
+ /** What a row is titled, as a key: a featured module's kind and theme, a rail's kind. Null for a row with no title of its own. */
108
+ export function discoveryRowTitle(row) {
109
+ if (!isModule(row))
110
+ return null;
111
+ const presentation = row.presentation;
112
+ if (RAILS.has(presentation))
113
+ return presentation;
114
+ if (!TITLED.has(presentation))
115
+ return null;
116
+ const theme = row.theme;
117
+ return `${presentation}|${typeof theme === 'string' ? theme : ''}`;
118
+ }
119
+ const hasMedia = (media) => Array.isArray(media)
120
+ && media.some(entry => !!entry && typeof entry === 'object' && (entry.type === 'image' || entry.type === 'video')
121
+ && entry.unplayable !== true);
122
+ /**
123
+ * A post the reader may be shown first: a Minds post nobody masks (isSensitivePost), and an open-network post that
124
+ * links no porn site (their cards carry no NSFW flag).
125
+ */
126
+ export function discoveryLeadable(item) {
127
+ return item.kind === 'minds' ? !isSensitivePost(item.post) : !linksAdultSite(item.post.text);
128
+ }
129
+ /** A post with a picture or a video of its own that the reader sees unblurred. */
130
+ export function discoveryShowsMedia(item) {
131
+ return discoveryLeadable(item) && hasMedia(item.post.media);
132
+ }
133
+ // ── Videos that can play ────────────────────────────────────────────────
134
+ /** Video files (or playlists) a player found will not play this session: never promoted again. */
135
+ const UNAVAILABLE_VIDEOS = new Set();
136
+ const UNAVAILABLE_VIDEOS_KEPT = 512;
137
+ /** A player found this video file (or HLS playlist) will not play: no Discover module promotes it, and it gets no video boost. */
138
+ export function reportDiscoveryVideoUnavailable(url) {
139
+ if (!url)
140
+ return;
141
+ UNAVAILABLE_VIDEOS.add(url);
142
+ while (UNAVAILABLE_VIDEOS.size > UNAVAILABLE_VIDEOS_KEPT)
143
+ UNAVAILABLE_VIDEOS.delete(UNAVAILABLE_VIDEOS.values().next().value);
144
+ }
145
+ /** Forget every report (a test seam, or a new viewer). */
146
+ export function resetDiscoveryVideoReports() { UNAVAILABLE_VIDEOS.clear(); }
147
+ const VIDEO_EXTENSIONS = ['.mp4', '.webm', '.mov', '.m4v', '.avi', '.m3u8'];
148
+ const isVideoUrl = (url) => VIDEO_EXTENSIONS.some(ext => url.toLowerCase().includes(ext)) || url.includes('cloudflarestream') || url.includes('/video/');
149
+ /** A URL that is a picture, whatever the row says: the legacy thumbnail route, or an image extension. */
150
+ const POSTER_URL = /\/fs\/v1\/thumbnail\/|\.(png|jpe?g|gif|webp|avif)(\?|$)/i;
151
+ /**
152
+ * A media entry a module may feature as a video: a video (by its type, or by its address when it has none) with a file
153
+ * to play, not a poster row (a row typed video whose file is a picture), not still encoding or failed by its own
154
+ * `status`, and not reported unavailable (reportDiscoveryVideoUnavailable).
155
+ */
156
+ export function promotableDiscoveryVideo(entry) {
157
+ const media = (typeof entry === 'string' ? { url: entry } : entry);
158
+ if (!media || typeof media !== 'object' || typeof media.url !== 'string' || !media.url)
159
+ return false;
160
+ const type = typeof media.type === 'string' && media.type ? media.type : isVideoUrl(media.url) ? 'video' : 'image';
161
+ if (type !== 'video' || media.unplayable === true || POSTER_URL.test(media.url))
162
+ return false;
163
+ if (media.status === 'processing' || media.status === 'failed')
164
+ return false;
165
+ if (UNAVAILABLE_VIDEOS.has(media.url))
166
+ return false;
167
+ const hls = typeof media.hlsUrl === 'string' ? media.hlsUrl : typeof media.hls_url === 'string' ? media.hls_url : '';
168
+ return !(hls && UNAVAILABLE_VIDEOS.has(hls));
169
+ }
170
+ /** A module's items whose post has a video that can play (promotableDiscoveryVideo). */
171
+ export function promotedDiscoveryVideoItems(items) {
172
+ return items.filter(item => 'post' in item && (() => {
173
+ const media = item.post.media;
174
+ return Array.isArray(media) ? media.some(promotableDiscoveryVideo) : promotableDiscoveryVideo(media);
175
+ })());
176
+ }
177
+ // ── Engagement ──────────────────────────────────────────────────────────
178
+ /** The first of the values that is a count (a finite number, 0 or more); null when none is. */
179
+ function firstCount(...values) {
180
+ for (const value of values) {
181
+ const count = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : Number.NaN;
182
+ if (Number.isFinite(count) && count >= 0)
183
+ return count;
184
+ }
185
+ return null;
186
+ }
187
+ function mindsPoints(post) {
188
+ if (!post)
189
+ return null;
190
+ const likes = firstCount(post.reactions_count, post.reactionsCount, post.likeCount, post.upvote_count, post.score);
191
+ const replies = firstCount(post.reply_count, post.repliesCount, post.replies_count);
192
+ const external = firstCount(post.reply_count_external, post.replyCountExternal);
193
+ const reposts = firstCount(post.reposts_count, post.repostsCount, post.repostCount);
194
+ if (likes === null && replies === null && external === null && reposts === null)
195
+ return null;
196
+ return POINTS.likes * (likes ?? 0) + POINTS.replies * ((replies ?? 0) + (external ?? 0)) + POINTS.reposts * (reposts ?? 0);
197
+ }
198
+ /**
199
+ * A post's engagement points: likes, reposts, replies, quotes and zappers. Null when its network reports no counts
200
+ * (unknown, not zero). A Minds repost counts its original's too, when that is more: the card shows the original.
201
+ */
202
+ export function discoveryEngagementPoints(item) {
203
+ if (item.kind === 'minds') {
204
+ const post = item.post;
205
+ const own = mindsPoints(post);
206
+ const original = mindsPoints((post.reposted_from ?? post.repostedFrom));
207
+ return own === null ? original : original === null ? own : Math.max(own, original);
208
+ }
209
+ const post = item.post;
210
+ const likes = firstCount(post.likeCount);
211
+ const reposts = firstCount(post.repostCount);
212
+ const replies = firstCount(post.replyCount);
213
+ const quotes = firstCount(post.quoteCount);
214
+ const zappers = firstCount(post.distinctZappers);
215
+ if (likes === null && reposts === null && replies === null && quotes === null && zappers === null)
216
+ return null;
217
+ return POINTS.likes * (likes ?? 0) + POINTS.reposts * (reposts ?? 0) + POINTS.replies * (replies ?? 0)
218
+ + POINTS.quotes * (quotes ?? 0) + POINTS.zappers * (zappers ?? 0);
219
+ }
220
+ function writtenAt(item) {
221
+ const post = item.post;
222
+ const value = item.kind === 'minds' ? post.created_at ?? post.createdAt ?? post.publishedAt : post.createdAt;
223
+ const at = typeof value === 'string' || typeof value === 'number' ? new Date(value).getTime() : Number.NaN;
224
+ return Number.isFinite(at) ? at : null;
225
+ }
226
+ /** Whose counts a post's points are: Minds' own for any post in Minds' lane, else its network's. */
227
+ const countedBy = (item) => (item.kind === 'minds' ? 'minds' : item.post.protocol);
228
+ function median(values) {
229
+ if (!values.length)
230
+ return 0;
231
+ const sorted = [...values].sort((a, b) => a - b);
232
+ const middle = Math.floor(sorted.length / 2);
233
+ return sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2;
234
+ }
235
+ /** The typical engagement of a batch of posts: the median of the posts with any, held to at least DISCOVERY_REFERENCE_FLOOR. */
236
+ export function discoveryEngagementReference(items) {
237
+ const byNetwork = new Map();
238
+ const all = [];
239
+ for (const item of items) {
240
+ const points = discoveryEngagementPoints(item);
241
+ if (points === null || points <= 0)
242
+ continue;
243
+ all.push(points);
244
+ const network = countedBy(item);
245
+ const list = byNetwork.get(network);
246
+ if (list)
247
+ list.push(points);
248
+ else
249
+ byNetwork.set(network, [points]);
250
+ }
251
+ const networks = new Map();
252
+ for (const [network, list] of byNetwork)
253
+ networks.set(network, Math.max(DISCOVERY_REFERENCE_FLOOR, median(list)));
254
+ return { networks, batch: Math.max(DISCOVERY_REFERENCE_FLOOR, median(all)) };
255
+ }
256
+ /** A post's tier and score in a batch. `now`: the time the scores are for. */
257
+ export function rankDiscoveryItem(item, reference, now) {
258
+ const points = discoveryEngagementPoints(item);
259
+ const at = writtenAt(item);
260
+ const age = at === null ? null : Math.max(0, now - at);
261
+ // A post that does not say when it was written is taken to be a day old: neither fresh nor buried.
262
+ const decay = 0.5 ** ((age ?? DISCOVERY_HALF_LIFE_HOURS * 3_600_000) / (DISCOVERY_HALF_LIFE_HOURS * 3_600_000));
263
+ const boost = promotedDiscoveryVideoItems([item]).length && discoveryLeadable(item) ? DISCOVERY_VIDEO_BOOST : discoveryShowsMedia(item) ? DISCOVERY_PICTURE_BOOST : 1;
264
+ if (points === null)
265
+ return { tier: 0, score: DISCOVERY_UNKNOWN_NORM * decay * boost };
266
+ if (points > 0) {
267
+ const own = Math.log1p(points) / Math.log1p(reference.networks.get(countedBy(item)) ?? DISCOVERY_REFERENCE_FLOOR);
268
+ const batch = Math.log1p(points) / Math.log1p(reference.batch);
269
+ return { tier: 0, score: ((own + batch) / 2) * decay * boost };
270
+ }
271
+ if (age !== null && age < DISCOVERY_FRESH_MS)
272
+ return { tier: 0, score: DISCOVERY_FRESH_NORM * decay * boost };
273
+ return { tier: 1, score: decay * boost };
274
+ }
275
+ /** A module's best post: the lowest tier, then the highest score. Null for a module with no posts. */
276
+ export function rankDiscoveryModule(module, reference, now) {
277
+ let best = null;
278
+ for (const item of module.items) {
279
+ if (!isPostItem(item))
280
+ continue;
281
+ const rank = rankDiscoveryItem(item, reference, now);
282
+ if (!best || rank.tier < best.tier || (rank.tier === best.tier && rank.score > best.score))
283
+ best = rank;
284
+ }
285
+ return best;
286
+ }
287
+ /** Where a module's post stands in the top of the feed: its place in the top's block, or null (not a top post, or a network lane's entry). */
288
+ export function discoveryTopPlace(module) {
289
+ const first = module.items[0];
290
+ const top = first && isPostItem(first) ? first.top : undefined;
291
+ return top && !top.lane && top.block === 'top' && typeof top.position === 'number' ? top.position : null;
292
+ }
293
+ const postsOf = (modules) => modules.flatMap(module => module.items.filter(isPostItem));
294
+ /** Several posts of one network one under another (a conversation, a thread of more than two): a screenful of one source. */
295
+ export function isColumnModule(module) {
296
+ const presentation = module.presentation;
297
+ return (presentation === 'conversation' || presentation === 'thread') && module.items.filter(isPostItem).length > 2;
298
+ }
299
+ const isGallery = (module) => module.presentation === 'visual' && module.items.length > 0 && module.items.every(isPostItem);
300
+ const withItems = (module, items) => {
301
+ const element = module.spec.elements.module;
302
+ return { ...module, items, spec: { ...module.spec, elements: { ...module.spec.elements, module: { ...element, props: { ...element.props, itemKeys: items.map(item => item.key) } } } } };
303
+ };
304
+ /** One card of a gallery as a post of its own (the ordinary presentation, so its spec still validates). */
305
+ function asPost(gallery, item) {
306
+ const element = gallery.spec.elements.module;
307
+ return { id: `${gallery.id}~${item.key}`, presentation: 'posts', source: 'fallback', items: [item],
308
+ spec: { root: 'module', elements: { module: { ...element, type: 'DiscoveryPosts', props: { itemKeys: [item.key] }, children: [] } } } };
309
+ }
310
+ /**
311
+ * The batch with its galleries pooled into one (one per title): the cards best first, at most
312
+ * DISCOVERY_GALLERY_NETWORK_MAX from one network and DISCOVERY_POOLED_GALLERY_MAX in all; every card that does not fit
313
+ * goes in as a post of its own. The gallery keeps the place and id of the batch's first gallery; one gallery that fits
314
+ * as it is stays as it is. `keep`: a module never pooled (a screen's lead).
315
+ */
316
+ export function poolDiscoveryGalleries(modules, now, keep) {
317
+ const themes = new Map();
318
+ for (const module of modules) {
319
+ if (module === keep || !isGallery(module))
320
+ continue;
321
+ const title = discoveryRowTitle(module) ?? '';
322
+ const group = themes.get(title);
323
+ if (group)
324
+ group.push(module);
325
+ else
326
+ themes.set(title, [module]);
327
+ }
328
+ if (!themes.size)
329
+ return [...modules];
330
+ const reference = discoveryEngagementReference(postsOf(modules));
331
+ const replaced = new Map();
332
+ for (const galleries of themes.values()) {
333
+ const seen = new Set();
334
+ const cards = galleries.flatMap((gallery, from) => gallery.items.filter(isPostItem).flatMap((item, index) => {
335
+ if (seen.has(item.key))
336
+ return [];
337
+ seen.add(item.key);
338
+ return [{ item, gallery, network: discoveryPostNetwork(item), rank: rankDiscoveryItem(item, reference, now), order: index * galleries.length + from }];
339
+ })).sort((a, b) => a.rank.tier - b.rank.tier || b.rank.score - a.rank.score || a.order - b.order);
340
+ const counts = new Map();
341
+ const kept = [];
342
+ const spilled = [];
343
+ for (const card of cards) {
344
+ const count = counts.get(card.network) ?? 0;
345
+ if (kept.length < DISCOVERY_POOLED_GALLERY_MAX && count < DISCOVERY_GALLERY_NETWORK_MAX) {
346
+ kept.push(card);
347
+ counts.set(card.network, count + 1);
348
+ }
349
+ else
350
+ spilled.push(card);
351
+ }
352
+ if (galleries.length === 1 && !spilled.length)
353
+ continue;
354
+ const [first, ...rest] = galleries;
355
+ replaced.set(first, [withItems(first, kept.map(card => card.item)), ...spilled.map(card => asPost(card.gallery, card.item))]);
356
+ for (const gallery of rest)
357
+ replaced.set(gallery, []);
358
+ }
359
+ return replaced.size ? modules.flatMap(module => replaced.get(module) ?? [module]) : [...modules];
360
+ }
361
+ const laneOfRow = (row) => (isDiscoveryPostModule(row) ? discoveryModuleLane(row) : null);
362
+ /**
363
+ * A batch of post modules, taken best first under the network mix's soft constraints (the rules above). Each `take`
364
+ * reads the rows above the place it fills, so it fits the rows a feed already shows.
365
+ *
366
+ * The top of the feed's posts (discoveryTopPlace) are taken first, in the top's order: the engagement lanes own that
367
+ * order. `order` breaks equal scores: the caller's order of the modules (lower first), by default their order in `modules`.
368
+ */
369
+ export class DiscoveryEngagementMerge {
370
+ left;
371
+ constructor(modules, { now = Date.now(), order } = {}) {
372
+ const reference = discoveryEngagementReference(postsOf(modules));
373
+ this.left = modules.map((module, index) => {
374
+ const rank = rankDiscoveryModule(module, reference, now) ?? { tier: 1, score: 0 };
375
+ return { module, lane: discoveryModuleLane(module), title: discoveryRowTitle(module), column: isColumnModule(module), top: discoveryTopPlace(module),
376
+ tier: rank.tier, score: rank.score, order: order ? order(module, index) : index };
377
+ }).sort((a, b) => (a.top ?? Number.POSITIVE_INFINITY) - (b.top ?? Number.POSITIVE_INFINITY) || a.tier - b.tier || b.score - a.score || a.order - b.order);
378
+ }
379
+ get size() { return this.left.length; }
380
+ /** The modules not taken yet, best first. */
381
+ rest() { return this.left.map(candidate => candidate.module); }
382
+ /** Whether a module is still to be taken. */
383
+ has(module) { return this.left.some(candidate => candidate.module === module); }
384
+ /** Takes this module out of the batch (a screen's lead). */
385
+ remove(module) { this.left = this.left.filter(candidate => candidate.module !== module); }
386
+ /**
387
+ * The module for the row under `above`, or undefined when none is left. `singles`: a column (a conversation, a
388
+ * thread) only when no single post of the same tier is left. `newNetwork`: a post from a network none of the rows
389
+ * above come from, while one of the same tier is left (a first screen's second row).
390
+ */
391
+ take(above, { singles = false, newNetwork = false } = {}) {
392
+ if (!this.left.length)
393
+ return undefined;
394
+ const pick = (candidate) => { this.left.splice(this.left.indexOf(candidate), 1); return candidate.module; };
395
+ const topped = this.left.find(candidate => candidate.top !== null);
396
+ if (topped)
397
+ return pick(topped);
398
+ const tier = Math.min(...this.left.map(candidate => candidate.tier));
399
+ let pool = this.left.filter(candidate => candidate.tier === tier);
400
+ if (singles && pool.some(candidate => !candidate.column))
401
+ pool = pool.filter(candidate => !candidate.column);
402
+ if (newNetwork) {
403
+ const shown = new Set(above.map(laneOfRow));
404
+ if (pool.some(candidate => !shown.has(candidate.lane)))
405
+ pool = pool.filter(candidate => !shown.has(candidate.lane));
406
+ }
407
+ let since = 0;
408
+ while (since < above.length && laneOfRow(above[above.length - 1 - since]) !== 'minds')
409
+ since += 1;
410
+ const home = since >= DISCOVERY_MINDS_GAP_MAX ? pool.find(candidate => candidate.lane === 'minds') : undefined;
411
+ if (home)
412
+ return pick(home);
413
+ const window = above.slice(-(DISCOVERY_NETWORK_WINDOW - 1)).map(laneOfRow);
414
+ const run = above.slice(-DISCOVERY_NETWORK_RUN_MAX).map(laneOfRow);
415
+ const titles = new Set(above.slice(-(DISCOVERY_TITLE_SCREEN_ROWS - 1)).map(discoveryRowTitle).filter((title) => !!title));
416
+ const share = (candidate) => window.filter(lane => lane === candidate.lane).length < DISCOVERY_NETWORK_ROWS_MAX;
417
+ const unbroken = (candidate) => run.length < DISCOVERY_NETWORK_RUN_MAX || run.some(lane => lane !== candidate.lane);
418
+ const untitled = (candidate) => !candidate.title || !titles.has(candidate.title);
419
+ const chosen = pool.find(candidate => share(candidate) && unbroken(candidate) && untitled(candidate))
420
+ ?? pool.find(candidate => share(candidate) && untitled(candidate))
421
+ ?? pool.find(candidate => share(candidate) && unbroken(candidate))
422
+ ?? pool.find(share)
423
+ ?? pool[0];
424
+ return pick(chosen);
425
+ }
426
+ }
427
+ function weave(first, second) {
428
+ const out = [];
429
+ for (let index = 0; index < Math.max(first.length, second.length); index++) {
430
+ if (index < first.length)
431
+ out.push(first[index]);
432
+ if (index < second.length)
433
+ out.push(second[index]);
434
+ }
435
+ return out;
436
+ }
437
+ /** The place below which rows that arrive go: after the last row seen and one more, never above `floor`; the end when nothing was seen. */
438
+ function arrivalPlace(rows, reached, floor) {
439
+ let last = -1;
440
+ rows.forEach((row, index) => { if (reached.has(row.id))
441
+ last = index; });
442
+ return Math.max(Math.min(floor, rows.length), last < 0 ? rows.length : Math.min(rows.length, last + 2));
443
+ }
444
+ /** Rows that arrive woven in one by one with the rows not seen yet, below the place a reader has reached: for a feed of one source. */
445
+ export function weaveDiscoveryArrival(rows, arriving, reached, floor = 0) {
446
+ const at = arrivalPlace(rows, reached, floor);
447
+ return at >= rows.length ? [...rows, ...arriving] : [...rows.slice(0, at), ...weave(arriving, rows.slice(at))];
448
+ }
449
+ /**
450
+ * Where rows that arrive after others go (a progressive feed's `mixed` part). Nothing the viewer has had on screen
451
+ * moves: they go after the last row seen (`reached`, by id, and one more for a row only peeking in), never above `floor`;
452
+ * with nothing seen yet, at the end. Below that place the arriving rows and the rows not seen yet are merged: the rows
453
+ * that are not posts (a story, a rail, a row of the client's own) take the places they would have had woven in one by
454
+ * one, and the posts fill the other places by engagement under the network mix (DiscoveryEngagementMerge).
455
+ */
456
+ export function mergeDiscoveryArrival(rows, arriving, reached, floor = 0, now = Date.now()) {
457
+ const at = arrivalPlace(rows, reached, floor);
458
+ const head = rows.slice(0, at);
459
+ const below = at >= rows.length ? [...arriving] : weave(arriving, rows.slice(at));
460
+ const posts = below.filter(isDiscoveryPostModule);
461
+ if (!posts.length)
462
+ return [...head, ...below];
463
+ const pooled = poolDiscoveryGalleries(posts, now);
464
+ const place = new Map(posts.map((module, index) => [module, index]));
465
+ const merge = new DiscoveryEngagementMerge(pooled, { now, order: (module, index) => place.get(module) ?? posts.length + index });
466
+ const out = [...head];
467
+ for (const row of below) {
468
+ if (!isDiscoveryPostModule(row)) {
469
+ out.push(row);
470
+ continue;
471
+ }
472
+ const next = merge.take(out);
473
+ if (next)
474
+ out.push(next);
475
+ }
476
+ for (let next = merge.take(out); next; next = merge.take(out))
477
+ out.push(next);
478
+ return out;
479
+ }
@@ -0,0 +1,117 @@
1
+ import type { DiscoveryFeedOptions, DiscoveryFeedPage, DiscoveryModule, DiscoveryRecommendationSources, DiscoveryTopSource } from './discovery.js';
2
+ import type { SocialNetwork } from './protocols.js';
3
+ /** A feed session a progressive feed reads, one per source: a DiscoveryFeedSession, or a reader of the same shape. */
4
+ export interface DiscoveryFeedReader {
5
+ next(): Promise<DiscoveryFeedPage>;
6
+ retryFailed(): void;
7
+ }
8
+ /** What opens a progressive feed's per-source sessions: `sdk.discovery`, or a reader of the same shape (a visitor's, a For you feed's). */
9
+ export interface DiscoveryFeedFactory {
10
+ createFeed(options: DiscoveryFeedOptions): DiscoveryFeedReader;
11
+ }
12
+ /**
13
+ * One source's page, as it arrived. `round` is the load that asked for it.
14
+ * `composed`: its modules are a first screen (composeFirstScreen), whose first module is its lead and whose rows are
15
+ * ordered by engagement across the networks; a client that moves clips out of it keeps both (`keepLead`).
16
+ * `mixed`: one of several sources' pages (a feed read per network) handed on as it landed, after the first screen: a
17
+ * client merges it by engagement with the rows the reader has not seen yet (mergeDiscoveryArrival). The first screen
18
+ * is ordered already, and a feed of one source keeps its server's order.
19
+ */
20
+ export interface DiscoveryPart {
21
+ source: string;
22
+ round: number;
23
+ modules: DiscoveryModule[];
24
+ composed?: boolean;
25
+ mixed?: boolean;
26
+ }
27
+ /** A progressive feed's state: a page's summary without its modules. */
28
+ export type DiscoveryFeedStatus = Omit<DiscoveryFeedPage, 'modules'>;
29
+ export interface ProgressiveDiscoveryFeed {
30
+ /**
31
+ * Read the next page of every source that has one; each part goes to `onPart` as it lands. Resolves with the round's
32
+ * summary (every module the round handed on while it was open). Rejects only when every source it read failed and
33
+ * nothing arrived.
34
+ */
35
+ next(onPart: (part: DiscoveryPart) => void): Promise<DiscoveryFeedPage>;
36
+ /** Mark the failed sources readable again; the next `next()` reads them. */
37
+ retryFailed(): void;
38
+ /**
39
+ * Told when a source that was still reading after its round ended lands, with the feed's state then (its posts, if
40
+ * any, went to the open `onPart`), so a network that fails late says so without another load. Returns an unsubscribe.
41
+ */
42
+ onStatus(listener: (status: DiscoveryFeedStatus) => void): () => void;
43
+ /**
44
+ * The network catalog as it is now, for a feed opened before it answered (or on an older list kept on the device): a
45
+ * network it did not have joins as its own source, and one the catalog no longer offers stops being read. What was
46
+ * handed on stays. Only a feed over every source opened per source takes networks in. Returns whether a network
47
+ * joined (one it did not read, or had stopped reading): the feed has its posts still to read.
48
+ */
49
+ setNetworks(networks: SocialNetwork[]): boolean;
50
+ }
51
+ export interface ProgressiveFeedSettings {
52
+ /**
53
+ * Open one session per source (default true). False keeps the factory's session whole, as one source: a feed that
54
+ * picks its own networks (For you, `createPersonalFeed`) ignores a per-network `source`, so opening it once per
55
+ * network would read the whole feed that many times over.
56
+ */
57
+ perSource?: boolean;
58
+ /** Hold the first load over every source and hand it on as one composed first screen (default true). */
59
+ firstScreen?: boolean;
60
+ /** How long the first load waits for Minds once a source has answered: FIRST_SCREEN_MS (a member's, the default) or VISITOR_FIRST_SCREEN_MS. */
61
+ firstScreenMs?: number;
62
+ /** Told each time a source's read lands, and whether it failed: a client that shortens its next feed's wait while Minds is failing reads it here. */
63
+ onLanded?: (source: string, failed: boolean) => void;
64
+ }
65
+ /** How long a round keeps waiting for its slower sources once one of them has answered. */
66
+ export declare const DISCOVERY_STRAGGLER_MS = 3000;
67
+ /**
68
+ * How long the first load waits for Minds and a second network, once its first source has answered, before it hands on
69
+ * what it has. Measured live (2026-09-27): the Minds read answers in 0.8-2 s in a category and 1.7-3.4 s on All; the
70
+ * open networks' in 0.2-1.3 s. Counted from the first answer, not from the load. A member's wait, and the default: the
71
+ * API caches a Discover page per caller for 30 s, so a member's first load is a fresh build of their Minds page.
72
+ */
73
+ export declare const FIRST_SCREEN_MS = 2500;
74
+ /**
75
+ * A visitor's wait. Every visitor reads the same anonymous page, so once anyone has read it in the last 30 s it answers
76
+ * in about 0.13 s, well inside this wait, and Minds leads; cold, the networks lead and Minds joins below.
77
+ */
78
+ export declare const VISITOR_FIRST_SCREEN_MS = 800;
79
+ /**
80
+ * A module without the items in `seen` (item keys). Posts already shown by another source's module are not shown again:
81
+ * the sessions dedupe within themselves, but across them a story's member posts can also arrive in that network's own
82
+ * page. A module that loses items keeps its spec in step. A suggestion already shown leaves its rail, and a rail left
83
+ * with fewer than two is dropped; a story already shown is dropped whole, and keeps its headline and outlets when only
84
+ * its posts were shown; a post module with none left is dropped (null).
85
+ */
86
+ export declare function withoutSeenItems(module: DiscoveryModule, seen: ReadonlySet<string>): DiscoveryModule | null;
87
+ /** The other networks a feed over every source reads, in the session's own order. */
88
+ export declare function discoveryRemoteSources(networks: SocialNetwork[], source?: string): string[];
89
+ /**
90
+ * The suggestions a feed filtered to one network carries. The candidate lists are the whole app's: live on 2026-09-27
91
+ * Discover filtered to Nostr showed "People to follow" with three ActivityPub accounts. A network's own feed suggests
92
+ * only that network's accounts (a person's `protocol`, Minds when it has none); groups and tags are Minds', so they come
93
+ * with Minds' feed and with All's.
94
+ */
95
+ export declare function discoveryRecommendationsFor(recommend: DiscoveryRecommendationSources | undefined, source?: string): DiscoveryRecommendationSources | undefined;
96
+ /** A token for one feed open ([A-Za-z0-9_-], up to 64): its sessions read the top of the feed once between them, and the server keeps that open's order stable. */
97
+ export declare function discoveryFeedOpenToken(): string;
98
+ /** How long a top answer is shared by the feed opens that ask for it with the same params. */
99
+ export declare const DISCOVERY_TOP_SHARE_MS = 60000;
100
+ /** After the top route answers 404 (it is off), how long a share does not ask it again. */
101
+ export declare const DISCOVERY_TOP_OFF_MS: number;
102
+ /**
103
+ * A share of top reads for one viewer's reader: `share(read, scope)` reads the top once for every session that asks
104
+ * with the same params (ref, limit, the open's session token) and `scope` (e.g. the Politics choice it reads under),
105
+ * while the read is out and DISCOVERY_TOP_SHARE_MS after. A route that answered 404 (off) is not asked again for
106
+ * DISCOVERY_TOP_OFF_MS. Make one share per viewer: never hand it another viewer's reader.
107
+ */
108
+ export declare function createDiscoveryTopShare(now?: () => number): (read: DiscoveryTopSource, scope?: string) => DiscoveryTopSource;
109
+ /**
110
+ * A progressive Discover feed over `factory`'s sessions (see the note above). `feedOptions` are a session's: over every
111
+ * source, Minds' session carries the radar and the recommendations (scoped to the feed's network filter,
112
+ * discoveryRecommendationsFor), and each other network with public trends gets a session filtered to itself, which
113
+ * carries Hide politics (`compass: 'hide'`) and no other choice. A feed filtered to one network, or with no other
114
+ * networks, is one session. `top`, `seen`, `seenClusters` and `bucket` reach every session, under one session token
115
+ * per open. Create a new feed when the viewer, category or filter changes.
116
+ */
117
+ export declare function createProgressiveFeed(factory: DiscoveryFeedFactory, feedOptions: DiscoveryFeedOptions, { perSource, firstScreen, firstScreenMs, onLanded }?: ProgressiveFeedSettings): ProgressiveDiscoveryFeed;