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/README.md +3 -3
- package/package.json +3 -3
- package/src/bridge-text.ts +4 -8
- package/src/config.ts +6 -4
- package/src/decider-text.ts +3 -6
- package/src/decider.ts +124 -670
- package/src/drafts.ts +68 -19
- package/src/guard.ts +107 -0
- package/src/handshake.ts +2 -2
- package/src/messages.ts +24 -6
- 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 +201 -25
- package/src/tool.ts +11 -2
- package/src/types.ts +60 -66
- package/src/ui/bootstrap-text.ts +24 -0
- package/src/ui/bootstrap.ts +77 -3
- package/src/ui/index.ts +6 -2
- package/src/ui/mail-message.ts +5 -1
- package/src/ui/viewer-text.ts +112 -4
- package/src/ui/viewer.ts +629 -57
- package/src/urls-text.ts +6 -0
- package/src/urls.ts +36 -4
- package/src/work.ts +10 -0
- package/src/acquire.ts +0 -182
- package/src/host-text.ts +0 -92
- package/src/node.ts +0 -139
- package/src/store.ts +0 -189
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 {
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 => ({
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
: {
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 === "
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
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", {
|
|
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
|
}
|