@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 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
- } from "./chunk-g9xn5036.js";
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 list your 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 chats = await (await client(flags)).conversations();
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
- export { DEFAULT_URL, configDir, sessionPath, baseUrl, loadSession, unlockSession, storeSession, clearSession, notify };
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
@@ -5,8 +5,8 @@ import {
5
5
  parseArgs2,
6
6
  findChat2,
7
7
  main2
8
- } from "./chunk-7fw9y4pv.js";
9
- import"./chunk-g9xn5036.js";
8
+ } from "./chunk-nrv1k5q2.js";
9
+ import"./chunk-zc4n789c.js";
10
10
  export {
11
11
  HELP2 as HELP,
12
12
  errorLine2 as errorLine,
package/dist/mcp.js CHANGED
@@ -1,7 +1,7 @@
1
- import"./chunk-g9xn5036.js";
1
+ import"./chunk-zc4n789c.js";
2
2
  import {
3
3
  findChat2
4
- } from "./chunk-7fw9y4pv.js";
4
+ } from "./chunk-nrv1k5q2.js";
5
5
 
6
6
  // src/cli/mcp.js
7
7
  import { createInterface } from "node:readline";
package/dist/tui.js CHANGED
@@ -1,12 +1,26 @@
1
1
  import {
2
2
  configDir,
3
- notify
4
- } from "./chunk-g9xn5036.js";
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
- row.panel({ title: "Chats", size: listWidth, borderColor: state.focus === "list" ? theme?.borderFocused : undefined }, (p) => {
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: state.conversations.map((c) => {
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
- async function loadConversations() {
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 (!state.activeId && state.conversations.length) {
250
- const want = initialChat ? state.conversations.findIndex((c) => c.id === initialChat || c.title.toLowerCase().includes(String(initialChat).toLowerCase())) : 0;
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, state.conversations.findIndex((c) => c.id === before));
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
- note(err.status === 401 ? "Session ended: run qc login" : `Could not load chats: ${err.message}`);
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.conversations[i];
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
- async function send() {
289
- const text = emojify(state.field.value.trim(), { mode: "emoji" });
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.conversations.length - 1, state.selected + 1));
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.conversations.length - 1, state.selected + 1);
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.conversations.findIndex((x) => x.id === state.activeId);
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 === "CONVERSATION_CREATED") {
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.6.4",
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": {