@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 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
- npm install -g @profullstack/qryptchat # or: bun add -g @profullstack/qryptchat
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
- 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
+ // 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
- } from "./chunk-g9xn5036.js";
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 list your 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 chats = await (await client(flags)).conversations();
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
@@ -5,8 +5,8 @@ import {
5
5
  parseArgs2,
6
6
  findChat2,
7
7
  main2
8
- } from "./chunk-bdg6cmfy.js";
9
- import"./chunk-g9xn5036.js";
8
+ } from "./chunk-wtbsdxe7.js";
9
+ import"./chunk-8gpysrd6.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-8gpysrd6.js";
2
2
  import {
3
3
  findChat2
4
- } from "./chunk-bdg6cmfy.js";
4
+ } from "./chunk-wtbsdxe7.js";
5
5
 
6
6
  // src/cli/mcp.js
7
7
  import { createInterface } from "node:readline";
package/dist/tui.js CHANGED
@@ -1,12 +1,28 @@
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
+ 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
- row.panel({ title: "Chats", size: listWidth, borderColor: state.focus === "list" ? theme?.borderFocused : undefined }, (p) => {
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: state.conversations.map((c) => {
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] = messages;
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
- async function loadConversations() {
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 (!state.activeId && state.conversations.length) {
250
- const want = initialChat ? state.conversations.findIndex((c) => c.id === initialChat || c.title.toLowerCase().includes(String(initialChat).toLowerCase())) : 0;
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, state.conversations.findIndex((c) => c.id === before));
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
- note(err.status === 401 ? "Session ended: run qc login" : `Could not load chats: ${err.message}`);
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.conversations[i];
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
- async function send() {
289
- const text = emojify(state.field.value.trim(), { mode: "emoji" });
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.conversations.length - 1, state.selected + 1));
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.conversations.length - 1, state.selected + 1);
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.conversations.findIndex((x) => x.id === state.activeId);
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 === "CONVERSATION_CREATED") {
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.7.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": {