@recursiv/sdk 0.7.52 → 0.7.53

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.
@@ -8,6 +8,20 @@ const RADAR_MAX_MEMBERS = 12;
8
8
  const RADAR_MAX_MINDS_READS = 4;
9
9
  /** A discovery page holds its own deadline: a retried 5xx from one provider only makes the whole page wait. */
10
10
  const NO_RETRY = { retry: false };
11
+ /**
12
+ * The provider no longer lists the topic (`trend_unavailable`): that topic's end, never a network
13
+ * failure. Matched on the server's code, which every SDK error carries: any other 404 on the route
14
+ * (`unsupported_protocol`, a missing route) is the network failing, and still fails the source.
15
+ */
16
+ const isGone = (error) => typeof error === 'object' && error !== null && error.code === 'trend_unavailable';
17
+ /**
18
+ * Topic pages one read of a topics network walks past when each is gone or carries nothing in the
19
+ * category, so a page reaches a topic that carries posts. Live (2026-09-26, Bluesky, news) 21 of 25
20
+ * topics answered no posts in the category; one topic a read left the network out of most pages.
21
+ */
22
+ const TOPIC_WALK = 5;
23
+ /** The walk starts no topic read after this long: well inside the page deadline, so it commits its progress rather than timing the network out. */
24
+ const TOPIC_WALK_MS = 8000;
11
25
  const RECOMMENDATION_KINDS = ['groups', 'people', 'tags'];
12
26
  const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0 && value.length <= 512;
13
27
  function recommendationItem(kind, candidate) {
@@ -116,7 +130,7 @@ function windowLayout(layout, index) {
116
130
  }
117
131
  /** Per-viewer feed session. Create a new session when user, app, category or
118
132
  * source changes. All reads retain existing server authorization/moderation.
119
- * At most four requests per page; providers fail independently. */
133
+ * At most four sources per page (a topics network's read walks up to TOPIC_WALK topic pages); providers fail independently. */
120
134
  export class DiscoveryFeedSession {
121
135
  posts;
122
136
  protocols;
@@ -181,23 +195,9 @@ export class DiscoveryFeedSession {
181
195
  if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(post => post.protocol !== stream.protocol || post.author.protocol !== stream.protocol))
182
196
  throw new Error('Invalid discovery source response');
183
197
  };
184
- if (stream.topics) {
185
- const topic = stream.topics[0];
186
- if (!topic) {
187
- stream.done = true;
188
- return { items: [], layout: ordinary, partial: false };
189
- }
190
- const { data } = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY);
191
- validatePosts(data);
192
- if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
193
- throw new Error('Invalid discovery topic');
194
- const next = data.cursor;
195
- stream.topics.shift();
196
- if (next && next !== topic.cursor && !topic.used.includes(next))
197
- stream.topics.push({ topic: topic.topic, cursor: next, used: [...topic.used, ...(topic.cursor ? [topic.cursor] : [])].slice(-32) });
198
- stream.done = !stream.topics.length;
199
- return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial: data.categoryFilter?.status === 'partial' };
200
- }
198
+ const started = Date.now();
199
+ if (stream.topics)
200
+ return this.walkTopics(stream, params, validatePosts, false, started);
201
201
  const { data } = await this.protocols.getPublicTrends({ ...params, protocol: stream.protocol, cursor: stream.cursor, includePreviews: true }, NO_RETRY);
202
202
  if (data.coverage !== 'provider_ranked_window' || data.items.length > 25 || data.items.some(item => item.protocol !== stream.protocol))
203
203
  throw new Error('Invalid discovery source response');
