omp-onlyne 0.0.0-stage → 0.2.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/AGENTS.sample.md +27 -0
- package/LICENSE +21 -0
- package/README.md +140 -2
- package/index.ts +55 -0
- package/package.json +62 -5
- package/src/acquire.ts +182 -0
- package/src/binding.ts +132 -0
- package/src/bridge-text.ts +145 -0
- package/src/config.ts +332 -0
- package/src/decider-text.ts +438 -0
- package/src/decider.ts +2233 -0
- package/src/drafts.ts +57 -0
- package/src/handshake.ts +291 -0
- package/src/host-text.ts +92 -0
- package/src/messages.ts +427 -0
- package/src/node.ts +139 -0
- package/src/resources.ts +36 -0
- package/src/runtime.ts +676 -0
- package/src/store.ts +189 -0
- package/src/tern.ts +66 -0
- package/src/todo-native.ts +230 -0
- package/src/todo.ts +896 -0
- package/src/tool-text.ts +167 -0
- package/src/tool.ts +342 -0
- package/src/types.ts +150 -0
- package/src/ui/bootstrap-text.ts +125 -0
- package/src/ui/bootstrap.ts +363 -0
- package/src/ui/command-text.ts +38 -0
- package/src/ui/format.ts +56 -0
- package/src/ui/index.ts +156 -0
- package/src/ui/mail-message.ts +263 -0
- package/src/ui/status.ts +213 -0
- package/src/ui/viewer-text.ts +309 -0
- package/src/ui/viewer.ts +1403 -0
- package/src/urls-text.ts +304 -0
- package/src/urls.ts +1488 -0
package/src/ui/viewer.ts
ADDED
|
@@ -0,0 +1,1403 @@
|
|
|
1
|
+
import type { KeyExchange, MessageRef, MsgInput, SendOutcome, TrustPhase } from "@onlyne/lib";
|
|
2
|
+
import {
|
|
3
|
+
type Component,
|
|
4
|
+
type DescribeContext,
|
|
5
|
+
Editor,
|
|
6
|
+
type NativeChild,
|
|
7
|
+
type NativeNode,
|
|
8
|
+
type NativeScroll,
|
|
9
|
+
type NativeUiEvent,
|
|
10
|
+
type Theme,
|
|
11
|
+
type TspSpan,
|
|
12
|
+
getEditorTheme,
|
|
13
|
+
matchesKey,
|
|
14
|
+
node,
|
|
15
|
+
span,
|
|
16
|
+
text as textNode,
|
|
17
|
+
} from "@oh-my-pi/pi-tui";
|
|
18
|
+
import { normalizeServiceUrl, shortNodeId } from "../config.ts";
|
|
19
|
+
import { getRuntime } from "../runtime.ts";
|
|
20
|
+
import { runDecision } from "../decider.ts";
|
|
21
|
+
import type { OnlyneStatus, ViewerDiscovery, ViewerMessage, ViewerSnapshot, ViewerThread } from "../types.ts";
|
|
22
|
+
import { ageText, clockText, fit, oneLine, shortAlias, sinceText } from "./format.ts";
|
|
23
|
+
import { type ViewerTextKey, t } from "./viewer-text.ts";
|
|
24
|
+
|
|
25
|
+
const REGION_PREFIX = "urn:onlyne:region:";
|
|
26
|
+
const NODE_PREFIX = "urn:onlyne:";
|
|
27
|
+
/** Groups for peers that declare no region, keyed by the kind their node id carries. */
|
|
28
|
+
const GATEWAY_KEY = "kind:gateway";
|
|
29
|
+
const OTHER_KEY = "kind:other";
|
|
30
|
+
|
|
31
|
+
/** The label column of a fact row: wide enough for the longest label (四格汉字), one gap before the value. */
|
|
32
|
+
const FACT_LABEL = "10ch";
|
|
33
|
+
|
|
34
|
+
/** A region URI is stored for stability; every label shows the short name the user typed. */
|
|
35
|
+
function regionLabel(region: string | undefined): string {
|
|
36
|
+
if (region === undefined) return t("region.undeclared");
|
|
37
|
+
if (!region.startsWith(REGION_PREFIX)) return region;
|
|
38
|
+
const name = region.slice(REGION_PREFIX.length);
|
|
39
|
+
try {
|
|
40
|
+
return decodeURIComponent(name);
|
|
41
|
+
} catch {
|
|
42
|
+
return name;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nodeKind(id: string): string | undefined {
|
|
47
|
+
if (!id.startsWith(NODE_PREFIX)) return undefined;
|
|
48
|
+
const rest = id.slice(NODE_PREFIX.length);
|
|
49
|
+
const colon = rest.indexOf(":");
|
|
50
|
+
return colon > 0 ? rest.slice(0, colon) : undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The group a peer belongs to: its declared region, else the kind its identity names. */
|
|
54
|
+
function groupKeyOf(record: ViewerDiscovery): string {
|
|
55
|
+
if (record.region !== undefined) return record.region;
|
|
56
|
+
return nodeKind(record.node_id) === "gateway" ? GATEWAY_KEY : OTHER_KEY;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function groupLabelOf(record: ViewerDiscovery): string {
|
|
60
|
+
if (record.region !== undefined) return regionLabel(record.region);
|
|
61
|
+
return nodeKind(record.node_id) === "gateway" ? t("group.gateway") : regionLabel(undefined);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function errorText(error: unknown): string {
|
|
65
|
+
return error instanceof Error ? error.message : String(error);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function plainAuthor(author: string): string {
|
|
69
|
+
const tail = author.split(":").pop();
|
|
70
|
+
return tail === undefined || tail === "" ? shortNodeId(author) : tail;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The stage names, in the words the approval flow uses: we wait on `invited`, the peer on `requested`. */
|
|
74
|
+
const PHASE_LABEL: Record<TrustPhase, ViewerTextKey> = {
|
|
75
|
+
exchanged: "trust.phase.exchanged",
|
|
76
|
+
requested: "trust.phase.requested",
|
|
77
|
+
invited: "trust.phase.invited",
|
|
78
|
+
revoked: "trust.phase.revoked",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const PHASE_TONE: Record<TrustPhase, string> = {
|
|
82
|
+
exchanged: "success",
|
|
83
|
+
requested: "info",
|
|
84
|
+
invited: "warning",
|
|
85
|
+
revoked: "error",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const PHASE_SHORT: Record<TrustPhase, ViewerTextKey> = {
|
|
89
|
+
exchanged: "trust.short.exchanged",
|
|
90
|
+
requested: "trust.short.requested",
|
|
91
|
+
invited: "trust.short.invited",
|
|
92
|
+
revoked: "trust.short.revoked",
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
interface Peer {
|
|
96
|
+
record: ViewerDiscovery;
|
|
97
|
+
trust?: { phase: TrustPhase; fingerprint: string };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One group in the rail. `key` is the selection handle and is always defined; `id` carries the
|
|
102
|
+
* region URI only when the peers declared one.
|
|
103
|
+
*/
|
|
104
|
+
interface DomainGroup {
|
|
105
|
+
key: string;
|
|
106
|
+
id?: string;
|
|
107
|
+
label: string;
|
|
108
|
+
peers: Peer[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface Invite {
|
|
112
|
+
node_id: string;
|
|
113
|
+
fingerprint: string;
|
|
114
|
+
base_url: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** What the main column shows: one thread, or the queue waiting on this node. */
|
|
118
|
+
type View = { kind: "thread"; alias: string } | { kind: "inbox" };
|
|
119
|
+
|
|
120
|
+
/** A described node with a scroll request attached; `node()` only builds the wire shape. */
|
|
121
|
+
function scrolling(described: NativeNode, scroll: NativeScroll): NativeNode {
|
|
122
|
+
return { ...described, scroll } as NativeNode;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A TSP line extent, for heights the terminal must never recompute from content. */
|
|
126
|
+
type LineExtent = `${number}lines`;
|
|
127
|
+
|
|
128
|
+
function linesOf(count: number): LineExtent {
|
|
129
|
+
return `${Math.max(1, Math.floor(count))}lines` as LineExtent;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The Onlyne surface, laid out as the design's four columns: the domain rail,
|
|
134
|
+
* the thread sidebar, the conversation, and the member column with the
|
|
135
|
+
* selected member's detail. Every operation is a click on a described node or
|
|
136
|
+
* a select on a list row; `draftText` is the one way message text enters,
|
|
137
|
+
* because a native editor reports text through the edit events itself.
|
|
138
|
+
*/
|
|
139
|
+
export class OnlyneViewer implements Component {
|
|
140
|
+
private snapshot: ViewerSnapshot | undefined;
|
|
141
|
+
private status: OnlyneStatus | undefined;
|
|
142
|
+
private loading = true;
|
|
143
|
+
private error: string | undefined;
|
|
144
|
+
/** The main column's subject; undefined shows the first thread. */
|
|
145
|
+
private view: View | undefined;
|
|
146
|
+
/** The domain the rail highlights; undefined follows this node's own region. */
|
|
147
|
+
private domainKey: string | undefined;
|
|
148
|
+
private memberKey: string | undefined;
|
|
149
|
+
/** An explicit recipient starts a new direct message. */
|
|
150
|
+
private target: string | undefined;
|
|
151
|
+
private replyTo: MessageRef | undefined;
|
|
152
|
+
/** The composer's text, mirrored from the editor so a rebuild keeps it. */
|
|
153
|
+
private draft = "";
|
|
154
|
+
private composer: Editor | undefined;
|
|
155
|
+
private sending = false;
|
|
156
|
+
/** The address card: a node typed by hand, and the one read in flight. */
|
|
157
|
+
private addressOpen = false;
|
|
158
|
+
private address: Editor | undefined;
|
|
159
|
+
private addressText = "";
|
|
160
|
+
private connecting = false;
|
|
161
|
+
/** The exchange whose fingerprint card is open; confirming is the session's own call. */
|
|
162
|
+
private exchange?: KeyExchange;
|
|
163
|
+
/** Only the newest request may open a card: a slower earlier lookup must not repaint it. */
|
|
164
|
+
private exchangeToken = 0;
|
|
165
|
+
/** Bumped to ask the terminal to scroll the message list to its newest row. */
|
|
166
|
+
private messageScroll = 0;
|
|
167
|
+
private messageScrollBy: NativeScroll["by"] = "end";
|
|
168
|
+
private notice: { text: string; error: boolean } | undefined;
|
|
169
|
+
private noticeTimer: Timer | undefined;
|
|
170
|
+
private unsubscribe: (() => void) | undefined;
|
|
171
|
+
private described: NativeNode | undefined;
|
|
172
|
+
private describedKey: string | undefined;
|
|
173
|
+
private rendered: { key: string; lines: readonly string[] } | undefined;
|
|
174
|
+
private version = 0;
|
|
175
|
+
private disposed = false;
|
|
176
|
+
private generation = 0;
|
|
177
|
+
focused = true;
|
|
178
|
+
// The four columns need the pane: a fixed `lg` sheet is about 68 cells and drops one of them.
|
|
179
|
+
readonly nativeOverlay = { role: "onlyne.viewer", size: "full" as const, head: "Onlyne" };
|
|
180
|
+
|
|
181
|
+
/** The text the composer holds; a native `edit`/`undo` event updates it through `handleNativeEvent`. */
|
|
182
|
+
get draftText(): string {
|
|
183
|
+
return this.draft;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
constructor(
|
|
187
|
+
private readonly theme: Theme,
|
|
188
|
+
private readonly requestRender: () => void,
|
|
189
|
+
private readonly done: () => void,
|
|
190
|
+
private readonly signal: AbortSignal,
|
|
191
|
+
private readonly rows: () => number = () => 40,
|
|
192
|
+
) {
|
|
193
|
+
void this.reload();
|
|
194
|
+
this.unsubscribe = getRuntime()?.onChange(() => {
|
|
195
|
+
if (this.disposed || this.signal.aborted) return;
|
|
196
|
+
void this.reload();
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private changed(): void {
|
|
201
|
+
if (this.disposed || this.signal.aborted) return;
|
|
202
|
+
this.version++;
|
|
203
|
+
this.described = undefined;
|
|
204
|
+
this.rendered = undefined;
|
|
205
|
+
this.requestRender();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async reload(): Promise<void> {
|
|
209
|
+
if (this.disposed || this.signal.aborted) return;
|
|
210
|
+
const generation = ++this.generation;
|
|
211
|
+
const runtime = getRuntime();
|
|
212
|
+
if (runtime === undefined) {
|
|
213
|
+
this.snapshot = undefined;
|
|
214
|
+
this.status = undefined;
|
|
215
|
+
this.loading = false;
|
|
216
|
+
this.error = t("error.notConfigured");
|
|
217
|
+
this.changed();
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
this.loading = this.snapshot === undefined;
|
|
221
|
+
try {
|
|
222
|
+
const [snapshot, status] = await Promise.all([runtime.snapshot(), runtime.status()]);
|
|
223
|
+
if (this.disposed || this.signal.aborted || generation !== this.generation) return;
|
|
224
|
+
this.snapshot = snapshot;
|
|
225
|
+
this.status = status;
|
|
226
|
+
if (this.domainKey === undefined) this.domainKey = snapshot.region;
|
|
227
|
+
this.error = undefined;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (this.signal.aborted || generation !== this.generation) return;
|
|
230
|
+
this.error = errorText(error);
|
|
231
|
+
} finally {
|
|
232
|
+
if (!this.disposed && !this.signal.aborted && generation === this.generation) {
|
|
233
|
+
this.loading = false;
|
|
234
|
+
this.changed();
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
private setNotice(text: string, error = false): void {
|
|
240
|
+
this.notice = { text, error };
|
|
241
|
+
clearTimeout(this.noticeTimer);
|
|
242
|
+
this.noticeTimer = setTimeout(() => {
|
|
243
|
+
this.noticeTimer = undefined;
|
|
244
|
+
this.notice = undefined;
|
|
245
|
+
this.changed();
|
|
246
|
+
}, 8000);
|
|
247
|
+
this.changed();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── data ────────────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
private threads(): ViewerThread[] {
|
|
253
|
+
const threads = this.snapshot?.threads ?? [];
|
|
254
|
+
return [...threads].sort((left, right) => {
|
|
255
|
+
const bySeq = Number(right.last_seq) - Number(left.last_seq);
|
|
256
|
+
if (Number.isFinite(bySeq) && bySeq !== 0) return bySeq;
|
|
257
|
+
return left.alias.localeCompare(right.alias);
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private selectedThread(): ViewerThread | undefined {
|
|
262
|
+
const threads = this.threads();
|
|
263
|
+
const view = this.view;
|
|
264
|
+
if (view?.kind === "thread") {
|
|
265
|
+
const named = threads.find(thread => thread.alias === view.alias);
|
|
266
|
+
if (named !== undefined) return named;
|
|
267
|
+
}
|
|
268
|
+
if (view?.kind === "inbox") return undefined;
|
|
269
|
+
// A message card's peer target names the conversation it belongs to.
|
|
270
|
+
if (this.target !== undefined) {
|
|
271
|
+
const live = threads.find(thread => thread.messages.some(message => message.author === this.target));
|
|
272
|
+
if (live !== undefined) return live;
|
|
273
|
+
}
|
|
274
|
+
return threads[0];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Key requests the peer already approved: the ones waiting for this user. */
|
|
278
|
+
private invites(): Invite[] {
|
|
279
|
+
return (this.snapshot?.trust ?? [])
|
|
280
|
+
.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);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Peers grouped by the domain they declare. Expired records stay in the
|
|
291
|
+
* list, flagged: the member column is the one place a user can revoke or
|
|
292
|
+
* retry a node whose observation aged out.
|
|
293
|
+
*/
|
|
294
|
+
private domainGroups(): DomainGroup[] {
|
|
295
|
+
const groups = new Map<string, DomainGroup>();
|
|
296
|
+
for (const record of this.snapshot?.discovery ?? []) {
|
|
297
|
+
if (record.node_id === this.status?.node_id) continue;
|
|
298
|
+
const key = groupKeyOf(record);
|
|
299
|
+
const group = groups.get(key) ?? {
|
|
300
|
+
key,
|
|
301
|
+
...(record.region === undefined ? {} : { id: record.region }),
|
|
302
|
+
label: groupLabelOf(record),
|
|
303
|
+
peers: [],
|
|
304
|
+
};
|
|
305
|
+
groups.set(key, group);
|
|
306
|
+
const trust = this.snapshot?.trust?.find(entry => entry.node_id === record.node_id);
|
|
307
|
+
group.peers.push({
|
|
308
|
+
record,
|
|
309
|
+
...(trust === undefined
|
|
310
|
+
? {}
|
|
311
|
+
: { trust: { phase: trust.phase, fingerprint: trust.fingerprint } }),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const own = this.snapshot?.region;
|
|
315
|
+
for (const group of groups.values()) {
|
|
316
|
+
group.peers.sort((left, right) => left.record.node_id.localeCompare(right.record.node_id));
|
|
317
|
+
}
|
|
318
|
+
return [...groups.values()].sort((left, right) => {
|
|
319
|
+
if (left.key === own) return -1;
|
|
320
|
+
if (right.key === own) return 1;
|
|
321
|
+
return left.label.localeCompare(right.label);
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
private currentDomain(): DomainGroup | undefined {
|
|
326
|
+
const groups = this.domainGroups();
|
|
327
|
+
if (this.domainKey !== undefined) {
|
|
328
|
+
const picked = groups.find(group => group.key === this.domainKey);
|
|
329
|
+
if (picked !== undefined) return picked;
|
|
330
|
+
}
|
|
331
|
+
return groups.find(group => group.key === this.snapshot?.region) ?? groups[0];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
private peers(): Peer[] {
|
|
335
|
+
return this.currentDomain()?.peers ?? [];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
private sendBlocked(): string | undefined {
|
|
339
|
+
if (this.error !== undefined) return this.error;
|
|
340
|
+
if (this.draft.trim() === "") return t("send.blocked.empty");
|
|
341
|
+
const thread = this.selectedThread();
|
|
342
|
+
// A joined thread is another node's: the viewer shows it and offers no write it cannot
|
|
343
|
+
// perform. A manual recipient starts a new message and is unaffected.
|
|
344
|
+
if (this.target === undefined && thread?.read_only === true) return t("send.blocked.readOnly");
|
|
345
|
+
if (this.target === undefined && this.replyTo === undefined && thread === undefined) {
|
|
346
|
+
return t("send.blocked.noTarget");
|
|
347
|
+
}
|
|
348
|
+
return undefined;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── recurring pieces ────────────────────────────────────────────────────
|
|
352
|
+
|
|
353
|
+
private button(label: string, act: string, options: { tone?: "accent" | "error"; title?: string } = {}): NativeNode {
|
|
354
|
+
return node(
|
|
355
|
+
"row",
|
|
356
|
+
// Tone stays on the glyph, never the row: `accent` is the terminal's button paint and
|
|
357
|
+
// reads as a selection highlight when spread over a whole row.
|
|
358
|
+
{ role: "omp.btn", align: "center", actions: { click: act }, title: options.title ?? label },
|
|
359
|
+
[textNode([span(label, options.tone === undefined ? undefined : options.tone === "error" ? "error" : "accent strong")])],
|
|
360
|
+
act,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** The dim one-line label that separates the sidebar's sections inside its scrolling list. */
|
|
365
|
+
private heading(label: string, count: number): NativeNode {
|
|
366
|
+
return node(
|
|
367
|
+
"item",
|
|
368
|
+
{
|
|
369
|
+
label: [span(label, "dim")],
|
|
370
|
+
...(count === 0 ? {} : { value: [span(String(count), "dim")] }),
|
|
371
|
+
disabled: true,
|
|
372
|
+
role: "onlyne.heading",
|
|
373
|
+
},
|
|
374
|
+
undefined,
|
|
375
|
+
`head:${label}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Label/value pairs as rows with a fixed label column. A `kv` grid sizes its
|
|
381
|
+
* label column in characters, so a Chinese label (two cells per character)
|
|
382
|
+
* leaves the value touching it and each row starting the value at a
|
|
383
|
+
* different column; a `ch` bound counts the cells the terminal draws.
|
|
384
|
+
*/
|
|
385
|
+
private facts(items: ReadonlyArray<{ k: string; v: string }>): NativeNode {
|
|
386
|
+
return node(
|
|
387
|
+
"col",
|
|
388
|
+
{ role: "onlyne.facts", gap: "xs", align: "stretch" },
|
|
389
|
+
items.map((item, index) =>
|
|
390
|
+
node(
|
|
391
|
+
"row",
|
|
392
|
+
{ gap: "none", align: "baseline" },
|
|
393
|
+
[
|
|
394
|
+
textNode([span(item.k, "dim")], { truncate: "end", min: { w: FACT_LABEL }, max: { w: FACT_LABEL } }),
|
|
395
|
+
textNode([span(item.v)], { wrap: "word", grow: 1, shrink: 1 }),
|
|
396
|
+
],
|
|
397
|
+
`fact-${index}`,
|
|
398
|
+
),
|
|
399
|
+
),
|
|
400
|
+
"facts",
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ── columns ─────────────────────────────────────────────────────────────
|
|
405
|
+
|
|
406
|
+
/** The domain rail: one letter per declared domain, with the count waiting on this user. */
|
|
407
|
+
private railNode(): NativeNode {
|
|
408
|
+
const groups = this.domainGroups();
|
|
409
|
+
const current = this.currentDomain();
|
|
410
|
+
const rows: NativeChild[] = groups.map(group => {
|
|
411
|
+
const waiting = group.peers.filter(peer => peer.trust?.phase === "invited").length;
|
|
412
|
+
const open = current?.key === group.key;
|
|
413
|
+
return node(
|
|
414
|
+
"item",
|
|
415
|
+
{
|
|
416
|
+
// The rail names the domain: one letter cannot be told apart from another, and
|
|
417
|
+
// the column width already truncates a long name.
|
|
418
|
+
label: [span(group.label, open ? "strong" : "muted")],
|
|
419
|
+
...(waiting === 0 ? {} : { value: [span(String(waiting), "error")] }),
|
|
420
|
+
...(open ? { tone: "accent" } : {}),
|
|
421
|
+
title: t("rail.domain.title", { label: group.label, count: group.peers.length }),
|
|
422
|
+
},
|
|
423
|
+
undefined,
|
|
424
|
+
`domain:${group.key}`,
|
|
425
|
+
);
|
|
426
|
+
});
|
|
427
|
+
return node(
|
|
428
|
+
"col",
|
|
429
|
+
// Wide enough for a one-word domain name (`daily-workbench` is 15 cells) so the rail
|
|
430
|
+
// names a domain instead of truncating it to a prefix two domains can share.
|
|
431
|
+
{ role: "onlyne.rail", gap: "none", min: { w: "16ch" }, max: { w: "22ch" } },
|
|
432
|
+
[
|
|
433
|
+
node(
|
|
434
|
+
"list",
|
|
435
|
+
{
|
|
436
|
+
role: "onlyne.rail.list",
|
|
437
|
+
grow: 1,
|
|
438
|
+
basis: 0,
|
|
439
|
+
selected: current === undefined ? null : `domain:${current.key}`,
|
|
440
|
+
empty: [span(t("rail.empty"), "muted")],
|
|
441
|
+
},
|
|
442
|
+
rows,
|
|
443
|
+
"domains",
|
|
444
|
+
),
|
|
445
|
+
],
|
|
446
|
+
"rail",
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* The thread sidebar: this node's identity, then every row that opens
|
|
452
|
+
* something, in one list that scrolls inside its own cap.
|
|
453
|
+
*/
|
|
454
|
+
private sidebar(): NativeNode {
|
|
455
|
+
const invites = this.invites();
|
|
456
|
+
const threads = this.threads();
|
|
457
|
+
const domain = this.currentDomain();
|
|
458
|
+
const fingerprint = this.snapshot?.identity?.fingerprint;
|
|
459
|
+
const queue = this.snapshot?.queue.length ?? 0;
|
|
460
|
+
const selected = this.selectedThread();
|
|
461
|
+
const rows: NativeChild[] = [];
|
|
462
|
+
|
|
463
|
+
rows.push(this.heading(t("sidebar.pending"), invites.length));
|
|
464
|
+
for (const invite of invites) {
|
|
465
|
+
rows.push(
|
|
466
|
+
node(
|
|
467
|
+
"item",
|
|
468
|
+
{
|
|
469
|
+
label: [span("▢ ", "warning"), span(shortNodeId(invite.node_id), "strong")],
|
|
470
|
+
value: [span(invite.fingerprint.slice(0, 8), "dim")],
|
|
471
|
+
tone: "warning",
|
|
472
|
+
title: t("sidebar.invite.title", { node: invite.node_id }),
|
|
473
|
+
},
|
|
474
|
+
undefined,
|
|
475
|
+
`invite:${invite.node_id}`,
|
|
476
|
+
),
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
rows.push(this.heading(t("sidebar.threads"), threads.length));
|
|
481
|
+
for (const thread of threads) {
|
|
482
|
+
const open = selected?.alias === thread.alias && this.view?.kind !== "inbox";
|
|
483
|
+
rows.push(
|
|
484
|
+
node(
|
|
485
|
+
"item",
|
|
486
|
+
{
|
|
487
|
+
label: [
|
|
488
|
+
span(`# ${thread.title}`, open ? "strong" : undefined),
|
|
489
|
+
...(thread.state === "closed" ? [span(` ${t("thread.closed")}`, "dim")] : []),
|
|
490
|
+
...(thread.read_only === true ? [span(` ${t("thread.readOnly")}`, "dim")] : []),
|
|
491
|
+
],
|
|
492
|
+
// One number per row, always the same number: the unread count. A row that
|
|
493
|
+
// showed the message total once everything was read made two rows of equal
|
|
494
|
+
// width mean different things.
|
|
495
|
+
value: thread.unread > 0 ? [span(String(thread.unread), "info")] : [],
|
|
496
|
+
title: t("sidebar.thread.title", {
|
|
497
|
+
title: thread.title,
|
|
498
|
+
count: thread.messages.length,
|
|
499
|
+
alias: shortAlias(thread.alias),
|
|
500
|
+
}),
|
|
501
|
+
},
|
|
502
|
+
undefined,
|
|
503
|
+
`thread:${thread.alias}`,
|
|
504
|
+
),
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
if (threads.length === 0) {
|
|
508
|
+
rows.push(node("item", { label: [span(t("sidebar.empty"), "muted")], disabled: true }, undefined, "head:empty-threads"));
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// The queue is its own section, so it needs no heading: the row names it and counts
|
|
512
|
+
// what opening it shows.
|
|
513
|
+
rows.push(
|
|
514
|
+
node(
|
|
515
|
+
"item",
|
|
516
|
+
{
|
|
517
|
+
label: [span(t("sidebar.inbox"), "muted")],
|
|
518
|
+
value: [span(String(queue), queue === 0 ? "dim" : "info")],
|
|
519
|
+
},
|
|
520
|
+
undefined,
|
|
521
|
+
"inbox",
|
|
522
|
+
),
|
|
523
|
+
);
|
|
524
|
+
|
|
525
|
+
return node(
|
|
526
|
+
"col",
|
|
527
|
+
{ role: "onlyne.threads", gap: "sm", align: "stretch", min: { w: "24ch" }, max: { w: "36ch" } },
|
|
528
|
+
[
|
|
529
|
+
node(
|
|
530
|
+
"col",
|
|
531
|
+
{ gap: "none" },
|
|
532
|
+
[
|
|
533
|
+
textNode([span(regionLabel(domain?.id ?? this.snapshot?.region), "dim")], { truncate: "end" }),
|
|
534
|
+
textNode([span(shortNodeId(this.status?.node_id ?? ""), "strong")], { truncate: "end" }),
|
|
535
|
+
textNode([span(fingerprint ?? t("sidebar.noKey"), "dim")], { truncate: "end" }),
|
|
536
|
+
],
|
|
537
|
+
"head",
|
|
538
|
+
),
|
|
539
|
+
node(
|
|
540
|
+
"list",
|
|
541
|
+
{
|
|
542
|
+
role: "onlyne.threads.list",
|
|
543
|
+
grow: 1,
|
|
544
|
+
basis: 0,
|
|
545
|
+
selected: this.view?.kind === "inbox" ? "inbox" : selected === undefined ? null : `thread:${selected.alias}`,
|
|
546
|
+
empty: [span(t("sidebar.empty"), "muted")],
|
|
547
|
+
},
|
|
548
|
+
rows,
|
|
549
|
+
"rows",
|
|
550
|
+
),
|
|
551
|
+
],
|
|
552
|
+
"threads",
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The approval bar: one row per pending request, with both answers on it. */
|
|
557
|
+
private approvalsCard(): NativeChild[] {
|
|
558
|
+
const invites = this.invites();
|
|
559
|
+
if (invites.length === 0) return [];
|
|
560
|
+
const mine = this.snapshot?.identity?.fingerprint ?? t("key.missing");
|
|
561
|
+
return invites.map(invite =>
|
|
562
|
+
node(
|
|
563
|
+
"card",
|
|
564
|
+
{
|
|
565
|
+
role: "onlyne.approval",
|
|
566
|
+
tone: "warning",
|
|
567
|
+
head: [span(t("approvals.head", { node: shortNodeId(invite.node_id) }), "strong")],
|
|
568
|
+
},
|
|
569
|
+
[
|
|
570
|
+
this.facts([
|
|
571
|
+
{ k: t("field.peerFingerprint"), v: invite.fingerprint },
|
|
572
|
+
{ k: t("field.ownFingerprint"), v: mine },
|
|
573
|
+
{ k: t("field.endpoint"), v: invite.base_url },
|
|
574
|
+
]),
|
|
575
|
+
node(
|
|
576
|
+
"row",
|
|
577
|
+
{ gap: "sm", align: "center" },
|
|
578
|
+
[
|
|
579
|
+
this.button(t("action.accept"), `onlyne.accept:${invite.node_id}`, { tone: "accent", title: t("approvals.accept.title") }),
|
|
580
|
+
this.button(t("action.reject"), `onlyne.reject:${invite.node_id}`, { title: t("approvals.reject.title") }),
|
|
581
|
+
this.button(t("action.details"), `onlyne.member:${invite.node_id}`, { title: t("approvals.details.title") }),
|
|
582
|
+
],
|
|
583
|
+
"acts",
|
|
584
|
+
),
|
|
585
|
+
],
|
|
586
|
+
`approval-${invite.node_id}`,
|
|
587
|
+
),
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private messageCard(message: ViewerMessage): NativeNode {
|
|
592
|
+
const out = message.direction === "out";
|
|
593
|
+
const messageKey = `${encodeURIComponent(message.ref.sender)}/${encodeURIComponent(message.ref.id)}`;
|
|
594
|
+
const answered = this.replyTo?.sender === message.ref.sender && this.replyTo.id === message.ref.id;
|
|
595
|
+
// A message is a flat entry, not a box: `section` gives the head and body placement of a
|
|
596
|
+
// card without its ring. A `list` row is one fixed line, so a multi-line message must not
|
|
597
|
+
// be the row itself.
|
|
598
|
+
return node(
|
|
599
|
+
"section",
|
|
600
|
+
{
|
|
601
|
+
role: "omp.btn",
|
|
602
|
+
...(answered ? { tone: "accent" } : {}),
|
|
603
|
+
head: [
|
|
604
|
+
span(out ? "→ " : "← ", out ? "accent" : "dim"),
|
|
605
|
+
span(plainAuthor(message.author), "strong"),
|
|
606
|
+
span(` ${clockText(message.at) || "—"}`, "dim"),
|
|
607
|
+
...(message.relation === undefined ? [] : [span(` ${message.relation}`, "dim")]),
|
|
608
|
+
],
|
|
609
|
+
title: t("message.reply.title", { seq: message.seq }),
|
|
610
|
+
actions: { click: `onlyne.message:${messageKey}` },
|
|
611
|
+
},
|
|
612
|
+
[textNode(message.text, { wrap: "word" })],
|
|
613
|
+
`message-${messageKey}`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
private composerNode(): NativeNode {
|
|
618
|
+
const thread = this.selectedThread();
|
|
619
|
+
const blocked = this.sendBlocked();
|
|
620
|
+
if (this.composer === undefined) {
|
|
621
|
+
const editor = new Editor(getEditorTheme());
|
|
622
|
+
editor.disableSubmit = true;
|
|
623
|
+
editor.setText(this.draft);
|
|
624
|
+
editor.setMaxHeight(3);
|
|
625
|
+
this.composer = editor;
|
|
626
|
+
}
|
|
627
|
+
// One caret at a time: the address card owns it while it is open.
|
|
628
|
+
this.composer.focused = this.focused && !this.addressOpen;
|
|
629
|
+
const where = this.replyTo !== undefined
|
|
630
|
+
? t("composer.reply", { node: shortNodeId(this.replyTo.sender), id: this.replyTo.id })
|
|
631
|
+
: this.target !== undefined
|
|
632
|
+
? t("composer.new", { node: shortNodeId(this.target) })
|
|
633
|
+
: thread === undefined
|
|
634
|
+
? t("send.blocked.noTarget")
|
|
635
|
+
: t("composer.thread", { title: thread.title, owner: shortNodeId(thread.ref.owner) });
|
|
636
|
+
return node(
|
|
637
|
+
"card",
|
|
638
|
+
// `bare` keeps the head-over-body grouping without a ring: the editor draws its own
|
|
639
|
+
// field, and a card ring around it reads as a box inside a box.
|
|
640
|
+
{ role: "onlyne.composer", inset: true, variant: "bare" },
|
|
641
|
+
[
|
|
642
|
+
this.composer,
|
|
643
|
+
node(
|
|
644
|
+
"row",
|
|
645
|
+
{ gap: "md", align: "center", justify: "between" },
|
|
646
|
+
[
|
|
647
|
+
textNode([span(where, blocked === undefined ? "dim" : "warning")], { wrap: "word", grow: 1 }),
|
|
648
|
+
...(this.replyTo === undefined ? [] : [this.button(t("composer.cancelReply"), "onlyne.reply.clear", { title: t("composer.cancelReply.title") })]),
|
|
649
|
+
...(this.target === undefined ? [] : [this.button(t("composer.back"), "onlyne.target:", { title: t("composer.back.title") })]),
|
|
650
|
+
node(
|
|
651
|
+
"row",
|
|
652
|
+
{
|
|
653
|
+
role: "omp.btn",
|
|
654
|
+
align: "center",
|
|
655
|
+
actions: { click: "onlyne.send" },
|
|
656
|
+
title: blocked ?? t("composer.send.title"),
|
|
657
|
+
},
|
|
658
|
+
[textNode([span(this.sending ? t("composer.sending") : t("composer.send"), blocked === undefined ? "accent strong" : "muted")])],
|
|
659
|
+
"send",
|
|
660
|
+
),
|
|
661
|
+
],
|
|
662
|
+
"row",
|
|
663
|
+
),
|
|
664
|
+
],
|
|
665
|
+
"composer",
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** The queue the inbox row opens: one scrolling list, grouped by what each row is. */
|
|
670
|
+
private inboxRows(): NativeNode {
|
|
671
|
+
const queue = this.snapshot?.queue ?? [];
|
|
672
|
+
const rows: NativeChild[] = [];
|
|
673
|
+
for (const [head, kind] of [
|
|
674
|
+
[t("inbox.heading.inbound"), "inbound"],
|
|
675
|
+
[t("inbox.heading.outbound"), "outbound"],
|
|
676
|
+
[t("inbox.heading.context"), "context"],
|
|
677
|
+
[t("inbox.heading.decision"), "decision"],
|
|
678
|
+
] as const) {
|
|
679
|
+
const items = queue.filter(entry => entry.kind === kind);
|
|
680
|
+
if (items.length === 0) continue;
|
|
681
|
+
rows.push(this.heading(head, items.length));
|
|
682
|
+
for (const entry of items) {
|
|
683
|
+
rows.push(
|
|
684
|
+
node(
|
|
685
|
+
"item",
|
|
686
|
+
{
|
|
687
|
+
label: [span(entry.label, "strong")],
|
|
688
|
+
detail: [span(oneLine(entry.detail), "dim")],
|
|
689
|
+
...(entry.at === undefined ? {} : { value: [span(ageText(entry.at), "dim")] }),
|
|
690
|
+
title: entry.detail,
|
|
691
|
+
},
|
|
692
|
+
undefined,
|
|
693
|
+
`queue:${entry.id}`,
|
|
694
|
+
),
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return node(
|
|
699
|
+
"list",
|
|
700
|
+
{
|
|
701
|
+
role: "onlyne.inbox.list",
|
|
702
|
+
grow: 1,
|
|
703
|
+
basis: 0,
|
|
704
|
+
empty: [span(t("inbox.empty"), "muted")],
|
|
705
|
+
},
|
|
706
|
+
rows,
|
|
707
|
+
"queue",
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** The main column: the open thread with its composer, or the queue. */
|
|
712
|
+
private flowNode(): NativeNode {
|
|
713
|
+
const children: NativeChild[] = [];
|
|
714
|
+
if (this.error !== undefined) {
|
|
715
|
+
children.push(textNode([span(this.error, "error")], { wrap: "word" }));
|
|
716
|
+
children.push(this.button(t("action.reload"), "onlyne.reload"));
|
|
717
|
+
}
|
|
718
|
+
if (this.notice !== undefined) {
|
|
719
|
+
children.push(textNode([span(this.notice.text, this.notice.error ? "error" : "info")], { wrap: "word" }));
|
|
720
|
+
}
|
|
721
|
+
if (this.exchange !== undefined) {
|
|
722
|
+
children.push(
|
|
723
|
+
node(
|
|
724
|
+
"card",
|
|
725
|
+
{ role: "onlyne.exchange", tone: "warning", head: [span(t("exchange.head", { node: shortNodeId(this.exchange.node_id) }), "strong")] },
|
|
726
|
+
[
|
|
727
|
+
this.facts([
|
|
728
|
+
{ k: t("field.peerNode"), v: this.exchange.node_id },
|
|
729
|
+
{ k: t("field.peerFingerprint"), v: this.exchange.fingerprint },
|
|
730
|
+
{ k: t("field.ownFingerprint"), v: this.snapshot?.identity?.fingerprint ?? t("key.missing") },
|
|
731
|
+
]),
|
|
732
|
+
node("row", { gap: "sm", align: "center" }, [
|
|
733
|
+
this.button(t("action.confirmExchange"), "onlyne.exchange.confirm", { tone: "accent" }),
|
|
734
|
+
this.button(t("action.cancel"), "onlyne.exchange.cancel"),
|
|
735
|
+
], "acts"),
|
|
736
|
+
],
|
|
737
|
+
"exchange",
|
|
738
|
+
),
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
if (this.view?.kind === "inbox") {
|
|
743
|
+
children.push(
|
|
744
|
+
node("row", { role: "onlyne.thread-head", gap: "md", align: "center", justify: "between" }, [
|
|
745
|
+
node("col", { gap: "none", grow: 1 }, [
|
|
746
|
+
textNode([span(t("inbox.title"), "strong")], { truncate: "end" }),
|
|
747
|
+
textNode([span(t("inbox.pending", { count: this.snapshot?.queue.length ?? 0 }), "dim")], { truncate: "end" }),
|
|
748
|
+
]),
|
|
749
|
+
], "thread-head"),
|
|
750
|
+
this.inboxRows(),
|
|
751
|
+
);
|
|
752
|
+
} else {
|
|
753
|
+
const thread = this.selectedThread();
|
|
754
|
+
if (thread === undefined) {
|
|
755
|
+
children.push(textNode([span(t("flow.empty"), "muted")], { wrap: "word", lines: 3 }));
|
|
756
|
+
} else {
|
|
757
|
+
const messages = thread.messages;
|
|
758
|
+
const last = messages.at(-1);
|
|
759
|
+
const lastLine = last === undefined
|
|
760
|
+
? ""
|
|
761
|
+
: t("thread.meta.last", { age: sinceText(last.at) || t("time.unknown") });
|
|
762
|
+
children.push(
|
|
763
|
+
node("row", { role: "onlyne.thread-head", gap: "md", align: "center", justify: "between" }, [
|
|
764
|
+
node("col", { gap: "none", grow: 1 }, [
|
|
765
|
+
textNode([
|
|
766
|
+
span(`# ${thread.title}`, "strong"),
|
|
767
|
+
...(thread.state === "closed" ? [span(` · ${t("thread.closed")}`, "dim")] : []),
|
|
768
|
+
...(thread.read_only === true ? [span(` · ${t("thread.readOnly")}`, "dim")] : []),
|
|
769
|
+
], { truncate: "end" }),
|
|
770
|
+
textNode([span(t("thread.meta", {
|
|
771
|
+
messages: messages.length,
|
|
772
|
+
speakers: new Set(messages.map(message => message.author)).size,
|
|
773
|
+
owner: shortNodeId(thread.ref.owner),
|
|
774
|
+
last: lastLine,
|
|
775
|
+
}), "dim")], { truncate: "end" }),
|
|
776
|
+
]),
|
|
777
|
+
], "thread-head"),
|
|
778
|
+
...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",
|
|
790
|
+
),
|
|
791
|
+
{ by: this.messageScrollBy, n: this.messageScroll },
|
|
792
|
+
),
|
|
793
|
+
this.composerNode(),
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
if (this.loading) children.push(node("spinner", { label: [span(t("loading"), "muted")] }, undefined, "loading"));
|
|
798
|
+
return node(
|
|
799
|
+
"col",
|
|
800
|
+
{ role: "onlyne.flow", gap: "sm", grow: 1, shrink: 1, align: "stretch", min: { w: 0 } },
|
|
801
|
+
children,
|
|
802
|
+
"flow",
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* A node reached by address instead of by discovery: mDNS never leaves the link, and a
|
|
808
|
+
* remote node on a public name has no advertisement at all. The address is read once and
|
|
809
|
+
* saved as a seed, so the next start finds the node again without this card.
|
|
810
|
+
*/
|
|
811
|
+
private addressCard(): NativeNode {
|
|
812
|
+
if (this.address === undefined) {
|
|
813
|
+
const editor = new Editor(getEditorTheme());
|
|
814
|
+
editor.disableSubmit = true;
|
|
815
|
+
editor.setText(this.addressText);
|
|
816
|
+
editor.setMaxHeight(1);
|
|
817
|
+
editor.onChange = () => {
|
|
818
|
+
this.addressText = editor.getExpandedText();
|
|
819
|
+
this.described = undefined;
|
|
820
|
+
this.requestRender();
|
|
821
|
+
};
|
|
822
|
+
this.address = editor;
|
|
823
|
+
}
|
|
824
|
+
this.address.focused = this.focused;
|
|
825
|
+
return node(
|
|
826
|
+
"card",
|
|
827
|
+
{ role: "onlyne.connect", inset: true, head: [span(t("connect.head"), "strong")] },
|
|
828
|
+
[
|
|
829
|
+
this.address,
|
|
830
|
+
textNode(
|
|
831
|
+
[span(t("connect.hint"), "dim")],
|
|
832
|
+
{ wrap: "word" },
|
|
833
|
+
),
|
|
834
|
+
node(
|
|
835
|
+
"row",
|
|
836
|
+
{ gap: "sm", align: "center", wrap: true },
|
|
837
|
+
[
|
|
838
|
+
this.button(this.connecting ? t("connect.connecting") : t("connect.submit"), "onlyne.connect.submit", {
|
|
839
|
+
tone: "accent",
|
|
840
|
+
title: t("connect.submit.title"),
|
|
841
|
+
}),
|
|
842
|
+
this.button(t("action.cancel"), "onlyne.connect.cancel", { title: t("connect.cancel.title") }),
|
|
843
|
+
],
|
|
844
|
+
"acts",
|
|
845
|
+
),
|
|
846
|
+
],
|
|
847
|
+
"connect",
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** The member column: the current domain's peers, then the selected one's detail. */
|
|
852
|
+
private memberColumn(): NativeNode {
|
|
853
|
+
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
|
+
});
|
|
869
|
+
const children: NativeChild[] = [
|
|
870
|
+
node("row", { gap: "sm", align: "center", justify: "between" }, [
|
|
871
|
+
textNode([span(t("members.head", { count: peers.length }), "dim")]),
|
|
872
|
+
node(
|
|
873
|
+
"row",
|
|
874
|
+
{ gap: "xs", align: "center" },
|
|
875
|
+
[
|
|
876
|
+
...(this.addressOpen ? [] : [this.button(t("members.add"), "onlyne.connect.open", { title: t("members.add.title") })]),
|
|
877
|
+
this.button(t("members.refresh"), "onlyne.discovery.refresh", { title: t("members.refresh.title") }),
|
|
878
|
+
],
|
|
879
|
+
"acts",
|
|
880
|
+
),
|
|
881
|
+
], "head"),
|
|
882
|
+
...(this.addressOpen ? [this.addressCard()] : []),
|
|
883
|
+
node(
|
|
884
|
+
"list",
|
|
885
|
+
{
|
|
886
|
+
role: "onlyne.members.list",
|
|
887
|
+
grow: 1,
|
|
888
|
+
basis: 0,
|
|
889
|
+
selected: this.memberKey === undefined ? null : `peer:${this.memberKey}`,
|
|
890
|
+
empty: [span(t("members.empty"), "muted")],
|
|
891
|
+
},
|
|
892
|
+
rows,
|
|
893
|
+
"roster",
|
|
894
|
+
),
|
|
895
|
+
];
|
|
896
|
+
const peer = peers.find(entry => entry.record.node_id === this.memberKey);
|
|
897
|
+
if (peer !== undefined) {
|
|
898
|
+
const phase = peer.trust?.phase;
|
|
899
|
+
const stale = Date.parse(peer.record.expires_at) <= Date.now();
|
|
900
|
+
children.push(
|
|
901
|
+
node(
|
|
902
|
+
"card",
|
|
903
|
+
{
|
|
904
|
+
role: "onlyne.member-detail",
|
|
905
|
+
head: [span(shortNodeId(peer.record.node_id), "strong"), ...(phase === undefined ? [] : [span(` · ${t(PHASE_LABEL[phase])}`, PHASE_TONE[phase])])],
|
|
906
|
+
},
|
|
907
|
+
[
|
|
908
|
+
this.facts([
|
|
909
|
+
{ k: t("field.fingerprint"), v: peer.trust?.fingerprint ?? t("member.fingerprint.none") },
|
|
910
|
+
{ k: t("field.domain"), v: regionLabel(peer.record.region) },
|
|
911
|
+
{ k: t("field.status"), v: phase === undefined ? t("trust.none") : t(PHASE_LABEL[phase]) },
|
|
912
|
+
{ k: t("field.endpoint"), v: peer.record.base_url },
|
|
913
|
+
{ k: t("field.source"), v: peer.record.source === "bootstrap" ? t("member.source.seed") : peer.record.source },
|
|
914
|
+
{
|
|
915
|
+
k: t("field.lastSeen"),
|
|
916
|
+
v: `${sinceText(peer.record.last_seen) || t("time.unknown")}${stale ? t("member.lastSeen.stale") : ""}`,
|
|
917
|
+
},
|
|
918
|
+
]),
|
|
919
|
+
node("row", { gap: "sm", align: "center", wrap: true }, [
|
|
920
|
+
this.button(t("action.useTarget"), `onlyne.target:${peer.record.node_id}`, { title: t("member.target.title") }),
|
|
921
|
+
...(phase === "invited" ? [this.button(t("action.acceptExchange"), `onlyne.accept:${peer.record.node_id}`, { tone: "accent" })] : []),
|
|
922
|
+
...(phase === undefined || phase === "revoked"
|
|
923
|
+
? [this.button(t("action.requestExchange"), `onlyne.request:${peer.record.node_id}`, { title: t("member.request.title") })]
|
|
924
|
+
: []),
|
|
925
|
+
...(phase === "exchanged" || phase === "requested" ? [this.button(t("action.revoke"), `onlyne.revoke:${peer.record.node_id}`, { tone: "error" })] : []),
|
|
926
|
+
], "acts"),
|
|
927
|
+
],
|
|
928
|
+
"member-detail",
|
|
929
|
+
),
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
return node(
|
|
933
|
+
"col",
|
|
934
|
+
{ role: "onlyne.members", gap: "sm", align: "stretch", min: { w: "24ch" }, max: { w: "34ch" } },
|
|
935
|
+
children,
|
|
936
|
+
"members",
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
describe(cx: DescribeContext): NativeNode {
|
|
941
|
+
const key = `${this.version}:${cx.cols}:${this.rows()}`;
|
|
942
|
+
if (this.described !== undefined && this.describedKey === key) return this.described;
|
|
943
|
+
// The full sheet draws a title row and one inset row around the body: bound the columns
|
|
944
|
+
// to the rest of the pane, or the composer lands past the bottom edge.
|
|
945
|
+
const body = linesOf(Math.max(8, this.rows() - 2));
|
|
946
|
+
const columns = node(
|
|
947
|
+
"row",
|
|
948
|
+
{ gap: "md", align: "stretch", grow: 1, shrink: 1, min: { h: body }, max: { h: body } },
|
|
949
|
+
[this.railNode(), this.sidebar(), this.flowNode(), this.memberColumn()],
|
|
950
|
+
"columns",
|
|
951
|
+
);
|
|
952
|
+
this.described = node("card", { role: "onlyne.viewer", grow: 1 }, [columns], "root");
|
|
953
|
+
this.describedKey = key;
|
|
954
|
+
return this.described;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ── events ──────────────────────────────────────────────────────────────
|
|
958
|
+
|
|
959
|
+
/** A row of the sidebar or the member list: its id names what the click opens. */
|
|
960
|
+
private selectRow(id: string): void {
|
|
961
|
+
if (id.startsWith("thread:")) {
|
|
962
|
+
const alias = id.slice("thread:".length);
|
|
963
|
+
if (!this.threads().some(thread => thread.alias === alias)) return;
|
|
964
|
+
this.view = { kind: "thread", alias };
|
|
965
|
+
this.target = undefined;
|
|
966
|
+
this.replyTo = undefined;
|
|
967
|
+
this.messageScrollBy = "end";
|
|
968
|
+
this.messageScroll++;
|
|
969
|
+
} else if (id.startsWith("invite:")) {
|
|
970
|
+
this.memberKey = id.slice("invite:".length);
|
|
971
|
+
} else if (id.startsWith("domain:")) {
|
|
972
|
+
this.domainKey = id.slice("domain:".length);
|
|
973
|
+
this.memberKey = undefined;
|
|
974
|
+
} else if (id.startsWith("peer:")) {
|
|
975
|
+
const peer = id.slice("peer:".length);
|
|
976
|
+
this.memberKey = this.memberKey === peer ? undefined : peer;
|
|
977
|
+
} else if (id === "inbox") {
|
|
978
|
+
this.view = { kind: "inbox" };
|
|
979
|
+
} else {
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
this.changed();
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
handleNativeEvent(event: NativeUiEvent): void {
|
|
986
|
+
if (this.disposed || this.signal.aborted) return;
|
|
987
|
+
if (event.type === "edit" || event.type === "undo") {
|
|
988
|
+
if (this.addressOpen) {
|
|
989
|
+
this.address?.handleNativeEvent(event);
|
|
990
|
+
this.addressText = this.address?.getExpandedText() ?? this.addressText;
|
|
991
|
+
} else {
|
|
992
|
+
this.composer?.handleNativeEvent(event);
|
|
993
|
+
this.draft = this.composer?.getExpandedText() ?? this.draft;
|
|
994
|
+
}
|
|
995
|
+
this.described = undefined;
|
|
996
|
+
this.requestRender();
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
// The keypath carries the component's ancestry (`root/columns/sidebar/rows`), so match its leaf.
|
|
1000
|
+
if (event.type === "select" || event.type === "activate") {
|
|
1001
|
+
const leaf = event.key.slice(event.key.lastIndexOf("/") + 1);
|
|
1002
|
+
if (leaf === "rows" || leaf === "roster" || leaf === "domains") this.selectRow(event.item);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (event.type !== "action") return;
|
|
1006
|
+
const act = event.act;
|
|
1007
|
+
if (act === "onlyne.close") {
|
|
1008
|
+
this.done();
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
if (act === "onlyne.reload") {
|
|
1012
|
+
void this.reload();
|
|
1013
|
+
return;
|
|
1014
|
+
}
|
|
1015
|
+
if (act === "onlyne.send") {
|
|
1016
|
+
void this.send();
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
if (act === "onlyne.reply.clear") {
|
|
1020
|
+
this.replyTo = undefined;
|
|
1021
|
+
this.changed();
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (act === "onlyne.discovery.refresh") {
|
|
1025
|
+
void this.refreshDiscovery();
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (act === "onlyne.connect.open") {
|
|
1029
|
+
this.addressOpen = true;
|
|
1030
|
+
this.changed();
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
if (act === "onlyne.connect.cancel") {
|
|
1034
|
+
this.closeAddress();
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
if (act === "onlyne.connect.submit") {
|
|
1038
|
+
void this.connect();
|
|
1039
|
+
return;
|
|
1040
|
+
}
|
|
1041
|
+
if (act === "onlyne.exchange.confirm") {
|
|
1042
|
+
void this.confirmExchange();
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
if (act === "onlyne.exchange.cancel") {
|
|
1046
|
+
this.exchangeToken++;
|
|
1047
|
+
this.exchange = undefined;
|
|
1048
|
+
this.changed();
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
if (act.startsWith("onlyne.domain:")) {
|
|
1052
|
+
this.domainKey = act.slice("onlyne.domain:".length);
|
|
1053
|
+
this.memberKey = undefined;
|
|
1054
|
+
this.changed();
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
if (act.startsWith("onlyne.member:")) {
|
|
1058
|
+
const id = act.slice("onlyne.member:".length);
|
|
1059
|
+
this.memberKey = this.memberKey === id ? undefined : id;
|
|
1060
|
+
this.changed();
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (act.startsWith("onlyne.target:")) {
|
|
1064
|
+
const id = act.slice("onlyne.target:".length);
|
|
1065
|
+
this.target = id === "" || this.target === id ? undefined : id;
|
|
1066
|
+
this.replyTo = undefined;
|
|
1067
|
+
this.changed();
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (act.startsWith("onlyne.message:")) {
|
|
1071
|
+
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();
|
|
1079
|
+
}
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
if (act.startsWith("onlyne.accept:")) {
|
|
1083
|
+
void this.accept(act.slice("onlyne.accept:".length));
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
if (act.startsWith("onlyne.reject:")) {
|
|
1087
|
+
// The runtime's only refusal is a revocation, which leaves the peer able to ask again.
|
|
1088
|
+
void this.revoke(act.slice("onlyne.reject:".length), true);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (act.startsWith("onlyne.revoke:")) {
|
|
1092
|
+
void this.revoke(act.slice("onlyne.revoke:".length));
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (act.startsWith("onlyne.request:")) {
|
|
1096
|
+
void this.request(act.slice("onlyne.request:".length));
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** Escape closes the overlay; the page keys scroll the open list; everything else is the composer's. */
|
|
1101
|
+
handleInput(data: string): void {
|
|
1102
|
+
if (this.disposed || this.signal.aborted) return;
|
|
1103
|
+
if (matchesKey(data, "escape")) {
|
|
1104
|
+
if (this.addressOpen) {
|
|
1105
|
+
this.closeAddress();
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
this.done();
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (this.addressOpen) {
|
|
1112
|
+
// A single-line field: Enter is the one way to submit it from the keyboard.
|
|
1113
|
+
if (matchesKey(data, "enter")) {
|
|
1114
|
+
void this.connect();
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
this.address?.handleInput(data);
|
|
1118
|
+
this.addressText = this.address?.getExpandedText() ?? this.addressText;
|
|
1119
|
+
this.described = undefined;
|
|
1120
|
+
this.requestRender();
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (matchesKey(data, "ctrl+enter")) {
|
|
1124
|
+
void this.send();
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
if (matchesKey(data, "pageUp") || matchesKey(data, "pageDown") || matchesKey(data, "end")) {
|
|
1128
|
+
this.messageScrollBy = matchesKey(data, "pageUp") ? "page-up" : matchesKey(data, "pageDown") ? "page-down" : "end";
|
|
1129
|
+
this.messageScroll++;
|
|
1130
|
+
this.changed();
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
if (this.composer === undefined) return;
|
|
1134
|
+
this.composer.handleInput(data);
|
|
1135
|
+
this.draft = this.composer.getExpandedText();
|
|
1136
|
+
this.described = undefined;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
private async refreshDiscovery(): Promise<void> {
|
|
1140
|
+
const runtime = getRuntime();
|
|
1141
|
+
if (runtime === undefined) return;
|
|
1142
|
+
this.setNotice(t("notice.refreshing"));
|
|
1143
|
+
try {
|
|
1144
|
+
await runtime.refreshDiscovery();
|
|
1145
|
+
this.setNotice(t("notice.refreshed"));
|
|
1146
|
+
} catch (error) {
|
|
1147
|
+
this.setNotice(t("notice.refreshFailed", { error: errorText(error) }), true);
|
|
1148
|
+
}
|
|
1149
|
+
await this.reload();
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
private closeAddress(): void {
|
|
1153
|
+
this.addressOpen = false;
|
|
1154
|
+
this.address = undefined;
|
|
1155
|
+
this.addressText = "";
|
|
1156
|
+
this.changed();
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Reads the typed address through the runtime, which owns normalization, the discovery
|
|
1161
|
+
* read and the seed write. The node keeps running; the new peer arrives in the member
|
|
1162
|
+
* list, where the ordinary exchange request applies to it.
|
|
1163
|
+
*/
|
|
1164
|
+
private async connect(): Promise<void> {
|
|
1165
|
+
if (this.connecting) return;
|
|
1166
|
+
const runtime = getRuntime();
|
|
1167
|
+
if (runtime === undefined) {
|
|
1168
|
+
this.setNotice(t("notice.notRunning"), true);
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
const typed = this.addressText.trim();
|
|
1172
|
+
if (typed.length === 0) {
|
|
1173
|
+
this.setNotice(t("notice.addressEmpty"), true);
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
this.connecting = true;
|
|
1177
|
+
this.changed();
|
|
1178
|
+
let seed: string | undefined;
|
|
1179
|
+
try {
|
|
1180
|
+
seed = normalizeServiceUrl(typed);
|
|
1181
|
+
await runtime.contact(typed);
|
|
1182
|
+
this.setNotice(t("notice.connected", { seed }));
|
|
1183
|
+
this.closeAddress();
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
seed = undefined;
|
|
1186
|
+
this.setNotice(t("notice.connectFailed", { error: errorText(error) }), true);
|
|
1187
|
+
} finally {
|
|
1188
|
+
this.connecting = false;
|
|
1189
|
+
await this.reload();
|
|
1190
|
+
}
|
|
1191
|
+
if (seed === undefined) return;
|
|
1192
|
+
// Land on the new peer, in its own domain: its detail card is where the exchange starts.
|
|
1193
|
+
const target = seed;
|
|
1194
|
+
const record = this.snapshot?.discovery.find(entry => entry.base_url === target);
|
|
1195
|
+
if (record === undefined) return;
|
|
1196
|
+
this.domainKey = groupKeyOf(record);
|
|
1197
|
+
this.memberKey = record.node_id;
|
|
1198
|
+
this.changed();
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private async accept(nodeId: string): Promise<void> {
|
|
1202
|
+
const runtime = getRuntime();
|
|
1203
|
+
const invite = this.invites().find(entry => entry.node_id === nodeId);
|
|
1204
|
+
if (runtime === undefined || invite === undefined) return;
|
|
1205
|
+
try {
|
|
1206
|
+
await runtime.trustAccept(nodeId, invite.fingerprint);
|
|
1207
|
+
this.setNotice(t("notice.accepted", { node: shortNodeId(nodeId) }));
|
|
1208
|
+
} catch (error) {
|
|
1209
|
+
this.setNotice(t("notice.acceptFailed", { error: errorText(error) }), true);
|
|
1210
|
+
}
|
|
1211
|
+
await this.reload();
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/** `refused` distinguishes a rejection of the peer's request from a revocation of trust. */
|
|
1215
|
+
private async revoke(nodeId: string, refused = false): Promise<void> {
|
|
1216
|
+
const runtime = getRuntime();
|
|
1217
|
+
if (runtime === undefined) return;
|
|
1218
|
+
try {
|
|
1219
|
+
await runtime.trustRevoke(nodeId);
|
|
1220
|
+
this.setNotice(refused
|
|
1221
|
+
? t("notice.rejected", { node: shortNodeId(nodeId) })
|
|
1222
|
+
: t("notice.revoked", { node: shortNodeId(nodeId) }));
|
|
1223
|
+
} catch (error) {
|
|
1224
|
+
this.setNotice(t("notice.failed", { verb: t(refused ? "verb.reject" : "verb.revoke"), error: errorText(error) }), true);
|
|
1225
|
+
}
|
|
1226
|
+
await this.reload();
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
private async request(nodeId: string): Promise<void> {
|
|
1230
|
+
const runtime = getRuntime();
|
|
1231
|
+
if (runtime === undefined) return;
|
|
1232
|
+
const token = ++this.exchangeToken;
|
|
1233
|
+
this.exchange = undefined;
|
|
1234
|
+
this.changed();
|
|
1235
|
+
try {
|
|
1236
|
+
const session = await runtime.trustBegin(nodeId);
|
|
1237
|
+
if (this.disposed || this.signal.aborted || token !== this.exchangeToken) return;
|
|
1238
|
+
this.exchange = session;
|
|
1239
|
+
this.changed();
|
|
1240
|
+
} catch (error) {
|
|
1241
|
+
if (token !== this.exchangeToken) return;
|
|
1242
|
+
this.setNotice(t("notice.requestFailed", { error: errorText(error) }), true);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
private async confirmExchange(): Promise<void> {
|
|
1247
|
+
const session = this.exchange;
|
|
1248
|
+
if (session === undefined) return;
|
|
1249
|
+
this.exchange = undefined;
|
|
1250
|
+
this.changed();
|
|
1251
|
+
try {
|
|
1252
|
+
await session.confirm(session.fingerprint);
|
|
1253
|
+
this.setNotice(t("notice.exchangeSent", { node: shortNodeId(session.node_id) }));
|
|
1254
|
+
} catch (error) {
|
|
1255
|
+
this.setNotice(t("notice.requestFailed", { error: errorText(error) }), true);
|
|
1256
|
+
}
|
|
1257
|
+
await this.reload();
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
/**
|
|
1261
|
+
* The composer sends down the one message path the tool uses: the node's own
|
|
1262
|
+
* client with this session as the consumption context, so a click and a `msg`
|
|
1263
|
+
* call leave the same records. A reply names the message it answers, which is
|
|
1264
|
+
* what lands it in the thread holding that message.
|
|
1265
|
+
*/
|
|
1266
|
+
private async send(): Promise<void> {
|
|
1267
|
+
if (this.sending) return;
|
|
1268
|
+
const runtime = getRuntime();
|
|
1269
|
+
const blocked = this.sendBlocked();
|
|
1270
|
+
if (blocked !== undefined) {
|
|
1271
|
+
this.setNotice(blocked, true);
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
if (runtime === undefined) return;
|
|
1275
|
+
const thread = this.selectedThread();
|
|
1276
|
+
const meta: NonNullable<MsgInput["meta"]> = {};
|
|
1277
|
+
if (this.replyTo !== undefined) {
|
|
1278
|
+
meta.in_reply_to = { sender: this.replyTo.sender, id: this.replyTo.id };
|
|
1279
|
+
meta.relation = "reply";
|
|
1280
|
+
} else if (this.target !== undefined) {
|
|
1281
|
+
meta.to = this.target;
|
|
1282
|
+
} else if (thread !== undefined) {
|
|
1283
|
+
meta.to = thread.ref.owner;
|
|
1284
|
+
meta.thread = { owner: thread.ref.owner, id: thread.ref.id };
|
|
1285
|
+
}
|
|
1286
|
+
const input: MsgInput = {
|
|
1287
|
+
delivery_kind: this.replyTo !== undefined
|
|
1288
|
+
? t("delivery.reply", { node: shortNodeId(this.replyTo.sender) })
|
|
1289
|
+
: thread === undefined
|
|
1290
|
+
? t("delivery.to", { node: shortNodeId(this.target ?? "") })
|
|
1291
|
+
: t("delivery.thread", { title: thread.title }),
|
|
1292
|
+
msg: this.draft.trim(),
|
|
1293
|
+
...(Object.keys(meta).length === 0 ? {} : { meta }),
|
|
1294
|
+
};
|
|
1295
|
+
this.sending = true;
|
|
1296
|
+
this.changed();
|
|
1297
|
+
try {
|
|
1298
|
+
// The library decides every send through the plugin, and the plugin only decides inside
|
|
1299
|
+
// a bound scope: without one the composer reported a send failure (audit §2.3). The
|
|
1300
|
+
// panel is the main session's input, so it carries the main session's scope.
|
|
1301
|
+
const outcome = (await runDecision({ ctx: runtime.main, input }, () =>
|
|
1302
|
+
runtime.node.client.msg(input, { context: runtime.mainAgentId }))) as SendOutcome;
|
|
1303
|
+
this.setNotice(this.describeOutcome(outcome), outcome.admission === "refused" || outcome.error !== undefined);
|
|
1304
|
+
if (outcome.admission === "accepted" || outcome.admission === "unknown") {
|
|
1305
|
+
this.draft = "";
|
|
1306
|
+
this.composer?.setText("");
|
|
1307
|
+
this.replyTo = undefined;
|
|
1308
|
+
this.messageScrollBy = "end";
|
|
1309
|
+
this.messageScroll++;
|
|
1310
|
+
}
|
|
1311
|
+
} catch (error) {
|
|
1312
|
+
this.setNotice(t("notice.sendFailed", { error: errorText(error) }), true);
|
|
1313
|
+
} finally {
|
|
1314
|
+
this.sending = false;
|
|
1315
|
+
await this.reload();
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
private describeOutcome(outcome: SendOutcome): string {
|
|
1320
|
+
if (outcome.admission === "refused") {
|
|
1321
|
+
return t("outcome.refused", { error: outcome.error?.message ?? t("outcome.refused.peer") });
|
|
1322
|
+
}
|
|
1323
|
+
const reply = outcome.reply?.message.msg;
|
|
1324
|
+
if (reply !== undefined) return t("outcome.replied", { reply: oneLine(reply) || t("outcome.reply.empty") });
|
|
1325
|
+
if (outcome.admission === "accepted") return t("outcome.accepted");
|
|
1326
|
+
return t("outcome.unknown");
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
/** The ANSI fallback for a terminal without the native surface: a listing, no interaction. */
|
|
1330
|
+
render(width: number): readonly string[] {
|
|
1331
|
+
const key = `${this.version}:${width}`;
|
|
1332
|
+
if (this.rendered !== undefined && this.rendered.key === key) return this.rendered.lines;
|
|
1333
|
+
const status = this.status;
|
|
1334
|
+
const lines: string[] = [
|
|
1335
|
+
fit(
|
|
1336
|
+
t("fallback.status", {
|
|
1337
|
+
region: regionLabel(status?.region),
|
|
1338
|
+
nodes: status?.nodes ?? "…",
|
|
1339
|
+
neighbors: status?.neighbors ?? "…",
|
|
1340
|
+
alerts: status?.alerts ?? 0,
|
|
1341
|
+
unread: status?.unread ?? 0,
|
|
1342
|
+
}),
|
|
1343
|
+
width,
|
|
1344
|
+
),
|
|
1345
|
+
];
|
|
1346
|
+
if (this.error !== undefined) lines.push(this.theme.fg("error", fit(this.error, width)));
|
|
1347
|
+
for (const line of this.ansiRows(width)) lines.push(line);
|
|
1348
|
+
this.rendered = { key, lines };
|
|
1349
|
+
return this.rendered.lines;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
private ansiRows(width: number): string[] {
|
|
1353
|
+
const lines: string[] = [];
|
|
1354
|
+
const short = (text: string): string => fit(text, width);
|
|
1355
|
+
if (this.view?.kind === "inbox") {
|
|
1356
|
+
lines.push(short(t("fallback.inbox", { count: this.snapshot?.queue.length ?? 0 })));
|
|
1357
|
+
for (const entry of this.snapshot?.queue ?? []) {
|
|
1358
|
+
lines.push(short(` ${entry.kind} ${entry.label} · ${oneLine(entry.detail)}`));
|
|
1359
|
+
}
|
|
1360
|
+
return lines;
|
|
1361
|
+
}
|
|
1362
|
+
const thread = this.selectedThread();
|
|
1363
|
+
lines.push(short(t("fallback.threads", {
|
|
1364
|
+
count: this.threads().length,
|
|
1365
|
+
title: thread === undefined ? t("fallback.none") : thread.title,
|
|
1366
|
+
})));
|
|
1367
|
+
for (const invite of this.invites()) {
|
|
1368
|
+
lines.push(short(t("fallback.invite", { node: shortNodeId(invite.node_id), fingerprint: invite.fingerprint })));
|
|
1369
|
+
}
|
|
1370
|
+
for (const message of thread?.messages ?? []) {
|
|
1371
|
+
lines.push(short(`${message.direction === "out" ? "→" : "←"} [${message.seq}] ${plainAuthor(message.author)} ${clockText(message.at)}`));
|
|
1372
|
+
for (const line of message.text.split("\n").slice(0, 6)) lines.push(short(` ${line}`));
|
|
1373
|
+
}
|
|
1374
|
+
if (this.addressOpen) {
|
|
1375
|
+
lines.push(short(t("fallback.address", { address: oneLine(this.addressText) || t("fallback.empty") })));
|
|
1376
|
+
}
|
|
1377
|
+
const blocked = this.sendBlocked();
|
|
1378
|
+
lines.push(short(t("fallback.draft", {
|
|
1379
|
+
text: oneLine(this.draft) || t("fallback.empty"),
|
|
1380
|
+
blocked: blocked === undefined ? "" : ` · ${blocked}`,
|
|
1381
|
+
})));
|
|
1382
|
+
return lines;
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
invalidate(): void {
|
|
1386
|
+
this.version++;
|
|
1387
|
+
this.described = undefined;
|
|
1388
|
+
this.rendered = undefined;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
dispose(): void {
|
|
1392
|
+
if (this.disposed) return;
|
|
1393
|
+
this.disposed = true;
|
|
1394
|
+
clearTimeout(this.noticeTimer);
|
|
1395
|
+
this.noticeTimer = undefined;
|
|
1396
|
+
this.unsubscribe?.();
|
|
1397
|
+
this.unsubscribe = undefined;
|
|
1398
|
+
this.composer = undefined;
|
|
1399
|
+
this.address = undefined;
|
|
1400
|
+
this.described = undefined;
|
|
1401
|
+
this.rendered = undefined;
|
|
1402
|
+
}
|
|
1403
|
+
}
|