omp-onlyne 0.1.0 → 0.3.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.
- package/README.md +16 -8
- package/package.json +3 -3
- package/src/bridge-text.ts +40 -8
- package/src/config.ts +39 -5
- package/src/decider-text.ts +36 -21
- package/src/decider.ts +253 -548
- package/src/drafts.ts +68 -19
- package/src/guard.ts +107 -0
- package/src/handshake.ts +291 -0
- package/src/messages.ts +134 -16
- package/src/plan.ts +133 -0
- package/src/questions.ts +250 -0
- package/src/resolve.ts +387 -0
- package/src/resources.ts +1 -1
- package/src/runtime.ts +454 -18
- package/src/tool-text.ts +64 -30
- package/src/tool.ts +136 -21
- package/src/types.ts +65 -57
- package/src/ui/bootstrap-text.ts +24 -0
- package/src/ui/bootstrap.ts +82 -3
- package/src/ui/index.ts +6 -2
- package/src/ui/mail-message.ts +5 -1
- package/src/ui/viewer-text.ts +116 -4
- package/src/ui/viewer.ts +650 -61
- package/src/urls-text.ts +24 -0
- package/src/urls.ts +206 -33
- package/src/work.ts +10 -0
- package/src/host-text.ts +0 -86
- package/src/node.ts +0 -127
- package/src/store.ts +0 -189
package/src/ui/viewer.ts
CHANGED
|
@@ -16,8 +16,19 @@ import {
|
|
|
16
16
|
text as textNode,
|
|
17
17
|
} from "@oh-my-pi/pi-tui";
|
|
18
18
|
import { normalizeServiceUrl, shortNodeId } from "../config.ts";
|
|
19
|
-
import {
|
|
20
|
-
import
|
|
19
|
+
import { DEFAULT_ACQUISITION, parseAllowBlock, type AcquirePolicy } from "@onlyne/node-host/acquire";
|
|
20
|
+
import { getRuntime, type PluginRuntime } from "../runtime.ts";
|
|
21
|
+
import { runDecision } from "../decider.ts";
|
|
22
|
+
import { workValue } from "../work.ts";
|
|
23
|
+
import type {
|
|
24
|
+
AcquisitionAuditEntry,
|
|
25
|
+
AcquisitionSnapshot,
|
|
26
|
+
OnlyneStatus,
|
|
27
|
+
ViewerDiscovery,
|
|
28
|
+
ViewerMessage,
|
|
29
|
+
ViewerSnapshot,
|
|
30
|
+
ViewerThread,
|
|
31
|
+
} from "../types.ts";
|
|
21
32
|
import { ageText, clockText, fit, oneLine, shortAlias, sinceText } from "./format.ts";
|
|
22
33
|
import { type ViewerTextKey, t } from "./viewer-text.ts";
|
|
23
34
|
|
|
@@ -93,7 +104,7 @@ const PHASE_SHORT: Record<TrustPhase, ViewerTextKey> = {
|
|
|
93
104
|
|
|
94
105
|
interface Peer {
|
|
95
106
|
record: ViewerDiscovery;
|
|
96
|
-
trust?: { phase: TrustPhase; fingerprint: string };
|
|
107
|
+
trust?: { phase: TrustPhase; fingerprint: string; observed?: string };
|
|
97
108
|
}
|
|
98
109
|
|
|
99
110
|
/**
|
|
@@ -111,10 +122,47 @@ interface Invite {
|
|
|
111
122
|
node_id: string;
|
|
112
123
|
fingerprint: string;
|
|
113
124
|
base_url: string;
|
|
125
|
+
observed?: string;
|
|
114
126
|
}
|
|
115
127
|
|
|
116
|
-
/** What the main column shows: one thread,
|
|
117
|
-
type View = { kind: "thread"; alias: string } | { kind: "inbox" };
|
|
128
|
+
/** What the main column shows: one thread, the queue waiting on this node, or the trust rules. */
|
|
129
|
+
type View = { kind: "thread"; alias: string } | { kind: "inbox" } | { kind: "trust" };
|
|
130
|
+
|
|
131
|
+
/** The chrome tones this surface paints; every one is a prop value `node()` accepts. */
|
|
132
|
+
type SurfaceTone = "accent" | "info" | "success" | "warning" | "error" | "muted";
|
|
133
|
+
|
|
134
|
+
/** RFC 0018 §5: the ring says where the exchange stands; selection is a separate background. */
|
|
135
|
+
const CANDIDATE_TONE: Record<TrustPhase, SurfaceTone> = {
|
|
136
|
+
exchanged: "success",
|
|
137
|
+
requested: "info",
|
|
138
|
+
invited: "info",
|
|
139
|
+
revoked: "error",
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** The newest decisions the panel lists; the logger keeps the durable record. */
|
|
143
|
+
const AUDIT_LIMIT = 20;
|
|
144
|
+
|
|
145
|
+
const POLICY_TEXT: Record<AcquisitionSnapshot["policy"], ViewerTextKey> = {
|
|
146
|
+
all: "trustpage.policy.all",
|
|
147
|
+
segment: "trustpage.policy.segment",
|
|
148
|
+
none: "trustpage.policy.none",
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const POLICY_VALUES: readonly AcquirePolicy[] = ["all", "segment", "none"];
|
|
152
|
+
|
|
153
|
+
const AUDIT_ACTOR: Record<AcquisitionAuditEntry["actor"], ViewerTextKey> = {
|
|
154
|
+
human: "trustpage.actor.human",
|
|
155
|
+
agent: "trustpage.actor.agent",
|
|
156
|
+
seed: "trustpage.actor.seed",
|
|
157
|
+
policy: "trustpage.actor.policy",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const AUDIT_TONE: Record<AcquisitionAuditEntry["actor"], SurfaceTone> = {
|
|
161
|
+
human: "accent",
|
|
162
|
+
agent: "info",
|
|
163
|
+
seed: "warning",
|
|
164
|
+
policy: "muted",
|
|
165
|
+
};
|
|
118
166
|
|
|
119
167
|
/** A described node with a scroll request attached; `node()` only builds the wire shape. */
|
|
120
168
|
function scrolling(described: NativeNode, scroll: NativeScroll): NativeNode {
|
|
@@ -164,9 +212,19 @@ export class OnlyneViewer implements Component {
|
|
|
164
212
|
/** Bumped to ask the terminal to scroll the message list to its newest row. */
|
|
165
213
|
private messageScroll = 0;
|
|
166
214
|
private messageScrollBy: NativeScroll["by"] = "end";
|
|
215
|
+
/** The message opened for full reading; target selection is its own action. */
|
|
216
|
+
private opened: ViewerMessage | undefined;
|
|
217
|
+
/** A policy change restarts the node, so one runs at a time. */
|
|
218
|
+
private policyBusy = false;
|
|
219
|
+
/** The allow set being edited in the trust page. */
|
|
220
|
+
private allowEditing = false;
|
|
221
|
+
private allowEditor: Editor | undefined;
|
|
222
|
+
private allowDraft = "";
|
|
167
223
|
private notice: { text: string; error: boolean } | undefined;
|
|
168
224
|
private noticeTimer: Timer | undefined;
|
|
169
225
|
private unsubscribe: (() => void) | undefined;
|
|
226
|
+
/** The runtime instance the change subscription belongs to; a restart replaces it. */
|
|
227
|
+
private watched: PluginRuntime | undefined;
|
|
170
228
|
private described: NativeNode | undefined;
|
|
171
229
|
private describedKey: string | undefined;
|
|
172
230
|
private rendered: { key: string; lines: readonly string[] } | undefined;
|
|
@@ -189,8 +247,17 @@ export class OnlyneViewer implements Component {
|
|
|
189
247
|
private readonly signal: AbortSignal,
|
|
190
248
|
private readonly rows: () => number = () => 40,
|
|
191
249
|
) {
|
|
250
|
+
this.bindRuntime();
|
|
192
251
|
void this.reload();
|
|
193
|
-
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** A config save restarts the node: the old runtime stops notifying, so bind the new one. */
|
|
255
|
+
private bindRuntime(): void {
|
|
256
|
+
const runtime = getRuntime();
|
|
257
|
+
if (runtime === this.watched) return;
|
|
258
|
+
this.unsubscribe?.();
|
|
259
|
+
this.watched = runtime;
|
|
260
|
+
this.unsubscribe = runtime?.onChange(() => {
|
|
194
261
|
if (this.disposed || this.signal.aborted) return;
|
|
195
262
|
void this.reload();
|
|
196
263
|
});
|
|
@@ -204,8 +271,54 @@ export class OnlyneViewer implements Component {
|
|
|
204
271
|
this.requestRender();
|
|
205
272
|
}
|
|
206
273
|
|
|
274
|
+
/**
|
|
275
|
+
* The rows the described sheet's body may draw: the fullscreen frame spends one row on
|
|
276
|
+
* the title and one on the inset around the columns.
|
|
277
|
+
*/
|
|
278
|
+
private bodyRows(): number {
|
|
279
|
+
return Math.max(8, this.rows() - 2);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* A list inside a column needs its own height cap: `grow` alone expands to the content,
|
|
284
|
+
* so a long thread pushes the composer past the pane and no scroller exists to page.
|
|
285
|
+
* The caps below subtract every fixed block that shares the column.
|
|
286
|
+
*/
|
|
287
|
+
private messageListRows(): number {
|
|
288
|
+
// Thread head 2, composer 5, the shared cards, one card per approval, and gaps.
|
|
289
|
+
const chrome = 2 + 5 + this.flowExtrasRows() + this.invites().length * 7;
|
|
290
|
+
return Math.max(3, this.bodyRows() - chrome - 3);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** The opened message takes the message list's place, so it gets that whole budget. */
|
|
294
|
+
private detailListRows(): number {
|
|
295
|
+
return Math.max(3, this.bodyRows() - 2 - 5 - this.flowExtrasRows() - 3);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private inboxListRows(): number {
|
|
299
|
+
return Math.max(3, this.bodyRows() - 2 - this.flowExtrasRows() - 3);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private sidebarListRows(): number {
|
|
303
|
+
// Three identity lines and the column's one small gap.
|
|
304
|
+
return Math.max(3, this.bodyRows() - 5);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private memberListRows(): number {
|
|
308
|
+
// Head row, optional address card, the selected candidate's detail, and gaps.
|
|
309
|
+
const detail = this.memberKey === undefined ? 0 : 11;
|
|
310
|
+
const address = this.addressOpen ? 9 : 0;
|
|
311
|
+
return Math.max(3, this.bodyRows() - 4 - detail - address);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Every card above the list in the flow column: errors, notices and an open exchange. */
|
|
315
|
+
private flowExtrasRows(): number {
|
|
316
|
+
return (this.error === undefined ? 0 : 2) + (this.notice === undefined ? 0 : 1) + (this.exchange === undefined ? 0 : 8);
|
|
317
|
+
}
|
|
318
|
+
|
|
207
319
|
async reload(): Promise<void> {
|
|
208
320
|
if (this.disposed || this.signal.aborted) return;
|
|
321
|
+
this.bindRuntime();
|
|
209
322
|
const generation = ++this.generation;
|
|
210
323
|
const runtime = getRuntime();
|
|
211
324
|
if (runtime === undefined) {
|
|
@@ -277,12 +390,12 @@ export class OnlyneViewer implements Component {
|
|
|
277
390
|
private invites(): Invite[] {
|
|
278
391
|
return (this.snapshot?.trust ?? [])
|
|
279
392
|
.filter(entry => entry.phase === "invited")
|
|
280
|
-
.map(entry => ({
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
393
|
+
.map(entry => ({
|
|
394
|
+
node_id: entry.node_id,
|
|
395
|
+
fingerprint: entry.fingerprint,
|
|
396
|
+
base_url: entry.base_url,
|
|
397
|
+
...(entry.observed === undefined ? {} : { observed: entry.observed }),
|
|
398
|
+
}));
|
|
286
399
|
}
|
|
287
400
|
|
|
288
401
|
/**
|
|
@@ -307,7 +420,13 @@ export class OnlyneViewer implements Component {
|
|
|
307
420
|
record,
|
|
308
421
|
...(trust === undefined
|
|
309
422
|
? {}
|
|
310
|
-
: {
|
|
423
|
+
: {
|
|
424
|
+
trust: {
|
|
425
|
+
phase: trust.phase,
|
|
426
|
+
fingerprint: trust.fingerprint,
|
|
427
|
+
...(trust.observed === undefined ? {} : { observed: trust.observed }),
|
|
428
|
+
},
|
|
429
|
+
}),
|
|
311
430
|
});
|
|
312
431
|
}
|
|
313
432
|
const own = this.snapshot?.region;
|
|
@@ -337,12 +456,23 @@ export class OnlyneViewer implements Component {
|
|
|
337
456
|
private sendBlocked(): string | undefined {
|
|
338
457
|
if (this.error !== undefined) return this.error;
|
|
339
458
|
if (this.draft.trim() === "") return t("send.blocked.empty");
|
|
340
|
-
|
|
459
|
+
const thread = this.selectedThread();
|
|
460
|
+
// A joined thread is another node's: the viewer shows it and offers no write it cannot
|
|
461
|
+
// perform. A manual recipient starts a new message and is unaffected.
|
|
462
|
+
if (this.target === undefined && thread?.read_only === true) return t("send.blocked.readOnly");
|
|
463
|
+
if (this.target === undefined && this.replyTo === undefined && thread === undefined) {
|
|
341
464
|
return t("send.blocked.noTarget");
|
|
342
465
|
}
|
|
343
466
|
return undefined;
|
|
344
467
|
}
|
|
345
468
|
|
|
469
|
+
/** The opened message as the current thread holds it: a reload replaces the row object. */
|
|
470
|
+
private openedIn(thread: ViewerThread | undefined): ViewerMessage | undefined {
|
|
471
|
+
const opened = this.opened;
|
|
472
|
+
if (thread === undefined || opened === undefined) return undefined;
|
|
473
|
+
return thread.messages.find(message => message.ref.sender === opened.ref.sender && message.ref.id === opened.ref.id);
|
|
474
|
+
}
|
|
475
|
+
|
|
346
476
|
// ── recurring pieces ────────────────────────────────────────────────────
|
|
347
477
|
|
|
348
478
|
private button(label: string, act: string, options: { tone?: "accent" | "error"; title?: string } = {}): NativeNode {
|
|
@@ -431,6 +561,7 @@ export class OnlyneViewer implements Component {
|
|
|
431
561
|
role: "onlyne.rail.list",
|
|
432
562
|
grow: 1,
|
|
433
563
|
basis: 0,
|
|
564
|
+
max: { lines: Math.max(3, this.bodyRows() - 1) },
|
|
434
565
|
selected: current === undefined ? null : `domain:${current.key}`,
|
|
435
566
|
empty: [span(t("rail.empty"), "muted")],
|
|
436
567
|
},
|
|
@@ -479,7 +610,11 @@ export class OnlyneViewer implements Component {
|
|
|
479
610
|
node(
|
|
480
611
|
"item",
|
|
481
612
|
{
|
|
482
|
-
label: [
|
|
613
|
+
label: [
|
|
614
|
+
span(`# ${thread.title}`, open ? "strong" : undefined),
|
|
615
|
+
...(thread.state === "closed" ? [span(` ${t("thread.closed")}`, "dim")] : []),
|
|
616
|
+
...(thread.read_only === true ? [span(` ${t("thread.readOnly")}`, "dim")] : []),
|
|
617
|
+
],
|
|
483
618
|
// One number per row, always the same number: the unread count. A row that
|
|
484
619
|
// showed the message total once everything was read made two rows of equal
|
|
485
620
|
// width mean different things.
|
|
@@ -512,6 +647,18 @@ export class OnlyneViewer implements Component {
|
|
|
512
647
|
"inbox",
|
|
513
648
|
),
|
|
514
649
|
);
|
|
650
|
+
rows.push(
|
|
651
|
+
node(
|
|
652
|
+
"item",
|
|
653
|
+
{
|
|
654
|
+
label: [span(t("trustpage.open"), "muted")],
|
|
655
|
+
value: [span(String(invites.length), invites.length === 0 ? "dim" : "warning")],
|
|
656
|
+
title: t("trustpage.open.title"),
|
|
657
|
+
},
|
|
658
|
+
undefined,
|
|
659
|
+
"trust",
|
|
660
|
+
),
|
|
661
|
+
);
|
|
515
662
|
|
|
516
663
|
return node(
|
|
517
664
|
"col",
|
|
@@ -533,7 +680,14 @@ export class OnlyneViewer implements Component {
|
|
|
533
680
|
role: "onlyne.threads.list",
|
|
534
681
|
grow: 1,
|
|
535
682
|
basis: 0,
|
|
536
|
-
|
|
683
|
+
max: { lines: this.sidebarListRows() },
|
|
684
|
+
selected: this.view?.kind === "trust"
|
|
685
|
+
? "trust"
|
|
686
|
+
: this.view?.kind === "inbox"
|
|
687
|
+
? "inbox"
|
|
688
|
+
: selected === undefined
|
|
689
|
+
? null
|
|
690
|
+
: `thread:${selected.alias}`,
|
|
537
691
|
empty: [span(t("sidebar.empty"), "muted")],
|
|
538
692
|
},
|
|
539
693
|
rows,
|
|
@@ -561,7 +715,8 @@ export class OnlyneViewer implements Component {
|
|
|
561
715
|
this.facts([
|
|
562
716
|
{ k: t("field.peerFingerprint"), v: invite.fingerprint },
|
|
563
717
|
{ k: t("field.ownFingerprint"), v: mine },
|
|
564
|
-
{ k: t("field.
|
|
718
|
+
{ k: t("field.declared"), v: invite.base_url },
|
|
719
|
+
{ k: t("field.observed"), v: invite.observed ?? t("member.observed.none") },
|
|
565
720
|
]),
|
|
566
721
|
node(
|
|
567
722
|
"row",
|
|
@@ -597,7 +752,7 @@ export class OnlyneViewer implements Component {
|
|
|
597
752
|
span(` ${clockText(message.at) || "—"}`, "dim"),
|
|
598
753
|
...(message.relation === undefined ? [] : [span(` ${message.relation}`, "dim")]),
|
|
599
754
|
],
|
|
600
|
-
title: t("message.
|
|
755
|
+
title: t("message.open.title", { seq: message.seq }),
|
|
601
756
|
actions: { click: `onlyne.message:${messageKey}` },
|
|
602
757
|
},
|
|
603
758
|
[textNode(message.text, { wrap: "word" })],
|
|
@@ -605,6 +760,48 @@ export class OnlyneViewer implements Component {
|
|
|
605
760
|
);
|
|
606
761
|
}
|
|
607
762
|
|
|
763
|
+
/** The full body in the message list's place; reply and recipient are two explicit actions. */
|
|
764
|
+
private messageDetailCard(message: ViewerMessage): NativeNode {
|
|
765
|
+
const own = message.author === this.status?.node_id;
|
|
766
|
+
const identity = `${encodeURIComponent(message.ref.sender)}/${encodeURIComponent(message.ref.id)}`;
|
|
767
|
+
return node(
|
|
768
|
+
"card",
|
|
769
|
+
{
|
|
770
|
+
role: "onlyne.message-detail",
|
|
771
|
+
inset: true,
|
|
772
|
+
head: [span(t("messageDetail.head", {
|
|
773
|
+
seq: message.seq,
|
|
774
|
+
author: plainAuthor(message.author),
|
|
775
|
+
time: clockText(message.at) || t("time.unknown"),
|
|
776
|
+
}), "strong")],
|
|
777
|
+
},
|
|
778
|
+
[
|
|
779
|
+
textNode(message.text, { wrap: "word" }),
|
|
780
|
+
...(message.work === undefined
|
|
781
|
+
? []
|
|
782
|
+
: [textNode([span(t("messageDetail.work", { value: workValue(message.work) }), "dim")], { wrap: "word" })]),
|
|
783
|
+
node(
|
|
784
|
+
"row",
|
|
785
|
+
{ gap: "sm", align: "center", wrap: true },
|
|
786
|
+
[
|
|
787
|
+
...(own ? [] : [
|
|
788
|
+
this.button(t("messageDetail.reply"), `onlyne.message.reply:${identity}`, {
|
|
789
|
+
tone: "accent",
|
|
790
|
+
title: t("messageDetail.reply.title"),
|
|
791
|
+
}),
|
|
792
|
+
this.button(t("messageDetail.target"), `onlyne.message.target:${identity}`, {
|
|
793
|
+
title: t("messageDetail.target.title"),
|
|
794
|
+
}),
|
|
795
|
+
]),
|
|
796
|
+
this.button(t("messageDetail.close"), "onlyne.message.close", { title: t("messageDetail.close.title") }),
|
|
797
|
+
],
|
|
798
|
+
"acts",
|
|
799
|
+
),
|
|
800
|
+
],
|
|
801
|
+
identity,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
804
|
+
|
|
608
805
|
private composerNode(): NativeNode {
|
|
609
806
|
const thread = this.selectedThread();
|
|
610
807
|
const blocked = this.sendBlocked();
|
|
@@ -616,7 +813,7 @@ export class OnlyneViewer implements Component {
|
|
|
616
813
|
this.composer = editor;
|
|
617
814
|
}
|
|
618
815
|
// One caret at a time: the address card owns it while it is open.
|
|
619
|
-
this.composer.focused = this.focused && !this.addressOpen;
|
|
816
|
+
this.composer.focused = this.focused && !this.addressOpen && !this.allowEditing;
|
|
620
817
|
const where = this.replyTo !== undefined
|
|
621
818
|
? t("composer.reply", { node: shortNodeId(this.replyTo.sender), id: this.replyTo.id })
|
|
622
819
|
: this.target !== undefined
|
|
@@ -692,6 +889,7 @@ export class OnlyneViewer implements Component {
|
|
|
692
889
|
role: "onlyne.inbox.list",
|
|
693
890
|
grow: 1,
|
|
694
891
|
basis: 0,
|
|
892
|
+
max: { lines: this.inboxListRows() },
|
|
695
893
|
empty: [span(t("inbox.empty"), "muted")],
|
|
696
894
|
},
|
|
697
895
|
rows,
|
|
@@ -699,6 +897,158 @@ export class OnlyneViewer implements Component {
|
|
|
699
897
|
);
|
|
700
898
|
}
|
|
701
899
|
|
|
900
|
+
/** RFC 0018 §5: one page carries the policy, the waiting peers and the decision record. */
|
|
901
|
+
private trustPage(): NativeNode {
|
|
902
|
+
const acquisition = this.snapshot?.acquisition;
|
|
903
|
+
const current = getRuntime()?.config.acquisition ?? acquisition ?? DEFAULT_ACQUISITION;
|
|
904
|
+
const effective = acquisition ?? current;
|
|
905
|
+
const allowLoopback = "allow_loopback" in effective ? effective.allow_loopback : false;
|
|
906
|
+
const invited = this.invites();
|
|
907
|
+
const audit = (this.snapshot?.audit ?? []).slice(0, AUDIT_LIMIT);
|
|
908
|
+
const children: NativeChild[] = [
|
|
909
|
+
node(
|
|
910
|
+
"card",
|
|
911
|
+
{ role: "onlyne.trust-policy", head: [span(t("trustpage.title"), "strong")] },
|
|
912
|
+
[
|
|
913
|
+
this.facts([
|
|
914
|
+
{ k: t("trustpage.policy"), v: t(POLICY_TEXT[effective.policy]) },
|
|
915
|
+
{
|
|
916
|
+
k: t("trustpage.allow"),
|
|
917
|
+
v: effective.allow.length === 0
|
|
918
|
+
? t("trustpage.allow.none")
|
|
919
|
+
: effective.allow.join(", "),
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
k: t("trustpage.allowLoopback"),
|
|
923
|
+
v: allowLoopback
|
|
924
|
+
? t("trustpage.allowLoopback.on")
|
|
925
|
+
: t("trustpage.allowLoopback.off"),
|
|
926
|
+
},
|
|
927
|
+
]),
|
|
928
|
+
node(
|
|
929
|
+
"row",
|
|
930
|
+
{ role: "onlyne.policy", gap: "sm", align: "center", wrap: true },
|
|
931
|
+
POLICY_VALUES.map(value =>
|
|
932
|
+
node(
|
|
933
|
+
"row",
|
|
934
|
+
{
|
|
935
|
+
role: "omp.btn",
|
|
936
|
+
actions: { click: `onlyne.policy:${value}` },
|
|
937
|
+
tone: current.policy === value ? "accent" : undefined,
|
|
938
|
+
title: t("trustpage.policy.set", { value }),
|
|
939
|
+
},
|
|
940
|
+
[textNode([span(value, current.policy === value ? "accent strong" : "muted")])],
|
|
941
|
+
`policy-${value}`,
|
|
942
|
+
),
|
|
943
|
+
),
|
|
944
|
+
"policy-switch",
|
|
945
|
+
),
|
|
946
|
+
...(this.allowEditing
|
|
947
|
+
? [this.allowEditorNode()]
|
|
948
|
+
: [
|
|
949
|
+
node("row", { gap: "sm", align: "center" }, [
|
|
950
|
+
this.button(t("trustpage.allow.edit"), "onlyne.allow.open", { title: t("trustpage.allow.edit.title") }),
|
|
951
|
+
], "allow-edit"),
|
|
952
|
+
]),
|
|
953
|
+
textNode([span(t("trustpage.edit"), "dim")], { wrap: "word" }),
|
|
954
|
+
textNode([span(t("trustpage.warn.lan"), "warning")], { wrap: "word" }),
|
|
955
|
+
textNode([span(t("trustpage.warn.axes"), "dim")], { wrap: "word" }),
|
|
956
|
+
textNode([span(t("trustpage.warn.limits"), "dim")], { wrap: "word" }),
|
|
957
|
+
],
|
|
958
|
+
"trust-policy",
|
|
959
|
+
),
|
|
960
|
+
this.heading(t("trustpage.invited.head"), invited.length),
|
|
961
|
+
];
|
|
962
|
+
if (invited.length === 0) children.push(textNode([span(t("trustpage.invited.empty"), "muted")], { wrap: "word" }));
|
|
963
|
+
else children.push(...this.approvalsCard());
|
|
964
|
+
children.push(this.heading(t("trustpage.audit.head"), audit.length));
|
|
965
|
+
if (audit.length === 0) children.push(textNode([span(t("trustpage.audit.empty"), "muted")], { wrap: "word" }));
|
|
966
|
+
else children.push(...audit.map((entry, index) => this.auditCard(entry, index)));
|
|
967
|
+
return node(
|
|
968
|
+
"list",
|
|
969
|
+
{
|
|
970
|
+
role: "onlyne.trust.list",
|
|
971
|
+
grow: 1,
|
|
972
|
+
basis: 0,
|
|
973
|
+
max: { lines: Math.max(3, this.bodyRows() - 3) },
|
|
974
|
+
},
|
|
975
|
+
children,
|
|
976
|
+
"trust",
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* One recorded decision. The actor is the whole point: a human's approval that bypassed
|
|
982
|
+
* the policy reads differently from a policy auto-approval, an agent's call, or a seed.
|
|
983
|
+
*/
|
|
984
|
+
private auditCard(entry: AcquisitionAuditEntry, index: number): NativeNode {
|
|
985
|
+
const tone = AUDIT_TONE[entry.actor];
|
|
986
|
+
const head: TspSpan[] = [
|
|
987
|
+
span(t(AUDIT_ACTOR[entry.actor]), `${tone} strong`),
|
|
988
|
+
span(` · ${entry.action}`, "strong"),
|
|
989
|
+
];
|
|
990
|
+
if (entry.override === true) head.push(span(` · ${t("trustpage.override")}`, "warning"));
|
|
991
|
+
if (entry.actor === "seed") head.push(span(` · ${t("trustpage.seed")}`, "warning"));
|
|
992
|
+
const when = clockText(entry.at);
|
|
993
|
+
if (when !== "") head.push(span(` ${when}`, "dim"));
|
|
994
|
+
const peer = entry.node_id === undefined
|
|
995
|
+
? ""
|
|
996
|
+
: `${shortNodeId(entry.node_id)}${entry.fingerprint === undefined ? "" : ` ${entry.fingerprint.slice(0, 8)}`}`;
|
|
997
|
+
const verdict = entry.allowed === undefined
|
|
998
|
+
? ""
|
|
999
|
+
: ` · ${t(entry.allowed ? "trustpage.audit.allowed" : "trustpage.audit.denied")}`;
|
|
1000
|
+
const judgement = [
|
|
1001
|
+
entry.policy === undefined ? "" : `${t("trustpage.policy")}: ${entry.policy}`,
|
|
1002
|
+
entry.reason === undefined ? "" : entry.reason,
|
|
1003
|
+
].filter(part => part !== "").join(" · ");
|
|
1004
|
+
const addresses = [
|
|
1005
|
+
entry.observed === undefined ? "" : `${t("field.observed")}: ${entry.observed}`,
|
|
1006
|
+
entry.declared === undefined ? "" : `${t("field.declared")}: ${entry.declared}`,
|
|
1007
|
+
].filter(part => part !== "").join(" · ");
|
|
1008
|
+
return node(
|
|
1009
|
+
"section",
|
|
1010
|
+
{ role: "onlyne.audit", tone },
|
|
1011
|
+
[
|
|
1012
|
+
textNode(head, { wrap: "word" }),
|
|
1013
|
+
...(peer === "" ? [] : [textNode([span(peer, "muted")], { wrap: "word" })]),
|
|
1014
|
+
...(judgement === "" && verdict === ""
|
|
1015
|
+
? []
|
|
1016
|
+
: [textNode([span(`${judgement}${verdict}`, "dim")], { wrap: "word" })]),
|
|
1017
|
+
...(addresses === "" ? [] : [textNode([span(addresses, "dim")], { wrap: "word" })]),
|
|
1018
|
+
],
|
|
1019
|
+
`audit-${index}`,
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** The allow set as one editable line; entries are CIDR blocks or host names. */
|
|
1024
|
+
private allowEditorNode(): NativeNode {
|
|
1025
|
+
if (this.allowEditor === undefined) {
|
|
1026
|
+
const editor = new Editor(getEditorTheme());
|
|
1027
|
+
editor.disableSubmit = true;
|
|
1028
|
+
editor.setText(this.allowDraft);
|
|
1029
|
+
editor.setMaxHeight(2);
|
|
1030
|
+
editor.onChange = () => {
|
|
1031
|
+
this.allowDraft = editor.getExpandedText();
|
|
1032
|
+
this.described = undefined;
|
|
1033
|
+
this.requestRender();
|
|
1034
|
+
};
|
|
1035
|
+
this.allowEditor = editor;
|
|
1036
|
+
}
|
|
1037
|
+
this.allowEditor.focused = this.focused;
|
|
1038
|
+
return node(
|
|
1039
|
+
"col",
|
|
1040
|
+
{ role: "onlyne.allow-editor", gap: "xs" },
|
|
1041
|
+
[
|
|
1042
|
+
this.allowEditor,
|
|
1043
|
+
node("row", { gap: "sm", align: "center" }, [
|
|
1044
|
+
this.button(t("trustpage.allow.save"), "onlyne.allow.save", { tone: "accent" }),
|
|
1045
|
+
this.button(t("trustpage.allow.cancel"), "onlyne.allow.cancel"),
|
|
1046
|
+
], "acts"),
|
|
1047
|
+
],
|
|
1048
|
+
"allow-editor",
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
702
1052
|
/** The main column: the open thread with its composer, or the queue. */
|
|
703
1053
|
private flowNode(): NativeNode {
|
|
704
1054
|
const children: NativeChild[] = [];
|
|
@@ -730,7 +1080,20 @@ export class OnlyneViewer implements Component {
|
|
|
730
1080
|
);
|
|
731
1081
|
}
|
|
732
1082
|
|
|
733
|
-
if (this.view?.kind === "
|
|
1083
|
+
if (this.view?.kind === "trust") {
|
|
1084
|
+
children.push(
|
|
1085
|
+
node("row", { role: "onlyne.thread-head", gap: "md", align: "center", justify: "between" }, [
|
|
1086
|
+
node("col", { gap: "none", grow: 1 }, [
|
|
1087
|
+
textNode([span(t("trustpage.title"), "strong")], { truncate: "end" }),
|
|
1088
|
+
textNode([span(t("trustpage.meta", {
|
|
1089
|
+
pending: this.invites().length,
|
|
1090
|
+
audit: Math.min(this.snapshot?.audit?.length ?? 0, AUDIT_LIMIT),
|
|
1091
|
+
}), "dim")], { truncate: "end" }),
|
|
1092
|
+
]),
|
|
1093
|
+
], "thread-head"),
|
|
1094
|
+
this.trustPage(),
|
|
1095
|
+
);
|
|
1096
|
+
} else if (this.view?.kind === "inbox") {
|
|
734
1097
|
children.push(
|
|
735
1098
|
node("row", { role: "onlyne.thread-head", gap: "md", align: "center", justify: "between" }, [
|
|
736
1099
|
node("col", { gap: "none", grow: 1 }, [
|
|
@@ -746,6 +1109,7 @@ export class OnlyneViewer implements Component {
|
|
|
746
1109
|
children.push(textNode([span(t("flow.empty"), "muted")], { wrap: "word", lines: 3 }));
|
|
747
1110
|
} else {
|
|
748
1111
|
const messages = thread.messages;
|
|
1112
|
+
const opened = this.openedIn(thread);
|
|
749
1113
|
const last = messages.at(-1);
|
|
750
1114
|
const lastLine = last === undefined
|
|
751
1115
|
? ""
|
|
@@ -753,7 +1117,11 @@ export class OnlyneViewer implements Component {
|
|
|
753
1117
|
children.push(
|
|
754
1118
|
node("row", { role: "onlyne.thread-head", gap: "md", align: "center", justify: "between" }, [
|
|
755
1119
|
node("col", { gap: "none", grow: 1 }, [
|
|
756
|
-
textNode([
|
|
1120
|
+
textNode([
|
|
1121
|
+
span(`# ${thread.title}`, "strong"),
|
|
1122
|
+
...(thread.state === "closed" ? [span(` · ${t("thread.closed")}`, "dim")] : []),
|
|
1123
|
+
...(thread.read_only === true ? [span(` · ${t("thread.readOnly")}`, "dim")] : []),
|
|
1124
|
+
], { truncate: "end" }),
|
|
757
1125
|
textNode([span(t("thread.meta", {
|
|
758
1126
|
messages: messages.length,
|
|
759
1127
|
speakers: new Set(messages.map(message => message.author)).size,
|
|
@@ -763,20 +1131,36 @@ export class OnlyneViewer implements Component {
|
|
|
763
1131
|
]),
|
|
764
1132
|
], "thread-head"),
|
|
765
1133
|
...this.approvalsCard(),
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1134
|
+
opened === undefined
|
|
1135
|
+
? scrolling(
|
|
1136
|
+
node(
|
|
1137
|
+
"list",
|
|
1138
|
+
{
|
|
1139
|
+
role: "onlyne.messages",
|
|
1140
|
+
grow: 1,
|
|
1141
|
+
basis: 0,
|
|
1142
|
+
max: { lines: this.messageListRows() },
|
|
1143
|
+
empty: [span(t("thread.empty"), "muted")],
|
|
1144
|
+
},
|
|
1145
|
+
messages.map(message => this.messageCard(message)),
|
|
1146
|
+
"messages",
|
|
1147
|
+
),
|
|
1148
|
+
{ by: this.messageScrollBy, n: this.messageScroll },
|
|
1149
|
+
)
|
|
1150
|
+
: scrolling(
|
|
1151
|
+
node(
|
|
1152
|
+
"list",
|
|
1153
|
+
{
|
|
1154
|
+
role: "onlyne.message-detail",
|
|
1155
|
+
grow: 1,
|
|
1156
|
+
basis: 0,
|
|
1157
|
+
max: { lines: this.detailListRows() },
|
|
1158
|
+
},
|
|
1159
|
+
[this.messageDetailCard(opened)],
|
|
1160
|
+
"message-detail",
|
|
1161
|
+
),
|
|
1162
|
+
{ by: this.messageScrollBy, n: this.messageScroll },
|
|
777
1163
|
),
|
|
778
|
-
{ by: this.messageScrollBy, n: this.messageScroll },
|
|
779
|
-
),
|
|
780
1164
|
this.composerNode(),
|
|
781
1165
|
);
|
|
782
1166
|
}
|
|
@@ -838,21 +1222,7 @@ export class OnlyneViewer implements Component {
|
|
|
838
1222
|
/** The member column: the current domain's peers, then the selected one's detail. */
|
|
839
1223
|
private memberColumn(): NativeNode {
|
|
840
1224
|
const peers = this.peers();
|
|
841
|
-
const rows: NativeChild[] = peers.map(peer =>
|
|
842
|
-
const phase = peer.trust?.phase;
|
|
843
|
-
return node(
|
|
844
|
-
"item",
|
|
845
|
-
{
|
|
846
|
-
label: [span("● ", phase === undefined ? "muted" : PHASE_TONE[phase]), span(shortNodeId(peer.record.node_id), "strong")],
|
|
847
|
-
// Every row here belongs to the selected domain, so the region would repeat
|
|
848
|
-
// the column head and truncate the id it sits next to.
|
|
849
|
-
value: [span(phase === undefined ? t("trust.none") : t(PHASE_SHORT[phase]), phase === undefined ? "dim" : PHASE_TONE[phase])],
|
|
850
|
-
title: `${peer.record.node_id}\n${peer.record.base_url}`,
|
|
851
|
-
},
|
|
852
|
-
undefined,
|
|
853
|
-
`peer:${peer.record.node_id}`,
|
|
854
|
-
);
|
|
855
|
-
});
|
|
1225
|
+
const rows: NativeChild[] = peers.map(peer => this.candidateCard(peer));
|
|
856
1226
|
const children: NativeChild[] = [
|
|
857
1227
|
node("row", { gap: "sm", align: "center", justify: "between" }, [
|
|
858
1228
|
textNode([span(t("members.head", { count: peers.length }), "dim")]),
|
|
@@ -873,7 +1243,7 @@ export class OnlyneViewer implements Component {
|
|
|
873
1243
|
role: "onlyne.members.list",
|
|
874
1244
|
grow: 1,
|
|
875
1245
|
basis: 0,
|
|
876
|
-
|
|
1246
|
+
max: { lines: this.memberListRows() },
|
|
877
1247
|
empty: [span(t("members.empty"), "muted")],
|
|
878
1248
|
},
|
|
879
1249
|
rows,
|
|
@@ -889,6 +1259,7 @@ export class OnlyneViewer implements Component {
|
|
|
889
1259
|
"card",
|
|
890
1260
|
{
|
|
891
1261
|
role: "onlyne.member-detail",
|
|
1262
|
+
tone: phase === undefined ? undefined : CANDIDATE_TONE[phase],
|
|
892
1263
|
head: [span(shortNodeId(peer.record.node_id), "strong"), ...(phase === undefined ? [] : [span(` · ${t(PHASE_LABEL[phase])}`, PHASE_TONE[phase])])],
|
|
893
1264
|
},
|
|
894
1265
|
[
|
|
@@ -896,7 +1267,8 @@ export class OnlyneViewer implements Component {
|
|
|
896
1267
|
{ k: t("field.fingerprint"), v: peer.trust?.fingerprint ?? t("member.fingerprint.none") },
|
|
897
1268
|
{ k: t("field.domain"), v: regionLabel(peer.record.region) },
|
|
898
1269
|
{ k: t("field.status"), v: phase === undefined ? t("trust.none") : t(PHASE_LABEL[phase]) },
|
|
899
|
-
{ k: t("field.
|
|
1270
|
+
{ k: t("field.declared"), v: peer.record.base_url },
|
|
1271
|
+
{ k: t("field.observed"), v: peer.trust?.observed ?? t("member.observed.none") },
|
|
900
1272
|
{ k: t("field.source"), v: peer.record.source === "bootstrap" ? t("member.source.seed") : peer.record.source },
|
|
901
1273
|
{
|
|
902
1274
|
k: t("field.lastSeen"),
|
|
@@ -924,6 +1296,32 @@ export class OnlyneViewer implements Component {
|
|
|
924
1296
|
);
|
|
925
1297
|
}
|
|
926
1298
|
|
|
1299
|
+
/**
|
|
1300
|
+
* RFC 0018 §5 / design §5.3: the ring says where the exchange stands (blue while it is
|
|
1301
|
+
* in progress, green once both sides confirmed), and selection paints its own background
|
|
1302
|
+
* over that ring instead of replacing it.
|
|
1303
|
+
*/
|
|
1304
|
+
private candidateCard(peer: Peer): NativeNode {
|
|
1305
|
+
const phase = peer.trust?.phase;
|
|
1306
|
+
const open = this.memberKey === peer.record.node_id;
|
|
1307
|
+
return node(
|
|
1308
|
+
"card",
|
|
1309
|
+
{
|
|
1310
|
+
role: "onlyne.candidate",
|
|
1311
|
+
tone: phase === undefined ? undefined : CANDIDATE_TONE[phase],
|
|
1312
|
+
selected: open,
|
|
1313
|
+
actions: { click: `onlyne.member:${peer.record.node_id}` },
|
|
1314
|
+
head: [
|
|
1315
|
+
span(shortNodeId(peer.record.node_id), "strong"),
|
|
1316
|
+
span(` · ${phase === undefined ? t("trust.none") : t(PHASE_SHORT[phase])}`, phase === undefined ? "dim" : PHASE_TONE[phase]),
|
|
1317
|
+
],
|
|
1318
|
+
title: `${peer.record.node_id}\n${peer.record.base_url}`,
|
|
1319
|
+
},
|
|
1320
|
+
[textNode([span(peer.record.base_url, "dim")], { truncate: "end" })],
|
|
1321
|
+
`peer:${peer.record.node_id}`,
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
|
|
927
1325
|
describe(cx: DescribeContext): NativeNode {
|
|
928
1326
|
const key = `${this.version}:${cx.cols}:${this.rows()}`;
|
|
929
1327
|
if (this.described !== undefined && this.describedKey === key) return this.described;
|
|
@@ -951,10 +1349,15 @@ export class OnlyneViewer implements Component {
|
|
|
951
1349
|
this.view = { kind: "thread", alias };
|
|
952
1350
|
this.target = undefined;
|
|
953
1351
|
this.replyTo = undefined;
|
|
1352
|
+
this.opened = undefined;
|
|
1353
|
+
this.allowEditing = false;
|
|
1354
|
+
this.allowEditor = undefined;
|
|
1355
|
+
this.allowDraft = "";
|
|
954
1356
|
this.messageScrollBy = "end";
|
|
955
1357
|
this.messageScroll++;
|
|
956
1358
|
} else if (id.startsWith("invite:")) {
|
|
957
1359
|
this.memberKey = id.slice("invite:".length);
|
|
1360
|
+
this.view = { kind: "trust" };
|
|
958
1361
|
} else if (id.startsWith("domain:")) {
|
|
959
1362
|
this.domainKey = id.slice("domain:".length);
|
|
960
1363
|
this.memberKey = undefined;
|
|
@@ -963,18 +1366,35 @@ export class OnlyneViewer implements Component {
|
|
|
963
1366
|
this.memberKey = this.memberKey === peer ? undefined : peer;
|
|
964
1367
|
} else if (id === "inbox") {
|
|
965
1368
|
this.view = { kind: "inbox" };
|
|
1369
|
+
this.opened = undefined;
|
|
1370
|
+
this.allowEditing = false;
|
|
1371
|
+
this.allowEditor = undefined;
|
|
1372
|
+
this.allowDraft = "";
|
|
1373
|
+
} else if (id === "trust") {
|
|
1374
|
+
this.view = { kind: "trust" };
|
|
1375
|
+
this.opened = undefined;
|
|
966
1376
|
} else {
|
|
967
1377
|
return;
|
|
968
1378
|
}
|
|
969
1379
|
this.changed();
|
|
970
1380
|
}
|
|
971
1381
|
|
|
1382
|
+
/** A message in the open thread, by the identity its card carries. */
|
|
1383
|
+
private messageByIdentity(identity: string): ViewerMessage | undefined {
|
|
1384
|
+
return this.selectedThread()?.messages.find(
|
|
1385
|
+
message => `${encodeURIComponent(message.ref.sender)}/${encodeURIComponent(message.ref.id)}` === identity,
|
|
1386
|
+
);
|
|
1387
|
+
}
|
|
1388
|
+
|
|
972
1389
|
handleNativeEvent(event: NativeUiEvent): void {
|
|
973
1390
|
if (this.disposed || this.signal.aborted) return;
|
|
974
1391
|
if (event.type === "edit" || event.type === "undo") {
|
|
975
1392
|
if (this.addressOpen) {
|
|
976
1393
|
this.address?.handleNativeEvent(event);
|
|
977
1394
|
this.addressText = this.address?.getExpandedText() ?? this.addressText;
|
|
1395
|
+
} else if (this.allowEditing) {
|
|
1396
|
+
this.allowEditor?.handleNativeEvent(event);
|
|
1397
|
+
this.allowDraft = this.allowEditor?.getExpandedText() ?? this.allowDraft;
|
|
978
1398
|
} else {
|
|
979
1399
|
this.composer?.handleNativeEvent(event);
|
|
980
1400
|
this.draft = this.composer?.getExpandedText() ?? this.draft;
|
|
@@ -1008,6 +1428,11 @@ export class OnlyneViewer implements Component {
|
|
|
1008
1428
|
this.changed();
|
|
1009
1429
|
return;
|
|
1010
1430
|
}
|
|
1431
|
+
if (act === "onlyne.message.close") {
|
|
1432
|
+
this.opened = undefined;
|
|
1433
|
+
this.changed();
|
|
1434
|
+
return;
|
|
1435
|
+
}
|
|
1011
1436
|
if (act === "onlyne.discovery.refresh") {
|
|
1012
1437
|
void this.refreshDiscovery();
|
|
1013
1438
|
return;
|
|
@@ -1054,16 +1479,54 @@ export class OnlyneViewer implements Component {
|
|
|
1054
1479
|
this.changed();
|
|
1055
1480
|
return;
|
|
1056
1481
|
}
|
|
1482
|
+
if (act.startsWith("onlyne.policy:")) {
|
|
1483
|
+
const policy = act.slice("onlyne.policy:".length);
|
|
1484
|
+
if (policy === "all" || policy === "segment" || policy === "none") void this.setPolicy(policy);
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
if (act === "onlyne.allow.open") {
|
|
1488
|
+
this.openAllow();
|
|
1489
|
+
return;
|
|
1490
|
+
}
|
|
1491
|
+
if (act === "onlyne.allow.save") {
|
|
1492
|
+
void this.saveAllow();
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
if (act === "onlyne.allow.cancel") {
|
|
1496
|
+
this.closeAllow();
|
|
1497
|
+
return;
|
|
1498
|
+
}
|
|
1057
1499
|
if (act.startsWith("onlyne.message:")) {
|
|
1058
1500
|
const identity = act.slice("onlyne.message:".length);
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1501
|
+
const message = this.messageByIdentity(identity);
|
|
1502
|
+
if (message === undefined) return;
|
|
1503
|
+
const already = this.opened !== undefined
|
|
1504
|
+
&& `${encodeURIComponent(this.opened.ref.sender)}/${encodeURIComponent(this.opened.ref.id)}` === identity;
|
|
1505
|
+
this.opened = already ? undefined : message;
|
|
1506
|
+
if (!already) {
|
|
1507
|
+
// A long body starts at its head, not wherever the message list was left.
|
|
1508
|
+
this.messageScrollBy = "start";
|
|
1509
|
+
this.messageScroll++;
|
|
1066
1510
|
}
|
|
1511
|
+
this.changed();
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
if (act.startsWith("onlyne.message.reply:")) {
|
|
1515
|
+
const message = this.messageByIdentity(act.slice("onlyne.message.reply:".length));
|
|
1516
|
+
if (message === undefined || message.ref.sender === this.status?.node_id) return;
|
|
1517
|
+
this.replyTo = message.ref;
|
|
1518
|
+
this.target = undefined;
|
|
1519
|
+
this.opened = undefined;
|
|
1520
|
+
this.changed();
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
if (act.startsWith("onlyne.message.target:")) {
|
|
1524
|
+
const message = this.messageByIdentity(act.slice("onlyne.message.target:".length));
|
|
1525
|
+
if (message === undefined || message.ref.sender === this.status?.node_id) return;
|
|
1526
|
+
this.target = message.author;
|
|
1527
|
+
this.replyTo = undefined;
|
|
1528
|
+
this.opened = undefined;
|
|
1529
|
+
this.changed();
|
|
1067
1530
|
return;
|
|
1068
1531
|
}
|
|
1069
1532
|
if (act.startsWith("onlyne.accept:")) {
|
|
@@ -1092,6 +1555,15 @@ export class OnlyneViewer implements Component {
|
|
|
1092
1555
|
this.closeAddress();
|
|
1093
1556
|
return;
|
|
1094
1557
|
}
|
|
1558
|
+
if (this.allowEditing) {
|
|
1559
|
+
this.closeAllow();
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
if (this.opened !== undefined) {
|
|
1563
|
+
this.opened = undefined;
|
|
1564
|
+
this.changed();
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1095
1567
|
this.done();
|
|
1096
1568
|
return;
|
|
1097
1569
|
}
|
|
@@ -1107,6 +1579,17 @@ export class OnlyneViewer implements Component {
|
|
|
1107
1579
|
this.requestRender();
|
|
1108
1580
|
return;
|
|
1109
1581
|
}
|
|
1582
|
+
if (this.allowEditing) {
|
|
1583
|
+
if (matchesKey(data, "enter")) {
|
|
1584
|
+
void this.saveAllow();
|
|
1585
|
+
return;
|
|
1586
|
+
}
|
|
1587
|
+
this.allowEditor?.handleInput(data);
|
|
1588
|
+
this.allowDraft = this.allowEditor?.getExpandedText() ?? this.allowDraft;
|
|
1589
|
+
this.described = undefined;
|
|
1590
|
+
this.requestRender();
|
|
1591
|
+
return;
|
|
1592
|
+
}
|
|
1110
1593
|
if (matchesKey(data, "ctrl+enter")) {
|
|
1111
1594
|
void this.send();
|
|
1112
1595
|
return;
|
|
@@ -1136,6 +1619,68 @@ export class OnlyneViewer implements Component {
|
|
|
1136
1619
|
await this.reload();
|
|
1137
1620
|
}
|
|
1138
1621
|
|
|
1622
|
+
/** RFC 0018 §2: a policy change is configuration, and never rewrites existing trust. */
|
|
1623
|
+
private async setPolicy(policy: AcquirePolicy): Promise<void> {
|
|
1624
|
+
const runtime = getRuntime();
|
|
1625
|
+
if (runtime === undefined || this.policyBusy) return;
|
|
1626
|
+
const current = runtime.config.acquisition ?? DEFAULT_ACQUISITION;
|
|
1627
|
+
if (current.policy === policy) return;
|
|
1628
|
+
this.policyBusy = true;
|
|
1629
|
+
this.changed();
|
|
1630
|
+
try {
|
|
1631
|
+
await runtime.updateConfig({ ...runtime.config, acquisition: { ...current, policy } });
|
|
1632
|
+
this.setNotice(t("notice.policySaved", { policy }));
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
this.setNotice(t("notice.policyFailed", { error: errorText(error) }), true);
|
|
1635
|
+
} finally {
|
|
1636
|
+
this.policyBusy = false;
|
|
1637
|
+
await this.reload();
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
private openAllow(): void {
|
|
1642
|
+
const runtime = getRuntime();
|
|
1643
|
+
const current = runtime?.config.acquisition ?? this.snapshot?.acquisition ?? DEFAULT_ACQUISITION;
|
|
1644
|
+
this.allowDraft = current.allow.join(", ");
|
|
1645
|
+
this.allowEditor = undefined;
|
|
1646
|
+
this.allowEditing = true;
|
|
1647
|
+
this.changed();
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
private closeAllow(): void {
|
|
1651
|
+
this.allowEditing = false;
|
|
1652
|
+
this.allowEditor = undefined;
|
|
1653
|
+
this.allowDraft = "";
|
|
1654
|
+
this.changed();
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
private async saveAllow(): Promise<void> {
|
|
1658
|
+
const runtime = getRuntime();
|
|
1659
|
+
if (runtime === undefined) {
|
|
1660
|
+
this.setNotice(t("notice.notRunning"), true);
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
const allow = this.allowDraft
|
|
1664
|
+
.split(/[,\s]+/)
|
|
1665
|
+
.map(entry => entry.trim())
|
|
1666
|
+
.filter(entry => entry.length > 0);
|
|
1667
|
+
for (const entry of allow) {
|
|
1668
|
+
if (parseAllowBlock(entry) === undefined) {
|
|
1669
|
+
this.setNotice(t("trustpage.allowInvalid", { value: entry }), true);
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
}
|
|
1673
|
+
const current = runtime.config.acquisition ?? DEFAULT_ACQUISITION;
|
|
1674
|
+
try {
|
|
1675
|
+
await runtime.updateConfig({ ...runtime.config, acquisition: { policy: current.policy, allow } });
|
|
1676
|
+
this.closeAllow();
|
|
1677
|
+
this.setNotice(t("notice.allowSaved"));
|
|
1678
|
+
} catch (error) {
|
|
1679
|
+
this.setNotice(t("notice.allowFailed", { error: errorText(error) }), true);
|
|
1680
|
+
}
|
|
1681
|
+
await this.reload();
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1139
1684
|
private closeAddress(): void {
|
|
1140
1685
|
this.addressOpen = false;
|
|
1141
1686
|
this.address = undefined;
|
|
@@ -1282,12 +1827,17 @@ export class OnlyneViewer implements Component {
|
|
|
1282
1827
|
this.sending = true;
|
|
1283
1828
|
this.changed();
|
|
1284
1829
|
try {
|
|
1285
|
-
|
|
1830
|
+
// The library decides every send through the plugin, and the plugin only decides inside
|
|
1831
|
+
// a bound scope: without one the composer reported a send failure (audit §2.3). The
|
|
1832
|
+
// panel is the main session's input, so it carries the main session's scope.
|
|
1833
|
+
const outcome = (await runDecision({ ctx: runtime.main, input }, () =>
|
|
1834
|
+
runtime.node.client.msg(input, { context: runtime.mainAgentId }))) as SendOutcome;
|
|
1286
1835
|
this.setNotice(this.describeOutcome(outcome), outcome.admission === "refused" || outcome.error !== undefined);
|
|
1287
1836
|
if (outcome.admission === "accepted" || outcome.admission === "unknown") {
|
|
1288
1837
|
this.draft = "";
|
|
1289
1838
|
this.composer?.setText("");
|
|
1290
1839
|
this.replyTo = undefined;
|
|
1840
|
+
this.opened = undefined;
|
|
1291
1841
|
this.messageScrollBy = "end";
|
|
1292
1842
|
this.messageScroll++;
|
|
1293
1843
|
}
|
|
@@ -1335,6 +1885,31 @@ export class OnlyneViewer implements Component {
|
|
|
1335
1885
|
private ansiRows(width: number): string[] {
|
|
1336
1886
|
const lines: string[] = [];
|
|
1337
1887
|
const short = (text: string): string => fit(text, width);
|
|
1888
|
+
if (this.view?.kind === "trust") {
|
|
1889
|
+
const acquisition = this.snapshot?.acquisition;
|
|
1890
|
+
lines.push(short(t("trustpage.title")));
|
|
1891
|
+
if (acquisition !== undefined) {
|
|
1892
|
+
lines.push(short(`${t("trustpage.policy")}: ${t(POLICY_TEXT[acquisition.policy])}`));
|
|
1893
|
+
lines.push(short(`${t("trustpage.allow")}: ${acquisition.allow.length === 0 ? t("trustpage.allow.none") : oneLine(acquisition.allow.join(", "))}`));
|
|
1894
|
+
lines.push(short(`${t("trustpage.allowLoopback")}: ${acquisition.allow_loopback ? t("trustpage.allowLoopback.on") : t("trustpage.allowLoopback.off")}`));
|
|
1895
|
+
} else lines.push(short(t("trustpage.policy.unknown")));
|
|
1896
|
+
lines.push(short(t("trustpage.warn.lan")));
|
|
1897
|
+
lines.push(short(t("trustpage.warn.limits")));
|
|
1898
|
+
lines.push(short(`${t("trustpage.invited.head")} · ${this.invites().length}`));
|
|
1899
|
+
for (const invite of this.invites()) {
|
|
1900
|
+
lines.push(short(
|
|
1901
|
+
`${shortNodeId(invite.node_id)} · ${invite.fingerprint} · ${t("field.declared")}: ${invite.base_url} · ${t("field.observed")}: ${invite.observed ?? t("member.observed.none")}`,
|
|
1902
|
+
));
|
|
1903
|
+
}
|
|
1904
|
+
const audit = (this.snapshot?.audit ?? []).slice(0, AUDIT_LIMIT);
|
|
1905
|
+
lines.push(short(`${t("trustpage.audit.head")} · ${audit.length}`));
|
|
1906
|
+
for (const entry of audit) {
|
|
1907
|
+
lines.push(short(
|
|
1908
|
+
`${t(AUDIT_ACTOR[entry.actor])} · ${entry.action}${entry.override === true ? ` · ${t("trustpage.override")}` : ""}${entry.node_id === undefined ? "" : ` · ${shortNodeId(entry.node_id)}`}${entry.reason === undefined ? "" : ` · ${entry.reason}`}`,
|
|
1909
|
+
));
|
|
1910
|
+
}
|
|
1911
|
+
return lines;
|
|
1912
|
+
}
|
|
1338
1913
|
if (this.view?.kind === "inbox") {
|
|
1339
1914
|
lines.push(short(t("fallback.inbox", { count: this.snapshot?.queue.length ?? 0 })));
|
|
1340
1915
|
for (const entry of this.snapshot?.queue ?? []) {
|
|
@@ -1348,9 +1923,21 @@ export class OnlyneViewer implements Component {
|
|
|
1348
1923
|
title: thread === undefined ? t("fallback.none") : thread.title,
|
|
1349
1924
|
})));
|
|
1350
1925
|
for (const invite of this.invites()) {
|
|
1351
|
-
lines.push(short(t("fallback.invite", {
|
|
1926
|
+
lines.push(short(t("fallback.invite", {
|
|
1927
|
+
node: shortNodeId(invite.node_id),
|
|
1928
|
+
fingerprint: `${invite.fingerprint} · ${invite.base_url} · ${invite.observed ?? t("member.observed.none")}`,
|
|
1929
|
+
})));
|
|
1930
|
+
}
|
|
1931
|
+
const opened = this.openedIn(thread);
|
|
1932
|
+
if (opened !== undefined) {
|
|
1933
|
+
lines.push(short(t("messageDetail.head", {
|
|
1934
|
+
seq: opened.seq,
|
|
1935
|
+
author: plainAuthor(opened.author),
|
|
1936
|
+
time: clockText(opened.at) || t("time.unknown"),
|
|
1937
|
+
})));
|
|
1938
|
+
for (const line of opened.text.split("\n")) lines.push(short(` ${line}`));
|
|
1352
1939
|
}
|
|
1353
|
-
for (const message of thread?.messages ?? []) {
|
|
1940
|
+
for (const message of opened === undefined ? thread?.messages ?? [] : []) {
|
|
1354
1941
|
lines.push(short(`${message.direction === "out" ? "→" : "←"} [${message.seq}] ${plainAuthor(message.author)} ${clockText(message.at)}`));
|
|
1355
1942
|
for (const line of message.text.split("\n").slice(0, 6)) lines.push(short(` ${line}`));
|
|
1356
1943
|
}
|
|
@@ -1378,8 +1965,10 @@ export class OnlyneViewer implements Component {
|
|
|
1378
1965
|
this.noticeTimer = undefined;
|
|
1379
1966
|
this.unsubscribe?.();
|
|
1380
1967
|
this.unsubscribe = undefined;
|
|
1968
|
+
this.watched = undefined;
|
|
1381
1969
|
this.composer = undefined;
|
|
1382
1970
|
this.address = undefined;
|
|
1971
|
+
this.allowEditor = undefined;
|
|
1383
1972
|
this.described = undefined;
|
|
1384
1973
|
this.rendered = undefined;
|
|
1385
1974
|
}
|