fedipod 1.56.0 → 1.57.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,7 +31,7 @@ This README is about the `FediPod` as used with a Gateway. There are also a numb
31
31
  You can access your account by pointing any Mastodon API or C2S client at fedipod.net and signing in. There are also two built-inclients, both served by fedipod.net: [Sengi](https://github.com/NicolasConstant/sengi) (MIT, by Nicolas Constant), which you get by default, and [Phanpy](https://github.com/cheeaun/phanpy)(MIT, by Chee Aun). The links at the top right of every page switch between
32
32
  them, at any time; whichever you opened last is the one that opens next time.
33
33
 
34
- You can follow and unfollow, post, reply, favourite, boost, attach media, edit and delete. Content warnings, polls with up to four options and one or several answers, all four visibility levels, a conversations view for direct messages, bookmarks, lists, keyword filters, pinned posts that other servers can see, blocking and muting, and the custom emojis other servers send.
34
+ You can follow and unfollow, post, reply, favourite, boost, attach media, edit and delete. Content warnings, polls with up to four options and one or several answers, all four visibility levels, a conversations view for direct messages, bookmarks, lists (a list can keep its people out of your home timeline), keyword filters, pinned posts that other servers can see, blocking and muting, and the custom emojis other servers send.
35
35
 
36
36
  A post can quote another post. Quote is offered on posts whose author allows
37
37
  it; the quoted author's server is asked, and the quote shows as pending until
package/gateway.md CHANGED
@@ -171,11 +171,10 @@ A browser account runs only while FediPod is open somewhere. While it is
171
171
  closed, fedipod.net does two things for it.
172
172
 
173
173
  **Your mail waits at the gateway.** Deliveries are kept there, not written to
174
- your pod one by one, and reach your pod in batches of up to a hundred: when you
175
- open FediPod, or every fifteen minutes otherwise. While FediPod is open, mail
176
- goes straight to your pod as before. A DeviceAgent's mail always goes straight
177
- to its pod. On a gateway whose admin has turned the hold off (below), mail
178
- always goes straight to your pod.
174
+ your pod one by one, and reach your pod in batches of up to a hundred: all of
175
+ them when you open FediPod, or one batch every fifteen minutes otherwise. While
176
+ FediPod is open, mail goes straight to your pod as before. On a gateway whose
177
+ admin has turned the hold off (below), mail always goes straight to your pod.
179
178
 
180
179
  **The gateway keeps your account running.** It accepts your follows, tries
181
180
  again what failed to go out, and publishes the posts you scheduled, all while FediPod is closed. Other mail waits on your
@@ -278,10 +277,10 @@ door answer 410 Gone, other servers drop the account the next time they look,
278
277
  and nobody can take the name. Nothing on your pod is touched. An address that
279
278
  had already moved to another gateway keeps answering as moved.
280
279
 
281
- Only accounts opened from a browser are counted. A DeviceAgent behind the
282
- gateway drains its own inbox as it runs, and is never paused or closed by
283
- time. Accounts from before this was built are counted from their next
284
- sign-in. A gateway operator sets the cap and the window with
280
+ Every person's account is counted: from the last time its owner signed in or
281
+ posted, or from the day it was set up if they never have. An account with no
282
+ day on record is counted from 23 September 2026. A forum, its categories and a
283
+ group are not counted. A gateway operator sets the cap and the window with
285
284
  `FEDIPOD_PAUSE_ITEMS` and `FEDIPOD_CLOSE_DAYS`.
286
285
 
287
286
  ## Notices
package/gui.md CHANGED
@@ -104,6 +104,16 @@ that one of your connected accounts also brought in is from someone you follow
104
104
  there, and stays. An app already showing them keeps them on screen until it
105
105
  reloads.
106
106
 
107
+ ## Lists in your home timeline
108
+
109
+ The **lists** row counts your lists, and under it each list has a switch:
110
+ *shown in home* or *hidden from home*. Hidden, the posts and boosts of the
111
+ people on that list leave your home timeline and show on the list's own
112
+ timeline and on the public timeline. Lists are made and filled in the app;
113
+ an app can set the same switch (in Phanpy, **Hide posts on this list from
114
+ Home/Following**). An app already showing the posts keeps them on screen
115
+ until it reloads.
116
+
107
117
  ## Handling follow requests
108
118
 
109
119
  New followers are accepted automatically. Set **new followers** to *wait for
@@ -250,7 +250,7 @@ export async function handle(api, ctx) {
250
250
  const actorUrl = api.store.urlFor(mAccLists[1]);
251
251
  if (!actorUrl) return send(404, { error: 'Record not found' });
252
252
  return send(200, api.store.getLists().filter(l => (l.members || []).includes(actorUrl))
253
- .map(l => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || 'list', exclusive: false })));
253
+ .map(l => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || 'list', exclusive: !!l.exclusive })));
254
254
  }
255
255
 
256
256
  // The people muted and blocked, as accounts; the domains blocked, as names.
