@profullstack/qryptchat 0.7.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/bin/qc.js +1 -1
- package/dist/{chunk-g9xn5036.js → chunk-8gpysrd6.js} +51 -1
- package/dist/{chunk-bdg6cmfy.js → chunk-wtbsdxe7.js} +119 -4
- package/dist/commands.js +2 -2
- package/dist/mcp.js +2 -2
- package/dist/tui.js +134 -18
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,10 +3,15 @@
|
|
|
3
3
|
[qrypt.chat](https://qrypt.chat) in your terminal: a full-screen, end-to-end encrypted chat client, a scriptable CLI and an MCP server, all in one command.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
|
-
|
|
6
|
+
curl -fsSL https://qrypt.chat/install.sh | sh
|
|
7
7
|
qc
|
|
8
8
|
```
|
|
9
9
|
|
|
10
|
+
The installer puts `qc` (and its alias `qryptchat`) under `~/.local`, never asks for sudo, and
|
|
11
|
+
fetches a private official Node if this machine has no Node 22+. Run it again to update; remove
|
|
12
|
+
with `sh ~/.local/share/qryptchat/uninstall.sh`. Or install it with a package manager:
|
|
13
|
+
`npm install -g @profullstack/qryptchat` (or `bun add -g @profullstack/qryptchat`).
|
|
14
|
+
|
|
10
15
|
The first run signs you in through your browser, then opens your chats.
|
|
11
16
|
|
|
12
17
|
## The client
|
package/bin/qc.js
CHANGED
|
@@ -6,7 +6,7 @@ import { errorLine, exitWhenFlushed, main } from '../dist/commands.js';
|
|
|
6
6
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
7
7
|
|
|
8
8
|
main(process.argv.slice(2), { version: pkg.version }).then(
|
|
9
|
-
() => exitWhenFlushed(0),
|
|
9
|
+
() => exitWhenFlushed(process.exitCode ?? 0),
|
|
10
10
|
(err) => {
|
|
11
11
|
process.stderr.write(`${errorLine(err)}\n`);
|
|
12
12
|
exitWhenFlushed(1);
|
|
@@ -214,4 +214,54 @@ function notify(title, body, { env = process.env, streams = [process.stdout, pro
|
|
|
214
214
|
return Boolean(seq && tty);
|
|
215
215
|
}
|
|
216
216
|
|
|
217
|
-
|
|
217
|
+
// src/lib/chat/voice.js
|
|
218
|
+
var PAIRUX_URL = "https://pairux.com";
|
|
219
|
+
var toBase64Url = (bytes) => {
|
|
220
|
+
let s = "";
|
|
221
|
+
for (const b of bytes)
|
|
222
|
+
s += String.fromCharCode(b);
|
|
223
|
+
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
224
|
+
};
|
|
225
|
+
function newVoiceRoomLink(base = PAIRUX_URL) {
|
|
226
|
+
const room = toBase64Url(crypto.getRandomValues(new Uint8Array(16)));
|
|
227
|
+
const key = toBase64Url(crypto.getRandomValues(new Uint8Array(32)));
|
|
228
|
+
return `${String(base).replace(/\/$/, "")}/r/${room}#k=${key}`;
|
|
229
|
+
}
|
|
230
|
+
var voiceMessage = (url) => `\uD83C\uDF99️ Join my end-to-end encrypted voice room (no account needed): ${url}`;
|
|
231
|
+
function parseSlashCommand(text, names) {
|
|
232
|
+
const m = /^\/([a-z]+)(?:\s+(.*))?$/s.exec(String(text).trim());
|
|
233
|
+
if (!m || !names.includes(m[1]))
|
|
234
|
+
return null;
|
|
235
|
+
return { name: m[1], args: (m[2] ?? "").trim() };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/lib/chat/irc.js
|
|
239
|
+
var nameOf = (p) => {
|
|
240
|
+
const u = p.user || p;
|
|
241
|
+
return u.username || u.display_name || "unknown";
|
|
242
|
+
};
|
|
243
|
+
function namesLines(conversation, title = conversation?.name || "this chat") {
|
|
244
|
+
const people = [...conversation?.participants || []].sort((a, b) => (b.role === "admin") - (a.role === "admin") || nameOf(a).localeCompare(nameOf(b)));
|
|
245
|
+
const names = people.map((p) => {
|
|
246
|
+
const u = p.user || p;
|
|
247
|
+
const op = p.role === "admin" ? "@" : p.role === "moderator" ? "+" : "";
|
|
248
|
+
const agent = u.account_type === "agent" ? " \uD83E\uDD16" : "";
|
|
249
|
+
return `${op}${nameOf(p)}${agent}`;
|
|
250
|
+
});
|
|
251
|
+
const n = names.length;
|
|
252
|
+
return [`${title}: ${n} ${n === 1 ? "person" : "people"}`, names.join(" ") || "(nobody)"];
|
|
253
|
+
}
|
|
254
|
+
function listLines(conversations, titleOf = (c) => c.name || "Chat") {
|
|
255
|
+
const live = (conversations || []).filter((c) => !c.is_archived);
|
|
256
|
+
const rank = { group: 0, direct: 1, note_to_self: 2 };
|
|
257
|
+
const sorted = [...live].sort((a, b) => (rank[a.type] ?? 1) - (rank[b.type] ?? 1) || titleOf(a).localeCompare(titleOf(b)));
|
|
258
|
+
const lines = sorted.map((c) => {
|
|
259
|
+
const n = c.participants?.length ?? 0;
|
|
260
|
+
const kind = c.type === "group" ? "group" : c.type === "note_to_self" ? "note" : "direct";
|
|
261
|
+
return `${c.type === "group" ? "#" : " "}${titleOf(c)} ${n} ${n === 1 ? "person" : "people"} · ${kind}`;
|
|
262
|
+
});
|
|
263
|
+
const archived = (conversations || []).length - live.length;
|
|
264
|
+
return [`${live.length} ${live.length === 1 ? "chat" : "chats"}${archived ? ` (+${archived} archived)` : ""}`, ...lines];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export { DEFAULT_URL, configDir, sessionPath, baseUrl, loadSession, unlockSession, storeSession, clearSession, notify, PAIRUX_URL, newVoiceRoomLink, voiceMessage, parseSlashCommand, namesLines, listLines };
|
|
@@ -6,8 +6,12 @@ import {
|
|
|
6
6
|
unlockSession,
|
|
7
7
|
storeSession,
|
|
8
8
|
clearSession,
|
|
9
|
-
notify
|
|
10
|
-
|
|
9
|
+
notify,
|
|
10
|
+
PAIRUX_URL,
|
|
11
|
+
newVoiceRoomLink,
|
|
12
|
+
voiceMessage,
|
|
13
|
+
namesLines
|
|
14
|
+
} from "./chunk-8gpysrd6.js";
|
|
11
15
|
|
|
12
16
|
// src/cli/crypto.js
|
|
13
17
|
import { MlKem1024 as MlKem10242 } from "mlkem";
|
|
@@ -743,6 +747,9 @@ class QcClient {
|
|
|
743
747
|
throw new QcError(`HTTP ${res.status}`, res.status);
|
|
744
748
|
return res.text();
|
|
745
749
|
}
|
|
750
|
+
async archive(conversationId, on = true) {
|
|
751
|
+
return this.request(`/api/conversations/${on ? "archive" : "unarchive"}`, { method: "POST", body: { conversationId } });
|
|
752
|
+
}
|
|
746
753
|
async conversations() {
|
|
747
754
|
const { conversations = [] } = await this.request("/api/conversations/load", { method: "POST", body: {} });
|
|
748
755
|
return conversations.map((c) => ({ ...c, title: conversationTitle(c, this.me) }));
|
|
@@ -1181,6 +1188,55 @@ function who(user, { agent = false } = {}) {
|
|
|
1181
1188
|
return `${face}${name}${handle}${pronouns}`;
|
|
1182
1189
|
}
|
|
1183
1190
|
|
|
1191
|
+
// src/cli/voice.js
|
|
1192
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1193
|
+
var PAIRUX_INSTALL = "curl -fsSL https://installer.pairux.com/install.sh | bash";
|
|
1194
|
+
var VOICE_HELP = `qc voice: end-to-end encrypted voice rooms on PairUX (an alias for the pairux CLI)
|
|
1195
|
+
|
|
1196
|
+
Usage:
|
|
1197
|
+
qc voice [--send <chat>] [--json] print a link to a fresh anonymous room; --send posts it to a chat.
|
|
1198
|
+
Made on this machine: the key is in the #fragment, so PairUX
|
|
1199
|
+
never sees it, and nobody needs an account to join.
|
|
1200
|
+
qc voice login|logout|whoami sign the pairux CLI in or out
|
|
1201
|
+
qc voice join <CODE> | say | who | listen | status | leave | help
|
|
1202
|
+
any other subcommand runs \`pairux <subcommand>\`
|
|
1203
|
+
|
|
1204
|
+
The pairux CLI comes with the PairUX app: ${PAIRUX_INSTALL}
|
|
1205
|
+
PAIRUX_API_URL changes the server (default ${PAIRUX_URL}).`;
|
|
1206
|
+
function parseVoiceArgs(argv) {
|
|
1207
|
+
const [first] = argv;
|
|
1208
|
+
if (first === "help" || first === "--help" || first === "-h")
|
|
1209
|
+
return { action: "help" };
|
|
1210
|
+
if (first === undefined || first.startsWith("--") || first === "link" || first === "room" || first === "new") {
|
|
1211
|
+
const rest = first && !first.startsWith("--") ? argv.slice(1) : argv;
|
|
1212
|
+
const flags = { json: false, send: null };
|
|
1213
|
+
for (let i = 0;i < rest.length; i++) {
|
|
1214
|
+
if (rest[i] === "--json")
|
|
1215
|
+
flags.json = true;
|
|
1216
|
+
else if (rest[i] === "--send" && rest[i + 1])
|
|
1217
|
+
flags.send = rest[++i];
|
|
1218
|
+
else if (rest[i].startsWith("--send="))
|
|
1219
|
+
flags.send = rest[i].slice(7);
|
|
1220
|
+
else
|
|
1221
|
+
throw new Error(`Unknown option "${rest[i]}" for qc voice.
|
|
1222
|
+
|
|
1223
|
+
${VOICE_HELP}`);
|
|
1224
|
+
}
|
|
1225
|
+
return { action: "link", ...flags };
|
|
1226
|
+
}
|
|
1227
|
+
return { action: "pairux", args: argv };
|
|
1228
|
+
}
|
|
1229
|
+
var voiceLink = (env = process.env) => newVoiceRoomLink(env.PAIRUX_API_URL || PAIRUX_URL);
|
|
1230
|
+
function runPairux(args, { spawnImpl = spawn2, env = process.env } = {}) {
|
|
1231
|
+
return new Promise((resolve, reject) => {
|
|
1232
|
+
const child = spawnImpl(env.PAIRUX_BIN || "pairux", args, { stdio: "inherit", env });
|
|
1233
|
+
child.on("error", (err) => {
|
|
1234
|
+
reject(err.code === "ENOENT" ? new Error(`The pairux CLI is not installed. Install PairUX: ${PAIRUX_INSTALL}`) : err);
|
|
1235
|
+
});
|
|
1236
|
+
child.on("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
1237
|
+
});
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1184
1240
|
// src/cli/exit.js
|
|
1185
1241
|
function exitWhenFlushed2(code, { timeoutMs = 30000 } = {}) {
|
|
1186
1242
|
setTimeout(() => process.exit(code), timeoutMs);
|
|
@@ -1202,7 +1258,11 @@ Usage:
|
|
|
1202
1258
|
qc profile [--emoji \uD83D\uDD2D] [--pronouns she/her] [--website URL] [--bio TEXT]
|
|
1203
1259
|
show or set your public profile (an empty value clears it);
|
|
1204
1260
|
--openprofile prints it as OpenProfile.md
|
|
1205
|
-
qc chats
|
|
1261
|
+
qc chats [--archived] list your chats (archived ones only with --archived); qc list is the same
|
|
1262
|
+
qc names <chat> who is in a chat (@ = admin, \uD83E\uDD16 = agent), like IRC /names
|
|
1263
|
+
qc archive <chat> archive a chat for you (qc unarchive <chat> brings it back)
|
|
1264
|
+
qc voice [--send <chat>] a link to a fresh end-to-end encrypted PairUX voice room (no account
|
|
1265
|
+
needed to join); qc voice login|join|... runs the pairux CLI (qc voice help)
|
|
1206
1266
|
qc read <chat> [-n 20] print the last messages of a chat
|
|
1207
1267
|
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
|
|
1208
1268
|
qc react <chat> <message-id> <emoji> [--remove] react to a message
|
|
@@ -1286,6 +1346,8 @@ async function main2(argv, { version = "0.0.0" } = {}) {
|
|
|
1286
1346
|
const out = (line) => process.stdout.write(`${line}
|
|
1287
1347
|
`);
|
|
1288
1348
|
const json = (value) => out(JSON.stringify(value, null, flags.json === "compact" ? 0 : 2));
|
|
1349
|
+
if (cmd === "voice")
|
|
1350
|
+
return voiceCommand(argv.slice(argv.indexOf("voice") + 1), { out, json });
|
|
1289
1351
|
if (flags.version)
|
|
1290
1352
|
return out(`qc ${version}`);
|
|
1291
1353
|
if (flags.help || cmd === "help")
|
|
@@ -1386,14 +1448,38 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1386
1448
|
return;
|
|
1387
1449
|
}
|
|
1388
1450
|
case "chats":
|
|
1451
|
+
case "list":
|
|
1389
1452
|
case "ls": {
|
|
1390
|
-
const
|
|
1453
|
+
const all = await (await client(flags)).conversations();
|
|
1454
|
+
const chats = all.filter((c) => !!c.is_archived === !!flags.archived);
|
|
1391
1455
|
if (flags.json)
|
|
1392
1456
|
return json(chats.map((c) => ({ id: c.id, title: c.title, type: c.type, updated_at: c.updated_at, participants: c.participants?.length ?? 0 })));
|
|
1393
1457
|
for (const c of chats)
|
|
1394
1458
|
out(`${c.id} ${mark(c.type === "group" ? "users" : "chat")}${c.title}`);
|
|
1395
1459
|
return;
|
|
1396
1460
|
}
|
|
1461
|
+
case "archive":
|
|
1462
|
+
case "unarchive": {
|
|
1463
|
+
if (!rest[0])
|
|
1464
|
+
throw new Error(`Usage: qc ${cmd} <chat>`);
|
|
1465
|
+
const c = await client(flags);
|
|
1466
|
+
const chat = findChat2(await c.conversations(), rest[0]);
|
|
1467
|
+
await c.archive(chat.id, cmd === "archive");
|
|
1468
|
+
if (flags.json)
|
|
1469
|
+
return json({ id: chat.id, archived: cmd === "archive" });
|
|
1470
|
+
return out(ok(cmd === "archive" ? `Archived ${chat.title}. qc chats --archived lists it; qc unarchive brings it back.` : `${chat.title} is back in your chats.`));
|
|
1471
|
+
}
|
|
1472
|
+
case "names": {
|
|
1473
|
+
if (!rest[0])
|
|
1474
|
+
throw new Error("Usage: qc names <chat>");
|
|
1475
|
+
const chat = findChat2(await (await client(flags)).conversations(), rest[0]);
|
|
1476
|
+
if (flags.json) {
|
|
1477
|
+
return json((chat.participants || []).map((p) => ({ id: p.user_id, username: p.user?.username, display_name: p.user?.display_name, role: p.role, agent: p.user?.account_type === "agent" })));
|
|
1478
|
+
}
|
|
1479
|
+
for (const line of namesLines(chat, chat.title))
|
|
1480
|
+
out(line);
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1397
1483
|
case "read": {
|
|
1398
1484
|
if (!rest[0])
|
|
1399
1485
|
throw new Error("Usage: qc read <chat> [-n 20]");
|
|
@@ -1507,5 +1593,34 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1507
1593
|
${HELP2}`);
|
|
1508
1594
|
}
|
|
1509
1595
|
}
|
|
1596
|
+
async function voiceCommand(argv, { out, json }) {
|
|
1597
|
+
const parsed = parseVoiceArgs(argv);
|
|
1598
|
+
if (parsed.action === "help")
|
|
1599
|
+
return out(VOICE_HELP);
|
|
1600
|
+
if (parsed.action === "pairux") {
|
|
1601
|
+
const code = await runPairux(parsed.args);
|
|
1602
|
+
if (code)
|
|
1603
|
+
process.exitCode = code;
|
|
1604
|
+
return;
|
|
1605
|
+
}
|
|
1606
|
+
const url = voiceLink();
|
|
1607
|
+
let sent = null;
|
|
1608
|
+
if (parsed.send) {
|
|
1609
|
+
const c = await client({});
|
|
1610
|
+
const chat = findChat2(await c.conversations(), parsed.send);
|
|
1611
|
+
const { skipped } = await c.send(chat.id, voiceMessage(url));
|
|
1612
|
+
sent = { id: chat.id, title: chat.title, skipped };
|
|
1613
|
+
}
|
|
1614
|
+
if (parsed.json)
|
|
1615
|
+
return json({ url, sent });
|
|
1616
|
+
out(url);
|
|
1617
|
+
if (sent) {
|
|
1618
|
+
if (sent.skipped)
|
|
1619
|
+
process.stderr.write(`qc: ${warn(`${sent.skipped} participant(s) have no key yet and will not see it.`)}
|
|
1620
|
+
`);
|
|
1621
|
+
process.stderr.write(`qc: ${mark("send")}Sent to ${sent.title}.
|
|
1622
|
+
`);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1510
1625
|
|
|
1511
1626
|
export { exitWhenFlushed2, errorLine2, HELP2, parseArgs2, findChat2, main2 };
|
package/dist/commands.js
CHANGED
package/dist/mcp.js
CHANGED
package/dist/tui.js
CHANGED
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
import {
|
|
2
2
|
configDir,
|
|
3
|
-
notify
|
|
4
|
-
|
|
3
|
+
notify,
|
|
4
|
+
PAIRUX_URL,
|
|
5
|
+
newVoiceRoomLink,
|
|
6
|
+
voiceMessage,
|
|
7
|
+
parseSlashCommand,
|
|
8
|
+
namesLines,
|
|
9
|
+
listLines
|
|
10
|
+
} from "./chunk-8gpysrd6.js";
|
|
5
11
|
|
|
6
12
|
// src/cli/tui.js
|
|
7
13
|
import { createApp, createImageStore, drawIcon, drawRichText, editText, emojify, insertText, stringWidth, truncate, widgets, wrap } from "@profullstack/hqtui";
|
|
8
14
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
15
|
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
// src/lib/chat/membership-events.js
|
|
18
|
+
var MEMBERSHIP_EVENTS = {
|
|
19
|
+
JOINED: "PARTICIPANTS_JOINED",
|
|
20
|
+
LEFT: "PARTICIPANT_LEFT",
|
|
21
|
+
ARCHIVED: "CONVERSATION_ARCHIVED"
|
|
22
|
+
};
|
|
23
|
+
var CHAT_LIST_EVENTS = new Set(["CONVERSATION_CREATED", ...Object.values(MEMBERSHIP_EVENTS)]);
|
|
24
|
+
|
|
25
|
+
// src/cli/tui.js
|
|
10
26
|
var SIDEBAR = 30;
|
|
11
27
|
function initialState(me) {
|
|
12
28
|
return {
|
|
@@ -24,9 +40,17 @@ function initialState(me) {
|
|
|
24
40
|
scrollBack: 0,
|
|
25
41
|
status: "connecting",
|
|
26
42
|
notice: "",
|
|
27
|
-
loading: true
|
|
43
|
+
loading: true,
|
|
44
|
+
showArchived: false
|
|
28
45
|
};
|
|
29
46
|
}
|
|
47
|
+
var listed = (state) => state.conversations.filter((c) => !!c.is_archived === !!state.showArchived);
|
|
48
|
+
var CHAT_LIST_REFRESH_MS = 15000;
|
|
49
|
+
function chatsWithNews(prev, next) {
|
|
50
|
+
const seen = new Map(prev.map((c) => [c.id, c.updated_at ?? ""]));
|
|
51
|
+
return next.filter((c) => !seen.has(c.id) || (c.updated_at ?? "") > seen.get(c.id)).map((c) => c.id);
|
|
52
|
+
}
|
|
53
|
+
var SLASH_COMMANDS = ["voice", "archive", "unarchive", "archived", "names", "list"];
|
|
30
54
|
var pad2 = (n) => String(n).padStart(2, "0");
|
|
31
55
|
var timeOf = (iso) => {
|
|
32
56
|
const d = new Date(iso);
|
|
@@ -132,10 +156,13 @@ function render({ ui, width, height, theme }, state, on = {}) {
|
|
|
132
156
|
});
|
|
133
157
|
ui.row({ size: "fill" }, (row) => {
|
|
134
158
|
const listWidth = Math.min(SIDEBAR, Math.max(18, Math.floor(width * 0.3)));
|
|
135
|
-
|
|
159
|
+
const chats = listed(state);
|
|
160
|
+
const archivedCount = state.conversations.filter((c) => c.is_archived).length;
|
|
161
|
+
const listTitle = state.showArchived ? `Archived (${archivedCount})` : archivedCount ? `Chats · ${archivedCount} archived` : "Chats";
|
|
162
|
+
row.panel({ title: truncate(listTitle, Math.max(4, listWidth - 4)), size: listWidth, borderColor: state.focus === "list" ? theme?.borderFocused : undefined }, (p) => {
|
|
136
163
|
const inner = Math.max(4, listWidth - 5);
|
|
137
164
|
p.list({
|
|
138
|
-
items:
|
|
165
|
+
items: chats.map((c) => {
|
|
139
166
|
const unread = state.unread[c.id] || 0;
|
|
140
167
|
const tag = unread ? ` ●${unread > 99 ? "99+" : unread}` : "";
|
|
141
168
|
const name = truncate(c.title, inner - stringWidth(tag));
|
|
@@ -177,6 +204,8 @@ function render({ ui, width, height, theme }, state, on = {}) {
|
|
|
177
204
|
{ key: "Ctrl+E", label: "emoji" },
|
|
178
205
|
{ key: "Tab", label: state.focus === "list" ? "compose" : "chats" },
|
|
179
206
|
{ key: "PgUp", label: "scroll" },
|
|
207
|
+
...state.focus === "list" ? [{ key: "a", label: state.showArchived ? "unarchive" : "archive" }] : [],
|
|
208
|
+
{ key: "Ctrl+O", label: state.showArchived ? "chats" : "archived" },
|
|
180
209
|
{ key: "Ctrl+C", label: "quit" }
|
|
181
210
|
],
|
|
182
211
|
size: 1
|
|
@@ -234,23 +263,41 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
234
263
|
async function loadMessages(id) {
|
|
235
264
|
try {
|
|
236
265
|
const { messages } = await client.messages(id, { limit: 100 });
|
|
237
|
-
state.messages[id]
|
|
266
|
+
const local = (state.messages[id] || []).filter((m) => m.local);
|
|
267
|
+
state.messages[id] = local.length ? [...messages, ...local].sort((a, b) => String(a.at).localeCompare(String(b.at))) : messages;
|
|
238
268
|
redraw();
|
|
239
269
|
} catch (err) {
|
|
240
270
|
note(`Could not load messages: ${err.message}`);
|
|
241
271
|
}
|
|
242
272
|
}
|
|
243
273
|
let keysChecked = false;
|
|
244
|
-
|
|
274
|
+
let listLoading = false;
|
|
275
|
+
async function loadConversations({ periodic = false } = {}) {
|
|
276
|
+
if (listLoading)
|
|
277
|
+
return;
|
|
278
|
+
listLoading = true;
|
|
245
279
|
try {
|
|
246
280
|
const before = state.activeId;
|
|
281
|
+
const prev = state.conversations;
|
|
247
282
|
state.conversations = await client.conversations();
|
|
248
283
|
state.loading = false;
|
|
249
|
-
if (
|
|
250
|
-
const
|
|
284
|
+
if (periodic) {
|
|
285
|
+
for (const id of chatsWithNews(prev, state.conversations)) {
|
|
286
|
+
if (id === state.activeId)
|
|
287
|
+
loadMessages(id);
|
|
288
|
+
else {
|
|
289
|
+
delete state.messages[id];
|
|
290
|
+
if (state.status !== "live" && prev.some((c) => c.id === id))
|
|
291
|
+
state.unread[id] = (state.unread[id] || 0) + 1;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const chats = listed(state);
|
|
296
|
+
if (!state.activeId && chats.length) {
|
|
297
|
+
const want = initialChat ? chats.findIndex((c) => c.id === initialChat || c.title.toLowerCase().includes(String(initialChat).toLowerCase())) : 0;
|
|
251
298
|
openAt(Math.max(0, want));
|
|
252
299
|
} else {
|
|
253
|
-
state.selected = Math.max(0,
|
|
300
|
+
state.selected = Math.max(0, chats.findIndex((c) => c.id === before));
|
|
254
301
|
}
|
|
255
302
|
redraw();
|
|
256
303
|
if (!keysChecked) {
|
|
@@ -260,11 +307,14 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
260
307
|
}
|
|
261
308
|
} catch (err) {
|
|
262
309
|
state.loading = false;
|
|
263
|
-
|
|
310
|
+
if (!periodic || err.status === 401)
|
|
311
|
+
note(err.status === 401 ? "Session ended: run qc login" : `Could not load chats: ${err.message}`);
|
|
312
|
+
} finally {
|
|
313
|
+
listLoading = false;
|
|
264
314
|
}
|
|
265
315
|
}
|
|
266
316
|
function openAt(i) {
|
|
267
|
-
const c = state
|
|
317
|
+
const c = listed(state)[i];
|
|
268
318
|
if (!c)
|
|
269
319
|
return;
|
|
270
320
|
state.selected = i;
|
|
@@ -285,8 +335,57 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
285
335
|
client.typing(state.activeId, true);
|
|
286
336
|
}
|
|
287
337
|
}
|
|
288
|
-
|
|
289
|
-
|
|
338
|
+
function toggleArchivedView() {
|
|
339
|
+
state.showArchived = !state.showArchived;
|
|
340
|
+
const chats = listed(state);
|
|
341
|
+
state.selected = Math.max(0, chats.findIndex((c) => c.id === state.activeId));
|
|
342
|
+
if (!state.showArchived && !chats.length)
|
|
343
|
+
note("No chats here yet.");
|
|
344
|
+
if (state.showArchived && !chats.length)
|
|
345
|
+
note("Nothing archived. Press a on a chat (or type /archive) to archive it.");
|
|
346
|
+
redraw();
|
|
347
|
+
}
|
|
348
|
+
async function setArchived(chat, on) {
|
|
349
|
+
try {
|
|
350
|
+
await client.archive(chat.id, on);
|
|
351
|
+
chat.is_archived = on;
|
|
352
|
+
const chats = listed(state);
|
|
353
|
+
state.selected = Math.max(0, Math.min(state.selected, chats.length - 1));
|
|
354
|
+
note(on ? `Archived ${chat.title} (Ctrl+O shows archived chats)` : `${chat.title} is back in your chats`);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
note(`Could not ${on ? "archive" : "unarchive"}: ${err.message}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
function runSlash(raw) {
|
|
360
|
+
const cmd = parseSlashCommand(raw, SLASH_COMMANDS);
|
|
361
|
+
if (!cmd)
|
|
362
|
+
return false;
|
|
363
|
+
const active = state.conversations.find((c) => c.id === state.activeId);
|
|
364
|
+
state.field = { value: "", cursor: 0 };
|
|
365
|
+
if (cmd.name === "archived")
|
|
366
|
+
toggleArchivedView();
|
|
367
|
+
else if (!active)
|
|
368
|
+
note("Pick a chat first");
|
|
369
|
+
else if (cmd.name === "names" || cmd.name === "list") {
|
|
370
|
+
const lines = cmd.name === "names" ? namesLines(active, active.title) : listLines(state.conversations, (c) => c.title);
|
|
371
|
+
const at = new Date().toISOString();
|
|
372
|
+
const local = { id: `local-${Date.now()}`, senderId: "qc", sender: `qc /${cmd.name}`, local: true, text: lines.join(`
|
|
373
|
+
`), at };
|
|
374
|
+
state.messages[active.id] = [...state.messages[active.id] || [], local];
|
|
375
|
+
state.scrollBack = 0;
|
|
376
|
+
} else if (cmd.name === "voice") {
|
|
377
|
+
const url = newVoiceRoomLink(process.env.PAIRUX_API_URL || PAIRUX_URL);
|
|
378
|
+
state.field = { value: voiceMessage(url), cursor: 0 };
|
|
379
|
+
send({ raw: true }).then(() => note("Voice room posted; open the link in a browser to join", { sticky: false }));
|
|
380
|
+
} else
|
|
381
|
+
setArchived(active, cmd.name === "archive");
|
|
382
|
+
redraw();
|
|
383
|
+
return true;
|
|
384
|
+
}
|
|
385
|
+
async function send({ raw = false } = {}) {
|
|
386
|
+
if (!raw && runSlash(state.field.value))
|
|
387
|
+
return;
|
|
388
|
+
const text = raw ? state.field.value.trim() : emojify(state.field.value.trim(), { mode: "emoji" });
|
|
290
389
|
if (!text || !state.activeId)
|
|
291
390
|
return;
|
|
292
391
|
const id = state.activeId;
|
|
@@ -350,9 +449,11 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
350
449
|
} else if (event.key === "pagedown") {
|
|
351
450
|
handlers.scroll(-4);
|
|
352
451
|
} else if (event.key === "ctrl+n" || event.key === "alt+down") {
|
|
353
|
-
openAt(Math.min(state.
|
|
452
|
+
openAt(Math.min(listed(state).length - 1, state.selected + 1));
|
|
354
453
|
} else if (event.key === "ctrl+p" || event.key === "alt+up") {
|
|
355
454
|
openAt(Math.max(0, state.selected - 1));
|
|
455
|
+
} else if (event.key === "ctrl+o") {
|
|
456
|
+
toggleArchivedView();
|
|
356
457
|
} else if (event.key === "ctrl+r") {
|
|
357
458
|
loadConversations();
|
|
358
459
|
if (state.activeId)
|
|
@@ -361,9 +462,14 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
361
462
|
if (event.key === "up" || event.key === "k")
|
|
362
463
|
state.selected = Math.max(0, state.selected - 1);
|
|
363
464
|
else if (event.key === "down" || event.key === "j")
|
|
364
|
-
state.selected = Math.min(state.
|
|
465
|
+
state.selected = Math.min(listed(state).length - 1, state.selected + 1);
|
|
365
466
|
else if (event.key === "enter" || event.key === "right" || event.key === "l")
|
|
366
467
|
handlers.open(state.selected);
|
|
468
|
+
else if (event.key === "a") {
|
|
469
|
+
const c = listed(state)[state.selected];
|
|
470
|
+
if (c)
|
|
471
|
+
setArchived(c, !c.is_archived);
|
|
472
|
+
}
|
|
367
473
|
} else if (event.key === "enter") {
|
|
368
474
|
send();
|
|
369
475
|
} else if (event.key === "up" && !state.field.value) {
|
|
@@ -386,7 +492,10 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
386
492
|
state.field = insertText(state.field, event.text);
|
|
387
493
|
redraw();
|
|
388
494
|
});
|
|
495
|
+
const listTimer = setInterval(() => loadConversations({ periodic: true }), CHAT_LIST_REFRESH_MS);
|
|
496
|
+
listTimer.unref?.();
|
|
389
497
|
app.on("exit", () => {
|
|
498
|
+
clearInterval(listTimer);
|
|
390
499
|
state.images?.clear();
|
|
391
500
|
abort.abort();
|
|
392
501
|
});
|
|
@@ -412,7 +521,7 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
412
521
|
if (i > 0) {
|
|
413
522
|
const [c] = state.conversations.splice(i, 1);
|
|
414
523
|
state.conversations.unshift(c);
|
|
415
|
-
state.selected = state.
|
|
524
|
+
state.selected = Math.max(0, listed(state).findIndex((x) => x.id === state.activeId));
|
|
416
525
|
}
|
|
417
526
|
if (i === -1)
|
|
418
527
|
loadConversations();
|
|
@@ -430,7 +539,9 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
430
539
|
}, 6100);
|
|
431
540
|
} else
|
|
432
541
|
delete room[data.userId];
|
|
433
|
-
} else if (type
|
|
542
|
+
} else if (CHAT_LIST_EVENTS.has(type)) {
|
|
543
|
+
if (type === MEMBERSHIP_EVENTS.LEFT && data?.userId === client.me?.id && data?.conversationId === state.activeId)
|
|
544
|
+
state.activeId = null;
|
|
434
545
|
loadConversations();
|
|
435
546
|
}
|
|
436
547
|
redraw();
|
|
@@ -442,10 +553,15 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
442
553
|
}
|
|
443
554
|
}).catch((err) => note(err.message));
|
|
444
555
|
await app.start();
|
|
556
|
+
clearInterval(listTimer);
|
|
445
557
|
abort.abort();
|
|
446
558
|
}
|
|
447
559
|
export {
|
|
560
|
+
CHAT_LIST_REFRESH_MS,
|
|
561
|
+
SLASH_COMMANDS,
|
|
562
|
+
chatsWithNews,
|
|
448
563
|
initialState,
|
|
564
|
+
listed,
|
|
449
565
|
render,
|
|
450
566
|
runTui,
|
|
451
567
|
transcriptLines
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/qryptchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "qc: qrypt.chat in your terminal. A full-screen end-to-end encrypted chat client (ML-KEM-1024), plus a scriptable CLI and an MCP server.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|