@recursiv/sdk 0.7.42 → 0.7.45
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.
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DiscoveryLayout } from '../types.js';
|
|
2
|
-
|
|
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';
|
|
3
4
|
type Counts = {
|
|
4
5
|
views: number;
|
|
5
6
|
engagements: number;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
const names = ['posts', 'visual', 'conversation', 'video'];
|
|
1
|
+
const names = ['posts', 'visual', 'conversation', 'video', 'thread', 'longread', 'groups', 'people', 'tags'];
|
|
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
|
+
thread: { views: 0, engagements: 0, hides: 0 }, longread: { views: 0, engagements: 0, hides: 0 },
|
|
5
|
+
groups: { views: 0, engagements: 0, hides: 0 }, people: { views: 0, engagements: 0, hides: 0 }, tags: { views: 0, engagements: 0, hides: 0 },
|
|
4
6
|
} });
|
|
5
7
|
/** Local, per-viewer experimental presentation preferences. Stores counts only,
|
|
6
8
|
* never source text, author IDs or inferred traits. It cannot promote content,
|
|
@@ -15,10 +17,10 @@ export class DiscoveryLearning {
|
|
|
15
17
|
const time = now();
|
|
16
18
|
this.value = value?.version === 1 && Number.isFinite(value.startedAt) && value.startedAt <= time
|
|
17
19
|
&& time - value.startedAt < 14 * 86400_000 && names.every(name => {
|
|
18
|
-
const c = value.layouts?.[name];
|
|
20
|
+
const c = value.layouts?.[name] ?? { views: 0, engagements: 0, hides: 0 };
|
|
19
21
|
return c && [c.views, c.engagements, c.hides].every(n => Number.isSafeInteger(n) && n >= 0 && n <= 10000)
|
|
20
22
|
&& c.engagements <= c.views && c.hides <= c.views;
|
|
21
|
-
}) ? JSON.parse(JSON.stringify(value)) : fresh(time);
|
|
23
|
+
}) ? { ...fresh(time), ...JSON.parse(JSON.stringify(value)), layouts: { ...fresh(time).layouts, ...JSON.parse(JSON.stringify(value.layouts)) } } : fresh(time);
|
|
22
24
|
}
|
|
23
25
|
observe(id, presentation, event) {
|
|
24
26
|
if (!names.includes(presentation) || !id || id.length > 8192)
|
|
@@ -1,8 +1,44 @@
|
|
|
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
4
|
import type { ProtocolsResource, SocialNetwork, SocialPost } from './protocols.js';
|
|
5
|
-
export type
|
|
5
|
+
export type DiscoveryRecommendationKind = 'groups' | 'people' | 'tags';
|
|
6
|
+
export interface DiscoveryGroupCandidate {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
description?: string | null;
|
|
10
|
+
image?: string | null;
|
|
11
|
+
memberCount?: number | null;
|
|
12
|
+
postCount?: number | null;
|
|
13
|
+
isMember?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface DiscoveryPersonCandidate {
|
|
16
|
+
id: string;
|
|
17
|
+
name?: string | null;
|
|
18
|
+
username?: string | null;
|
|
19
|
+
image?: string | null;
|
|
20
|
+
bio?: string | null;
|
|
21
|
+
followers?: number | null;
|
|
22
|
+
isFollowing?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export interface DiscoveryTagCandidate {
|
|
25
|
+
id?: string | null;
|
|
26
|
+
name: string;
|
|
27
|
+
slug?: string | null;
|
|
28
|
+
postCount?: number | null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* App-supplied candidate lists for recommendation modules. The session decides
|
|
32
|
+
* when to show one, which kind, dedupes across pages, and drops candidates the
|
|
33
|
+
* viewer already follows or joined. Each call is bounded to three seconds and a
|
|
34
|
+
* failure simply means no recommendation on that page.
|
|
35
|
+
*/
|
|
36
|
+
export interface DiscoveryRecommendationSources {
|
|
37
|
+
groups?: () => Promise<DiscoveryGroupCandidate[]>;
|
|
38
|
+
people?: () => Promise<DiscoveryPersonCandidate[]>;
|
|
39
|
+
tags?: () => Promise<DiscoveryTagCandidate[]>;
|
|
40
|
+
}
|
|
41
|
+
export type DiscoveryPostItem = {
|
|
6
42
|
key: string;
|
|
7
43
|
kind: 'minds';
|
|
8
44
|
post: Post;
|
|
@@ -11,18 +47,36 @@ export type DiscoveryItem = {
|
|
|
11
47
|
kind: 'social';
|
|
12
48
|
post: SocialPost;
|
|
13
49
|
};
|
|
50
|
+
export type DiscoveryItem = DiscoveryPostItem | {
|
|
51
|
+
key: string;
|
|
52
|
+
kind: 'group';
|
|
53
|
+
group: DiscoveryGroupCandidate;
|
|
54
|
+
} | {
|
|
55
|
+
key: string;
|
|
56
|
+
kind: 'person';
|
|
57
|
+
person: DiscoveryPersonCandidate;
|
|
58
|
+
} | {
|
|
59
|
+
key: string;
|
|
60
|
+
kind: 'tag';
|
|
61
|
+
tag: DiscoveryTagCandidate;
|
|
62
|
+
};
|
|
63
|
+
export declare const isDiscoveryPost: (item: DiscoveryItem) => item is DiscoveryPostItem;
|
|
64
|
+
/** At most this many candidates per recommendation module. */
|
|
65
|
+
export declare const RECOMMENDATION_MAX_ITEMS = 6;
|
|
14
66
|
/** json-render Spec: values are references to authenticated SDK results, never
|
|
15
67
|
* arbitrary executable components, links, generated text or provider markup. */
|
|
16
68
|
export interface DiscoveryModule {
|
|
17
69
|
id: string;
|
|
18
|
-
presentation: DiscoveryLayout['presentation'];
|
|
70
|
+
presentation: DiscoveryLayout['presentation'] | DiscoveryRecommendationKind;
|
|
19
71
|
source: DiscoveryLayout['source'];
|
|
72
|
+
/** Subject of the featured window (a browse-category id or `mixed`), for specific module copy. */
|
|
73
|
+
theme?: string;
|
|
20
74
|
items: DiscoveryItem[];
|
|
21
75
|
spec: {
|
|
22
76
|
root: 'module';
|
|
23
77
|
elements: {
|
|
24
78
|
module: {
|
|
25
|
-
type: 'DiscoveryPosts' | 'DiscoveryGallery' | 'DiscoveryConversation' | 'DiscoveryVideo';
|
|
79
|
+
type: 'DiscoveryPosts' | 'DiscoveryGallery' | 'DiscoveryConversation' | 'DiscoveryVideo' | 'DiscoveryThread' | 'DiscoveryLongRead' | 'DiscoveryGroups' | 'DiscoveryPeople' | 'DiscoveryTags';
|
|
26
80
|
props: {
|
|
27
81
|
itemKeys: string[];
|
|
28
82
|
};
|
|
@@ -37,6 +91,8 @@ export interface DiscoveryFeedPage {
|
|
|
37
91
|
failedSources: string[];
|
|
38
92
|
partialCategories: boolean;
|
|
39
93
|
}
|
|
94
|
+
/** A gallery strip holds up to twelve image posts gathered from the whole page. */
|
|
95
|
+
export declare const GALLERY_MAX_ITEMS = 12;
|
|
40
96
|
/** Per-viewer feed session. Create a new session when user, app, category or
|
|
41
97
|
* source changes. All reads retain existing server authorization/moderation.
|
|
42
98
|
* At most four requests per page; providers fail independently. */
|
|
@@ -45,16 +101,18 @@ export declare class DiscoveryFeedSession {
|
|
|
45
101
|
private protocols;
|
|
46
102
|
private category?;
|
|
47
103
|
private learning?;
|
|
104
|
+
private recommend?;
|
|
48
105
|
private streams;
|
|
49
106
|
private pending;
|
|
50
107
|
private seen;
|
|
51
108
|
private page;
|
|
52
109
|
private nextSource;
|
|
53
|
-
constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined);
|
|
110
|
+
constructor(posts: Pick<PostsResource, 'list'>, protocols: Pick<ProtocolsResource, 'getPublicTrends' | 'getPublicTrendFeed'>, networks: SocialNetwork[], category?: string | undefined, source?: string, learning?: DiscoveryLearning | undefined, recommend?: DiscoveryRecommendationSources | undefined);
|
|
54
111
|
retryFailed(): void;
|
|
55
112
|
next(): Promise<DiscoveryFeedPage>;
|
|
56
113
|
private read;
|
|
57
114
|
private readPage;
|
|
115
|
+
private recommendation;
|
|
58
116
|
}
|
|
59
117
|
export declare class DiscoveryResource {
|
|
60
118
|
private posts;
|
|
@@ -65,5 +123,6 @@ export declare class DiscoveryResource {
|
|
|
65
123
|
category?: string;
|
|
66
124
|
source?: string;
|
|
67
125
|
learning?: DiscoveryLearning;
|
|
126
|
+
recommend?: DiscoveryRecommendationSources;
|
|
68
127
|
}): DiscoveryFeedSession;
|
|
69
128
|
}
|
|
@@ -1,5 +1,43 @@
|
|
|
1
|
+
export const isDiscoveryPost = (item) => item.kind === 'minds' || item.kind === 'social';
|
|
2
|
+
/** At most this many candidates per recommendation module. */
|
|
3
|
+
export const RECOMMENDATION_MAX_ITEMS = 6;
|
|
4
|
+
const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
|
|
5
|
+
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
|
|
6
|
+
function recommendationItem(kind, candidate) {
|
|
7
|
+
if (!candidate || typeof candidate !== 'object')
|
|
8
|
+
return null;
|
|
9
|
+
const value = candidate;
|
|
10
|
+
if (kind === 'groups') {
|
|
11
|
+
if (!nonEmpty(value.id) || !nonEmpty(value.name) || value.isMember === true)
|
|
12
|
+
return null;
|
|
13
|
+
return { key: `rec:group:${value.id}`, kind: 'group', group: value };
|
|
14
|
+
}
|
|
15
|
+
if (kind === 'people') {
|
|
16
|
+
if (!nonEmpty(value.id) || value.isFollowing === true)
|
|
17
|
+
return null;
|
|
18
|
+
return { key: `rec:person:${value.id}`, kind: 'person', person: value };
|
|
19
|
+
}
|
|
20
|
+
if (!nonEmpty(value.name))
|
|
21
|
+
return null;
|
|
22
|
+
return { key: `rec:tag:${(nonEmpty(value.slug) ? value.slug : value.name).toLowerCase()}`, kind: 'tag', tag: value };
|
|
23
|
+
}
|
|
1
24
|
const ordinary = { version: 'discovery-layout-v1', presentation: 'posts', source: 'fallback' };
|
|
2
25
|
const key = (protocol, id) => JSON.stringify([protocol, id]);
|
|
26
|
+
const presentations = new Set(['posts', 'visual', 'conversation', 'video', 'thread', 'longread']);
|
|
27
|
+
/** A gallery strip holds up to twelve image posts gathered from the whole page. */
|
|
28
|
+
export const GALLERY_MAX_ITEMS = 12;
|
|
29
|
+
/** Minimum text for a reading card; mirrors the server's DISCOVERY_LONGREAD_MIN_CHARS. */
|
|
30
|
+
const LONGREAD_MIN_CHARS = 600;
|
|
31
|
+
const quotesAnother = (item) => item.kind === 'social' ? item.post.quote?.state === 'available' : !!item.post.reposted_from && !!item.post.content?.trim();
|
|
32
|
+
const textLength = (item) => item.kind === 'social' ? item.post.text.length : item.post.content?.length ?? 0;
|
|
33
|
+
/** The layout for one response position: its window's judgment when the server provided windows, else the batch-wide one. */
|
|
34
|
+
function windowLayout(layout, index) {
|
|
35
|
+
const window = layout.windows?.find(candidate => Number.isInteger(candidate.start) && Number.isInteger(candidate.end) && index >= candidate.start && index < candidate.end);
|
|
36
|
+
if (!window || !presentations.has(window.presentation) || !['jev', 'fallback'].includes(window.source))
|
|
37
|
+
return layout;
|
|
38
|
+
const theme = typeof window.theme === 'string' && /^[a-z][a-z0-9-]{0,31}$/.test(window.theme) ? window.theme : undefined;
|
|
39
|
+
return { version: layout.version, presentation: window.presentation, source: window.source, ...(theme ? { theme } : {}) };
|
|
40
|
+
}
|
|
3
41
|
/** Per-viewer feed session. Create a new session when user, app, category or
|
|
4
42
|
* source changes. All reads retain existing server authorization/moderation.
|
|
5
43
|
* At most four requests per page; providers fail independently. */
|
|
@@ -8,16 +46,18 @@ export class DiscoveryFeedSession {
|
|
|
8
46
|
protocols;
|
|
9
47
|
category;
|
|
10
48
|
learning;
|
|
49
|
+
recommend;
|
|
11
50
|
streams;
|
|
12
51
|
pending = null;
|
|
13
52
|
seen = new Set();
|
|
14
53
|
page = 0;
|
|
15
54
|
nextSource = 0;
|
|
16
|
-
constructor(posts, protocols, networks, category, source, learning) {
|
|
55
|
+
constructor(posts, protocols, networks, category, source, learning, recommend) {
|
|
17
56
|
this.posts = posts;
|
|
18
57
|
this.protocols = protocols;
|
|
19
58
|
this.category = category;
|
|
20
59
|
this.learning = learning;
|
|
60
|
+
this.recommend = recommend;
|
|
21
61
|
const sources = [...new Set(networks.filter(n => n.capabilities.includes('public_trends')).map(n => n.protocol))].slice(0, 16);
|
|
22
62
|
this.streams = ['minds', ...sources].filter(protocol => !source || source === 'all' || source === protocol)
|
|
23
63
|
.map(protocol => ({ protocol, offset: 0, done: false, failed: false, used: [] }));
|
|
@@ -31,9 +71,11 @@ export class DiscoveryFeedSession {
|
|
|
31
71
|
return this.pending;
|
|
32
72
|
}
|
|
33
73
|
async read(stream) {
|
|
34
|
-
const params = { limit:
|
|
74
|
+
const params = { limit: 25, ...(this.category ? { category: this.category } : {}), discovery: true };
|
|
35
75
|
if (stream.protocol === 'minds') {
|
|
36
|
-
|
|
76
|
+
// Discover leads with what is moving now: the REST hot order (engagement
|
|
77
|
+
// decayed by age). Hot pages by offset; the server sends no cursor for it.
|
|
78
|
+
const response = await this.posts.list({ ...params, sort: 'hot', ...(stream.cursor ? { cursor: stream.cursor } : { offset: stream.offset }) });
|
|
37
79
|
stream.offset += response.meta.scanned ?? response.meta.limit;
|
|
38
80
|
const next = response.meta.next_cursor ?? undefined;
|
|
39
81
|
stream.done = !response.meta.has_more || !!next && (next === stream.cursor || stream.used.includes(next));
|
|
@@ -110,6 +152,8 @@ export class DiscoveryFeedSession {
|
|
|
110
152
|
}
|
|
111
153
|
}));
|
|
112
154
|
// Round-robin before grouping: the largest network cannot occupy every slot.
|
|
155
|
+
// Each item carries the judgment for its own window of its source response,
|
|
156
|
+
// so one page can feature several modules instead of one per source batch.
|
|
113
157
|
const mixed = [];
|
|
114
158
|
for (let index = 0; index < Math.max(0, ...reads.map(read => read.items.length)); index++)
|
|
115
159
|
for (const read of reads) {
|
|
@@ -117,7 +161,7 @@ export class DiscoveryFeedSession {
|
|
|
117
161
|
if (!item || this.seen.has(item.key))
|
|
118
162
|
continue;
|
|
119
163
|
this.seen.add(item.key);
|
|
120
|
-
mixed.push({ item, layout: read.layout });
|
|
164
|
+
mixed.push({ item, layout: windowLayout(read.layout, index) });
|
|
121
165
|
}
|
|
122
166
|
// Deduplication memory is bounded independently of the UI's virtual window.
|
|
123
167
|
while (this.seen.size > 4096)
|
|
@@ -125,31 +169,89 @@ export class DiscoveryFeedSession {
|
|
|
125
169
|
const modules = [];
|
|
126
170
|
const page = this.page++;
|
|
127
171
|
let remaining = mixed;
|
|
172
|
+
// One gallery strip per page: it may gather images from the whole page, so a
|
|
173
|
+
// second one would only sweep up leftovers into a stub.
|
|
174
|
+
let galleryMinted = false;
|
|
128
175
|
while (remaining.length) {
|
|
129
176
|
const index = modules.length;
|
|
130
177
|
const first = remaining[0];
|
|
131
178
|
const proposal = first.layout.source === 'jev' ? first.layout.presentation : 'posts';
|
|
132
179
|
const preferred = this.learning?.choose(proposal, page) ?? proposal;
|
|
133
|
-
// Feature eligible posts
|
|
134
|
-
//
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
const
|
|
180
|
+
// Feature eligible posts, retaining the other posts in their original
|
|
181
|
+
// order. A gallery is a horizontal strip, so it may gather image posts
|
|
182
|
+
// from the whole page (up to twelve); conversations and threads stay
|
|
183
|
+
// near their position so the vertical feed keeps its sense of time.
|
|
184
|
+
const group = remaining.slice(0, 12);
|
|
185
|
+
const images = remaining.filter(row => row.item.post.media?.some(media => media.type === 'image')).slice(0, GALLERY_MAX_ITEMS);
|
|
186
|
+
const conversations = group.filter(row => row.item.kind === 'social' ? (row.item.post.replyCount ?? 0) > 0 : (row.item.post.reply_count ?? 0) > 0).slice(0, 6);
|
|
187
|
+
const quotes = group.filter(row => quotesAnother(row.item)).slice(0, 6);
|
|
139
188
|
const video = preferred === 'video' && first.item.post.media?.some(media => media.type === 'video');
|
|
140
|
-
const visual = preferred === 'visual' && images.length >= 2;
|
|
189
|
+
const visual = preferred === 'visual' && !galleryMinted && images.length >= 2;
|
|
190
|
+
if (visual)
|
|
191
|
+
galleryMinted = true;
|
|
141
192
|
const discussion = preferred === 'conversation' && conversations.length >= 2;
|
|
142
|
-
const
|
|
143
|
-
|
|
193
|
+
const thread = preferred === 'thread' && quotes.length >= 2;
|
|
194
|
+
// A reading card features the post itself, never a neighbour: the first row must be the long one.
|
|
195
|
+
const longread = preferred === 'longread' && textLength(first.item) >= LONGREAD_MIN_CHARS;
|
|
196
|
+
const presentation = video ? 'video' : visual ? 'visual' : discussion ? 'conversation' : thread ? 'thread' : longread ? 'longread' : 'posts';
|
|
197
|
+
const selected = visual ? images : discussion ? conversations : thread ? quotes : [first];
|
|
144
198
|
const items = selected.map(row => row.item);
|
|
145
|
-
|
|
146
|
-
|
|
199
|
+
const type = video ? 'DiscoveryVideo' : visual ? 'DiscoveryGallery' : discussion ? 'DiscoveryConversation' : thread ? 'DiscoveryThread' : longread ? 'DiscoveryLongRead' : 'DiscoveryPosts';
|
|
200
|
+
const theme = presentation !== 'posts' && first.layout.theme && first.layout.theme !== 'mixed' ? first.layout.theme : undefined;
|
|
201
|
+
modules.push({ id: `discovery-${page}-${index}-${items[0].key}`, presentation, source: presentation === 'posts' ? 'fallback' : 'jev', items, ...(theme ? { theme } : {}),
|
|
202
|
+
spec: { root: 'module', elements: { module: { type, props: { itemKeys: items.map(item => item.key) }, children: [] } } } });
|
|
147
203
|
const selectedKeys = new Set(items.map(item => item.key));
|
|
148
204
|
remaining = remaining.filter(row => !selectedKeys.has(row.item.key));
|
|
149
205
|
}
|
|
206
|
+
// One recommendation module per page with posts, rotating kind, placed after
|
|
207
|
+
// the opening modules so the page still leads with content.
|
|
208
|
+
const recommendation = modules.length ? await this.recommendation(page) : null;
|
|
209
|
+
if (recommendation)
|
|
210
|
+
modules.splice(Math.min(2, modules.length), 0, recommendation);
|
|
150
211
|
return { modules, hasMore: this.streams.some(stream => !stream.done && !stream.failed),
|
|
151
212
|
failedSources: this.streams.filter(stream => stream.failed).map(stream => stream.protocol), partialCategories: reads.some(read => read.partial) };
|
|
152
213
|
}
|
|
214
|
+
async recommendation(page) {
|
|
215
|
+
const kinds = RECOMMENDATION_KINDS.filter(kind => typeof this.recommend?.[kind] === 'function');
|
|
216
|
+
const kind = kinds[page % Math.max(1, kinds.length)];
|
|
217
|
+
if (!kind)
|
|
218
|
+
return null;
|
|
219
|
+
// A viewer who keeps hiding a kind stops seeing it, through the same bandit as layouts.
|
|
220
|
+
if (this.learning && this.learning.choose(kind, page) === 'posts')
|
|
221
|
+
return null;
|
|
222
|
+
let candidates;
|
|
223
|
+
let cancel = () => { };
|
|
224
|
+
try {
|
|
225
|
+
candidates = await Promise.race([this.recommend[kind](), new Promise((_resolve, reject) => {
|
|
226
|
+
const timer = setTimeout(() => reject(new Error('Recommendation source timed out')), 3000);
|
|
227
|
+
cancel = () => clearTimeout(timer);
|
|
228
|
+
})]);
|
|
229
|
+
}
|
|
230
|
+
catch {
|
|
231
|
+
return null;
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
cancel();
|
|
235
|
+
}
|
|
236
|
+
if (!Array.isArray(candidates))
|
|
237
|
+
return null;
|
|
238
|
+
const items = [];
|
|
239
|
+
for (const candidate of candidates) {
|
|
240
|
+
const item = recommendationItem(kind, candidate);
|
|
241
|
+
if (!item || this.seen.has(item.key) || items.some(existing => existing.key === item.key))
|
|
242
|
+
continue;
|
|
243
|
+
items.push(item);
|
|
244
|
+
if (items.length >= RECOMMENDATION_MAX_ITEMS)
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
if (items.length < 2)
|
|
248
|
+
return null;
|
|
249
|
+
for (const item of items)
|
|
250
|
+
this.seen.add(item.key);
|
|
251
|
+
const type = kind === 'groups' ? 'DiscoveryGroups' : kind === 'people' ? 'DiscoveryPeople' : 'DiscoveryTags';
|
|
252
|
+
return { id: `discovery-${page}-recommend-${kind}`, presentation: kind, source: 'fallback', items,
|
|
253
|
+
spec: { root: 'module', elements: { module: { type, props: { itemKeys: items.map(item => item.key) }, children: [] } } } };
|
|
254
|
+
}
|
|
153
255
|
}
|
|
154
256
|
export class DiscoveryResource {
|
|
155
257
|
posts;
|
|
@@ -159,6 +261,6 @@ export class DiscoveryResource {
|
|
|
159
261
|
this.protocols = protocols;
|
|
160
262
|
}
|
|
161
263
|
createFeed(options) {
|
|
162
|
-
return new DiscoveryFeedSession(this.posts, this.protocols, options.networks, options.category, options.source, options.learning);
|
|
264
|
+
return new DiscoveryFeedSession(this.posts, this.protocols, options.networks, options.category, options.source, options.learning, options.recommend);
|
|
163
265
|
}
|
|
164
266
|
}
|
|
@@ -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. */
|
package/dist/types.d.ts
CHANGED
|
@@ -5,10 +5,22 @@ export interface PaginationParams {
|
|
|
5
5
|
cursor?: string;
|
|
6
6
|
[key: string]: unknown;
|
|
7
7
|
}
|
|
8
|
+
/** One judgment per consecutive window of the response; `start`/`end` index the response order. */
|
|
9
|
+
export interface DiscoveryLayoutWindow {
|
|
10
|
+
start: number;
|
|
11
|
+
end: number;
|
|
12
|
+
presentation: 'posts' | 'visual' | 'conversation' | 'video' | 'thread' | 'longread';
|
|
13
|
+
source: 'jev' | 'fallback';
|
|
14
|
+
/** Browse-category id (or `mixed`) the window's posts share; lets a module be titled after its subject. */
|
|
15
|
+
theme?: string;
|
|
16
|
+
}
|
|
8
17
|
export interface DiscoveryLayout {
|
|
9
18
|
version: 'discovery-layout-v1';
|
|
10
|
-
|
|
19
|
+
/** The first featured window's presentation; readers that understand `windows` use those instead. */
|
|
20
|
+
presentation: 'posts' | 'visual' | 'conversation' | 'video' | 'thread' | 'longread';
|
|
11
21
|
source: 'jev' | 'fallback';
|
|
22
|
+
theme?: string;
|
|
23
|
+
windows?: DiscoveryLayoutWindow[];
|
|
12
24
|
}
|
|
13
25
|
export interface PaginationMeta {
|
|
14
26
|
discovery?: DiscoveryLayout;
|