@recursiv/sdk 0.7.44 → 0.7.46
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/dist/resources/discovery-learning.d.ts +2 -2
- package/dist/resources/discovery-learning.js +2 -1
- package/dist/resources/discovery.d.ts +64 -5
- package/dist/resources/discovery.js +153 -3
- package/dist/resources/protocols.d.ts +105 -2
- package/dist/resources/protocols.js +12 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DiscoveryLayout } from '../types.js';
|
|
2
|
-
/** Layout presentations plus the recommendation module kinds, so viewers who keep hiding a kind stop seeing it. */
|
|
3
|
-
export type DiscoveryPresentation = DiscoveryLayout['presentation'] | 'groups' | 'people' | 'tags';
|
|
2
|
+
/** Layout presentations plus the recommendation and radar module kinds, so viewers who keep hiding a kind stop seeing it. */
|
|
3
|
+
export type DiscoveryPresentation = DiscoveryLayout['presentation'] | 'groups' | 'people' | 'tags' | 'breaking' | 'story';
|
|
4
4
|
type Counts = {
|
|
5
5
|
views: number;
|
|
6
6
|
engagements: number;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
const names = ['posts', 'visual', 'conversation', 'video', 'thread', 'longread', 'groups', 'people', 'tags'];
|
|
1
|
+
const names = ['posts', 'visual', 'conversation', 'video', 'thread', 'longread', 'groups', 'people', 'tags', 'breaking', 'story'];
|
|
2
2
|
const fresh = (now) => ({ version: 1, startedAt: now, layouts: {
|
|
3
3
|
video: { views: 0, engagements: 0, hides: 0 }, posts: { views: 0, engagements: 0, hides: 0 }, visual: { views: 0, engagements: 0, hides: 0 }, conversation: { views: 0, engagements: 0, hides: 0 },
|
|
4
4
|
thread: { views: 0, engagements: 0, hides: 0 }, longread: { views: 0, engagements: 0, hides: 0 },
|
|
5
5
|
groups: { views: 0, engagements: 0, hides: 0 }, people: { views: 0, engagements: 0, hides: 0 }, tags: { views: 0, engagements: 0, hides: 0 },
|
|
6
|
+
breaking: { views: 0, engagements: 0, hides: 0 }, story: { views: 0, engagements: 0, hides: 0 },
|
|
6
7
|
} });
|
|
7
8
|
/** Local, per-viewer experimental presentation preferences. Stores counts only,
|
|
8
9
|
* never source text, author IDs or inferred traits. It cannot promote content,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type { DiscoveryLearning } from './discovery-learning.js';
|
|
2
1
|
import type { DiscoveryLayout, Post } from '../types.js';
|
|
2
|
+
import type { DiscoveryLearning } from './discovery-learning.js';
|
|
3
3
|
import type { PostsResource } from './posts.js';
|
|
4
|
-
import type { ProtocolsResource, SocialNetwork, SocialPost } from './protocols.js';
|
|
4
|
+
import type { ProtocolsResource, SocialNetwork, SocialPost, SocialRadar } from './protocols.js';
|
|
5
5
|
export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
|
|
6
6
|
export interface DiscoveryGroupCandidate {
|
|
7
7
|
id: string;
|
|
@@ -38,6 +38,51 @@ export interface DiscoveryRecommendationSources {
|
|
|
38
38
|
people?: () => Promise<DiscoveryPersonCandidate[]>;
|
|
39
39
|
tags?: () => Promise<DiscoveryTagCandidate[]>;
|
|
40
40
|
}
|
|
41
|
+
/** An outlet or Hacker News member of a radar cluster: rendered as a link chip, never as a post card. */
|
|
42
|
+
export interface DiscoveryClusterSource {
|
|
43
|
+
kind: 'outlet' | 'hackernews';
|
|
44
|
+
title: string;
|
|
45
|
+
url: string;
|
|
46
|
+
outlet?: string;
|
|
47
|
+
lane?: 'mainstream' | 'independent';
|
|
48
|
+
publishedAt: string;
|
|
49
|
+
}
|
|
50
|
+
/** One story from the app's topic radar, validated for rendering. Every value is a reference to server data; the label is a verbatim headline or topic name. */
|
|
51
|
+
export interface DiscoveryCluster {
|
|
52
|
+
id: string;
|
|
53
|
+
label: string;
|
|
54
|
+
kind: 'breaking' | 'viral' | 'story';
|
|
55
|
+
theme?: string;
|
|
56
|
+
outletCount: number;
|
|
57
|
+
laneMix: {
|
|
58
|
+
mainstream: number;
|
|
59
|
+
independent: number;
|
|
60
|
+
};
|
|
61
|
+
sources: Record<string, number>;
|
|
62
|
+
velocity: {
|
|
63
|
+
normalized: number;
|
|
64
|
+
};
|
|
65
|
+
newestAt: string;
|
|
66
|
+
lead?: {
|
|
67
|
+
type: 'image' | 'video';
|
|
68
|
+
url: string;
|
|
69
|
+
poster?: string;
|
|
70
|
+
width?: number;
|
|
71
|
+
height?: number;
|
|
72
|
+
memberKey: string;
|
|
73
|
+
};
|
|
74
|
+
links: DiscoveryClusterSource[];
|
|
75
|
+
/** Keys of the minds/social DiscoveryPostItems that belong to the story, in this session's key format. */
|
|
76
|
+
memberKeys: string[];
|
|
77
|
+
}
|
|
78
|
+
/** Reader for the app's radar snapshot; bounded to three seconds and optional, so an older server simply yields no radar module. */
|
|
79
|
+
export type DiscoveryRadarSource = (params: {
|
|
80
|
+
limit?: number;
|
|
81
|
+
category?: string;
|
|
82
|
+
}) => Promise<{
|
|
83
|
+
data: SocialRadar;
|
|
84
|
+
}>;
|
|
85
|
+
export type DiscoveryRadarPresentation = 'breaking' | 'story';
|
|
41
86
|
export type DiscoveryPostItem = {
|
|
42
87
|
key: string;
|
|
43
88
|
kind: 'minds';
|
|
@@ -59,15 +104,21 @@ export type DiscoveryItem = DiscoveryPostItem | {
|
|
|
59
104
|
key: string;
|
|
60
105
|
kind: 'tag';
|
|
61
106
|
tag: DiscoveryTagCandidate;
|
|
107
|
+
} | {
|
|
108
|
+
key: string;
|
|
109
|
+
kind: 'cluster';
|
|
110
|
+
cluster: DiscoveryCluster;
|
|
62
111
|
};
|
|
63
112
|
export declare const isDiscoveryPost: (item: DiscoveryItem) => item is DiscoveryPostItem;
|
|
64
113
|
/** At most this many candidates per recommendation module. */
|
|
65
114
|
export declare const RECOMMENDATION_MAX_ITEMS = 6;
|
|
115
|
+
/** Clusters read from the radar per session; the server never holds more. */
|
|
116
|
+
export declare const RADAR_MAX_CLUSTERS = 40;
|
|
66
117
|
/** json-render Spec: values are references to authenticated SDK results, never
|
|
67
118
|
* arbitrary executable components, links, generated text or provider markup. */
|
|
68
119
|
export interface DiscoveryModule {
|
|
69
120
|
id: string;
|
|
70
|
-
presentation: DiscoveryLayout['presentation'] | DiscoveryRecommendationKind;
|
|
121
|
+
presentation: DiscoveryLayout['presentation'] | DiscoveryRecommendationKind | DiscoveryRadarPresentation;
|
|
71
122
|
source: DiscoveryLayout['source'];
|
|
72
123
|
/** Subject of the featured window (a browse-category id or `mixed`), for specific module copy. */
|
|
73
124
|
theme?: string;
|
|
@@ -76,7 +127,7 @@ export interface DiscoveryModule {
|
|
|
76
127
|
root: 'module';
|
|
77
128
|
elements: {
|
|
78
129
|
module: {
|
|
79
|
-
type: 'DiscoveryPosts' | 'DiscoveryGallery' | 'DiscoveryConversation' | 'DiscoveryVideo' | 'DiscoveryThread' | 'DiscoveryLongRead' | 'DiscoveryGroups' | 'DiscoveryPeople' | 'DiscoveryTags';
|
|
130
|
+
type: 'DiscoveryPosts' | 'DiscoveryGallery' | 'DiscoveryConversation' | 'DiscoveryVideo' | 'DiscoveryThread' | 'DiscoveryLongRead' | 'DiscoveryGroups' | 'DiscoveryPeople' | 'DiscoveryTags' | 'DiscoveryBreaking' | 'DiscoveryStory';
|
|
80
131
|
props: {
|
|
81
132
|
itemKeys: string[];
|
|
82
133
|
};
|
|
@@ -107,11 +158,19 @@ export declare class DiscoveryFeedSession {
|
|
|
107
158
|
private seen;
|
|
108
159
|
private page;
|
|
109
160
|
private nextSource;
|
|
110
|
-
|
|
161
|
+
/** The radar snapshot, read once on the first page; null until then. */
|
|
162
|
+
private clusters;
|
|
163
|
+
private radarUsed;
|
|
164
|
+
private radar?;
|
|
165
|
+
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);
|
|
111
166
|
retryFailed(): void;
|
|
112
167
|
next(): Promise<DiscoveryFeedPage>;
|
|
113
168
|
private read;
|
|
114
169
|
private readPage;
|
|
170
|
+
/** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
|
|
171
|
+
private radarCluster;
|
|
172
|
+
private loadRadar;
|
|
173
|
+
private radarModule;
|
|
115
174
|
private recommendation;
|
|
116
175
|
}
|
|
117
176
|
export declare class DiscoveryResource {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
export const isDiscoveryPost = (item) => item.kind === 'minds' || item.kind === 'social';
|
|
2
2
|
/** At most this many candidates per recommendation module. */
|
|
3
3
|
export const RECOMMENDATION_MAX_ITEMS = 6;
|
|
4
|
+
/** Clusters read from the radar per session; the server never holds more. */
|
|
5
|
+
export const RADAR_MAX_CLUSTERS = 40;
|
|
6
|
+
const RADAR_MAX_MEMBERS = 12;
|
|
4
7
|
const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
|
|
5
8
|
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
|
|
6
9
|
function recommendationItem(kind, candidate) {
|
|
@@ -21,6 +24,75 @@ function recommendationItem(kind, candidate) {
|
|
|
21
24
|
return null;
|
|
22
25
|
return { key: `rec:tag:${(nonEmpty(value.slug) ? value.slug : value.name).toLowerCase()}`, kind: 'tag', tag: value };
|
|
23
26
|
}
|
|
27
|
+
const RADAR_KINDS = new Set(['breaking', 'viral', 'story']);
|
|
28
|
+
const RADAR_SOCIAL_KINDS = new Set(['bluesky', 'activitypub', 'nostr']);
|
|
29
|
+
const httpUrl = (value) => typeof value === 'string' && value.length <= 4096 && /^https?:\/\//i.test(value);
|
|
30
|
+
const countOf = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
31
|
+
const dimension = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
32
|
+
/** Validate one radar cluster: bad or unexpected shapes are dropped, never rendered. Member keys are translated to this session's key format. */
|
|
33
|
+
function radarCluster(value) {
|
|
34
|
+
if (!value || typeof value !== 'object')
|
|
35
|
+
return null;
|
|
36
|
+
const c = value;
|
|
37
|
+
if (typeof c.id !== 'string' || !/^[0-9a-f]{64}$/.test(c.id) || !nonEmpty(c.label) || typeof c.kind !== 'string' || !RADAR_KINDS.has(c.kind))
|
|
38
|
+
return null;
|
|
39
|
+
const newestAt = (c.freshness ?? {}).newestAt;
|
|
40
|
+
if (typeof newestAt !== 'string' || !Number.isFinite(Date.parse(newestAt)))
|
|
41
|
+
return null;
|
|
42
|
+
const laneMix = (c.laneMix ?? {});
|
|
43
|
+
const mainstream = countOf(laneMix.mainstream);
|
|
44
|
+
const independent = countOf(laneMix.independent);
|
|
45
|
+
if (mainstream === null || independent === null)
|
|
46
|
+
return null;
|
|
47
|
+
const normalized = c.velocity?.normalized;
|
|
48
|
+
const sources = {};
|
|
49
|
+
if (c.sources && typeof c.sources === 'object')
|
|
50
|
+
for (const [name, count] of Object.entries(c.sources)) {
|
|
51
|
+
const n = countOf(count);
|
|
52
|
+
if (n !== null && /^[a-z]{1,32}$/.test(name))
|
|
53
|
+
sources[name] = n;
|
|
54
|
+
}
|
|
55
|
+
const links = [];
|
|
56
|
+
const memberKeys = [];
|
|
57
|
+
const translated = new Map();
|
|
58
|
+
for (const raw of Array.isArray(c.members) ? c.members.slice(0, RADAR_MAX_MEMBERS) : []) {
|
|
59
|
+
if (!raw || typeof raw !== 'object')
|
|
60
|
+
continue;
|
|
61
|
+
const member = raw;
|
|
62
|
+
if (typeof member.key !== 'string' || !member.key || member.key.length > 4096)
|
|
63
|
+
continue;
|
|
64
|
+
if (member.kind === 'outlet' || member.kind === 'hackernews') {
|
|
65
|
+
if (!nonEmpty(member.title) || !httpUrl(member.url) || typeof member.publishedAt !== 'string')
|
|
66
|
+
continue;
|
|
67
|
+
const outlet = (member.outlet ?? {});
|
|
68
|
+
links.push({ kind: member.kind, title: member.title, url: member.url, ...(nonEmpty(outlet.name) ? { outlet: outlet.name } : {}),
|
|
69
|
+
...(outlet.lane === 'mainstream' || outlet.lane === 'independent' ? { lane: outlet.lane } : {}), publishedAt: member.publishedAt });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
let itemKey = null;
|
|
73
|
+
if (member.kind === 'minds')
|
|
74
|
+
itemKey = key('minds', member.key);
|
|
75
|
+
else if (typeof member.kind === 'string' && RADAR_SOCIAL_KINDS.has(member.kind)) {
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(member.key);
|
|
78
|
+
if (Array.isArray(parsed) && parsed.length === 2 && parsed[0] === member.kind && nonEmpty(parsed[1]))
|
|
79
|
+
itemKey = key(parsed[0], parsed[1]);
|
|
80
|
+
}
|
|
81
|
+
catch { /* not a post reference */ }
|
|
82
|
+
}
|
|
83
|
+
if (itemKey && !memberKeys.includes(itemKey)) {
|
|
84
|
+
memberKeys.push(itemKey);
|
|
85
|
+
translated.set(member.key, itemKey);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const media = (c.media?.lead ?? null);
|
|
89
|
+
const lead = media && (media.type === 'image' || media.type === 'video') && httpUrl(media.url) && typeof media.memberKey === 'string' && media.memberKey
|
|
90
|
+
? { type: media.type, url: media.url, ...(httpUrl(media.poster) ? { poster: media.poster } : {}), ...(dimension(media.width) && dimension(media.height) ? { width: dimension(media.width), height: dimension(media.height) } : {}),
|
|
91
|
+
memberKey: translated.get(media.memberKey) ?? media.memberKey } : undefined;
|
|
92
|
+
const theme = typeof c.theme === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(c.theme) ? c.theme : undefined;
|
|
93
|
+
return { id: c.id, label: c.label.slice(0, 300), kind: c.kind, ...(theme ? { theme } : {}), outletCount: mainstream + independent, laneMix: { mainstream, independent }, sources,
|
|
94
|
+
velocity: { normalized: typeof normalized === 'number' && Number.isFinite(normalized) && normalized >= 0 ? normalized : 0 }, newestAt, ...(lead ? { lead } : {}), links, memberKeys };
|
|
95
|
+
}
|
|
24
96
|
const ordinary = { version: 'discovery-layout-v1', presentation: 'posts', source: 'fallback' };
|
|
25
97
|
const key = (protocol, id) => JSON.stringify([protocol, id]);
|
|
26
98
|
const presentations = new Set(['posts', 'visual', 'conversation', 'video', 'thread', 'longread']);
|
|
@@ -52,12 +124,19 @@ export class DiscoveryFeedSession {
|
|
|
52
124
|
seen = new Set();
|
|
53
125
|
page = 0;
|
|
54
126
|
nextSource = 0;
|
|
55
|
-
|
|
127
|
+
/** The radar snapshot, read once on the first page; null until then. */
|
|
128
|
+
clusters = null;
|
|
129
|
+
radarUsed = new Set();
|
|
130
|
+
radar;
|
|
131
|
+
constructor(posts, protocols, networks, category, source, learning, recommend, radar) {
|
|
56
132
|
this.posts = posts;
|
|
57
133
|
this.protocols = protocols;
|
|
58
134
|
this.category = category;
|
|
59
135
|
this.learning = learning;
|
|
60
136
|
this.recommend = recommend;
|
|
137
|
+
// The radar clusters across every source, so a feed narrowed to one network carries none.
|
|
138
|
+
if (!source || source === 'all')
|
|
139
|
+
this.radar = radar;
|
|
61
140
|
const sources = [...new Set(networks.filter(n => n.capabilities.includes('public_trends')).map(n => n.protocol))].slice(0, 16);
|
|
62
141
|
this.streams = ['minds', ...sources].filter(protocol => !source || source === 'all' || source === protocol)
|
|
63
142
|
.map(protocol => ({ protocol, offset: 0, done: false, failed: false, used: [] }));
|
|
@@ -73,7 +152,9 @@ export class DiscoveryFeedSession {
|
|
|
73
152
|
async read(stream) {
|
|
74
153
|
const params = { limit: 25, ...(this.category ? { category: this.category } : {}), discovery: true };
|
|
75
154
|
if (stream.protocol === 'minds') {
|
|
76
|
-
|
|
155
|
+
// Discover leads with what is moving now: the REST hot order (engagement
|
|
156
|
+
// decayed by age). Hot pages by offset; the server sends no cursor for it.
|
|
157
|
+
const response = await this.posts.list({ ...params, sort: 'hot', ...(stream.cursor ? { cursor: stream.cursor } : { offset: stream.offset }) });
|
|
77
158
|
stream.offset += response.meta.scanned ?? response.meta.limit;
|
|
78
159
|
const next = response.meta.next_cursor ?? undefined;
|
|
79
160
|
stream.done = !response.meta.has_more || !!next && (next === stream.cursor || stream.used.includes(next));
|
|
@@ -167,6 +248,19 @@ export class DiscoveryFeedSession {
|
|
|
167
248
|
const modules = [];
|
|
168
249
|
const page = this.page++;
|
|
169
250
|
let remaining = mixed;
|
|
251
|
+
// The radar leads the first page with the top breaking story and returns
|
|
252
|
+
// every second page with the next one. Its member posts move under the
|
|
253
|
+
// story, on this page or on any later one, so the hot stream never repeats them.
|
|
254
|
+
const cluster = mixed.length ? await this.radarCluster(page) : null;
|
|
255
|
+
let clusterItems = [];
|
|
256
|
+
if (cluster) {
|
|
257
|
+
const members = new Set(cluster.memberKeys);
|
|
258
|
+
// Under the story, posts keep the server's member order: the lead media member first, then by member score.
|
|
259
|
+
clusterItems = remaining.filter(row => members.has(row.item.key)).map(row => row.item).sort((a, b) => cluster.memberKeys.indexOf(a.key) - cluster.memberKeys.indexOf(b.key));
|
|
260
|
+
remaining = remaining.filter(row => !members.has(row.item.key));
|
|
261
|
+
for (const memberKey of cluster.memberKeys)
|
|
262
|
+
this.seen.add(memberKey);
|
|
263
|
+
}
|
|
170
264
|
// One gallery strip per page: it may gather images from the whole page, so a
|
|
171
265
|
// second one would only sweep up leftovers into a stub.
|
|
172
266
|
let galleryMinted = false;
|
|
@@ -206,9 +300,64 @@ export class DiscoveryFeedSession {
|
|
|
206
300
|
const recommendation = modules.length ? await this.recommendation(page) : null;
|
|
207
301
|
if (recommendation)
|
|
208
302
|
modules.splice(Math.min(2, modules.length), 0, recommendation);
|
|
303
|
+
if (cluster)
|
|
304
|
+
modules.splice(page === 0 ? 0 : Math.min(2, modules.length), 0, this.radarModule(page, cluster, clusterItems));
|
|
209
305
|
return { modules, hasMore: this.streams.some(stream => !stream.done && !stream.failed),
|
|
210
306
|
failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial) };
|
|
211
307
|
}
|
|
308
|
+
/** Page 0: the top breaking cluster, else the top cluster. Every second later page: the next unused story or viral cluster. */
|
|
309
|
+
async radarCluster(page) {
|
|
310
|
+
if (!this.radar)
|
|
311
|
+
return null;
|
|
312
|
+
if (page === 0)
|
|
313
|
+
this.clusters = await this.loadRadar();
|
|
314
|
+
if (!this.clusters?.length || (page !== 0 && page % 2 !== 0))
|
|
315
|
+
return null;
|
|
316
|
+
const unused = this.clusters.filter(candidate => !this.radarUsed.has(candidate.id));
|
|
317
|
+
const cluster = page === 0 ? unused.find(candidate => candidate.kind === 'breaking') ?? unused[0] : unused.find(candidate => candidate.kind !== 'breaking');
|
|
318
|
+
if (!cluster)
|
|
319
|
+
return null;
|
|
320
|
+
this.radarUsed.add(cluster.id);
|
|
321
|
+
// A viewer who keeps hiding radar stories stops seeing them, through the same bandit as layouts.
|
|
322
|
+
if (this.learning && this.learning.choose(cluster.kind === 'breaking' ? 'breaking' : 'story', page) === 'posts')
|
|
323
|
+
return null;
|
|
324
|
+
return cluster;
|
|
325
|
+
}
|
|
326
|
+
async loadRadar() {
|
|
327
|
+
let response;
|
|
328
|
+
let cancel = () => { };
|
|
329
|
+
try {
|
|
330
|
+
response = await Promise.race([this.radar({ limit: RADAR_MAX_CLUSTERS, ...(this.category ? { category: this.category } : {}) }), new Promise((_resolve, reject) => {
|
|
331
|
+
const timer = setTimeout(() => reject(new Error('Radar source timed out')), 3000);
|
|
332
|
+
cancel = () => clearTimeout(timer);
|
|
333
|
+
})]);
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
return [];
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
cancel();
|
|
340
|
+
}
|
|
341
|
+
const clusters = response?.data?.clusters;
|
|
342
|
+
if (!Array.isArray(clusters))
|
|
343
|
+
return [];
|
|
344
|
+
const out = [];
|
|
345
|
+
const ids = new Set();
|
|
346
|
+
for (const value of clusters.slice(0, RADAR_MAX_CLUSTERS)) {
|
|
347
|
+
const cluster = radarCluster(value);
|
|
348
|
+
if (cluster && !ids.has(cluster.id)) {
|
|
349
|
+
ids.add(cluster.id);
|
|
350
|
+
out.push(cluster);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
radarModule(page, cluster, posts) {
|
|
356
|
+
const presentation = cluster.kind === 'breaking' ? 'breaking' : 'story';
|
|
357
|
+
const items = [{ key: `radar:${cluster.id}`, kind: 'cluster', cluster }, ...posts];
|
|
358
|
+
return { id: `discovery-${page}-radar-${cluster.id}`, presentation, source: 'fallback', items, ...(cluster.theme && cluster.theme !== 'mixed' ? { theme: cluster.theme } : {}),
|
|
359
|
+
spec: { root: 'module', elements: { module: { type: presentation === 'breaking' ? 'DiscoveryBreaking' : 'DiscoveryStory', props: { itemKeys: items.map(item => item.key) }, children: [] } } } };
|
|
360
|
+
}
|
|
212
361
|
async recommendation(page) {
|
|
213
362
|
const kinds = RECOMMENDATION_KINDS.filter(kind => typeof this.recommend?.[kind] === 'function');
|
|
214
363
|
const kind = kinds[page % Math.max(1, kinds.length)];
|
|
@@ -259,6 +408,7 @@ export class DiscoveryResource {
|
|
|
259
408
|
this.protocols = protocols;
|
|
260
409
|
}
|
|
261
410
|
createFeed(options) {
|
|
262
|
-
|
|
411
|
+
// The radar rides along by default; an older server answers 404 and the session simply shows no story module.
|
|
412
|
+
return new DiscoveryFeedSession(this.posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend, params => this.protocols.getPublicRadar(params));
|
|
263
413
|
}
|
|
264
414
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import type { DiscoveryLayout } from '../types.js';
|
|
2
1
|
import type { HttpClient } from '../client.js';
|
|
3
|
-
import type { PaginatedResponse, PaginationParams, SingleResponse } from '../types.js';
|
|
2
|
+
import type { DiscoveryLayout, PaginatedResponse, PaginationParams, SingleResponse } from '../types.js';
|
|
4
3
|
/** Canonical protocol operation names returned by the adapter registry. */
|
|
5
4
|
export declare const PROTOCOL_CAPABILITY_OPERATIONS: readonly ["discover", "stream", "backfill", "search", "resolveIdentity", "fetchProfile", "fetchThread", "publish", "reply", "react", "repost", "follow", "delete", "health", "checkpoint"];
|
|
6
5
|
export declare const PROTOCOL_CAPABILITY_STATUSES: readonly ["supported", "planned", "unsupported"];
|
|
@@ -844,11 +843,14 @@ export interface SocialPost {
|
|
|
844
843
|
replyCount: number | null;
|
|
845
844
|
likeCount: number | null;
|
|
846
845
|
repostCount: number | null;
|
|
846
|
+
/** Pixel dimensions when the source declares them (Bluesky aspect ratio, Mastodon attachment meta), so a card can take the clip's shape before it loads. */
|
|
847
847
|
media: Array<{
|
|
848
848
|
type: 'image' | 'video';
|
|
849
849
|
url: string;
|
|
850
850
|
alt: string;
|
|
851
851
|
poster?: string;
|
|
852
|
+
width?: number;
|
|
853
|
+
height?: number;
|
|
852
854
|
}>;
|
|
853
855
|
hasAdditionalContent: boolean;
|
|
854
856
|
/** At most one independently filtered quoted post. */
|
|
@@ -927,6 +929,103 @@ export interface SocialTrendFeed extends SocialTrendProvenance {
|
|
|
927
929
|
items: SocialPost[];
|
|
928
930
|
cursor: string | null;
|
|
929
931
|
}
|
|
932
|
+
/** A member of a radar cluster: a headline or excerpt and a link only, never a body. */
|
|
933
|
+
export type SocialRadarSourceKind = 'outlet' | 'hackernews' | 'bluesky' | 'activitypub' | 'nostr' | 'minds';
|
|
934
|
+
export interface SocialRadarMember {
|
|
935
|
+
kind: SocialRadarSourceKind;
|
|
936
|
+
/** outlet: the article url; hackernews: the `hn:<id>` candidate id; social: JSON `[protocol, nativeId]`; minds: the post id. */
|
|
937
|
+
key: string;
|
|
938
|
+
title: string;
|
|
939
|
+
url?: string;
|
|
940
|
+
publishedAt: string;
|
|
941
|
+
outlet?: {
|
|
942
|
+
name: string;
|
|
943
|
+
lane: 'mainstream' | 'independent';
|
|
944
|
+
};
|
|
945
|
+
engagement?: {
|
|
946
|
+
likes?: number;
|
|
947
|
+
reposts?: number;
|
|
948
|
+
replies?: number;
|
|
949
|
+
score?: number;
|
|
950
|
+
perHour?: number;
|
|
951
|
+
samples: number;
|
|
952
|
+
};
|
|
953
|
+
media?: {
|
|
954
|
+
type: 'image' | 'video';
|
|
955
|
+
url: string;
|
|
956
|
+
poster?: string;
|
|
957
|
+
width?: number;
|
|
958
|
+
height?: number;
|
|
959
|
+
};
|
|
960
|
+
providerRank?: number;
|
|
961
|
+
}
|
|
962
|
+
/** One story on the radar. `breaking` is outlet corroboration inside a short window; `viral` is social velocity above its network's norm. */
|
|
963
|
+
export interface SocialRadarCluster {
|
|
964
|
+
id: string;
|
|
965
|
+
/** Verbatim: a World Today headline, a provider topic name or the medoid outlet title. Never generated. */
|
|
966
|
+
label: string;
|
|
967
|
+
kind: 'breaking' | 'viral' | 'story';
|
|
968
|
+
seed: {
|
|
969
|
+
kind: 'world_story' | 'trend_topic' | 'emergent';
|
|
970
|
+
ref: string;
|
|
971
|
+
};
|
|
972
|
+
/** A browse-category id or `mixed`, from cached judgments only. */
|
|
973
|
+
theme?: string;
|
|
974
|
+
sources: Partial<Record<SocialRadarSourceKind, number>>;
|
|
975
|
+
corroboration: number;
|
|
976
|
+
laneMix: {
|
|
977
|
+
mainstream: number;
|
|
978
|
+
independent: number;
|
|
979
|
+
};
|
|
980
|
+
velocity: {
|
|
981
|
+
perHour: number;
|
|
982
|
+
normalized: number;
|
|
983
|
+
samples: number;
|
|
984
|
+
};
|
|
985
|
+
media: {
|
|
986
|
+
images: number;
|
|
987
|
+
videos: number;
|
|
988
|
+
lead?: {
|
|
989
|
+
type: 'image' | 'video';
|
|
990
|
+
url: string;
|
|
991
|
+
poster?: string;
|
|
992
|
+
width?: number;
|
|
993
|
+
height?: number;
|
|
994
|
+
memberKey: string;
|
|
995
|
+
};
|
|
996
|
+
};
|
|
997
|
+
freshness: {
|
|
998
|
+
newestAt: string;
|
|
999
|
+
oldestAt: string;
|
|
1000
|
+
outletsInBreakingWindow: number;
|
|
1001
|
+
};
|
|
1002
|
+
score: number;
|
|
1003
|
+
members: SocialRadarMember[];
|
|
1004
|
+
}
|
|
1005
|
+
/** The app's radar snapshot: rebuilt by a server job on its own interval, never on a request. `builtAt` is null before the first tick. */
|
|
1006
|
+
export interface SocialRadar {
|
|
1007
|
+
version: 'topic-radar-v1';
|
|
1008
|
+
builtAt: string | null;
|
|
1009
|
+
windowHours: number;
|
|
1010
|
+
breakingWindowHours: number;
|
|
1011
|
+
clusters: SocialRadarCluster[];
|
|
1012
|
+
provenance: {
|
|
1013
|
+
providers: Array<{
|
|
1014
|
+
name: string;
|
|
1015
|
+
observedAt: string;
|
|
1016
|
+
coverage: 'provider_ranked_window' | 'allowlisted_outlets' | 'minds_hot';
|
|
1017
|
+
}>;
|
|
1018
|
+
};
|
|
1019
|
+
/** Present when a category was selected; `partial` while some clusters have no cached theme yet. */
|
|
1020
|
+
radar_filter?: {
|
|
1021
|
+
selected: string;
|
|
1022
|
+
status: 'complete' | 'partial';
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
export interface SocialRadarParams {
|
|
1026
|
+
category?: string;
|
|
1027
|
+
limit?: number;
|
|
1028
|
+
}
|
|
930
1029
|
/** Provider-ranked accounts. `source` says whether this page is the app's hourly index snapshot or a live read. */
|
|
931
1030
|
export interface SocialPopularProfiles {
|
|
932
1031
|
items: SocialProfile[];
|
|
@@ -1007,6 +1106,8 @@ export declare class ProtocolsResource {
|
|
|
1007
1106
|
category?: string;
|
|
1008
1107
|
discovery?: boolean;
|
|
1009
1108
|
}): Promise<SingleResponse<SocialTrendFeed>>;
|
|
1109
|
+
/** Breaking and viral stories clustered across outlets, open networks and Minds, through the same public-only app boundary. */
|
|
1110
|
+
getSharedRadar(projectId: string, params?: SocialRadarParams): Promise<SingleResponse<SocialRadar>>;
|
|
1010
1111
|
publicNetworks(): Promise<SingleResponse<SocialNetwork[]>>;
|
|
1011
1112
|
/** Query the live public network. It does not import users or posts. */
|
|
1012
1113
|
searchPublicPosts(params: SearchPublicPostsParams): Promise<SingleResponse<SocialPostSearch>>;
|
|
@@ -1056,6 +1157,8 @@ export declare class ProtocolsResource {
|
|
|
1056
1157
|
category?: string;
|
|
1057
1158
|
discovery?: boolean;
|
|
1058
1159
|
}): Promise<SingleResponse<SocialTrendFeed>>;
|
|
1160
|
+
/** The app's topic radar under its own key: the same snapshot the keyless route serves. Never triggers a build. */
|
|
1161
|
+
getPublicRadar(params?: SocialRadarParams): Promise<SingleResponse<SocialRadar>>;
|
|
1059
1162
|
/** Provider-ranked public accounts with explicit provenance; served from the app's index when a fresh snapshot exists. */
|
|
1060
1163
|
getPublicPopularProfiles(params: {
|
|
1061
1164
|
protocol: string;
|
|
@@ -34,6 +34,10 @@ function hostedIdentityPath(id) {
|
|
|
34
34
|
}
|
|
35
35
|
return `/protocols/identities/${encodeURIComponent(id)}`;
|
|
36
36
|
}
|
|
37
|
+
/** Only the two radar fields travel; caller scope or destination extras never reach the query string. */
|
|
38
|
+
function radarQuery(params) {
|
|
39
|
+
return { ...(params.category !== undefined ? { category: params.category } : {}), ...(params.limit !== undefined ? { limit: params.limit } : {}) };
|
|
40
|
+
}
|
|
37
41
|
export class ProtocolsResource {
|
|
38
42
|
client;
|
|
39
43
|
subscriptionListeners = new Set();
|
|
@@ -80,6 +84,10 @@ export class ProtocolsResource {
|
|
|
80
84
|
return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/trend-feed`, { protocol: params.protocol, topic: params.topic,
|
|
81
85
|
limit: params.limit, cursor: params.cursor, category: params.category, discovery: params.discovery });
|
|
82
86
|
}
|
|
87
|
+
/** Breaking and viral stories clustered across outlets, open networks and Minds, through the same public-only app boundary. */
|
|
88
|
+
getSharedRadar(projectId, params = {}) {
|
|
89
|
+
return this.client.get(`/protocols/public/${encodeURIComponent(projectId)}/radar`, radarQuery(params));
|
|
90
|
+
}
|
|
83
91
|
publicNetworks() {
|
|
84
92
|
return this.client.get('/protocols/social/networks');
|
|
85
93
|
}
|
|
@@ -130,6 +138,10 @@ export class ProtocolsResource {
|
|
|
130
138
|
...(params.category !== undefined ? { category: params.category } : {}),
|
|
131
139
|
...(params.discovery !== undefined ? { discovery: params.discovery } : {}) });
|
|
132
140
|
}
|
|
141
|
+
/** The app's topic radar under its own key: the same snapshot the keyless route serves. Never triggers a build. */
|
|
142
|
+
getPublicRadar(params = {}) {
|
|
143
|
+
return this.client.get('/protocols/social/radar', radarQuery(params));
|
|
144
|
+
}
|
|
133
145
|
/** Provider-ranked public accounts with explicit provenance; served from the app's index when a fresh snapshot exists. */
|
|
134
146
|
getPublicPopularProfiles(params) {
|
|
135
147
|
return this.client.get('/protocols/social/popular-profiles', { protocol: params.protocol, limit: params.limit, cursor: params.cursor });
|