omp-onlyne 0.2.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/src/ui/viewer.ts CHANGED
@@ -16,9 +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 { getRuntime } from "../runtime.ts";
19
+ import { DEFAULT_ACQUISITION, parseAllowBlock, type AcquirePolicy } from "@onlyne/node-host/acquire";
20
+ import { getRuntime, type PluginRuntime } from "../runtime.ts";
20
21
  import { runDecision } from "../decider.ts";
21
- import type { OnlyneStatus, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread } from "../types.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";
22
32
  import { ageText, clockText, fit, oneLine, shortAlias, sinceText } from "./format.ts";
23
33
  import { type ViewerTextKey, t } from "./viewer-text.ts";
24
34
 
@@ -94,7 +104,7 @@ const PHASE_SHORT: Record<TrustPhase, ViewerTextKey> = {
94
104
 
95
105
  interface Peer {
96
106
  record: ViewerDiscovery;
97
- trust?: { phase: TrustPhase; fingerprint: string };
107
+ trust?: { phase: TrustPhase; fingerprint: string; observed?: string };
98
108
  }
99
109
 
100
110
  /**
@@ -112,10 +122,47 @@ interface Invite {
112
122
  node_id: string;
113
123
  fingerprint: string;
114
124
  base_url: string;
125
+ observed?: string;
115
126
  }
116
127
 
117
- /** What the main column shows: one thread, or the queue waiting on this node. */
118
- 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
+ };
119
166
 
120
167
  /** A described node with a scroll request attached; `node()` only builds the wire shape. */
