@recursiv/sdk 0.7.70 → 0.7.72
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.
- package/README.md +85 -10
- package/dist/client.d.ts +5 -0
- package/dist/client.js +7 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.js +11 -1
- package/dist/resources/agents.d.ts +48 -0
- package/dist/resources/agents.js +22 -0
- package/dist/resources/curator.d.ts +11 -4
- package/dist/resources/discovery-fallover.d.ts +8 -0
- package/dist/resources/discovery-fallover.js +26 -0
- package/dist/resources/discovery-first-screen.d.ts +30 -0
- package/dist/resources/discovery-first-screen.js +149 -0
- package/dist/resources/discovery-merge.d.ts +144 -0
- package/dist/resources/discovery-merge.js +479 -0
- package/dist/resources/discovery-progressive.d.ts +117 -0
- package/dist/resources/discovery-progressive.js +414 -0
- package/dist/resources/discovery-safety.d.ts +32 -0
- package/dist/resources/discovery-safety.js +521 -0
- package/dist/resources/discovery.d.ts +59 -16
- package/dist/resources/discovery.js +147 -47
- package/dist/resources/feeds.d.ts +3 -1
- package/dist/resources/judgments.d.ts +252 -0
- package/dist/resources/judgments.js +79 -0
- package/dist/resources/notifications.d.ts +7 -2
- package/dist/resources/protocols.d.ts +204 -17
- package/dist/resources/protocols.js +24 -5
- package/dist/resources/search.d.ts +9 -1
- package/dist/resources/settings.d.ts +8 -1
- package/dist/resources/settings.js +9 -0
- package/dist/resources/signals.d.ts +58 -0
- package/dist/resources/signals.js +32 -0
- package/dist/types.d.ts +67 -0
- package/llm.md +24 -7
- package/package.json +1 -1
|
@@ -2,9 +2,15 @@ import type { HttpClient } from '../client.js';
|
|
|
2
2
|
import type { CompassFilterMeta, DiscoveryLayout, FalloverStep, MediaItem, Post } from '../types.js';
|
|
3
3
|
import type { CuratorResource } from './curator.js';
|
|
4
4
|
import type { DiscoveryLearning } from './discovery-learning.js';
|
|
5
|
+
import { type ProgressiveDiscoveryFeed, type ProgressiveFeedSettings } from './discovery-progressive.js';
|
|
5
6
|
import type { FeedStory, FeedTopItem, FeedTopNetwork, FeedTopNetworkEntry, FeedsResource } from './feeds.js';
|
|
6
7
|
import type { PostsResource } from './posts.js';
|
|
7
8
|
import type { ProtocolsResource, SocialNetwork, SocialPost, SocialRadar, SocialRadarPosts } from './protocols.js';
|
|
9
|
+
export * from './discovery-fallover.js';
|
|
10
|
+
export * from './discovery-first-screen.js';
|
|
11
|
+
export * from './discovery-merge.js';
|
|
12
|
+
export * from './discovery-progressive.js';
|
|
13
|
+
export * from './discovery-safety.js';
|
|
8
14
|
export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
|
|
9
15
|
/** A page of Watch's Minds videos (`discovery.videos`). */
|
|
10
16
|
export interface WatchVideosParams {
|
|
@@ -142,7 +148,7 @@ export interface CategorySurfaceSection<T> {
|
|
|
142
148
|
reason?: string;
|
|
143
149
|
}
|
|
144
150
|
/** A radar cluster themed into the category, with up to six members (headline or excerpt and link only). */
|
|
145
|
-
export type CategoryStory = Pick<SocialRadar['clusters'][number], 'id' | 'label' | 'kind' | 'theme' | 'sources' | 'corroboration' | 'laneMix' | 'velocity' | 'media' | 'freshness' | 'score' | 'members'>;
|
|
151
|
+
export type CategoryStory = Pick<SocialRadar['clusters'][number], 'id' | 'label' | 'kind' | 'theme' | 'sources' | 'corroboration' | 'laneMix' | 'outlets' | 'velocity' | 'media' | 'freshness' | 'score' | 'members'>;
|
|
146
152
|
export interface CategoryClip {
|
|
147
153
|
id: string;
|
|
148
154
|
title: string | null;
|
|
@@ -240,6 +246,7 @@ export interface DiscoveryCluster {
|
|
|
240
246
|
label: string;
|
|
241
247
|
kind: 'breaking' | 'viral' | 'story';
|
|
242
248
|
theme?: string;
|
|
249
|
+
/** The story's outlets (StoryOutlets.total from 0.7.71's servers, else the lanes' sum): the number to show beside it. */
|
|
243
250
|
outletCount: number;
|
|
244
251
|
laneMix: {
|
|
245
252
|
mainstream: number;
|
|
@@ -366,16 +373,30 @@ export interface DiscoveryFeedSessionOptions {
|
|
|
366
373
|
top?: DiscoveryTopOptions;
|
|
367
374
|
/**
|
|
368
375
|
* What the member already saw (Minds post ids and '<protocol>/<nativeId>' refs, as the app records visible views): left
|
|
369
|
-
* out of the Minds hot pages
|
|
376
|
+
* out of the Minds hot pages, and out of the networks' trend pages, topic previews and topic pages (the top's own lanes
|
|
377
|
+
* stay: the top ranks them by impressions). A page the filter leaves under DISCOVERY_SEEN_MIN_ITEMS walks on, up to
|
|
370
378
|
* DISCOVERY_SEEN_WALK more hot pages, and the stream's fall-through (`fallover`) still reports when it ends. When the
|
|
371
379
|
* top's order did not use impressions (`meta.flag.impressions` not 'on'), the top's seen items follow its unseen ones.
|
|
372
380
|
*/
|
|
373
381
|
seen?: Iterable<string>;
|
|
374
|
-
/** The client's own story rotation
|
|
382
|
+
/** The client's own story rotation: a story shown RADAR_REST_AFTER times with no open is skipped, the top's lead included (the session's own pick leads instead). */
|
|
375
383
|
seenClusters?: ReadonlyMap<string, DiscoverySeenCluster>;
|
|
376
384
|
/** A visitor's rotation bucket (e.g. floor(now / 15 min)): page 0's story rotates among the breaking stories by it. */
|
|
377
385
|
bucket?: number;
|
|
378
386
|
}
|
|
387
|
+
/** What a Discover feed session reads (`discovery.createFeed`, and each session of `discovery.createProgressiveFeed`). */
|
|
388
|
+
export interface DiscoveryFeedOptions extends DiscoveryFeedSessionOptions {
|
|
389
|
+
/** The app's network catalog (`protocols.publicNetworks()`): each network with `public_trends` is read. */
|
|
390
|
+
networks: SocialNetwork[];
|
|
391
|
+
/** A browse category id. */
|
|
392
|
+
category?: string;
|
|
393
|
+
/** 'all' (or none) for every network, 'minds', or one network's protocol. */
|
|
394
|
+
source?: string;
|
|
395
|
+
learning?: DiscoveryLearning;
|
|
396
|
+
recommend?: DiscoveryRecommendationSources;
|
|
397
|
+
/** A Politics choice, as `posts.list` takes it: it goes on every Minds read (and the top's read). */
|
|
398
|
+
compass?: string;
|
|
399
|
+
}
|
|
379
400
|
export type DiscoveryItem = DiscoveryPostItem | {
|
|
380
401
|
key: string;
|
|
381
402
|
kind: 'group';
|
|
@@ -398,6 +419,15 @@ export declare const isDiscoveryPost: (item: DiscoveryItem) => item is Discovery
|
|
|
398
419
|
export declare const RECOMMENDATION_MAX_ITEMS = 6;
|
|
399
420
|
/** Clusters read from the radar per session; the server never holds more. */
|
|
400
421
|
export declare const RADAR_MAX_CLUSTERS = 40;
|
|
422
|
+
/**
|
|
423
|
+
* In a category, a topics network's topic pages in a row that showed nothing new, after which its other topics are
|
|
424
|
+
* passed over without a call (its index stream is still read); and an index stream's pages in a row that showed nothing
|
|
425
|
+
* new, on the last of which the stream ends. Live on 2026-09-27 Bluesky's catalog was the same 25 topics in every
|
|
426
|
+
* category and off-topic pages carried a cursor: Food's feed made 42 trend-feed calls of 1-2 s in 24 bottom hits for
|
|
427
|
+
* nothing. The server ends a stream after three empty pages too, but it counts what it served, not what this session
|
|
428
|
+
* had not shown yet.
|
|
429
|
+
*/
|
|
430
|
+
export declare const DISCOVERY_EMPTY_TOPIC_PAGES = 3;
|
|
401
431
|
/** How long one source's read may take before the page goes on without it (DiscoveryFeedSession). */
|
|
402
432
|
export declare const DISCOVERY_SOURCE_TIMEOUT_MS = 15000;
|
|
403
433
|
/**
|
|
@@ -535,13 +565,20 @@ export declare class DiscoveryFeedSession {
|
|
|
535
565
|
retryFailed(): void;
|
|
536
566
|
next(): Promise<DiscoveryFeedPage>;
|
|
537
567
|
private read;
|
|
568
|
+
/** An open-network post in the member's seen set (options.seen: '<protocol>/<nativeId>', an ActivityPub id also without https://). */
|
|
569
|
+
private metNetworkPost;
|
|
538
570
|
/**
|
|
539
571
|
* One read of a topics network: the next topic page that carries posts. A topic the provider no
|
|
540
572
|
* longer lists (Bluesky rotates its trends between the catalog read and the walk through it) is
|
|
541
|
-
* that topic's end, not the network's failure; a topic page with nothing
|
|
542
|
-
*
|
|
543
|
-
*
|
|
544
|
-
*
|
|
573
|
+
* that topic's end, not the network's failure; a topic page with nothing new is walked past too,
|
|
574
|
+
* so the page reaches a topic that carries posts instead of spending a round on each empty one.
|
|
575
|
+
* Bounded by TOPIC_WALK reads and TOPIC_WALK_MS; a walk that stops short has moved the stream on,
|
|
576
|
+
* and the next read continues from there.
|
|
577
|
+
*
|
|
578
|
+
* In a category (DISCOVERY_EMPTY_TOPIC_PAGES): a topic page that shows nothing new ends that topic
|
|
579
|
+
* (its provider cursor is dropped: more of an off-category topic is more of the same), after that
|
|
580
|
+
* many such pages in a row the network's other topics are passed over without a call (its index
|
|
581
|
+
* stream is still read), and an index stream ends on its that-many-th page in a row with nothing new.
|
|
545
582
|
*/
|
|
546
583
|
private walkTopics;
|
|
547
584
|
private readPage;
|
|
@@ -571,7 +608,8 @@ export declare class DiscoveryFeedSession {
|
|
|
571
608
|
* Places the top on page 0: each lane stands in for its network's first read (a topics network's catalog previews; a
|
|
572
609
|
* posts network's read follows its lane), and the top's items, read by id or carried by this page, become the page's
|
|
573
610
|
* first items, marked seen so no hot page repeats them. Without impressions in the top's order, the member's seen
|
|
574
|
-
* items follow the unseen ones.
|
|
611
|
+
* items follow the unseen ones. Each placed item says its place in the whole top (TopReady.positions), so the items
|
|
612
|
+
* the sessions of one progressive feed place never share a place.
|
|
575
613
|
*/
|
|
576
614
|
private placeTop;
|
|
577
615
|
/** The story's members as feed items, read once per story and bounded to three seconds; a failed read is simply none. */
|
|
@@ -587,6 +625,8 @@ export declare class DiscoveryResource {
|
|
|
587
625
|
private curator?;
|
|
588
626
|
private http?;
|
|
589
627
|
private feeds?;
|
|
628
|
+
/** One share of top reads for this client's viewer: a progressive feed's sessions read the top once between them. */
|
|
629
|
+
private readonly topShare;
|
|
590
630
|
constructor(posts: PostsResource, protocols: ProtocolsResource, curator?: Pick<CuratorResource, "forYou"> | undefined, http?: Pick<HttpClient, "get" | "post"> | undefined, feeds?: Pick<FeedsResource, "top"> | undefined);
|
|
591
631
|
/**
|
|
592
632
|
* Suggestions for the signed-in viewer from the app's one recommender: active or warm members, groups
|
|
@@ -652,12 +692,15 @@ export declare class DiscoveryResource {
|
|
|
652
692
|
* given); `seen` leaves what the member saw out of the hot pages; `seenClusters` and `bucket` rotate the story when
|
|
653
693
|
* the top names none (DiscoveryFeedSessionOptions). With none of them the session is what it was before they existed.
|
|
654
694
|
*/
|
|
655
|
-
createFeed(options:
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
695
|
+
createFeed(options: DiscoveryFeedOptions): DiscoveryFeedSession;
|
|
696
|
+
/**
|
|
697
|
+
* Discover's feed, handed on as each network answers rather than as a page that waits for its slowest network: one
|
|
698
|
+
* `createFeed` session per source (Minds with the radar and suggestions, one per network with `public_trends`), each
|
|
699
|
+
* page a `DiscoveryPart` the moment it lands (createProgressiveFeed). The first load is held until Minds and one other
|
|
700
|
+
* network answered, or FIRST_SCREEN_MS after the first answer, and arrives as one composed first screen
|
|
701
|
+
* (composeFirstScreen). `top`, `seen`, `seenClusters` and `bucket` reach every session; the top is read once per
|
|
702
|
+
* open (one session token) and, for this client, not asked again for five minutes after it answers 404.
|
|
703
|
+
* `settings`: `perSource`, `firstScreen`, `firstScreenMs` (VISITOR_FIRST_SCREEN_MS for a visitor), `onLanded`.
|
|
704
|
+
*/
|
|
705
|
+
createProgressiveFeed(options: DiscoveryFeedOptions, settings?: ProgressiveFeedSettings): ProgressiveDiscoveryFeed;
|
|
663
706
|
}
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { readFalloverSteps } from './discovery-fallover.js';
|
|
2
|
+
import { createDiscoveryTopShare, createProgressiveFeed } from './discovery-progressive.js';
|
|
3
|
+
export * from './discovery-fallover.js';
|
|
4
|
+
export * from './discovery-first-screen.js';
|
|
5
|
+
export * from './discovery-merge.js';
|
|
6
|
+
export * from './discovery-progressive.js';
|
|
7
|
+
export * from './discovery-safety.js';
|
|
1
8
|
export const isDiscoveryPost = (item) => item.kind === 'minds' || item.kind === 'social';
|
|
2
9
|
/** At most this many candidates per recommendation module. */
|
|
3
10
|
export const RECOMMENDATION_MAX_ITEMS = 6;
|
|
@@ -32,6 +39,15 @@ const TOPIC_WALK_MS = 8000;
|
|
|
32
39
|
* new counts as an empty topic.
|
|
33
40
|
*/
|
|
34
41
|
const INDEX_CURSOR = 'idx:tf:';
|
|
42
|
+
/**
|
|
43
|
+
* In a category, a topics network's topic pages in a row that showed nothing new, after which its other topics are
|
|
44
|
+
* passed over without a call (its index stream is still read); and an index stream's pages in a row that showed nothing
|
|
45
|
+
* new, on the last of which the stream ends. Live on 2026-09-27 Bluesky's catalog was the same 25 topics in every
|
|
46
|
+
* category and off-topic pages carried a cursor: Food's feed made 42 trend-feed calls of 1-2 s in 24 bottom hits for
|
|
47
|
+
* nothing. The server ends a stream after three empty pages too, but it counts what it served, not what this session
|
|
48
|
+
* had not shown yet.
|
|
49
|
+
*/
|
|
50
|
+
export const DISCOVERY_EMPTY_TOPIC_PAGES = 3;
|
|
35
51
|
/** How long one source's read may take before the page goes on without it (DiscoveryFeedSession). */
|
|
36
52
|
export const DISCOVERY_SOURCE_TIMEOUT_MS = 15_000;
|
|
37
53
|
/**
|
|
@@ -124,6 +140,19 @@ const RADAR_SOCIAL_KINDS = new Set(['bluesky', 'activitypub', 'nostr']);
|
|
|
124
140
|
const httpUrl = (value) => typeof value === 'string' && value.length <= 4096 && /^https?:\/\//i.test(value);
|
|
125
141
|
const countOf = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
126
142
|
const dimension = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
143
|
+
/** A story's outlets as the server counted them (StoryOutlets), or null when absent (an older server) or when its buckets do not sum to its total. */
|
|
144
|
+
function storyOutletsOf(value) {
|
|
145
|
+
if (!value || typeof value !== 'object')
|
|
146
|
+
return null;
|
|
147
|
+
const o = value;
|
|
148
|
+
const total = countOf(o.total);
|
|
149
|
+
const mainstream = countOf(o.mainstream);
|
|
150
|
+
const independent = countOf(o.independent);
|
|
151
|
+
const other = countOf(o.other);
|
|
152
|
+
if (total === null || mainstream === null || independent === null || other === null || mainstream + independent + other !== total)
|
|
153
|
+
return null;
|
|
154
|
+
return { total, mainstream, independent, other };
|
|
155
|
+
}
|
|
127
156
|
/** Validate one radar cluster: bad or unexpected shapes are dropped, never rendered. Member keys are translated to this session's key format. */
|
|
128
157
|
function radarCluster(value) {
|
|
129
158
|
if (!value || typeof value !== 'object')
|
|
@@ -139,6 +168,8 @@ function radarCluster(value) {
|
|
|
139
168
|
const independent = countOf(laneMix.independent);
|
|
140
169
|
if (mainstream === null || independent === null)
|
|
141
170
|
return null;
|
|
171
|
+
// The server's one count for the story (0.7.71), which its page, World Today and the News section name too.
|
|
172
|
+
const outlets = storyOutletsOf(c.outlets);
|
|
142
173
|
const normalized = c.velocity?.normalized;
|
|
143
174
|
const sources = {};
|
|
144
175
|
if (c.sources && typeof c.sources === 'object')
|
|
@@ -185,7 +216,8 @@ function radarCluster(value) {
|
|
|
185
216
|
? { type: media.type, url: media.url, ...(httpUrl(media.poster) ? { poster: media.poster } : {}), ...(dimension(media.width) && dimension(media.height) ? { width: dimension(media.width), height: dimension(media.height) } : {}),
|
|
186
217
|
memberKey: translated.get(media.memberKey) ?? media.memberKey } : undefined;
|
|
187
218
|
const theme = typeof c.theme === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(c.theme) ? c.theme : undefined;
|
|
188
|
-
return { id: c.id, label: c.label.slice(0, 300), kind: c.kind, ...(theme ? { theme } : {}), outletCount:
|
|
219
|
+
return { id: c.id, label: c.label.slice(0, 300), kind: c.kind, ...(theme ? { theme } : {}), outletCount: outlets ? outlets.total : mainstream + independent,
|
|
220
|
+
laneMix: outlets ? { mainstream: outlets.mainstream, independent: outlets.independent } : { mainstream, independent }, sources,
|
|
189
221
|
velocity: { normalized: typeof normalized === 'number' && Number.isFinite(normalized) && normalized >= 0 ? normalized : 0 }, newestAt, ...(lead ? { lead } : {}), links, memberKeys };
|
|
190
222
|
}
|
|
191
223
|
/**
|
|
@@ -209,12 +241,19 @@ function storyCluster(value) {
|
|
|
209
241
|
sources[name] = n;
|
|
210
242
|
}
|
|
211
243
|
const theme = typeof s.theme === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(s.theme) ? s.theme : undefined;
|
|
212
|
-
|
|
213
|
-
|
|
244
|
+
// `sources.outlet` counts articles: a server that sends the story's outlets (0.7.71) is read for the number instead.
|
|
245
|
+
const outlets = storyOutletsOf(s.outlets);
|
|
246
|
+
return { id: s.id, label: s.label.slice(0, 300), kind: s.kind, ...(theme ? { theme } : {}), outletCount: outlets ? outlets.total : sources.outlet ?? 0,
|
|
247
|
+
laneMix: outlets ? { mainstream: outlets.mainstream, independent: outlets.independent } : { mainstream: 0, independent: 0 }, sources, velocity: { normalized: 0 }, newestAt, links: [], memberKeys: [] };
|
|
248
|
+
}
|
|
249
|
+
/** A story whose lead the member had on screen RADAR_REST_AFTER times without an open: it rests. */
|
|
250
|
+
function restedStory(id, seenClusters) {
|
|
251
|
+
const seen = seenClusters?.get(id);
|
|
252
|
+
return !!seen && !seen.clicked && seen.n >= RADAR_REST_AFTER;
|
|
214
253
|
}
|
|
215
254
|
/** Page 0: the top breaking story, else the top story. Later pages: the next unused story that is not breaking. */
|
|
216
255
|
export function pickRadarCluster(clusters, page, options = {}) {
|
|
217
|
-
const rested = (cluster) =>
|
|
256
|
+
const rested = (cluster) => restedStory(cluster.id, options.seenClusters);
|
|
218
257
|
const unused = clusters.filter(candidate => !options.used?.has(candidate.id) && !rested(candidate));
|
|
219
258
|
if (page !== 0)
|
|
220
259
|
return unused.find(candidate => candidate.kind !== 'breaking');
|
|
@@ -285,31 +324,12 @@ function laneItem(value, protocol) {
|
|
|
285
324
|
media, hasAdditionalContent: card.hasQuote === true, ...(sensitive ? { sensitive: true } : {}) };
|
|
286
325
|
return { key: key(protocol, target.nativeId), kind: 'social', post, top: { ref: entry.ref, position: 0, block: 'top', lane: protocol } };
|
|
287
326
|
}
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
* no list (a server before the contract, or a feed that is not a category's).
|
|
295
|
-
*/
|
|
296
|
-
function readFallover(value) {
|
|
297
|
-
if (!Array.isArray(value))
|
|
298
|
-
return undefined;
|
|
299
|
-
return value.filter((raw) => {
|
|
300
|
-
if (!raw || typeof raw !== 'object')
|
|
301
|
-
return false;
|
|
302
|
-
const step = raw;
|
|
303
|
-
switch (step.kind) {
|
|
304
|
-
case 'category_older': return filled(step.category) && filled(step.cursor);
|
|
305
|
-
case 'category_network': return filled(step.category);
|
|
306
|
-
case 'category': return filled(step.category) && typeof step.via === 'string' && FALLOVER_VIA.has(step.via);
|
|
307
|
-
case 'for_you':
|
|
308
|
-
case 'all': return true;
|
|
309
|
-
case 'feed': return filled(step.ref);
|
|
310
|
-
default: return false;
|
|
311
|
-
}
|
|
312
|
-
});
|
|
327
|
+
/** The refs a member's seen set may hold for a top item, as the app records visible views. */
|
|
328
|
+
function topItemSeenRefs(value) {
|
|
329
|
+
const target = (value?.target ?? {});
|
|
330
|
+
if (target.kind === 'post' && idString(target.id))
|
|
331
|
+
return seenRefsOf('minds', target.id);
|
|
332
|
+
return target.kind === 'network_post' && typeof target.protocol === 'string' && idString(target.nativeId) ? seenRefsOf(target.protocol, target.nativeId) : [];
|
|
313
333
|
}
|
|
314
334
|
const ordinary = { version: 'discovery-layout-v1', presentation: 'posts', source: 'fallback' };
|
|
315
335
|
const key = (protocol, id) => JSON.stringify([protocol, id]);
|
|
@@ -429,7 +449,7 @@ export class DiscoveryFeedSession {
|
|
|
429
449
|
stream.used = [...stream.used, stream.cursor].slice(-32);
|
|
430
450
|
stream.cursor = next;
|
|
431
451
|
// A category feed that ended a stage says where to go next; kept on the stream and reported on every page from here.
|
|
432
|
-
const fallover =
|
|
452
|
+
const fallover = readFalloverSteps(response.meta.fallover);
|
|
433
453
|
if (fallover)
|
|
434
454
|
stream.fallover = fallover;
|
|
435
455
|
const items = response.data.map(post => ({ key: key('minds', post.id), kind: 'minds', post }));
|
|
@@ -470,7 +490,9 @@ export class DiscoveryFeedSession {
|
|
|
470
490
|
// Outside a category the catalog's previews are this page. In a category a preview is not
|
|
471
491
|
// judged to be in it, so the catalog carries no card: the read walks into its topics now,
|
|
472
492
|
// not a round later, so the network's first page in a category is not empty by design.
|
|
473
|
-
|
|
493
|
+
// A preview the member already saw is left out; its topic stays.
|
|
494
|
+
const previews = data.items.flatMap(topic => !this.category && topic.preview && !this.metNetworkPost(topic.preview)
|
|
495
|
+
? [{ key: key(topic.preview.protocol, topic.preview.nativeId), kind: 'social', post: topic.preview }] : []);
|
|
474
496
|
if (previews.length || stream.done)
|
|
475
497
|
return { items: previews, layout: ordinary, partial, ...(previews.length ? { previews: true } : {}) };
|
|
476
498
|
return this.walkTopics(stream, params, validatePosts, partial, started);
|
|
@@ -481,19 +503,43 @@ export class DiscoveryFeedSession {
|
|
|
481
503
|
if (stream.cursor)
|
|
482
504
|
stream.used = [...stream.used, stream.cursor].slice(-32);
|
|
483
505
|
stream.cursor = next;
|
|
484
|
-
|
|
506
|
+
// A post the member already saw is left out (the top's lanes are the top's own, ranked by impressions). Each kept
|
|
507
|
+
// post keeps its own window's layout.
|
|
508
|
+
const kept = data.items.flatMap((post, index) => this.metNetworkPost(post) ? [] : [{ post, index }]);
|
|
509
|
+
const layout = data.discovery ?? ordinary;
|
|
510
|
+
return { items: kept.map(({ post }) => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout, partial: data.categoryFilter?.status === 'partial',
|
|
511
|
+
...(kept.length !== data.items.length ? { layouts: kept.map(({ index }) => windowLayout(layout, index)) } : {}) };
|
|
512
|
+
}
|
|
513
|
+
/** An open-network post in the member's seen set (options.seen: '<protocol>/<nativeId>', an ActivityPub id also without https://). */
|
|
514
|
+
metNetworkPost(post) {
|
|
515
|
+
if (!this.appSeen || typeof post.protocol !== 'string' || typeof post.nativeId !== 'string' || !post.protocol || !post.nativeId)
|
|
516
|
+
return false;
|
|
517
|
+
return seenRefsOf(post.protocol, post.nativeId).some(ref => this.appSeen.has(ref));
|
|
485
518
|
}
|
|
486
519
|
/**
|
|
487
520
|
* One read of a topics network: the next topic page that carries posts. A topic the provider no
|
|
488
521
|
* longer lists (Bluesky rotates its trends between the catalog read and the walk through it) is
|
|
489
|
-
* that topic's end, not the network's failure; a topic page with nothing
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
522
|
+
* that topic's end, not the network's failure; a topic page with nothing new is walked past too,
|
|
523
|
+
* so the page reaches a topic that carries posts instead of spending a round on each empty one.
|
|
524
|
+
* Bounded by TOPIC_WALK reads and TOPIC_WALK_MS; a walk that stops short has moved the stream on,
|
|
525
|
+
* and the next read continues from there.
|
|
526
|
+
*
|
|
527
|
+
* In a category (DISCOVERY_EMPTY_TOPIC_PAGES): a topic page that shows nothing new ends that topic
|
|
528
|
+
* (its provider cursor is dropped: more of an off-category topic is more of the same), after that
|
|
529
|
+
* many such pages in a row the network's other topics are passed over without a call (its index
|
|
530
|
+
* stream is still read), and an index stream ends on its that-many-th page in a row with nothing new.
|
|
493
531
|
*/
|
|
494
532
|
async walkTopics(stream, params, validatePosts, partial, started) {
|
|
495
533
|
const topics = stream.topics;
|
|
534
|
+
const inCategory = !!this.category;
|
|
535
|
+
/** A category page's outcome for the network's run of pages that showed nothing new. */
|
|
536
|
+
const count = (showsSomething) => { if (inCategory)
|
|
537
|
+
stream.emptyTopics = showsSomething ? 0 : (stream.emptyTopics ?? 0) + 1; };
|
|
538
|
+
const passedOver = () => inCategory && (stream.emptyTopics ?? 0) >= DISCOVERY_EMPTY_TOPIC_PAGES;
|
|
496
539
|
for (let reads = 0; reads < TOPIC_WALK && Date.now() - started < TOPIC_WALK_MS; reads++) {
|
|
540
|
+
// Past the run, the network's provider topics are passed over without a call; its index stream is still read.
|
|
541
|
+
while (topics[0] && passedOver() && !topics[0].cursor?.startsWith(INDEX_CURSOR))
|
|
542
|
+
topics.shift();
|
|
497
543
|
const topic = topics[0];
|
|
498
544
|
if (!topic)
|
|
499
545
|
break;
|
|
@@ -503,8 +549,10 @@ export class DiscoveryFeedSession {
|
|
|
503
549
|
const indexContinuation = !!topic.cursor?.startsWith(INDEX_CURSOR);
|
|
504
550
|
const response = await reach(this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY))
|
|
505
551
|
.catch((error) => {
|
|
506
|
-
if (isGone(error))
|
|
552
|
+
if (isGone(error)) {
|
|
553
|
+
count(false);
|
|
507
554
|
return null;
|
|
555
|
+
}
|
|
508
556
|
if (indexContinuation) {
|
|
509
557
|
stream.indexStarted = false;
|
|
510
558
|
return null;
|
|
@@ -518,23 +566,42 @@ export class DiscoveryFeedSession {
|
|
|
518
566
|
validatePosts(data);
|
|
519
567
|
if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
|
|
520
568
|
throw new Error('Invalid discovery topic');
|
|
521
|
-
|
|
522
|
-
//
|
|
523
|
-
|
|
569
|
+
// What this page shows that no page of the session showed: the index serves the same posts to every gone or
|
|
570
|
+
// empty topic, so an index page's posts already shown are left out, and a page with nothing new is an empty topic.
|
|
571
|
+
const showsSomething = data.items.some(post => !this.seen.has(key(post.protocol, post.nativeId)));
|
|
572
|
+
count(showsSomething);
|
|
573
|
+
let next = data.cursor ?? null;
|
|
574
|
+
// In a category, a provider page with nothing new ends its topic.
|
|
575
|
+
if (inCategory && !showsSomething && next && !next.startsWith(INDEX_CURSOR))
|
|
576
|
+
next = null;
|
|
577
|
+
// An index stream (a page that starts or continues it) ends on its DISCOVERY_EMPTY_TOPIC_PAGES-th page in a row
|
|
578
|
+
// with nothing new; the run travels with the cursor, so a page read again counts once.
|
|
579
|
+
let indexRun;
|
|
580
|
+
if (next?.startsWith(INDEX_CURSOR)) {
|
|
581
|
+
indexRun = showsSomething ? 0 : (indexContinuation ? topic.indexRun ?? 0 : 0) + 1;
|
|
582
|
+
if (indexRun >= DISCOVERY_EMPTY_TOPIC_PAGES)
|
|
583
|
+
next = null;
|
|
584
|
+
}
|
|
585
|
+
// A provider cursor is kept. An index cursor from a provider page starts the network's one index stream: queued
|
|
586
|
+
// the first time only, since every later start would re-read the same stream from its top.
|
|
524
587
|
const indexed = !!data.sourceIndex;
|
|
525
|
-
const startsIndex = !!next?.startsWith(INDEX_CURSOR) && !
|
|
588
|
+
const startsIndex = !!next?.startsWith(INDEX_CURSOR) && !indexContinuation;
|
|
526
589
|
if (next && next !== topic.cursor && !topic.used.includes(next) && !(startsIndex && stream.indexStarted)) {
|
|
527
|
-
topics.push({ topic: topic.topic, cursor: next, used: [...topic.used, ...(topic.cursor ? [topic.cursor] : [])].slice(-32)
|
|
590
|
+
topics.push({ topic: topic.topic, cursor: next, used: [...topic.used, ...(topic.cursor ? [topic.cursor] : [])].slice(-32),
|
|
591
|
+
...(indexRun !== undefined ? { indexRun } : {}) });
|
|
528
592
|
if (startsIndex)
|
|
529
593
|
stream.indexStarted = true;
|
|
530
594
|
}
|
|
531
595
|
partial ||= data.categoryFilter?.status === 'partial';
|
|
532
|
-
|
|
596
|
+
// A post the member already saw is left out too (options.seen).
|
|
597
|
+
const items = (indexed ? data.items.filter(post => !this.seen.has(key(post.protocol, post.nativeId))) : data.items).filter(post => !this.metNetworkPost(post));
|
|
533
598
|
if (items.length) {
|
|
534
599
|
stream.done = !topics.length;
|
|
535
600
|
return { items: items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial };
|
|
536
601
|
}
|
|
537
602
|
}
|
|
603
|
+
while (topics[0] && passedOver() && !topics[0].cursor?.startsWith(INDEX_CURSOR))
|
|
604
|
+
topics.shift();
|
|
538
605
|
stream.done = !topics.length;
|
|
539
606
|
return { items: [], layout: ordinary, partial };
|
|
540
607
|
}
|
|
@@ -784,8 +851,21 @@ export class DiscoveryFeedSession {
|
|
|
784
851
|
if (lane.length)
|
|
785
852
|
lanes.set(protocol, lane);
|
|
786
853
|
}
|
|
787
|
-
|
|
854
|
+
// A lead story the member passed over RADAR_REST_AFTER times without an open is left to the session's own pick,
|
|
855
|
+
// which rests it: with the top on, the same story led every visit.
|
|
856
|
+
const named = this.radar ? storyCluster(value.lead) : null;
|
|
857
|
+
const lead = named && !restedStory(named.id, this.seenClusters) ? named : null;
|
|
788
858
|
const impressions = (value.meta?.flag?.impressions) === 'on';
|
|
859
|
+
// Each item's place in the whole top: the server's order, with the member's seen items after the unseen ones when
|
|
860
|
+
// that order did not use impressions. A session places only its own network's items, so it never counts from 0.
|
|
861
|
+
const ordered = value.items.filter(item => !!item && typeof item === 'object' && idString(item.ref));
|
|
862
|
+
const met = (item) => !!this.appSeen && topItemSeenRefs(item).some(ref => this.appSeen.has(ref));
|
|
863
|
+
const positions = new Map();
|
|
864
|
+
for (const item of impressions || !this.appSeen ? ordered : [...ordered.filter(item => !met(item)), ...ordered.filter(met)]) {
|
|
865
|
+
const ref = item.ref;
|
|
866
|
+
if (!positions.has(ref))
|
|
867
|
+
positions.set(ref, positions.size);
|
|
868
|
+
}
|
|
789
869
|
if (!rows.length && !lanes.size && !lead)
|
|
790
870
|
return null;
|
|
791
871
|
// The page's story is the top's lead: its member reads start now, alongside the reads below, not after them.
|
|
@@ -812,13 +892,14 @@ export class DiscoveryFeedSession {
|
|
|
812
892
|
for (const item of [...minds, ...network.flat()])
|
|
813
893
|
if (item && !posts.has(item.key))
|
|
814
894
|
posts.set(item.key, item);
|
|
815
|
-
return { rows, posts, lanes, lead, impressions };
|
|
895
|
+
return { rows, posts, lanes, lead, impressions, positions };
|
|
816
896
|
}
|
|
817
897
|
/**
|
|
818
898
|
* Places the top on page 0: each lane stands in for its network's first read (a topics network's catalog previews; a
|
|
819
899
|
* posts network's read follows its lane), and the top's items, read by id or carried by this page, become the page's
|
|
820
900
|
* first items, marked seen so no hot page repeats them. Without impressions in the top's order, the member's seen
|
|
821
|
-
* items follow the unseen ones.
|
|
901
|
+
* items follow the unseen ones. Each placed item says its place in the whole top (TopReady.positions), so the items
|
|
902
|
+
* the sessions of one progressive feed place never share a place.
|
|
822
903
|
*/
|
|
823
904
|
placeTop(top, selected, reads) {
|
|
824
905
|
for (const [protocol, lane] of top.lanes) {
|
|
@@ -839,7 +920,7 @@ export class DiscoveryFeedSession {
|
|
|
839
920
|
const found = top.posts.get(row.key) ?? carried.get(row.key);
|
|
840
921
|
if (!found || this.seen.has(row.key))
|
|
841
922
|
continue;
|
|
842
|
-
const position = placed.length;
|
|
923
|
+
const position = top.positions.get(row.ref) ?? placed.length;
|
|
843
924
|
placed.push({ ...found, top: { ref: row.ref, position, block: position < 10 ? 'top' : 'below' } });
|
|
844
925
|
this.seen.add(row.key);
|
|
845
926
|
this.topKeys.add(row.key);
|
|
@@ -963,6 +1044,8 @@ export class DiscoveryResource {
|
|
|
963
1044
|
curator;
|
|
964
1045
|
http;
|
|
965
1046
|
feeds;
|
|
1047
|
+
/** One share of top reads for this client's viewer: a progressive feed's sessions read the top once between them. */
|
|
1048
|
+
topShare = createDiscoveryTopShare();
|
|
966
1049
|
constructor(posts, protocols, curator, http, feeds) {
|
|
967
1050
|
this.posts = posts;
|
|
968
1051
|
this.protocols = protocols;
|
|
@@ -1063,6 +1146,23 @@ export class DiscoveryResource {
|
|
|
1063
1146
|
return new DiscoveryFeedSession(posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend, params => this.protocols.getPublicRadar(params, NO_RETRY), { social: params => this.protocols.getPublicRadarPosts(params, NO_RETRY), minds: id => this.posts.get(id) }, { ...(top ? { top } : {}), ...(options.seen ? { seen: options.seen } : {}), ...(options.seenClusters ? { seenClusters: options.seenClusters } : {}),
|
|
1064
1147
|
...(options.bucket !== undefined ? { bucket: options.bucket } : {}) });
|
|
1065
1148
|
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Discover's feed, handed on as each network answers rather than as a page that waits for its slowest network: one
|
|
1151
|
+
* `createFeed` session per source (Minds with the radar and suggestions, one per network with `public_trends`), each
|
|
1152
|
+
* page a `DiscoveryPart` the moment it lands (createProgressiveFeed). The first load is held until Minds and one other
|
|
1153
|
+
* network answered, or FIRST_SCREEN_MS after the first answer, and arrives as one composed first screen
|
|
1154
|
+
* (composeFirstScreen). `top`, `seen`, `seenClusters` and `bucket` reach every session; the top is read once per
|
|
1155
|
+
* open (one session token) and, for this client, not asked again for five minutes after it answers 404.
|
|
1156
|
+
* `settings`: `perSource`, `firstScreen`, `firstScreenMs` (VISITOR_FIRST_SCREEN_MS for a visitor), `onLanded`.
|
|
1157
|
+
*/
|
|
1158
|
+
createProgressiveFeed(options, settings = {}) {
|
|
1159
|
+
const feeds = this.feeds;
|
|
1160
|
+
const compass = options.compass;
|
|
1161
|
+
const top = options.top && !options.top.read && feeds
|
|
1162
|
+
? { ...options.top, read: this.topShare(params => feeds.top({ ...params, ...(compass ? { compass } : {}) }, NO_RETRY), compass ?? '') }
|
|
1163
|
+
: options.top;
|
|
1164
|
+
return createProgressiveFeed(this, { ...options, ...(top ? { top } : {}) }, settings);
|
|
1165
|
+
}
|
|
1066
1166
|
}
|
|
1067
1167
|
/** A read bounded to three seconds; a late or failed one resolves to `fallback`, never an error. */
|
|
1068
1168
|
function withDeadline(read, fallback, ms = 3000) {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HttpClient } from '../client.js';
|
|
2
|
-
import type { CompassFilterMeta } from '../types.js';
|
|
2
|
+
import type { CompassFilterMeta, StoryOutlets } from '../types.js';
|
|
3
3
|
/**
|
|
4
4
|
* The top of a feed: what the member (or a visitor) is most likely to engage with, rotated by what they have seen.
|
|
5
5
|
* `feeds.top` and `feeds.stories` read it for a member with their own key of the app; `feeds.publicTop` reads a
|
|
@@ -104,6 +104,8 @@ export interface FeedStory {
|
|
|
104
104
|
*/
|
|
105
105
|
political?: boolean;
|
|
106
106
|
sources: Partial<Record<'outlet' | 'hackernews' | 'bluesky' | 'activitypub' | 'nostr' | 'minds', number>>;
|
|
107
|
+
/** How many outlets reported it (StoryOutlets): the number every surface names for the story. Absent from a server older than 0.7.71. */
|
|
108
|
+
outlets?: StoryOutlets;
|
|
107
109
|
media: {
|
|
108
110
|
images: number;
|
|
109
111
|
videos: number;
|