@profullstack/qryptchat 0.6.4 → 0.8.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/bin/qc.js +1 -1
- package/dist/{chunk-7fw9y4pv.js → chunk-nrv1k5q2.js} +156 -4
- package/dist/{chunk-g9xn5036.js → chunk-zc4n789c.js} +22 -1
- package/dist/commands.js +2 -2
- package/dist/mcp.js +2 -2
- package/dist/tui.js +123 -17
- package/package.json +1 -1
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);
|
|
@@ -6,8 +6,11 @@ import {
|
|
|
6
6
|
unlockSession,
|
|
7
7
|
storeSession,
|
|
8
8
|
clearSession,
|
|
9
|
-
notify
|
|
10
|
-
|
|
9
|
+
notify,
|
|
10
|
+
PAIRUX_URL,
|
|
11
|
+
newVoiceRoomLink,
|
|
12
|
+
voiceMessage
|
|
13
|
+
} from "./chunk-zc4n789c.js";
|
|
11
14
|
|
|
12
15
|
// src/cli/crypto.js
|
|
13
16
|
import { MlKem1024 as MlKem10242 } from "mlkem";
|
|
@@ -743,6 +746,9 @@ class QcClient {
|
|
|
743
746
|
throw new QcError(`HTTP ${res.status}`, res.status);
|
|
744
747
|
return res.text();
|
|
745
748
|
}
|
|
749
|
+
async archive(conversationId, on = true) {
|
|
750
|
+
return this.request(`/api/conversations/${on ? "archive" : "unarchive"}`, { method: "POST", body: { conversationId } });
|
|
751
|
+
}
|
|
746
752
|
async conversations() {
|
|
747
753
|
const { conversations = [] } = await this.request("/api/conversations/load", { method: "POST", body: {} });
|
|
748
754
|
return conversations.map((c) => ({ ...c, title: conversationTitle(c, this.me) }));
|
|
@@ -778,6 +784,15 @@ class QcClient {
|
|
|
778
784
|
}));
|
|
779
785
|
return { messages: out, hasMore };
|
|
780
786
|
}
|
|
787
|
+
async joinGroup(code) {
|
|
788
|
+
return this.request("/api/conversations/join", { method: "POST", body: { code } });
|
|
789
|
+
}
|
|
790
|
+
async groupInvite(conversationId, { reset = false, off = false } = {}) {
|
|
791
|
+
const path = `/api/chat/conversations/${encodeURIComponent(conversationId)}/invite`;
|
|
792
|
+
if (off)
|
|
793
|
+
return this.request(path, { method: "DELETE" });
|
|
794
|
+
return this.request(path, { method: "POST", body: { reset } });
|
|
795
|
+
}
|
|
781
796
|
async react(conversationId, messageId, emoji, remove = false) {
|
|
782
797
|
return this.send(conversationId, reactionEnvelope(messageId, emoji, remove), { messageType: REACTION_TYPE });
|
|
783
798
|
}
|
|
@@ -1136,6 +1151,21 @@ async function joinAsAgent(invite, { name, username, env = process.env, fetchImp
|
|
|
1136
1151
|
return { session, conversationId: body.conversation_id, operator: body.operator };
|
|
1137
1152
|
}
|
|
1138
1153
|
|
|
1154
|
+
// src/lib/chat/group-invites.js
|
|
1155
|
+
var CODE_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
|
1156
|
+
function parseInviteCode(input) {
|
|
1157
|
+
const text = String(input ?? "").trim();
|
|
1158
|
+
let code = text;
|
|
1159
|
+
if (/^https?:\/\//i.test(text)) {
|
|
1160
|
+
try {
|
|
1161
|
+
code = new URL(text).pathname.match(/\/join\/([^/]+)\/?$/)?.[1] ?? "";
|
|
1162
|
+
} catch {
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return CODE_RE.test(code) ? code : null;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1139
1169
|
// src/cli/style.js
|
|
1140
1170
|
import { emojify, icon } from "@profullstack/hqtui";
|
|
1141
1171
|
var mark = (name) => {
|
|
@@ -1157,6 +1187,55 @@ function who(user, { agent = false } = {}) {
|
|
|
1157
1187
|
return `${face}${name}${handle}${pronouns}`;
|
|
1158
1188
|
}
|
|
1159
1189
|
|
|
1190
|
+
// src/cli/voice.js
|
|
1191
|
+
import { spawn as spawn2 } from "node:child_process";
|
|
1192
|
+
var PAIRUX_INSTALL = "curl -fsSL https://installer.pairux.com/install.sh | bash";
|
|
1193
|
+
var VOICE_HELP = `qc voice: end-to-end encrypted voice rooms on PairUX (an alias for the pairux CLI)
|
|
1194
|
+
|
|
1195
|
+
Usage:
|
|
1196
|
+
qc voice [--send <chat>] [--json] print a link to a fresh anonymous room; --send posts it to a chat.
|
|
1197
|
+
Made on this machine: the key is in the #fragment, so PairUX
|
|
1198
|
+
never sees it, and nobody needs an account to join.
|
|
1199
|
+
qc voice login|logout|whoami sign the pairux CLI in or out
|
|
1200
|
+
qc voice join <CODE> | say | who | listen | status | leave | help
|
|
1201
|
+
any other subcommand runs \`pairux <subcommand>\`
|
|
1202
|
+
|
|
1203
|
+
The pairux CLI comes with the PairUX app: ${PAIRUX_INSTALL}
|
|
1204
|
+
PAIRUX_API_URL changes the server (default ${PAIRUX_URL}).`;
|
|
1205
|
+
function parseVoiceArgs(argv) {
|
|
1206
|
+
const [first] = argv;
|
|
1207
|
+
if (first === "help" || first === "--help" || first === "-h")
|
|
1208
|
+
return { action: "help" };
|
|
1209
|
+
if (first === undefined || first.startsWith("--") || first === "link" || first === "room" || first === "new") {
|
|
1210
|
+
const rest = first && !first.startsWith("--") ? argv.slice(1) : argv;
|
|
1211
|
+
const flags = { json: false, send: null };
|
|
1212
|
+
for (let i = 0;i < rest.length; i++) {
|
|
1213
|
+
if (rest[i] === "--json")
|
|
1214
|
+
flags.json = true;
|
|
1215
|
+
else if (rest[i] === "--send" && rest[i + 1])
|
|
1216
|
+
flags.send = rest[++i];
|
|
1217
|
+
else if (rest[i].startsWith("--send="))
|
|
1218
|
+
flags.send = rest[i].slice(7);
|
|
1219
|
+
else
|
|
1220
|
+
throw new Error(`Unknown option "${rest[i]}" for qc voice.
|
|
1221
|
+
|
|
1222
|
+
${VOICE_HELP}`);
|
|
1223
|
+
}
|
|
1224
|
+
return { action: "link", ...flags };
|
|
1225
|
+
}
|
|
1226
|
+
return { action: "pairux", args: argv };
|
|
1227
|
+
}
|
|
1228
|
+
var voiceLink = (env = process.env) => newVoiceRoomLink(env.PAIRUX_API_URL || PAIRUX_URL);
|
|
1229
|
+
function runPairux(args, { spawnImpl = spawn2, env = process.env } = {}) {
|
|
1230
|
+
return new Promise((resolve, reject) => {
|
|
1231
|
+
const child = spawnImpl(env.PAIRUX_BIN || "pairux", args, { stdio: "inherit", env });
|
|
1232
|
+
child.on("error", (err) => {
|
|
1233
|
+
reject(err.code === "ENOENT" ? new Error(`The pairux CLI is not installed. Install PairUX: ${PAIRUX_INSTALL}`) : err);
|
|
1234
|
+
});
|
|
1235
|
+
child.on("exit", (code, signal) => resolve(code ?? (signal ? 1 : 0)));
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1160
1239
|
// src/cli/exit.js
|
|
1161
1240
|
function exitWhenFlushed2(code, { timeoutMs = 30000 } = {}) {
|
|
1162
1241
|
setTimeout(() => process.exit(code), timeoutMs);
|
|
@@ -1178,12 +1257,17 @@ Usage:
|
|
|
1178
1257
|
qc profile [--emoji \uD83D\uDD2D] [--pronouns she/her] [--website URL] [--bio TEXT]
|
|
1179
1258
|
show or set your public profile (an empty value clears it);
|
|
1180
1259
|
--openprofile prints it as OpenProfile.md
|
|
1181
|
-
qc chats
|
|
1260
|
+
qc chats [--archived] list your chats (archived ones only with --archived)
|
|
1261
|
+
qc archive <chat> archive a chat for you (qc unarchive <chat> brings it back)
|
|
1262
|
+
qc voice [--send <chat>] a link to a fresh end-to-end encrypted PairUX voice room (no account
|
|
1263
|
+
needed to join); qc voice login|join|... runs the pairux CLI (qc voice help)
|
|
1182
1264
|
qc read <chat> [-n 20] print the last messages of a chat
|
|
1183
1265
|
qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
|
|
1184
1266
|
qc react <chat> <message-id> <emoji> [--remove] react to a message
|
|
1185
1267
|
qc listen print new messages as they arrive (NDJSON with --json; --notify alerts with it)
|
|
1186
1268
|
qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
|
|
1269
|
+
qc join <link> join a group chat from its invite link (https://qrypt.chat/join/...)
|
|
1270
|
+
qc invite <chat> print a group's invite link (group admins) [--reset makes a new one, --off turns it off]
|
|
1187
1271
|
|
|
1188
1272
|
<chat> is a chat id or part of its name. Options: --json, --url <server> (or QC_URL).
|
|
1189
1273
|
Keys and tokens are sealed (ChaCha20-Poly1305) in ${"$"}QC_HOME or ~/.config/qc; the key is in your
|
|
@@ -1260,6 +1344,8 @@ async function main2(argv, { version = "0.0.0" } = {}) {
|
|
|
1260
1344
|
const out = (line) => process.stdout.write(`${line}
|
|
1261
1345
|
`);
|
|
1262
1346
|
const json = (value) => out(JSON.stringify(value, null, flags.json === "compact" ? 0 : 2));
|
|
1347
|
+
if (cmd === "voice")
|
|
1348
|
+
return voiceCommand(argv.slice(argv.indexOf("voice") + 1), { out, json });
|
|
1263
1349
|
if (flags.version)
|
|
1264
1350
|
return out(`qc ${version}`);
|
|
1265
1351
|
if (flags.help || cmd === "help")
|
|
@@ -1361,13 +1447,25 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1361
1447
|
}
|
|
1362
1448
|
case "chats":
|
|
1363
1449
|
case "ls": {
|
|
1364
|
-
const
|
|
1450
|
+
const all = await (await client(flags)).conversations();
|
|
1451
|
+
const chats = all.filter((c) => !!c.is_archived === !!flags.archived);
|
|
1365
1452
|
if (flags.json)
|
|
1366
1453
|
return json(chats.map((c) => ({ id: c.id, title: c.title, type: c.type, updated_at: c.updated_at, participants: c.participants?.length ?? 0 })));
|
|
1367
1454
|
for (const c of chats)
|
|
1368
1455
|
out(`${c.id} ${mark(c.type === "group" ? "users" : "chat")}${c.title}`);
|
|
1369
1456
|
return;
|
|
1370
1457
|
}
|
|
1458
|
+
case "archive":
|
|
1459
|
+
case "unarchive": {
|
|
1460
|
+
if (!rest[0])
|
|
1461
|
+
throw new Error(`Usage: qc ${cmd} <chat>`);
|
|
1462
|
+
const c = await client(flags);
|
|
1463
|
+
const chat = findChat2(await c.conversations(), rest[0]);
|
|
1464
|
+
await c.archive(chat.id, cmd === "archive");
|
|
1465
|
+
if (flags.json)
|
|
1466
|
+
return json({ id: chat.id, archived: cmd === "archive" });
|
|
1467
|
+
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.`));
|
|
1468
|
+
}
|
|
1371
1469
|
case "read": {
|
|
1372
1470
|
if (!rest[0])
|
|
1373
1471
|
throw new Error("Usage: qc read <chat> [-n 20]");
|
|
@@ -1385,6 +1483,31 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1385
1483
|
}
|
|
1386
1484
|
return;
|
|
1387
1485
|
}
|
|
1486
|
+
case "join": {
|
|
1487
|
+
if (!rest[0])
|
|
1488
|
+
throw new Error("Usage: qc join <invite link>");
|
|
1489
|
+
const code = parseInviteCode(rest[0]);
|
|
1490
|
+
if (!code)
|
|
1491
|
+
throw new Error("That is not a group invite link (https://qrypt.chat/join/...).");
|
|
1492
|
+
const c = await client(flags);
|
|
1493
|
+
const result = await c.joinGroup(code);
|
|
1494
|
+
const chat = (await c.conversations()).find((x) => x.id === result.conversation_id);
|
|
1495
|
+
if (flags.json)
|
|
1496
|
+
return json(result);
|
|
1497
|
+
return out(ok(`${result.joined ? "Joined" : "Already in"} ${chat?.title ?? "the group"}. Try: qc read "${chat?.title ?? result.conversation_id}"`));
|
|
1498
|
+
}
|
|
1499
|
+
case "invite": {
|
|
1500
|
+
if (!rest[0])
|
|
1501
|
+
throw new Error("Usage: qc invite <chat> [--reset | --off]");
|
|
1502
|
+
const c = await client(flags);
|
|
1503
|
+
const chat = findChat2(await c.conversations(), rest[0]);
|
|
1504
|
+
const result = await c.groupInvite(chat.id, { reset: !!flags.reset, off: !!flags.off });
|
|
1505
|
+
if (flags.json)
|
|
1506
|
+
return json(result);
|
|
1507
|
+
if (flags.off)
|
|
1508
|
+
return out(ok(`The invite link for ${chat.title} is off.`));
|
|
1509
|
+
return out(result.invite.url);
|
|
1510
|
+
}
|
|
1388
1511
|
case "react": {
|
|
1389
1512
|
const [chatQuery, messageId, emoji] = rest;
|
|
1390
1513
|
if (!chatQuery || !messageId || !emoji)
|
|
@@ -1456,5 +1579,34 @@ ${info("Set: qc profile --emoji :telescope: --pronouns she/her --website https:/
|
|
|
1456
1579
|
${HELP2}`);
|
|
1457
1580
|
}
|
|
1458
1581
|
}
|
|
1582
|
+
async function voiceCommand(argv, { out, json }) {
|
|
1583
|
+
const parsed = parseVoiceArgs(argv);
|
|
1584
|
+
if (parsed.action === "help")
|
|
1585
|
+
return out(VOICE_HELP);
|
|
1586
|
+
if (parsed.action === "pairux") {
|
|
1587
|
+
const code = await runPairux(parsed.args);
|
|
1588
|
+
if (code)
|
|
1589
|
+
process.exitCode = code;
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
const url = voiceLink();
|
|
1593
|
+
let sent = null;
|
|
1594
|
+
if (parsed.send) {
|
|
1595
|
+
const c = await client({});
|
|
1596
|
+
const chat = findChat2(await c.conversations(), parsed.send);
|
|
1597
|
+
const { skipped } = await c.send(chat.id, voiceMessage(url));
|
|
1598
|
+
sent = { id: chat.id, title: chat.title, skipped };
|
|
1599
|
+
}
|
|
1600
|
+
if (parsed.json)
|
|
1601
|
+
return json({ url, sent });
|
|
1602
|
+
out(url);
|
|
1603
|
+
if (sent) {
|
|
1604
|
+
if (sent.skipped)
|
|
1605
|
+
process.stderr.write(`qc: ${warn(`${sent.skipped} participant(s) have no key yet and will not see it.`)}
|
|
1606
|
+
`);
|
|
1607
|
+
process.stderr.write(`qc: ${mark("send")}Sent to ${sent.title}.
|
|
1608
|
+
`);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1459
1611
|
|
|
1460
1612
|
export { exitWhenFlushed2, errorLine2, HELP2, parseArgs2, findChat2, main2 };
|
|
@@ -214,4 +214,25 @@ 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
|
+
export { DEFAULT_URL, configDir, sessionPath, baseUrl, loadSession, unlockSession, storeSession, clearSession, notify, PAIRUX_URL, newVoiceRoomLink, voiceMessage, parseSlashCommand };
|
package/dist/commands.js
CHANGED
package/dist/mcp.js
CHANGED
package/dist/tui.js
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
import {
|
|
2
2
|
configDir,
|
|
3
|
-
notify
|
|
4
|
-
|
|
3
|
+
notify,
|
|
4
|
+
PAIRUX_URL,
|
|
5
|
+
newVoiceRoomLink,
|
|
6
|
+
voiceMessage,
|
|
7
|
+
parseSlashCommand
|
|
8
|
+
} from "./chunk-zc4n789c.js";
|
|
5
9
|
|
|
6
10
|
// src/cli/tui.js
|
|
7
11
|
import { createApp, createImageStore, drawIcon, drawRichText, editText, emojify, insertText, stringWidth, truncate, widgets, wrap } from "@profullstack/hqtui";
|
|
8
12
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
9
13
|
import { join } from "node:path";
|
|
14
|
+
|
|
15
|
+
// src/lib/chat/membership-events.js
|
|
16
|
+
var MEMBERSHIP_EVENTS = {
|
|
17
|
+
JOINED: "PARTICIPANTS_JOINED",
|
|
18
|
+
LEFT: "PARTICIPANT_LEFT",
|
|
19
|
+
ARCHIVED: "CONVERSATION_ARCHIVED"
|
|
20
|
+
};
|
|
21
|
+
var CHAT_LIST_EVENTS = new Set(["CONVERSATION_CREATED", ...Object.values(MEMBERSHIP_EVENTS)]);
|
|
22
|
+
|
|
23
|
+
// src/cli/tui.js
|
|
10
24
|
var SIDEBAR = 30;
|
|
11
25
|
function initialState(me) {
|
|
12
26
|
return {
|
|
@@ -24,9 +38,17 @@ function initialState(me) {
|
|
|
24
38
|
scrollBack: 0,
|
|
25
39
|
status: "connecting",
|
|
26
40
|
notice: "",
|
|
27
|
-
loading: true
|
|
41
|
+
loading: true,
|
|
42
|
+
showArchived: false
|
|
28
43
|
};
|
|
29
44
|
}
|
|
45
|
+
var listed = (state) => state.conversations.filter((c) => !!c.is_archived === !!state.showArchived);
|
|
46
|
+
var CHAT_LIST_REFRESH_MS = 15000;
|
|
47
|
+
function chatsWithNews(prev, next) {
|
|
48
|
+
const seen = new Map(prev.map((c) => [c.id, c.updated_at ?? ""]));
|
|
49
|
+
return next.filter((c) => !seen.has(c.id) || (c.updated_at ?? "") > seen.get(c.id)).map((c) => c.id);
|
|
50
|
+
}
|
|
51
|
+
var SLASH_COMMANDS = ["voice", "archive", "unarchive", "archived"];
|
|
30
52
|
var pad2 = (n) => String(n).padStart(2, "0");
|
|
31
53
|
var timeOf = (iso) => {
|
|
32
54
|
const d = new Date(iso);
|
|
@@ -132,10 +154,13 @@ function render({ ui, width, height, theme }, state, on = {}) {
|
|
|
132
154
|
});
|
|
133
155
|
ui.row({ size: "fill" }, (row) => {
|
|
134
156
|
const listWidth = Math.min(SIDEBAR, Math.max(18, Math.floor(width * 0.3)));
|
|
135
|
-
|
|
157
|
+
const chats = listed(state);
|
|
158
|
+
const archivedCount = state.conversations.filter((c) => c.is_archived).length;
|
|
159
|
+
const listTitle = state.showArchived ? `Archived (${archivedCount})` : archivedCount ? `Chats · ${archivedCount} archived` : "Chats";
|
|
160
|
+
row.panel({ title: truncate(listTitle, Math.max(4, listWidth - 4)), size: listWidth, borderColor: state.focus === "list" ? theme?.borderFocused : undefined }, (p) => {
|
|
136
161
|
const inner = Math.max(4, listWidth - 5);
|
|
137
162
|
p.list({
|
|
138
|
-
items:
|
|
163
|
+
items: chats.map((c) => {
|
|
139
164
|
const unread = state.unread[c.id] || 0;
|
|
140
165
|
const tag = unread ? ` ●${unread > 99 ? "99+" : unread}` : "";
|
|
141
166
|
const name = truncate(c.title, inner - stringWidth(tag));
|
|
@@ -177,6 +202,8 @@ function render({ ui, width, height, theme }, state, on = {}) {
|
|
|
177
202
|
{ key: "Ctrl+E", label: "emoji" },
|
|
178
203
|
{ key: "Tab", label: state.focus === "list" ? "compose" : "chats" },
|
|
179
204
|
{ key: "PgUp", label: "scroll" },
|
|
205
|
+
...state.focus === "list" ? [{ key: "a", label: state.showArchived ? "unarchive" : "archive" }] : [],
|
|
206
|
+
{ key: "Ctrl+O", label: state.showArchived ? "chats" : "archived" },
|
|
180
207
|
{ key: "Ctrl+C", label: "quit" }
|
|
181
208
|
],
|
|
182
209
|
size: 1
|
|
@@ -241,16 +268,33 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
241
268
|
}
|
|
242
269
|
}
|
|
243
270
|
let keysChecked = false;
|
|
244
|
-
|
|
271
|
+
let listLoading = false;
|
|
272
|
+
async function loadConversations({ periodic = false } = {}) {
|
|
273
|
+
if (listLoading)
|
|
274
|
+
return;
|
|
275
|
+
listLoading = true;
|
|
245
276
|
try {
|
|
246
277
|
const before = state.activeId;
|
|
278
|
+
const prev = state.conversations;
|
|
247
279
|
state.conversations = await client.conversations();
|
|
248
280
|
state.loading = false;
|
|
249
|
-
if (
|
|
250
|
-
const
|
|
281
|
+
if (periodic) {
|
|
282
|
+
for (const id of chatsWithNews(prev, state.conversations)) {
|
|
283
|
+
if (id === state.activeId)
|
|
284
|
+
loadMessages(id);
|
|
285
|
+
else {
|
|
286
|
+
delete state.messages[id];
|
|
287
|
+
if (state.status !== "live" && prev.some((c) => c.id === id))
|
|
288
|
+
state.unread[id] = (state.unread[id] || 0) + 1;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
const chats = listed(state);
|
|
293
|
+
if (!state.activeId && chats.length) {
|
|
294
|
+
const want = initialChat ? chats.findIndex((c) => c.id === initialChat || c.title.toLowerCase().includes(String(initialChat).toLowerCase())) : 0;
|
|
251
295
|
openAt(Math.max(0, want));
|
|
252
296
|
} else {
|
|
253
|
-
state.selected = Math.max(0,
|
|
297
|
+
state.selected = Math.max(0, chats.findIndex((c) => c.id === before));
|
|
254
298
|
}
|
|
255
299
|
redraw();
|
|
256
300
|
if (!keysChecked) {
|
|
@@ -260,11 +304,14 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
260
304
|
}
|
|
261
305
|
} catch (err) {
|
|
262
306
|
state.loading = false;
|
|
263
|
-
|
|
307
|
+
if (!periodic || err.status === 401)
|
|
308
|
+
note(err.status === 401 ? "Session ended: run qc login" : `Could not load chats: ${err.message}`);
|
|
309
|
+
} finally {
|
|
310
|
+
listLoading = false;
|
|
264
311
|
}
|
|
265
312
|
}
|
|
266
313
|
function openAt(i) {
|
|
267
|
-
const c = state
|
|
314
|
+
const c = listed(state)[i];
|
|
268
315
|
if (!c)
|
|
269
316
|
return;
|
|
270
317
|
state.selected = i;
|
|
@@ -285,8 +332,50 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
285
332
|
client.typing(state.activeId, true);
|
|
286
333
|
}
|
|
287
334
|
}
|
|
288
|
-
|
|
289
|
-
|
|
335
|
+
function toggleArchivedView() {
|
|
336
|
+
state.showArchived = !state.showArchived;
|
|
337
|
+
const chats = listed(state);
|
|
338
|
+
state.selected = Math.max(0, chats.findIndex((c) => c.id === state.activeId));
|
|
339
|
+
if (!state.showArchived && !chats.length)
|
|
340
|
+
note("No chats here yet.");
|
|
341
|
+
if (state.showArchived && !chats.length)
|
|
342
|
+
note("Nothing archived. Press a on a chat (or type /archive) to archive it.");
|
|
343
|
+
redraw();
|
|
344
|
+
}
|
|
345
|
+
async function setArchived(chat, on) {
|
|
346
|
+
try {
|
|
347
|
+
await client.archive(chat.id, on);
|
|
348
|
+
chat.is_archived = on;
|
|
349
|
+
const chats = listed(state);
|
|
350
|
+
state.selected = Math.max(0, Math.min(state.selected, chats.length - 1));
|
|
351
|
+
note(on ? `Archived ${chat.title} (Ctrl+O shows archived chats)` : `${chat.title} is back in your chats`);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
note(`Could not ${on ? "archive" : "unarchive"}: ${err.message}`);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
function runSlash(raw) {
|
|
357
|
+
const cmd = parseSlashCommand(raw, SLASH_COMMANDS);
|
|
358
|
+
if (!cmd)
|
|
359
|
+
return false;
|
|
360
|
+
const active = state.conversations.find((c) => c.id === state.activeId);
|
|
361
|
+
state.field = { value: "", cursor: 0 };
|
|
362
|
+
if (cmd.name === "archived")
|
|
363
|
+
toggleArchivedView();
|
|
364
|
+
else if (!active)
|
|
365
|
+
note("Pick a chat first");
|
|
366
|
+
else if (cmd.name === "voice") {
|
|
367
|
+
const url = newVoiceRoomLink(process.env.PAIRUX_API_URL || PAIRUX_URL);
|
|
368
|
+
state.field = { value: voiceMessage(url), cursor: 0 };
|
|
369
|
+
send({ raw: true }).then(() => note("Voice room posted; open the link in a browser to join", { sticky: false }));
|
|
370
|
+
} else
|
|
371
|
+
setArchived(active, cmd.name === "archive");
|
|
372
|
+
redraw();
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
async function send({ raw = false } = {}) {
|
|
376
|
+
if (!raw && runSlash(state.field.value))
|
|
377
|
+
return;
|
|
378
|
+
const text = raw ? state.field.value.trim() : emojify(state.field.value.trim(), { mode: "emoji" });
|
|
290
379
|
if (!text || !state.activeId)
|
|
291
380
|
return;
|
|
292
381
|
const id = state.activeId;
|
|
@@ -350,9 +439,11 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
350
439
|
} else if (event.key === "pagedown") {
|
|
351
440
|
handlers.scroll(-4);
|
|
352
441
|
} else if (event.key === "ctrl+n" || event.key === "alt+down") {
|
|
353
|
-
openAt(Math.min(state.
|
|
442
|
+
openAt(Math.min(listed(state).length - 1, state.selected + 1));
|
|
354
443
|
} else if (event.key === "ctrl+p" || event.key === "alt+up") {
|
|
355
444
|
openAt(Math.max(0, state.selected - 1));
|
|
445
|
+
} else if (event.key === "ctrl+o") {
|
|
446
|
+
toggleArchivedView();
|
|
356
447
|
} else if (event.key === "ctrl+r") {
|
|
357
448
|
loadConversations();
|
|
358
449
|
if (state.activeId)
|
|
@@ -361,9 +452,14 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
361
452
|
if (event.key === "up" || event.key === "k")
|
|
362
453
|
state.selected = Math.max(0, state.selected - 1);
|
|
363
454
|
else if (event.key === "down" || event.key === "j")
|
|
364
|
-
state.selected = Math.min(state.
|
|
455
|
+
state.selected = Math.min(listed(state).length - 1, state.selected + 1);
|
|
365
456
|
else if (event.key === "enter" || event.key === "right" || event.key === "l")
|
|
366
457
|
handlers.open(state.selected);
|
|
458
|
+
else if (event.key === "a") {
|
|
459
|
+
const c = listed(state)[state.selected];
|
|
460
|
+
if (c)
|
|
461
|
+
setArchived(c, !c.is_archived);
|
|
462
|
+
}
|
|
367
463
|
} else if (event.key === "enter") {
|
|
368
464
|
send();
|
|
369
465
|
} else if (event.key === "up" && !state.field.value) {
|
|
@@ -386,7 +482,10 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
386
482
|
state.field = insertText(state.field, event.text);
|
|
387
483
|
redraw();
|
|
388
484
|
});
|
|
485
|
+
const listTimer = setInterval(() => loadConversations({ periodic: true }), CHAT_LIST_REFRESH_MS);
|
|
486
|
+
listTimer.unref?.();
|
|
389
487
|
app.on("exit", () => {
|
|
488
|
+
clearInterval(listTimer);
|
|
390
489
|
state.images?.clear();
|
|
391
490
|
abort.abort();
|
|
392
491
|
});
|
|
@@ -412,7 +511,7 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
412
511
|
if (i > 0) {
|
|
413
512
|
const [c] = state.conversations.splice(i, 1);
|
|
414
513
|
state.conversations.unshift(c);
|
|
415
|
-
state.selected = state.
|
|
514
|
+
state.selected = Math.max(0, listed(state).findIndex((x) => x.id === state.activeId));
|
|
416
515
|
}
|
|
417
516
|
if (i === -1)
|
|
418
517
|
loadConversations();
|
|
@@ -430,7 +529,9 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
430
529
|
}, 6100);
|
|
431
530
|
} else
|
|
432
531
|
delete room[data.userId];
|
|
433
|
-
} else if (type
|
|
532
|
+
} else if (CHAT_LIST_EVENTS.has(type)) {
|
|
533
|
+
if (type === MEMBERSHIP_EVENTS.LEFT && data?.userId === client.me?.id && data?.conversationId === state.activeId)
|
|
534
|
+
state.activeId = null;
|
|
434
535
|
loadConversations();
|
|
435
536
|
}
|
|
436
537
|
redraw();
|
|
@@ -442,10 +543,15 @@ async function runTui(client, { initialChat } = {}) {
|
|
|
442
543
|
}
|
|
443
544
|
}).catch((err) => note(err.message));
|
|
444
545
|
await app.start();
|
|
546
|
+
clearInterval(listTimer);
|
|
445
547
|
abort.abort();
|
|
446
548
|
}
|
|
447
549
|
export {
|
|
550
|
+
CHAT_LIST_REFRESH_MS,
|
|
551
|
+
SLASH_COMMANDS,
|
|
552
|
+
chatsWithNews,
|
|
448
553
|
initialState,
|
|
554
|
+
listed,
|
|
449
555
|
render,
|
|
450
556
|
runTui,
|
|
451
557
|
transcriptLines
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@profullstack/qryptchat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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": {
|