121
168
  function scrolling(described: NativeNode, scroll: NativeScroll): NativeNode {
@@ -165,9 +212,19 @@ export class OnlyneViewer implements Component {
165
212
  /** Bumped to ask the terminal to scroll the message list to its newest row. */
166
213
  private messageScroll = 0;
167
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 = "";
168
223
  private notice: { text: string; error: boolean } | undefined;
169
224
  private noticeTimer: Timer | undefined;
170
225
  private unsubscribe: (() => void) | undefined;
226
+ /** The runtime instance the change subscription belongs to; a restart replaces it. */
227
+ private watched: PluginRuntime | undefined;
171
228
  private described: NativeNode | undefined;
172
229
  private describedKey: string | undefined;
173
230
  private rendered: { key: string; lines: readonly string[] } | undefined;
@@ -190,8 +247,17 @@ export class OnlyneViewer implements Component {
190
247
  private readonly signal: AbortSignal,
191
248
  private readonly rows: () => number = () => 40,
192
249
  ) {
250
+ this.bindRuntime();
193
251
  void this.reload();
194
- this.unsubscribe = getRuntime()?.onChange(() => {
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(() => {
195
261
  if (this.disposed || this.signal.aborted) return;
196
262
  void this.reload();
197
263
  });
@@ -205,8 +271,54 @@ export class OnlyneViewer implements Component {
205
271
  this.requestRender();
206
272
  }
207
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
+
208
319
  async reload(): Promise<void> {
209
320
  if (this.disposed || this.signal.aborted) return;
321
+ this.bindRuntime();
210
322
  const generation = ++this.generation;
211
323
  const runtime = getRuntime();
212
324
  if (runtime === undefined) {
@@ -278,12 +390,12 @@ export class OnlyneViewer implements Component {
278
390
  private invites(): Invite[] {
279
391
  return (this.snapshot?.trust ?? [])
280
392
  .filter(entry => entry.phase === "invited")
281
- .map(entry => ({ node_id: entry.node_id, fingerprint: entry.fingerprint, base_url: entry.base_url }));
282
- }
283
-
284
- /** This node's trust row for a peer, undefined when the two never exchanged keys. */
285
- private trustOf(nodeId: string): Peer["trust"] {
286
- return this.snapshot?.trust?.find(entry => entry.node_id === nodeId);
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
+ }));
287
399
  }
288
400
 
289
401
  /**
@@ -308,7 +420,13 @@ export class OnlyneViewer implements Component {
308
420
  record,
309
421
  ...(trust === undefined
310
422
  ? {}
311
- : { trust: { phase: trust.phase, fingerprint: trust.fingerprint } }),
423
+ : {
424
+ trust: {
425
+ phase: trust.phase,
426
+ fingerprint: trust.fingerprint,
427
+ ...(trust.observed === undefined ? {} : { observed: trust.observed }),
428
+ },
429
+ }),
312
430
  });
313
431
  }
314
432
  const own = this.snapshot?.region;
@@ -348,6 +466,13 @@ export class OnlyneViewer implements Component {
348
466
  return undefined;
349
467
  }
350
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
+
351
476
  // ── recurring pieces ────────────────────────────────────────────────────
352
477
 
353
478
  private button(label: string, act: string, options: { tone?: "accent" | "error"; title?: string } = {}): NativeNode {
@@ -436,6 +561,7 @@ export class OnlyneViewer implements Component {
436
561
  role: "onlyne.rail.list",
437
562
  grow: 1,
438
563
  basis: 0,
564
+ max: { lines: Math.max(3, this.bodyRows() - 1) },
439
565
  selected: current === undefined ? null : `domain:${current.key}`,
440
566
  empty: [span(t("rail.empty"), "muted")],
441
567
  },
@@ -521,6 +647,18 @@ export class OnlyneViewer implements Component {
521
647
  "inbox",
522
648
  ),
523
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
+ );
524
662
 
525
663
  return node(
526
664
  "col",
@@ -542,7 +680,14 @@ export class OnlyneViewer implements Component {
542
680
  role: "onlyne.threads.list",
543
681
  grow: 1,
544
682
  basis: 0,
545
- selected: this.view?.kind === "inbox" ? "inbox" : selected === undefined ? null : `thread:${selected.alias}`,
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}`,
546
691
  empty: [span(t("sidebar.empty"), "muted")],
547
692
  },
548
693
  rows,
@@ -570,7 +715,8 @@ export class OnlyneViewer implements Component {
570
715
  this.facts([
571
716
  { k: t("field.peerFingerprint"), v: invite.fingerprint },
572
717
  { k: t("field.ownFingerprint"), v: mine },
573
- { k: t("field.endpoint"), v: invite.base_url },
718
+ { k: t("field.declared"), v: invite.base_url },
719
+ { k: t("field.observed"), v: invite.observed ?? t("member.observed.none") },
574
720
  ]),
575
721
  node(
576
722
  "row",
@@ -606,7 +752,7 @@ export class OnlyneViewer implements Component {
606
752
  span(` ${clockText(message.at) || "—"}`, "dim"),
607
753
  ...(message.relation === undefined ? [] : [span(` ${message.relation}`, "dim")]),
608
754
  ],
609
- title: t("message.reply.title", { seq: message.seq }),
755
+ title: t("message.open.title", { seq: message.seq }),
610
756
  actions: { click: `onlyne.message:${messageKey}` },
611
757
  },
612
758
  [textNode(message.text, { wrap: "word" })],
@@ -614,6 +760,48 @@ export class OnlyneViewer implements Component {
614
760
  );
615
761
  }
616
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
+
617
805
  private composerNode(): NativeNode {
618
806
  const thread = this.selectedThread();
619
807
  const blocked = this.sendBlocked();
@@ -625,7 +813,7 @@ export class OnlyneViewer implements Component {
625
813
  this.composer = editor;
626
814
  }
627
815
  // One caret at a time: the address card owns it while it is open.
628
- this.composer.focused = this.focused && !this.addressOpen;
816
+ this.composer.focused = this.focused && !this.addressOpen && !this.allowEditing;
629
817
  const where = this.replyTo !== undefined
630
818
  ? t("composer.reply", { node: shortNodeId(this.replyTo.sender), id: this.replyTo.id })
631
819
  : this.target !== undefined
@@ -701,6 +889,7 @@ export class OnlyneViewer implements Component {
701
889
  role: "onlyne.inbox.list",
702
890
  grow: 1,
703
891
  basis: 0,
892
+ max: { lines: this.inboxListRows() },
704
893
  empty: [span(t("inbox.empty"), "muted")],
705
894
  },
706
895
  rows,
@@ -708,6 +897,158 @@ export class OnlyneViewer implements Component {
708
897
  );
709
898
  }
710
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
+
711
1052
  /** The main column: the open thread with its composer, or the queue. */
712
1053
  private flowNode(): NativeNode {
713
1054
  const children: NativeChild[] = [];
@@ -739,7 +1080,20 @@ export class OnlyneViewer implements Component {
739
1080
  );
740
1081
  }
741
1082
 
742
- if (this.view?.kind === "inbox") {
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") {
743
1097
  children.push(
744
1098
  node("row", { role: "onlyne.thread-head", gap: "md", align: "center", justify: "between" }, [
745
1099
  node("col", { gap: "none", grow: 1 }, [
@@ -755,6 +1109,7 @@ export class OnlyneViewer implements Component {
755
1109
  children.push(textNode([span(t("flow.empty"), "muted")], { wrap: "word", lines: 3 }));
756
1110
  } else {
757
1111
  const messages = thread.messages;
1112
+ const opened = this.openedIn(thread);
758
1113
  const last = messages.at(-1);
759
1114
  const lastLine = last === undefined
760
1115
  ? ""
@@ -776,20 +1131,36 @@ export class OnlyneViewer implements Component {
776
1131
  ]),
777
1132
  ], "thread-head"),
778
1133
  ...this.approvalsCard(),
779
- scrolling(
780
- node(
781
- "list",
782
- {
783
- role: "onlyne.messages",
784
- grow: 1,
785
- basis: 0,
786
- empty: [span(t("thread.empty"), "muted")],
787
- },
788
- messages.map(message => this.messageCard(message)),
789
- "messages",
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 },
790
1163
  ),
791
- { by: this.messageScrollBy, n: this.messageScroll },
792
- ),
793
1164
  this.composerNode(),
794
1165
  );
795
1166
  }
@@ -851,21 +1222,7 @@ export class OnlyneViewer implements Component {
851
1222
  /** The member column: the current domain's peers, then the selected one's detail. */
852
1223
  private memberColumn(): NativeNode {
853
1224
  const peers = this.peers();
854
- const rows: NativeChild[] = peers.map(peer => {
855
- const phase = peer.trust?.phase;
856
- return node(
857
- "item",
858
- {
859
- label: [span("● ", phase === undefined ? "muted" : PHASE_TONE[phase]), span(shortNodeId(peer.record.node_id), "strong")],
860
- // Every row here belongs to the selected domain, so the region would repeat
861
- // the column head and truncate the id it sits next to.
862
- value: [span(phase === undefined ? t("trust.none") : t(PHASE_SHORT[phase]), phase === undefined ? "dim" : PHASE_TONE[phase])],
863
- title: `${peer.record.node_id}\n${peer.record.base_url}`,
864
- },
865
- undefined,
866
- `peer:${peer.record.node_id}`,
867
- );
868
- });
1225
+ const rows: NativeChild[] = peers.map(peer => this.candidateCard(peer));
869
1226
  const children: NativeChild[] = [
870
1227
  node("row", { gap: "sm", align: "center", justify: "between" }, [
871
1228
  textNode([span(t("members.head", { count: peers.length }), "dim")]),
@@ -886,7 +1243,7 @@ export class OnlyneViewer implements Component {
886
1243
  role: "onlyne.members.list",
887
1244
  grow: 1,
888
1245
  basis: 0,
889
- selected: this.memberKey === undefined ? null : `peer:${this.memberKey}`,
1246
+ max: { lines: this.memberListRows() },
890
1247
  empty: [span(t("members.empty"), "muted")],
891
1248
  },
892
1249
  rows,
@@ -902,6 +1259,7 @@ export class OnlyneViewer implements Component {
902
1259
  "card",
903
1260
  {
904
1261
  role: "onlyne.member-detail",
1262
+ tone: phase === undefined ? undefined : CANDIDATE_TONE[phase],
905
1263
  head: [span(shortNodeId(peer.record.node_id), "strong"), ...(phase === undefined ? [] : [span(` · ${t(PHASE_LABEL[phase])}`, PHASE_TONE[phase])])],
906
1264
  },
907
1265
  [
@@ -909,7 +1267,8 @@ export class OnlyneViewer implements Component {
909
1267
  { k: t("field.fingerprint"), v: peer.trust?.fingerprint ?? t("member.fingerprint.none") },
910
1268
  { k: t("field.domain"), v: regionLabel(peer.record.region) },
911
1269
  { k: t("field.status"), v: phase === undefined ? t("trust.none") : t(PHASE_LABEL[phase]) },
912
- { k: t("field.endpoint"), v: peer.record.base_url },
1270
+ { k: t("field.declared"), v: peer.record.base_url },
1271
+ { k: t("field.observed"), v: peer.trust?.observed ?? t("member.observed.none") },
913
1272
  { k: t("field.source"), v: peer.record.source === "bootstrap" ? t("member.source.seed") : peer.record.source },
914
1273
  {
915
1274
  k: t("field.lastSeen"),
@@ -937,6 +1296,32 @@ export class OnlyneViewer implements Component {
937
1296
  );
938
1297
  }
939
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
+
940
1325
  describe(cx: DescribeContext): NativeNode {
941
1326
  const key = `${this.version}:${cx.cols}:${this.rows()}`;
942
1327
  if (this.described !== undefined && this.describedKey === key) return this.described;
@@ -964,10 +1349,15 @@ export class OnlyneViewer implements Component {
964
1349
  this.view = { kind: "thread", alias };
965
1350
  this.target = undefined;
966
1351
  this.replyTo = undefined;
1352
+ this.opened = undefined;
1353
+ this.allowEditing = false;
1354
+ this.allowEditor = undefined;
1355
+ this.allowDraft = "";
967
1356
  this.messageScrollBy = "end";
968
1357
  this.messageScroll++;
969
1358
  } else if (id.startsWith("invite:")) {
970
1359
  this.memberKey = id.slice("invite:".length);
1360
+ this.view = { kind: "trust" };
971
1361
  } else if (id.startsWith("domain:")) {
972
1362
  this.domainKey = id.slice("domain:".length);
973
1363
  this.memberKey = undefined;
@@ -976,18 +1366,35 @@ export class OnlyneViewer implements Component {
976
1366
  this.memberKey = this.memberKey === peer ? undefined : peer;
977
1367
  } else if (id === "inbox") {
978
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;
979
1376
  } else {
980
1377
  return;
981
1378
  }
982
1379
  this.changed();
983
1380
  }
984
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
+
985
1389
  handleNativeEvent(event: NativeUiEvent): void {
986
1390
  if (this.disposed || this.signal.aborted) return;
987
1391
  if (event.type === "edit" || event.type === "undo") {
988
1392
  if (this.addressOpen) {
989
1393
  this.address?.handleNativeEvent(event);
990
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;
991
1398
  } else {
992
1399
  this.composer?.handleNativeEvent(event);
993
1400
  this.draft = this.composer?.getExpandedText() ?? this.draft;
@@ -1021,6 +1428,11 @@ export class OnlyneViewer implements Component {
1021
1428
  this.changed();
1022
1429
  return;
1023
1430
  }
1431
+ if (act === "onlyne.message.close") {
1432
+ this.opened = undefined;
1433
+ this.changed();
1434
+ return;
1435
+ }
1024
1436
  if (act === "onlyne.discovery.refresh") {
1025
1437
  void this.refreshDiscovery();
1026
1438
  return;
@@ -1067,16 +1479,54 @@ export class OnlyneViewer implements Component {
1067
1479
  this.changed();
1068
1480
  return;
1069
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
+ }
1070
1499
  if (act.startsWith("onlyne.message:")) {
1071
1500
  const identity = act.slice("onlyne.message:".length);
1072
- const thread = this.selectedThread();
1073
- const message = thread?.messages.find(entry => `${encodeURIComponent(entry.ref.sender)}/${encodeURIComponent(entry.ref.id)}` === identity);
1074
- if (message !== undefined && thread !== undefined) {
1075
- if (message.ref.sender === this.status?.node_id) return;
1076
- this.replyTo = message.ref;
1077
- this.target = undefined;
1078
- this.changed();
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++;
1079
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();
1080
1530
  return;
1081
1531
  }
1082
1532
  if (act.startsWith("onlyne.accept:")) {
@@ -1105,6 +1555,15 @@ export class OnlyneViewer implements Component {
1105
1555
  this.closeAddress();
1106
1556
  return;
1107
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
+ }
1108
1567
  this.done();
1109
1568
  return;
1110
1569
  }
@@ -1120,6 +1579,17 @@ export class OnlyneViewer implements Component {
1120
1579
  this.requestRender();
1121
1580
  return;
1122
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
+ }
1123
1593
  if (matchesKey(data, "ctrl+enter")) {
1124
1594
  void this.send();
1125
1595
  return;
@@ -1149,6 +1619,68 @@ export class OnlyneViewer implements Component {
1149
1619
  await this.reload();
1150
1620
  }
1151
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
+
1152
1684
  private closeAddress(): void {
1153
1685
  this.addressOpen = false;
1154
1686
  this.address = undefined;
@@ -1305,6 +1837,7 @@ export class OnlyneViewer implements Component {
1305
1837
  this.draft = "";
1306
1838
  this.composer?.setText("");
1307
1839
  this.replyTo = undefined;
1840
+ this.opened = undefined;
1308
1841
  this.messageScrollBy = "end";
1309
1842
  this.messageScroll++;
1310
1843
  }
@@ -1352,6 +1885,31 @@ export class OnlyneViewer implements Component {
1352
1885
  private ansiRows(width: number): string[] {
1353
1886
  const lines: string[] = [];
1354
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
+ }
1355
1913
  if (this.view?.kind === "inbox") {
1356
1914
  lines.push(short(t("fallback.inbox", { count: this.snapshot?.queue.length ?? 0 })));
1357
1915
  for (const entry of this.snapshot?.queue ?? []) {
@@ -1365,9 +1923,21 @@ export class OnlyneViewer implements Component {
1365
1923
  title: thread === undefined ? t("fallback.none") : thread.title,
1366
1924
  })));
1367
1925
  for (const invite of this.invites()) {
1368
- lines.push(short(t("fallback.invite", { node: shortNodeId(invite.node_id), fingerprint: invite.fingerprint })));
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}`));
1369
1939
  }
1370
- for (const message of thread?.messages ?? []) {
1940
+ for (const message of opened === undefined ? thread?.messages ?? [] : []) {
1371
1941
  lines.push(short(`${message.direction === "out" ? "→" : "←"} [${message.seq}] ${plainAuthor(message.author)} ${clockText(message.at)}`));
1372
1942
  for (const line of message.text.split("\n").slice(0, 6)) lines.push(short(` ${line}`));
1373
1943
  }
@@ -1395,8 +1965,10 @@ export class OnlyneViewer implements Component {
1395
1965
  this.noticeTimer = undefined;
1396
1966
  this.unsubscribe?.();
1397
1967
  this.unsubscribe = undefined;
1968
+ this.watched = undefined;
1398
1969
  this.composer = undefined;
1399
1970
  this.address = undefined;
1971
+ this.allowEditor = undefined;
1400
1972
  this.described = undefined;
1401
1973
  this.rendered = undefined;
1402
1974
  }