fedipod-server 0.11.0 → 0.13.0

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.
Files changed (152) hide show
  1. package/README.md +22 -6
  2. package/dist/claims.d.ts +8 -0
  3. package/dist/claims.js +10 -0
  4. package/dist/handler.d.ts +13 -0
  5. package/dist/handler.js +56 -11
  6. package/dist/handler.jsonld +8 -0
  7. package/dist/store-pod.js +18 -4
  8. package/lib/{c2s.mjs → client/c2s.mjs} +10 -3
  9. package/lib/{localapi.mjs → client/localapi.mjs} +2 -2
  10. package/lib/client/masto/accounts.mjs +264 -0
  11. package/lib/client/masto/body.mjs +69 -0
  12. package/lib/client/masto/index.mjs +183 -0
  13. package/lib/client/masto/instance.mjs +104 -0
  14. package/lib/client/masto/media.mjs +133 -0
  15. package/lib/client/masto/oauth.mjs +599 -0
  16. package/lib/client/masto/render.mjs +459 -0
  17. package/lib/client/masto/statuses.mjs +331 -0
  18. package/lib/client/masto/timelines.mjs +316 -0
  19. package/lib/{streaming.mjs → client/streaming.mjs} +1 -1
  20. package/lib/{acctfeed.mjs → connections/acctfeed.mjs} +1 -1
  21. package/lib/{atproto.mjs → connections/atproto.mjs} +15 -16
  22. package/lib/{bskygroup.mjs → connections/bskygroup.mjs} +1 -1
  23. package/lib/{fediacct.mjs → connections/fediacct.mjs} +31 -35
  24. package/lib/{import.mjs → connections/import.mjs} +1 -1
  25. package/lib/{tagfeed.mjs → connections/tagfeed.mjs} +3 -3
  26. package/lib/connections/vault.mjs +114 -0
  27. package/lib/core/as2.mjs +170 -0
  28. package/lib/core/contexts/activitystreams.json +379 -0
  29. package/lib/core/contexts/did-v1.json +57 -0
  30. package/lib/core/contexts/fep-5711.json +36 -0
  31. package/lib/core/contexts/gotosocial.json +86 -0
  32. package/lib/core/contexts/identity-v1.json +152 -0
  33. package/lib/core/contexts/index.mjs +45 -0
  34. package/lib/core/contexts/join-lemmy.json +33 -0
  35. package/lib/core/contexts/joinmastodon.json +28 -0
  36. package/lib/core/contexts/map.json +16 -0
  37. package/lib/core/contexts/miscellany.json +19 -0
  38. package/lib/core/contexts/schemaorg.json +8845 -0
  39. package/lib/core/contexts/security-data-integrity-v1.json +78 -0
  40. package/lib/core/contexts/security-data-integrity-v2.json +81 -0
  41. package/lib/core/contexts/security-multikey-v1.json +35 -0
  42. package/lib/core/contexts/security-v1.json +74 -0
  43. package/lib/core/contexts/webfinger.json +10 -0
  44. package/lib/{deliver.mjs → core/deliver.mjs} +2 -2
  45. package/lib/core/graphview.mjs +269 -0
  46. package/lib/core/intake/activities.mjs +437 -0
  47. package/lib/core/intake/activity.mjs +240 -0
  48. package/lib/core/intake/channel.mjs +144 -0
  49. package/lib/core/intake/group.mjs +222 -0
  50. package/lib/core/intake/index.mjs +629 -0
  51. package/lib/core/intake/notes.mjs +288 -0
  52. package/lib/core/intake/verify.mjs +142 -0
  53. package/lib/{keys.mjs → core/keys.mjs} +1 -1
  54. package/lib/core/publisher/collections.mjs +229 -0
  55. package/lib/core/publisher/index.mjs +421 -0
  56. package/lib/core/publisher/notes.mjs +188 -0
  57. package/lib/core/publisher/questions.mjs +233 -0
  58. package/lib/core/publisher/restore.mjs +199 -0
  59. package/lib/core/shapes/activitystreams.ttl +129 -0
  60. package/lib/core/shapes/index.mjs +107 -0
  61. package/lib/core/shapes/shapes-text.mjs +13 -0
  62. package/lib/{social.mjs → core/social.mjs} +2 -2
  63. package/lib/{store.mjs → core/store.mjs} +4 -0
  64. package/lib/{wire.mjs → core/wire.mjs} +2 -2
  65. package/lib/device/admin/index.mjs +13 -0
  66. package/lib/device/admin/origins.mjs +35 -0
  67. package/lib/device/admin/routes/connections.mjs +144 -0
  68. package/lib/device/admin/routes/gateway.mjs +199 -0
  69. package/lib/device/admin/routes/lifecycle.mjs +191 -0
  70. package/lib/device/admin/routes/owner.mjs +322 -0
  71. package/lib/device/admin/routes/setup.mjs +393 -0
  72. package/lib/device/admin/routes/social.mjs +188 -0
  73. package/lib/device/admin/server.mjs +95 -0
  74. package/lib/device/admin/static.mjs +244 -0
  75. package/lib/device/admin/surface.mjs +274 -0
  76. package/lib/device/cli/commands/account.mjs +586 -0
  77. package/lib/device/cli/commands/run.mjs +278 -0
  78. package/lib/device/cli/commands/service.mjs +221 -0
  79. package/lib/device/cli/commands/setup.mjs +410 -0
  80. package/lib/device/cli/commands/state.mjs +559 -0
  81. package/lib/device/cli/context.mjs +288 -0
  82. package/lib/{migrate.mjs → device/migrate.mjs} +1 -1
  83. package/lib/{remote.mjs → device/remote.mjs} +3 -3
  84. package/lib/{setup.mjs → device/setup.mjs} +3 -3
  85. package/lib/{update.mjs → device/update.mjs} +1 -1
  86. package/lib/{directory.mjs → gateway/directory.mjs} +1 -1
  87. package/lib/{front-core.mjs → gateway/front-core.mjs} +3 -3
  88. package/lib/{gateway-core.mjs → gateway/gateway-core.mjs} +1 -1
  89. package/lib/{httpsig.mjs → gateway/httpsig.mjs} +1 -1
  90. package/lib/server/embed.mjs +405 -0
  91. package/lib/{links.mjs → shared/links.mjs} +1 -1
  92. package/lib/{ua.mjs → shared/ua.mjs} +1 -1
  93. package/package.json +1 -1
  94. package/run-agent.mjs +33 -25
  95. package/web/admin/actors.js +145 -0
  96. package/web/admin/common.js +23 -0
  97. package/web/admin/connections.js +112 -0
  98. package/web/admin/gateway.js +111 -0
  99. package/web/admin/group.js +258 -0
  100. package/web/admin/index.html +7 -1
  101. package/web/admin/record.js +378 -0
  102. package/web/admin/setup/index.html +1 -0
  103. package/web/admin/setup/setup.js +2 -13
  104. package/web/admin/upkeep.js +170 -0
  105. package/web/app/README.md +6 -6
  106. package/web/app/admin-facade.mjs +3 -3
  107. package/web/app/agent.mjs +14 -16
  108. package/web/app/atproto-browser.mjs +1 -1
  109. package/web/app/boot.mjs +2 -3
  110. package/web/app/deliver-relay.mjs +1 -1
  111. package/web/app/dist/boot.js +22 -3
  112. package/web/app/dist/boot.js.map +2 -2
  113. package/web/app/dist/sw.js +21913 -5446
  114. package/web/app/dist/sw.js.map +4 -4
  115. package/web/app/fediacct-browser.mjs +1 -1
  116. package/web/app/keys-browser.mjs +27 -4
  117. package/web/app/shims/shapes-text.mjs +8 -0
  118. package/web/app/signup.mjs +2 -3
  119. package/web/app/site/admin/actors.js +145 -0
  120. package/web/app/site/admin/common.js +23 -0
  121. package/web/app/site/admin/connections.js +112 -0
  122. package/web/app/site/admin/gateway.js +111 -0
  123. package/web/app/site/admin/group.js +258 -0
  124. package/web/app/site/admin/index.html +7 -1
  125. package/web/app/site/admin/record.js +378 -0
  126. package/web/app/site/admin/setup/index.html +1 -0
  127. package/web/app/site/admin/setup/setup.js +2 -13
  128. package/web/app/site/admin/upkeep.js +170 -0
  129. package/web/app/site/boot.js +22 -3
  130. package/web/app/site/sw.js +21913 -5446
  131. package/web/app/sw-src.mjs +17 -2
  132. package/lib/admin.mjs +0 -1913
  133. package/lib/embed.mjs +0 -220
  134. package/lib/intake.mjs +0 -1981
  135. package/lib/mastoapi.mjs +0 -2284
  136. package/lib/publisher.mjs +0 -1192
  137. package/web/admin/admin.js +0 -1181
  138. package/web/app/site/admin/admin.js +0 -1181
  139. /package/lib/{oidc-auth.mjs → client/oidc-auth.mjs} +0 -0
  140. /package/lib/{webpush.mjs → client/webpush.mjs} +0 -0
  141. /package/lib/{bskyfeed.mjs → connections/bskyfeed.mjs} +0 -0
  142. /package/lib/{lease.mjs → core/lease.mjs} +0 -0
  143. /package/lib/{polls.mjs → core/polls.mjs} +0 -0
  144. /package/lib/{proof.mjs → core/proof.mjs} +0 -0
  145. /package/lib/{storage.mjs → core/storage.mjs} +0 -0
  146. /package/lib/{account.mjs → device/account.mjs} +0 -0
  147. /package/lib/{certs.mjs → device/certs.mjs} +0 -0
  148. /package/lib/{export-collections.mjs → device/export-collections.mjs} +0 -0
  149. /package/lib/{home.mjs → device/home.mjs} +0 -0
  150. /package/lib/{ports.mjs → device/ports.mjs} +0 -0
  151. /package/lib/{guard.mjs → shared/guard.mjs} +0 -0
  152. /package/lib/{safefetch.mjs → shared/safefetch.mjs} +0 -0
