@recursiv/sdk 0.7.70 → 0.7.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -10
- package/dist/client.d.ts +5 -0
- package/dist/client.js +7 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.js +11 -1
- package/dist/resources/agents.d.ts +48 -0
- package/dist/resources/agents.js +22 -0
- package/dist/resources/curator.d.ts +11 -4
- package/dist/resources/discovery-fallover.d.ts +8 -0
- package/dist/resources/discovery-fallover.js +26 -0
- package/dist/resources/discovery-first-screen.d.ts +30 -0
- package/dist/resources/discovery-first-screen.js +149 -0
- package/dist/resources/discovery-merge.d.ts +144 -0
- package/dist/resources/discovery-merge.js +479 -0
- package/dist/resources/discovery-progressive.d.ts +117 -0
- package/dist/resources/discovery-progressive.js +414 -0
- package/dist/resources/discovery-safety.d.ts +32 -0
- package/dist/resources/discovery-safety.js +521 -0
- package/dist/resources/discovery.d.ts +59 -16
- package/dist/resources/discovery.js +147 -47
- package/dist/resources/feeds.d.ts +3 -1
- package/dist/resources/judgments.d.ts +252 -0
- package/dist/resources/judgments.js +79 -0
- package/dist/resources/notifications.d.ts +7 -2
- package/dist/resources/protocols.d.ts +204 -17
- package/dist/resources/protocols.js +24 -5
- package/dist/resources/search.d.ts +9 -1
- package/dist/resources/settings.d.ts +8 -1
- package/dist/resources/settings.js +9 -0
- package/dist/resources/signals.d.ts +58 -0
- package/dist/resources/signals.js +32 -0
- package/dist/types.d.ts +67 -0
- package/llm.md +24 -7
- package/package.json +1 -1
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import { readFalloverSteps } from './discovery-fallover.js';
|
|
2
|
+
import { composeFirstScreen } from './discovery-first-screen.js';
|
|
3
|
+
/** How long a round keeps waiting for its slower sources once one of them has answered. */
|
|
4
|
+
export const DISCOVERY_STRAGGLER_MS = 3000;
|
|
5
|
+
/**
|
|
6
|
+
* How long the first load waits for Minds and a second network, once its first source has answered, before it hands on
|
|
7
|
+
* what it has. Measured live (2026-09-27): the Minds read answers in 0.8-2 s in a category and 1.7-3.4 s on All; the
|
|
8
|
+
* open networks' in 0.2-1.3 s. Counted from the first answer, not from the load. A member's wait, and the default: the
|
|
9
|
+
* API caches a Discover page per caller for 30 s, so a member's first load is a fresh build of their Minds page.
|
|
10
|
+
*/
|
|
11
|
+
export const FIRST_SCREEN_MS = 2500;
|
|
12
|
+
/**
|
|
13
|
+
* A visitor's wait. Every visitor reads the same anonymous page, so once anyone has read it in the last 30 s it answers
|
|
14
|
+
* in about 0.13 s, well inside this wait, and Minds leads; cold, the networks lead and Minds joins below.
|
|
15
|
+
*/
|
|
16
|
+
export const VISITOR_FIRST_SCREEN_MS = 800;
|
|
17
|
+
/** Other networks read per round, as the session does per page: at most four requests a round. */
|
|
18
|
+
const REMOTE_PER_ROUND = 3;
|
|
19
|
+
const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
|
|
20
|
+
const isPost = (item) => item.kind === 'minds' || item.kind === 'social';
|
|
21
|
+
const isSuggestion = (item) => item.kind === 'group' || item.kind === 'person' || item.kind === 'tag';
|
|
22
|
+
/** A suggestion rail with fewer is not drawn: the session builds none with fewer either. */
|
|
23
|
+
const SUGGESTIONS_MIN = 2;
|
|
24
|
+
/**
|
|
25
|
+
* A module without the items in `seen` (item keys). Posts already shown by another source's module are not shown again:
|
|
26
|
+
* the sessions dedupe within themselves, but across them a story's member posts can also arrive in that network's own
|
|
27
|
+
* page. A module that loses items keeps its spec in step. A suggestion already shown leaves its rail, and a rail left
|
|
28
|
+
* with fewer than two is dropped; a story already shown is dropped whole, and keeps its headline and outlets when only
|
|
29
|
+
* its posts were shown; a post module with none left is dropped (null).
|
|
30
|
+
*/
|
|
31
|
+
export function withoutSeenItems(module, seen) {
|
|
32
|
+
const lead = module.items[0];
|
|
33
|
+
if (lead?.kind === 'cluster' && seen.has(lead.key))
|
|
34
|
+
return null;
|
|
35
|
+
const items = module.items.filter(item => item.kind === 'cluster' || !seen.has(item.key));
|
|
36
|
+
if (items.length === module.items.length)
|
|
37
|
+
return module;
|
|
38
|
+
if (module.items.some(isSuggestion)) {
|
|
39
|
+
if (items.filter(isSuggestion).length < SUGGESTIONS_MIN)
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
else if (!items.some(item => isPost(item) || item.kind === 'cluster'))
|
|
43
|
+
return null;
|
|
44
|
+
const element = module.spec.elements.module;
|
|
45
|
+
return { ...module, items, spec: { ...module.spec, elements: { ...module.spec.elements,
|
|
46
|
+
module: { ...element, props: { ...element.props, itemKeys: items.map(item => item.key) } } } } };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Claims the posts a module shows, and only those. A story claims the members it carries, never its whole member list:
|
|
50
|
+
* the Minds session reads a story's network members only through its member reader, which may fail, time out or be
|
|
51
|
+
* missing. A member the story does not carry still shows in its network's own page.
|
|
52
|
+
*/
|
|
53
|
+
function remember(module, seen) {
|
|
54
|
+
for (const item of module.items)
|
|
55
|
+
if (isPost(item))
|
|
56
|
+
seen.add(item.key);
|
|
57
|
+
}
|
|
58
|
+
/** The other networks a feed over every source reads, in the session's own order. */
|
|
59
|
+
export function discoveryRemoteSources(networks, source) {
|
|
60
|
+
if (source && source !== 'all')
|
|
61
|
+
return [];
|
|
62
|
+
return [...new Set(networks.filter(network => network.capabilities.includes('public_trends')).map(network => network.protocol))].slice(0, 16);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The suggestions a feed filtered to one network carries. The candidate lists are the whole app's: live on 2026-09-27
|
|
66
|
+
* Discover filtered to Nostr showed "People to follow" with three ActivityPub accounts. A network's own feed suggests
|
|
67
|
+
* only that network's accounts (a person's `protocol`, Minds when it has none); groups and tags are Minds', so they come
|
|
68
|
+
* with Minds' feed and with All's.
|
|
69
|
+
*/
|
|
70
|
+
export function discoveryRecommendationsFor(recommend, source) {
|
|
71
|
+
if (!recommend || !source || source === 'all')
|
|
72
|
+
return recommend;
|
|
73
|
+
const people = recommend.people;
|
|
74
|
+
const onSource = (person) => {
|
|
75
|
+
const protocol = person?.protocol;
|
|
76
|
+
return (typeof protocol === 'string' && protocol ? protocol : 'minds') === source;
|
|
77
|
+
};
|
|
78
|
+
const scoped = people ? { people: async () => (await people()).filter(onSource) } : {};
|
|
79
|
+
return source === 'minds' ? { ...recommend, ...scoped } : scoped;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The first recommendation read starts with the feed, not after its first page's posts; the Minds session's first call
|
|
83
|
+
* for that kind answers with that same read, and later calls read afresh.
|
|
84
|
+
*/
|
|
85
|
+
function warmRecommendations(recommend) {
|
|
86
|
+
const kind = RECOMMENDATION_KINDS.find(candidate => typeof recommend?.[candidate] === 'function');
|
|
87
|
+
if (!recommend || !kind)
|
|
88
|
+
return recommend;
|
|
89
|
+
const source = recommend[kind];
|
|
90
|
+
let warm = Promise.resolve().then(() => source());
|
|
91
|
+
warm.catch(() => { });
|
|
92
|
+
const first = () => {
|
|
93
|
+
const read = warm;
|
|
94
|
+
warm = null;
|
|
95
|
+
return read ?? source();
|
|
96
|
+
};
|
|
97
|
+
return { ...recommend, [kind]: first };
|
|
98
|
+
}
|
|
99
|
+
/** A token for one feed open ([A-Za-z0-9_-], up to 64): its sessions read the top of the feed once between them, and the server keeps that open's order stable. */
|
|
100
|
+
export function discoveryFeedOpenToken() {
|
|
101
|
+
return `f${Math.random().toString(36).slice(2, 12)}${Date.now().toString(36)}`.replace(/[^A-Za-z0-9_-]/g, '').slice(0, 64);
|
|
102
|
+
}
|
|
103
|
+
/** One top read per params for one feed open: every session asks it, and they share the answer. */
|
|
104
|
+
function sharedTopRead(read) {
|
|
105
|
+
const reads = new Map();
|
|
106
|
+
return params => {
|
|
107
|
+
const key = JSON.stringify([params.ref, params.limit, params.session ?? null]);
|
|
108
|
+
let answer = reads.get(key);
|
|
109
|
+
if (!answer) {
|
|
110
|
+
answer = Promise.resolve().then(() => read(params));
|
|
111
|
+
reads.set(key, answer);
|
|
112
|
+
}
|
|
113
|
+
return answer;
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What every session of one feed open shares: the top of the feed under one token for the open (one read between them:
|
|
118
|
+
* each places its own network's part), the posts the viewer saw (read once: an iterable may be read only once), and the
|
|
119
|
+
* stories they passed over and the visitor's rotation bucket.
|
|
120
|
+
*/
|
|
121
|
+
function openingOptions(options) {
|
|
122
|
+
const top = options.top
|
|
123
|
+
? { ...options.top, session: options.top.session ?? discoveryFeedOpenToken(), ...(options.top.read ? { read: sharedTopRead(options.top.read) } : {}) }
|
|
124
|
+
: undefined;
|
|
125
|
+
return {
|
|
126
|
+
...(top ? { top } : {}),
|
|
127
|
+
...(options.seen ? { seen: new Set([...options.seen].filter((ref) => typeof ref === 'string')) } : {}),
|
|
128
|
+
...(options.seenClusters ? { seenClusters: options.seenClusters } : {}),
|
|
129
|
+
...(options.bucket !== undefined ? { bucket: options.bucket } : {}),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** How long a top answer is shared by the feed opens that ask for it with the same params. */
|
|
133
|
+
export const DISCOVERY_TOP_SHARE_MS = 60_000;
|
|
134
|
+
/** After the top route answers 404 (it is off), how long a share does not ask it again. */
|
|
135
|
+
export const DISCOVERY_TOP_OFF_MS = 5 * 60_000;
|
|
136
|
+
const TOP_SHARE_MAX = 32;
|
|
137
|
+
/** The answer a share gives while the route is off: what the route itself said (a 404). */
|
|
138
|
+
class TopOff extends Error {
|
|
139
|
+
status = 404;
|
|
140
|
+
code = 'not_found';
|
|
141
|
+
constructor() { super('The top of the feed is off.'); this.name = 'TopOff'; }
|
|
142
|
+
}
|
|
143
|
+
const statusOf = (error) => (typeof error === 'object' && error !== null ? error.status : undefined);
|
|
144
|
+
/**
|
|
145
|
+
* A share of top reads for one viewer's reader: `share(read, scope)` reads the top once for every session that asks
|
|
146
|
+
* with the same params (ref, limit, the open's session token) and `scope` (e.g. the Politics choice it reads under),
|
|
147
|
+
* while the read is out and DISCOVERY_TOP_SHARE_MS after. A route that answered 404 (off) is not asked again for
|
|
148
|
+
* DISCOVERY_TOP_OFF_MS. Make one share per viewer: never hand it another viewer's reader.
|
|
149
|
+
*/
|
|
150
|
+
export function createDiscoveryTopShare(now = Date.now) {
|
|
151
|
+
const reads = new Map();
|
|
152
|
+
let offUntil = 0;
|
|
153
|
+
return (read, scope = '') => params => {
|
|
154
|
+
const at = now();
|
|
155
|
+
if (at < offUntil)
|
|
156
|
+
return Promise.reject(new TopOff());
|
|
157
|
+
for (const [key, entry] of reads)
|
|
158
|
+
if (at - entry.at > DISCOVERY_TOP_SHARE_MS)
|
|
159
|
+
reads.delete(key);
|
|
160
|
+
const key = JSON.stringify([scope, params.ref, params.limit, params.session ?? null]);
|
|
161
|
+
const known = reads.get(key);
|
|
162
|
+
if (known)
|
|
163
|
+
return known.answer;
|
|
164
|
+
const answer = Promise.resolve().then(() => read(params));
|
|
165
|
+
reads.set(key, { at, answer });
|
|
166
|
+
while (reads.size > TOP_SHARE_MAX)
|
|
167
|
+
reads.delete(reads.keys().next().value);
|
|
168
|
+
answer.catch(error => { if (statusOf(error) === 404)
|
|
169
|
+
offUntil = now() + DISCOVERY_TOP_OFF_MS; });
|
|
170
|
+
return answer;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* A progressive Discover feed over `factory`'s sessions (see the note above). `feedOptions` are a session's: over every
|
|
175
|
+
* source, Minds' session carries the radar and the recommendations (scoped to the feed's network filter,
|
|
176
|
+
* discoveryRecommendationsFor), and each other network with public trends gets a session filtered to itself, which
|
|
177
|
+
* carries Hide politics (`compass: 'hide'`) and no other choice. A feed filtered to one network, or with no other
|
|
178
|
+
* networks, is one session. `top`, `seen`, `seenClusters` and `bucket` reach every session, under one session token
|
|
179
|
+
* per open. Create a new feed when the viewer, category or filter changes.
|
|
180
|
+
*/
|
|
181
|
+
export function createProgressiveFeed(factory, feedOptions, { perSource = true, firstScreen = true, firstScreenMs = FIRST_SCREEN_MS, onLanded } = {}) {
|
|
182
|
+
// Discover's own feed follows its network filter; a feed that picks its own networks keeps the suggestions it was given.
|
|
183
|
+
const scoped = perSource ? discoveryRecommendationsFor(feedOptions.recommend, feedOptions.source) : feedOptions.recommend;
|
|
184
|
+
const recommend = warmRecommendations(scoped);
|
|
185
|
+
const opening = openingOptions(feedOptions);
|
|
186
|
+
const options = { ...feedOptions, ...(recommend === feedOptions.recommend ? {} : { recommend }), ...opening };
|
|
187
|
+
// The other networks this feed reads: the catalog it was opened with, and any it takes in later (setNetworks).
|
|
188
|
+
let remote = perSource ? discoveryRemoteSources(options.networks, options.source) : [];
|
|
189
|
+
const lane = (source, session) => ({ source, session, hasMore: true, failed: false, failedSources: [], retryingSources: [], partial: false, reading: null, opened: false });
|
|
190
|
+
// A network's session reads no Minds post: of the Politics choices it carries Hide politics alone.
|
|
191
|
+
const networkCompass = options.compass === 'hide' ? { compass: 'hide' } : {};
|
|
192
|
+
const networkFeed = (networks, protocol) => factory.createFeed({ networks, category: options.category, source: protocol,
|
|
193
|
+
...(options.learning ? { learning: options.learning } : {}), ...opening, ...networkCompass });
|
|
194
|
+
// One source (a network filter, or no other networks): the session as it is. Every source: Minds carries the radar
|
|
195
|
+
// and the recommendations; each other network is a session filtered to itself.
|
|
196
|
+
const lanes = remote.length
|
|
197
|
+
? [lane('minds', factory.createFeed({ ...options, networks: [] })), ...remote.map(protocol => lane(protocol, networkFeed(options.networks, protocol)))]
|
|
198
|
+
: [lane(options.source && options.source !== 'all' ? options.source : 'minds', factory.createFeed(options))];
|
|
199
|
+
/** Whether the first load is held and composed: only over every source, and only while there are other networks. */
|
|
200
|
+
const composing = () => firstScreen && remote.length > 0;
|
|
201
|
+
const seen = new Set();
|
|
202
|
+
/**
|
|
203
|
+
* Where a source's page goes as it lands. `opening`: the source's first page over every network, which leads with a
|
|
204
|
+
* post as the first screen does, unless the first load is holding it to hand on with the others.
|
|
205
|
+
*/
|
|
206
|
+
let listener = () => { };
|
|
207
|
+
/** A source's first page handed on by itself (it landed after the first screen): it leads with a post, as that screen does. */
|
|
208
|
+
const opened = (part, opening) => (opening ? { ...part, modules: composeFirstScreen([part]), composed: true } : part);
|
|
209
|
+
let rotation = 0;
|
|
210
|
+
let rounds = 0;
|
|
211
|
+
/** The open round's callback, told each time any source's read lands (its own reads or a slow one from before). */
|
|
212
|
+
const landings = new Set();
|
|
213
|
+
/** Told of a landing when no round is open: a straggler from a round that already ended. */
|
|
214
|
+
const statusListeners = new Set();
|
|
215
|
+
const status = () => {
|
|
216
|
+
// Only the Minds feed carries a fall-through; each other network's lane is that network alone.
|
|
217
|
+
const fallover = lanes.find(item => item.source === 'minds')?.fallover;
|
|
218
|
+
const retryingSources = [...new Set(lanes.filter(item => !item.failed).flatMap(item => item.retryingSources))];
|
|
219
|
+
return {
|
|
220
|
+
hasMore: lanes.some(item => !!item.reading || (item.hasMore && !item.failed)),
|
|
221
|
+
failedSources: [...new Set(lanes.filter(item => item.failed).flatMap(item => item.failedSources.length ? item.failedSources : [item.source]))],
|
|
222
|
+
...(retryingSources.length ? { retryingSources } : {}),
|
|
223
|
+
partialCategories: lanes.some(item => item.partial),
|
|
224
|
+
...(fallover ? { fallover } : {}),
|
|
225
|
+
};
|
|
226
|
+
};
|
|
227
|
+
const summary = (modules) => ({ modules, ...status() });
|
|
228
|
+
const read = (item, round, onError) => {
|
|
229
|
+
item.reading = Promise.resolve().then(() => item.session.next()).then(page => {
|
|
230
|
+
// A network the catalog dropped while it read: nothing it brought is handed on, and it is not a failure.
|
|
231
|
+
if (item.retired)
|
|
232
|
+
return;
|
|
233
|
+
item.hasMore = page.hasMore;
|
|
234
|
+
item.failedSources = page.failedSources;
|
|
235
|
+
item.retryingSources = page.retryingSources ?? [];
|
|
236
|
+
item.failed = page.failedSources.length > 0;
|
|
237
|
+
onLanded?.(item.source, page.failedSources.includes(item.source));
|
|
238
|
+
item.partial = page.partialCategories;
|
|
239
|
+
item.fallover = readFalloverSteps(page.fallover) ?? item.fallover;
|
|
240
|
+
const modules = [];
|
|
241
|
+
for (const module of page.modules) {
|
|
242
|
+
const kept = withoutSeenItems(module, seen);
|
|
243
|
+
if (!kept)
|
|
244
|
+
continue;
|
|
245
|
+
remember(kept, seen);
|
|
246
|
+
modules.push(kept);
|
|
247
|
+
}
|
|
248
|
+
if (!modules.length)
|
|
249
|
+
return;
|
|
250
|
+
// Over every network, a source's first page leads with a post, as the first screen does: the Minds page opens on
|
|
251
|
+
// its radar story, and one that landed after the first screen would go in story first. The page goes on as its
|
|
252
|
+
// source ranked it, marked `mixed` for a client to merge by engagement below what the reader has seen.
|
|
253
|
+
const opening = composing() && !item.opened;
|
|
254
|
+
item.opened = true;
|
|
255
|
+
listener({ source: item.source, round, modules, ...(remote.length > 0 ? { mixed: true } : {}) }, opening);
|
|
256
|
+
}, error => {
|
|
257
|
+
if (item.retired)
|
|
258
|
+
return;
|
|
259
|
+
item.failed = true;
|
|
260
|
+
item.failedSources = [item.source];
|
|
261
|
+
item.retryingSources = [];
|
|
262
|
+
onLanded?.(item.source, true);
|
|
263
|
+
onError(error);
|
|
264
|
+
}).finally(() => {
|
|
265
|
+
item.reading = null;
|
|
266
|
+
if (landings.size) {
|
|
267
|
+
for (const landed of [...landings])
|
|
268
|
+
landed();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const now = status();
|
|
272
|
+
for (const tell of [...statusListeners])
|
|
273
|
+
tell(now);
|
|
274
|
+
});
|
|
275
|
+
};
|
|
276
|
+
return {
|
|
277
|
+
onStatus(tell) {
|
|
278
|
+
statusListeners.add(tell);
|
|
279
|
+
return () => { statusListeners.delete(tell); };
|
|
280
|
+
},
|
|
281
|
+
setNetworks(networks) {
|
|
282
|
+
// A named feed reads its networks as one session, and a feed filtered to one network (or to Minds) reads no other.
|
|
283
|
+
if (!perSource || (options.source && options.source !== 'all'))
|
|
284
|
+
return false;
|
|
285
|
+
const wanted = discoveryRemoteSources(networks, options.source);
|
|
286
|
+
let rejoined = false;
|
|
287
|
+
for (const item of lanes) {
|
|
288
|
+
if (item.source === 'minds')
|
|
289
|
+
continue;
|
|
290
|
+
const offered = wanted.includes(item.source);
|
|
291
|
+
if (!offered && !item.retired) {
|
|
292
|
+
item.retired = true;
|
|
293
|
+
item.hasMore = false;
|
|
294
|
+
item.failed = false;
|
|
295
|
+
item.failedSources = [];
|
|
296
|
+
item.retryingSources = [];
|
|
297
|
+
}
|
|
298
|
+
else if (offered && item.retired) {
|
|
299
|
+
item.retired = false;
|
|
300
|
+
item.hasMore = true;
|
|
301
|
+
rejoined = true;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
remote = wanted;
|
|
305
|
+
// Under the same open as the rest: its token, so it shares the one top read, and what the reader already saw.
|
|
306
|
+
const added = wanted.filter(protocol => !lanes.some(item => item.source === protocol)).map(protocol => lane(protocol, networkFeed(networks, protocol)));
|
|
307
|
+
if (!added.length)
|
|
308
|
+
return rejoined;
|
|
309
|
+
lanes.push(...added);
|
|
310
|
+
// Before the first load, that load reads them with the rest, under the first screen's rules.
|
|
311
|
+
if (!rounds)
|
|
312
|
+
return true;
|
|
313
|
+
// After it, they are read now and land as a late source does, but only once the Minds read in flight has
|
|
314
|
+
// answered: a feed that began on Minds alone still opens on Minds. As many as a load reads; any more wait.
|
|
315
|
+
const begin = () => {
|
|
316
|
+
const ready = added.filter(item => !item.reading && !item.retired && item.hasMore && !item.failed).slice(0, REMOTE_PER_ROUND);
|
|
317
|
+
for (const item of ready)
|
|
318
|
+
read(item, rounds - 1, () => undefined);
|
|
319
|
+
};
|
|
320
|
+
const minds = lanes[0]?.source === 'minds' ? lanes[0].reading : null;
|
|
321
|
+
if (minds)
|
|
322
|
+
void minds.then(begin, begin);
|
|
323
|
+
else
|
|
324
|
+
begin();
|
|
325
|
+
return true;
|
|
326
|
+
},
|
|
327
|
+
retryFailed() {
|
|
328
|
+
for (const item of lanes) {
|
|
329
|
+
if (!item.failed)
|
|
330
|
+
continue;
|
|
331
|
+
item.failed = false;
|
|
332
|
+
item.failedSources = [];
|
|
333
|
+
item.hasMore = true;
|
|
334
|
+
item.session.retryFailed();
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
next(onPart) {
|
|
338
|
+
const round = rounds++;
|
|
339
|
+
const idle = lanes.filter(item => !item.reading && !item.retired && item.hasMore && !item.failed);
|
|
340
|
+
const multi = remote.length > 0;
|
|
341
|
+
const lead = multi && lanes[0] && idle.includes(lanes[0]) ? lanes[0] : undefined;
|
|
342
|
+
const others = multi ? idle.filter(item => item !== lanes[0]) : idle;
|
|
343
|
+
let picked = others;
|
|
344
|
+
if (multi && others.length > REMOTE_PER_ROUND) {
|
|
345
|
+
picked = Array.from({ length: REMOTE_PER_ROUND }, (_, index) => others[(rotation + index) % others.length]);
|
|
346
|
+
rotation += REMOTE_PER_ROUND;
|
|
347
|
+
}
|
|
348
|
+
const started = [...(lead ? [lead] : []), ...picked];
|
|
349
|
+
return new Promise((resolve, reject) => {
|
|
350
|
+
const arrived = [];
|
|
351
|
+
const errors = [];
|
|
352
|
+
let done = false;
|
|
353
|
+
let grace = null;
|
|
354
|
+
const deliver = (part) => { arrived.push(...part.modules); onPart(part); };
|
|
355
|
+
// The first load's parts wait here and go out as one screen.
|
|
356
|
+
const held = [];
|
|
357
|
+
let holding = round === 0 && composing();
|
|
358
|
+
let holdTimer = null;
|
|
359
|
+
const release = () => {
|
|
360
|
+
if (!holding)
|
|
361
|
+
return;
|
|
362
|
+
holding = false;
|
|
363
|
+
if (holdTimer)
|
|
364
|
+
clearTimeout(holdTimer);
|
|
365
|
+
const parts = held.splice(0);
|
|
366
|
+
const modules = composeFirstScreen(parts);
|
|
367
|
+
if (modules.length)
|
|
368
|
+
deliver({ source: parts.some(part => part.source === 'minds') ? 'minds' : parts[0].source, round, modules, composed: true });
|
|
369
|
+
};
|
|
370
|
+
/** Minds has answered, and so has another network with posts (or every other network has). */
|
|
371
|
+
const firstScreenReady = () => !lead?.reading
|
|
372
|
+
&& (held.some(part => part.source !== 'minds') || !started.some(item => item !== lead && item.reading));
|
|
373
|
+
const collect = (part, opening) => { if (holding)
|
|
374
|
+
held.push(part);
|
|
375
|
+
else
|
|
376
|
+
deliver(opened(part, opening)); };
|
|
377
|
+
listener = collect;
|
|
378
|
+
const finish = () => {
|
|
379
|
+
if (done)
|
|
380
|
+
return;
|
|
381
|
+
done = true;
|
|
382
|
+
release();
|
|
383
|
+
if (grace)
|
|
384
|
+
clearTimeout(grace);
|
|
385
|
+
landings.delete(landed);
|
|
386
|
+
// Later landings still reach the screen; they are simply not this round's.
|
|
387
|
+
listener = (part, opening) => onPart(opened(part, opening));
|
|
388
|
+
if (started.length && errors.length === started.length && !arrived.length)
|
|
389
|
+
reject(errors[0]);
|
|
390
|
+
else
|
|
391
|
+
resolve(summary(arrived));
|
|
392
|
+
};
|
|
393
|
+
function landed() {
|
|
394
|
+
if (done)
|
|
395
|
+
return;
|
|
396
|
+
if (holding)
|
|
397
|
+
holdTimer ??= setTimeout(release, firstScreenMs);
|
|
398
|
+
if (holding && firstScreenReady())
|
|
399
|
+
release();
|
|
400
|
+
if (!lanes.some(item => item.reading)) {
|
|
401
|
+
finish();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
grace ??= setTimeout(finish, DISCOVERY_STRAGGLER_MS);
|
|
405
|
+
}
|
|
406
|
+
landings.add(landed);
|
|
407
|
+
for (const item of started)
|
|
408
|
+
read(item, round, error => { errors.push(error); });
|
|
409
|
+
if (!lanes.some(item => item.reading))
|
|
410
|
+
finish();
|
|
411
|
+
});
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** A tag (a `Tag`, `{ slug }` or a bare name, with or without `#`) that marks a post sensitive: exact aliases only. */
|
|
2
|
+
export declare function isSensitiveTag(tag: unknown): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* A link as written, to the end of its address; never from inside a word, an
|
|
5
|
+
* address or an email. A scheme is read to 63 characters: unbounded, a run of
|
|
6
|
+
* scheme characters with no ":" ("a+a+a+…//") was scanned to its end from
|
|
7
|
+
* every "+".
|
|
8
|
+
*/
|
|
9
|
+
export declare const DISCOVERY_LINK_TOKEN: RegExp;
|
|
10
|
+
/**
|
|
11
|
+
* Each link DISCOVERY_LINK_TOKEN finds in a text, found without trying it at
|
|
12
|
+
* every character. A link runs to the end of its run of link characters, so
|
|
13
|
+
* a run holds one at most, and every one holds "//", "www." or a bare host's
|
|
14
|
+
* "label.label/": only a run holding one of those is read, from its start.
|
|
15
|
+
* The same tokens in the same order as text.matchAll(DISCOVERY_LINK_TOKEN).
|
|
16
|
+
*/
|
|
17
|
+
export declare function discoveryLinkTokens(text: string): string[];
|
|
18
|
+
/**
|
|
19
|
+
* The text links a porn site, as stored or as a member sees it: a link in it
|
|
20
|
+
* (read through look-alike letters, hidden characters, IDNA dots and escapes),
|
|
21
|
+
* a link target in its markup or markdown as a browser opens it, or either
|
|
22
|
+
* once its character references are decoded or a legacy body's tags stripped.
|
|
23
|
+
*/
|
|
24
|
+
export declare function linksAdultSite(text: unknown): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* A Minds post to mask unless the member shows sensitive content: flagged NSFW
|
|
27
|
+
* (`is_nsfw` or `isNsfw`), tagged so, or linking a porn site in its content or
|
|
28
|
+
* title. Live on 2026-09-27 a visitor's Discover All showed "I think she likes
|
|
29
|
+
* it. https://xhamster.com/videos/…" with the video's explicit thumbnail as its
|
|
30
|
+
* picture: its author never flagged it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function isSensitivePost(post: unknown): boolean;
|