fedipod 1.30.0 → 1.36.6
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 +1 -1
- package/gateway.md +37 -0
- package/gui.md +2 -2
- package/lib/connections/bskyfeed.mjs +6 -0
- package/lib/core/deliver.mjs +52 -22
- package/lib/core/intake/index.mjs +17 -4
- package/lib/core/lease.mjs +7 -7
- package/lib/core/store.mjs +25 -5
- package/lib/gateway/caches.mjs +36 -0
- package/lib/gateway/front-core.mjs +59 -66
- package/lib/gateway/gateway-core.mjs +20 -8
- package/lib/gateway/headers.mjs +49 -0
- package/lib/gateway/notices.mjs +84 -0
- package/lib/gateway/quiet.mjs +189 -0
- package/lib/pod/containers.mjs +5 -1
- package/lib/pod/inbox.mjs +22 -3
- package/lib/pod/transport.mjs +36 -3
- package/lib/session/README.md +14 -0
- package/lib/session/fedi-account.mjs +62 -0
- package/lib/session/package.json +10 -2
- package/package.json +1 -1
- package/run-agent.mjs +16 -0
- package/scripts/stage-site.mjs +6 -3
- package/web/admin/bar.css +44 -10
- package/web/admin/client/index.html +29 -2
- package/web/admin/gateway.js +19 -0
- package/web/admin/index.html +46 -3
- package/web/admin/notices-bar.js +90 -0
- package/web/admin/record.js +1 -0
- package/web/admin/setup/index.html +29 -2
- package/web/admin/upkeep.js +5 -1
- package/web/app/admin-facade.mjs +24 -0
- package/web/app/agent.mjs +59 -6
- package/web/app/boot.mjs +2 -0
- package/web/app/deliver-relay.mjs +47 -7
- package/web/app/dist/boot.js +39 -4
- package/web/app/dist/boot.js.map +2 -2
- package/web/app/dist/sw.js +273 -38
- package/web/app/dist/sw.js.map +3 -3
- package/web/app/update.js +4 -0
- package/web/front/admin.html +3 -1
- package/web/front/notices.html +69 -0
- package/web/front/notices.js +107 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// quiet.mjs — accounts that go quiet: what the front knows about an account
|
|
2
|
+
// nobody opens, and what its owner may say about it. The router in
|
|
3
|
+
// front-core.mjs asks closedState/accountState at the door and on every
|
|
4
|
+
// public read, stamps noteOpened from the relay, counts noteReceived after
|
|
5
|
+
// a forwarded delivery, and hands /api/open, /api/pause and /api/close here.
|
|
6
|
+
|
|
7
|
+
// ---- accounts that go quiet -------------------------------------------------
|
|
8
|
+
//
|
|
9
|
+
// The gateway holds no mail: every delivery it takes is two writes into the
|
|
10
|
+
// owner's pod inbox, read only when their browser is open. An account nobody
|
|
11
|
+
// opens grows on its pod without limit, and its owner comes back to a drain
|
|
12
|
+
// of everything at once. So the front keeps two facts about each browser
|
|
13
|
+
// account — when its owner was last here, and how much content has arrived
|
|
14
|
+
// since — and acts on them.
|
|
15
|
+
//
|
|
16
|
+
// `openedAt` is written when the owner signs in (POST /api/open, always) and
|
|
17
|
+
// while they act through the relay (at most hourly). The count of content
|
|
18
|
+
// deliveries since then lives in its own record, keyed by handle AND
|
|
19
|
+
// openedAt: a fresh stamp starts a fresh count with nothing to reset, and a
|
|
20
|
+
// delivery's write can never overwrite a sign-in's. Two deliveries landing
|
|
21
|
+
// together may each read the same total and one increment is lost — the cap
|
|
22
|
+
// comes out slightly soft, never too strict.
|
|
23
|
+
//
|
|
24
|
+
// PAUSED: the count has reached the cap, or the owner said so. Content is
|
|
25
|
+
// accepted and discarded, control (follows, unfollows, moves, deletions,
|
|
26
|
+
// blocks) still lands. The owner's next sign-in ends a cap pause by itself; a
|
|
27
|
+
// pause they set lasts until they lift it.
|
|
28
|
+
//
|
|
29
|
+
// CLOSED: the owner said so, or nothing has opened the account for the close
|
|
30
|
+
// window. Found closed by time, it is written down so it stays closed. From
|
|
31
|
+
// then on the door, the actor and the handle answer 410 — never a failure
|
|
32
|
+
// code, which senders retry and count against this whole host, and never a
|
|
33
|
+
// success, which would keep them sending. Nothing on the pod is touched. An
|
|
34
|
+
// address that moved (see /api/move) keeps answering as moved; closing does
|
|
35
|
+
// not take that away.
|
|
36
|
+
//
|
|
37
|
+
// Only an account whose owner has signed in from a browser carries
|
|
38
|
+
// `openedAt`. A DeviceAgent behind this door never does: it drains its own
|
|
39
|
+
// inbox as it runs, so it is never counted, never paused and never closed by
|
|
40
|
+
// time. Accounts from before this was built are counted from their next
|
|
41
|
+
// sign-in.
|
|
42
|
+
export const DEFAULT_PAUSE_ITEMS = 5000;
|
|
43
|
+
export const DEFAULT_CLOSE_DAYS = 183; // six months
|
|
44
|
+
const OPEN_STAMP_EVERY_MS = 60 * 60_000; // the relay's stamp, at most hourly
|
|
45
|
+
const pauseItemsOf = (ctx) => (Number(ctx.pauseItems) > 0 ? Number(ctx.pauseItems) : DEFAULT_PAUSE_ITEMS);
|
|
46
|
+
const closeDaysOf = (ctx) => (Number(ctx.closeDays) > 0 ? Number(ctx.closeDays) : DEFAULT_CLOSE_DAYS);
|
|
47
|
+
const receivedKey = (key, rec) => `${key}/${rec.openedAt}`;
|
|
48
|
+
|
|
49
|
+
export async function receivedSince(ctx, key, rec) {
|
|
50
|
+
if (!ctx.readReceived || !rec.openedAt) return { items: 0, bytes: 0 };
|
|
51
|
+
const got = await ctx.readReceived(receivedKey(key, rec)).catch(() => null);
|
|
52
|
+
return { items: Number(got?.items) || 0, bytes: Number(got?.bytes) || 0 };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One more content delivery reached the pod. Nothing to count against until
|
|
56
|
+
// the owner has been here once.
|
|
57
|
+
export async function noteReceived(ctx, key, rec, bytes) {
|
|
58
|
+
if (!ctx.readReceived || !ctx.writeReceived || !rec.openedAt) return;
|
|
59
|
+
const so = await receivedSince(ctx, key, rec);
|
|
60
|
+
await ctx.writeReceived(receivedKey(key, rec), { items: so.items + 1, bytes: so.bytes + (Number(bytes) || 0) })
|
|
61
|
+
.catch((e) => console.log(`front @${key}: count not written: ${e?.message || e}`));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The owner is here. `always` is a sign-in; the relay stamps at most hourly,
|
|
65
|
+
// so a busy hour of posting is one write. The count that went with the old
|
|
66
|
+
// stamp is dropped: it is over, and a store should not keep one per hour.
|
|
67
|
+
export async function noteOpened(ctx, key, rec, { always = false } = {}) {
|
|
68
|
+
if (!ctx.putDirectory) return rec;
|
|
69
|
+
if (!always && rec.openedAt && Date.now() - Date.parse(rec.openedAt) < OPEN_STAMP_EVERY_MS) return rec;
|
|
70
|
+
const next = { ...rec, openedAt: new Date().toISOString() };
|
|
71
|
+
await ctx.putDirectory(key, next);
|
|
72
|
+
if (rec.openedAt && ctx.dropReceived) await ctx.dropReceived(receivedKey(key, rec)).catch(() => {});
|
|
73
|
+
return next;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Closed, and why. Reads no count: this is asked on every public read.
|
|
77
|
+
export async function closedState(ctx, key, rec) {
|
|
78
|
+
if (rec.movedTo) return { closed: false };
|
|
79
|
+
if (rec.closedAt) return { closed: true, closedAt: rec.closedAt, closedBy: rec.closedBy || 'owner' };
|
|
80
|
+
if (rec.openedAt && Date.now() - Date.parse(rec.openedAt) > closeDaysOf(ctx) * 86400_000) {
|
|
81
|
+
const closedAt = new Date().toISOString();
|
|
82
|
+
if (ctx.putDirectory) await ctx.putDirectory(key, { ...rec, closedAt, closedBy: 'quiet' });
|
|
83
|
+
console.log(`front @${key}: closed — nothing opened it since ${rec.openedAt}`);
|
|
84
|
+
return { closed: true, closedAt, closedBy: 'quiet' };
|
|
85
|
+
}
|
|
86
|
+
return { closed: false };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// The whole standing of an account, for the door and for its owner's page.
|
|
90
|
+
export async function accountState(ctx, key, rec) {
|
|
91
|
+
const c = await closedState(ctx, key, rec);
|
|
92
|
+
const received = c.closed ? { items: 0, bytes: 0 } : await receivedSince(ctx, key, rec);
|
|
93
|
+
const cap = pauseItemsOf(ctx);
|
|
94
|
+
const pausedBy = c.closed ? null : rec.pausedAt ? 'owner' : received.items >= cap ? 'quiet' : null;
|
|
95
|
+
return {
|
|
96
|
+
closed: c.closed, closedAt: c.closedAt || null, closedBy: c.closedBy || null,
|
|
97
|
+
paused: !!pausedBy, pausedBy, pausedAt: rec.pausedAt || null,
|
|
98
|
+
openedAt: rec.openedAt || null, received, pauseItems: cap, closeDays: closeDaysOf(ctx),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A 410 with a reason, the shape every closed or moved id answers with.
|
|
103
|
+
export const closedAnswer = (headers = {}, why = 'this address is closed') => ({
|
|
104
|
+
status: 410, headers: { 'cache-control': 'no-store', ...headers, 'content-type': 'application/json' },
|
|
105
|
+
body: JSON.stringify({ error: why }) });
|
|
106
|
+
|
|
107
|
+
// The owner of a row, proved the way the relay proves them: a pod token whose
|
|
108
|
+
// WebID is the row's, or lives on the row's pod for a row from before WebIDs
|
|
109
|
+
// were recorded.
|
|
110
|
+
async function provedOwner(request, pathname, ctx, rec, { j, verifyPodToken, webidUnderPod }) {
|
|
111
|
+
const webid = await verifyPodToken(request, pathname, ctx.verifier);
|
|
112
|
+
if (!webid) return { error: j(401, { error: 'a Solid-OIDC token proving the pod is required' }) };
|
|
113
|
+
const owner = rec.webId ? webid === rec.webId : webidUnderPod(webid, rec.podHome);
|
|
114
|
+
if (!owner) return { error: j(403, { error: "the token proves a different pod than this account's" }) };
|
|
115
|
+
return { webid };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// The owner's three routes. `deps` are front-core's own JSON reply and
|
|
119
|
+
// token check, handed in so this module stays free of the router's privates.
|
|
120
|
+
// Returns a response, or null when the path is not one of these.
|
|
121
|
+
export async function routeQuietApi(request, pathname, ctx, deps) {
|
|
122
|
+
const { j } = deps;
|
|
123
|
+
if (!['/api/open', '/api/pause', '/api/close'].includes(pathname)) return null;
|
|
124
|
+
// A page at another origin asks first whether it may call, and is answered
|
|
125
|
+
// as the relay answers it. Unanswered, the browser never sends the call and
|
|
126
|
+
// some pages asked again every minute.
|
|
127
|
+
if (request.method === 'OPTIONS') return deps.apiPreflight();
|
|
128
|
+
if (request.method !== 'POST') return null;
|
|
129
|
+
// The owner is here: their browser says so as it opens the account. The
|
|
130
|
+
// answer is the account's standing at this gateway, which the manage page
|
|
131
|
+
// shows. A closed address is told so and nothing is written.
|
|
132
|
+
if (pathname === '/api/open' && request.method === 'POST') {
|
|
133
|
+
if (!ctx.putDirectory) return j(501, { error: 'this front keeps no directory' });
|
|
134
|
+
let body;
|
|
135
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
136
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
137
|
+
let rec = await ctx.lookup(handle);
|
|
138
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
139
|
+
const who = await provedOwner(request, pathname, ctx, rec, deps);
|
|
140
|
+
if (who.error) return who.error;
|
|
141
|
+
const before = await closedState(ctx, handle, rec);
|
|
142
|
+
if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt, closedBy: before.closedBy });
|
|
143
|
+
rec = await noteOpened(ctx, handle, rec, { always: true });
|
|
144
|
+
return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec) });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// The owner pausing their own account, or lifting the pause they set.
|
|
148
|
+
if (pathname === '/api/pause' && request.method === 'POST') {
|
|
149
|
+
if (!ctx.putDirectory) return j(501, { error: 'this front keeps no directory' });
|
|
150
|
+
let body;
|
|
151
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
152
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
153
|
+
let rec = await ctx.lookup(handle);
|
|
154
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
155
|
+
const who = await provedOwner(request, pathname, ctx, rec, deps);
|
|
156
|
+
if (who.error) return who.error;
|
|
157
|
+
const before = await closedState(ctx, handle, rec);
|
|
158
|
+
if (before.closed) return j(410, { error: 'this address is closed', closedAt: before.closedAt });
|
|
159
|
+
if (typeof body.paused !== 'boolean') return j(400, { error: 'paused must be true or false' });
|
|
160
|
+
if (body.paused && !rec.pausedAt) rec = { ...rec, pausedAt: new Date().toISOString() };
|
|
161
|
+
if (!body.paused && rec.pausedAt) { rec = { ...rec }; delete rec.pausedAt; }
|
|
162
|
+
// Lifting a pause is a sign-in's worth of "I am here": the count starts over.
|
|
163
|
+
if (!body.paused) rec = { ...rec, openedAt: new Date().toISOString() };
|
|
164
|
+
await ctx.putDirectory(handle, rec);
|
|
165
|
+
console.log(`front @${handle}: ${body.paused ? 'paused' : 'resumed'} by its owner`);
|
|
166
|
+
return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec) });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The owner closing their address for good. Repeating it changes nothing.
|
|
170
|
+
if (pathname === '/api/close' && request.method === 'POST') {
|
|
171
|
+
if (!ctx.putDirectory) return j(501, { error: 'this front keeps no directory' });
|
|
172
|
+
let body;
|
|
173
|
+
try { body = JSON.parse(await request.clone().text()); } catch { return j(400, { error: 'bad JSON' }); }
|
|
174
|
+
const handle = String(body.handle || '').toLowerCase();
|
|
175
|
+
let rec = await ctx.lookup(handle);
|
|
176
|
+
if (!rec) return j(404, { error: 'no such account' });
|
|
177
|
+
const who = await provedOwner(request, pathname, ctx, rec, deps);
|
|
178
|
+
if (who.error) return who.error;
|
|
179
|
+
if (body.confirm !== true) return j(400, { error: 'closing is for good — send confirm: true' });
|
|
180
|
+
if (!rec.closedAt) {
|
|
181
|
+
rec = { ...rec, closedAt: new Date().toISOString(), closedBy: 'owner' };
|
|
182
|
+
await ctx.putDirectory(handle, rec);
|
|
183
|
+
console.log(`front @${handle}: closed by its owner`);
|
|
184
|
+
}
|
|
185
|
+
return j(200, { ok: true, handle, ...await accountState(ctx, handle, rec) });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return null;
|
|
189
|
+
}
|
package/lib/pod/containers.mjs
CHANGED
|
@@ -51,12 +51,16 @@ export async function provisionPublic(pod, base) {
|
|
|
51
51
|
* The private half: the home itself and the state container.
|
|
52
52
|
*
|
|
53
53
|
* Idempotent, so two devices doing it at once is harmless — which is what
|
|
54
|
-
* makes it safe to run on every boot rather than only at setup.
|
|
54
|
+
* makes it safe to run on every boot rather than only at setup. Asked before
|
|
55
|
+
* written: a browser's worker boots whenever the browser likes, and each boot
|
|
56
|
+
* used to write the canary and both rules again. Returns whether it wrote.
|
|
55
57
|
*/
|
|
56
58
|
export async function provisionPrivate(pod, urls) {
|
|
59
|
+
if (await exists(pod, urls.state)) return false;
|
|
57
60
|
await pod.putJson(keepUrl(urls.state), KEEP, KEEP_CT);
|
|
58
61
|
await pod.setAcl(urls.state, []);
|
|
59
62
|
await pod.setAcl(urls.home, []);
|
|
63
|
+
return true;
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
package/lib/pod/inbox.mjs
CHANGED
|
@@ -85,13 +85,15 @@ export async function writeKeep(pod, urls) {
|
|
|
85
85
|
* those and not the others is an inbox that is open when it should be shut.
|
|
86
86
|
*/
|
|
87
87
|
export async function setPosture(pod, urls, posture) {
|
|
88
|
-
|
|
89
|
-
|
|
88
|
+
// Restated at every start, and most starts find it already so: the rule is
|
|
89
|
+
// read first and written only when it differs (transport.setAcl ifChanged).
|
|
90
|
+
if (posture === 'open') return pod.setAcl(urls.inbox, ['Append'], { ifChanged: true });
|
|
91
|
+
if (posture === 'closed') return pod.setAcl(urls.inbox, [], { ifChanged: true });
|
|
90
92
|
const webId = posture?.gatewayWebId;
|
|
91
93
|
if (!webId) throw new Error(`inbox.setPosture: unknown posture ${JSON.stringify(posture)}`);
|
|
92
94
|
// Public loses Append; the door keeps it. Both halves in one write, because
|
|
93
95
|
// between two writes the inbox is either open to everyone or shut to the door.
|
|
94
|
-
return pod.setAcl(urls.inbox, [], { appendAgents: [webId] });
|
|
96
|
+
return pod.setAcl(urls.inbox, [], { appendAgents: [webId], ifChanged: true });
|
|
95
97
|
}
|
|
96
98
|
|
|
97
99
|
/**
|
|
@@ -145,3 +147,20 @@ export async function readDeliveryReceipt(pod, itemUrl, { maxBytes, readCapped }
|
|
|
145
147
|
* @returns {Promise<boolean>} whether the pod removed it
|
|
146
148
|
*/
|
|
147
149
|
export const dropHandledItem = (pod, url) => pod.delete(url);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Remove the receipt a gateway wrote beside an item, once the item is gone.
|
|
153
|
+
* Best-effort: a receipt that stays is a stray document, never a lost
|
|
154
|
+
* delivery. Left behind, they were one stray per delivery, forever.
|
|
155
|
+
*/
|
|
156
|
+
export const dropReceiptBeside = (pod, url) => pod.delete(url + '.receipt.json').catch(() => false);
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Receipts whose item is no longer in the inbox, from the last listing: what
|
|
160
|
+
* earlier drains left behind. The drain removes a few of these each sweep
|
|
161
|
+
* until none are left.
|
|
162
|
+
*/
|
|
163
|
+
export const orphanReceipts = (pod, urls) => pod.orphanReceipts?.(urls.inbox) ?? [];
|
|
164
|
+
|
|
165
|
+
/** Remove one such stray. @returns {Promise<boolean>} whether the pod removed it */
|
|
166
|
+
export const dropStrayReceipt = (pod, url) => pod.delete(url).catch(() => false);
|
package/lib/pod/transport.mjs
CHANGED
|
@@ -345,7 +345,31 @@ export class PodTransport {
|
|
|
345
345
|
const podTarget = this.toPod ? this.toPod(targetUrl) : targetUrl;
|
|
346
346
|
const url = await this.aclUrlFor(podTarget);
|
|
347
347
|
if (!await this.aclWritable(url)) return null;
|
|
348
|
-
|
|
348
|
+
const doc = this.aclDoc(podTarget, publicModes, { ...opts, aclUrl: url });
|
|
349
|
+
// `ifChanged`: read the rule the pod holds and write only when it differs.
|
|
350
|
+
// For a rule restated at every start — the inbox door — a read is the
|
|
351
|
+
// whole cost, where a write took the pod's lock to say the same thing.
|
|
352
|
+
if (opts.ifChanged && await this.aclSame(url, doc)) return { status: 304, unchanged: true };
|
|
353
|
+
return this.put(url, doc, 'text/turtle');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Whether the pod's rule at `aclUrl` states exactly what `doc` states.
|
|
357
|
+
// Compared as graphs, not bytes: the pod serialises what it holds its own
|
|
358
|
+
// way. Every rule this file writes names its subjects, so triple sets are
|
|
359
|
+
// enough; anything unreadable or with blank nodes reads as different.
|
|
360
|
+
async aclSame(aclUrl, doc) {
|
|
361
|
+
try {
|
|
362
|
+
const res = await this.fetch(aclUrl, { headers: { accept: 'text/turtle' } });
|
|
363
|
+
if (res.status !== 200) return false;
|
|
364
|
+
const triples = (text) => {
|
|
365
|
+
const g = $rdf.graph();
|
|
366
|
+
$rdf.parse(text, g, aclUrl, 'text/turtle');
|
|
367
|
+
if (g.statements.some((st) => st.subject.termType === 'BlankNode' || st.object.termType === 'BlankNode')) return null;
|
|
368
|
+
return g.statements.map((st) => `${st.subject.value} ${st.predicate.value} ${st.object.value}`).sort().join('\n');
|
|
369
|
+
};
|
|
370
|
+
const theirs = triples(await res.text());
|
|
371
|
+
return theirs !== null && theirs === triples(doc);
|
|
372
|
+
} catch { return false; }
|
|
349
373
|
}
|
|
350
374
|
|
|
351
375
|
// Child documents of an LDP container (URLs under it, excluding aux docs).
|
|
@@ -382,12 +406,14 @@ export class PodTransport {
|
|
|
382
406
|
$rdf.parse(body, g, url, 'text/turtle');
|
|
383
407
|
const here = $rdf.sym(url);
|
|
384
408
|
const seen = new Set();
|
|
409
|
+
const receipts = new Set();
|
|
385
410
|
const list = [];
|
|
386
411
|
for (const child of g.each(here, LDP('contains'), null, here)) {
|
|
387
412
|
const u = child.value;
|
|
388
413
|
// `.receipt.json` is a verification receipt a gateway wrote beside an
|
|
389
414
|
// inbox item — read with the item, never enumerated as an item itself.
|
|
390
|
-
if (
|
|
415
|
+
if (u.endsWith('.receipt.json')) { receipts.add(u); continue; }
|
|
416
|
+
if (!u.startsWith(url) || u === url || /\.(acl|meta)$/.test(u) || seen.has(u)) continue;
|
|
391
417
|
seen.add(u);
|
|
392
418
|
list.push({
|
|
393
419
|
url: u,
|
|
@@ -395,14 +421,21 @@ export class PodTransport {
|
|
|
395
421
|
modified: g.any(child, DC('modified'), null, here)?.value || null,
|
|
396
422
|
});
|
|
397
423
|
}
|
|
424
|
+
// Whether a receipt sits beside each item, so the drain asks for one only
|
|
425
|
+
// where there is one; and the receipts nothing sits beside any more.
|
|
426
|
+
for (const item of list) item.receipt = receipts.has(item.url + '.receipt.json');
|
|
427
|
+
const orphans = [...receipts].filter((r) => !seen.has(r.slice(0, -'.receipt.json'.length)));
|
|
398
428
|
// Oldest first. An LDP listing is a set, so without this a drain works in
|
|
399
429
|
// whatever order the graph happened to parse — a mention from last week
|
|
400
430
|
// after one from today, and no way to make progress predictable.
|
|
401
431
|
list.sort((a, b) => String(a.modified || '').localeCompare(String(b.modified || '')));
|
|
402
|
-
this._listCache.set(url, { etag: res.headers.get('etag'), children: list });
|
|
432
|
+
this._listCache.set(url, { etag: res.headers.get('etag'), children: list, orphans });
|
|
403
433
|
return list;
|
|
404
434
|
}
|
|
405
435
|
|
|
436
|
+
/** Receipts in the last listing of `url` whose item is gone. */
|
|
437
|
+
orphanReceipts(url) { return this._listCache?.get(url)?.orphans ?? []; }
|
|
438
|
+
|
|
406
439
|
/**
|
|
407
440
|
* The WebID profile advertises the actor as an account:
|
|
408
441
|
* <webId> foaf:account <actor> .
|
package/lib/session/README.md
CHANGED
|
@@ -18,6 +18,9 @@ Once signed in, the account gives you:
|
|
|
18
18
|
- **`post`** — publishes a post as them, public, unlisted or followers-only.
|
|
19
19
|
- **`reply`** — answers a post.
|
|
20
20
|
- **`timeline`** — their home timeline, newest first.
|
|
21
|
+
- **`outbox`** — their own posts, newest first. Pass `rdf: true` to also get
|
|
22
|
+
the real thing as RDF, for an app that wants to work with it as linked
|
|
23
|
+
data rather than as this library's own shape.
|
|
21
24
|
- **`follow`** — follows someone by handle.
|
|
22
25
|
- **`favourite`**, **`boost`** — as they say.
|
|
23
26
|
- **`profile`** — who they are as their server or pod shows them: name,
|
|
@@ -37,6 +40,8 @@ DeviceAgent or Server account, and when fedipod.net is next open for a
|
|
|
37
40
|
browser-based one.
|
|
38
41
|
|
|
39
42
|
Three files, no dependencies. It runs in a page and in a service worker.
|
|
43
|
+
The one exception: `outbox({ rdf: true })` needs the `jsonld` package, and
|
|
44
|
+
only loads it when that flag is actually used.
|
|
40
45
|
|
|
41
46
|
[The demo](https://jeff-zucker.github.io/FediPod/) shows the sign-in and
|
|
42
47
|
the profile that comes back.
|
|
@@ -73,6 +78,7 @@ const me = await accounts.current(); // null when nobody i
|
|
|
73
78
|
if (me?.notice) show(me.notice);
|
|
74
79
|
await me.post({ text: 'Hello from my app' });
|
|
75
80
|
for (const p of await me.timeline({ limit: 20 })) render(p);
|
|
81
|
+
for (const p of await me.outbox({ limit: 20 })) render(p);
|
|
76
82
|
await me.follow('@aisha@her.server');
|
|
77
83
|
await me.reply(p.url, 'Well said');
|
|
78
84
|
await me.signOut();
|
|
@@ -93,6 +99,14 @@ await me.signOut();
|
|
|
93
99
|
- **`reply(post, text)`** — the post is named by its address or its id.
|
|
94
100
|
- **`timeline({ limit })`** — each post as `{ id, url, author: { id, handle,
|
|
95
101
|
name }, html, published, inReplyTo }`.
|
|
102
|
+
- **`outbox({ limit, rdf })`** — the same shape as `timeline`, but only this
|
|
103
|
+
account's own posts. With `rdf: true`, the returned array also carries
|
|
104
|
+
`.rdf`: the real outbox parsed into an RDF/JS quad array — for a Mastodon
|
|
105
|
+
account too, since a Mastodon server is itself an ActivityPub server with
|
|
106
|
+
a real actor and outbox, same as a pod's. It comes back empty on a server
|
|
107
|
+
that requires a signed request just to read that document, which some
|
|
108
|
+
do. Reading it needs the `jsonld` package available to your app; without
|
|
109
|
+
`rdf: true` nothing changes and nothing extra loads.
|
|
96
110
|
- **`fetch`** — the raw authenticated fetch, for anything the above does not
|
|
97
111
|
cover.
|
|
98
112
|
- **`actor`** — the account's ActivityPub id.
|
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
// await me.post({ text }); await me.timeline({ limit: 20 })
|
|
16
16
|
// await me.reply(postUrlOrId, text); await me.follow('@aisha@her.server')
|
|
17
17
|
// await me.favourite(postUrlOrId); await me.boost(postUrlOrId)
|
|
18
|
+
// await me.outbox({ limit: 20 }); // this account's own posts, newest first
|
|
19
|
+
// await me.outbox({ rdf: true }); // the same, plus the real RDF graph on .rdf
|
|
18
20
|
// await me.signOut()
|
|
19
21
|
//
|
|
20
22
|
// A Mastodon account is used through its server's API with a token the
|
|
@@ -58,6 +60,35 @@ export function fediAccount({
|
|
|
58
60
|
return body;
|
|
59
61
|
};
|
|
60
62
|
|
|
63
|
+
// Only loaded when `outbox({ rdf: true })` is actually called, so nobody
|
|
64
|
+
// pays for a JSON-LD parser just by importing this file. Not in
|
|
65
|
+
// `dependencies` — the app supplies "jsonld" if it wants this flag to work.
|
|
66
|
+
const loadJsonLd = async () => {
|
|
67
|
+
try { return (await import('jsonld')).default; }
|
|
68
|
+
catch { throw new Error('outbox({ rdf: true }) needs the "jsonld" package available to the app — it is not bundled with this library'); }
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// The account's real outbox, read from the pod: the head collection names
|
|
72
|
+
// `first`, each page names `orderedItems` and (while there is more) `next`.
|
|
73
|
+
// Walked only far enough to cover `limit`, and run through jsonld.toRDF so
|
|
74
|
+
// the result is a genuine RDF/JS quad array, not this library's own shape.
|
|
75
|
+
const fetchOutboxRdf = async (actorUrl, limit) => {
|
|
76
|
+
if (!actorUrl) return null;
|
|
77
|
+
const jsonld = await loadJsonLd();
|
|
78
|
+
const actorDoc = await json(actorUrl, { headers: { accept: 'application/activity+json' } });
|
|
79
|
+
let pageUrl = actorDoc?.outbox ? (await json(actorDoc.outbox, { headers: { accept: 'application/activity+json' } }))?.first : null;
|
|
80
|
+
const quads = []; const seen = new Set(); let collected = 0;
|
|
81
|
+
while (pageUrl && collected < limit && !seen.has(pageUrl)) {
|
|
82
|
+
seen.add(pageUrl);
|
|
83
|
+
const page = await json(pageUrl, { headers: { accept: 'application/activity+json' } });
|
|
84
|
+
if (!page) break;
|
|
85
|
+
quads.push(...(await jsonld.toRDF(page)));
|
|
86
|
+
collected += (page.orderedItems || []).length;
|
|
87
|
+
pageUrl = page.next || null;
|
|
88
|
+
}
|
|
89
|
+
return quads;
|
|
90
|
+
};
|
|
91
|
+
|
|
61
92
|
// ---- finding out what an address is ----
|
|
62
93
|
|
|
63
94
|
// Does this host speak the Mastodon API? Any server of that family says so
|
|
@@ -255,6 +286,20 @@ export function fediAccount({
|
|
|
255
286
|
const list = await call(`/api/v1/timelines/home?limit=${Math.min(40, limit)}`).then((r) => said(r, a.host));
|
|
256
287
|
return (Array.isArray(list) ? list : []).map(item);
|
|
257
288
|
},
|
|
289
|
+
// This account's own posts, newest first — Mastodon's equivalent of an
|
|
290
|
+
// outbox. A Mastodon server IS an ActivityPub server, so its actor and
|
|
291
|
+
// outbox are real AS2/JSON-LD too, same as a pod's; `rdf: true` reads
|
|
292
|
+
// that, not the REST API above. It comes back empty on an instance that
|
|
293
|
+
// requires a signed request just to read the public actor document
|
|
294
|
+
// (some do — mastodon.social among them); `fetchOutboxRdf`'s plain GET
|
|
295
|
+
// then gets 401 and the loop below finds nothing to walk.
|
|
296
|
+
async outbox({ limit = 20, rdf = false } = {}) {
|
|
297
|
+
const me = await call('/api/v1/accounts/verify_credentials').then((r) => said(r, a.host));
|
|
298
|
+
const list = await call(`/api/v1/accounts/${me.id}/statuses?limit=${Math.min(40, limit)}`).then((r) => said(r, a.host));
|
|
299
|
+
const items = (Array.isArray(list) ? list : []).map(item);
|
|
300
|
+
if (rdf) items.rdf = await fetchOutboxRdf(a.actor, limit);
|
|
301
|
+
return items;
|
|
302
|
+
},
|
|
258
303
|
async follow(handle) {
|
|
259
304
|
const who = parseAddress(handle);
|
|
260
305
|
if (!who?.at) throw new Error('a handle looks like @you@your.server');
|
|
@@ -314,6 +359,23 @@ export function fediAccount({
|
|
|
314
359
|
.slice(0, limit)
|
|
315
360
|
.map((s) => ({ id: s.noteId, url: s.noteId, published: s.published || null, author: who(s.actor), html: s.content || '', inReplyTo: s.inReplyTo || null }));
|
|
316
361
|
},
|
|
362
|
+
// This account's own posts, newest first, read from the same local
|
|
363
|
+
// record `timeline` uses (fast, no network call). With `rdf: true`,
|
|
364
|
+
// also fetches the real outbox from the pod and parses it into RDF/JS
|
|
365
|
+
// quads on the returned array's `.rdf` — a second, live document, not
|
|
366
|
+
// derived from the local record, so it needs `jsonld` (see loadJsonLd).
|
|
367
|
+
async outbox({ limit = 20, rdf = false } = {}) {
|
|
368
|
+
const rows = await state('statuses.json');
|
|
369
|
+
const actors = (await state('actors.json')) || {};
|
|
370
|
+
const who = (id) => { const d = actors[id]?.doc || actors[id] || {}; return { id, handle: d.preferredUsername ? `@${d.preferredUsername}@${new URL(id).host}` : null, name: d.name || null }; };
|
|
371
|
+
const items = (Array.isArray(rows) ? rows : [])
|
|
372
|
+
.filter((s) => s.kind === 'post')
|
|
373
|
+
.sort((x, y) => String(y.published || '').localeCompare(String(x.published || '')))
|
|
374
|
+
.slice(0, limit)
|
|
375
|
+
.map((s) => ({ id: s.noteId, url: s.noteId, published: s.published || null, author: who(s.actor), html: s.content || '', inReplyTo: s.inReplyTo || null }));
|
|
376
|
+
if (rdf) items.rdf = await fetchOutboxRdf(facts.actor, limit);
|
|
377
|
+
return items;
|
|
378
|
+
},
|
|
317
379
|
async follow(handle) {
|
|
318
380
|
const who = parseAddress(handle);
|
|
319
381
|
if (!who?.at) throw new Error('a handle looks like @you@your.server');
|
package/lib/session/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fediverse-account",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Type a Fediverse handle or a WebID and get back an account you can act with: post, read its timeline, follow, reply, favourite, boost. Works out whether the account lives on a Mastodon-family server or on a Solid pod through FediPod, sends the person to sign in there, brings them back to where they were, and speaks to whichever it is.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -37,5 +37,13 @@
|
|
|
37
37
|
"scripts": {
|
|
38
38
|
"prepublishOnly": "node ../../scripts/check-pod-calls.mjs"
|
|
39
39
|
},
|
|
40
|
-
"dependencies": {}
|
|
40
|
+
"dependencies": {},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"jsonld": "^9.0.0"
|
|
43
|
+
},
|
|
44
|
+
"peerDependenciesMeta": {
|
|
45
|
+
"jsonld": {
|
|
46
|
+
"optional": true
|
|
47
|
+
}
|
|
48
|
+
}
|
|
41
49
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fedipod",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.36.6",
|
|
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",
|
package/run-agent.mjs
CHANGED
|
@@ -53,6 +53,7 @@ import { exposureProblem, hostLabel } from './lib/shared/guard.mjs';
|
|
|
53
53
|
import { pendingSteps } from './lib/device/migrate.mjs';
|
|
54
54
|
import { apUrls, assertionKeyId , publicHandle } from './lib/core/wire.mjs';
|
|
55
55
|
import { followActor, unfollowActor, resolveHandle } from './lib/core/social.mjs';
|
|
56
|
+
import * as podInbox from './lib/pod/inbox.mjs';
|
|
56
57
|
|
|
57
58
|
export class Agent {
|
|
58
59
|
constructor({ home, log }) {
|
|
@@ -510,6 +511,19 @@ export class Agent {
|
|
|
510
511
|
return true;
|
|
511
512
|
}
|
|
512
513
|
|
|
514
|
+
// The inbox's door must be open to the world (or to the gateway alone, in
|
|
515
|
+
// locked mode) or nothing anyone sends ever lands. The permission is written
|
|
516
|
+
// at setup, and a container made again later — a root move, a pod rebuilt —
|
|
517
|
+
// came back without it: the group's inbox on fp2 refused every delivery for
|
|
518
|
+
// nine days while its agent ran on, healthy-looking. One write per start,
|
|
519
|
+
// and it is idempotent.
|
|
520
|
+
async ensureInboxOpen() {
|
|
521
|
+
const g = this.store.getConfig()?.gateway;
|
|
522
|
+
const posture = g?.mode === 'locked' && g.webId ? { gatewayWebId: g.webId } : 'open';
|
|
523
|
+
await podInbox.setPosture(this.remote, this.urls, posture);
|
|
524
|
+
return posture;
|
|
525
|
+
}
|
|
526
|
+
|
|
513
527
|
// Read-only mode: refresh the state cache periodically, and take over the
|
|
514
528
|
// moment the active agent's lease frees.
|
|
515
529
|
startViewer() {
|
|
@@ -607,6 +621,8 @@ export class Agent {
|
|
|
607
621
|
.catch(e => this.log(`actor check failed: ${e.message}`));
|
|
608
622
|
this.ensureFeaturedPublished()
|
|
609
623
|
.catch(e => this.log(`featured check failed: ${e.message}`));
|
|
624
|
+
this.ensureInboxOpen()
|
|
625
|
+
.catch(e => this.log(`inbox door check failed: ${e.message}`));
|
|
610
626
|
// The human page, rewritten only when what it shows has changed: one
|
|
611
627
|
// local digest compare, and a write the first time after an upgrade.
|
|
612
628
|
this.publisher.publishProfilePage()
|
package/scripts/stage-site.mjs
CHANGED
|
@@ -86,7 +86,7 @@ const shell = (text, c) => text
|
|
|
86
86
|
.replace('src="../../"', `src="${c.path}"`)
|
|
87
87
|
.replace('<script src="client.js"></script>',
|
|
88
88
|
c.login ? '<script src="/admin/client/client.js"></script>' : '')
|
|
89
|
-
.replace('
|
|
89
|
+
.replace('<span id="account-pick">', `${clientBar()}\n <span id="account-pick">`)
|
|
90
90
|
.replace('</head>', '<script src="/admin/client-pick.js"></script>\n</head>')
|
|
91
91
|
.replace('</body>', `${clientNews()}</body>`);
|
|
92
92
|
|
|
@@ -136,7 +136,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
|
|
|
136
136
|
// A sign-out beside "manage account", on every bar. It clears the FediPod
|
|
137
137
|
// session, the client's stored account, browser-stored connected keys, and
|
|
138
138
|
// the worker — the /?signout page (boot.js) does the teardown.
|
|
139
|
-
.replace(
|
|
139
|
+
.replace(/(<a id="bar-manage"[^>]*>manage<\/a>)/u, '$1\n <a id="bar-signout" href="/?signout" title=" Sign out of FediPod and clear this browser">sign out</a>')
|
|
140
140
|
.replace('</head>', '<style>#actor-pick{display:none!important}</style>\n</head>');
|
|
141
141
|
text = withUpdate(text);
|
|
142
142
|
if (/[/\\]client$/.test(from)) {
|
|
@@ -147,7 +147,7 @@ const copyAdmin = (from, to) => { fs.mkdirSync(to, { recursive: true });
|
|
|
147
147
|
} else if (/[/\\]admin$/.test(from)) {
|
|
148
148
|
// The record page carries the same client control as the shells: it is
|
|
149
149
|
// the page the owner is on when they want to change which client opens.
|
|
150
|
-
text = text.replace('
|
|
150
|
+
text = text.replace('<span id="account-pick">', `${clientBar()}\n <span id="account-pick">`)
|
|
151
151
|
.replace('</head>', '<script src="/admin/client-pick.js"></script>\n</head>')
|
|
152
152
|
.replace('</body>', `${clientNews()}</body>`);
|
|
153
153
|
// Hide controls the browser build does not carry: the manual drain (it
|
|
@@ -197,6 +197,9 @@ fs.writeFileSync(path.join(site, '_redirects'), [
|
|
|
197
197
|
'/api/roster /.netlify/functions/front 200',
|
|
198
198
|
'/api/revoke /.netlify/functions/front 200',
|
|
199
199
|
'/api/agent /.netlify/functions/front 200',
|
|
200
|
+
'/api/notices /.netlify/functions/front 200',
|
|
201
|
+
'/notices /.netlify/functions/front 200',
|
|
202
|
+
'/notices.js /.netlify/functions/front 200',
|
|
200
203
|
'/u/* /.netlify/functions/front 200',
|
|
201
204
|
'/.well-known/* /.netlify/functions/front 200',
|
|
202
205
|
'/@* /.netlify/functions/front 200',
|
package/web/admin/bar.css
CHANGED
|
@@ -62,16 +62,32 @@ body { background: var(--surface); }
|
|
|
62
62
|
quieter than the links; the one in use is marked `aria-current="true"` — the
|
|
63
63
|
current item of a set, which on the record page is not the current PAGE — so
|
|
64
64
|
it needs its own mark rather than borrowing the rule above. */
|
|
65
|
-
#bar #client-pick { display: inline-flex; gap: .6rem; align-items: baseline;
|
|
65
|
+
#bar #client-pick, #bar #account-pick { display: inline-flex; gap: .6rem; align-items: baseline;
|
|
66
66
|
white-space: nowrap; margin-left: 1rem; }
|
|
67
|
-
#bar #client-now { opacity: .75; }
|
|
67
|
+
#bar #client-now, #bar #account-now { opacity: .75; }
|
|
68
|
+
/* The notices bell: the one filled control in the bar, because it is the one
|
|
69
|
+
thing there that is not a place to go but something to be told. The count is
|
|
70
|
+
how many notices this browser has not opened yet; with none it is quiet. */
|
|
71
|
+
#bar #bar-notices { display: inline-flex; align-items: center; gap: .35rem; margin-left: 1rem;
|
|
72
|
+
padding: .3rem .7rem; border-radius: 999px; font-size: 1.05rem; line-height: 1;
|
|
73
|
+
background: var(--btn); color: var(--btn-text); border: 1px solid var(--btn); }
|
|
74
|
+
/* `display` on an id rule outranks the browser's own [hidden] rule, and the
|
|
75
|
+
shell has no !important of its own, so the bell hid on the record page and
|
|
76
|
+
showed on the client with nothing to show. Said here, once, for every page. */
|
|
77
|
+
#bar #bar-notices[hidden] { display: none; }
|
|
78
|
+
#bar #bar-notices:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
|
|
79
|
+
#bar #bar-notices:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
|
|
80
|
+
#bar #bar-notices-count { font-size: .9rem; font-weight: 700; min-width: 1.3em; text-align: center;
|
|
81
|
+
padding: .05rem .4rem; border-radius: 999px; background: var(--btn-text); color: var(--btn); }
|
|
82
|
+
#bar #bar-notices-count:empty { display: none; }
|
|
83
|
+
#bar #bar-notices[data-new="0"] { background: var(--field-bg); color: var(--ink); border-color: var(--field-edge); }
|
|
68
84
|
#bar a[aria-current="true"] {
|
|
69
85
|
font-weight: 700; text-decoration: underline; text-underline-offset: .3rem; }
|
|
70
86
|
|
|
71
87
|
/* The two-clients notice: said once, to somebody who knew this page when it
|
|
72
88
|
had one client. Sized and placed rather than left to the browser's default,
|
|
73
89
|
which stretched it edge to edge and put it over the very links it names. */
|
|
74
|
-
#client-news {
|
|
90
|
+
#client-news, #notices-list, #notice-view {
|
|
75
91
|
/* Low rather than centred: it points at the links in the bar, so the bar has
|
|
76
92
|
to stay in view while it is read. */
|
|
77
93
|
max-width: 34rem; margin: auto auto 2.5rem; padding: 1.5rem 1.7rem;
|
|
@@ -80,13 +96,31 @@ body { background: var(--surface); }
|
|
|
80
96
|
font: 1rem/1.55 var(--font);
|
|
81
97
|
box-shadow: 0 .6rem 2rem rgba(0, 0, 0, .25);
|
|
82
98
|
}
|
|
83
|
-
#client-news::backdrop { background: rgba(0, 0, 0, .45); }
|
|
84
|
-
#client-news h2 { font-size: 1.25rem; margin: 0 0 .9rem; color: var(--heading); }
|
|
85
|
-
#client-news p { margin: 0 0 1.2rem; }
|
|
86
|
-
#client-news p:last-child { margin: 0; }
|
|
87
|
-
#client-news button {
|
|
99
|
+
#client-news::backdrop, #notices-list::backdrop, #notice-view::backdrop { background: rgba(0, 0, 0, .45); }
|
|
100
|
+
#client-news h2, #notices-list h2, #notice-view h2 { font-size: 1.25rem; margin: 0 0 .9rem; color: var(--heading); }
|
|
101
|
+
#client-news p, #notices-list p, #notice-view p { margin: 0 0 1.2rem; }
|
|
102
|
+
#client-news p:last-child, #notices-list p:last-child, #notice-view p:last-child { margin: 0; }
|
|
103
|
+
#client-news button, #notices-list button, #notice-view button {
|
|
88
104
|
font: inherit; padding: .55rem 1.3rem; border-radius: .35rem; cursor: pointer;
|
|
89
105
|
background: var(--btn); color: var(--btn-text); border: 1px solid var(--btn);
|
|
90
106
|
}
|
|
91
|
-
#client-news button:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
|
|
92
|
-
#client-news button:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
|
|
107
|
+
#client-news button:hover, #notices-list button:hover, #notice-view button:hover { background: var(--btn-hover); border-color: var(--btn-hover); }
|
|
108
|
+
#client-news button:focus-visible, #notices-list button:focus-visible, #notice-view button:focus-visible { outline: 3px solid var(--ring); outline-offset: 2px; }
|
|
109
|
+
|
|
110
|
+
/* The notices dialogs are centred and never taller than the window: the
|
|
111
|
+
client-news rule above sits low, and a short window pushed a list of
|
|
112
|
+
notices up past the top edge where nothing could reach it. */
|
|
113
|
+
#notices-list, #notice-view { margin: auto; max-height: calc(100dvh - 3rem); overflow: auto; box-sizing: border-box; }
|
|
114
|
+
/* The list of notices: each title a button that opens that notice, the date
|
|
115
|
+
beside it, the ones not yet opened here in bold. */
|
|
116
|
+
#notices-items { list-style: none; margin: 0 0 1.2rem; padding: 0; }
|
|
117
|
+
#notices-items li { display: flex; gap: .8rem; align-items: baseline; padding: .45rem 0;
|
|
118
|
+
border-bottom: 1px solid var(--line); }
|
|
119
|
+
#notices-items li:last-child { border-bottom: 0; }
|
|
120
|
+
#notices-items button.notice-open { flex: 1 1 auto; text-align: left; padding: .1rem 0;
|
|
121
|
+
background: none; border: 0; color: var(--link); text-decoration: underline; text-underline-offset: .2rem; }
|
|
122
|
+
#notices-items li.new button.notice-open { font-weight: 700; }
|
|
123
|
+
#notices-items .when, #notice-view .when { color: var(--sub); font-size: .9rem; white-space: nowrap; }
|
|
124
|
+
#notices-items p.none { color: var(--sub); }
|
|
125
|
+
#notice-view-body p { margin: 0 0 .9rem; }
|
|
126
|
+
#notice-view-body a { color: var(--link); word-break: break-all; }
|