@@ -0,0 +1,459 @@
1
+ // render.mjs — Mastodon's JSON shapes out of the agent's records: accounts,
2
+ // statuses and the carry that wraps one, notifications, polls, media,
3
+ // relationships, filters, and the cursor paging with its Link header. Also
4
+ // the three actions that answer as a connected account rather than the pod
5
+ // actor (a Bluesky reply, a connected-account reply, a like or boost there).
6
+
7
+ import * as social from '../../core/social.mjs';
8
+ import { sanitizeHtml, followsNeedApproval, publicHandle } from '../../core/wire.mjs';
9
+ import { profileUrl, postUrl } from '../../connections/bskyfeed.mjs';
10
+ import { htmlToText } from './body.mjs';
11
+
12
+ // 1x1 transparent PNG — placeholder avatar/header for accounts without icons.
13
+ export const TRANSPARENT_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
14
+
15
+ const selfIcon = (api, self, cached) =>
16
+ (self ? api.store.getConfig()?.icon : null) || cached.icon;
17
+
18
+ export function selfAccount(api) {
19
+ // No fallback handle. `account()` already reads config for self, so a
20
+ // literal here only ever supplies a name that is not this actor's — and it
21
+ // was a real person's, so every install with no handle called itself jeff.
22
+ return api.account(api.urls.actor, { selfAcct: publicHandle(api.store.getConfig()) });
23
+ }
24
+
25
+ export function account(api, actorUrl, { selfAcct } = {}) {
26
+ const cached = api.store.getActors()[actorUrl] || {};
27
+ let host = '', user = cached.preferredUsername || '';
28
+ try { host = new URL(actorUrl).host; if (!user) user = new URL(actorUrl).pathname.split('/').pop(); } catch {}
29
+ const self = actorUrl === api.urls?.actor;
30
+ if (self) user = selfAcct || publicHandle(api.store.getConfig()) || user;
31
+ return {
32
+ id: api.store.idFor(actorUrl),
33
+ username: user,
34
+ // Self gets the FULL acct (Mastodon proper returns the bare local part
35
+ // here): the client's login domain is the loopback agent, so the bare
36
+ // form would display as user@127.0.0.1 — the full form shows the real
37
+ // fediverse identity, and every client renders @-containing accts as-is.
38
+ acct: `${self ? (selfAcct || user) : user}@${host}`,
39
+ // Our own profile is not in the actor cache — the cache is for other
40
+ // people — so read it from config, or the editor opens empty and saving
41
+ // wipes what was there.
42
+ display_name: (self ? api.store.getConfig()?.name : cached.name) || cached.name || user,
43
+ locked: self ? followsNeedApproval(api.store.getConfig() || {}) : false,
44
+ // Read from config for self, like the fields above it: our own actor is
45
+ // not in the actor cache — the cache is for other people — so a group
46
+ // asking about itself would be told it was a person.
47
+ bot: false, discoverable: true,
48
+ group: self ? api.store.getConfig()?.kind === 'group' : cached.type === 'Group',
49
+ created_at: '2026-01-01T00:00:00.000Z',
50
+ note: (self ? api.store.getConfig()?.summary : cached.summary) || '',
51
+ url: actorUrl, uri: actorUrl,
52
+ avatar: selfIcon(api, self, cached) || TRANSPARENT_PNG,
53
+ avatar_static: selfIcon(api, self, cached) || TRANSPARENT_PNG,
54
+ header: (self ? api.store.getConfig()?.image : null) || TRANSPARENT_PNG,
55
+ header_static: (self ? api.store.getConfig()?.image : null) || TRANSPARENT_PNG,
56
+ // A remote actor's counts are whatever its own collections said when we
57
+ // last asked; unknown stays 0 because the API has no way to say "unknown".
58
+ followers_count: self ? api.store.getContacts().followers.length : (cached.counts?.followers ?? 0),
59
+ // Accepted only, matching both the published `following` collection and
60
+ // the list this number opens — a pending Follow is not yet a following.
61
+ following_count: self ? api.store.getContacts().following.filter(f => f.accepted).length : (cached.counts?.following ?? 0),
62
+ statuses_count: self ? api.store.getStatuses().filter(s => s.kind === 'post').length : 0,
63
+ last_status_at: null, emojis: [],
64
+ fields: (self ? api.store.getConfig()?.fields : cached.fields) || [],
65
+ };
66
+ }
67
+
68
+ // `all` is not an optimisation, it is the difference between one clone and
69
+ // one per status: store.getStatuses() structuredClones the entire array, so
70
+ // rendering a 40-status timeline without it cloned a 1000-entry array 40
71
+ // times purely to count replies. Every caller that renders more than one
72
+ // status passes it; this fallback is for the single-status paths.
73
+ // Mastodon's cursor paging, over an array already in newest-first order.
74
+ // Clients do not read a `next` out of the body — they follow the Link header,
75
+ // which nothing here emitted, so a client could only ever see the first page.
76
+ // `idOf` is what a cursor names. Statuses are cursored by note, account lists
77
+ // by actor, so the caller says which field carries the id.
78
+ export function page(api, items, url, { limit = 20, max = 40, idOf = (s) => api.store.idFor(s.noteId) } = {}) {
79
+ const n = Math.min(Number(url.searchParams.get('limit')) || limit, max);
80
+ // The cursor scan is linear over every status, and `idOf` defaults to
81
+ // store.idFor, which structuredClones the whole uncapped ids.json on every
82
+ // call. Scrolling a 5000-status timeline was therefore millions of object
83
+ // copies of synchronous, event-loop-blocking work to serve one page of 20,
84
+ // and it ran again for since_id and min_id. The id is a pure function of
85
+ // the URL, so remembering it per item within one call is free and exact.
86
+ const ids = new Map();
87
+ const idAt = (s) => {
88
+ if (!ids.has(s)) ids.set(s, idOf(s));
89
+ return ids.get(s);
90
+ };
91
+ const cut = (param) => {
92
+ const v = url.searchParams.get(param);
93
+ if (!v) return null;
94
+ const i = items.findIndex(s => idAt(s) === v);
95
+ return i < 0 ? null : i;
96
+ };
97
+ const maxAt = cut('max_id');
98
+ if (maxAt != null) items = items.slice(maxAt + 1); // older than this one
99
+ for (const p of ['since_id', 'min_id']) {
100
+ const at = cut(p);
101
+ if (at != null) items = items.slice(0, at); // newer than this one
102
+ }
103
+ const pageItems = items.slice(0, n);
104
+ if (!pageItems.length) return { items: pageItems, headers: {} };
105
+ const base = `http://${api.host}${url.pathname}`;
106
+ const q = (extra) => {
107
+ const u = new URL(base);
108
+ for (const [k, v] of url.searchParams) if (!['max_id', 'since_id', 'min_id'].includes(k)) u.searchParams.set(k, v);
109
+ for (const [k, v] of Object.entries(extra)) u.searchParams.set(k, v);
110
+ return u.href;
111
+ };
112
+ const links = [`<${q({ max_id: idOf(pageItems[pageItems.length - 1]) })}>; rel="next"`];
113
+ if (pageItems.length) links.push(`<${q({ min_id: idOf(pageItems[0]) })}>; rel="prev"`);
114
+ return { items: pageItems, headers: { link: links.join(', ') } };
115
+ }
116
+
117
+ // A reply to a mirrored Bluesky post: a native reply from the connected
118
+ // account, threaded under the original. It exists only on Bluesky, so the
119
+ // row added here is its one local copy.
120
+ export async function bskyReply(api, send, body, parent, visibility) {
121
+ const at = api.agent.atproto;
122
+ if (!at?.connected()) return send(422, { error: 'this is a Bluesky post — no Bluesky account is connected to reply from' });
123
+ if (visibility !== 'public' && visibility !== 'unlisted') {
124
+ return send(422, { error: 'a Bluesky reply is public — pick public visibility' });
125
+ }
126
+ if (body.scheduled_at) return send(422, { error: 'a Bluesky reply cannot be scheduled' });
127
+ if ([].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean).length) {
128
+ return send(422, { error: 'images on a Bluesky reply are not supported' });
129
+ }
130
+ try {
131
+ const out = await at.reply(body.status, parent.noteId);
132
+ const rec = at.read();
133
+ const actor = profileUrl(rec.did);
134
+ if (!api.store.getActors()[actor]) {
135
+ api.store.cacheActor(actor, { name: rec.handle, preferredUsername: rec.handle, type: 'Person' });
136
+ }
137
+ const text = String(body.status).trim();
138
+ api.store.addStatus({
139
+ noteId: out.uri, actor, inReplyTo: parent.noteId,
140
+ content: `<p>${text.replace(/[&<>]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' }[c]))}</p>`,
141
+ published: new Date().toISOString(), kind: 'bsky',
142
+ ...(out.cid ? { cid: out.cid } : {}), link: postUrl(out.uri),
143
+ });
144
+ return send(200, api.status(api.store.getStatuses().find(x => x.noteId === out.uri)));
145
+ } catch (e) { return send(422, { error: e.message }); }
146
+ }
147
+
148
+ // Which account acts: the one that saw the post, and the first of them when
149
+ // several did. Undo inverts that — a like fans IN to one account, an unlike
150
+ // fans OUT to every account holding one, because a stray like left behind
151
+ // after the owner asked for it to go is the worse failure.
152
+ export async function acctAction(api, send, s, verb) {
153
+ const accounts = api.agent.fediaccts;
154
+ const held = s.sourceAccts || [];
155
+ if (!accounts || !held.length) {
156
+ return send(422, { error: 'this post came from a connected account, and none is connected now' });
157
+ }
158
+ const flag = verb.endsWith('favourite') ? 'favourited' : 'reblogged';
159
+ const undo = verb.startsWith('un');
160
+ const targets = (undo ? held.filter(v => v[flag]) : [held[0]]).filter(v => v?.remoteId);
161
+ if (!targets.length) return send(200, api.status(s));
162
+ try {
163
+ const acted = new Set();
164
+ for (const v of targets) {
165
+ await accounts.api(v.acct,
166
+ `/api/v1/statuses/${encodeURIComponent(v.remoteId)}/${verb}`, { method: 'POST' });
167
+ acted.add(v.acct);
168
+ }
169
+ const next = held.map(v => (acted.has(v.acct) ? { ...v, [flag]: !undo } : v));
170
+ return send(200, api.status(api.store.updateStatus(s.noteId, { sourceAccts: next }) || s));
171
+ } catch (e) { return send(e.status === 401 ? 401 : 422, { error: e.message }); }
172
+ }
173
+
174
+ // The reply exists only on that account's server, so the row added here is
175
+ // its one local copy — the same shape bskyReply uses for the same reason.
176
+ export async function acctReply(api, send, body, parent, visibility) {
177
+ const accounts = api.agent.fediaccts;
178
+ const held = (parent.sourceAccts || [])[0];
179
+ if (!accounts || !held?.remoteId) {
180
+ return send(422, { error: 'this post came from a connected account, and none is connected now' });
181
+ }
182
+ if (visibility !== 'public' && visibility !== 'unlisted') {
183
+ return send(422, { error: 'a reply from a connected account is public — pick public or unlisted' });
184
+ }
185
+ if (body.scheduled_at) return send(422, { error: 'a reply from a connected account cannot be scheduled' });
186
+ if ([].concat(body.media_ids || body['media_ids[]'] || []).filter(Boolean).length) {
187
+ return send(422, { error: 'images on a reply from a connected account are not supported' });
188
+ }
189
+ try {
190
+ const out = await accounts.apiJson(held.acct, '/api/v1/statuses', {
191
+ method: 'POST',
192
+ headers: { 'content-type': 'application/json' },
193
+ body: JSON.stringify({
194
+ status: body.status, in_reply_to_id: held.remoteId, visibility,
195
+ ...(body.spoiler_text ? { spoiler_text: String(body.spoiler_text) } : {}),
196
+ }),
197
+ });
198
+ if (!out?.uri) return send(502, { error: 'that server accepted the reply but did not say where it is' });
199
+ const rec = accounts.read(held.acct);
200
+ const actor = out.account?.uri || out.account?.url || rec?.actorUrl;
201
+ if (actor && !api.store.getActors()[actor]) {
202
+ api.store.cacheActor(actor, {
203
+ name: out.account?.display_name || rec?.name,
204
+ preferredUsername: out.account?.username || rec?.acct, type: 'Person',
205
+ });
206
+ }
207
+ api.store.addStatus({
208
+ noteId: out.uri, actor, inReplyTo: parent.noteId,
209
+ content: sanitizeHtml(out.content || ''),
210
+ published: out.created_at || new Date().toISOString(), kind: 'acct',
211
+ sourceAccts: [{ acct: held.acct, remoteId: String(out.id) }],
212
+ ...(out.url && out.url !== out.uri ? { link: out.url } : {}),
213
+ });
214
+ return send(200, api.status(api.store.getStatuses().find(x => x.noteId === out.uri)));
215
+ } catch (e) { return send(422, { error: e.message }); }
216
+ }
217
+
218
+ export function status(api, s, { all } = {}) {
219
+ const replies = (all || api.store.getStatuses()).filter(x => x.inReplyTo === s.noteId).length;
220
+ return {
221
+ id: api.store.idFor(s.noteId),
222
+ created_at: s.published || new Date().toISOString(),
223
+ in_reply_to_id: s.inReplyTo ? api.store.idFor(s.inReplyTo) : null,
224
+ in_reply_to_account_id: null,
225
+ sensitive: !!s.spoiler, spoiler_text: s.spoiler || '',
226
+ visibility: s.visibility || 'public', language: null,
227
+ edited_at: s.editedAt || null,
228
+ uri: s.noteId, url: s.link || s.noteId,
229
+ replies_count: replies, reblogs_count: 0, favourites_count: 0,
230
+ // True when ANY of the owner's accounts holds it. The flag is really
231
+ // what the next tap will do: an empty star on a post one account has
232
+ // already liked invites a second outward like from a second identity.
233
+ favourited: !!s.favourited || (s.sourceAccts || []).some(v => v.favourited),
234
+ reblogged: !!s.reblogged || (s.sourceAccts || []).some(v => v.reblogged),
235
+ muted: false, bookmarked: !!s.bookmarked, pinned: !!s.pinned,
236
+ content: s.content || '',
237
+ reblog: null, application: null,
238
+ account: api.account(s.actor),
239
+ media_attachments: (s.attachments || []).map(a => api.mediaJson(a)),
240
+ // The mention entities are how a client knows a link is an ACCOUNT —
241
+ // without them, clicking a mentioned group lands on the raw actor doc.
242
+ mentions: (s.mentions || []).map((m) => {
243
+ const bare = String(m.name || '').replace(/^@/, '');
244
+ const user = bare.split('@')[0];
245
+ let host = '';
246
+ try { host = new URL(m.href).host; } catch { /* keep bare */ }
247
+ return {
248
+ id: api.store.idFor(m.href),
249
+ username: user || bare,
250
+ url: m.href,
251
+ acct: bare.includes('@') ? bare : (host ? `${user}@${host}` : bare),
252
+ };
253
+ }),
254
+ tags: [],
255
+ emojis: (s.emojis || []).map(e => ({
256
+ shortcode: e.shortcode, url: e.url, static_url: e.url, visible_in_picker: false,
257
+ })),
258
+ card: null, poll: s.poll ? api.pollJson(s) : null,
259
+ // What a filter matched, if any. Mastodon's clients read this and do the
260
+ // hiding or warning; they do NOT match keywords themselves — Phanpy does
261
+ // not — so filters that were stored and served but never applied were a
262
+ // setting that did nothing. The README named them as a feature.
263
+ filtered: api.filtersFor(s),
264
+ };
265
+ }
266
+
267
+ // v2 filters against one status. `context` is the timeline the client is
268
+ // showing, which we do not know here, so every non-expired filter is offered
269
+ // and the client drops the ones whose context does not match — the same
270
+ // information it uses to decide anyway.
271
+ export function filtersFor(api, s) {
272
+ const now = Date.now();
273
+ const hay = `${s.content || ''} ${s.spoiler || ''}`
274
+ .replace(/<[^>]*>/gu, ' ') // the text, not the markup around it
275
+ .toLowerCase();
276
+ if (!hay.trim()) return [];
277
+ const out = [];
278
+ // `status()` renders on every timeline read, including from the Bluesky and
279
+ // connected-account paths whose stores are narrower than the pod's. A
280
+ // missing filter list means no filters, never a thrown render.
281
+ for (const f of api.store.getFilters?.() || []) {
282
+ if (f.expiresAt && Date.parse(f.expiresAt) <= now) continue;
283
+ const hit = (f.keywords || []).filter((k) => {
284
+ const word = String(k.keyword || '').toLowerCase().trim();
285
+ if (!word) return false;
286
+ if (!k.wholeWord) return hay.includes(word);
287
+ // A whole word, by the same rule Mastodon uses: a boundary that is not
288
+ // itself a word character, at both ends.
289
+ const esc = word.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
290
+ return new RegExp(`(?:^|[^\\p{L}\\p{N}_])${esc}(?:[^\\p{L}\\p{N}_]|$)`, 'u').test(hay);
291
+ }).map((k) => k.keyword);
292
+ if (!hit.length) continue;
293
+ out.push({
294
+ filter: {
295
+ id: f.id, title: f.title, context: f.context || ['home'],
296
+ expires_at: f.expiresAt || null, filter_action: f.action || 'warn',
297
+ },
298
+ keyword_matches: hit,
299
+ status_matches: [],
300
+ });
301
+ }
302
+ return out;
303
+ }
304
+
305
+ // A timeline row's id may name the CARRY rather than the post — statusOrBoost
306
+ // mints `via:<noteId>` for the envelope. A client that asks about a row it
307
+ // was served has to get an answer: a 404 there makes it drop the row, which
308
+ // is how carried posts vanished from the home column while every other view
309
+ // still had them.
310
+ export function lookup(api, id) {
311
+ const raw = api.store.urlFor(id);
312
+ if (typeof raw !== 'string') return { s: null, wrapped: false };
313
+ const wrapped = raw.startsWith('via:');
314
+ const noteId = wrapped ? raw.slice(4) : raw;
315
+ return { s: api.store.getStatuses().find(x => x.noteId === noteId) || null, wrapped };
316
+ }
317
+
318
+ // A carried post, the way clients expect to see one: the carrier "boosts"
319
+ // the inner post, so the feed says who brought it. Timeline views only —
320
+ // fetching the post by its own id still returns the post itself.
321
+ export function statusOrBoost(api, s, opts = {}) {
322
+ if (!s.via || s.via === s.actor) return api.status(s, opts);
323
+ const inner = api.status(s, opts);
324
+ return {
325
+ id: api.store.idFor('via:' + s.noteId),
326
+ created_at: s.announcedAt || s.published || inner.created_at,
327
+ in_reply_to_id: null, in_reply_to_account_id: null,
328
+ sensitive: false, spoiler_text: '', visibility: inner.visibility, language: null,
329
+ edited_at: null,
330
+ uri: s.announceActivity?.id || s.noteId + '#announce',
331
+ url: inner.url,
332
+ replies_count: 0, reblogs_count: 0, favourites_count: 0,
333
+ favourited: false, reblogged: false, muted: false, bookmarked: false, pinned: false,
334
+ content: '', reblog: inner, application: null,
335
+ account: api.account(s.via),
336
+ media_attachments: [], mentions: [], tags: [], emojis: [], card: null, poll: null,
337
+ };
338
+ }
339
+
340
+ // A notification, pushed. Fire-and-forget from the store's event hook.
341
+ export function pushNotify(api, n) {
342
+ const acct = api.account(n.actor);
343
+ const verbs = {
344
+ mention: 'mentioned you', favourite: 'favourited your post',
345
+ reblog: 'boosted your post', follow: 'followed you',
346
+ 'follow-request': 'asked to follow you', move: 'moved account',
347
+ };
348
+ const s = n.noteId && api.store.getStatuses().find(x => x.noteId === n.noteId);
349
+ return api.push.notify(n, {
350
+ notification_id: n.id, notification_type: n.type, preferred_locale: 'en',
351
+ title: `${acct.display_name || acct.acct} ${verbs[n.type] || n.type}`,
352
+ body: s ? htmlToText(s.content || '').slice(0, 140) : '',
353
+ icon: acct.avatar || '',
354
+ });
355
+ }
356
+
357
+ export function scheduledJson(api, e) {
358
+ return {
359
+ id: e.id, scheduled_at: e.scheduledAt,
360
+ params: {
361
+ text: e.params.status, visibility: e.params.visibility || 'public',
362
+ spoiler_text: e.params.spoilerText || null, sensitive: !!e.params.spoilerText,
363
+ in_reply_to_id: e.params.inReplyTo ? api.store.idFor(e.params.inReplyTo) : null,
364
+ media_ids: (e.params.attachments || []).map(a => a.id),
365
+ poll: null, idempotency: null, scheduled_at: e.scheduledAt, application_id: null,
366
+ },
367
+ media_attachments: (e.params.attachments || []).map(a => api.mediaJson(a)),
368
+ };
369
+ }
370
+
371
+ export function pollJson(api, s) {
372
+ const opts = s.poll.options || [];
373
+ const votes = opts.reduce((n, o) => n + (o.votes || 0), 0);
374
+ return {
375
+ id: api.store.idFor(s.noteId),
376
+ expires_at: s.poll.expiresAt || null,
377
+ expired: !!s.poll.closed || (!!s.poll.expiresAt && Date.parse(s.poll.expiresAt) < Date.now()),
378
+ multiple: !!s.poll.multiple,
379
+ votes_count: votes, voters_count: s.poll.votersCount ?? null,
380
+ options: opts.map(o => ({ title: o.title, votes_count: o.votes || 0 })),
381
+ voted: !!s.poll.voted, own_votes: s.poll.ownVotes || [],
382
+ emojis: [],
383
+ };
384
+ }
385
+
386
+ export function mediaJson(api, a) {
387
+ const kind = /^video\//.test(a.mediaType) ? 'video'
388
+ : /^audio\//.test(a.mediaType) ? 'audio'
389
+ // NOT 'gifv'. Mastodon uses gifv for a silent MP4 it transcoded a GIF
390
+ // into, and a client renders one with <video src>; handed the raw .gif
391
+ // URL, every GIF came out blank. Nothing here transcodes, so what we
392
+ // have is an image and saying so is what makes it show.
393
+ : 'image';
394
+ return {
395
+ id: a.id || api.store.idFor(a.url),
396
+ type: kind, url: a.url, preview_url: a.url, remote_url: null,
397
+ description: a.description || null, blurhash: null, meta: {},
398
+ };
399
+ }
400
+
401
+ export function relationship(api, actorUrl) {
402
+ const c = api.store.getContacts();
403
+ const fol = c.following.find(f => f.actor === actorUrl);
404
+ return {
405
+ id: api.store.idFor(actorUrl),
406
+ following: !!fol?.accepted, requested: !!fol && !fol.accepted,
407
+ followed_by: c.followers.some(f => f.actor === actorUrl),
408
+ showing_reblogs: true, notifying: false, languages: null,
409
+ blocking: api.store.getBlocklist().actors.includes(actorUrl),
410
+ blocked_by: false, domain_blocking: false,
411
+ muting: api.store.getMuted().actors.includes(actorUrl),
412
+ muting_notifications: false, endorsed: false, note: '',
413
+ };
414
+ }
415
+
416
+ // Ours is stored with a hyphen; Mastodon's API spells it with an underscore,
417
+ // and a client that does not know the type shows "Unknown notification type"
418
+ // — which is what every Mastodon client did with a follow request here. The
419
+ // stored spelling is left alone so existing state keeps reading.
420
+ export function notificationType(api, t) { return t === 'follow-request' ? 'follow_request' : t; }
421
+
422
+ export function notification(api, n) {
423
+ const out = { id: n.id, type: api.notificationType(n.type), created_at: n.at, account: api.account(n.actor) };
424
+ if (n.noteId) {
425
+ const s = api.store.getStatuses().find(x => x.noteId === n.noteId);
426
+ if (s) out.status = api.status(s);
427
+ }
428
+ return out;
429
+ }
430
+
431
+ // Every account this instance knows whose handle or name matches: self,
432
+ // contacts, cached actor docs. Handle-shaped queries resolve via webfinger.
433
+ export async function accountSearch(api, q) {
434
+ const needle = String(q || '').replace(/^@/, '').toLowerCase().trim();
435
+ if (!needle) return [];
436
+ if (/^[^@\s]+@[^@\s.]+\.[^@\s]+$/.test(needle)) {
437
+ try {
438
+ const doc = await social.resolveHandle(api.agent, needle);
439
+ return [api.account(doc.id)];
440
+ } catch (e) { api.log(`account search resolve ${needle}: ${e.message}`); }
441
+ return [];
442
+ }
443
+ const cfg = api.store.getConfig();
444
+ const seen = new Set();
445
+ const out = [];
446
+ const add = (actorUrl) => {
447
+ if (actorUrl && !seen.has(actorUrl)) { seen.add(actorUrl); out.push(api.account(actorUrl)); }
448
+ };
449
+ if ((cfg?.handle || '').toLowerCase().includes(needle)
450
+ || (cfg?.name || '').toLowerCase().includes(needle)) add(api.urls.actor);
451
+ const contacts = api.store.getContacts();
452
+ for (const rec of [...contacts.followers, ...contacts.following]) {
453
+ if ((rec.handle || rec.actor || '').toLowerCase().includes(needle)) add(rec.actor);
454
+ }
455
+ for (const [u, a] of Object.entries(api.store.getActors())) {
456
+ if ((a.preferredUsername + ' ' + a.name + ' ' + u).toLowerCase().includes(needle)) add(u);
457
+ }
458
+ return out.slice(0, 20);
459
+ }