@recursiv/sdk 0.7.70 → 0.7.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ import { DISCOVERY_RAIL_LANE, DISCOVERY_STORY_LANE, DiscoveryEngagementMerge, discoveryLeadable, discoveryModuleLane, discoveryShowsMedia, discoveryTopPlace, isColumnModule, isDiscoveryPostModule, poolDiscoveryGalleries, promotedDiscoveryVideoItems, } from './discovery-merge.js';
2
+ // ──────────────────────────────────────────────────────────────────────────
3
+ // Discover's first screen, composed from every source that answered in time.
4
+ // Ported from the Minds app (lib/firstScreen, 2026-10-04).
5
+ //
6
+ // Live (Minds, 2026-09-27) five of six categories opened on one network and
7
+ // none opened on a post by a Minds member: each source's page was drawn the
8
+ // moment it landed, so the open networks' fast trend reads filled the screen
9
+ // before Minds landed. The first load of a progressive feed
10
+ // (createProgressiveFeed) holds its sources' parts until Minds and one other
11
+ // network have answered, or its wait has passed since the first of them
12
+ // answered, and hands them on as one part in this order:
13
+ //
14
+ // - it leads with a post that opens on a picture or a video (one post, or a
15
+ // gallery whose first card has one), from the best of the screen by
16
+ // engagement (FIRST_SCREEN_LEAD_WINDOW). A story, a suggestion rail, a
17
+ // video strip or a conversation never leads, and neither does a sensitive
18
+ // Minds post or an open-network post that links a porn site;
19
+ // - then the posts by engagement, every source's together
20
+ // (DiscoveryEngagementMerge), with the network mix as a soft constraint. A
21
+ // column of several posts goes in from FIRST_SCREEN_FEATURE_SLOT on;
22
+ // - the row under the lead is the best post from another network, while the
23
+ // screen has one of the same standing (FIRST_SCREEN_NETWORK_ROWS): a
24
+ // desktop or phone screen shows the lead and that one row, and live
25
+ // (2026-10-05) 7 of 8 category screens showed two posts of one network;
26
+ // - a story goes in as the third row (FIRST_SCREEN_STORY_SLOT), never right
27
+ // under another story, and a suggestion rail as the fifth
28
+ // (FIRST_SCREEN_RAIL_SLOT);
29
+ // - nothing is dropped. The sources' galleries are pooled into one.
30
+ //
31
+ // With the top of the feed (DiscoveryFeedSessionOptions.top: the posts a
32
+ // member is most likely to engage with, each marked with its place), the
33
+ // top's posts go first, in the top's order. A video may lead too: when the
34
+ // top's first post is a video that can play, it is the screen's first row.
35
+ // ──────────────────────────────────────────────────────────────────────────
36
+ /** The row a story takes when there are posts enough above it: under the lead and one other post. */
37
+ export const FIRST_SCREEN_STORY_SLOT = 2;
38
+ /** The row a suggestion rail takes. */
39
+ export const FIRST_SCREEN_RAIL_SLOT = 4;
40
+ /** How far down the screen's engagement order a post may be and still lead it for its picture: about one screen. */
41
+ export const FIRST_SCREEN_LEAD_WINDOW = 8;
42
+ /** The first row a column of several posts (a conversation, a thread) may take. */
43
+ export const FIRST_SCREEN_FEATURE_SLOT = 3;
44
+ /** The first rows that each come from a network no row above them does, while the screen has one: the lead and the row under it. */
45
+ export const FIRST_SCREEN_NETWORK_ROWS = 2;
46
+ const isPostItem = (item) => item.kind === 'minds' || item.kind === 'social';
47
+ /**
48
+ * A module that opens on a picture or a video: one post that shows one, or a gallery whose first card does. A column
49
+ * of posts whose picture is further down opens on text, and a video strip is not a post's picture.
50
+ */
51
+ export function moduleOpensOnPicture(module) {
52
+ const first = module.items[0];
53
+ if (!first || !isPostItem(first) || !discoveryShowsMedia(first))
54
+ return false;
55
+ const presentation = module.presentation;
56
+ return module.items.length === 1 ? presentation !== 'video' : presentation === 'visual';
57
+ }
58
+ /** One post the reader may be shown first whose video can play. */
59
+ function opensOnVideo(module) {
60
+ const first = module.items[0];
61
+ return module.items.length === 1 && !!first && isPostItem(first) && discoveryLeadable(first) && promotedDiscoveryVideoItems([first]).length > 0;
62
+ }
63
+ /** The top's first post (the smallest place among the modules), when it is a video that can play: it leads the screen. */
64
+ export function discoveryTopVideoLead(modules) {
65
+ let first;
66
+ let place = Number.POSITIVE_INFINITY;
67
+ for (const module of modules) {
68
+ const at = discoveryTopPlace(module);
69
+ if (at !== null && at < place) {
70
+ first = module;
71
+ place = at;
72
+ }
73
+ }
74
+ return first && opensOnVideo(first) ? first : undefined;
75
+ }
76
+ /** A module of posts only, every one of them one the reader may be shown first, and never a video strip. */
77
+ function mayLead(module) {
78
+ const presentation = module.presentation;
79
+ const posts = module.items.filter(isPostItem);
80
+ return presentation !== 'video' && posts.length > 0 && posts.length === module.items.length && posts.every(discoveryLeadable);
81
+ }
82
+ /**
83
+ * The first screen's modules, from the parts that landed (in landing order), in the order described above. Pure for a
84
+ * given `now` (the time the posts' ages are counted to): the same parts give the same screen.
85
+ * `keepLead`: the parts' first module is the lead already (a composed screen a client recomposes after moving its
86
+ * clips into a strip of its own).
87
+ */
88
+ export function composeFirstScreen(parts, { keepLead = false, now = Date.now() } = {}) {
89
+ const modules = parts.flatMap(part => part.modules);
90
+ if (modules.length < 2)
91
+ return [...modules];
92
+ const kept = keepLead ? modules[0] : undefined;
93
+ // Equal scores keep each source's own order: a post's place among its source's posts, then its source, Minds first
94
+ // and the others as they landed. A pooled gallery, and a card of one that goes in as a post, take the gallery's place.
95
+ const lanes = [...new Set(modules.filter(isDiscoveryPostModule).map(discoveryModuleLane))].sort((a, b) => (a === 'minds' ? -1 : b === 'minds' ? 1 : 0));
96
+ const standing = new Map();
97
+ for (const part of parts) {
98
+ let place = 0;
99
+ for (const module of part.modules) {
100
+ if (!isDiscoveryPostModule(module))
101
+ continue;
102
+ standing.set(module.id, place++ * Math.max(1, lanes.length) + lanes.indexOf(discoveryModuleLane(module)));
103
+ }
104
+ }
105
+ const order = (module, index) => standing.get(module.id) ?? standing.get(module.id.split('~')[0]) ?? modules.length * lanes.length + index;
106
+ const pooled = poolDiscoveryGalleries(modules, now, kept);
107
+ const merge = new DiscoveryEngagementMerge(pooled.filter(isDiscoveryPostModule), { now, order });
108
+ const stories = pooled.filter(module => module !== kept && discoveryModuleLane(module) === DISCOVERY_STORY_LANE);
109
+ const rails = pooled.filter(module => module !== kept && discoveryModuleLane(module) === DISCOVERY_RAIL_LANE);
110
+ // The lead: the top's first post when it is a video that can play; else the best post near the top of the screen's
111
+ // engagement order that opens on a picture or a video; else the best single post; else the best post.
112
+ const best = merge.rest().filter(mayLead);
113
+ const lead = kept
114
+ ?? discoveryTopVideoLead(pooled)
115
+ ?? best.slice(0, FIRST_SCREEN_LEAD_WINDOW).find(moduleOpensOnPicture)
116
+ ?? best.find(module => !isColumnModule(module)) ?? best[0];
117
+ const out = [];
118
+ if (lead) {
119
+ merge.remove(lead);
120
+ out.push(lead);
121
+ }
122
+ const storyFits = () => {
123
+ const above = out[out.length - 1];
124
+ return out.length >= FIRST_SCREEN_STORY_SLOT && !!above && discoveryModuleLane(above) !== DISCOVERY_STORY_LANE;
125
+ };
126
+ for (;;) {
127
+ const [story] = stories;
128
+ const [rail] = rails;
129
+ if (story && storyFits()) {
130
+ out.push(story);
131
+ stories.shift();
132
+ continue;
133
+ }
134
+ if (rail && out.length >= FIRST_SCREEN_RAIL_SLOT && discoveryModuleLane(out[out.length - 1]) !== DISCOVERY_RAIL_LANE) {
135
+ out.push(rail);
136
+ rails.shift();
137
+ continue;
138
+ }
139
+ // Above FIRST_SCREEN_FEATURE_SLOT a column waits for the single posts of its standing.
140
+ const post = merge.take(out, { singles: out.length < FIRST_SCREEN_FEATURE_SLOT, newNetwork: out.length < FIRST_SCREEN_NETWORK_ROWS });
141
+ if (post) {
142
+ out.push(post);
143
+ continue;
144
+ }
145
+ break;
146
+ }
147
+ // Too few posts to reach their rows: a story or a rail still waiting goes in at the end.
148
+ return [...out, ...stories, ...rails];
149
+ }
@@ -0,0 +1,144 @@
1
+ import type { DiscoveryItem, DiscoveryModule, DiscoveryPostItem } from './discovery.js';
2
+ /** A radar story's lane (discoveryModuleLane). */
3
+ export declare const DISCOVERY_STORY_LANE = "story";
4
+ /** A suggestion rail's lane (groups, people, tags). */
5
+ export declare const DISCOVERY_RAIL_LANE = "rail";
6
+ /** Rows the network share is counted over... */
7
+ export declare const DISCOVERY_NETWORK_WINDOW = 20;
8
+ /** ...and the most of them one network may take (40%). */
9
+ export declare const DISCOVERY_NETWORK_ROWS_MAX = 8;
10
+ /** The most rows in a row one network may take. */
11
+ export declare const DISCOVERY_NETWORK_RUN_MAX = 3;
12
+ /** The home network's floor: after this many rows in a row with no Minds member's post, the next row is Minds' best of the tier. */
13
+ export declare const DISCOVERY_MINDS_GAP_MAX = 5;
14
+ /** Rows that separate two modules with the same title: more than a screen. */
15
+ export declare const DISCOVERY_TITLE_SCREEN_ROWS = 6;
16
+ /** A post younger than this with no engagement yet is fresh: it scores DISCOVERY_FRESH_NORM, not zero. */
17
+ export declare const DISCOVERY_FRESH_MS: number;
18
+ /** The score halves with every this many hours of a post's age. */
19
+ export declare const DISCOVERY_HALF_LIFE_HOURS = 24;
20
+ /** A video that can play, and a picture, against a text post with the same engagement. */
21
+ export declare const DISCOVERY_VIDEO_BOOST = 1.5;
22
+ export declare const DISCOVERY_PICTURE_BOOST = 1.15;
23
+ /** The least a network's (or the batch's) typical engagement is taken to be, in points. */
24
+ export declare const DISCOVERY_REFERENCE_FLOOR = 5;
25
+ /** A fresh post with no engagement yet, and a post whose network reports no counts: against a typical post's 1. */
26
+ export declare const DISCOVERY_FRESH_NORM = 0.5;
27
+ export declare const DISCOVERY_UNKNOWN_NORM = 0.5;
28
+ /** A pooled gallery's cards, and the most of them from one network. */
29
+ export declare const DISCOVERY_POOLED_GALLERY_MAX = 12;
30
+ export declare const DISCOVERY_GALLERY_NETWORK_MAX = 5;
31
+ /**
32
+ * The network a post in Minds' own lane comes from. Minds' feeds carry
33
+ * accounts that fediverse servers federated into legacy minds.com, stored as
34
+ * ordinary accounts with a `name@host` handle; a post by one is ActivityPub's.
35
+ * The server's `isFederatedHandle` rule: a Minds username never carries an @.
36
+ */
37
+ export declare function mindsPostNetwork(post: {
38
+ author?: {
39
+ username?: string | null;
40
+ } | null;
41
+ } | null | undefined): 'minds' | 'activitypub';
42
+ /** The network a post item comes from: 'minds' only for a Minds member's own post. */
43
+ export declare function discoveryPostNetwork(item: DiscoveryPostItem): string;
44
+ /** Whose row a module is: the network of the post it leads with ('minds' for a Minds member), the story lane or the rail lane. */
45
+ export declare function discoveryModuleLane(module: DiscoveryModule): string;
46
+ /** A module of posts (a post, a gallery, a conversation, a strip of clips): what the merge orders. */
47
+ export declare function isDiscoveryPostModule(row: unknown): row is DiscoveryModule;
48
+ /** What a row is titled, as a key: a featured module's kind and theme, a rail's kind. Null for a row with no title of its own. */
49
+ export declare function discoveryRowTitle(row: unknown): string | null;
50
+ /**
51
+ * A post the reader may be shown first: a Minds post nobody masks (isSensitivePost), and an open-network post that
52
+ * links no porn site (their cards carry no NSFW flag).
53
+ */
54
+ export declare function discoveryLeadable(item: DiscoveryPostItem): boolean;
55
+ /** A post with a picture or a video of its own that the reader sees unblurred. */
56
+ export declare function discoveryShowsMedia(item: DiscoveryPostItem): boolean;
57
+ /** A player found this video file (or HLS playlist) will not play: no Discover module promotes it, and it gets no video boost. */
58
+ export declare function reportDiscoveryVideoUnavailable(url: string): void;
59
+ /** Forget every report (a test seam, or a new viewer). */
60
+ export declare function resetDiscoveryVideoReports(): void;
61
+ /**
62
+ * A media entry a module may feature as a video: a video (by its type, or by its address when it has none) with a file
63
+ * to play, not a poster row (a row typed video whose file is a picture), not still encoding or failed by its own
64
+ * `status`, and not reported unavailable (reportDiscoveryVideoUnavailable).
65
+ */
66
+ export declare function promotableDiscoveryVideo(entry: unknown): boolean;
67
+ /** A module's items whose post has a video that can play (promotableDiscoveryVideo). */
68
+ export declare function promotedDiscoveryVideoItems<T extends DiscoveryItem>(items: readonly T[]): T[];
69
+ /**
70
+ * A post's engagement points: likes, reposts, replies, quotes and zappers. Null when its network reports no counts
71
+ * (unknown, not zero). A Minds repost counts its original's too, when that is more: the card shows the original.
72
+ */
73
+ export declare function discoveryEngagementPoints(item: DiscoveryPostItem): number | null;
74
+ /** A batch's typical engagement: per network (by who counted it), and over the whole batch. */
75
+ export interface DiscoveryEngagementReference {
76
+ networks: ReadonlyMap<string, number>;
77
+ batch: number;
78
+ }
79
+ /** The typical engagement of a batch of posts: the median of the posts with any, held to at least DISCOVERY_REFERENCE_FLOOR. */
80
+ export declare function discoveryEngagementReference(items: readonly DiscoveryPostItem[]): DiscoveryEngagementReference;
81
+ /** A post's place in the merge: its tier (0: engaged, fresh or unknown; 1: stale with no engagement) and its score. */
82
+ export interface DiscoveryItemRank {
83
+ tier: 0 | 1;
84
+ score: number;
85
+ }
86
+ /** A post's tier and score in a batch. `now`: the time the scores are for. */
87
+ export declare function rankDiscoveryItem(item: DiscoveryPostItem, reference: DiscoveryEngagementReference, now: number): DiscoveryItemRank;
88
+ /** A module's best post: the lowest tier, then the highest score. Null for a module with no posts. */
89
+ export declare function rankDiscoveryModule(module: DiscoveryModule, reference: DiscoveryEngagementReference, now: number): DiscoveryItemRank | null;
90
+ /** Where a module's post stands in the top of the feed: its place in the top's block, or null (not a top post, or a network lane's entry). */
91
+ export declare function discoveryTopPlace(module: DiscoveryModule): number | null;
92
+ /** Several posts of one network one under another (a conversation, a thread of more than two): a screenful of one source. */
93
+ export declare function isColumnModule(module: DiscoveryModule): boolean;
94
+ /**
95
+ * The batch with its galleries pooled into one (one per title): the cards best first, at most
96
+ * DISCOVERY_GALLERY_NETWORK_MAX from one network and DISCOVERY_POOLED_GALLERY_MAX in all; every card that does not fit
97
+ * goes in as a post of its own. The gallery keeps the place and id of the batch's first gallery; one gallery that fits
98
+ * as it is stays as it is. `keep`: a module never pooled (a screen's lead).
99
+ */
100
+ export declare function poolDiscoveryGalleries(modules: readonly DiscoveryModule[], now: number, keep?: DiscoveryModule): DiscoveryModule[];
101
+ /**
102
+ * A batch of post modules, taken best first under the network mix's soft constraints (the rules above). Each `take`
103
+ * reads the rows above the place it fills, so it fits the rows a feed already shows.
104
+ *
105
+ * The top of the feed's posts (discoveryTopPlace) are taken first, in the top's order: the engagement lanes own that
106
+ * order. `order` breaks equal scores: the caller's order of the modules (lower first), by default their order in `modules`.
107
+ */
108
+ export declare class DiscoveryEngagementMerge {
109
+ private left;
110
+ constructor(modules: readonly DiscoveryModule[], { now, order }?: {
111
+ now?: number;
112
+ order?: (module: DiscoveryModule, index: number) => number;
113
+ });
114
+ get size(): number;
115
+ /** The modules not taken yet, best first. */
116
+ rest(): DiscoveryModule[];
117
+ /** Whether a module is still to be taken. */
118
+ has(module: DiscoveryModule): boolean;
119
+ /** Takes this module out of the batch (a screen's lead). */
120
+ remove(module: DiscoveryModule): void;
121
+ /**
122
+ * The module for the row under `above`, or undefined when none is left. `singles`: a column (a conversation, a
123
+ * thread) only when no single post of the same tier is left. `newNetwork`: a post from a network none of the rows
124
+ * above come from, while one of the same tier is left (a first screen's second row).
125
+ */
126
+ take(above: ReadonlyArray<unknown>, { singles, newNetwork }?: {
127
+ singles?: boolean;
128
+ newNetwork?: boolean;
129
+ }): DiscoveryModule | undefined;
130
+ }
131
+ /** Rows that arrive woven in one by one with the rows not seen yet, below the place a reader has reached: for a feed of one source. */
132
+ export declare function weaveDiscoveryArrival<T extends {
133
+ id: string;
134
+ }>(rows: readonly T[], arriving: readonly T[], reached: ReadonlySet<string>, floor?: number): T[];
135
+ /**
136
+ * Where rows that arrive after others go (a progressive feed's `mixed` part). Nothing the viewer has had on screen
137
+ * moves: they go after the last row seen (`reached`, by id, and one more for a row only peeking in), never above `floor`;
138
+ * with nothing seen yet, at the end. Below that place the arriving rows and the rows not seen yet are merged: the rows
139
+ * that are not posts (a story, a rail, a row of the client's own) take the places they would have had woven in one by
140
+ * one, and the posts fill the other places by engagement under the network mix (DiscoveryEngagementMerge).
141
+ */
142
+ export declare function mergeDiscoveryArrival<T extends {
143
+ id: string;
144
+ }>(rows: readonly T[], arriving: readonly T[], reached: ReadonlySet<string>, floor?: number, now?: number): T[];