@@ -22,6 +22,10 @@ export async function handle(api, ctx) {
22
22
  // stay on the public timeline, their hashtags' pages and trends. One a
23
23
  // connected account also brought in is from someone followed there.
24
24
  const hideTags = pathname === '/api/v1/timelines/home' && api.store.getConfig?.()?.tagsInHome === false;
25
+ // A list set to hide its people from Home (`exclusive`): their posts and
26
+ // boosts show on the list and on the public timeline only.
27
+ const hiddenBy = new Set(pathname === '/api/v1/timelines/home'
28
+ ? (api.store.getLists?.() || []).filter(l => l.exclusive).flatMap(l => l.members || []) : []);
25
29
  // The UNFILTERED list, both as the source and as the reply-count corpus:
26
30
  // a reply can be a mention, and mentions are filtered out of timelines
27
31
  // below — counting against the filtered set would undercount them.
@@ -36,6 +40,7 @@ export async function handle(api, ctx) {
36
40
  .filter(s => !s.direct && s.visibility !== 'direct')
37
41
  .filter(s => !muted.has(s.actor) && !muted.has(s.via))
38
42
  .filter(s => !hideTags || s.kind !== 'tag' || s.sourceAccts?.length)
43
+ .filter(s => !hiddenBy.size || !(hiddenBy.has(s.actor) || hiddenBy.has(s.via)))
39
44
  .filter(s => !localOnly || (s.kind === 'post' && s.visibility !== 'private'))
40
45
  .sort((a, b) => String(b.published || '').localeCompare(String(a.published || '')));
41
46
  const { items: page, headers } = api.page(items, url);
@@ -122,7 +127,9 @@ export async function handle(api, ctx) {
122
127
 
123
128
  // Lists, filters and scheduled posts are the client's own arrangements —
124
129
  // kept in local state, nothing federates.
125
- const listJson = (l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || 'list', exclusive: false });
130
+ // `exclusive`: the list hides its people from Home. A form sends it as a word.
131
+ const flag = (v) => v === true || v === 'true' || v === 'on' || v === '1';
132
+ const listJson = (l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || 'list', exclusive: !!l.exclusive });
126
133
  if (pathname === '/api/v1/lists' && req.method === 'GET') {
127
134
  return send(200, api.store.getLists().map(listJson));
128
135
  }
@@ -131,7 +138,8 @@ export async function handle(api, ctx) {
131
138
  const title = String(body.title || '').trim();
132
139
  if (!title) return send(422, { error: 'a title is required' });
133
140
  const lists = api.store.getLists();
134
- const l = { id: crypto.randomBytes(8).toString('hex'), title, repliesPolicy: body.replies_policy || 'list', members: [] };
141
+ const l = { id: crypto.randomBytes(8).toString('hex'), title, repliesPolicy: body.replies_policy || 'list',
142
+ exclusive: flag(body.exclusive), members: [] };
135
143
  lists.push(l);
136
144
  api.store.setLists(lists);
137
145
  return send(200, listJson(l));
@@ -149,6 +157,7 @@ export async function handle(api, ctx) {
149
157
  const body = await readBody(req);
150
158
  if (body.title) l.title = String(body.title);
151
159
  if (body.replies_policy) l.repliesPolicy = body.replies_policy;
160
+ if ('exclusive' in body) l.exclusive = flag(body.exclusive);
152
161
  api.store.setLists(lists);
153
162
  }
154
163
  return send(200, listJson(l));
@@ -23,7 +23,7 @@ import * as podRoot from '../pod/root.mjs';
23
23
  import * as podPolicy from '../pod/policy.mjs';
24
24
  import { podBaseOfWebId } from '../pod/urls.mjs';
25
25
  import { routeQuietApi, noteOpened, noteReceived, closedState, accountState, closedAnswer, provedOwner } from './quiet.mjs';
26
- import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, routeHereApi, routeKeeperApi, routePushApi, routeAlertApi } from './held-mail.mjs';
26
+ import { holdsMail, mayBeAway, isPresent, holdingPut, afterHeld, waitsForRound, routeHereApi, routeKeeperApi, routePushApi, routeAlertApi } from './held-mail.mjs';
27
27
  import { routeCheckApi, missingLetter } from './check-letter.mjs';
28
28
  import { routeNoticesApi } from './notices.mjs';
29
29
  import { routeForumPage } from './forum-pages.mjs';
@@ -827,7 +827,7 @@ async function route(request, ctx) {
827
827
  const standing = await accountState(ctx, up.handle, rec);
828
828
  // A browser account's mail waits here while its app is closed (held-mail.mjs), unless the hold is off.
829
829
  const away = mayBeAway(ctx, rec) && !(await isPresent(ctx, up.handle)); const held = away && holdsMail(ctx, rec);
830
- const { status, reason, content, bytes, type, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
830
+ const { status, reason, content, bytes, type, accountDeleted, notifies, name, raw, receipt } = await handleDelivery(request, identFor(rec, policy),
831
831
  { podPut: held ? holdingPut(ctx, up.handle, rec) : (u, b, ct) => ctx.podPut(up.handle, u, b, ct),
832
832
  fetchImpl: ctx.fetchImpl, paused: standing.paused, origin: ctx.frontOrigin || null });
833
833
  // One line per delivery, so "did it arrive at the door" has an answer.
@@ -836,8 +836,8 @@ async function route(request, ctx) {
836
836
  if (away && status === 202) await afterHeld(ctx, up.handle, rec, { type, notifies });
837
837
  // A forum fedipod.net keeps: the delivery is placed now, in this request,
838
838
  // and the run that carries it is started. Whatever could not be placed
839
- // here is still in the inbox for that run.
840
- if (status === 202 && name && ctx.forum?.isForumRow(rec) && rec.keeper) {
839
+ // here is still in the inbox for that run. A notice that an account was deleted waits for the round's run (held-mail.mjs).
840
+ if (status === 202 && name && ctx.forum?.isForumRow(rec) && rec.keeper && !await waitsForRound(ctx, up.handle, rec, { accountDeleted })) {
841
841
  const placed = await ctx.forum.placeAtDoor(ctx, up.handle, rec, { name, raw, receipt })
842
842
  .catch((e) => ({ placed: false, why: e.message }));
843
843
  console.log(`door @${up.handle}: ${placed.placed ? 'placed' : `not placed (${placed.why})`}`);
@@ -168,14 +168,23 @@ export async function handleDelivery(request, ident, { podPut, fetchImpl = fetch
168
168
  ? signReceipt(makeReceipt(v, { gateway: ident.gatewayWebId }), ident.hmacSecret)
169
169
  : null;
170
170
  const hash = sha256hex(raw);
171
- const okA = await inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw);
171
+ // The item and its receipt together: on a slow pod, one write after the
172
+ // other outlasted the sender's wait, and the sender sent the item again. A
173
+ // receipt whose item did not land vouches for nothing, and the reader lets
174
+ // it go.
175
+ const [okA] = await Promise.all([
176
+ inbox.appendVerifiedDelivery(podPut, ident.inboxUrl, hash, raw),
177
+ receipt ? inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt) : null,
178
+ ]);
172
179
  // A pod-write failure returns 5xx so the ORIGIN retries over its own ladder —
173
180
  // that is what preserves the pod's buffer property without the gateway
174
181
  // holding any state.
175
182
  if (!okA) return { status: 502, reason: 'pod inbox write failed' };
176
- if (receipt) await inbox.writeReceiptBeside(podPut, ident.inboxUrl, hash, receipt);
183
+ // `accountDeleted`: the sender says its own account is gone. Mastodon sends
184
+ // one of these to every inbox it knows, for every account it deletes.
177
185
  return { status: 202, reason: v.verified ? 'verified' : failed ? `buffered-unverified: ${failed}` : 'buffered-unverified',
178
- content, bytes: bytes.byteLength, type: activity.type, notifies: notifies(activity, ident), name: hash, raw, receipt };
186
+ content, bytes: bytes.byteLength, type: activity.type, accountDeleted: activity.type === 'Delete' && idOf(activity.object) === actor,
187
+ notifies: notifies(activity, ident), name: hash, raw, receipt };
179
188
  }
180
189
 
181
190
  // The outbox door: the owner's own post, taken on their behalf.
@@ -69,40 +69,55 @@ export const holdingPut = (ctx, handle, rec) => async (url, body, ct) => {
69
69
  * [{ name, body, receipt, held: [the held names it came from] }]. A receipt
70
70
  * whose delivery is not here has nothing to vouch for, and is let go.
71
71
  */
72
- export async function heldEntries(ctx, handle) {
73
- const names = await ctx.listHeld(handle);
72
+ // `max`: at most that many deliveries, the first by name, and no more of the
73
+ // pile is listed or read than holds them. Without it, everything.
74
+ export async function heldEntries(ctx, handle, { max = Infinity } = {}) {
75
+ // A receipt may sit beside each delivery: twice as many names hold them whole.
76
+ const names = (await ctx.listHeld(handle, Number.isFinite(max) ? { limit: max * 2 } : {})).sort();
74
77
  if (!names.length) return [];
75
78
  const items = new Map(); // item name -> { body, receipt, held: [names] }
76
79
  for (const name of names) {
77
- const got = await ctx.readHeld(handle, name);
78
80
  const item = name.endsWith(RECEIPT) ? name.slice(0, -RECEIPT.length) : name;
81
+ if (!items.has(item) && items.size >= max) break;
79
82
  const e = items.get(item) || { held: [] };
80
83
  e.held.push(name);
81
- if (got != null) {
82
- if (name.endsWith(RECEIPT)) { try { e.receipt = JSON.parse(got); } catch { e.receipt = null; } } else e.body = got;
83
- }
84
84
  items.set(item, e);
85
85
  }
86
- for (const [item, e] of items) {
86
+ await inTurns([...items], async ([item, e]) => {
87
+ for (const name of e.held) {
88
+ const got = await ctx.readHeld(handle, name);
89
+ if (got == null) continue;
90
+ if (name.endsWith(RECEIPT)) { try { e.receipt = JSON.parse(got); } catch { e.receipt = null; } } else e.body = got;
91
+ }
87
92
  if (typeof e.body !== 'string') { for (const n of e.held) await ctx.dropHeld(handle, n); items.delete(item); }
88
- }
93
+ });
89
94
  return [...items].map(([name, e]) => ({ name, body: e.body, receipt: e.receipt || null, held: e.held }));
90
95
  }
91
96
 
92
- // Everything held for one account, written to its pod inbox in batches and
93
- // then let go. Returns how many deliveries reached the pod. A batch the pod
94
- // refuses stays held for the next try.
95
- export async function flushHeld(ctx, handle, rec, { now = Date.now() } = {}) {
96
- const all = (await heldEntries(ctx, handle)).map((e) => [e.name, e]);
97
- if (!all.length) return 0;
97
+ // A few at a time: a pile is read and let go quickly without asking the store
98
+ // for all of it at once.
99
+ const TOGETHER = 6;
100
+ async function inTurns(list, fn) {
101
+ for (let i = 0; i < list.length; i += TOGETHER) await Promise.all(list.slice(i, i + TOGETHER).map(fn));
102
+ }
103
+
104
+ // What is held for one account, written to its pod inbox in batches and then
105
+ // let go: every batch, or `batches` of them. Returns how many deliveries
106
+ // reached the pod. A batch the pod refuses, or does not take within `podMs`
107
+ // where one is given, stays held for the next try.
108
+ export async function flushHeld(ctx, handle, rec, { now = Date.now(), batches = Infinity, podMs = null } = {}) {
98
109
  let delivered = 0;
99
- for (let i = 0; i < all.length; i += BATCH_MAX) {
100
- const chunk = all.slice(i, i + BATCH_MAX);
101
- const batch = chunk.map(([name, e]) => ({ name, body: e.body, receipt: e.receipt || null }));
102
- const doc = `batch-${now}-${i / BATCH_MAX}-${Math.random().toString(36).slice(2, 8)}.json`;
103
- const ok = await ctx.podPut(handle, inboxOf(rec) + doc, JSON.stringify({ batch }), 'application/json');
110
+ const sent = new Set();
111
+ for (let i = 0; i < batches; i++) {
112
+ // A delivery written once is not written again, should letting it go fail.
113
+ const chunk = (await heldEntries(ctx, handle, { max: BATCH_MAX })).filter((e) => !sent.has(e.name));
114
+ if (!chunk.length) break;
115
+ const batch = chunk.map((e) => ({ name: e.name, body: e.body, receipt: e.receipt || null }));
116
+ const doc = `batch-${now}-${i}-${Math.random().toString(36).slice(2, 8)}.json`;
117
+ const ok = await ctx.podPut(handle, inboxOf(rec) + doc, JSON.stringify({ batch }), 'application/json', podMs ? { timeoutMs: podMs } : undefined);
104
118
  if (!ok) break;
105
- for (const [, e] of chunk) for (const n of e.held) await ctx.dropHeld(handle, n);
119
+ for (const e of chunk) sent.add(e.name);
120
+ await inTurns(chunk.flatMap((e) => e.held), (n) => ctx.dropHeld(handle, n));
106
121
  delivered += chunk.length;
107
122
  }
108
123
  return delivered;
@@ -118,6 +133,18 @@ export async function wakesKeeper(ctx, handle, rec, type) {
118
133
  return true;
119
134
  }
120
135
 
136
+ // A notice that an account was deleted, arriving for a forum this gateway
137
+ // keeps, starts no run of its own: the forum is marked as having work, and the
138
+ // round's run reads the notice from the inbox. False where that cannot be
139
+ // noted, so the caller starts the run as for any delivery.
140
+ export async function waitsForRound(ctx, handle, rec, { accountDeleted = false } = {}) {
141
+ const forum = rec.kind === 'application' ? handle : rec.forum;
142
+ if (!accountDeleted || !forum || !ctx.noteNext) return false;
143
+ const noted = await ctx.noteNext(forum, new Date().toISOString(), { earliest: true }).then(() => true, () => false);
144
+ if (noted) console.log(`door @${handle}: an account's deletion, left for the round's run of @${forum}`);
145
+ return noted;
146
+ }
147
+
121
148
  // After a delivery is held: a follow wakes the keeper, and anything that
122
149
  // becomes a notification is read in and pushed at once to the phones and
123
150
  // browsers the owner signed up for notifications (masto-gateway.mjs: pushHeld).
@@ -132,17 +159,33 @@ export async function afterHeld(ctx, handle, rec, { type, notifies = false } = {
132
159
  // is gone, closed or moved has nowhere to deliver to, so its mail is let go.
133
160
  // `keep(handle, rec)` answers true when the account's keeper will take its mail
134
161
  // itself (lib/gateway/keeper.mjs), which delivers it and then reads it.
162
+ //
163
+ // The round has to end. So an account gets one batch a round, a pod has
164
+ // ROUND_POD_MS to take it, a few accounts are worked on at a time, and each
165
+ // round begins after the account the last one finished with (ctx.roundMark,
166
+ // ctx.setRoundMark): a round cut short leaves out different accounts each time.
167
+ export const ROUND_POD_MS = 10_000;
168
+ const ROUND_TOGETHER = 4;
135
169
  export async function flushAll(ctx, { log = console.log, isGone = async () => false, keep = async () => false } = {}) {
136
- for (const handle of await ctx.heldAccounts()) {
170
+ const all = [...await ctx.heldAccounts()].sort();
171
+ const mark = await ctx.roundMark?.().catch(() => null);
172
+ const from = mark ? all.findIndex((h) => h > mark) : 0;
173
+ const order = from > 0 ? [...all.slice(from), ...all.slice(0, from)] : all;
174
+ const one = async (handle) => {
137
175
  const rec = await ctx.lookup(handle);
138
176
  if (!rec || rec.movedTo || await isGone(handle, rec)) {
139
- for (const n of await ctx.listHeld(handle)) await ctx.dropHeld(handle, n);
177
+ await inTurns(await ctx.listHeld(handle, { limit: BATCH_MAX * 2 }), (n) => ctx.dropHeld(handle, n));
140
178
  log(`held mail @${handle}: let go (no account to deliver to)`);
141
- continue;
179
+ return;
142
180
  }
143
- if (await keep(handle, rec).catch(() => false)) continue;
144
- const n = await flushHeld(ctx, handle, rec).catch((e) => { log(`held mail @${handle}: ${e?.message || e}`); return 0; });
181
+ if (await keep(handle, rec).catch(() => false)) return;
182
+ const n = await flushHeld(ctx, handle, rec, { batches: 1, podMs: ROUND_POD_MS });
145
183
  if (n) log(`held mail @${handle}: ${n} delivered to the pod`);
184
+ };
185
+ for (let i = 0; i < order.length; i += ROUND_TOGETHER) {
186
+ const turn = order.slice(i, i + ROUND_TOGETHER);
187
+ await Promise.all(turn.map((h) => one(h).catch((e) => log(`held mail @${h}: ${e?.message || e}`))));
188
+ await ctx.setRoundMark?.(turn.at(-1)).catch(() => {});
146
189
  }
147
190
  }
148
191
 
@@ -4,7 +4,17 @@
4
4
  // public read, stamps noteOpened from the relay, counts noteReceived after
5
5
  // a forwarded delivery, and hands /api/open, /api/pause and /api/close here.
6
6
 
7
- import { keptNow, holdOn } from './copy.mjs';
7
+ import { keptNow, holdOn, isPersonal } from './copy.mjs';
8
+
9
+ // A gateway whose people all run FediPod in a browser gives every person's
10
+ // account a day its owner was last here, so none is left out of what follows:
11
+ // the day it was attached, or the day these dates began for a row that has
12
+ // neither. A forum, a group and a forum's category are nobody's to open.
13
+ export const FIRST_OPENED = '2026-09-23T00:00:00.000Z';
14
+ export function withOpened(rec) {
15
+ if (!rec || rec.openedAt || !isPersonal(rec)) return rec;
16
+ return { ...rec, openedAt: rec.attachedAt || FIRST_OPENED };
17
+ }
8
18
 
9
19
  // ---- accounts that go quiet -------------------------------------------------
10
20
  //
package/lib/pod/inbox.mjs CHANGED
@@ -20,12 +20,15 @@ const RECEIPT_CT = 'application/json';
20
20
  * public Append the pod may well allow. This is the one place that gets to
21
21
  * decide that, because it lived in two adapters and had to be right in both.
22
22
  *
23
+ * `timeoutMs`, when given, is how long the pod has to answer; a pod that takes
24
+ * longer counts as one that did not answer.
25
+ *
23
26
  * @returns {Promise<boolean>} whether it landed
24
27
  */
25
- export async function appendWithToken(url, body, contentType, { appendToken = null, fetchImpl = fetch, report = null } = {}) {
28
+ export async function appendWithToken(url, body, contentType, { appendToken = null, fetchImpl = fetch, report = null, timeoutMs = null } = {}) {
26
29
  const headers = { 'content-type': contentType,
27
30
  ...(appendToken ? { authorization: `Bearer ${appendToken}` } : {}) };
28
- const r = await fetchImpl(url, { method: 'PUT', headers, body }).catch(() => null);
31
+ const r = await fetchImpl(url, { method: 'PUT', headers, body, ...(timeoutMs ? { signal: AbortSignal.timeout(timeoutMs) } : {}) }).catch(() => null);
29
32
  // The pod's answer, for a caller that keeps a log: a refused write is
30
33
  // otherwise a bare "failed" with the status thrown away.
31
34
  report?.(r ? r.status : 0);
@@ -14,6 +14,9 @@ const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
14
14
  // Config the actor document carries, so a change is not real until it is
15
15
  // republished.
16
16
  const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'approveJoins', 'moderators', 'autoAcceptFollows'];
17
+ // A list as the page shows it: its name and whether its people are kept out
18
+ // of Home. Lists are made and filled in the client; the page only switches.
19
+ const listRow = (l) => ({ id: l.id, title: l.title, exclusive: !!l.exclusive });
17
20
 
18
21
  export async function get(p, ctx, req, res, url) { // eslint-disable-line no-unused-vars
19
22
  const { agent, log, allowed, embedded, port, publicOrigin, versionOnDisk, isGroup } = ctx; // eslint-disable-line no-unused-vars
@@ -71,6 +74,7 @@ export async function get(p, ctx, req, res, url) { // eslint-disable-line no-u
71
74
  aliases: cfg.aliases || [],
72
75
  autoAcceptFollows: cfg.autoAcceptFollows !== false,
73
76
  tagsInHome: cfg.tagsInHome !== false,
77
+ lists: agent.store.getLists().map(listRow),
74
78
  quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
75
79
  // Per-machine, so it comes from the credential file, not pod config —
76
80
  // and it is a fact here, not a setting: moving it means moving data,
@@ -254,6 +258,18 @@ export async function post(p, body, ctx, req, res) { // eslint-disable-line no
254
258
  },
255
259
  });
256
260
  }
261
+ // One list's Home switch, the same `exclusive` a Mastodon app sets.
262
+ case '/lists': {
263
+ if (typeof body.exclusive !== 'boolean') return json(res, 400, { error: 'exclusive (true or false) is the one thing to set here' });
264
+ await agent.requestTakeover?.();
265
+ const lists = agent.store.getLists();
266
+ const l = lists.find(x => x.id === String(body.id || ''));
267
+ if (!l) return json(res, 404, { error: 'no such list' });
268
+ l.exclusive = body.exclusive;
269
+ agent.store.setLists(lists);
270
+ await agent.store.flush();
271
+ return json(res, 200, { ok: true, lists: lists.map(listRow) });
272
+ }
257
273
  default: return false;
258
274
  }
259
275
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fedipod",
3
- "version": "1.56.0",
3
+ "version": "1.57.2",
4
4
  "description": "Standalone single-actor ActivityPub agent whose wire face, RDF truth and state all live on a Solid pod (CSS). Bundles a Phanpy UI and a Mastodon client-API facade.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -55,7 +55,7 @@
55
55
  "install-service": "node bin/fedipod.mjs install-service",
56
56
  "uninstall-service": "node bin/fedipod.mjs uninstall-service",
57
57
  "prepublishOnly": "npm test",
58
- "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node claude/smoke-tests/admin-facade-smoke.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs claude/smoke-tests/connected-pod-only.test.mjs claude/smoke-tests/check-letter.test.mjs claude/smoke-tests/gone-through-relay.test.mjs claude/smoke-tests/connected-feeds.test.mjs claude/smoke-tests/forum-pages.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
58
+ "test": "node claude/smoke-tests/agent-smoke.mjs && node claude/smoke-tests/front-hard-test.mjs && node claude/smoke-tests/copy-test.mjs && node claude/smoke-tests/copy-mode-test.mjs && node claude/smoke-tests/hold-test.mjs && node claude/smoke-tests/masto-gateway-test.mjs && node claude/smoke-tests/admin-facade-smoke.mjs && node scripts/check-pod-calls.mjs && node scripts/verify-ui.mjs && node --test claude/smoke-tests/forum-signup-form.test.mjs claude/smoke-tests/door-verify.test.mjs claude/smoke-tests/oauth-signin.test.mjs claude/smoke-tests/app-registration.test.mjs claude/smoke-tests/push-signout.test.mjs claude/smoke-tests/token-fetches.test.mjs claude/smoke-tests/signin-hardening.test.mjs claude/smoke-tests/load-fixes.test.mjs claude/smoke-tests/unread-docs.test.mjs claude/smoke-tests/post-text-once.test.mjs claude/smoke-tests/connected-pod-only.test.mjs claude/smoke-tests/check-letter.test.mjs claude/smoke-tests/gone-through-relay.test.mjs claude/smoke-tests/connected-feeds.test.mjs claude/smoke-tests/forum-pages.test.mjs claude/smoke-tests/round.test.mjs && node scripts/check-sizes.mjs && node scripts/test-dependents.mjs && node claude/validation/browser-agent/all.mjs",
59
59
  "verify-ui": "node scripts/verify-ui.mjs",
60
60
  "build:sw": "node scripts/build-app.mjs --entry web/app/sw-src.mjs --out web/app/dist/sw.js",
61
61
  "build:boot": "node scripts/build-app.mjs --entry web/app/boot.mjs --out web/app/dist/boot.js",
@@ -108,6 +108,37 @@ document.getElementById('win').addEventListener('win:closed', () => {
108
108
  });
109
109
 
110
110
 
111
+ // One row per list, indented under the lists row: its name, and one dropdown
112
+ // whose two options are its states — the list's people shown in Home, or kept
113
+ // to the list. Changing the word is the whole action.
114
+ function listRows() {
115
+ const out = [];
116
+ for (const l of config.lists || []) {
117
+ const dt = document.createElement('dt');
118
+ dt.className = 'under';
119
+ dt.textContent = l.title;
120
+ const sel = document.createElement('select');
121
+ sel.setAttribute('aria-label', `Posts from ${l.title} in your home timeline`);
122
+ sel.title = ' Whether the posts and boosts of the people on this list appear in your home timeline; hidden, they show only on the list and on the public timeline';
123
+ for (const [v, t] of [['show', 'shown in home'], ['hide', 'hidden from home']]) {
124
+ const o = document.createElement('option');
125
+ o.value = v; o.textContent = t; o.selected = (v === 'hide') === !!l.exclusive;
126
+ sel.appendChild(o);
127
+ }
128
+ sel.addEventListener('change', async () => {
129
+ const hide = sel.value === 'hide';
130
+ sel.disabled = true;
131
+ const r = await write('/lists', { id: l.id, exclusive: hide },
132
+ hide ? `${l.title} is hidden from your home timeline` : `${l.title} is shown in your home timeline`);
133
+ if (r) await load(); else { sel.value = l.exclusive ? 'hide' : 'show'; sel.disabled = false; }
134
+ });
135
+ const dd = document.createElement('dd');
136
+ dd.append(sel);
137
+ out.push([dt, dd]);
138
+ }
139
+ return out;
140
+ }
141
+
111
142
  // One row per identity connected elsewhere, indented under the row that adds
112
143
  // them. Each carries its own way out: disconnecting one has nothing to do with
113
144
  // the others.
@@ -254,7 +285,7 @@ function render() {
254
285
  if (config.movedTo) rows.push(['moved to', config.movedTo]);
255
286
  // A person gates followers here; a group's gate is the joins control on its
256
287
  // kind row, so the row would be a second switch for the same thing.
257
- if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl'], ['hashtag posts in home', 'ctl']);
288
+ if (config.kind !== 'group') rows.splice(2, 0, ['new followers', 'ctl'], ['hashtag posts in home', 'ctl'], ['lists', 'ctl']);
258
289
  // Rows that belong under the one being written, appended after it.
259
290
  let kids = null;
260
291
  for (const [k, v, link] of rows) {
@@ -301,6 +332,13 @@ function render() {
301
332
  HOME_TAGS_CTL.hidden = false;
302
333
  HOME_TAGS_PICK.value = config.tagsInHome !== false ? 'show' : 'hide';
303
334
  }
335
+ // How many lists; each one is a row of its own underneath with its Home
336
+ // switch. Lists are made and filled in the client, so this row only counts.
337
+ if (k === 'lists') {
338
+ const n = (config.lists || []).length;
339
+ dd.textContent = n ? `${n} ${n === 1 ? 'list' : 'lists'}` : 'none — make one in Phanpy';
340
+ kids = listRows();
341
+ }
304
342
  if (k === 'gateway') {
305
343
  dd.textContent = '';
306
344
  dd.append(GATEWAY_CTL);
@@ -23,12 +23,15 @@ import { followActor, followHandle } from '../../lib/core/social.mjs';
23
23
  const PERMANENT_CONFIG = ['handle', 'remotePod', 'issuer', 'root', 'kind'];
24
24
  // Carried in the actor document, so a change is not live until republished.
25
25
  const WIRE_CONFIG = ['name', 'summary', 'icon', 'image', 'fields', 'aliases', 'autoAcceptFollows'];
26
+ // A list as the page shows it: its name and whether its people are kept out
27
+ // of Home. Lists are made and filled in the client; the page only switches.
28
+ const listRow = (l) => ({ id: l.id, title: l.title, exclusive: !!l.exclusive });
26
29
 
27
30
  // The data endpoints this agent answers at the origin root (the page computes
28
31
  // its base as the origin, so it asks for /status, not /admin/status). Anything
29
32
  // not here falls through to the network as a static file or the gateway.
30
33
  export const ADMIN_PATHS = new Set([
31
- '/status', '/config', '/gateway', '/alias', '/rotate-key', '/import',
34
+ '/status', '/config', '/gateway', '/alias', '/rotate-key', '/import', '/lists',
32
35
  '/deadletter', '/trouble', '/blocks', '/profiles', '/modqueue', '/log', '/fediacct', '/describe', '/follow',
33
36
  '/atproto', '/atproto/connect', '/atproto/disconnect',
34
37
  '/rebuild', '/move', '/retire', '/inbox/prune', '/park', '/revive', '/takeover',
@@ -121,6 +124,7 @@ export class AdminFacade {
121
124
  aliases: cfg.aliases || [],
122
125
  autoAcceptFollows: cfg.autoAcceptFollows !== false,
123
126
  tagsInHome: cfg.tagsInHome !== false,
127
+ lists: a.store.getLists().map(listRow),
124
128
  quiescedAt: cfg.quiescedAt || null, movedTo: cfg.movedTo || null,
125
129
  mode: a.status().mode,
126
130
  // Live from the credential's actual backend on THIS browser (a
@@ -242,6 +246,19 @@ export class AdminFacade {
242
246
  });
243
247
  }
244
248
 
249
+ // One list's Home switch, the same `exclusive` a Mastodon app sets.
250
+ case '/lists': {
251
+ if (typeof body.exclusive !== 'boolean') return json(400, { error: 'exclusive (true or false) is the one thing to set here' });
252
+ await a.requestTakeover?.();
253
+ const lists = a.store.getLists();
254
+ const l = lists.find(x => x.id === String(body.id || ''));
255
+ if (!l) return json(404, { error: 'no such list' });
256
+ l.exclusive = body.exclusive;
257
+ a.store.setLists(lists);
258
+ await a.store.flush();
259
+ return json(200, { ok: true, lists: lists.map(listRow) });
260
+ }
261
+
245
262
  case '/describe': {
246
263
  const cfg = { ...a.store.getConfig() };
247
264
  if ('summary' in body) cfg.summary = body.summary || undefined;
@@ -69313,7 +69313,7 @@ async function handle3(api, ctx) {
69313
69313
  if (mAccLists && req.method === "GET") {
69314
69314
  const actorUrl = api.store.urlFor(mAccLists[1]);
69315
69315
  if (!actorUrl) return send(404, { error: "Record not found" });
69316
- return send(200, api.store.getLists().filter((l) => (l.members || []).includes(actorUrl)).map((l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || "list", exclusive: false })));
69316
+ return send(200, api.store.getLists().filter((l) => (l.members || []).includes(actorUrl)).map((l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || "list", exclusive: !!l.exclusive })));
69317
69317
  }
69318
69318
  if (pathname === "/api/v1/mutes" && req.method === "GET") {
69319
69319
  return send(200, (api.store.getMuted().actors || []).map((a) => api.account(a)));
@@ -69382,9 +69382,10 @@ async function handle4(api, ctx) {
69382
69382
  if (pathname === "/api/v1/timelines/home" || pathname === "/api/v1/timelines/public" || pathname === "/api/v1/trends/statuses") {
69383
69383
  const localOnly = pathname === "/api/v1/timelines/public" && url.searchParams.get("local") === "true";
69384
69384
  const hideTags = pathname === "/api/v1/timelines/home" && api.store.getConfig?.()?.tagsInHome === false;
69385
+ const hiddenBy = new Set(pathname === "/api/v1/timelines/home" ? (api.store.getLists?.() || []).filter((l) => l.exclusive).flatMap((l) => l.members || []) : []);
69385
69386
  const all = api.store.getStatuses();
69386
69387
  const muted = new Set(api.store.getMuted().actors);
69387
- let items = all.filter((s) => s.kind !== "remote" && s.kind !== "mention").filter((s) => !s.direct && s.visibility !== "direct").filter((s) => !muted.has(s.actor) && !muted.has(s.via)).filter((s) => !hideTags || s.kind !== "tag" || s.sourceAccts?.length).filter((s) => !localOnly || s.kind === "post" && s.visibility !== "private").sort((a, b) => String(b.published || "").localeCompare(String(a.published || "")));
69388
+ let items = all.filter((s) => s.kind !== "remote" && s.kind !== "mention").filter((s) => !s.direct && s.visibility !== "direct").filter((s) => !muted.has(s.actor) && !muted.has(s.via)).filter((s) => !hideTags || s.kind !== "tag" || s.sourceAccts?.length).filter((s) => !hiddenBy.size || !(hiddenBy.has(s.actor) || hiddenBy.has(s.via))).filter((s) => !localOnly || s.kind === "post" && s.visibility !== "private").sort((a, b) => String(b.published || "").localeCompare(String(a.published || "")));
69388
69389
  const { items: page2, headers } = api.page(items, url);
69389
69390
  return send(200, page2.map((s) => api.statusOrBoost(s, { all })), headers);
69390
69391
  }
@@ -69452,7 +69453,8 @@ async function handle4(api, ctx) {
69452
69453
  const { items: page2, headers } = api.page(items, url);
69453
69454
  return send(200, page2.map((s) => api.statusOrBoost(s, { all })), headers);
69454
69455
  }
69455
- const listJson = (l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || "list", exclusive: false });
69456
+ const flag = (v) => v === true || v === "true" || v === "on" || v === "1";
69457
+ const listJson = (l) => ({ id: l.id, title: l.title, replies_policy: l.repliesPolicy || "list", exclusive: !!l.exclusive });
69456
69458
  if (pathname === "/api/v1/lists" && req.method === "GET") {
69457
69459
  return send(200, api.store.getLists().map(listJson));
69458
69460
  }
@@ -69461,7 +69463,13 @@ async function handle4(api, ctx) {
69461
69463
  const title = String(body.title || "").trim();
69462
69464
  if (!title) return send(422, { error: "a title is required" });
69463
69465
  const lists = api.store.getLists();
69464
- const l = { id: node_crypto_default.randomBytes(8).toString("hex"), title, repliesPolicy: body.replies_policy || "list", members: [] };
69466
+ const l = {
69467
+ id: node_crypto_default.randomBytes(8).toString("hex"),
69468
+ title,
69469
+ repliesPolicy: body.replies_policy || "list",
69470
+ exclusive: flag(body.exclusive),
69471
+ members: []
69472
+ };
69465
69473
  lists.push(l);
69466
69474
  api.store.setLists(lists);
69467
69475
  return send(200, listJson(l));
@@ -69479,6 +69487,7 @@ async function handle4(api, ctx) {
69479
69487
  const body = await readBody2(req);
69480
69488
  if (body.title) l.title = String(body.title);
69481
69489
  if (body.replies_policy) l.repliesPolicy = body.replies_policy;
69490
+ if ("exclusive" in body) l.exclusive = flag(body.exclusive);
69482
69491
  api.store.setLists(lists);
69483
69492
  }
69484
69493
  return send(200, listJson(l));
@@ -71622,6 +71631,7 @@ var ImportWorker = class {
71622
71631
  // web/app/admin-facade.mjs
71623
71632
  var PERMANENT_CONFIG = ["handle", "remotePod", "issuer", "root", "kind"];
71624
71633
  var WIRE_CONFIG = ["name", "summary", "icon", "image", "fields", "aliases", "autoAcceptFollows"];
71634
+ var listRow = (l) => ({ id: l.id, title: l.title, exclusive: !!l.exclusive });
71625
71635
  var ADMIN_PATHS = /* @__PURE__ */ new Set([
71626
71636
  "/status",
71627
71637
  "/config",
@@ -71629,6 +71639,7 @@ var ADMIN_PATHS = /* @__PURE__ */ new Set([
71629
71639
  "/alias",
71630
71640
  "/rotate-key",
71631
71641
  "/import",
71642
+ "/lists",
71632
71643
  "/deadletter",
71633
71644
  "/trouble",
71634
71645
  "/blocks",
@@ -71733,6 +71744,7 @@ var AdminFacade = class {
71733
71744
  aliases: cfg.aliases || [],
71734
71745
  autoAcceptFollows: cfg.autoAcceptFollows !== false,
71735
71746
  tagsInHome: cfg.tagsInHome !== false,
71747
+ lists: a.store.getLists().map(listRow),
71736
71748
  quiescedAt: cfg.quiescedAt || null,
71737
71749
  movedTo: cfg.movedTo || null,
71738
71750
  mode: a.status().mode,
@@ -71876,6 +71888,18 @@ var AdminFacade = class {
71876
71888
  ...pub?.unreachable?.length ? { unreachable: pub.unreachable } : {}
71877
71889
  });
71878
71890
  }
71891
+ // One list's Home switch, the same `exclusive` a Mastodon app sets.
71892
+ case "/lists": {
71893
+ if (typeof body.exclusive !== "boolean") return json2(400, { error: "exclusive (true or false) is the one thing to set here" });
71894
+ await a.requestTakeover?.();
71895
+ const lists = a.store.getLists();
71896
+ const l = lists.find((x) => x.id === String(body.id || ""));
71897
+ if (!l) return json2(404, { error: "no such list" });
71898
+ l.exclusive = body.exclusive;
71899
+ a.store.setLists(lists);
71900
+ await a.store.flush();
71901
+ return json2(200, { ok: true, lists: lists.map(listRow) });
71902
+ }
71879
71903
  case "/describe": {
71880
71904
  const cfg = { ...a.store.getConfig() };
71881
71905
  if ("summary" in body) cfg.summary = body.summary || void 0;