ciphermesh 1.0.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/LICENSE +21 -0
- package/README.md +251 -0
- package/README.pt-BR.md +253 -0
- package/bin/ciphermesh.js +34 -0
- package/docs/ARCHITECTURE.md +1188 -0
- package/docs/SETUP.md +305 -0
- package/docs/demo.svg +46 -0
- package/package.json +87 -0
- package/src/client/ChatController.js +2476 -0
- package/src/client/Connection.js +129 -0
- package/src/client/FileTransfer.js +488 -0
- package/src/client/ImagePreview.js +88 -0
- package/src/client/UI.js +1830 -0
- package/src/client/index.js +231 -0
- package/src/crypto/CertPinStore.js +79 -0
- package/src/crypto/DeniableEncrypt.js +53 -0
- package/src/crypto/DoubleRatchet.js +574 -0
- package/src/crypto/Handshake.js +219 -0
- package/src/crypto/HistoryStore.js +241 -0
- package/src/crypto/IdentityBackup.js +70 -0
- package/src/crypto/KeyManager.js +134 -0
- package/src/crypto/MessageCrypto.js +181 -0
- package/src/crypto/NonceManager.js +72 -0
- package/src/crypto/SealedSender.js +58 -0
- package/src/crypto/SenderKey.js +204 -0
- package/src/crypto/StateManager.js +138 -0
- package/src/crypto/TrustStore.js +216 -0
- package/src/p2p/Discovery.js +80 -0
- package/src/p2p/P2PChatController.js +1856 -0
- package/src/p2p/PeerConnectionManager.js +252 -0
- package/src/p2p/PeerServer.js +68 -0
- package/src/p2p/index.js +219 -0
- package/src/protocol/messages.js +138 -0
- package/src/protocol/validators.js +175 -0
- package/src/server/CertManager.js +173 -0
- package/src/server/MessageRouter.js +80 -0
- package/src/server/OfflineQueue.js +124 -0
- package/src/server/SessionManager.js +296 -0
- package/src/server/WebSocketServer.js +632 -0
- package/src/server/index.js +89 -0
- package/src/shared/AuditLog.js +91 -0
- package/src/shared/PluginManager.js +83 -0
- package/src/shared/banner.js +271 -0
- package/src/shared/commandSuggest.js +59 -0
- package/src/shared/config.js +90 -0
- package/src/shared/constants.js +126 -0
- package/src/shared/coverTraffic.js +34 -0
- package/src/shared/dnd.js +60 -0
- package/src/shared/emoji.js +17 -0
- package/src/shared/fuzzy.js +40 -0
- package/src/shared/invite.js +61 -0
- package/src/shared/keyArt.js +66 -0
- package/src/shared/logger.js +38 -0
- package/src/shared/panic.js +38 -0
- package/src/shared/prompt.js +31 -0
- package/src/shared/terminalGraphics.js +72 -0
- package/src/shared/themes.js +36 -0
- package/src/shared/voiceNote.js +128 -0
package/src/client/UI.js
ADDED
|
@@ -0,0 +1,1830 @@
|
|
|
1
|
+
import blessed from 'blessed';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { shortcodeSuggestions } from '../shared/emoji.js';
|
|
4
|
+
import { nickPalette } from '../shared/themes.js';
|
|
5
|
+
import { fuzzyFilter } from '../shared/fuzzy.js';
|
|
6
|
+
import { EMOJI_MAP } from '../shared/constants.js';
|
|
7
|
+
|
|
8
|
+
const EMOJI_ENTRIES = Object.entries(EMOJI_MAP); // [':name:', '๐']
|
|
9
|
+
|
|
10
|
+
const NICK_AVATARS = ['๐', '๐', '๐ค ', '๐ค', '๐ป', '๐ฝ', '๐ฆ', '๐ผ', '๐ธ', '๐ฆ', '๐', '๐ง'];
|
|
11
|
+
const TYPING_DOTS = ['', '.', '..', '...'];
|
|
12
|
+
const SPINNER_FRAMES = ['โ ', 'โ ', 'โ น', 'โ ธ', 'โ ผ', 'โ ด', 'โ ฆ', 'โ ง', 'โ ', 'โ '];
|
|
13
|
+
const INPUT_MAX_LINES = 8; // input box grows up to this many text lines
|
|
14
|
+
|
|
15
|
+
// Command โ one-line description for the Ctrl+K fuzzy command palette.
|
|
16
|
+
const COMMAND_INFO = [
|
|
17
|
+
['/help', 'Show help'],
|
|
18
|
+
['/users', 'List online users'],
|
|
19
|
+
['/msg', 'Private message (DM)'],
|
|
20
|
+
['/reply', 'Reply to the last message'],
|
|
21
|
+
['/away', 'Mark yourself as away'],
|
|
22
|
+
['/back', 'Clear away status'],
|
|
23
|
+
['/status', 'Set a status'],
|
|
24
|
+
['/join', 'Join a room'],
|
|
25
|
+
['/rooms', 'List rooms'],
|
|
26
|
+
['/room', 'Show the current room'],
|
|
27
|
+
['/invite', 'Generate an invite with QR'],
|
|
28
|
+
['/fingerprint', 'Show fingerprint'],
|
|
29
|
+
['/verify', 'SAS verification code'],
|
|
30
|
+
['/trust', "Accept a peer's new key"],
|
|
31
|
+
['/trustlist', 'Trust status'],
|
|
32
|
+
['/clear', 'Clear the chat'],
|
|
33
|
+
['/file', 'Send a file'],
|
|
34
|
+
['/voice', 'Record and send a voice note'],
|
|
35
|
+
['/play', 'Play the last voice note'],
|
|
36
|
+
['/img', 'Render a high-resolution image'],
|
|
37
|
+
['/sound', 'Sound notifications'],
|
|
38
|
+
['/notify', 'Desktop notifications'],
|
|
39
|
+
['/search', 'Search history'],
|
|
40
|
+
['/history', 'Recent messages from history'],
|
|
41
|
+
['/export', 'Export history'],
|
|
42
|
+
['/backup', 'Back up identity + trust'],
|
|
43
|
+
['/retention', 'Local history retention'],
|
|
44
|
+
['/audit', 'Audit events'],
|
|
45
|
+
['/ephemeral', 'Ephemeral messages'],
|
|
46
|
+
['/react', 'React to a message'],
|
|
47
|
+
['/edit', 'Edit the last sent message'],
|
|
48
|
+
['/delete', 'Delete the last sent message'],
|
|
49
|
+
['/pin', 'Pin a message'],
|
|
50
|
+
['/unpin', 'Unpin a message'],
|
|
51
|
+
['/pins', 'List pinned messages'],
|
|
52
|
+
['/deniable', 'Deniable mode'],
|
|
53
|
+
['/receipts', 'Read receipts'],
|
|
54
|
+
['/cover', 'Cover traffic (anti-metadata)'],
|
|
55
|
+
['/theme', 'Nick color theme'],
|
|
56
|
+
['/panic', 'Wipe everything from disk and exit (duress)'],
|
|
57
|
+
['/kick', 'Kick a user (owner)'],
|
|
58
|
+
['/mute', 'Mute a user (owner)'],
|
|
59
|
+
['/ban', 'Ban a user (owner)'],
|
|
60
|
+
['/owner', 'Current room owner'],
|
|
61
|
+
['/plugins', 'List plugins'],
|
|
62
|
+
['/quit', 'Quit the chat'],
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
export const COMMANDS = [
|
|
66
|
+
'/help',
|
|
67
|
+
'/nick',
|
|
68
|
+
'/users',
|
|
69
|
+
'/fingerprint',
|
|
70
|
+
'/verify',
|
|
71
|
+
'/verify-confirm',
|
|
72
|
+
'/trust',
|
|
73
|
+
'/trustlist',
|
|
74
|
+
'/clear',
|
|
75
|
+
'/file',
|
|
76
|
+
'/voice',
|
|
77
|
+
'/play',
|
|
78
|
+
'/accept',
|
|
79
|
+
'/reject',
|
|
80
|
+
'/img',
|
|
81
|
+
'/sound',
|
|
82
|
+
'/msg',
|
|
83
|
+
'/reply',
|
|
84
|
+
'/away',
|
|
85
|
+
'/back',
|
|
86
|
+
'/autoaway',
|
|
87
|
+
'/status',
|
|
88
|
+
'/notify',
|
|
89
|
+
'/dnd',
|
|
90
|
+
'/join',
|
|
91
|
+
'/rooms',
|
|
92
|
+
'/room',
|
|
93
|
+
'/invite',
|
|
94
|
+
'/search',
|
|
95
|
+
'/history',
|
|
96
|
+
'/export',
|
|
97
|
+
'/backup',
|
|
98
|
+
'/retention',
|
|
99
|
+
'/audit',
|
|
100
|
+
'/ephemeral',
|
|
101
|
+
'/react',
|
|
102
|
+
'/edit',
|
|
103
|
+
'/delete',
|
|
104
|
+
'/pin',
|
|
105
|
+
'/unpin',
|
|
106
|
+
'/pins',
|
|
107
|
+
'/deniable',
|
|
108
|
+
'/receipts',
|
|
109
|
+
'/cover',
|
|
110
|
+
'/theme',
|
|
111
|
+
'/panic',
|
|
112
|
+
'/kick',
|
|
113
|
+
'/mute',
|
|
114
|
+
'/ban',
|
|
115
|
+
'/owner',
|
|
116
|
+
'/plugins',
|
|
117
|
+
'/quit',
|
|
118
|
+
];
|
|
119
|
+
const NICK_COMMANDS = [
|
|
120
|
+
'/fingerprint',
|
|
121
|
+
'/verify',
|
|
122
|
+
'/verify-confirm',
|
|
123
|
+
'/trust',
|
|
124
|
+
'/msg',
|
|
125
|
+
'/kick',
|
|
126
|
+
'/mute',
|
|
127
|
+
'/ban',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
function nickHash(nickname) {
|
|
131
|
+
let hash = 0;
|
|
132
|
+
for (let i = 0; i < nickname.length; i++) {
|
|
133
|
+
hash = ((hash << 5) - hash + nickname.charCodeAt(i)) | 0;
|
|
134
|
+
}
|
|
135
|
+
return Math.abs(hash);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function nickColor(nickname) {
|
|
139
|
+
const palette = nickPalette();
|
|
140
|
+
return palette[nickHash(nickname) % palette.length];
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Deterministic avatar per nick โ emojis can't be color-tinted in the
|
|
144
|
+
// terminal, so the visual identity comes from the variety of the emoji itself
|
|
145
|
+
function nickAvatar(nickname) {
|
|
146
|
+
return NICK_AVATARS[nickHash(nickname) % NICK_AVATARS.length];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function time() {
|
|
150
|
+
return new Date().toLocaleTimeString('en-US', {
|
|
151
|
+
hour12: false,
|
|
152
|
+
hour: '2-digit',
|
|
153
|
+
minute: '2-digit',
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Inline markdown for a single line: `code`, **bold**, *italic*, links.
|
|
158
|
+
function renderInline(text) {
|
|
159
|
+
// Collect all markdown spans with their positions
|
|
160
|
+
const spans = [];
|
|
161
|
+
|
|
162
|
+
// Inline code: `code`
|
|
163
|
+
for (const m of text.matchAll(/`([^`]+)`/g)) {
|
|
164
|
+
spans.push({ start: m.index, end: m.index + m[0].length, inner: m[1], tag: 'yellow-fg' });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Bold: **text**
|
|
168
|
+
for (const m of text.matchAll(/\*\*([^*]+)\*\*/g)) {
|
|
169
|
+
// Skip if overlaps with an existing span (inside code)
|
|
170
|
+
if (spans.some((s) => m.index >= s.start && m.index < s.end)) {
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
spans.push({ start: m.index, end: m.index + m[0].length, inner: m[1], tag: 'bold' });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Italic: *text* (not preceded/followed by *)
|
|
177
|
+
for (const m of text.matchAll(/(?<!\*)\*([^*]+)\*(?!\*)/g)) {
|
|
178
|
+
if (spans.some((s) => m.index >= s.start && m.index < s.end)) {
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
spans.push({ start: m.index, end: m.index + m[0].length, inner: m[1], tag: 'underline' });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Links: http(s):// โฆ โ highlighted cyan + underline.
|
|
185
|
+
for (const m of text.matchAll(/https?:\/\/[^\s]+/g)) {
|
|
186
|
+
if (spans.some((s) => m.index >= s.start && m.index < s.end)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
spans.push({
|
|
190
|
+
start: m.index,
|
|
191
|
+
end: m.index + m[0].length,
|
|
192
|
+
inner: m[0],
|
|
193
|
+
open: '{cyan-fg}{underline}',
|
|
194
|
+
close: '{/underline}{/cyan-fg}',
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (spans.length === 0) {
|
|
199
|
+
return blessed.escape(text);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Sort by position
|
|
203
|
+
spans.sort((a, b) => a.start - b.start);
|
|
204
|
+
|
|
205
|
+
// Build result: escape plain segments, apply tags to markdown segments
|
|
206
|
+
let result = '';
|
|
207
|
+
let pos = 0;
|
|
208
|
+
for (const span of spans) {
|
|
209
|
+
if (span.start > pos) {
|
|
210
|
+
result += blessed.escape(text.slice(pos, span.start));
|
|
211
|
+
}
|
|
212
|
+
const open = span.open ?? `{${span.tag}}`;
|
|
213
|
+
const close = span.close ?? `{/${span.tag}}`;
|
|
214
|
+
result += `${open}${blessed.escape(span.inner)}${close}`;
|
|
215
|
+
pos = span.end;
|
|
216
|
+
}
|
|
217
|
+
if (pos < text.length) {
|
|
218
|
+
result += blessed.escape(text.slice(pos));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A markdown table separator row, e.g. |---|:--:|.
|
|
225
|
+
function isTableSeparator(line) {
|
|
226
|
+
return /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(line) && line.includes('-');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function splitRow(line) {
|
|
230
|
+
return line
|
|
231
|
+
.trim()
|
|
232
|
+
.replace(/^\||\|$/g, '')
|
|
233
|
+
.split('|')
|
|
234
|
+
.map((c) => c.trim());
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Render a buffered block of pipe-lines. If it's a real table (has a separator
|
|
238
|
+
// row) align the columns; otherwise fall back to inline rendering per line.
|
|
239
|
+
function renderTable(lines) {
|
|
240
|
+
const sepIdx = lines.findIndex(isTableSeparator);
|
|
241
|
+
if (sepIdx < 1) {
|
|
242
|
+
return lines.map(renderInline); // not a table โ just piped text
|
|
243
|
+
}
|
|
244
|
+
const header = splitRow(lines[sepIdx - 1]);
|
|
245
|
+
const bodyRows = lines.filter((l, i) => i !== sepIdx && i !== sepIdx - 1).map(splitRow);
|
|
246
|
+
const cols = header.length;
|
|
247
|
+
const widths = header.map((h, c) =>
|
|
248
|
+
Math.max(h.length, ...bodyRows.map((r) => (r[c] || '').length)),
|
|
249
|
+
);
|
|
250
|
+
const pad = (cells) =>
|
|
251
|
+
' ' +
|
|
252
|
+
cells
|
|
253
|
+
.slice(0, cols)
|
|
254
|
+
.map((cell, c) => (cell || '').padEnd(widths[c]))
|
|
255
|
+
.join(' ');
|
|
256
|
+
const out = [`{bold}{cyan-fg}${blessed.escape(pad(header))}{/cyan-fg}{/bold}`];
|
|
257
|
+
out.push(`{#666666-fg}${blessed.escape(pad(widths.map((w) => 'โ'.repeat(w))))}{/#666666-fg}`);
|
|
258
|
+
for (const row of bodyRows) {
|
|
259
|
+
out.push(blessed.escape(pad(row)));
|
|
260
|
+
}
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Block-aware markdown: fenced ``` code blocks and | tables | on top of the
|
|
265
|
+
// inline formatting. Multi-line messages are processed line by line.
|
|
266
|
+
export function renderMarkdown(text) {
|
|
267
|
+
if (!text.includes('\n')) {
|
|
268
|
+
return renderInline(text); // single line โ the common, fast path
|
|
269
|
+
}
|
|
270
|
+
const lines = text.split('\n');
|
|
271
|
+
const out = [];
|
|
272
|
+
let inCode = false;
|
|
273
|
+
let tableBuf = [];
|
|
274
|
+
const flushTable = () => {
|
|
275
|
+
if (tableBuf.length) {
|
|
276
|
+
out.push(...renderTable(tableBuf));
|
|
277
|
+
tableBuf = [];
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
for (const line of lines) {
|
|
282
|
+
if (/^\s*```/.test(line)) {
|
|
283
|
+
flushTable();
|
|
284
|
+
inCode = !inCode;
|
|
285
|
+
out.push(`{#666666-fg}${blessed.escape(line)}{/#666666-fg}`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (inCode) {
|
|
289
|
+
out.push(`{#7fdbca-fg}${blessed.escape(line)}{/#7fdbca-fg}`); // code โ no inline md
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
if (line.includes('|')) {
|
|
293
|
+
tableBuf.push(line);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
flushTable();
|
|
297
|
+
out.push(renderInline(line));
|
|
298
|
+
}
|
|
299
|
+
flushTable();
|
|
300
|
+
return out.join('\n');
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Builds the rendered content of the (possibly multi-line) input box with an
|
|
304
|
+
// inverse cursor cell, windowed so the cursor line is always visible. Pure and
|
|
305
|
+
// exported for testing. Returns { content, height } (height includes borders).
|
|
306
|
+
export function inputView(value, cursorPos, maxLines = 8) {
|
|
307
|
+
const segments = value.split('\n');
|
|
308
|
+
const built = [];
|
|
309
|
+
let lineStart = 0;
|
|
310
|
+
let cursorLine = 0;
|
|
311
|
+
|
|
312
|
+
for (let li = 0; li < segments.length; li++) {
|
|
313
|
+
const seg = segments[li];
|
|
314
|
+
const segEnd = lineStart + seg.length; // flat offset just before the '\n'
|
|
315
|
+
if (cursorPos >= lineStart && cursorPos <= segEnd) {
|
|
316
|
+
cursorLine = li;
|
|
317
|
+
const col = cursorPos - lineStart;
|
|
318
|
+
// grapheme under the cursor (extend across a surrogate pair)
|
|
319
|
+
let cur = seg.slice(col, col + 1);
|
|
320
|
+
const code = seg.charCodeAt(col);
|
|
321
|
+
if (code >= 0xd800 && code <= 0xdbff && col + 1 < seg.length) {
|
|
322
|
+
cur = seg.slice(col, col + 2);
|
|
323
|
+
}
|
|
324
|
+
const cursorCell = cur === '' ? ' ' : cur;
|
|
325
|
+
const before = blessed.escape(seg.slice(0, col));
|
|
326
|
+
const after = blessed.escape(seg.slice(col + (cur === '' ? 0 : cur.length)));
|
|
327
|
+
built.push(` ${before}{inverse}${blessed.escape(cursorCell)}{/inverse}${after}`);
|
|
328
|
+
} else {
|
|
329
|
+
built.push(` ${blessed.escape(seg)}`);
|
|
330
|
+
}
|
|
331
|
+
lineStart = segEnd + 1; // skip the '\n'
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
let lines = built;
|
|
335
|
+
if (built.length > maxLines) {
|
|
336
|
+
let start = Math.max(0, cursorLine - maxLines + 1);
|
|
337
|
+
if (start + maxLines > built.length) {
|
|
338
|
+
start = built.length - maxLines;
|
|
339
|
+
}
|
|
340
|
+
lines = built.slice(start, start + maxLines);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const height = Math.min(Math.max(segments.length, 1), maxLines) + 2; // + borders
|
|
344
|
+
return { content: lines.join('\n'), height };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Extra frames appended after the flame reaches the last glyph so the tail
|
|
348
|
+
// (hot โ ember โ ash โ gone) finishes burning out.
|
|
349
|
+
const BURN_TAIL = 5;
|
|
350
|
+
|
|
351
|
+
// One glyph of the burn animation. `phase` is how many frames the flame front
|
|
352
|
+
// has passed this position: <0 still intact, then it heats up and cools to ash.
|
|
353
|
+
function burnGlyph(ch, phase) {
|
|
354
|
+
if (phase <= 0) {
|
|
355
|
+
return blessed.escape(ch);
|
|
356
|
+
}
|
|
357
|
+
if (phase < 1) {
|
|
358
|
+
return '{#ffd000-fg}โ{/#ffd000-fg}'; // ignite
|
|
359
|
+
}
|
|
360
|
+
if (phase < 2) {
|
|
361
|
+
return '{#ff8c00-fg}โ{/#ff8c00-fg}'; // burning
|
|
362
|
+
}
|
|
363
|
+
if (phase < 3) {
|
|
364
|
+
return '{#ff2b00-fg}โ{/#ff2b00-fg}'; // hottest
|
|
365
|
+
}
|
|
366
|
+
if (phase < 4) {
|
|
367
|
+
return '{#7a2b00-fg}โ{/#7a2b00-fg}'; // ember
|
|
368
|
+
}
|
|
369
|
+
if (phase < BURN_TAIL) {
|
|
370
|
+
return '{#555555-fg}ยท{/#555555-fg}'; // ash
|
|
371
|
+
}
|
|
372
|
+
return ' '; // gone
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Builds one frame of the burn effect for a tag-free string, given how far the
|
|
376
|
+
// flame front has advanced. Exported for testing.
|
|
377
|
+
export function burnFrame(text, front) {
|
|
378
|
+
const chars = [...text];
|
|
379
|
+
let out = '';
|
|
380
|
+
for (let i = 0; i < chars.length; i++) {
|
|
381
|
+
out += burnGlyph(chars[i], front - i);
|
|
382
|
+
}
|
|
383
|
+
return out;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// A blessed-tagged progress bar. `shimmerPos` moves a 2-cell bright band along
|
|
387
|
+
// the filled region; at 100% the whole bar goes solid green. Exported for
|
|
388
|
+
// testing.
|
|
389
|
+
export function progressBar(pct, shimmerPos = 0, width = 24) {
|
|
390
|
+
const p = Math.max(0, Math.min(100, pct));
|
|
391
|
+
const done = p >= 100;
|
|
392
|
+
const filled = Math.round((p / 100) * width);
|
|
393
|
+
let bar = '';
|
|
394
|
+
for (let i = 0; i < width; i++) {
|
|
395
|
+
if (i < filled) {
|
|
396
|
+
const lit = !done && ((shimmerPos % width) - i + width) % width < 2;
|
|
397
|
+
const color = done ? 'green' : lit ? '#ffffff' : '#00b8ff';
|
|
398
|
+
bar += `{${color}-fg}โ{/${color}-fg}`;
|
|
399
|
+
} else {
|
|
400
|
+
bar += '{#333333-fg}โ{/#333333-fg}';
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
return bar;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Estimates remaining time from elapsed ms and percent done. Returns '' when
|
|
407
|
+
// there isn't enough signal yet (too early, at the ends). Exported for testing.
|
|
408
|
+
export function formatETA(elapsedMs, pct) {
|
|
409
|
+
if (pct <= 0 || pct >= 100 || elapsedMs < 600) {
|
|
410
|
+
return '';
|
|
411
|
+
}
|
|
412
|
+
const remaining = Math.max(0, (elapsedMs / pct) * 100 - elapsedMs);
|
|
413
|
+
const secs = Math.ceil(remaining / 1000);
|
|
414
|
+
return secs >= 60 ? `~${Math.ceil(secs / 60)}m remaining` : `~${secs}s remaining`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export class UI extends EventEmitter {
|
|
418
|
+
#screen;
|
|
419
|
+
#header;
|
|
420
|
+
#chatLog;
|
|
421
|
+
#inputBox;
|
|
422
|
+
#nickname;
|
|
423
|
+
#onlineCount;
|
|
424
|
+
#inputValue;
|
|
425
|
+
#cursorPos;
|
|
426
|
+
#lastKeyEvent;
|
|
427
|
+
#typingPeers;
|
|
428
|
+
#typingAnimInterval;
|
|
429
|
+
#typingAnimFrame;
|
|
430
|
+
#soundEnabled;
|
|
431
|
+
#notifyEnabled;
|
|
432
|
+
#peerNames;
|
|
433
|
+
#tabState;
|
|
434
|
+
#lines;
|
|
435
|
+
#headerIndicators;
|
|
436
|
+
#scrolledUp;
|
|
437
|
+
#connState;
|
|
438
|
+
#lastMsgDate;
|
|
439
|
+
#lastSender;
|
|
440
|
+
#pasting;
|
|
441
|
+
#pasteBuffer;
|
|
442
|
+
#lastPaste;
|
|
443
|
+
#statusBar;
|
|
444
|
+
#statusFingerprint;
|
|
445
|
+
#statusRoom;
|
|
446
|
+
#connSpinner;
|
|
447
|
+
#spinnerFrame;
|
|
448
|
+
#reconnectFlashTimer;
|
|
449
|
+
#reconnectFlashFrame;
|
|
450
|
+
#reconnectFlashActive;
|
|
451
|
+
#progIndex;
|
|
452
|
+
#progPercent;
|
|
453
|
+
#progText;
|
|
454
|
+
#progStart;
|
|
455
|
+
#shimmerTimer;
|
|
456
|
+
#shimmerPos;
|
|
457
|
+
#unseenCount;
|
|
458
|
+
#pillTimer;
|
|
459
|
+
#pillFrame;
|
|
460
|
+
#palette;
|
|
461
|
+
#paletteOpen;
|
|
462
|
+
#paletteQuery;
|
|
463
|
+
#emojiPicker;
|
|
464
|
+
#emojiOpen;
|
|
465
|
+
#emojiQuery;
|
|
466
|
+
|
|
467
|
+
constructor(nickname) {
|
|
468
|
+
super();
|
|
469
|
+
this.#nickname = nickname;
|
|
470
|
+
this.#onlineCount = 1;
|
|
471
|
+
this.#connState = 'online';
|
|
472
|
+
this.#lastMsgDate = null;
|
|
473
|
+
this.#lastSender = null;
|
|
474
|
+
this.#pasting = false;
|
|
475
|
+
this.#pasteBuffer = '';
|
|
476
|
+
this.#lastPaste = { content: '', time: 0 };
|
|
477
|
+
this.#inputValue = '';
|
|
478
|
+
this.#cursorPos = 0;
|
|
479
|
+
this.#lastKeyEvent = { seq: '', time: 0 };
|
|
480
|
+
this.#typingPeers = new Set();
|
|
481
|
+
this.#soundEnabled = true;
|
|
482
|
+
this.#notifyEnabled = true;
|
|
483
|
+
this.#typingAnimInterval = null;
|
|
484
|
+
this.#typingAnimFrame = 0;
|
|
485
|
+
this.#peerNames = [];
|
|
486
|
+
this.#tabState = { suggestions: [], index: -1, original: '' };
|
|
487
|
+
this.#lines = [];
|
|
488
|
+
this.#headerIndicators = [];
|
|
489
|
+
this.#scrolledUp = false;
|
|
490
|
+
this.#statusFingerprint = '';
|
|
491
|
+
this.#statusRoom = 'general';
|
|
492
|
+
this.#connSpinner = null;
|
|
493
|
+
this.#spinnerFrame = 0;
|
|
494
|
+
this.#reconnectFlashTimer = null;
|
|
495
|
+
this.#reconnectFlashFrame = 0;
|
|
496
|
+
this.#reconnectFlashActive = false;
|
|
497
|
+
this.#progIndex = null;
|
|
498
|
+
this.#progPercent = 0;
|
|
499
|
+
this.#progText = '';
|
|
500
|
+
this.#progStart = 0;
|
|
501
|
+
this.#shimmerTimer = null;
|
|
502
|
+
this.#shimmerPos = 0;
|
|
503
|
+
this.#unseenCount = 0;
|
|
504
|
+
this.#pillTimer = null;
|
|
505
|
+
this.#pillFrame = 0;
|
|
506
|
+
this.#paletteOpen = false;
|
|
507
|
+
this.#paletteQuery = '';
|
|
508
|
+
this.#emojiOpen = false;
|
|
509
|
+
this.#emojiQuery = '';
|
|
510
|
+
|
|
511
|
+
this.#screen = blessed.screen({
|
|
512
|
+
smartCSR: true,
|
|
513
|
+
fullUnicode: true, // renders emojis and characters outside the BMP
|
|
514
|
+
title: 'CipherMesh',
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
// โโ Header โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
518
|
+
this.#header = blessed.box({
|
|
519
|
+
parent: this.#screen,
|
|
520
|
+
top: 0,
|
|
521
|
+
left: 0,
|
|
522
|
+
width: '100%',
|
|
523
|
+
height: 3,
|
|
524
|
+
tags: true,
|
|
525
|
+
style: {
|
|
526
|
+
fg: 'white',
|
|
527
|
+
bg: '#1a1a2e',
|
|
528
|
+
},
|
|
529
|
+
content: this.#headerContent(),
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// โโ Chat log โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
533
|
+
this.#chatLog = blessed.log({
|
|
534
|
+
parent: this.#screen,
|
|
535
|
+
top: 3,
|
|
536
|
+
left: 0,
|
|
537
|
+
width: '100%',
|
|
538
|
+
bottom: 4, // leave room for the 1-line status bar above the input
|
|
539
|
+
tags: true,
|
|
540
|
+
scrollable: true,
|
|
541
|
+
alwaysScroll: true,
|
|
542
|
+
scrollbar: {
|
|
543
|
+
style: { bg: 'magenta' },
|
|
544
|
+
},
|
|
545
|
+
border: {
|
|
546
|
+
type: 'line',
|
|
547
|
+
},
|
|
548
|
+
style: {
|
|
549
|
+
border: { fg: 'cyan' },
|
|
550
|
+
},
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
// โโ Input (plain box, manual keypress) โโโโโโโโโโโโโโโ
|
|
554
|
+
this.#inputBox = blessed.box({
|
|
555
|
+
parent: this.#screen,
|
|
556
|
+
bottom: 0,
|
|
557
|
+
left: 0,
|
|
558
|
+
width: '100%',
|
|
559
|
+
height: 3,
|
|
560
|
+
tags: true,
|
|
561
|
+
border: {
|
|
562
|
+
type: 'line',
|
|
563
|
+
},
|
|
564
|
+
style: {
|
|
565
|
+
fg: 'white',
|
|
566
|
+
border: { fg: 'green' },
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
// โโ Status bar (1 line, between chat and input) โโโโโโ
|
|
571
|
+
this.#statusBar = blessed.box({
|
|
572
|
+
parent: this.#screen,
|
|
573
|
+
bottom: 3,
|
|
574
|
+
left: 0,
|
|
575
|
+
width: '100%',
|
|
576
|
+
height: 1,
|
|
577
|
+
tags: true,
|
|
578
|
+
style: {
|
|
579
|
+
fg: 'white',
|
|
580
|
+
bg: '#16213e',
|
|
581
|
+
},
|
|
582
|
+
content: this.#statusContent(),
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
// โโ Command palette (Ctrl+K) โโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
586
|
+
this.#palette = blessed.list({
|
|
587
|
+
parent: this.#screen,
|
|
588
|
+
hidden: true,
|
|
589
|
+
top: 'center',
|
|
590
|
+
left: 'center',
|
|
591
|
+
width: '70%',
|
|
592
|
+
height: '60%',
|
|
593
|
+
tags: true,
|
|
594
|
+
border: { type: 'line' },
|
|
595
|
+
label: ' Commands (Ctrl+K) ',
|
|
596
|
+
style: {
|
|
597
|
+
border: { fg: 'magenta' },
|
|
598
|
+
selected: { bg: 'magenta', fg: 'white' },
|
|
599
|
+
item: { fg: 'white' },
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// โโ Emoji picker (Ctrl+E) โโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
604
|
+
this.#emojiPicker = blessed.list({
|
|
605
|
+
parent: this.#screen,
|
|
606
|
+
hidden: true,
|
|
607
|
+
top: 'center',
|
|
608
|
+
left: 'center',
|
|
609
|
+
width: '50%',
|
|
610
|
+
height: '55%',
|
|
611
|
+
tags: true,
|
|
612
|
+
border: { type: 'line' },
|
|
613
|
+
label: ' Emoji (Ctrl+E) ',
|
|
614
|
+
style: {
|
|
615
|
+
border: { fg: 'yellow' },
|
|
616
|
+
selected: { bg: 'yellow', fg: 'black' },
|
|
617
|
+
item: { fg: 'white' },
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
this.#renderInput();
|
|
622
|
+
|
|
623
|
+
// โโ Single keypress listener with dedup โโโโโโโโโโโโโโ
|
|
624
|
+
// blessed on Windows fires every keypress twice through
|
|
625
|
+
// both program and screen pipelines. We listen ONLY on
|
|
626
|
+
// screen level and deduplicate by sequence + timestamp.
|
|
627
|
+
this.#screen.on('keypress', (ch, key) => {
|
|
628
|
+
if (!key) {
|
|
629
|
+
return;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const now = performance.now();
|
|
633
|
+
const seq = key.sequence || key.full || ch || '';
|
|
634
|
+
|
|
635
|
+
// Mouse tracking sequences (ex: \x1b[<35;10;20M) that leak past
|
|
636
|
+
// blessed's parser are not typing โ discard
|
|
637
|
+
if (seq.startsWith('\x1b[<') || seq.startsWith('\x1b[M')) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Shift+Enter arrives as a distinct sequence only under the kitty keyboard
|
|
642
|
+
// protocol (\x1b[13;2u) or xterm modifyOtherKeys (\x1b[27;2;13~). When the
|
|
643
|
+
// terminal sends it, insert a newline instead of submitting.
|
|
644
|
+
if (seq === '\x1b[13;2u' || seq === '\x1b[27;2;13~') {
|
|
645
|
+
this.#insertNewline();
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Bracketed paste: insert the whole pasted block atomically (avoids the
|
|
650
|
+
// double-pipeline duplicating a paste that spans the 25ms dedup window).
|
|
651
|
+
if (this.#handlePaste(seq)) {
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// Same raw sequence within 25ms = duplicate from blessed
|
|
656
|
+
if (seq === this.#lastKeyEvent.seq && now - this.#lastKeyEvent.time < 25) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
this.#lastKeyEvent = { seq, time: now };
|
|
660
|
+
|
|
661
|
+
if (this.#paletteOpen) {
|
|
662
|
+
this.#handlePaletteKey(ch, key);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
if (this.#emojiOpen) {
|
|
667
|
+
this.#handleEmojiKey(ch, key);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
this.#handleKey(ch, key);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// Ask the terminal to bracket pasted text with \x1b[200~ โฆ \x1b[201~.
|
|
675
|
+
try {
|
|
676
|
+
this.#screen.program.write('\x1b[?2004h');
|
|
677
|
+
process.on('exit', () => {
|
|
678
|
+
try {
|
|
679
|
+
process.stdout.write('\x1b[?2004l');
|
|
680
|
+
} catch {
|
|
681
|
+
/* ignore */
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
} catch {
|
|
685
|
+
/* terminals without bracketed paste just ignore this */
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
this.#screen.render();
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
#handleKey(ch, key) {
|
|
692
|
+
const name = key.name || '';
|
|
693
|
+
|
|
694
|
+
// Ctrl+C โ quit
|
|
695
|
+
if (key.ctrl && name === 'c') {
|
|
696
|
+
this.emit('quit');
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Ctrl+K โ command palette
|
|
701
|
+
if (key.ctrl && name === 'k') {
|
|
702
|
+
this.#openPalette();
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// Ctrl+E โ emoji picker
|
|
707
|
+
if (key.ctrl && name === 'e') {
|
|
708
|
+
this.#openEmoji();
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Tab โ autocomplete
|
|
713
|
+
if (name === 'tab') {
|
|
714
|
+
this.#handleTab();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Any non-tab key resets tab cycling
|
|
719
|
+
this.#tabState = { suggestions: [], index: -1, original: '' };
|
|
720
|
+
|
|
721
|
+
// Alt+Enter / Ctrl+J โ insert a newline (reliable across terminals, unlike
|
|
722
|
+
// bare Shift+Enter which most terminals don't distinguish from Enter).
|
|
723
|
+
if (((name === 'return' || name === 'enter') && key.meta) || (key.ctrl && name === 'j')) {
|
|
724
|
+
this.#insertNewline();
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Enter โ submit (newlines inside the message are preserved)
|
|
729
|
+
if (name === 'return' || name === 'enter') {
|
|
730
|
+
const text = this.#inputValue.replace(/^\n+|\n+$/g, '').trim();
|
|
731
|
+
if (text) {
|
|
732
|
+
this.emit('input', text);
|
|
733
|
+
}
|
|
734
|
+
this.#inputValue = '';
|
|
735
|
+
this.#cursorPos = 0;
|
|
736
|
+
this.#renderInput();
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// Backspace โ deletes a whole code point (emojis are surrogate pairs)
|
|
741
|
+
if (name === 'backspace') {
|
|
742
|
+
if (this.#cursorPos > 0) {
|
|
743
|
+
const start = this.#prevCharBoundary(this.#cursorPos);
|
|
744
|
+
this.#inputValue =
|
|
745
|
+
this.#inputValue.slice(0, start) + this.#inputValue.slice(this.#cursorPos);
|
|
746
|
+
this.#cursorPos = start;
|
|
747
|
+
}
|
|
748
|
+
this.emit('activity');
|
|
749
|
+
this.#renderInput();
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Delete
|
|
754
|
+
if (name === 'delete') {
|
|
755
|
+
if (this.#cursorPos < this.#inputValue.length) {
|
|
756
|
+
const end = this.#nextCharBoundary(this.#cursorPos);
|
|
757
|
+
this.#inputValue = this.#inputValue.slice(0, this.#cursorPos) + this.#inputValue.slice(end);
|
|
758
|
+
}
|
|
759
|
+
this.#renderInput();
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// Arrow left
|
|
764
|
+
if (name === 'left') {
|
|
765
|
+
if (this.#cursorPos > 0) {
|
|
766
|
+
this.#cursorPos = this.#prevCharBoundary(this.#cursorPos);
|
|
767
|
+
}
|
|
768
|
+
this.#renderInput();
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// Arrow right
|
|
773
|
+
if (name === 'right') {
|
|
774
|
+
if (this.#cursorPos < this.#inputValue.length) {
|
|
775
|
+
this.#cursorPos = this.#nextCharBoundary(this.#cursorPos);
|
|
776
|
+
}
|
|
777
|
+
this.#renderInput();
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// Home
|
|
782
|
+
if (name === 'home') {
|
|
783
|
+
this.#cursorPos = 0;
|
|
784
|
+
this.#renderInput();
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// End
|
|
789
|
+
if (name === 'end') {
|
|
790
|
+
this.#cursorPos = this.#inputValue.length;
|
|
791
|
+
this.#renderInput();
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Ctrl+U โ clear line
|
|
796
|
+
if (key.ctrl && name === 'u') {
|
|
797
|
+
this.#inputValue = '';
|
|
798
|
+
this.#cursorPos = 0;
|
|
799
|
+
this.#renderInput();
|
|
800
|
+
return;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// Page Up / Page Down โ scroll chat
|
|
804
|
+
if (name === 'pageup' || name === 'pagedown') {
|
|
805
|
+
const page = Math.max(1, this.#chatLog.height - this.#chatLog.iheight);
|
|
806
|
+
this.#chatLog.scroll(name === 'pageup' ? -page : page);
|
|
807
|
+
this.#screen.render();
|
|
808
|
+
this.#syncScrollState();
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Regular character โ length 2 = surrogate pair (emoji delivered whole
|
|
813
|
+
// in some terminals; in others it arrives as two halves of length 1)
|
|
814
|
+
const firstCode = ch ? ch.charCodeAt(0) : 0;
|
|
815
|
+
if (ch && ch.length <= 2 && !key.ctrl && !key.meta && firstCode > 0x1f && firstCode !== 0x7f) {
|
|
816
|
+
this.#inputValue =
|
|
817
|
+
this.#inputValue.slice(0, this.#cursorPos) + ch + this.#inputValue.slice(this.#cursorPos);
|
|
818
|
+
this.#cursorPos += ch.length;
|
|
819
|
+
this.emit('activity');
|
|
820
|
+
this.#renderInput();
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// Bracketed-paste state machine. Returns true when the sequence is part of a
|
|
825
|
+
// paste (start / content / end) and must not be treated as normal keypresses.
|
|
826
|
+
#handlePaste(seq) {
|
|
827
|
+
const START = '\x1b[200~';
|
|
828
|
+
const END = '\x1b[201~';
|
|
829
|
+
|
|
830
|
+
if (this.#pasting) {
|
|
831
|
+
const endIdx = seq.indexOf(END);
|
|
832
|
+
if (endIdx !== -1) {
|
|
833
|
+
this.#pasteBuffer += seq.slice(0, endIdx);
|
|
834
|
+
this.#pasting = false;
|
|
835
|
+
const content = this.#pasteBuffer;
|
|
836
|
+
this.#pasteBuffer = '';
|
|
837
|
+
this.#insertPaste(content);
|
|
838
|
+
} else {
|
|
839
|
+
this.#pasteBuffer += seq;
|
|
840
|
+
}
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
const startIdx = seq.indexOf(START);
|
|
845
|
+
if (startIdx === -1) {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
const rest = seq.slice(startIdx + START.length);
|
|
850
|
+
const endIdx = rest.indexOf(END);
|
|
851
|
+
if (endIdx !== -1) {
|
|
852
|
+
this.#insertPaste(rest.slice(0, endIdx)); // whole paste in one sequence
|
|
853
|
+
} else {
|
|
854
|
+
this.#pasting = true;
|
|
855
|
+
this.#pasteBuffer = rest;
|
|
856
|
+
}
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
#insertPaste(raw) {
|
|
861
|
+
// Strip any stray paste markers and control chars (a message is one line).
|
|
862
|
+
// eslint-disable-next-line no-control-regex
|
|
863
|
+
const clean = raw.replace(/\x1b\[20[01]~/g, '').replace(/[\x00-\x1f\x7f]/g, '');
|
|
864
|
+
if (!clean) {
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
// Drop a replay of the same paste from the second input pipeline.
|
|
868
|
+
const now = performance.now();
|
|
869
|
+
if (clean === this.#lastPaste.content && now - this.#lastPaste.time < 400) {
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
this.#lastPaste = { content: clean, time: now };
|
|
873
|
+
|
|
874
|
+
this.#inputValue =
|
|
875
|
+
this.#inputValue.slice(0, this.#cursorPos) + clean + this.#inputValue.slice(this.#cursorPos);
|
|
876
|
+
this.#cursorPos += clean.length;
|
|
877
|
+
this.emit('activity');
|
|
878
|
+
this.#renderInput();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Code-point boundaries โ a surrogate pair moves as a whole
|
|
882
|
+
#prevCharBoundary(pos) {
|
|
883
|
+
if (pos <= 1) {
|
|
884
|
+
return 0;
|
|
885
|
+
}
|
|
886
|
+
const code = this.#inputValue.charCodeAt(pos - 1);
|
|
887
|
+
return code >= 0xdc00 && code <= 0xdfff ? pos - 2 : pos - 1;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
#nextCharBoundary(pos) {
|
|
891
|
+
const len = this.#inputValue.length;
|
|
892
|
+
if (pos >= len) {
|
|
893
|
+
return len;
|
|
894
|
+
}
|
|
895
|
+
const code = this.#inputValue.charCodeAt(pos);
|
|
896
|
+
return code >= 0xd800 && code <= 0xdbff && pos + 1 < len ? pos + 2 : pos + 1;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
#renderInput() {
|
|
900
|
+
const { content, height } = inputView(this.#inputValue, this.#cursorPos, INPUT_MAX_LINES);
|
|
901
|
+
this.#applyInputHeight(height);
|
|
902
|
+
this.#inputBox.setContent(content);
|
|
903
|
+
this.#screen.render();
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// Grow/shrink the input box with the number of lines and reflow the status bar
|
|
907
|
+
// and chat log above it.
|
|
908
|
+
#applyInputHeight(height) {
|
|
909
|
+
if (this.#inputBox.height === height) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
this.#inputBox.height = height;
|
|
913
|
+
if (this.#statusBar) {
|
|
914
|
+
this.#statusBar.bottom = height;
|
|
915
|
+
}
|
|
916
|
+
this.#chatLog.bottom = height + 1;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
#insertNewline() {
|
|
920
|
+
this.#inputValue =
|
|
921
|
+
this.#inputValue.slice(0, this.#cursorPos) + '\n' + this.#inputValue.slice(this.#cursorPos);
|
|
922
|
+
this.#cursorPos += 1;
|
|
923
|
+
this.emit('activity');
|
|
924
|
+
this.#renderInput();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// โโ Command palette (Ctrl+K, fuzzy) โโโโโโโโโโโโโโโโโโ
|
|
928
|
+
#openPalette() {
|
|
929
|
+
this.#paletteOpen = true;
|
|
930
|
+
this.#paletteQuery = '';
|
|
931
|
+
this.#refreshPalette();
|
|
932
|
+
this.#palette.show();
|
|
933
|
+
this.#palette.setFront();
|
|
934
|
+
this.#screen.render();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
#closePalette() {
|
|
938
|
+
this.#paletteOpen = false;
|
|
939
|
+
this.#palette.hide();
|
|
940
|
+
this.#screen.render();
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
#paletteMatches() {
|
|
944
|
+
return fuzzyFilter(COMMAND_INFO, this.#paletteQuery, (c) => `${c[0]} ${c[1]}`);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
#refreshPalette() {
|
|
948
|
+
const matches = this.#paletteMatches();
|
|
949
|
+
this.#palette.setItems(
|
|
950
|
+
matches.map(([cmd, desc]) => ` {bold}${cmd}{/bold} {#8888aa-fg}${desc}{/#8888aa-fg}`),
|
|
951
|
+
);
|
|
952
|
+
this.#palette.select(0);
|
|
953
|
+
const q = this.#paletteQuery ? ` โบ ${this.#paletteQuery}` : '';
|
|
954
|
+
this.#palette.setLabel(` Commands (Ctrl+K)${q} `);
|
|
955
|
+
this.#screen.render();
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
#handlePaletteKey(ch, key) {
|
|
959
|
+
const name = key.name || '';
|
|
960
|
+
if (name === 'escape' || (key.ctrl && name === 'c')) {
|
|
961
|
+
this.#closePalette();
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
if (name === 'up' || name === 'down') {
|
|
965
|
+
this.#palette[name === 'up' ? 'up' : 'down'](1);
|
|
966
|
+
this.#screen.render();
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (name === 'return' || name === 'enter') {
|
|
970
|
+
const matches = this.#paletteMatches();
|
|
971
|
+
const chosen = matches[this.#palette.selected];
|
|
972
|
+
if (chosen) {
|
|
973
|
+
this.#inputValue = `${chosen[0]} `;
|
|
974
|
+
this.#cursorPos = this.#inputValue.length;
|
|
975
|
+
this.#renderInput();
|
|
976
|
+
}
|
|
977
|
+
this.#closePalette();
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (name === 'backspace') {
|
|
981
|
+
this.#paletteQuery = this.#paletteQuery.slice(0, -1);
|
|
982
|
+
this.#refreshPalette();
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
// printable char โ extend the query
|
|
986
|
+
const code = ch ? ch.charCodeAt(0) : 0;
|
|
987
|
+
if (ch && ch.length === 1 && !key.ctrl && !key.meta && code > 0x1f && code !== 0x7f) {
|
|
988
|
+
this.#paletteQuery += ch;
|
|
989
|
+
this.#refreshPalette();
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// โโ Emoji picker (Ctrl+E, fuzzy) โโโโโโโโโโโโโโโโโโโโโ
|
|
994
|
+
#openEmoji() {
|
|
995
|
+
this.#emojiOpen = true;
|
|
996
|
+
this.#emojiQuery = '';
|
|
997
|
+
this.#refreshEmoji();
|
|
998
|
+
this.#emojiPicker.show();
|
|
999
|
+
this.#emojiPicker.setFront();
|
|
1000
|
+
this.#screen.render();
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
#closeEmoji() {
|
|
1004
|
+
this.#emojiOpen = false;
|
|
1005
|
+
this.#emojiPicker.hide();
|
|
1006
|
+
this.#screen.render();
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
#emojiMatches() {
|
|
1010
|
+
return fuzzyFilter(EMOJI_ENTRIES, this.#emojiQuery, (e) => e[0]);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
#refreshEmoji() {
|
|
1014
|
+
const matches = this.#emojiMatches();
|
|
1015
|
+
this.#emojiPicker.setItems(
|
|
1016
|
+
matches.map(([code, emoji]) => ` ${emoji} {#8888aa-fg}${code}{/#8888aa-fg}`),
|
|
1017
|
+
);
|
|
1018
|
+
this.#emojiPicker.select(0);
|
|
1019
|
+
const q = this.#emojiQuery ? ` โบ ${this.#emojiQuery}` : '';
|
|
1020
|
+
this.#emojiPicker.setLabel(` Emoji (Ctrl+E)${q} `);
|
|
1021
|
+
this.#screen.render();
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
#handleEmojiKey(ch, key) {
|
|
1025
|
+
const name = key.name || '';
|
|
1026
|
+
if (name === 'escape' || (key.ctrl && name === 'c')) {
|
|
1027
|
+
this.#closeEmoji();
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (name === 'up' || name === 'down') {
|
|
1031
|
+
this.#emojiPicker[name === 'up' ? 'up' : 'down'](1);
|
|
1032
|
+
this.#screen.render();
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (name === 'return' || name === 'enter') {
|
|
1036
|
+
const chosen = this.#emojiMatches()[this.#emojiPicker.selected];
|
|
1037
|
+
if (chosen) {
|
|
1038
|
+
const emoji = chosen[1];
|
|
1039
|
+
this.#inputValue =
|
|
1040
|
+
this.#inputValue.slice(0, this.#cursorPos) +
|
|
1041
|
+
emoji +
|
|
1042
|
+
this.#inputValue.slice(this.#cursorPos);
|
|
1043
|
+
this.#cursorPos += emoji.length;
|
|
1044
|
+
this.#renderInput();
|
|
1045
|
+
}
|
|
1046
|
+
this.#closeEmoji();
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
if (name === 'backspace') {
|
|
1050
|
+
this.#emojiQuery = this.#emojiQuery.slice(0, -1);
|
|
1051
|
+
this.#refreshEmoji();
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const code = ch ? ch.charCodeAt(0) : 0;
|
|
1055
|
+
if (ch && ch.length === 1 && !key.ctrl && !key.meta && code > 0x1f && code !== 0x7f) {
|
|
1056
|
+
this.#emojiQuery += ch;
|
|
1057
|
+
this.#refreshEmoji();
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// โโ Tab autocomplete โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1062
|
+
#handleTab() {
|
|
1063
|
+
if (this.#tabState.suggestions.length === 0) {
|
|
1064
|
+
this.#tabState.original = this.#inputValue;
|
|
1065
|
+
this.#tabState.suggestions = this.#computeSuggestions(this.#inputValue);
|
|
1066
|
+
this.#tabState.index = -1;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
if (this.#tabState.suggestions.length === 0) {
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
this.#tabState.index = (this.#tabState.index + 1) % this.#tabState.suggestions.length;
|
|
1074
|
+
this.#inputValue = this.#tabState.suggestions[this.#tabState.index];
|
|
1075
|
+
this.#cursorPos = this.#inputValue.length;
|
|
1076
|
+
this.#renderInput();
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
#computeSuggestions(input) {
|
|
1080
|
+
// Emoji shortcode: last token starting with ':' (works in
|
|
1081
|
+
// any position, including inside commands like /status)
|
|
1082
|
+
const lastSpace = input.lastIndexOf(' ');
|
|
1083
|
+
const lastWord = input.slice(lastSpace + 1);
|
|
1084
|
+
if (/^:[a-z0-9_+-]+$/.test(lastWord)) {
|
|
1085
|
+
const head = input.slice(0, lastSpace + 1);
|
|
1086
|
+
return shortcodeSuggestions(lastWord).map((code) => head + code);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
if (!input.startsWith('/')) {
|
|
1090
|
+
return [];
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
const spaceIdx = input.indexOf(' ');
|
|
1094
|
+
|
|
1095
|
+
// No space yet โ autocomplete command name
|
|
1096
|
+
if (spaceIdx === -1) {
|
|
1097
|
+
const prefix = input.toLowerCase();
|
|
1098
|
+
return COMMANDS.filter((cmd) => cmd.startsWith(prefix));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Has space โ autocomplete nickname argument
|
|
1102
|
+
const cmd = input.slice(0, spaceIdx).toLowerCase();
|
|
1103
|
+
if (!NICK_COMMANDS.includes(cmd)) {
|
|
1104
|
+
return [];
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
const partial = input.slice(spaceIdx + 1).toLowerCase();
|
|
1108
|
+
return this.#peerNames
|
|
1109
|
+
.filter((name) => name.toLowerCase().startsWith(partial))
|
|
1110
|
+
.map((name) => `${cmd} ${name}`);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
setPeerNames(names) {
|
|
1114
|
+
this.#peerNames = names;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// โโ Typing indicator โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1118
|
+
#updateTypingLabel() {
|
|
1119
|
+
if (this.#typingPeers.size === 0) {
|
|
1120
|
+
this.#inputBox.setLabel('');
|
|
1121
|
+
this.#screen.render();
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const names = [...this.#typingPeers].join(', ');
|
|
1126
|
+
const dots = TYPING_DOTS[this.#typingAnimFrame % TYPING_DOTS.length];
|
|
1127
|
+
this.#inputBox.setLabel(` {yellow-fg}${names} typing${dots}{/yellow-fg} `);
|
|
1128
|
+
this.#screen.render();
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
showTyping(nickname) {
|
|
1132
|
+
this.#typingPeers.add(nickname);
|
|
1133
|
+
|
|
1134
|
+
if (!this.#typingAnimInterval) {
|
|
1135
|
+
this.#typingAnimFrame = 0;
|
|
1136
|
+
this.#updateTypingLabel();
|
|
1137
|
+
this.#typingAnimInterval = setInterval(() => {
|
|
1138
|
+
this.#typingAnimFrame++;
|
|
1139
|
+
this.#updateTypingLabel();
|
|
1140
|
+
}, 400);
|
|
1141
|
+
} else {
|
|
1142
|
+
this.#updateTypingLabel();
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
hideTyping(nickname) {
|
|
1147
|
+
this.#typingPeers.delete(nickname);
|
|
1148
|
+
|
|
1149
|
+
if (this.#typingPeers.size === 0 && this.#typingAnimInterval) {
|
|
1150
|
+
clearInterval(this.#typingAnimInterval);
|
|
1151
|
+
this.#typingAnimInterval = null;
|
|
1152
|
+
this.#typingAnimFrame = 0;
|
|
1153
|
+
}
|
|
1154
|
+
this.#updateTypingLabel();
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
#headerContent() {
|
|
1158
|
+
let dot;
|
|
1159
|
+
if (this.#connState === 'reconnecting') {
|
|
1160
|
+
// animated braille spinner while reconnecting
|
|
1161
|
+
dot = `{yellow-fg}${SPINNER_FRAMES[this.#spinnerFrame % SPINNER_FRAMES.length]}{/yellow-fg}`;
|
|
1162
|
+
} else if (this.#connState === 'online' && this.#reconnectFlashActive) {
|
|
1163
|
+
// brief green pulse when we come back online
|
|
1164
|
+
dot =
|
|
1165
|
+
this.#reconnectFlashFrame % 2 === 0
|
|
1166
|
+
? '{#00ff9f-fg}\u25c9{/#00ff9f-fg}'
|
|
1167
|
+
: '{green-fg}\u25cf{/green-fg}';
|
|
1168
|
+
} else {
|
|
1169
|
+
const dotColor = this.#connState === 'online' ? 'green' : 'red';
|
|
1170
|
+
dot = `{${dotColor}-fg}\u25cf{/${dotColor}-fg}`;
|
|
1171
|
+
}
|
|
1172
|
+
const indicators =
|
|
1173
|
+
this.#headerIndicators.length > 0
|
|
1174
|
+
? ' ' + this.#headerIndicators.map((i) => i.label).join(' ')
|
|
1175
|
+
: '';
|
|
1176
|
+
return ` ${dot} {bold}CipherMesh{/bold} {white-fg}\u2502{/white-fg} {bold}${this.#nickname}{/bold}${indicators} {|} ${dot} ${this.#onlineCount} online {white-fg}\u2502{/white-fg} {green-fg}E2E{/green-fg} `;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
setHeaderIndicator(key, label) {
|
|
1180
|
+
this.removeHeaderIndicator(key);
|
|
1181
|
+
this.#headerIndicators.push({ key, label });
|
|
1182
|
+
this.#updateHeader();
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
removeHeaderIndicator(key) {
|
|
1186
|
+
this.#headerIndicators = this.#headerIndicators.filter((i) => i.key !== key);
|
|
1187
|
+
this.#updateHeader();
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
#updateHeader() {
|
|
1191
|
+
this.#header.setContent(this.#headerContent());
|
|
1192
|
+
this.#screen.render();
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
setOnlineCount(count) {
|
|
1196
|
+
this.#onlineCount = count;
|
|
1197
|
+
this.#updateHeader();
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// 'online' | 'reconnecting' | 'offline' โ recolors the header dot.
|
|
1201
|
+
setConnectionState(state) {
|
|
1202
|
+
const prev = this.#connState;
|
|
1203
|
+
this.#connState = state;
|
|
1204
|
+
if (state === 'reconnecting') {
|
|
1205
|
+
if (!this.#connSpinner) {
|
|
1206
|
+
this.#connSpinner = setInterval(() => {
|
|
1207
|
+
this.#spinnerFrame++;
|
|
1208
|
+
this.#updateHeader();
|
|
1209
|
+
}, 120);
|
|
1210
|
+
if (this.#connSpinner.unref) {
|
|
1211
|
+
this.#connSpinner.unref();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
} else if (this.#connSpinner) {
|
|
1215
|
+
clearInterval(this.#connSpinner);
|
|
1216
|
+
this.#connSpinner = null;
|
|
1217
|
+
}
|
|
1218
|
+
// Coming back online after being away โ a brief green pulse.
|
|
1219
|
+
if (state === 'online' && prev && prev !== 'online') {
|
|
1220
|
+
this.#startReconnectFlash();
|
|
1221
|
+
}
|
|
1222
|
+
this.#updateHeader();
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
#startReconnectFlash() {
|
|
1226
|
+
if (this.#reconnectFlashTimer) {
|
|
1227
|
+
clearInterval(this.#reconnectFlashTimer);
|
|
1228
|
+
}
|
|
1229
|
+
this.#reconnectFlashActive = true;
|
|
1230
|
+
this.#reconnectFlashFrame = 0;
|
|
1231
|
+
this.#reconnectFlashTimer = setInterval(() => {
|
|
1232
|
+
this.#reconnectFlashFrame++;
|
|
1233
|
+
if (this.#reconnectFlashFrame >= 8) {
|
|
1234
|
+
clearInterval(this.#reconnectFlashTimer);
|
|
1235
|
+
this.#reconnectFlashTimer = null;
|
|
1236
|
+
this.#reconnectFlashActive = false;
|
|
1237
|
+
}
|
|
1238
|
+
this.#updateHeader();
|
|
1239
|
+
}, 110);
|
|
1240
|
+
if (this.#reconnectFlashTimer.unref) {
|
|
1241
|
+
this.#reconnectFlashTimer.unref();
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
setNickname(nickname) {
|
|
1246
|
+
this.#nickname = nickname;
|
|
1247
|
+
this.#updateHeader();
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
#statusContent() {
|
|
1251
|
+
const room = `{cyan-fg}#${this.#statusRoom}{/cyan-fg}`;
|
|
1252
|
+
const fp = this.#statusFingerprint
|
|
1253
|
+
? ` {#8888aa-fg}๐ ${this.#statusFingerprint}{/#8888aa-fg}`
|
|
1254
|
+
: '';
|
|
1255
|
+
const hint =
|
|
1256
|
+
'{#7777aa-fg}Tab ยท Ctrl+K commands ยท Ctrl+E emoji ยท PgUp/PgDn scroll ยท /help ยท Ctrl+C quit{/#7777aa-fg}';
|
|
1257
|
+
return ` ${room}${fp} {|} ${hint} `;
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
#updateStatusBar() {
|
|
1261
|
+
if (this.#statusBar) {
|
|
1262
|
+
this.#statusBar.setContent(this.#statusContent());
|
|
1263
|
+
this.#screen.render();
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
setFingerprint(fingerprint) {
|
|
1268
|
+
// short prefix of the fingerprint as a persistent identity anchor
|
|
1269
|
+
this.#statusFingerprint = (fingerprint || '').slice(0, 17);
|
|
1270
|
+
this.#updateStatusBar();
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
setRoom(room) {
|
|
1274
|
+
this.#statusRoom = room || 'general';
|
|
1275
|
+
this.#updateStatusBar();
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
// Render a real image inline by briefly leaving the TUI (kitty/iTerm2). Safe
|
|
1279
|
+
// best-effort: any keypress or a 30s timeout returns to the chat.
|
|
1280
|
+
showRealImage(escapeSeq) {
|
|
1281
|
+
let resumed = false;
|
|
1282
|
+
let timer = null;
|
|
1283
|
+
const onData = () => resume();
|
|
1284
|
+
const resume = () => {
|
|
1285
|
+
if (resumed) {
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
resumed = true;
|
|
1289
|
+
process.stdin.removeListener('data', onData);
|
|
1290
|
+
if (timer) {
|
|
1291
|
+
clearTimeout(timer);
|
|
1292
|
+
}
|
|
1293
|
+
try {
|
|
1294
|
+
this.#screen.enter();
|
|
1295
|
+
this.#screen.render();
|
|
1296
|
+
} catch {
|
|
1297
|
+
/* ignore */
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
|
|
1301
|
+
try {
|
|
1302
|
+
this.#screen.leave();
|
|
1303
|
+
process.stdout.write(
|
|
1304
|
+
`\n${escapeSeq}\n\n [high-resolution image โ press Enter to return to chat]\n`,
|
|
1305
|
+
);
|
|
1306
|
+
process.stdin.on('data', onData);
|
|
1307
|
+
timer = setTimeout(resume, 30_000);
|
|
1308
|
+
if (timer.unref) {
|
|
1309
|
+
timer.unref();
|
|
1310
|
+
}
|
|
1311
|
+
} catch {
|
|
1312
|
+
resume();
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
addMessage(
|
|
1317
|
+
nickname,
|
|
1318
|
+
text,
|
|
1319
|
+
isDM = false,
|
|
1320
|
+
ephemeralLabel = null,
|
|
1321
|
+
deniable = false,
|
|
1322
|
+
mentioned = false,
|
|
1323
|
+
) {
|
|
1324
|
+
this.#daySeparator();
|
|
1325
|
+
|
|
1326
|
+
const color = nickColor(nickname);
|
|
1327
|
+
const isSelf = nickname === this.#nickname || nickname.includes('\u2192');
|
|
1328
|
+
const tag = isSelf ? 'bold' : `${color}-fg`;
|
|
1329
|
+
const avatar = nickAvatar(isSelf ? this.#nickname : nickname);
|
|
1330
|
+
const dmLabel = isDM ? ' {magenta-fg}(DM){/magenta-fg}' : '';
|
|
1331
|
+
const ephLabel = ephemeralLabel ? ` {yellow-fg}[${ephemeralLabel}]{/yellow-fg}` : '';
|
|
1332
|
+
const denLabel = deniable ? ' {magenta-fg}[D]{/magenta-fg}' : '';
|
|
1333
|
+
const mentionMark = mentioned && !isSelf ? '{yellow-fg}\ud83d\udd14 {/yellow-fg}' : '';
|
|
1334
|
+
|
|
1335
|
+
// Consecutive messages from the same peer collapse the avatar/name into a
|
|
1336
|
+
// compact continuation bullet (cleaner layout).
|
|
1337
|
+
const grouped = !isSelf && !isDM && this.#lastSender === nickname;
|
|
1338
|
+
const core = grouped
|
|
1339
|
+
? `{${tag}}\u00b7{/${tag}} ${renderMarkdown(text)}`
|
|
1340
|
+
: `${avatar} {${tag}}${nickname}{/${tag}}${dmLabel}: ${renderMarkdown(text)}`;
|
|
1341
|
+
|
|
1342
|
+
// My own messages on the right (timestamp at the end), others on the left
|
|
1343
|
+
const line = isSelf
|
|
1344
|
+
? this.#alignRight(`${core}${ephLabel}${denLabel} {white-fg}[${time()}]{/white-fg}`)
|
|
1345
|
+
: ` {white-fg}[${time()}]{/white-fg}${ephLabel}${denLabel} ${mentionMark}${core}`;
|
|
1346
|
+
|
|
1347
|
+
this.#lines.push(line);
|
|
1348
|
+
this.#chatLog.log(line);
|
|
1349
|
+
this.#screen.render();
|
|
1350
|
+
this.#lastSender = isSelf ? 'self' : nickname;
|
|
1351
|
+
if (!isSelf) {
|
|
1352
|
+
this.#noteIncoming();
|
|
1353
|
+
}
|
|
1354
|
+
return { lineIndex: this.#lines.length - 1 };
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
#daySeparator() {
|
|
1358
|
+
const today = new Date().toLocaleDateString('en-US');
|
|
1359
|
+
if (this.#lastMsgDate === today) {
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
this.#lastMsgDate = today;
|
|
1363
|
+
this.#lastSender = null;
|
|
1364
|
+
const sep = ` {#666666-fg}\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 ${today} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500{/#666666-fg}`;
|
|
1365
|
+
this.#lines.push(sep);
|
|
1366
|
+
this.#chatLog.log(sep);
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Visible width of a string with blessed tags (emoji ~2 columns)
|
|
1370
|
+
#visibleWidth(tagged) {
|
|
1371
|
+
const plain = tagged.replace(/\{[^{}]*\}/g, '');
|
|
1372
|
+
let width = 0;
|
|
1373
|
+
for (const chr of plain) {
|
|
1374
|
+
const cp = chr.codePointAt(0);
|
|
1375
|
+
width += cp > 0xffff || (cp >= 0x2600 && cp <= 0x27bf) ? 2 : 1;
|
|
1376
|
+
}
|
|
1377
|
+
return width;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
#alignRight(tagged) {
|
|
1381
|
+
const avail = (this.#chatLog.width || 0) - this.#chatLog.iwidth - 2;
|
|
1382
|
+
const visible = this.#visibleWidth(tagged);
|
|
1383
|
+
if (avail <= visible) {
|
|
1384
|
+
return ` ${tagged}`; // doesn't fit on one line \u2014 falls back to normal flow with wrap
|
|
1385
|
+
}
|
|
1386
|
+
return ' '.repeat(avail - visible) + tagged;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Appends a badge (e.g. \u2713\u2713) preserving right alignment:
|
|
1390
|
+
// shifts the padding instead of overflowing the width
|
|
1391
|
+
appendBadge(lineIndex, baseLine, badgeTagged) {
|
|
1392
|
+
const badgeWidth = this.#visibleWidth(badgeTagged) + 1;
|
|
1393
|
+
const leading = baseLine.match(/^ +/);
|
|
1394
|
+
const line =
|
|
1395
|
+
leading && leading[0].length > badgeWidth
|
|
1396
|
+
? `${baseLine.slice(badgeWidth)} ${badgeTagged}`
|
|
1397
|
+
: `${baseLine} ${badgeTagged}`;
|
|
1398
|
+
this.updateLine(lineIndex, line);
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
addSystemMessage(text) {
|
|
1402
|
+
this.#lastSender = null; // interrupts message grouping
|
|
1403
|
+
const line = ` {white-fg}[${time()}] * ${blessed.escape(text)}{/white-fg}`;
|
|
1404
|
+
this.#lines.push(line);
|
|
1405
|
+
this.#chatLog.log(line);
|
|
1406
|
+
this.#screen.render();
|
|
1407
|
+
this.#noteIncoming();
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
addErrorMessage(text) {
|
|
1411
|
+
this.#lastSender = null;
|
|
1412
|
+
const line = ` {red-fg}[${time()}] ! ${blessed.escape(text)}{/red-fg}`;
|
|
1413
|
+
this.#lines.push(line);
|
|
1414
|
+
this.#chatLog.log(line);
|
|
1415
|
+
this.#screen.render();
|
|
1416
|
+
this.#noteIncoming();
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
addInfoMessage(text) {
|
|
1420
|
+
this.#lastSender = null;
|
|
1421
|
+
const line = ` {cyan-fg}[${time()}] ${blessed.escape(text)}{/cyan-fg}`;
|
|
1422
|
+
this.#lines.push(line);
|
|
1423
|
+
this.#chatLog.log(line);
|
|
1424
|
+
this.#screen.render();
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
addQuoteLine(nickname, excerpt, alignRight = false) {
|
|
1428
|
+
const quoted = `{#888888-fg}โฉ ${blessed.escape(nickname)}: "${blessed.escape(excerpt)}"{/#888888-fg}`;
|
|
1429
|
+
const line = alignRight ? this.#alignRight(quoted) : ` ${quoted}`;
|
|
1430
|
+
this.#lines.push(line);
|
|
1431
|
+
this.#chatLog.log(line);
|
|
1432
|
+
this.#screen.render();
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
addPlainLines(rawLines) {
|
|
1436
|
+
for (const raw of rawLines) {
|
|
1437
|
+
const line = ` ${blessed.escape(raw)}`;
|
|
1438
|
+
this.#lines.push(line);
|
|
1439
|
+
this.#chatLog.log(line);
|
|
1440
|
+
}
|
|
1441
|
+
this.#screen.render();
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
// Lines already carry blessed color tags โ do not escape
|
|
1445
|
+
addImagePreview(taggedLines) {
|
|
1446
|
+
for (const raw of taggedLines) {
|
|
1447
|
+
const line = ` ${raw}`;
|
|
1448
|
+
this.#lines.push(line);
|
|
1449
|
+
this.#chatLog.log(line);
|
|
1450
|
+
}
|
|
1451
|
+
this.#screen.render();
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// blessed's Log wrapper wrongly resets _userScrolled (getScrollPerc runs
|
|
1455
|
+
// with the previous frame's layout and returns 100) โ without the flag, every
|
|
1456
|
+
// new line pulls the view to the bottom. Recompute with the already-rendered layout.
|
|
1457
|
+
#syncScrollState() {
|
|
1458
|
+
const visible = this.#chatLog.height - this.#chatLog.iheight;
|
|
1459
|
+
const canScroll = this.#chatLog.getScrollHeight() > visible;
|
|
1460
|
+
const scrolledUp = canScroll && this.#chatLog.getScrollPerc() < 100;
|
|
1461
|
+
this.#chatLog._userScrolled = scrolledUp;
|
|
1462
|
+
|
|
1463
|
+
if (scrolledUp !== this.#scrolledUp) {
|
|
1464
|
+
this.#scrolledUp = scrolledUp;
|
|
1465
|
+
if (!scrolledUp) {
|
|
1466
|
+
this.#unseenCount = 0; // back at the bottom โ everything is seen
|
|
1467
|
+
}
|
|
1468
|
+
this.#refreshScrollIndicator();
|
|
1469
|
+
}
|
|
1470
|
+
this.#scrolledUp = scrolledUp;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// Count a fresh arrival while the user is reading history, and pulse the
|
|
1474
|
+
// "new messages" pill so they know to page down.
|
|
1475
|
+
#noteIncoming() {
|
|
1476
|
+
if (this.#scrolledUp) {
|
|
1477
|
+
this.#unseenCount++;
|
|
1478
|
+
this.#refreshScrollIndicator();
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
#refreshScrollIndicator() {
|
|
1483
|
+
if (!this.#scrolledUp) {
|
|
1484
|
+
this.#stopPill();
|
|
1485
|
+
this.removeHeaderIndicator('scroll');
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
if (this.#unseenCount > 0) {
|
|
1489
|
+
if (!this.#pillTimer && process.stdout.isTTY) {
|
|
1490
|
+
this.#pillTimer = setInterval(() => {
|
|
1491
|
+
this.#pillFrame++;
|
|
1492
|
+
this.#renderPill();
|
|
1493
|
+
}, 450);
|
|
1494
|
+
if (this.#pillTimer.unref) {
|
|
1495
|
+
this.#pillTimer.unref();
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
this.#renderPill();
|
|
1499
|
+
} else {
|
|
1500
|
+
this.#stopPill();
|
|
1501
|
+
this.setHeaderIndicator('scroll', '{yellow-fg}[โ history โ PageDown to return]{/yellow-fg}');
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
#renderPill() {
|
|
1506
|
+
const n = this.#unseenCount;
|
|
1507
|
+
const plural = n === 1 ? 'new message' : 'new messages';
|
|
1508
|
+
const bright = this.#pillFrame % 2 === 0;
|
|
1509
|
+
const label = bright
|
|
1510
|
+
? `{black-fg}{yellow-bg} โ ${n} ${plural} โ PageDown {/yellow-bg}{/black-fg}`
|
|
1511
|
+
: `{yellow-fg}[โ ${n} ${plural} โ PageDown]{/yellow-fg}`;
|
|
1512
|
+
this.setHeaderIndicator('scroll', label);
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
#stopPill() {
|
|
1516
|
+
if (this.#pillTimer) {
|
|
1517
|
+
clearInterval(this.#pillTimer);
|
|
1518
|
+
this.#pillTimer = null;
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
getLine(lineIndex) {
|
|
1523
|
+
if (lineIndex < 0 || lineIndex >= this.#lines.length) {
|
|
1524
|
+
return null;
|
|
1525
|
+
}
|
|
1526
|
+
return this.#lines[lineIndex];
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
updateLine(lineIndex, newLine) {
|
|
1530
|
+
if (lineIndex < 0 || lineIndex >= this.#lines.length) {
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
if (this.#lines[lineIndex] === null) {
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
this.#lines[lineIndex] = newLine;
|
|
1537
|
+
const content = this.#lines.filter((l) => l !== null).join('\n');
|
|
1538
|
+
this.#chatLog.setContent(content);
|
|
1539
|
+
if (!this.#scrolledUp) {
|
|
1540
|
+
this.#chatLog.setScrollPerc(100);
|
|
1541
|
+
}
|
|
1542
|
+
this.#screen.render();
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
removeLine(lineIndex) {
|
|
1546
|
+
if (lineIndex < 0 || lineIndex >= this.#lines.length) {
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
this.#lines[lineIndex] = null;
|
|
1550
|
+
const content = this.#lines.filter((l) => l !== null).join('\n');
|
|
1551
|
+
this.#chatLog.setContent(content);
|
|
1552
|
+
if (!this.#scrolledUp) {
|
|
1553
|
+
this.#chatLog.setScrollPerc(100);
|
|
1554
|
+
}
|
|
1555
|
+
this.#screen.render();
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
// Ephemeral messages don't just vanish โ they burn. A flame front sweeps the
|
|
1559
|
+
// text leftโright (ignite โ hot โ ember โ ash โ gone), then the line is
|
|
1560
|
+
// removed. Non-TTY falls back to an instant removeLine.
|
|
1561
|
+
burnLine(lineIndex, onDone) {
|
|
1562
|
+
const orig = this.getLine(lineIndex);
|
|
1563
|
+
if (orig === null || orig === undefined) {
|
|
1564
|
+
onDone?.();
|
|
1565
|
+
return null;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// Strip blessed tags to get the raw glyphs, preserving leading padding
|
|
1569
|
+
// (right-aligned self messages) so the flame stays under the text.
|
|
1570
|
+
const plain = orig.replace(/\{[^{}]*\}/g, '');
|
|
1571
|
+
const lead = (plain.match(/^ */) || [''])[0];
|
|
1572
|
+
const body = [...plain.slice(lead.length)];
|
|
1573
|
+
const len = body.length;
|
|
1574
|
+
|
|
1575
|
+
const bodyStr = plain.slice(lead.length);
|
|
1576
|
+
if (!process.stdout.isTTY || len === 0) {
|
|
1577
|
+
this.removeLine(lineIndex);
|
|
1578
|
+
onDone?.();
|
|
1579
|
+
return null;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const TOTAL_FRAMES = 16;
|
|
1583
|
+
const advance = Math.max(1, (len + BURN_TAIL) / TOTAL_FRAMES);
|
|
1584
|
+
let front = 0;
|
|
1585
|
+
|
|
1586
|
+
const timer = setInterval(() => {
|
|
1587
|
+
front += advance;
|
|
1588
|
+
this.updateLine(lineIndex, lead + burnFrame(bodyStr, front));
|
|
1589
|
+
if (front >= len + BURN_TAIL) {
|
|
1590
|
+
clearInterval(timer);
|
|
1591
|
+
this.removeLine(lineIndex);
|
|
1592
|
+
onDone?.();
|
|
1593
|
+
}
|
|
1594
|
+
}, 45);
|
|
1595
|
+
if (timer.unref) {
|
|
1596
|
+
timer.unref();
|
|
1597
|
+
}
|
|
1598
|
+
return timer;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
clearChat() {
|
|
1602
|
+
this.#lines = [];
|
|
1603
|
+
this.#lastSender = null;
|
|
1604
|
+
this.#lastMsgDate = null;
|
|
1605
|
+
this.#chatLog.setContent('');
|
|
1606
|
+
this.#chatLog.setScroll(0);
|
|
1607
|
+
this.#syncScrollState();
|
|
1608
|
+
// Force a full repaint so no cells from the old (right-aligned, padded)
|
|
1609
|
+
// lines are left as artifacts on the screen.
|
|
1610
|
+
this.#screen.realloc();
|
|
1611
|
+
this.#screen.render();
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
// A single, in-place progress line with a moving shimmer and a live ETA.
|
|
1615
|
+
// Successive calls update the same line; a new transfer (percent goes
|
|
1616
|
+
// backwards, or after a finish) starts a fresh line.
|
|
1617
|
+
updateProgress(text, percent) {
|
|
1618
|
+
const pct = Math.max(0, Math.min(100, Math.round(percent)));
|
|
1619
|
+
const restart = this.#progIndex === null || pct < this.#progPercent;
|
|
1620
|
+
if (restart) {
|
|
1621
|
+
this.#lines.push('');
|
|
1622
|
+
this.#chatLog.log('');
|
|
1623
|
+
this.#progIndex = this.#lines.length - 1;
|
|
1624
|
+
this.#progStart = Date.now();
|
|
1625
|
+
this.#ensureShimmer();
|
|
1626
|
+
}
|
|
1627
|
+
this.#progPercent = pct;
|
|
1628
|
+
this.#progText = text;
|
|
1629
|
+
this.#renderProgress();
|
|
1630
|
+
if (pct >= 100) {
|
|
1631
|
+
this.#progIndex = null;
|
|
1632
|
+
this.#stopShimmer();
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Guaranteed terminator โ call when a transfer ends (complete/reject/error)
|
|
1637
|
+
// so the bar settles and the shimmer stops even if 100% was never delivered.
|
|
1638
|
+
finishProgress() {
|
|
1639
|
+
if (this.#progIndex !== null) {
|
|
1640
|
+
this.#progPercent = 100;
|
|
1641
|
+
this.#renderProgress();
|
|
1642
|
+
this.#progIndex = null;
|
|
1643
|
+
}
|
|
1644
|
+
this.#stopShimmer();
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
#ensureShimmer() {
|
|
1648
|
+
if (this.#shimmerTimer || !process.stdout.isTTY) {
|
|
1649
|
+
return;
|
|
1650
|
+
}
|
|
1651
|
+
this.#shimmerTimer = setInterval(() => {
|
|
1652
|
+
this.#shimmerPos++;
|
|
1653
|
+
this.#renderProgress();
|
|
1654
|
+
}, 90);
|
|
1655
|
+
if (this.#shimmerTimer.unref) {
|
|
1656
|
+
this.#shimmerTimer.unref();
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
#stopShimmer() {
|
|
1661
|
+
if (this.#shimmerTimer) {
|
|
1662
|
+
clearInterval(this.#shimmerTimer);
|
|
1663
|
+
this.#shimmerTimer = null;
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
#renderProgress() {
|
|
1668
|
+
if (this.#progIndex === null) {
|
|
1669
|
+
return;
|
|
1670
|
+
}
|
|
1671
|
+
const pct = this.#progPercent;
|
|
1672
|
+
const done = pct >= 100;
|
|
1673
|
+
const bar = progressBar(pct, this.#shimmerPos);
|
|
1674
|
+
const head = done ? '{green-fg}โ{/green-fg}' : '{#00b8ff-fg}โ
{/#00b8ff-fg}';
|
|
1675
|
+
const eta = formatETA(Date.now() - this.#progStart, pct);
|
|
1676
|
+
const tail = done
|
|
1677
|
+
? '{green-fg}100% complete{/green-fg}'
|
|
1678
|
+
: `{white-fg}${pct}%{/white-fg}${eta ? ` {#888888-fg}${eta}{/#888888-fg}` : ''}`;
|
|
1679
|
+
const line = ` {white-fg}[${time()}]{/white-fg} ${head} ${blessed.escape(this.#progText)} [${bar}] ${tail}`;
|
|
1680
|
+
this.updateLine(this.#progIndex, line);
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// A brief "secure channel" flourish when a peer's E2E link is established:
|
|
1684
|
+
// a spark travels between the two nicks, then a lock snaps shut.
|
|
1685
|
+
handshakeConnect(peerNickname) {
|
|
1686
|
+
const me = blessed.escape(this.#nickname);
|
|
1687
|
+
const peer = blessed.escape(peerNickname);
|
|
1688
|
+
const label = (mid) =>
|
|
1689
|
+
` {bold}{#00b8ff-fg}${me}{/#00b8ff-fg}{/bold} ${mid} {bold}{#7b2dff-fg}${peer}{/#7b2dff-fg}{/bold}`;
|
|
1690
|
+
const done = ` {green-fg}๐ Secure channel with {bold}${peer}{/bold} โ E2E established{/green-fg}`;
|
|
1691
|
+
|
|
1692
|
+
if (!process.stdout.isTTY) {
|
|
1693
|
+
this.#lastSender = null;
|
|
1694
|
+
this.#lines.push(done);
|
|
1695
|
+
this.#chatLog.log(done);
|
|
1696
|
+
this.#screen.render();
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
this.#lastSender = null;
|
|
1701
|
+
this.#lines.push('');
|
|
1702
|
+
const idx = this.#lines.length - 1;
|
|
1703
|
+
const W = 11;
|
|
1704
|
+
const total = W + 6;
|
|
1705
|
+
let f = 0;
|
|
1706
|
+
const timer = setInterval(() => {
|
|
1707
|
+
let mid;
|
|
1708
|
+
if (f < W) {
|
|
1709
|
+
let track = '';
|
|
1710
|
+
for (let i = 0; i < W; i++) {
|
|
1711
|
+
if (i === f) {
|
|
1712
|
+
track += '{#ffd000-fg}โ{/#ffd000-fg}';
|
|
1713
|
+
} else if (i < f) {
|
|
1714
|
+
track += '{#00b8ff-fg}โ{/#00b8ff-fg}';
|
|
1715
|
+
} else {
|
|
1716
|
+
track += '{#333333-fg}โ{/#333333-fg}';
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
mid = track;
|
|
1720
|
+
} else {
|
|
1721
|
+
const lock = (f - W) % 2 === 0 ? '{#ffd000-fg}๐{/#ffd000-fg}' : '{green-fg}๐{/green-fg}';
|
|
1722
|
+
mid = `{#00b8ff-fg}โโโโโ{/#00b8ff-fg}${lock}{#00b8ff-fg}โโโโโ{/#00b8ff-fg}`;
|
|
1723
|
+
}
|
|
1724
|
+
this.updateLine(idx, label(mid));
|
|
1725
|
+
f++;
|
|
1726
|
+
if (f >= total) {
|
|
1727
|
+
clearInterval(timer);
|
|
1728
|
+
this.updateLine(idx, done);
|
|
1729
|
+
}
|
|
1730
|
+
}, 60);
|
|
1731
|
+
if (timer.unref) {
|
|
1732
|
+
timer.unref();
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1736
|
+
// The mirror of handshakeConnect: the secure link weakens and the lock opens
|
|
1737
|
+
// when a peer leaves.
|
|
1738
|
+
handshakeDisconnect(peerNickname) {
|
|
1739
|
+
const me = blessed.escape(this.#nickname);
|
|
1740
|
+
const peer = blessed.escape(peerNickname);
|
|
1741
|
+
const label = (mid) =>
|
|
1742
|
+
` {bold}{#00b8ff-fg}${me}{/#00b8ff-fg}{/bold} ${mid} {bold}{#7b2dff-fg}${peer}{/#7b2dff-fg}{/bold}`;
|
|
1743
|
+
const done = ` {#888888-fg}๐ {bold}${peer}{/bold} left โ channel closed{/#888888-fg}`;
|
|
1744
|
+
|
|
1745
|
+
if (!process.stdout.isTTY) {
|
|
1746
|
+
this.#lastSender = null;
|
|
1747
|
+
this.#lines.push(done);
|
|
1748
|
+
this.#chatLog.log(done);
|
|
1749
|
+
this.#screen.render();
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
this.#lastSender = null;
|
|
1754
|
+
this.#lines.push('');
|
|
1755
|
+
const idx = this.#lines.length - 1;
|
|
1756
|
+
const W = 11;
|
|
1757
|
+
const mid_i = Math.floor(W / 2);
|
|
1758
|
+
const total = W + 6;
|
|
1759
|
+
let f = 0;
|
|
1760
|
+
const timer = setInterval(() => {
|
|
1761
|
+
let mid;
|
|
1762
|
+
if (f < W) {
|
|
1763
|
+
// The link dims from both ends inward; the lock stays shut for now.
|
|
1764
|
+
let track = '';
|
|
1765
|
+
for (let i = 0; i < W; i++) {
|
|
1766
|
+
if (i === mid_i) {
|
|
1767
|
+
track += '{green-fg}๐{/green-fg}';
|
|
1768
|
+
} else {
|
|
1769
|
+
const distFromEnd = Math.min(i, W - 1 - i);
|
|
1770
|
+
track +=
|
|
1771
|
+
distFromEnd < f / 2 ? '{#333333-fg}ยท{/#333333-fg}' : '{#00b8ff-fg}โ{/#00b8ff-fg}';
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
mid = track;
|
|
1775
|
+
} else {
|
|
1776
|
+
// The lock clicks open.
|
|
1777
|
+
const lock =
|
|
1778
|
+
(f - W) % 2 === 0 ? '{#ffd000-fg}๐{/#ffd000-fg}' : '{#888888-fg}๐{/#888888-fg}';
|
|
1779
|
+
mid = `{#333333-fg}ยทยทยทยทยท{/#333333-fg}${lock}{#333333-fg}ยทยทยทยทยท{/#333333-fg}`;
|
|
1780
|
+
}
|
|
1781
|
+
this.updateLine(idx, label(mid));
|
|
1782
|
+
f++;
|
|
1783
|
+
if (f >= total) {
|
|
1784
|
+
clearInterval(timer);
|
|
1785
|
+
this.updateLine(idx, done);
|
|
1786
|
+
}
|
|
1787
|
+
}, 60);
|
|
1788
|
+
if (timer.unref) {
|
|
1789
|
+
timer.unref();
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// โโ Sound notifications โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1794
|
+
get soundEnabled() {
|
|
1795
|
+
return this.#soundEnabled;
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
setSoundEnabled(enabled) {
|
|
1799
|
+
this.#soundEnabled = enabled;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
get notifyEnabled() {
|
|
1803
|
+
return this.#notifyEnabled;
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
setNotifyEnabled(enabled) {
|
|
1807
|
+
this.#notifyEnabled = enabled;
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
playNotification() {
|
|
1811
|
+
if (this.#soundEnabled) {
|
|
1812
|
+
process.stdout.write('\x07');
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
destroy() {
|
|
1817
|
+
if (this.#typingAnimInterval) {
|
|
1818
|
+
clearInterval(this.#typingAnimInterval);
|
|
1819
|
+
}
|
|
1820
|
+
if (this.#connSpinner) {
|
|
1821
|
+
clearInterval(this.#connSpinner);
|
|
1822
|
+
}
|
|
1823
|
+
if (this.#reconnectFlashTimer) {
|
|
1824
|
+
clearInterval(this.#reconnectFlashTimer);
|
|
1825
|
+
}
|
|
1826
|
+
this.#stopShimmer();
|
|
1827
|
+
this.#stopPill();
|
|
1828
|
+
this.#screen.destroy();
|
|
1829
|
+
}
|
|
1830
|
+
}
|