@@ -206,7 +206,14 @@ export class DiscoveryFeedSession {
206
206
  throw new Error('Invalid discovery preview');
207
207
  stream.topics = [...new Set(data.items.map(topic => topic.nativeId))].map(topic => ({ topic, used: [] }));
208
208
  stream.done = !stream.topics.length;
209
- return { items: data.items.flatMap(topic => !this.category && topic.preview ? [{ key: key(topic.preview.protocol, topic.preview.nativeId), kind: 'social', post: topic.preview }] : []), layout: ordinary, partial: data.categoryFilter?.status === 'partial' };
209
+ const partial = data.categoryFilter?.status === 'partial';
210
+ // Outside a category the catalog's previews are this page. In a category a preview is not
211
+ // judged to be in it, so the catalog carries no card: the read walks into its topics now,
212
+ // not a round later, so the network's first page in a category is not empty by design.
213
+ const previews = data.items.flatMap(topic => !this.category && topic.preview ? [{ key: key(topic.preview.protocol, topic.preview.nativeId), kind: 'social', post: topic.preview }] : []);
214
+ if (previews.length || stream.done)
215
+ return { items: previews, layout: ordinary, partial };
216
+ return this.walkTopics(stream, params, validatePosts, partial, started);
210
217
  }
211
218
  validatePosts(data);
212
219
  const next = data.cursor ?? undefined;
@@ -216,6 +223,42 @@ export class DiscoveryFeedSession {
216
223
  stream.cursor = next;
217
224
  return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial: data.categoryFilter?.status === 'partial' };
218
225
  }
