@recursiv/sdk 0.7.63 → 0.7.65
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 +34 -0
- package/dist/feed-definition.d.ts +5 -1
- package/dist/feed-definition.js +5 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +6 -1
- package/dist/resources/discovery.d.ts +159 -5
- package/dist/resources/discovery.js +360 -30
- package/dist/resources/feeds.d.ts +228 -0
- package/dist/resources/feeds.js +61 -0
- package/dist/resources/search.d.ts +3 -2
- package/dist/types.d.ts +3 -2
- package/llm.md +13 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -95,6 +95,7 @@ for await (const chunk of r.agents.chatStream(agent.id, { message: 'Hello!' }))
|
|
|
95
95
|
|----------|------------|
|
|
96
96
|
| `r.posts` | `create`, `update`, `list`, `liked`, `bookmarked`, `bookmark`, `unbookmark`, `search`, `replies`, `react` |
|
|
97
97
|
| `r.bookmarks` | `save`, `unsave`, `posts`, `list`, `keys`, `merge` — the member's saved posts, open-network posts and articles, synced across devices |
|
|
98
|
+
| `r.feeds` | `top` (what the member is most likely to engage with, rotated by what they saw, with open-network lanes and the lead story), `stories` (the story tail), `publicTop` (a visitor's top, keyless and edge-cacheable) |
|
|
98
99
|
| `r.communities` | `create`, `join`, `search`, `members` |
|
|
99
100
|
| `r.chat` | `send`, `dm`, `createGroup` (`encrypted: true` for an end-to-end encrypted group, 0.7.59), `messages` (cursor paging), `acceptRequest`, `declineRequest`, `leave`, `deleteConversation`; a private group's admin: `addMembers`, `removeMember`, `updateGroup` (0.7.59) |
|
|
100
101
|
| `r.tags` | `create`, `list` |
|
|
@@ -669,6 +670,39 @@ and says whether the member is personalized yet (`meta.personalized`). With a
|
|
|
669
670
|
`category` (the member's first), the session also reads open-network posts from it;
|
|
670
671
|
without, it is Minds only. It never carries the radar.
|
|
671
672
|
|
|
673
|
+
### The top of a feed
|
|
674
|
+
|
|
675
|
+
`sdk.feeds.top({ ref, limit?, session?, compass? })` answers the top of a feed for the
|
|
676
|
+
signed-in member, read with their own key of the app (`ref`: `builtin:all`,
|
|
677
|
+
`category:<id>`, `following`, `watch` or `story:<cluster id>`): `items` (the posts most
|
|
678
|
+
likely to be engaged with, often video, rotated by what the member has seen), `networks`
|
|
679
|
+
(Bluesky, Fediverse and Nostr lanes of up to six posts, each with a `card` to draw and no
|
|
680
|
+
URL on its network), `lead` (the story the feed should open with) and `meta` (`short`,
|
|
681
|
+
`safety`, whether the order used impressions). Every item's `target` is in the app: a
|
|
682
|
+
post id, `/social/post/<protocol>/<id>`, or a story id. `sdk.feeds.stories({ after?, limit? })`
|
|
683
|
+
is the story tail from the same ranker. `sdk.feeds.publicTop({ projectId, ref?, limit?, compass? })`
|
|
684
|
+
is a visitor's top (safe items only, the same for every visitor in one 15-minute bucket),
|
|
685
|
+
read with no credential so the edge can hold it. Each answers 404 while the app's operator
|
|
686
|
+
has it off. Send `block: 'top'` and the item's position with a visible view of a top item.
|
|
687
|
+
|
|
688
|
+
A feed session opens with it when asked: `createFeed({ networks, top: { ref, session? }, seen,
|
|
689
|
+
seenClusters?, bucket? })` (or `new DiscoveryFeedSession(..., { top: { ref, read, post? }, seen })`
|
|
690
|
+
with your own readers). The top's read starts with the first page's reads, and the top holds
|
|
691
|
+
the page at most 1.5 s from its start, the reads of its items' posts included; a top that is
|
|
692
|
+
off, fails or is late leaves the session exactly as it is without one. The radar's story reads
|
|
693
|
+
its posts as soon as the radar answers, whatever the top does. When it answers, page 0 opens with the story it leads with, then its items (each item
|
|
694
|
+
carries `top: { ref, position, block }`, a lane entry also `lane`), and its lanes stand in
|
|
695
|
+
for the networks' first reads. Minds items the first hot page does not carry are read by id
|
|
696
|
+
once, within the same 1.5 s (`top.post`, or `GET /posts/:id` without retries). `seen` (Minds post ids and
|
|
697
|
+
`<protocol>/<nativeId>` refs the member already saw) leaves those posts out of the hot pages,
|
|
698
|
+
and no hot page repeats a top item; a hot page left under five posts walks on, up to three
|
|
699
|
+
more pages, and the feed's `fallover` still reports when the ranking ends. Without impressions
|
|
700
|
+
in the top's order, the member's seen top items follow the unseen ones. With no server lead,
|
|
701
|
+
`seenClusters` (`Map<id, { n, clicked }>`) skips a story shown three times without an open,
|
|
702
|
+
and a visitor's `bucket` rotates page 0's story among the breaking ones
|
|
703
|
+
(`pickRadarCluster` is the same rule on its own). With none of these options a session is
|
|
704
|
+
unchanged.
|
|
705
|
+
|
|
672
706
|
Both take a Politics choice, `compass` (`all`, `mix`, `hide`, or cells in the order
|
|
673
707
|
`lo,co,ro,lm,cm,rm,ll,cl,rl`, optionally `np`), and send it with every Minds read
|
|
674
708
|
(`posts.list` and `curator.forYou` take it too). Each such page says what it did in
|
|
@@ -17,7 +17,11 @@ export declare const FEED_SOURCES_MAX = 50;
|
|
|
17
17
|
export declare const FEED_DEFINITION_MAX_BYTES: number;
|
|
18
18
|
export declare const FEED_SOURCE_KINDS: readonly ["category", "tag", "account", "community", "collection", "list", "story", "bsky_feed", "bsky_list", "bsky_starterpack", "nostr_set"];
|
|
19
19
|
export type FeedSourceKind = typeof FEED_SOURCE_KINDS[number];
|
|
20
|
-
|
|
20
|
+
/**
|
|
21
|
+
* 'engagement' ranks by predicted engagement for the reader: the top of a feed (`feeds.top`). Like For you
|
|
22
|
+
* and viral it reaches past the sources on the reader's behalf.
|
|
23
|
+
*/
|
|
24
|
+
export declare const FEED_RANKING_MODES: readonly ["hot", "new", "top", "for_you", "viral", "engagement"];
|
|
21
25
|
export type FeedRankingMode = typeof FEED_RANKING_MODES[number];
|
|
22
26
|
export declare const FEED_RANKING_RANGES: readonly ["24h", "7d", "30d", "1y"];
|
|
23
27
|
export type FeedRankingRange = typeof FEED_RANKING_RANGES[number];
|
package/dist/feed-definition.js
CHANGED
|
@@ -19,7 +19,11 @@ export const FEED_SOURCE_KINDS = [
|
|
|
19
19
|
'category', 'tag', 'account', 'community', 'collection', 'list', 'story',
|
|
20
20
|
'bsky_feed', 'bsky_list', 'bsky_starterpack', 'nostr_set',
|
|
21
21
|
];
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* 'engagement' ranks by predicted engagement for the reader: the top of a feed (`feeds.top`). Like For you
|
|
24
|
+
* and viral it reaches past the sources on the reader's behalf.
|
|
25
|
+
*/
|
|
26
|
+
export const FEED_RANKING_MODES = ['hot', 'new', 'top', 'for_you', 'viral', 'engagement'];
|
|
23
27
|
export const FEED_RANKING_RANGES = ['24h', '7d', '30d', '1y'];
|
|
24
28
|
export const FEED_MEDIA = ['any', 'images', 'video', 'longread'];
|
|
25
29
|
export const FEED_NETWORKS = ['minds', 'bluesky', 'activitypub', 'nostr'];
|
package/dist/index.d.ts
CHANGED
|
@@ -62,6 +62,7 @@ import { ModerationResource } from './resources/moderation.js';
|
|
|
62
62
|
import { LinkPreviewResource } from './resources/link-preview.js';
|
|
63
63
|
import { SearchResource } from './resources/search.js';
|
|
64
64
|
import { BookmarksResource } from './resources/bookmarks.js';
|
|
65
|
+
import { FeedsResource } from './resources/feeds.js';
|
|
65
66
|
import { AccountsResource } from './resources/accounts.js';
|
|
66
67
|
import { RealtimeClient } from './realtime.js';
|
|
67
68
|
import type { RecursivConfig } from './types.js';
|
|
@@ -121,6 +122,8 @@ export declare class Recursiv {
|
|
|
121
122
|
readonly jobs: JobsResource;
|
|
122
123
|
readonly curator: CuratorResource;
|
|
123
124
|
readonly discovery: DiscoveryResource;
|
|
125
|
+
/** The top of a feed: what the member (or a visitor) is most likely to engage with, rotated by what they have seen. */
|
|
126
|
+
readonly feeds: FeedsResource;
|
|
124
127
|
readonly video: VideoResource;
|
|
125
128
|
readonly appSubscriptions: AppSubscriptionsResource;
|
|
126
129
|
readonly boosts: BoostsResource;
|
|
@@ -201,6 +204,8 @@ export { LinkPreviewResource } from './resources/link-preview.js';
|
|
|
201
204
|
export { SEARCH_SECTIONS, SearchResource } from './resources/search.js';
|
|
202
205
|
export type { SearchAnswer, SearchGroup, SearchOmitReason, SearchParams, SearchPerson, SearchRelated, SearchResponse, SearchSection, SearchStory, SearchTopic } from './resources/search.js';
|
|
203
206
|
export { BOOKMARK_MERGE_MAX_ITEMS, BookmarksResource } from './resources/bookmarks.js';
|
|
207
|
+
export { FEED_TOP_NETWORKS, FeedsResource } from './resources/feeds.js';
|
|
208
|
+
export type { FeedStoriesParams, FeedStoriesResponse, FeedStory, FeedTopExplain, FeedTopItem, FeedTopMeta, FeedTopNetwork, FeedTopNetworkCard, FeedTopNetworkEntry, FeedTopParams, FeedTopResponse, FeedTopTarget, PublicFeedTopMeta, PublicFeedTopParams, PublicFeedTopResponse, } from './resources/feeds.js';
|
|
204
209
|
export type { BookmarkedPost, BookmarkInput, BookmarkItem, BookmarkKey, BookmarkKeyRow, BookmarkKind, BookmarkMergeItem, BookmarkMergeResult, BookmarkProtocol, BookmarkRequestOptions, BookmarksPage, SavedBookmark, UnsavedBookmark, } from './resources/bookmarks.js';
|
|
205
210
|
export { AccountsResource } from './resources/accounts.js';
|
|
206
211
|
export type { SiblingAccount, SiblingAccountsResponse, SwitchAccountResult, } from './resources/accounts.js';
|
package/dist/index.js
CHANGED
|
@@ -63,6 +63,7 @@ import { ModerationResource } from './resources/moderation.js';
|
|
|
63
63
|
import { LinkPreviewResource } from './resources/link-preview.js';
|
|
64
64
|
import { SearchResource } from './resources/search.js';
|
|
65
65
|
import { BookmarksResource } from './resources/bookmarks.js';
|
|
66
|
+
import { FeedsResource } from './resources/feeds.js';
|
|
66
67
|
import { AccountsResource } from './resources/accounts.js';
|
|
67
68
|
import { RealtimeClient } from './realtime.js';
|
|
68
69
|
export class Recursiv {
|
|
@@ -122,6 +123,8 @@ export class Recursiv {
|
|
|
122
123
|
jobs;
|
|
123
124
|
curator;
|
|
124
125
|
discovery;
|
|
126
|
+
/** The top of a feed: what the member (or a visitor) is most likely to engage with, rotated by what they have seen. */
|
|
127
|
+
feeds;
|
|
125
128
|
video;
|
|
126
129
|
appSubscriptions;
|
|
127
130
|
boosts;
|
|
@@ -188,7 +191,8 @@ export class Recursiv {
|
|
|
188
191
|
this.webhooks = new WebhooksResource(client);
|
|
189
192
|
this.jobs = new JobsResource(client);
|
|
190
193
|
this.curator = new CuratorResource(client);
|
|
191
|
-
this.
|
|
194
|
+
this.feeds = new FeedsResource(client);
|
|
195
|
+
this.discovery = new DiscoveryResource(this.posts, this.protocols, this.curator, client, this.feeds);
|
|
192
196
|
this.video = new VideoResource(client);
|
|
193
197
|
this.appSubscriptions = new AppSubscriptionsResource(client);
|
|
194
198
|
this.boosts = new BoostsResource(client);
|
|
@@ -254,6 +258,7 @@ export { ModerationResource } from './resources/moderation.js';
|
|
|
254
258
|
export { LinkPreviewResource } from './resources/link-preview.js';
|
|
255
259
|
export { SEARCH_SECTIONS, SearchResource } from './resources/search.js';
|
|
256
260
|
export { BOOKMARK_MERGE_MAX_ITEMS, BookmarksResource } from './resources/bookmarks.js';
|
|
261
|
+
export { FEED_TOP_NETWORKS, FeedsResource } from './resources/feeds.js';
|
|
257
262
|
export { AccountsResource } from './resources/accounts.js';
|
|
258
263
|
export { OrganizationSettingsResource } from './resources/organization-settings.js';
|
|
259
264
|
export { ProjectSettingsResource } from './resources/project-settings.js';
|
|
@@ -2,6 +2,7 @@ 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 { FeedStory, FeedTopItem, FeedTopNetwork, FeedTopNetworkEntry, FeedsResource } from './feeds.js';
|
|
5
6
|
import type { PostsResource } from './posts.js';
|
|
6
7
|
import type { ProtocolsResource, SocialNetwork, SocialPost, SocialRadar, SocialRadarPosts } from './protocols.js';
|
|
7
8
|
export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
|
|
@@ -284,15 +285,97 @@ export interface DiscoveryRadarMemberSources {
|
|
|
284
285
|
}>;
|
|
285
286
|
}
|
|
286
287
|
export type DiscoveryRadarPresentation = 'breaking' | 'story';
|
|
288
|
+
/** Where an item sits in the top of the feed: set only on the items a session's top placed on its first page. */
|
|
289
|
+
export interface DiscoveryTopPlacement {
|
|
290
|
+
/** The item's ref in the top (a Minds post id, or '<protocol>/<nativeId>'): what a visible view of it names. */
|
|
291
|
+
ref: string;
|
|
292
|
+
/** Its position in the top, or in its network's lane. */
|
|
293
|
+
position: number;
|
|
294
|
+
/** 'top' for the first 10: send `block: 'top'` and the position with a visible view of one (an impression). */
|
|
295
|
+
block: 'top' | 'below';
|
|
296
|
+
/** Set on an open-network lane's entry: its network. */
|
|
297
|
+
lane?: FeedTopNetwork;
|
|
298
|
+
}
|
|
287
299
|
export type DiscoveryPostItem = {
|
|
288
300
|
key: string;
|
|
289
301
|
kind: 'minds';
|
|
290
302
|
post: Post;
|
|
303
|
+
top?: DiscoveryTopPlacement;
|
|
291
304
|
} | {
|
|
292
305
|
key: string;
|
|
293
306
|
kind: 'social';
|
|
294
307
|
post: SocialPost;
|
|
308
|
+
top?: DiscoveryTopPlacement;
|
|
295
309
|
};
|
|
310
|
+
/** What a session reads of a top answer: the member route's (`feeds.top`) and the visitor read's (`feeds.publicTop`) common shape. */
|
|
311
|
+
export interface DiscoveryTopAnswer {
|
|
312
|
+
items: FeedTopItem[];
|
|
313
|
+
networks: Partial<Record<FeedTopNetwork, FeedTopNetworkEntry[]>> | null;
|
|
314
|
+
lead: FeedStory | null;
|
|
315
|
+
meta?: {
|
|
316
|
+
flag?: {
|
|
317
|
+
impressions?: string;
|
|
318
|
+
};
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
/** Reads the top of the session's feed. */
|
|
322
|
+
export type DiscoveryTopSource = (params: {
|
|
323
|
+
ref: string;
|
|
324
|
+
limit: number;
|
|
325
|
+
session?: string;
|
|
326
|
+
}) => Promise<DiscoveryTopAnswer>;
|
|
327
|
+
/** The top of the feed a session opens with (`discovery.createFeed({ top })`, or DiscoveryFeedSession's options). */
|
|
328
|
+
export interface DiscoveryTopOptions {
|
|
329
|
+
/**
|
|
330
|
+
* The feed's ref as the reader takes it: `builtin:all`, `category:<id>`, `following`, `watch` or `story:<id>` for
|
|
331
|
+
* `feeds.top`; `all`, `watch` or `category:<id>` for `feeds.publicTop`.
|
|
332
|
+
*/
|
|
333
|
+
ref: string;
|
|
334
|
+
/** Items asked for, 1..25 (default DISCOVERY_TOP_LIMIT). */
|
|
335
|
+
limit?: number;
|
|
336
|
+
/** The app's session token (`feeds.top` keeps one session's order stable with it). */
|
|
337
|
+
session?: string;
|
|
338
|
+
/** The reader. createFeed reads `feeds.top` (with its `compass`); a session built directly has no top without one. */
|
|
339
|
+
read?: DiscoveryTopSource;
|
|
340
|
+
/**
|
|
341
|
+
* Reads one Minds post by id, for a top item the first hot page does not carry, within the top's one deadline
|
|
342
|
+
* (DISCOVERY_TOP_TIMEOUT_MS from the page's start). Defaults to the session's radar Minds reader; createFeed passes
|
|
343
|
+
* `GET /posts/:id` without retries. Without either, a Minds top item shows only when the first hot page carries it.
|
|
344
|
+
*/
|
|
345
|
+
post?: (id: string) => Promise<{
|
|
346
|
+
data: Post;
|
|
347
|
+
}>;
|
|
348
|
+
}
|
|
349
|
+
/** What a member saw of a radar story: how often its lead was on screen, and whether they ever opened it. */
|
|
350
|
+
export interface DiscoverySeenCluster {
|
|
351
|
+
n: number;
|
|
352
|
+
clicked: boolean;
|
|
353
|
+
}
|
|
354
|
+
/** The options a session takes besides its readers. With none set, a session answers exactly what 0.7.63's did. */
|
|
355
|
+
export interface DiscoveryFeedSessionOptions {
|
|
356
|
+
/**
|
|
357
|
+
* The top of the feed. Its read starts with the first page's reads, and it holds the page at most
|
|
358
|
+
* DISCOVERY_TOP_TIMEOUT_MS from the page's start, the reads of its items' posts included: a top that fails (404 while
|
|
359
|
+
* it is off, a network error, a timeout) leaves the session exactly as it is without one. The radar's own story reads
|
|
360
|
+
* its member posts as soon as the radar answers, whatever the top does; a different story the top leads with reads its
|
|
361
|
+
* own from the moment the top answers.
|
|
362
|
+
* When it answers, the first page opens with its items (each carrying `top`), its open-network lanes stand in for the
|
|
363
|
+
* networks' first reads (a topics network's previews), its lead is the page's story, and no later hot page repeats an
|
|
364
|
+
* item it placed.
|
|
365
|
+
*/
|
|
366
|
+
top?: DiscoveryTopOptions;
|
|
367
|
+
/**
|
|
368
|
+
* 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. A page the filter leaves under DISCOVERY_SEEN_MIN_ITEMS walks on, up to
|
|
370
|
+
* DISCOVERY_SEEN_WALK more hot pages, and the stream's fall-through (`fallover`) still reports when it ends. When the
|
|
371
|
+
* top's order did not use impressions (`meta.flag.impressions` not 'on'), the top's seen items follow its unseen ones.
|
|
372
|
+
*/
|
|
373
|
+
seen?: Iterable<string>;
|
|
374
|
+
/** The client's own story rotation, for a session with no server lead: a story shown RADAR_REST_AFTER times with no open is skipped. */
|
|
375
|
+
seenClusters?: ReadonlyMap<string, DiscoverySeenCluster>;
|
|
376
|
+
/** A visitor's rotation bucket (e.g. floor(now / 15 min)): page 0's story rotates among the breaking stories by it. */
|
|
377
|
+
bucket?: number;
|
|
378
|
+
}
|
|
296
379
|
export type DiscoveryItem = DiscoveryPostItem | {
|
|
297
380
|
key: string;
|
|
298
381
|
kind: 'group';
|
|
@@ -325,6 +408,22 @@ export declare const DISCOVERY_SOURCE_TIMEOUT_MS = 15000;
|
|
|
325
408
|
* "Minds posts didn't load" with Try again and which its Politics fallback (minds#1212) waits for.
|
|
326
409
|
*/
|
|
327
410
|
export declare const DISCOVERY_SOURCE_RETRIES = 2;
|
|
411
|
+
/**
|
|
412
|
+
* The longest the top of the feed holds the first page, counted from the page's start. The top's read and the reads of
|
|
413
|
+
* the posts its items need (Minds posts by id, a story's network posts) share this one deadline: a top that has not
|
|
414
|
+
* answered by then is left out, and an item whose post was not read by then shows only if the first hot page carries it.
|
|
415
|
+
*/
|
|
416
|
+
export declare const DISCOVERY_TOP_TIMEOUT_MS = 1500;
|
|
417
|
+
/** Top items a session asks for unless its options say otherwise: the top block. */
|
|
418
|
+
export declare const DISCOVERY_TOP_LIMIT = 10;
|
|
419
|
+
/** A hot page the seen set (or the top) leaves under this many posts walks on to the next hot page... */
|
|
420
|
+
export declare const DISCOVERY_SEEN_MIN_ITEMS = 5;
|
|
421
|
+
/** ...up to this many more pages in one read; then the page is what it is and the next read goes on from there. */
|
|
422
|
+
export declare const DISCOVERY_SEEN_WALK = 3;
|
|
423
|
+
/** A story whose lead was on screen this many times with no open is skipped (DiscoveryFeedSessionOptions.seenClusters). */
|
|
424
|
+
export declare const RADAR_REST_AFTER = 3;
|
|
425
|
+
/** A visitor's page-0 story rotates by bucket among this many best breaking stories (or best stories when none is breaking). */
|
|
426
|
+
export declare const RADAR_BUCKET_ROTATION = 5;
|
|
328
427
|
/**
|
|
329
428
|
* Whether a source's read failed for a reason that passes: it ran past its deadline, its call never got an answer (a
|
|
330
429
|
* failed connection or an abort, as the session's read reports it), or the server answered 408, 425, 429 or 5xx (or no
|
|
@@ -332,6 +431,17 @@ export declare const DISCOVERY_SOURCE_RETRIES = 2;
|
|
|
332
431
|
* checking it) and any other 4xx are the source's own failure and are not asked again.
|
|
333
432
|
*/
|
|
334
433
|
export declare function transientSourceFailure(error: unknown): boolean;
|
|
434
|
+
/** Page 0's story, else a later page's: the client's own pick, for a session with no lead from the top of the feed. */
|
|
435
|
+
export interface RadarPickOptions {
|
|
436
|
+
/** Stories this session already showed. */
|
|
437
|
+
used?: ReadonlySet<string>;
|
|
438
|
+
/** What the member saw of each story: one shown RADAR_REST_AFTER times with no open is skipped. */
|
|
439
|
+
seenClusters?: ReadonlyMap<string, DiscoverySeenCluster>;
|
|
440
|
+
/** A visitor's bucket: page 0's story rotates by it among the RADAR_BUCKET_ROTATION best breaking stories (best stories when none is breaking). */
|
|
441
|
+
bucket?: number;
|
|
442
|
+
}
|
|
443
|
+
/** Page 0: the top breaking story, else the top story. Later pages: the next unused story that is not breaking. */
|
|
444
|
+
export declare function pickRadarCluster(clusters: readonly DiscoveryCluster[], page: number, options?: RadarPickOptions): DiscoveryCluster | undefined;
|
|
335
445
|
/** json-render Spec: values are references to authenticated SDK results, never
|
|
336
446
|
* arbitrary executable components, links, generated text or provider markup. */
|
|
337
447
|
export interface DiscoveryModule {
|
|
@@ -407,7 +517,21 @@ export declare class DiscoveryFeedSession {
|
|
|
407
517
|
private radarLoading;
|
|
408
518
|
/** Member reads per story, started as soon as the story is known and shared by whichever page shows it. */
|
|
409
519
|
private memberReads;
|
|
410
|
-
|
|
520
|
+
/** The top of the feed (options.top with a reader), its read started with page 0's reads, and the keys it placed. */
|
|
521
|
+
private top?;
|
|
522
|
+
private topLoading;
|
|
523
|
+
/** The story the top leads with, once its answer is in (null: it names none); undefined until then. */
|
|
524
|
+
private topLead;
|
|
525
|
+
private topKeys;
|
|
526
|
+
/** What the member already saw (options.seen): left out of the Minds hot pages. */
|
|
527
|
+
private appSeen;
|
|
528
|
+
private seenClusters?;
|
|
529
|
+
private bucket?;
|
|
530
|
+
constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined, recommend?: DiscoveryRecommendationSources | undefined, radar?: DiscoveryRadarSource, members?: DiscoveryRadarMemberSources | undefined, options?: DiscoveryFeedSessionOptions);
|
|
531
|
+
/** The Minds hot pages leave out what the member saw or the top placed, and walk on past a page that leaves too little. */
|
|
532
|
+
private get filtering();
|
|
533
|
+
/** A Minds hot item the page leaves out: shown on an earlier page, placed by the top, or in the member's seen set. */
|
|
534
|
+
private hiddenFromHot;
|
|
411
535
|
retryFailed(): void;
|
|
412
536
|
next(): Promise<DiscoveryFeedPage>;
|
|
413
537
|
private read;
|
|
@@ -421,10 +545,35 @@ export declare class DiscoveryFeedSession {
|
|
|
421
545
|
*/
|
|
422
546
|
private walkTopics;
|
|
423
547
|
private readPage;
|
|
424
|
-
/**
|
|
548
|
+
/**
|
|
549
|
+
* Page 0: the top's lead when it names one, else the top breaking cluster, else the top cluster. Every second later
|
|
550
|
+
* page: the next unused story or viral cluster.
|
|
551
|
+
*/
|
|
425
552
|
private radarCluster;
|
|
426
|
-
/** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster. */
|
|
553
|
+
/** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster (pickRadarCluster). */
|
|
427
554
|
private pickCluster;
|
|
555
|
+
/** Page 0's story: the top's lead (from the radar snapshot when it holds it, so its links and members come along), else the client's pick. */
|
|
556
|
+
private leadOf;
|
|
557
|
+
/** One top item as its own module, in the top's order: a video plays as a video module (unless the member's layouts say posts). */
|
|
558
|
+
private topModule;
|
|
559
|
+
/**
|
|
560
|
+
* The top's read, then the posts its items need, all under one deadline of DISCOVERY_TOP_TIMEOUT_MS from the page's
|
|
561
|
+
* start: the reads of its posts get what is left of it, never a deadline of their own. Null when it failed, timed out
|
|
562
|
+
* or answered nothing usable: the page is then as without a top.
|
|
563
|
+
*/
|
|
564
|
+
private loadTop;
|
|
565
|
+
/**
|
|
566
|
+
* Checks a top answer and reads what its items need before `deadline`: Minds posts by id, a story's network posts
|
|
567
|
+
* (lanes come with their cards). A lead it names starts reading its member posts at once.
|
|
568
|
+
*/
|
|
569
|
+
private readyTop;
|
|
570
|
+
/**
|
|
571
|
+
* Places the top on page 0: each lane stands in for its network's first read (a topics network's catalog previews; a
|
|
572
|
+
* 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
|
+
* 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.
|
|
575
|
+
*/
|
|
576
|
+
private placeTop;
|
|
428
577
|
/** The story's members as feed items, read once per story and bounded to three seconds; a failed read is simply none. */
|
|
429
578
|
private memberPosts;
|
|
430
579
|
private readMembers;
|
|
@@ -437,7 +586,8 @@ export declare class DiscoveryResource {
|
|
|
437
586
|
private protocols;
|
|
438
587
|
private curator?;
|
|
439
588
|
private http?;
|
|
440
|
-
|
|
589
|
+
private feeds?;
|
|
590
|
+
constructor(posts: PostsResource, protocols: ProtocolsResource, curator?: Pick<CuratorResource, "forYou"> | undefined, http?: Pick<HttpClient, "get" | "post"> | undefined, feeds?: Pick<FeedsResource, "top"> | undefined);
|
|
441
591
|
/**
|
|
442
592
|
* Suggestions for the signed-in viewer from the app's one recommender: active or warm members, groups
|
|
443
593
|
* with several posters, tags used by several people, open-network accounts or stories, overall or in one
|
|
@@ -497,6 +647,10 @@ export declare class DiscoveryResource {
|
|
|
497
647
|
* Discover's feed session. `compass` (a Politics choice, as `posts.list` takes it) goes on every Minds read, whose
|
|
498
648
|
* pages then carry `meta.compass_filter`; the open networks' reads and the radar are not filtered by it, so a view
|
|
499
649
|
* that leaves posts out (Hide politics, Choose sides) is best read with `source: 'minds'`.
|
|
650
|
+
*
|
|
651
|
+
* `top` opens the first page with the top of the feed (`feeds.top`, with the same `compass`, unless `top.read` is
|
|
652
|
+
* given); `seen` leaves what the member saw out of the hot pages; `seenClusters` and `bucket` rotate the story when
|
|
653
|
+
* the top names none (DiscoveryFeedSessionOptions). With none of them the session is what it was before they existed.
|
|
500
654
|
*/
|
|
501
655
|
createFeed(options: {
|
|
502
656
|
networks: SocialNetwork[];
|
|
@@ -505,5 +659,5 @@ export declare class DiscoveryResource {
|
|
|
505
659
|
learning?: DiscoveryLearning;
|
|
506
660
|
recommend?: DiscoveryRecommendationSources;
|
|
507
661
|
compass?: string;
|
|
508
|
-
}): DiscoveryFeedSession;
|
|
662
|
+
} & DiscoveryFeedSessionOptions): DiscoveryFeedSession;
|
|
509
663
|
}
|
|
@@ -42,6 +42,26 @@ export const DISCOVERY_SOURCE_TIMEOUT_MS = 15_000;
|
|
|
42
42
|
* "Minds posts didn't load" with Try again and which its Politics fallback (minds#1212) waits for.
|
|
43
43
|
*/
|
|
44
44
|
export const DISCOVERY_SOURCE_RETRIES = 2;
|
|
45
|
+
/**
|
|
46
|
+
* The longest the top of the feed holds the first page, counted from the page's start. The top's read and the reads of
|
|
47
|
+
* the posts its items need (Minds posts by id, a story's network posts) share this one deadline: a top that has not
|
|
48
|
+
* answered by then is left out, and an item whose post was not read by then shows only if the first hot page carries it.
|
|
49
|
+
*/
|
|
50
|
+
export const DISCOVERY_TOP_TIMEOUT_MS = 1500;
|
|
51
|
+
/** What the top's deadline answers when it passes first. */
|
|
52
|
+
const TOP_LATE = Symbol('top of the feed late');
|
|
53
|
+
/** Top items a session asks for unless its options say otherwise: the top block. */
|
|
54
|
+
export const DISCOVERY_TOP_LIMIT = 10;
|
|
55
|
+
/** Stories whose network posts a top's items are read from, at most. */
|
|
56
|
+
const TOP_MAX_STORY_READS = 3;
|
|
57
|
+
/** A hot page the seen set (or the top) leaves under this many posts walks on to the next hot page... */
|
|
58
|
+
export const DISCOVERY_SEEN_MIN_ITEMS = 5;
|
|
59
|
+
/** ...up to this many more pages in one read; then the page is what it is and the next read goes on from there. */
|
|
60
|
+
export const DISCOVERY_SEEN_WALK = 3;
|
|
61
|
+
/** A story whose lead was on screen this many times with no open is skipped (DiscoveryFeedSessionOptions.seenClusters). */
|
|
62
|
+
export const RADAR_REST_AFTER = 3;
|
|
63
|
+
/** A visitor's page-0 story rotates by bucket among this many best breaking stories (or best stories when none is breaking). */
|
|
64
|
+
export const RADAR_BUCKET_ROTATION = 5;
|
|
45
65
|
/** The page deadline a source's read ran past. */
|
|
46
66
|
class SourceTimedOut extends Error {
|
|
47
67
|
constructor() { super('Discovery source timed out'); this.name = 'SourceTimedOut'; }
|
|
@@ -168,6 +188,103 @@ function radarCluster(value) {
|
|
|
168
188
|
return { id: c.id, label: c.label.slice(0, 300), kind: c.kind, ...(theme ? { theme } : {}), outletCount: mainstream + independent, laneMix: { mainstream, independent }, sources,
|
|
169
189
|
velocity: { normalized: typeof normalized === 'number' && Number.isFinite(normalized) && normalized >= 0 ? normalized : 0 }, newestAt, ...(lead ? { lead } : {}), links, memberKeys };
|
|
170
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* The top's lead story as a cluster, for a page whose radar snapshot does not hold it (the radar read failed or timed
|
|
193
|
+
* out): its label, kind, theme, sources and freshness, with no links or member posts (the app opens the story by id).
|
|
194
|
+
*/
|
|
195
|
+
function storyCluster(value) {
|
|
196
|
+
if (!value || typeof value !== 'object')
|
|
197
|
+
return null;
|
|
198
|
+
const s = value;
|
|
199
|
+
if (typeof s.id !== 'string' || !/^[0-9a-f]{64}$/.test(s.id) || !nonEmpty(s.label) || typeof s.kind !== 'string' || !RADAR_KINDS.has(s.kind))
|
|
200
|
+
return null;
|
|
201
|
+
const newestAt = (s.freshness ?? {}).newestAt;
|
|
202
|
+
if (typeof newestAt !== 'string' || !Number.isFinite(Date.parse(newestAt)))
|
|
203
|
+
return null;
|
|
204
|
+
const sources = {};
|
|
205
|
+
if (s.sources && typeof s.sources === 'object')
|
|
206
|
+
for (const [name, count] of Object.entries(s.sources)) {
|
|
207
|
+
const n = countOf(count);
|
|
208
|
+
if (n !== null && /^[a-z]{1,32}$/.test(name))
|
|
209
|
+
sources[name] = n;
|
|
210
|
+
}
|
|
211
|
+
const theme = typeof s.theme === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(s.theme) ? s.theme : undefined;
|
|
212
|
+
return { id: s.id, label: s.label.slice(0, 300), kind: s.kind, ...(theme ? { theme } : {}), outletCount: sources.outlet ?? 0,
|
|
213
|
+
laneMix: { mainstream: 0, independent: 0 }, sources, velocity: { normalized: 0 }, newestAt, links: [], memberKeys: [] };
|
|
214
|
+
}
|
|
215
|
+
/** Page 0: the top breaking story, else the top story. Later pages: the next unused story that is not breaking. */
|
|
216
|
+
export function pickRadarCluster(clusters, page, options = {}) {
|
|
217
|
+
const rested = (cluster) => { const seen = options.seenClusters?.get(cluster.id); return !!seen && !seen.clicked && seen.n >= RADAR_REST_AFTER; };
|
|
218
|
+
const unused = clusters.filter(candidate => !options.used?.has(candidate.id) && !rested(candidate));
|
|
219
|
+
if (page !== 0)
|
|
220
|
+
return unused.find(candidate => candidate.kind !== 'breaking');
|
|
221
|
+
const breaking = unused.filter(candidate => candidate.kind === 'breaking');
|
|
222
|
+
if (options.bucket !== undefined && Number.isSafeInteger(options.bucket)) {
|
|
223
|
+
const turn = (breaking.length ? breaking : unused).slice(0, RADAR_BUCKET_ROTATION);
|
|
224
|
+
if (turn.length)
|
|
225
|
+
return turn[((options.bucket % turn.length) + turn.length) % turn.length];
|
|
226
|
+
}
|
|
227
|
+
return breaking[0] ?? unused[0];
|
|
228
|
+
}
|
|
229
|
+
const TOP_NETWORKS = new Set(['bluesky', 'activitypub', 'nostr']);
|
|
230
|
+
const idString = (value) => typeof value === 'string' && value.length > 0 && value.length <= 4096;
|
|
231
|
+
const counted = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : null;
|
|
232
|
+
/** The refs an app's seen set may hold for a post: its id, or '<protocol>/<nativeId>' (an ActivityPub id also without https://). */
|
|
233
|
+
function seenRefsOf(protocol, id) {
|
|
234
|
+
if (protocol === 'minds')
|
|
235
|
+
return [id];
|
|
236
|
+
const ref = `${protocol}/${id}`;
|
|
237
|
+
return protocol === 'activitypub' && /^https:\/\//i.test(id) ? [ref, `${protocol}/${id.replace(/^https:\/\//i, '')}`] : [ref];
|
|
238
|
+
}
|
|
239
|
+
function topRow(value) {
|
|
240
|
+
if (!value || typeof value !== 'object')
|
|
241
|
+
return null;
|
|
242
|
+
const item = value;
|
|
243
|
+
const target = (item.target ?? {});
|
|
244
|
+
if (!idString(item.ref))
|
|
245
|
+
return null;
|
|
246
|
+
const explain = (item.explain ?? {});
|
|
247
|
+
const cluster = typeof explain.cluster === 'string' && /^[0-9a-f]{64}$/.test(explain.cluster) ? explain.cluster : null;
|
|
248
|
+
if (item.kind === 'post' && target.kind === 'post' && nonEmpty(target.id))
|
|
249
|
+
return { ref: item.ref, key: key('minds', target.id), kind: 'minds', id: target.id, cluster, seenRefs: seenRefsOf('minds', target.id) };
|
|
250
|
+
if (item.kind === 'network_post' && target.kind === 'network_post' && typeof target.protocol === 'string' && TOP_NETWORKS.has(target.protocol) && idString(target.nativeId)) {
|
|
251
|
+
return { ref: item.ref, key: key(target.protocol, target.nativeId), kind: 'social', id: target.nativeId, cluster, seenRefs: seenRefsOf(target.protocol, target.nativeId) };
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* One open-network lane entry as a post the session can show: the card's text, author, media and counts. The card
|
|
257
|
+
* carries no URL on the post's network, so neither does the post (`sourceUrl` and the author's `profileUrl` are empty):
|
|
258
|
+
* the app opens it by its ref, in the app.
|
|
259
|
+
*/
|
|
260
|
+
function laneItem(value, protocol) {
|
|
261
|
+
if (!value || typeof value !== 'object')
|
|
262
|
+
return null;
|
|
263
|
+
const entry = value;
|
|
264
|
+
const target = (entry.target ?? {});
|
|
265
|
+
const card = (entry.card ?? null);
|
|
266
|
+
const author = (card?.author ?? null);
|
|
267
|
+
if (entry.protocol !== protocol || !idString(entry.ref) || target.kind !== 'network_post' || target.protocol !== protocol || !idString(target.nativeId))
|
|
268
|
+
return null;
|
|
269
|
+
if (!card || typeof card.text !== 'string' || typeof card.createdAt !== 'string' || !author || !idString(author.nativeId) || !nonEmpty(author.handle))
|
|
270
|
+
return null;
|
|
271
|
+
const counts = (card.counts ?? {});
|
|
272
|
+
const sensitive = card.sensitive === true;
|
|
273
|
+
const media = [];
|
|
274
|
+
for (const raw of Array.isArray(card.media) ? card.media.slice(0, 8) : []) {
|
|
275
|
+
const m = (raw ?? {});
|
|
276
|
+
if ((m.type !== 'image' && m.type !== 'video') || !httpUrl(m.url))
|
|
277
|
+
continue;
|
|
278
|
+
media.push({ type: m.type, url: m.url, alt: typeof m.alt === 'string' ? m.alt : '', ...(httpUrl(m.poster) ? { poster: m.poster } : {}),
|
|
279
|
+
...(dimension(m.width) && dimension(m.height) ? { width: dimension(m.width), height: dimension(m.height) } : {}), ...(sensitive ? { sensitive: true } : {}) });
|
|
280
|
+
}
|
|
281
|
+
const post = { protocol, nativeId: target.nativeId, sourceUrl: '', ...(nonEmpty(target.readRef) ? { readRef: target.readRef } : {}),
|
|
282
|
+
author: { protocol, nativeId: author.nativeId, handle: author.handle, displayName: nonEmpty(author.displayName) ? author.displayName : author.handle, description: '',
|
|
283
|
+
profileUrl: '', avatarUrl: httpUrl(author.avatarUrl) ? author.avatarUrl : null, followersCount: null, followingCount: null, postsCount: null },
|
|
284
|
+
text: card.text, createdAt: card.createdAt, replyCount: counted(counts.replies), likeCount: counted(counts.likes), repostCount: counted(counts.reposts),
|
|
285
|
+
media, hasAdditionalContent: card.hasQuote === true, ...(sensitive ? { sensitive: true } : {}) };
|
|
286
|
+
return { key: key(protocol, target.nativeId), kind: 'social', post, top: { ref: entry.ref, position: 0, block: 'top', lane: protocol } };
|
|
287
|
+
}
|
|
171
288
|
const FALLOVER_VIA = new Set(['personal', 'popular', 'static']);
|
|
172
289
|
const filled = (value) => typeof value === 'string' && value.length > 0;
|
|
173
290
|
/**
|
|
@@ -234,7 +351,17 @@ export class DiscoveryFeedSession {
|
|
|
234
351
|
radarLoading = null;
|
|
235
352
|
/** Member reads per story, started as soon as the story is known and shared by whichever page shows it. */
|
|
236
353
|
memberReads = new Map();
|
|
237
|
-
|
|
354
|
+
/** The top of the feed (options.top with a reader), its read started with page 0's reads, and the keys it placed. */
|
|
355
|
+
top;
|
|
356
|
+
topLoading = null;
|
|
357
|
+
/** The story the top leads with, once its answer is in (null: it names none); undefined until then. */
|
|
358
|
+
topLead;
|
|
359
|
+
topKeys = new Set();
|
|
360
|
+
/** What the member already saw (options.seen): left out of the Minds hot pages. */
|
|
361
|
+
appSeen = null;
|
|
362
|
+
seenClusters;
|
|
363
|
+
bucket;
|
|
364
|
+
constructor(posts, protocols, networks, category, source, learning, recommend, radar, members, options = {}) {
|
|
238
365
|
this.posts = posts;
|
|
239
366
|
this.protocols = protocols;
|
|
240
367
|
this.category = category;
|
|
@@ -247,6 +374,19 @@ export class DiscoveryFeedSession {
|
|
|
247
374
|
const sources = [...new Set(networks.filter(n => n.capabilities.includes('public_trends')).map(n => n.protocol))].slice(0, 16);
|
|
248
375
|
this.streams = ['minds', ...sources].filter(protocol => !source || source === 'all' || source === protocol)
|
|
249
376
|
.map(protocol => ({ protocol, offset: 0, done: false, failed: false, used: [] }));
|
|
377
|
+
const { top, seen, seenClusters, bucket } = options;
|
|
378
|
+
if (top && typeof top.read === 'function' && nonEmpty(top.ref))
|
|
379
|
+
this.top = { ...top, read: top.read };
|
|
380
|
+
if (seen)
|
|
381
|
+
this.appSeen = new Set([...seen].filter((ref) => typeof ref === 'string').slice(0, 10_000));
|
|
382
|
+
this.seenClusters = seenClusters;
|
|
383
|
+
this.bucket = bucket;
|
|
384
|
+
}
|
|
385
|
+
/** The Minds hot pages leave out what the member saw or the top placed, and walk on past a page that leaves too little. */
|
|
386
|
+
get filtering() { return !!this.appSeen || this.topKeys.size > 0; }
|
|
387
|
+
/** A Minds hot item the page leaves out: shown on an earlier page, placed by the top, or in the member's seen set. */
|
|
388
|
+
hiddenFromHot(item) {
|
|
389
|
+
return this.seen.has(item.key) || this.topKeys.has(item.key) || (item.kind === 'minds' && !!this.appSeen?.has(item.post.id));
|
|
250
390
|
}
|
|
251
391
|
retryFailed() { for (const stream of this.streams) {
|
|
252
392
|
stream.failed = false;
|
|
@@ -272,20 +412,44 @@ export class DiscoveryFeedSession {
|
|
|
272
412
|
// opt-out rides in the params (ListPostsParams.retry), which a reader handed to a session forwards to read the
|
|
273
413
|
// right page at all: a second argument was dropped by the Minds app's own wrappers (lib/discoverFeedResource,
|
|
274
414
|
// lib/compassStatus withCompass, lib/watchSource), which pass the params alone (review of #4047).
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
stream.
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
415
|
+
// With a seen set or a top, a hot page leaves out what the member saw or the top placed; one left under
|
|
416
|
+
// DISCOVERY_SEEN_MIN_ITEMS walks on to the next hot page, up to DISCOVERY_SEEN_WALK more, so the feed never looks
|
|
417
|
+
// ended while the ranking goes on. Each kept post keeps its own window's layout. Without either, one page as before.
|
|
418
|
+
const kept = [];
|
|
419
|
+
const layouts = [];
|
|
420
|
+
let head;
|
|
421
|
+
let partial = false;
|
|
422
|
+
let filtered = false;
|
|
423
|
+
for (let walked = 0;; walked++) {
|
|
424
|
+
const response = await this.posts.list({ ...params, sort: 'hot', ...(stream.cursor ? { cursor: stream.cursor } : { offset: stream.offset }), retry: false });
|
|
425
|
+
stream.offset += response.meta.scanned ?? response.meta.limit;
|
|
426
|
+
const next = response.meta.next_cursor ?? undefined;
|
|
427
|
+
stream.done = !response.meta.has_more || !!next && (next === stream.cursor || stream.used.includes(next));
|
|
428
|
+
if (stream.cursor)
|
|
429
|
+
stream.used = [...stream.used, stream.cursor].slice(-32);
|
|
430
|
+
stream.cursor = next;
|
|
431
|
+
// 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 = readFallover(response.meta.fallover);
|
|
433
|
+
if (fallover)
|
|
434
|
+
stream.fallover = fallover;
|
|
435
|
+
const items = response.data.map(post => ({ key: key('minds', post.id), kind: 'minds', post }));
|
|
436
|
+
const read = { layout: response.meta.discovery ?? ordinary, partial: response.meta.category_filter?.status === 'partial',
|
|
437
|
+
...(response.meta.compass_filter ? { compass: response.meta.compass_filter } : {}) };
|
|
438
|
+
if (!this.filtering)
|
|
439
|
+
return { items, ...read };
|
|
440
|
+
head ??= read;
|
|
441
|
+
partial ||= read.partial;
|
|
442
|
+
items.forEach((item, index) => {
|
|
443
|
+
if (this.hiddenFromHot(item) || kept.some(row => row.key === item.key)) {
|
|
444
|
+
filtered = true;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
kept.push(item);
|
|
448
|
+
layouts.push(windowLayout(read.layout, index));
|
|
449
|
+
});
|
|
450
|
+
if (kept.length >= DISCOVERY_SEEN_MIN_ITEMS || !filtered || stream.done || walked >= DISCOVERY_SEEN_WALK)
|
|
451
|
+
return { items: kept, ...head, partial, layouts };
|
|
452
|
+
}
|
|
289
453
|
}
|
|
290
454
|
const validatePosts = (data) => {
|
|
291
455
|
if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(post => post.protocol !== stream.protocol || post.author.protocol !== stream.protocol))
|
|
@@ -308,7 +472,7 @@ export class DiscoveryFeedSession {
|
|
|
308
472
|
// not a round later, so the network's first page in a category is not empty by design.
|
|
309
473
|
const previews = data.items.flatMap(topic => !this.category && topic.preview ? [{ key: key(topic.preview.protocol, topic.preview.nativeId), kind: 'social', post: topic.preview }] : []);
|
|
310
474
|
if (previews.length || stream.done)
|
|
311
|
-
return { items: previews, layout: ordinary, partial };
|
|
475
|
+
return { items: previews, layout: ordinary, partial, ...(previews.length ? { previews: true } : {}) };
|
|
312
476
|
return this.walkTopics(stream, params, validatePosts, partial, started);
|
|
313
477
|
}
|
|
314
478
|
validatePosts(data);
|
|
@@ -377,10 +541,21 @@ export class DiscoveryFeedSession {
|
|
|
377
541
|
async readPage() {
|
|
378
542
|
// The radar is one small snapshot read: start it with the page, not after the slowest provider,
|
|
379
543
|
// and start the lead story's member reads the moment the snapshot names it.
|
|
544
|
+
// The top of the feed starts with them too, and holds the page at most DISCOVERY_TOP_TIMEOUT_MS from here (loadTop).
|
|
545
|
+
// Neither waits for the other: the radar's own pick reads its members as soon as the radar answers (unless the top
|
|
546
|
+
// already named its lead), and a lead the top names reads its own as soon as the top answers (readyTop). A lead that
|
|
547
|
+
// is the radar's pick shares the one read (memberPosts).
|
|
548
|
+
if (this.page === 0 && this.top && !this.topLoading)
|
|
549
|
+
this.topLoading = this.loadTop();
|
|
380
550
|
if (this.page === 0 && this.radar && !this.radarLoading) {
|
|
381
551
|
this.radarLoading = this.loadRadar();
|
|
382
|
-
void this.radarLoading.then(clusters => {
|
|
383
|
-
|
|
552
|
+
void this.radarLoading.then(clusters => {
|
|
553
|
+
if (this.topLead)
|
|
554
|
+
return;
|
|
555
|
+
const lead = this.pickCluster(clusters, 0);
|
|
556
|
+
if (lead)
|
|
557
|
+
void this.memberPosts(lead);
|
|
558
|
+
});
|
|
384
559
|
}
|
|
385
560
|
// A network that failed for a passing reason sat out the page after its failure; from this one it takes its turn
|
|
386
561
|
// again. It stays failed, and named in retryingSources, until a page asks it: with more than three networks the
|
|
@@ -422,6 +597,9 @@ export class DiscoveryFeedSession {
|
|
|
422
597
|
cancel();
|
|
423
598
|
}
|
|
424
599
|
}));
|
|
600
|
+
// Page 0 opens with the top of the feed when it answered: its items lead, its lanes stand in for the networks' first reads.
|
|
601
|
+
const top = this.page === 0 && this.topLoading ? await this.topLoading : null;
|
|
602
|
+
const topItems = top ? this.placeTop(top, selected, reads) : [];
|
|
425
603
|
// Round-robin before grouping: the largest network cannot occupy every slot.
|
|
426
604
|
// Each item carries the judgment for its own window of its source response,
|
|
427
605
|
// so one page can feature several modules instead of one per source batch.
|
|
@@ -432,18 +610,20 @@ export class DiscoveryFeedSession {
|
|
|
432
610
|
if (!item || this.seen.has(item.key))
|
|
433
611
|
continue;
|
|
434
612
|
this.seen.add(item.key);
|
|
435
|
-
mixed.push({ item, layout: windowLayout(read.layout, index) });
|
|
613
|
+
mixed.push({ item, layout: read.layouts?.[index] ?? windowLayout(read.layout, index) });
|
|
436
614
|
}
|
|
437
615
|
// Deduplication memory is bounded independently of the UI's virtual window.
|
|
438
616
|
while (this.seen.size > 4096)
|
|
439
617
|
this.seen.delete(this.seen.values().next().value);
|
|
440
|
-
const modules =
|
|
618
|
+
const modules = topItems.map((item, index) => this.topModule(this.page, index, item));
|
|
441
619
|
const page = this.page++;
|
|
620
|
+
const topCount = modules.length;
|
|
442
621
|
let remaining = mixed;
|
|
443
|
-
// The radar leads the first page with the top breaking story and returns
|
|
622
|
+
// The radar leads the first page with the top breaking story (the top's lead, when it names one) and returns
|
|
444
623
|
// every second page with the next one. Its member posts move under the
|
|
445
624
|
// story, on this page or on any later one, so the hot stream never repeats them.
|
|
446
|
-
|
|
625
|
+
// A top that names a story leads with it even when no post came with the top or the reads (Codex review of #4205).
|
|
626
|
+
const cluster = mixed.length || topCount || top?.lead ? await this.radarCluster(page, top) : null;
|
|
447
627
|
let clusterItems = [];
|
|
448
628
|
if (cluster) {
|
|
449
629
|
const members = new Set(cluster.memberKeys);
|
|
@@ -495,7 +675,7 @@ export class DiscoveryFeedSession {
|
|
|
495
675
|
// the opening modules so the page still leads with content.
|
|
496
676
|
const recommendation = modules.length ? await this.recommendation(page) : null;
|
|
497
677
|
if (recommendation)
|
|
498
|
-
modules.splice(Math.min(2, modules.length), 0, recommendation);
|
|
678
|
+
modules.splice(Math.min(topCount + 2, modules.length), 0, recommendation);
|
|
499
679
|
if (cluster)
|
|
500
680
|
modules.splice(page === 0 ? 0 : Math.min(2, modules.length), 0, this.radarModule(page, cluster, clusterItems));
|
|
501
681
|
const fallover = this.streams.find(stream => stream.protocol === 'minds')?.fallover;
|
|
@@ -507,12 +687,20 @@ export class DiscoveryFeedSession {
|
|
|
507
687
|
...(retrying.length ? { retryingSources: retrying } : {}), partialCategories: reads.some(read => read.partial),
|
|
508
688
|
...(fallover ? { fallover } : {}), ...(compass ? { compass } : {}) };
|
|
509
689
|
}
|
|
510
|
-
/**
|
|
511
|
-
|
|
690
|
+
/**
|
|
691
|
+
* Page 0: the top's lead when it names one, else the top breaking cluster, else the top cluster. Every second later
|
|
692
|
+
* page: the next unused story or viral cluster.
|
|
693
|
+
*/
|
|
694
|
+
async radarCluster(page, top = null) {
|
|
512
695
|
if (!this.radar)
|
|
513
696
|
return null;
|
|
514
697
|
if (page === 0)
|
|
515
698
|
this.clusters = await (this.radarLoading ?? this.loadRadar());
|
|
699
|
+
if (page === 0 && top?.lead) {
|
|
700
|
+
const lead = this.leadOf(this.clusters ?? [], top);
|
|
701
|
+
this.radarUsed.add(lead.id);
|
|
702
|
+
return this.learning && this.learning.choose(lead.kind === 'breaking' ? 'breaking' : 'story', page) === 'posts' ? null : lead;
|
|
703
|
+
}
|
|
516
704
|
if (!this.clusters?.length || (page !== 0 && page % 2 !== 0))
|
|
517
705
|
return null;
|
|
518
706
|
const cluster = this.pickCluster(this.clusters, page);
|
|
@@ -524,10 +712,139 @@ export class DiscoveryFeedSession {
|
|
|
524
712
|
return null;
|
|
525
713
|
return cluster;
|
|
526
714
|
}
|
|
527
|
-
/** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster. */
|
|
715
|
+
/** Page 0: the top breaking cluster, else the top cluster. Later pages: the next unused story or viral cluster (pickRadarCluster). */
|
|
528
716
|
pickCluster(clusters, page) {
|
|
529
|
-
|
|
530
|
-
|
|
717
|
+
return pickRadarCluster(clusters, page, { used: this.radarUsed, seenClusters: this.seenClusters, bucket: this.bucket });
|
|
718
|
+
}
|
|
719
|
+
/** Page 0's story: the top's lead (from the radar snapshot when it holds it, so its links and members come along), else the client's pick. */
|
|
720
|
+
leadOf(clusters, top) {
|
|
721
|
+
if (top?.lead)
|
|
722
|
+
return clusters.find(candidate => candidate.id === top.lead.id) ?? top.lead;
|
|
723
|
+
return this.pickCluster(clusters, 0);
|
|
724
|
+
}
|
|
725
|
+
/** One top item as its own module, in the top's order: a video plays as a video module (unless the member's layouts say posts). */
|
|
726
|
+
topModule(page, index, item) {
|
|
727
|
+
const video = !!item.post.media?.some(media => media.type === 'video') && (this.learning?.choose('video', page) ?? 'video') === 'video';
|
|
728
|
+
return { id: `discovery-${page}-top-${index}-${item.key}`, presentation: video ? 'video' : 'posts', source: 'fallback', items: [item],
|
|
729
|
+
spec: { root: 'module', elements: { module: { type: video ? 'DiscoveryVideo' : 'DiscoveryPosts', props: { itemKeys: [item.key] }, children: [] } } } };
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* The top's read, then the posts its items need, all under one deadline of DISCOVERY_TOP_TIMEOUT_MS from the page's
|
|
733
|
+
* start: the reads of its posts get what is left of it, never a deadline of their own. Null when it failed, timed out
|
|
734
|
+
* or answered nothing usable: the page is then as without a top.
|
|
735
|
+
*/
|
|
736
|
+
async loadTop() {
|
|
737
|
+
const top = this.top;
|
|
738
|
+
let timer;
|
|
739
|
+
const deadline = new Promise(resolve => { timer = setTimeout(() => resolve(TOP_LATE), DISCOVERY_TOP_TIMEOUT_MS); });
|
|
740
|
+
try {
|
|
741
|
+
const limit = Number.isSafeInteger(top.limit) && top.limit >= 1 && top.limit <= 25 ? top.limit : DISCOVERY_TOP_LIMIT;
|
|
742
|
+
const answer = await Promise.race([Promise.resolve().then(() => top.read({ ref: top.ref, limit, ...(nonEmpty(top.session) ? { session: top.session } : {}) })), deadline]);
|
|
743
|
+
if (answer === TOP_LATE)
|
|
744
|
+
return null;
|
|
745
|
+
return await this.readyTop(answer, deadline);
|
|
746
|
+
}
|
|
747
|
+
catch {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
finally {
|
|
751
|
+
clearTimeout(timer);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
/**
|
|
755
|
+
* Checks a top answer and reads what its items need before `deadline`: Minds posts by id, a story's network posts
|
|
756
|
+
* (lanes come with their cards). A lead it names starts reading its member posts at once.
|
|
757
|
+
*/
|
|
758
|
+
async readyTop(answer, deadline) {
|
|
759
|
+
if (!answer || typeof answer !== 'object')
|
|
760
|
+
return null;
|
|
761
|
+
const value = answer;
|
|
762
|
+
if (!Array.isArray(value.items))
|
|
763
|
+
return null;
|
|
764
|
+
// Only what this session reads: a Minds item needs its Minds stream, a network's item or lane that network's stream.
|
|
765
|
+
const streams = new Set(this.streams.map(stream => stream.protocol));
|
|
766
|
+
const rows = [];
|
|
767
|
+
for (const raw of value.items.slice(0, 25)) {
|
|
768
|
+
const row = topRow(raw);
|
|
769
|
+
if (row && !rows.some(existing => existing.key === row.key) && streams.has(row.kind === 'minds' ? 'minds' : JSON.parse(row.key)[0]))
|
|
770
|
+
rows.push(row);
|
|
771
|
+
}
|
|
772
|
+
const lanes = new Map();
|
|
773
|
+
const networks = value.networks && typeof value.networks === 'object' ? value.networks : {};
|
|
774
|
+
for (const protocol of TOP_NETWORKS) {
|
|
775
|
+
const entries = networks[protocol];
|
|
776
|
+
if (!streams.has(protocol) || !Array.isArray(entries))
|
|
777
|
+
continue;
|
|
778
|
+
const lane = [];
|
|
779
|
+
for (const raw of entries.slice(0, 12)) {
|
|
780
|
+
const item = laneItem(raw, protocol);
|
|
781
|
+
if (item && !lane.some(existing => existing.key === item.key))
|
|
782
|
+
lane.push({ ...item, top: { ...item.top, position: lane.length, block: lane.length < 10 ? 'top' : 'below' } });
|
|
783
|
+
}
|
|
784
|
+
if (lane.length)
|
|
785
|
+
lanes.set(protocol, lane);
|
|
786
|
+
}
|
|
787
|
+
const lead = this.radar ? storyCluster(value.lead) : null;
|
|
788
|
+
const impressions = (value.meta?.flag?.impressions) === 'on';
|
|
789
|
+
if (!rows.length && !lanes.size && !lead)
|
|
790
|
+
return null;
|
|
791
|
+
// The page's story is the top's lead: its member reads start now, alongside the reads below, not after them.
|
|
792
|
+
this.topLead = lead?.id ?? null;
|
|
793
|
+
if (lead && this.radarLoading)
|
|
794
|
+
void this.radarLoading.then(clusters => this.memberPosts(clusters.find(candidate => candidate.id === lead.id) ?? lead));
|
|
795
|
+
// The posts the rows need, read at once under the top's one deadline: a Minds post by id; a network post from its
|
|
796
|
+
// lane's card, else from its story's member posts. A row nothing read in time is left out (the first hot page may
|
|
797
|
+
// still carry it).
|
|
798
|
+
const inTime = (read, fallback) => Promise.race([Promise.resolve().then(read).catch(() => fallback), deadline.then(() => fallback)]);
|
|
799
|
+
const posts = new Map();
|
|
800
|
+
for (const lane of lanes.values())
|
|
801
|
+
for (const item of lane)
|
|
802
|
+
posts.set(item.key, { key: item.key, kind: 'social', post: item.post });
|
|
803
|
+
const readPost = this.top?.post ?? this.members?.minds;
|
|
804
|
+
const mindsReads = readPost ? rows.filter(row => row.kind === 'minds').map(row => inTime(() => readPost(row.id).then(({ data }) => data && data.id === row.id ? { key: row.key, kind: 'minds', post: data } : null), null)) : [];
|
|
805
|
+
const stories = [...new Set(rows.filter(row => row.kind === 'social' && !posts.has(row.key) && row.cluster).map(row => row.cluster))].slice(0, TOP_MAX_STORY_READS);
|
|
806
|
+
const wanted = new Set(rows.filter(row => row.kind === 'social').map(row => row.key));
|
|
807
|
+
const social = this.members?.social;
|
|
808
|
+
const storyReads = social ? stories.map(cluster => inTime(() => social({ cluster }).then(({ data }) => !data || data.cluster !== cluster || !Array.isArray(data.items) ? [] : data.items
|
|
809
|
+
.filter(post => post && idString(post.nativeId) && post.author?.protocol === post.protocol && wanted.has(key(post.protocol, post.nativeId)))
|
|
810
|
+
.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post }))), [])) : [];
|
|
811
|
+
const [minds, network] = await Promise.all([Promise.all(mindsReads), Promise.all(storyReads)]);
|
|
812
|
+
for (const item of [...minds, ...network.flat()])
|
|
813
|
+
if (item && !posts.has(item.key))
|
|
814
|
+
posts.set(item.key, item);
|
|
815
|
+
return { rows, posts, lanes, lead, impressions };
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* Places the top on page 0: each lane stands in for its network's first read (a topics network's catalog previews; a
|
|
819
|
+
* 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
|
+
* 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.
|
|
822
|
+
*/
|
|
823
|
+
placeTop(top, selected, reads) {
|
|
824
|
+
for (const [protocol, lane] of top.lanes) {
|
|
825
|
+
const at = selected.findIndex(stream => stream.protocol === protocol);
|
|
826
|
+
const read = at >= 0 ? reads[at] : null;
|
|
827
|
+
if (read) {
|
|
828
|
+
const kept = read.previews ? [] : read.items;
|
|
829
|
+
reads[at] = { ...read, items: [...lane, ...kept], layouts: [...lane.map(() => ordinary), ...kept.map((_item, index) => read.layouts?.[index] ?? windowLayout(read.layout, index))] };
|
|
830
|
+
}
|
|
831
|
+
else if (this.streams.some(stream => stream.protocol === protocol))
|
|
832
|
+
reads.push({ items: lane, layout: ordinary, partial: false });
|
|
833
|
+
}
|
|
834
|
+
const carried = new Map(reads.flatMap(read => read.items.map(item => [item.key, item])));
|
|
835
|
+
const seen = (row) => !!this.appSeen && row.seenRefs.some(ref => this.appSeen.has(ref));
|
|
836
|
+
const rows = top.impressions || !this.appSeen ? top.rows : [...top.rows.filter(row => !seen(row)), ...top.rows.filter(seen)];
|
|
837
|
+
const placed = [];
|
|
838
|
+
for (const row of rows) {
|
|
839
|
+
const found = top.posts.get(row.key) ?? carried.get(row.key);
|
|
840
|
+
if (!found || this.seen.has(row.key))
|
|
841
|
+
continue;
|
|
842
|
+
const position = placed.length;
|
|
843
|
+
placed.push({ ...found, top: { ref: row.ref, position, block: position < 10 ? 'top' : 'below' } });
|
|
844
|
+
this.seen.add(row.key);
|
|
845
|
+
this.topKeys.add(row.key);
|
|
846
|
+
}
|
|
847
|
+
return placed;
|
|
531
848
|
}
|
|
532
849
|
/** The story's members as feed items, read once per story and bounded to three seconds; a failed read is simply none. */
|
|
533
850
|
memberPosts(cluster) {
|
|
@@ -645,11 +962,13 @@ export class DiscoveryResource {
|
|
|
645
962
|
protocols;
|
|
646
963
|
curator;
|
|
647
964
|
http;
|
|
648
|
-
|
|
965
|
+
feeds;
|
|
966
|
+
constructor(posts, protocols, curator, http, feeds) {
|
|
649
967
|
this.posts = posts;
|
|
650
968
|
this.protocols = protocols;
|
|
651
969
|
this.curator = curator;
|
|
652
970
|
this.http = http;
|
|
971
|
+
this.feeds = feeds;
|
|
653
972
|
}
|
|
654
973
|
/**
|
|
655
974
|
* Suggestions for the signed-in viewer from the app's one recommender: active or warm members, groups
|
|
@@ -726,12 +1045,23 @@ export class DiscoveryResource {
|
|
|
726
1045
|
* Discover's feed session. `compass` (a Politics choice, as `posts.list` takes it) goes on every Minds read, whose
|
|
727
1046
|
* pages then carry `meta.compass_filter`; the open networks' reads and the radar are not filtered by it, so a view
|
|
728
1047
|
* that leaves posts out (Hide politics, Choose sides) is best read with `source: 'minds'`.
|
|
1048
|
+
*
|
|
1049
|
+
* `top` opens the first page with the top of the feed (`feeds.top`, with the same `compass`, unless `top.read` is
|
|
1050
|
+
* given); `seen` leaves what the member saw out of the hot pages; `seenClusters` and `bucket` rotate the story when
|
|
1051
|
+
* the top names none (DiscoveryFeedSessionOptions). With none of them the session is what it was before they existed.
|
|
729
1052
|
*/
|
|
730
1053
|
createFeed(options) {
|
|
731
1054
|
const compass = options.compass;
|
|
732
1055
|
const posts = compass ? { list: params => this.posts.list({ ...params, compass }) } : this.posts;
|
|
1056
|
+
const feeds = this.feeds;
|
|
1057
|
+
const http = this.http;
|
|
1058
|
+
const read = options.top?.read ?? (feeds ? params => feeds.top({ ...params, ...(compass ? { compass } : {}) }, NO_RETRY) : undefined);
|
|
1059
|
+
// A top item the first hot page does not carry is read by id once, never retried: the page holds its own deadline.
|
|
1060
|
+
const post = options.top?.post ?? (http ? (id) => http.get(`/posts/${encodeURIComponent(id)}`, undefined, NO_RETRY) : undefined);
|
|
1061
|
+
const top = options.top && read ? { ...options.top, read, ...(post ? { post } : {}) } : undefined;
|
|
733
1062
|
// The radar rides along by default; an older server answers 404 and the session simply shows no story module.
|
|
734
|
-
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) })
|
|
1063
|
+
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
|
+
...(options.bucket !== undefined ? { bucket: options.bucket } : {}) });
|
|
735
1065
|
}
|
|
736
1066
|
}
|
|
737
1067
|
/** A read bounded to three seconds; a late or failed one resolves to `fallback`, never an error. */
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { HttpClient } from '../client.js';
|
|
2
|
+
import type { CompassFilterMeta } from '../types.js';
|
|
3
|
+
/**
|
|
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
|
+
* `feeds.top` and `feeds.stories` read it for a member with their own key of the app; `feeds.publicTop` reads a
|
|
6
|
+
* visitor's top keylessly. A feed session (`discovery.createFeed({ top })`) opens its first page with it.
|
|
7
|
+
*
|
|
8
|
+
* Every tap target is in the app: a Minds post by id, an open-network post by `/social/post/<protocol>/<id>`, a story
|
|
9
|
+
* by id. Nothing here is a URL on another network.
|
|
10
|
+
*/
|
|
11
|
+
/** The open networks whose lanes a top carries. */
|
|
12
|
+
export type FeedTopNetwork = 'bluesky' | 'activitypub' | 'nostr';
|
|
13
|
+
export declare const FEED_TOP_NETWORKS: readonly FeedTopNetwork[];
|
|
14
|
+
/** Where a tap on an item goes, always in the app. `path` is the app's own page of a network post, never its URL there. */
|
|
15
|
+
export type FeedTopTarget = {
|
|
16
|
+
kind: 'post';
|
|
17
|
+
id: string;
|
|
18
|
+
} | {
|
|
19
|
+
kind: 'network_post';
|
|
20
|
+
protocol: string;
|
|
21
|
+
nativeId: string;
|
|
22
|
+
path: string;
|
|
23
|
+
readRef?: string;
|
|
24
|
+
};
|
|
25
|
+
/** Why an item is where it is. */
|
|
26
|
+
export interface FeedTopExplain {
|
|
27
|
+
score: number;
|
|
28
|
+
engagement: {
|
|
29
|
+
source: string;
|
|
30
|
+
rate: number;
|
|
31
|
+
};
|
|
32
|
+
media: string;
|
|
33
|
+
origin: string;
|
|
34
|
+
/** Where the item came from: the Minds ranking, Watch's list, a radar story, the member's follows, or a network's trends. */
|
|
35
|
+
source: 'minds' | 'watch' | 'radar' | 'following' | 'trends';
|
|
36
|
+
/** 'safe' for everyone; 'open' only for a member who turned safe defaults off. */
|
|
37
|
+
label: 'safe' | 'open';
|
|
38
|
+
cluster: string | null;
|
|
39
|
+
topic: string | null;
|
|
40
|
+
/** 'demoted': the member viewed it in the last 6 hours (it sits below rank 20). A visitor's items are all 'fresh'. */
|
|
41
|
+
impression: 'fresh' | 'demoted';
|
|
42
|
+
}
|
|
43
|
+
/** One item of the top: a Minds post (`ref` is its id) or an open-network post (`ref` is '<protocol>/<nativeId>'). */
|
|
44
|
+
export interface FeedTopItem {
|
|
45
|
+
ref: string;
|
|
46
|
+
kind: 'post' | 'network_post';
|
|
47
|
+
authorId: string;
|
|
48
|
+
target: FeedTopTarget;
|
|
49
|
+
position: number;
|
|
50
|
+
/** 'top' for the first 10: send `block: 'top'` and the position with a visible view of one (an impression). */
|
|
51
|
+
block: 'top' | 'below';
|
|
52
|
+
explain: FeedTopExplain;
|
|
53
|
+
}
|
|
54
|
+
/** What an app draws for an open-network lane entry. No URL on the post's network: the app opens it by `target.path`. */
|
|
55
|
+
export interface FeedTopNetworkCard {
|
|
56
|
+
text: string;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
author: {
|
|
59
|
+
nativeId: string;
|
|
60
|
+
handle: string;
|
|
61
|
+
displayName: string;
|
|
62
|
+
avatarUrl: string | null;
|
|
63
|
+
};
|
|
64
|
+
media: Array<{
|
|
65
|
+
type: 'image' | 'video';
|
|
66
|
+
url: string;
|
|
67
|
+
alt: string;
|
|
68
|
+
poster?: string;
|
|
69
|
+
width?: number;
|
|
70
|
+
height?: number;
|
|
71
|
+
}>;
|
|
72
|
+
counts: {
|
|
73
|
+
likes: number | null;
|
|
74
|
+
replies: number | null;
|
|
75
|
+
reposts: number | null;
|
|
76
|
+
};
|
|
77
|
+
sensitive: boolean;
|
|
78
|
+
/** The post quotes another; read the quote when the post is opened. */
|
|
79
|
+
hasQuote: boolean;
|
|
80
|
+
}
|
|
81
|
+
/** One entry of an open-network lane: up to six per network, ranked by engagement and rotated by impressions. */
|
|
82
|
+
export interface FeedTopNetworkEntry extends FeedTopItem {
|
|
83
|
+
kind: 'network_post';
|
|
84
|
+
protocol: FeedTopNetwork;
|
|
85
|
+
card: FeedTopNetworkCard;
|
|
86
|
+
}
|
|
87
|
+
/** A story as the top names it: its id and in-app target, never an outlet's or a network's URL. */
|
|
88
|
+
export interface FeedStory {
|
|
89
|
+
id: string;
|
|
90
|
+
/** 'cluster:<id>': what an impression of the story names. */
|
|
91
|
+
ref: string;
|
|
92
|
+
target: {
|
|
93
|
+
kind: 'story';
|
|
94
|
+
id: string;
|
|
95
|
+
};
|
|
96
|
+
label: string;
|
|
97
|
+
kind: 'breaking' | 'viral' | 'story';
|
|
98
|
+
breaking: boolean;
|
|
99
|
+
theme: string | null;
|
|
100
|
+
sources: Partial<Record<'outlet' | 'hackernews' | 'bluesky' | 'activitypub' | 'nostr' | 'minds', number>>;
|
|
101
|
+
media: {
|
|
102
|
+
images: number;
|
|
103
|
+
videos: number;
|
|
104
|
+
};
|
|
105
|
+
freshness: {
|
|
106
|
+
newestAt: string;
|
|
107
|
+
oldestAt: string;
|
|
108
|
+
};
|
|
109
|
+
explain: {
|
|
110
|
+
score: number;
|
|
111
|
+
parts: {
|
|
112
|
+
ageHours: number;
|
|
113
|
+
engagement: number;
|
|
114
|
+
media: number;
|
|
115
|
+
kind: number;
|
|
116
|
+
freshness: number;
|
|
117
|
+
};
|
|
118
|
+
impression: 'fresh' | 'demoted';
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export interface FeedTopMeta {
|
|
122
|
+
ref: string;
|
|
123
|
+
poolBuiltAt: string;
|
|
124
|
+
poolSize: number;
|
|
125
|
+
/** Fewer items than asked for (a thin pool, the caps, or only items the member saw recently). */
|
|
126
|
+
short: boolean;
|
|
127
|
+
/** `impressions`: 'on' when the order uses what the member saw; otherwise the order is the pool's, jittered per session. */
|
|
128
|
+
flag: {
|
|
129
|
+
top: 'on';
|
|
130
|
+
impressions: 'on' | 'off' | 'unavailable';
|
|
131
|
+
};
|
|
132
|
+
stoppedBy: 'pool' | 'caps' | 'seen' | null;
|
|
133
|
+
/** 'safe' for a member on safe defaults, 'open' for one who turned them off. */
|
|
134
|
+
safety: 'safe' | 'open';
|
|
135
|
+
mindsSource: 'member' | 'anonymous' | 'none' | 'following';
|
|
136
|
+
networks: Record<FeedTopNetwork, 'ok' | 'unavailable'> | null;
|
|
137
|
+
compass_filter?: CompassFilterMeta;
|
|
138
|
+
}
|
|
139
|
+
/** GET /feeds/top. `networks` is null on a feed with no lanes (Following, a story); `lead` is null when the feed has no story. */
|
|
140
|
+
export interface FeedTopResponse {
|
|
141
|
+
items: FeedTopItem[];
|
|
142
|
+
networks: Record<FeedTopNetwork, FeedTopNetworkEntry[]> | null;
|
|
143
|
+
lead: FeedStory | null;
|
|
144
|
+
meta: FeedTopMeta;
|
|
145
|
+
}
|
|
146
|
+
export interface FeedStoriesResponse {
|
|
147
|
+
data: FeedStory[];
|
|
148
|
+
meta: {
|
|
149
|
+
after: string | null;
|
|
150
|
+
limit: number;
|
|
151
|
+
short: boolean;
|
|
152
|
+
flag: {
|
|
153
|
+
top: 'on';
|
|
154
|
+
impressions: 'on' | 'off' | 'unavailable';
|
|
155
|
+
};
|
|
156
|
+
compass_filter?: CompassFilterMeta;
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
export interface PublicFeedTopMeta {
|
|
160
|
+
ref: string;
|
|
161
|
+
/** The 15-minute bucket the page was drawn for: the same for every visitor in it. */
|
|
162
|
+
bucket: number;
|
|
163
|
+
poolBuiltAt: string | null;
|
|
164
|
+
poolSize: number;
|
|
165
|
+
short: boolean;
|
|
166
|
+
flag: {
|
|
167
|
+
top: 'on';
|
|
168
|
+
};
|
|
169
|
+
compass_filter?: CompassFilterMeta;
|
|
170
|
+
}
|
|
171
|
+
/** A visitor's top (`feeds.publicTop`): safe items only, the same for every visitor in one 15-minute bucket. */
|
|
172
|
+
export interface PublicFeedTopResponse {
|
|
173
|
+
items: FeedTopItem[];
|
|
174
|
+
networks: Record<FeedTopNetwork, FeedTopNetworkEntry[]> | null;
|
|
175
|
+
lead: FeedStory | null;
|
|
176
|
+
meta: PublicFeedTopMeta;
|
|
177
|
+
}
|
|
178
|
+
export interface FeedTopParams {
|
|
179
|
+
/** `builtin:all`, `category:<a browse category id>`, `following`, `watch` or `story:<a 64-character cluster id>`. */
|
|
180
|
+
ref: string;
|
|
181
|
+
/** 1..25, default 25. */
|
|
182
|
+
limit?: number;
|
|
183
|
+
/** Opaque per app session ([A-Za-z0-9_-], up to 64): the same token keeps one session's order stable across reads. */
|
|
184
|
+
session?: string;
|
|
185
|
+
/** A Politics choice, as `posts.list` takes it (refused on `following`). */
|
|
186
|
+
compass?: string;
|
|
187
|
+
}
|
|
188
|
+
export interface FeedStoriesParams {
|
|
189
|
+
/** The story the member is on (a 64-character cluster id): left out of the next ones. */
|
|
190
|
+
after?: string;
|
|
191
|
+
/** 1..10, default 10. */
|
|
192
|
+
limit?: number;
|
|
193
|
+
compass?: string;
|
|
194
|
+
}
|
|
195
|
+
export interface PublicFeedTopParams {
|
|
196
|
+
/** The app's project id. */
|
|
197
|
+
projectId: string;
|
|
198
|
+
/** `all` (default), `watch` or `category:<a browse category id>`. */
|
|
199
|
+
ref?: string;
|
|
200
|
+
/** 1..25 (the server's default is 25). */
|
|
201
|
+
limit?: number;
|
|
202
|
+
/** `all` or `hide`: a visitor's Politics choice. */
|
|
203
|
+
compass?: string;
|
|
204
|
+
}
|
|
205
|
+
export declare class FeedsResource {
|
|
206
|
+
private readonly http;
|
|
207
|
+
constructor(http: HttpClient);
|
|
208
|
+
/**
|
|
209
|
+
* The top of a feed for the signed-in member, with their own key of the app: the items they are most likely to engage
|
|
210
|
+
* with, rotated by what they have seen, the open-network lanes and the lead story. Off (404) until the app's operator
|
|
211
|
+
* turns it on; a 503 `feed_top_unavailable` carries Retry-After. Always private: never cached for anyone else.
|
|
212
|
+
*/
|
|
213
|
+
top(params: FeedTopParams, options?: {
|
|
214
|
+
retry?: boolean;
|
|
215
|
+
}): Promise<FeedTopResponse>;
|
|
216
|
+
/** The stories the member should meet next (the story tail), by the same ranker as the top's lead. */
|
|
217
|
+
stories(params?: FeedStoriesParams, options?: {
|
|
218
|
+
retry?: boolean;
|
|
219
|
+
}): Promise<FeedStoriesResponse>;
|
|
220
|
+
/**
|
|
221
|
+
* A visitor's top of All, a category or Watch (tRPC `publicDiscovery.feedTop`): safe items only, the same for every
|
|
222
|
+
* visitor in one 15-minute bucket. Read with no credential at all (no key, no cookie: `credentials: 'omit'`), so the
|
|
223
|
+
* edge can hold it; a credentialed read would be private and uncached. Off (404) until the app's operator turns it on.
|
|
224
|
+
*/
|
|
225
|
+
publicTop(params: PublicFeedTopParams, options?: {
|
|
226
|
+
timeoutMs?: number;
|
|
227
|
+
}): Promise<PublicFeedTopResponse>;
|
|
228
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { NotFoundError, RecursivError } from '../errors.js';
|
|
2
|
+
export const FEED_TOP_NETWORKS = ['bluesky', 'activitypub', 'nostr'];
|
|
3
|
+
export class FeedsResource {
|
|
4
|
+
http;
|
|
5
|
+
constructor(http) {
|
|
6
|
+
this.http = http;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The top of a feed for the signed-in member, with their own key of the app: the items they are most likely to engage
|
|
10
|
+
* with, rotated by what they have seen, the open-network lanes and the lead story. Off (404) until the app's operator
|
|
11
|
+
* turns it on; a 503 `feed_top_unavailable` carries Retry-After. Always private: never cached for anyone else.
|
|
12
|
+
*/
|
|
13
|
+
top(params, options) {
|
|
14
|
+
return this.http.get('/feeds/top', { ref: params.ref, limit: params.limit, session: params.session, compass: params.compass }, options);
|
|
15
|
+
}
|
|
16
|
+
/** The stories the member should meet next (the story tail), by the same ranker as the top's lead. */
|
|
17
|
+
stories(params = {}, options) {
|
|
18
|
+
return this.http.get('/feeds/stories', { after: params.after, limit: params.limit, compass: params.compass }, options);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* A visitor's top of All, a category or Watch (tRPC `publicDiscovery.feedTop`): safe items only, the same for every
|
|
22
|
+
* visitor in one 15-minute bucket. Read with no credential at all (no key, no cookie: `credentials: 'omit'`), so the
|
|
23
|
+
* edge can hold it; a credentialed read would be private and uncached. Off (404) until the app's operator turns it on.
|
|
24
|
+
*/
|
|
25
|
+
async publicTop(params, options) {
|
|
26
|
+
// One fixed key order, so equal reads are one edge-cached URL.
|
|
27
|
+
const input = { projectId: params.projectId, ref: params.ref ?? 'all', ...(params.limit !== undefined ? { limit: params.limit } : {}),
|
|
28
|
+
...(params.compass ? { compass: params.compass } : {}) };
|
|
29
|
+
const url = `${this.http.baseOrigin}/api/trpc/publicDiscovery.feedTop?input=${encodeURIComponent(JSON.stringify(input))}`;
|
|
30
|
+
const controller = new AbortController();
|
|
31
|
+
const timer = setTimeout(() => controller.abort(), options?.timeoutMs ?? this.http.requestTimeoutMs);
|
|
32
|
+
try {
|
|
33
|
+
let response;
|
|
34
|
+
try {
|
|
35
|
+
response = await fetch(url, { method: 'GET', headers: { Accept: 'application/json' }, credentials: 'omit', signal: controller.signal });
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
throw new RecursivError(0, { type: 'api_error', code: controller.signal.aborted ? 'timeout' : 'network_error',
|
|
39
|
+
message: controller.signal.aborted ? 'The visitor top did not answer in time.' : `The visitor top could not be reached (${error instanceof Error ? error.message : String(error)}).` });
|
|
40
|
+
}
|
|
41
|
+
let body = null;
|
|
42
|
+
try {
|
|
43
|
+
body = await response.json();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
body = null;
|
|
47
|
+
}
|
|
48
|
+
const data = body?.result?.data;
|
|
49
|
+
if (response.ok && data && typeof data === 'object')
|
|
50
|
+
return data;
|
|
51
|
+
const error = body?.error && typeof body.error === 'object' ? body.error : undefined;
|
|
52
|
+
const said = [error?.json?.message, error?.message].find((value) => typeof value === 'string' && value.length > 0);
|
|
53
|
+
const code = typeof error?.data?.code === 'string' ? error.data.code.toLowerCase() : 'unknown_error';
|
|
54
|
+
const failure = { type: response.status === 404 ? 'not_found_error' : 'api_error', code, message: said ?? `HTTP ${response.status}` };
|
|
55
|
+
throw response.status === 404 ? new NotFoundError(failure) : new RecursivError(response.ok ? 502 : response.status, failure);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -35,8 +35,9 @@ export interface SearchPerson {
|
|
|
35
35
|
pro: boolean;
|
|
36
36
|
founder: boolean;
|
|
37
37
|
/**
|
|
38
|
-
* The account wears the display name of a verified or founder account it is not
|
|
39
|
-
*
|
|
38
|
+
* The account wears the display name of a verified or founder account it is not. Such an account is listed only when
|
|
39
|
+
* the query asks for its handle ("@handle"), after every account that is not such a copy (an older API listed it
|
|
40
|
+
* last for any query). Label it as not the account at `name_matches_verified_handle`. Absent from an older API.
|
|
40
41
|
*/
|
|
41
42
|
name_matches_verified?: boolean;
|
|
42
43
|
name_matches_verified_handle?: string | null;
|
package/dist/types.d.ts
CHANGED
|
@@ -1261,8 +1261,9 @@ export interface ProfileSearchResult extends Profile {
|
|
|
1261
1261
|
pro: boolean;
|
|
1262
1262
|
founder: boolean;
|
|
1263
1263
|
/**
|
|
1264
|
-
* The account wears the display name of a verified or founder account it is not
|
|
1265
|
-
*
|
|
1264
|
+
* The account wears the display name of a verified or founder account it is not. Such an account is listed only when
|
|
1265
|
+
* the query asks for its handle ("@handle"), after every account that is not such a copy (an older API listed it
|
|
1266
|
+
* last for any query). Label it as not the account at `name_matches_verified_handle`. Absent from an older API.
|
|
1266
1267
|
*/
|
|
1267
1268
|
name_matches_verified?: boolean;
|
|
1268
1269
|
name_matches_verified_handle?: string | null;
|
package/llm.md
CHANGED
|
@@ -639,6 +639,19 @@ await r.bookmarks.unsave(key) // or uns
|
|
|
639
639
|
|
|
640
640
|
Newest saved first. Pages can be short while they skip posts the member can no longer read: page on `meta.next_cursor`, not length. Needs the member's own app key (else 403 `project_binding_required`). 503 `bookmarks_unavailable` means the server has no synced bookmarks yet: keep saves on the device (pass `{ retry: false }` to the probe so the 503 is not retried).
|
|
641
641
|
|
|
642
|
+
### r.feeds — The top of a feed (member and visitor)
|
|
643
|
+
|
|
644
|
+
```typescript
|
|
645
|
+
const top = await r.feeds.top({ ref: 'builtin:all', limit: 10, session }) // member's own app key; category:<id>, following, watch, story:<id>
|
|
646
|
+
// top.items: { ref, kind, target, position, block: 'top' | 'below', explain }; top.networks: { bluesky, activitypub, nostr } lanes with a card each
|
|
647
|
+
// top.lead: the story to open with; every target is in the app (post id, /social/post/<protocol>/<id>, story id), never a URL out
|
|
648
|
+
const { data: next } = await r.feeds.stories({ after: storyId }) // the story tail, same ranker as the lead
|
|
649
|
+
const page = await r.feeds.publicTop({ projectId, ref: 'all' }) // a visitor's top: safe items only, keyless, edge-cacheable
|
|
650
|
+
const feed = r.discovery.createFeed({ networks, top: { ref: 'builtin:all', session }, seen: seenRefs })
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
Each answers 404 while the app's operator has it off; `feeds.top` is private to the member and 503 `feed_top_unavailable` carries Retry-After. With a visible view of a top item, send `block: 'top'` and its position. In a feed session the top's read starts with page 0's reads and the top holds the page at most 1.5 s from its start, the by-id reads of its posts included; off, failed or late, the session is unchanged, and the radar's story reads its posts as soon as the radar answers either way. Page 0 then opens with the top's lead story and items (each item carries `top: { ref, position, block }`), its lanes replace the networks' first reads, and no hot page repeats a top item. `seen` (Minds ids and `<protocol>/<nativeId>` refs) leaves seen posts out of the hot pages, walking on past a page left under five posts (up to three more). `seenClusters` (`Map<id, { n, clicked }>`) and a visitor's `bucket` rotate the story when the top names none (`pickRadarCluster`).
|
|
654
|
+
|
|
642
655
|
### r.search
|
|
643
656
|
|
|
644
657
|
```typescript
|