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/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 { getRuntime } from "../runtime.ts";
20
- import type { OnlyneStatus, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread } from "../types.ts";
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, or the queue waiting on this node. */
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
- 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(() => {
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 => ({ node_id: entry.node_id, fingerprint: entry.fingerprint, base_url: entry.base_url }));
281
- }
282
-
283
- /** This node's trust row for a peer, undefined when the two never exchanged keys. */
284
- private trustOf(nodeId: string): Peer["trust"] {
285
- 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
+ }));
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
- : { 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
+ }),
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
- if (this.target === undefined && this.replyTo === undefined && this.selectedThread() === undefined) {
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: [span(`# ${thread.title}`, open ? "strong" : undefined), ...(thread.state === "closed" ? [span(` ${t("thread.closed")}`, "dim")] : [])],
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
- 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}`,
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.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") },
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.reply.title", { seq: message.seq }),
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 === "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") {
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([span(`# ${thread.title}`, "strong"), ...(thread.state === "closed" ? [span(` · ${t("thread.closed")}`, "dim")] : [])], { truncate: "end" }),
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
- scrolling(
767
- node(
768
- "list",
769
- {
770
- role: "onlyne.messages",
771
- grow: 1,
772
- basis: 0,
773
- empty: [span(t("thread.empty"), "muted")],
774
- },
775
- messages.map(message => this.messageCard(message)),
776
- "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 },
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
- selected: this.memberKey === undefined ? null : `peer:${this.memberKey}`,
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.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") },
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 thread = this.selectedThread();
1060
- const message = thread?.messages.find(entry => `${encodeURIComponent(entry.ref.sender)}/${encodeURIComponent(entry.ref.id)}` === identity);
1061
- if (message !== undefined && thread !== undefined) {
1062
- if (message.ref.sender === this.status?.node_id) return;
1063
- this.replyTo = message.ref;
1064
- this.target = undefined;
1065
- 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++;
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
- const outcome = (await runtime.node.client.msg(input, { context: runtime.mainAgentId })) as SendOutcome;
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", { 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}`));
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
  }