226
+ /**
227
+ * One read of a topics network: the next topic page that carries posts. A topic the provider no
228
+ * longer lists (Bluesky rotates its trends between the catalog read and the walk through it) is
229
+ * that topic's end, not the network's failure; a topic page with nothing in the category is
230
+ * walked past too, and its continuation kept, so the page reaches a topic that carries posts
231
+ * instead of spending a round on each empty one. Bounded by TOPIC_WALK reads and TOPIC_WALK_MS;
232
+ * a walk that stops short has moved the stream on, and the next read continues from there.
233
+ */
234
+ async walkTopics(stream, params, validatePosts, partial, started) {
235
+ const topics = stream.topics;
236
+ for (let reads = 0; reads < TOPIC_WALK && Date.now() - started < TOPIC_WALK_MS; reads++) {
237
+ const topic = topics[0];
238
+ if (!topic)
239
+ break;
240
+ const response = await this.protocols.getPublicTrendFeed({ ...params, protocol: stream.protocol, topic: topic.topic, cursor: topic.cursor }, NO_RETRY)
241
+ .catch((error) => { if (isGone(error))
242
+ return null; throw error; });
243
+ topics.shift();
244
+ if (!response)
245
+ continue;
246
+ const { data } = response;
247
+ validatePosts(data);
248
+ if (data.trend.protocol !== stream.protocol || data.trend.nativeId !== topic.topic)
249
+ throw new Error('Invalid discovery topic');
250
+ const next = data.cursor;
251
+ if (next && next !== topic.cursor && !topic.used.includes(next))
252
+ topics.push({ topic: topic.topic, cursor: next, used: [...topic.used, ...(topic.cursor ? [topic.cursor] : [])].slice(-32) });
253
+ partial ||= data.categoryFilter?.status === 'partial';
254
+ if (data.items.length) {
255
+ stream.done = !topics.length;
256
+ return { items: data.items.map(post => ({ key: key(post.protocol, post.nativeId), kind: 'social', post })), layout: data.discovery ?? ordinary, partial };
257
+ }
258
+ }
259
+ stream.done = !topics.length;
260
+ return { items: [], layout: ordinary, partial };
261
+ }
219
262
  async readPage() {
220
263
  // The radar is one small snapshot read: start it with the page, not after the slowest provider,
221
264
  // and start the lead story's member reads the moment the snapshot names it.
@@ -466,10 +509,30 @@ export class DiscoveryResource {
466
509
  posts;
467
510
  protocols;
468
511
  curator;
469
- constructor(posts, protocols, curator) {
512
+ http;
513
+ constructor(posts, protocols, curator, http) {
470
514
  this.posts = posts;
471
515
  this.protocols = protocols;
472
516
  this.curator = curator;
517
+ this.http = http;
518
+ }
519
+ /**
520
+ * Suggestions for the signed-in viewer from the app's one recommender: active or warm members, groups
521
+ * with several posters, tags used by several people, open-network accounts or stories, overall or in one
522
+ * browse category. What the viewer follows, joined, blocked or muted is left out, and so is anything
523
+ * resting for them. Call `recordImpressions` when suggestions are actually on screen and when one is tapped.
524
+ */
525
+ recommendations(params, options) {
526
+ if (!this.http)
527
+ throw new Error('recommendations needs the HTTP client');
528
+ const { exclude, ...rest } = params;
529
+ return this.http.get('/discover/recommendations', { ...rest, exclude: exclude?.length ? exclude.join(',') : undefined }, options);
530
+ }
531
+ /** Records that suggestions were shown (at most once per half hour counts) or engaged with. */
532
+ recordImpressions(input) {
533
+ if (!this.http)
534
+ throw new Error('recordImpressions needs the HTTP client');
535
+ return this.http.post('/discover/impressions', input);
473
536
  }
474
537
  /**
475
538
  * A For you Discover feed: the member's For You ranking (asked for Discover's surface, with layouts and a
@@ -9,7 +9,13 @@ export interface LinkPreview {
9
9
  siteName: string | null;
10
10
  }
11
11
  export interface LinkPreviewResponse {
12
- data: LinkPreview;
12
+ /**
13
+ * The preview, or null when the link cannot be previewed: the page refused a
14
+ * server (a private repository, a paywall or bot wall), is not HTML, or could
15
+ * not be reached. Show the plain link. Servers before this change answered
16
+ * 422 for these links instead, so treat that rejection the same way.
17
+ */
18
+ data: LinkPreview | null;
13
19
  cached?: boolean;
14
20
  }
15
21
  export declare class LinkPreviewResource {
@@ -17,9 +23,10 @@ export declare class LinkPreviewResource {
17
23
  constructor(client: HttpClient);
18
24
  /**
19
25
  * Fetch Open Graph / Twitter card metadata for a public URL. Cached
20
- * server-side so repeated lookups are cheap. No auth required — the
21
- * endpoint is intentionally public so unauthenticated UIs can render
22
- * link previews.
26
+ * server-side so repeated lookups are cheap, including links that have no
27
+ * preview (`data: null`). No auth required — the endpoint is intentionally
28
+ * public so unauthenticated UIs can render link previews. A URL that is not
29
+ * http(s), or points at a private or internal address, still rejects (422).
23
30
  */
24
31
  get(url: string): Promise<LinkPreviewResponse>;
25
32
  }
@@ -5,9 +5,10 @@ export class LinkPreviewResource {
5
5
  }
6
6
  /**
7
7
  * Fetch Open Graph / Twitter card metadata for a public URL. Cached
8
- * server-side so repeated lookups are cheap. No auth required — the
9
- * endpoint is intentionally public so unauthenticated UIs can render
10
- * link previews.
8
+ * server-side so repeated lookups are cheap, including links that have no
9
+ * preview (`data: null`). No auth required — the endpoint is intentionally
10
+ * public so unauthenticated UIs can render link previews. A URL that is not
11
+ * http(s), or points at a private or internal address, still rejects (422).
11
12
  */
12
13
  get(url) {
13
14
  return this.client.get('/link-preview', { url });
@@ -7,6 +7,12 @@ export interface NotificationActor {
7
7
  username: string | null;
8
8
  image: string | null;
9
9
  is_ai: boolean | null;
10
+ /**
11
+ * Follow rows only: whether the viewer already follows this person, so
12
+ * Follow back can render without reading the follow set. Absent on other
13
+ * rows and from servers older than this field.
14
+ */
15
+ viewer_follows?: boolean;
10
16
  }
11
17
  /**
12
18
  * The post a notification references, as it reads NOW, with the viewer's own
@@ -15,7 +21,18 @@ export interface NotificationActor {
15
21
  */
16
22
  export interface NotificationPostPreview {
17
23
  id: string;
24
+ /**
25
+ * The post's words as plain text (no Markdown or HTML marks; a link reads as
26
+ * its label and a bare URL as its site), up to 140 characters. A longer post
27
+ * is cut at the last whole word and ends in "…". Show it as it is.
28
+ */
18
29
  excerpt: string | null;
30
+ /**
31
+ * True when `excerpt` is a cut of a longer post. Use this rather than the
32
+ * excerpt's length to decide whether there is more to read. Absent from
33
+ * servers older than this field.
34
+ */
35
+ excerpt_truncated?: boolean;
19
36
  media_url: string | null;
20
37
  media_type: string | null;
21
38
  reactions_count?: number;
@@ -24,7 +41,10 @@ export interface NotificationPostPreview {
24
41
  downvote_count?: number;
25
42
  /** The viewer's own reaction on this post ('upvote', 'downvote', ...), if any. */
26
43
  user_reaction?: string | null;
44
+ /** Stored replies a reader can open. */
27
45
  reply_count?: number;
46
+ /** A legacy import's comment count; none of those comments are stored as replies. */
47
+ reply_count_external?: number;
28
48
  unique_commenters?: number;
29
49
  reposts_count?: number;
30
50
  /** Id of the viewer's own repost of this post; delete it to undo the repost. */
@@ -43,6 +63,12 @@ export interface Notification {
43
63
  read_at: string | null;
44
64
  /** For `post_reaction` rows: which reaction ('upvote', 'downvote', ...). Null otherwise or on older rows. */
45
65
  reaction_type?: string | null;
66
+ /**
67
+ * For `agent_needs_input` rows: whether you have written in the agent's
68
+ * thread since it asked, which is what answers an ask. The title then reads
69
+ * "You answered <agent>". Null on other rows; absent from older servers.
70
+ */
71
+ answered?: boolean | null;
46
72
  actor?: NotificationActor | null;
47
73
  post_preview?: NotificationPostPreview | null;
48
74
  }
@@ -1,5 +1,6 @@
1
1
  import type { HttpClient } from '../client.js';
2
- import type { PaginatedResponse, SingleResponse, DeleteResponse, Post, PostDetail, CreatePostInput, CreatedPost, UpdatePostInput, ListPostsParams, ListPostRepliesParams, PostRepliesPage, SearchPostsParams, ReactionType } from '../types.js';
2
+ import type { PaginatedResponse, SingleResponse, DeleteResponse, Post, PostDetail, CreatePostInput, CreatedPost, UpdatePostInput, ListPostsParams, ListPostRepliesParams, PostRepliesPage, SearchPostsParams, SearchPostsResponse, ReactionType } from '../types.js';
3
+ import type { BookmarkedPost, BookmarksPage, SavedBookmark, UnsavedBookmark } from './bookmarks.js';
3
4
  /** How GET /posts/categories ordered its answer: the member's own order, the app's popularity order, or the fixed order. */
4
5
  export type CategoryOrder = 'personal' | 'popular' | 'static';
5
6
  /** Why an answer is not `personal`. */
@@ -33,6 +34,19 @@ export declare class PostsResource {
33
34
  }>;
34
35
  /** List posts the authenticated caller has upvoted. */
35
36
  liked(params?: Pick<ListPostsParams, 'limit' | 'offset' | 'cursor'>): Promise<PaginatedResponse<Post>>;
37
+ /**
38
+ * The caller's saved posts in this app, newest saved first, each with `bookmarked_at`. Page on
39
+ * `meta.next_cursor` (a page can be short while it skips posts the member can no longer read).
40
+ * Same as `bookmarks.posts`; 503 `bookmarks_unavailable` where synced bookmarks are not available.
41
+ */
42
+ bookmarked(params?: {
43
+ limit?: number;
44
+ cursor?: string;
45
+ }): Promise<BookmarksPage<BookmarkedPost>>;
46
+ /** Save a post to the caller's synced bookmarks (idempotent). A bare repost is saved as its original: see `post_id`. */
47
+ bookmark(postId: string): Promise<SingleResponse<SavedBookmark>>;
48
+ /** Remove a post from the caller's synced bookmarks (idempotent). Pass the `post_id` its save returned. */
49
+ unbookmark(postId: string): Promise<SingleResponse<UnsavedBookmark>>;
36
50
  /** Posts similar to this one (embedding neighbours), in the app's scope. */
37
51
  similar(id: string, params?: {
38
52
  limit?: number;
@@ -48,8 +62,12 @@ export declare class PostsResource {
48
62
  update(id: string, input: UpdatePostInput): Promise<SingleResponse<CreatedPost>>;
49
63
  /** Delete a post */
50
64
  delete(id: string): Promise<DeleteResponse>;
51
- /** Search posts by text query */
52
- search(params: SearchPostsParams): Promise<PaginatedResponse<Post>>;
65
+ /**
66
+ * Search posts by text query. Relevance (the default) ranks relevance x recency x engagement through
67
+ * the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as
68
+ * `cursor`; the first relevance page carries `latest`, the last page `fall_over` (SearchPostsResponse).
69
+ */
70
+ search(params: SearchPostsParams): Promise<SearchPostsResponse>;
53
71
  /** Add or update a reaction on a post */
54
72
  react(postId: string, type: ReactionType): Promise<SingleResponse<{
55
73
  success: true;
@@ -20,6 +20,22 @@ export class PostsResource {
20
20
  liked(params) {
21
21
  return this.list({ ...params, liked: true });
22
22
  }
23
+ /**
24
+ * The caller's saved posts in this app, newest saved first, each with `bookmarked_at`. Page on
25
+ * `meta.next_cursor` (a page can be short while it skips posts the member can no longer read).
26
+ * Same as `bookmarks.posts`; 503 `bookmarks_unavailable` where synced bookmarks are not available.
27
+ */
28
+ bookmarked(params) {
29
+ return this.client.get('/posts', { bookmarked: true, ...params });
30
+ }
31
+ /** Save a post to the caller's synced bookmarks (idempotent). A bare repost is saved as its original: see `post_id`. */
32
+ bookmark(postId) {
33
+ return this.client.put('/bookmarks', { kind: 'post', post_id: postId });
34
+ }
35
+ /** Remove a post from the caller's synced bookmarks (idempotent). Pass the `post_id` its save returned. */
36
+ unbookmark(postId) {
37
+ return this.client.delete(`/bookmarks/${encodeURIComponent(`post:${postId}`)}`);
38
+ }
23
39
  /** Posts similar to this one (embedding neighbours), in the app's scope. */
24
40
  similar(id, params) {
25
41
  return this.client.get(`/posts/${encodeURIComponent(id)}/similar`, params);
@@ -44,7 +60,11 @@ export class PostsResource {
44
60
  delete(id) {
45
61
  return this.client.delete(`/posts/${id}`);
46
62
  }
47
- /** Search posts by text query */
63
+ /**
64
+ * Search posts by text query. Relevance (the default) ranks relevance x recency x engagement through
65
+ * the promotion safety floor; `sort: 'new'` lists newest first. Page with `meta.next_cursor` as
66
+ * `cursor`; the first relevance page carries `latest`, the last page `fall_over` (SearchPostsResponse).
67
+ */
48
68
  search(params) {
49
69
  return this.client.get('/posts/search', params);
50
70
  }
@@ -1,5 +1,5 @@
1
1
  import type { HttpClient } from '../client.js';
2
- import type { PaginatedResponse, SingleResponse, SuccessResponse, Profile, ProfileDetail, ProfileMe, LeaderboardEntry, UpdateProfileInput, SearchProfilesParams, SearchMentionsParams, MentionCandidate, FollowListProfile, BlockedProfile, MutedProfile, ModerationListParams, ViewerRelationship } from '../types.js';
2
+ import type { PaginatedResponse, SingleResponse, SuccessResponse, Profile, ProfileDetail, ProfileMe, LeaderboardEntry, UpdateProfileInput, SearchProfilesParams, ProfileSearchResult, SearchMentionsParams, MentionCandidate, FollowListParams, FollowListProfile, BlockedProfile, MutedProfile, ModerationListParams, ViewerRelationship } from '../types.js';
3
3
  export declare class ProfilesResource {
4
4
  private client;
5
5
  constructor(client: HttpClient);
@@ -15,14 +15,25 @@ export declare class ProfilesResource {
15
15
  /** Get a user by username */
16
16
  getByUsername(username: string): Promise<SingleResponse<ProfileDetail>>;
17
17
  /** Search users by name or username */
18
- search(params: SearchProfilesParams): Promise<PaginatedResponse<Profile>>;
18
+ /**
19
+ * Search accounts by name or handle, ranked by match x ln(1 + followers + min(posts, 100)) (doubled for an
20
+ * official account): empties last and marked `empty`, the official ones `verified`. Every page is
21
+ * a slice of one ranked pool of 200; past it `has_more` is false.
22
+ */
23
+ search(params: SearchProfilesParams): Promise<PaginatedResponse<ProfileSearchResult>>;
19
24
  /**
20
25
  * Accounts to @mention: people and agents on the caller's network, the
21
26
  * caller's follows first. Unlike `search`, this includes agents the app may
22
27
  * discover (an agent answers when mentioned); another member's personal AI
23
28
  * is never offered.
29
+ *
30
+ * A search that runs over its 2 s budget answers 503 with Retry-After. A
31
+ * type-ahead has its own deadline, so pass `{ retry: false }` to get that
32
+ * answer at once instead of waiting through retries.
24
33
  */
25
- mentions(params: SearchMentionsParams): Promise<PaginatedResponse<MentionCandidate>>;
34
+ mentions(params: SearchMentionsParams, options?: {
35
+ retry?: boolean;
36
+ }): Promise<PaginatedResponse<MentionCandidate>>;
26
37
  /** Update own profile */
27
38
  update(input: UpdateProfileInput): Promise<SingleResponse<Profile>>;
28
39
  /** Follow a user */
@@ -30,18 +41,16 @@ export declare class ProfilesResource {
30
41
  /** Unfollow a user */
31
42
  unfollow(userId: string): Promise<SuccessResponse>;
32
43
  /**
33
- * List a user's followers. Each row carries the caller's relationship with
34
- * it (`is_following`, `follows_you`), so a Follow button needs no second read.
44
+ * List a user's followers, newest follow first once the server's database
45
+ * has the follow-list order indexes (until then, in the order it reads
46
+ * them). Each row carries the caller's relationship with it (`is_following`,
47
+ * `follows_you`), so a Follow button needs no second read. `search` narrows
48
+ * the whole list to accounts whose name or handle contains it (at most 200
49
+ * characters); page the matches with `limit` and `offset`.
35
50
  */
36
- followers(userId: string, params?: {
37
- limit?: number;
38
- offset?: number;
39
- }): Promise<PaginatedResponse<FollowListProfile>>;
40
- /** List who a user follows, each row with the caller's relationship with it. */
41
- following(userId: string, params?: {
42
- limit?: number;
43
- offset?: number;
44
- }): Promise<PaginatedResponse<FollowListProfile>>;
51
+ followers(userId: string, params?: FollowListParams): Promise<PaginatedResponse<FollowListProfile>>;
52
+ /** List who a user follows, in the order `followers` uses, each row with the caller's relationship with it; `search` as on `followers`. */
53
+ following(userId: string, params?: FollowListParams): Promise<PaginatedResponse<FollowListProfile>>;
45
54
  /**
46
55
  * The caller's whole follow set as ids, up to 5000 per page (default 1000).
47
56
  * Answers what `isFollowing` answers, for every account at once. Pass
@@ -20,6 +20,11 @@ export class ProfilesResource {
20
20
  return this.client.get(`/profiles/by-username/${username}`);
21
21
  }
22
22
  /** Search users by name or username */
23
+ /**
24
+ * Search accounts by name or handle, ranked by match x ln(1 + followers + min(posts, 100)) (doubled for an
25
+ * official account): empties last and marked `empty`, the official ones `verified`. Every page is
26
+ * a slice of one ranked pool of 200; past it `has_more` is false.
27
+ */
23
28
  search(params) {
24
29
  return this.client.get('/profiles/search', params);
25
30
  }
@@ -28,9 +33,13 @@ export class ProfilesResource {
28
33
  * caller's follows first. Unlike `search`, this includes agents the app may
29
34
  * discover (an agent answers when mentioned); another member's personal AI
30
35
  * is never offered.
36
+ *
37
+ * A search that runs over its 2 s budget answers 503 with Retry-After. A
38
+ * type-ahead has its own deadline, so pass `{ retry: false }` to get that
39
+ * answer at once instead of waiting through retries.
31
40
  */
32
- mentions(params) {
33
- return this.client.get('/profiles/mentions', params);
41
+ mentions(params, options) {
42
+ return this.client.get('/profiles/mentions', params, options);
34
43
  }
35
44
  /** Update own profile */
36
45
  update(input) {
@@ -45,13 +54,17 @@ export class ProfilesResource {
45
54
  return this.client.delete(`/profiles/${userId}/follow`);
46
55
  }
47
56
  /**
48
- * List a user's followers. Each row carries the caller's relationship with
49
- * it (`is_following`, `follows_you`), so a Follow button needs no second read.
57
+ * List a user's followers, newest follow first once the server's database
58
+ * has the follow-list order indexes (until then, in the order it reads
59
+ * them). Each row carries the caller's relationship with it (`is_following`,
60
+ * `follows_you`), so a Follow button needs no second read. `search` narrows
61
+ * the whole list to accounts whose name or handle contains it (at most 200
62
+ * characters); page the matches with `limit` and `offset`.
50
63
  */
51
64
  followers(userId, params) {
52
65
  return this.client.get(`/profiles/${userId}/followers`, params);
53
66
  }
54
- /** List who a user follows, each row with the caller's relationship with it. */
67
+ /** List who a user follows, in the order `followers` uses, each row with the caller's relationship with it; `search` as on `followers`. */
55
68
  following(userId, params) {
56
69
  return this.client.get(`/profiles/${userId}/following`, params);
57
70
  }
@@ -305,6 +305,8 @@ export interface SocialSourceControlDisplay {
305
305
  avatarUrl: string | null;
306
306
  /** A hidden post's text, at most 140 characters; null for accounts, deleted posts, and moderated posts or authors. */
307
307
  excerpt: string | null;
308
+ /** A hidden ActivityPub post's public reader id, for the conversation read; absent for accounts, other networks and posts observed without one. */
309
+ readRef?: string;
308
310
  observedAt: string;
309
311
  }
310
312
  export interface SocialSourceControl {
@@ -641,6 +643,8 @@ export interface NativeReplyBatch {
641
643
  authorHandle: string;
642
644
  text: string;
643
645
  textTruncated?: boolean;
646
+ /** An ActivityPub parent's public reader id, when the reply was prepared with one: pass it to the conversation read. */
647
+ readRef?: string;
644
648
  };
645
649
  text: string;
646
650
  wireText: string;
@@ -742,7 +746,7 @@ export interface NativeDelegatedReplyHistoryItem {
742
746
  createdAt: string;
743
747
  text: string;
744
748
  textTruncated: boolean;
745
- source: Pick<NativeReplyBatch['deliveries'][number]['parent'], 'protocol' | 'nativeId' | 'sourceUrl'>;
749
+ source: Pick<NativeReplyBatch['deliveries'][number]['parent'], 'protocol' | 'nativeId' | 'sourceUrl' | 'readRef'>;
746
750
  deliveries: Array<Pick<NativeReplyBatch['deliveries'][number], 'id' | 'protocol' | 'kind' | 'state' | 'attemptedAt' | 'account' | 'recoverable' | 'replacedBy'> & {
747
751
  removalReceipt: NativeReplyRemovalReceipt | null;
748
752
  }>;
@@ -751,10 +755,12 @@ export interface NativeNostrReplyResult {
751
755
  id: string;
752
756
  createdAt: string;
753
757
  text: string;
758
+ /** `readRef`: a linked ActivityPub source's public reader id, for the conversation read (replies saved before it existed have none). */
754
759
  source: {
755
760
  protocol: SocialSourceRef['protocol'];
756
761
  nativeId: string;
757
762
  sourceUrl: string;
763
+ readRef?: string;
758
764
  };
759
765
  identityId: string;
760
766
  pubkey: string;
@@ -962,6 +968,12 @@ export interface SocialPost {
962
968
  replyCount: number | null;
963
969
  likeCount: number | null;
964
970
  repostCount: number | null;
971
+ /**
972
+ * When the counts were read from the source network. Present only on a post a category feed served
973
+ * from the open-network index, whose copy can be older than the page (the server refreshes counts
974
+ * older than 15 minutes when the provider answers in time). Absent: the counts are from this read.
975
+ */
976
+ countsObservedAt?: string;
965
977
  /** 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. */
966
978
  media: Array<{
967
979
  type: 'image' | 'video';
@@ -1165,17 +1177,46 @@ export interface SocialRadarPosts {
1165
1177
  builtAt: string | null;
1166
1178
  items: SocialPost[];
1167
1179
  }
1180
+ /** A story that has left the radar, as the radar's story archive keeps it (up to 30 days after it was last on the radar). */
1181
+ export interface SocialRadarStoryArchive {
1182
+ /** The story as the radar last published it. Articles keep their headline and link; a post is a member by reference only (no `title`). */
1183
+ cluster: Pick<SocialRadarCluster, 'id' | 'label' | 'kind' | 'seed' | 'theme' | 'sources' | 'laneMix'> & {
1184
+ members: Array<Pick<SocialRadarMember, 'kind' | 'key' | 'url' | 'publishedAt' | 'outlet'> & {
1185
+ title?: string;
1186
+ }>;
1187
+ };
1188
+ /** Its open-network posts the app's source index still holds and its source controls and safety floor admit today, in member order. */
1189
+ items: SocialPost[];
1190
+ /** A World Today story's dek: an outlet's own sentence, or the editor model's. */
1191
+ summary: {
1192
+ text: string;
1193
+ origin: 'outlet' | 'editor';
1194
+ } | null;
1195
+ /** The first and the last radar build that published it. */
1196
+ firstSeenAt: string;
1197
+ lastSeenAt: string;
1198
+ /** The index holds none of its open-network posts any more (retention or deletion), so none are listed. */
1199
+ postsExpired: boolean;
1200
+ }
1168
1201
  /**
1169
1202
  * One radar story by id, with its social member posts.
1170
- * `live`: on the current radar (`cluster` and `items` present). `aged_out`: on one of the last six
1171
- * radars but not this one. `unknown`: not seen in that window (never existed, or aged out long ago).
1203
+ * `live`: on the current radar (`cluster` and `items` present). `aged_out`: not on this radar, but
1204
+ * held by the story archive (`archive`, and the live story it continues as in `successor`) or seen on
1205
+ * one of the last six radars (`archive` null). `unknown`: in neither (never existed, or left longer ago
1206
+ * than the archive keeps stories). A server without the story archive leaves `archive` and `successor` out.
1172
1207
  */
1173
1208
  export interface SocialRadarStory {
1174
1209
  id: string;
1175
1210
  builtAt: string | null;
1176
1211
  status: 'live' | 'aged_out' | 'unknown';
1177
1212
  cluster: SocialRadarCluster | null;
1213
+ /** The live story's posts; empty otherwise (an archived story's are in `archive.items`). */
1178
1214
  items: SocialPost[];
1215
+ archive?: SocialRadarStoryArchive | null;
1216
+ successor?: {
1217
+ id: string;
1218
+ label: string;
1219
+ } | null;
1179
1220
  }
1180
1221
  /** Read options for callers that hold their own deadline, such as a discovery page. */
1181
1222
  export interface SocialReadOptions {