@profullstack/qryptchat 0.1.1 → 0.2.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.
@@ -822,6 +822,81 @@ async function openKeyBlob(blob, ephemeral) {
822
822
  return { keys1024: keys.keys1024, keys768: keys.keys768 };
823
823
  }
824
824
 
825
+ // src/lib/chat/reactions.js
826
+ var REACTION_TYPE = "reaction";
827
+ var MAX_EMOJI_LENGTH = 32;
828
+ function reactionEnvelope(targetId, emoji, remove = false) {
829
+ return JSON.stringify({ v: 1, type: REACTION_TYPE, target: String(targetId), emoji: String(emoji), remove: !!remove });
830
+ }
831
+ function parseReaction(content) {
832
+ try {
833
+ const d = JSON.parse(content);
834
+ if (d?.type !== REACTION_TYPE || typeof d.target !== "string" || typeof d.emoji !== "string")
835
+ return null;
836
+ if (!d.emoji || d.emoji.length > MAX_EMOJI_LENGTH)
837
+ return null;
838
+ return { target: d.target, emoji: d.emoji, remove: !!d.remove };
839
+ } catch {
840
+ return null;
841
+ }
842
+ }
843
+ var senderName = (m) => m?.sender?.display_name || m?.sender?.username || "Someone";
844
+ function snippet(m, max = 90) {
845
+ if (!m)
846
+ return "";
847
+ if (m.message_type === "file" || m.has_attachments)
848
+ return "\uD83D\uDCCE Attachment";
849
+ const text = String(m.content ?? "").replace(/\s+/g, " ").trim();
850
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
851
+ }
852
+ function foldMessages(messages, myUserId) {
853
+ const visible = [];
854
+ const byId = new Map;
855
+ const picks = new Map;
856
+ for (const m of messages) {
857
+ if (m.message_type === REACTION_TYPE) {
858
+ const r = parseReaction(m.content);
859
+ if (!r)
860
+ continue;
861
+ const forTarget = picks.get(r.target) ?? new Map;
862
+ const current = forTarget.get(m.sender_id);
863
+ if (r.remove) {
864
+ if (current && current.emoji === r.emoji)
865
+ forTarget.delete(m.sender_id);
866
+ } else {
867
+ forTarget.set(m.sender_id, { emoji: r.emoji, name: senderName(m), at: m.created_at });
868
+ }
869
+ picks.set(r.target, forTarget);
870
+ continue;
871
+ }
872
+ visible.push(m);
873
+ byId.set(m.id, m);
874
+ }
875
+ return visible.map((m) => {
876
+ const out = { ...m, reactions: [] };
877
+ const forTarget = picks.get(m.id);
878
+ if (forTarget?.size) {
879
+ const groups = new Map;
880
+ for (const [senderId, p] of forTarget) {
881
+ const g = groups.get(p.emoji) ?? { emoji: p.emoji, count: 0, mine: false, names: [], first: p.at };
882
+ g.count += 1;
883
+ g.names.push(senderId === myUserId ? "You" : p.name);
884
+ if (senderId === myUserId)
885
+ g.mine = true;
886
+ if (p.at < g.first)
887
+ g.first = p.at;
888
+ groups.set(p.emoji, g);
889
+ }
890
+ out.reactions = [...groups.values()].sort((a, b) => b.count - a.count || String(a.first).localeCompare(String(b.first)));
891
+ }
892
+ if (m.reply_to_id) {
893
+ const target = byId.get(m.reply_to_id);
894
+ out.replyTo = target ? { id: target.id, name: target.sender_id === myUserId ? "You" : senderName(target), snippet: snippet(target), missing: false } : { id: m.reply_to_id, name: "", snippet: "Original message not loaded", missing: true };
895
+ }
896
+ return out;
897
+ });
898
+ }
899
+
825
900
  // src/cli/api.js
826
901
  class QcError extends Error {
827
902
  constructor(message, status) {
@@ -889,34 +964,37 @@ class QcClient {
889
964
  const { conversations = [] } = await this.request("/api/conversations/load", { method: "POST", body: {} });
890
965
  return conversations.map((c) => ({ ...c, title: conversationTitle(c, this.me) }));
891
966
  }
892
- async messages(conversationId, { limit = 100, before } = {}) {
967
+ async messages(conversationId, { limit = 200, before } = {}) {
893
968
  const { messages = [], hasMore = false } = await this.request("/api/messages/load", {
894
969
  method: "POST",
895
970
  body: { conversationId, limit, ...before ? { before } : {} }
896
971
  });
897
- const out = [];
972
+ const raw = [];
898
973
  for (const m of messages) {
899
- let text;
974
+ let content = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
900
975
  if (m.message_type === "file" || m.has_attachments) {
901
- text = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
902
- text = `\uD83D\uDCCE ${text && text !== "[File attachment]" ? `${text} ` : ""}(attachment: open qrypt.chat to download)`;
903
- } else {
904
- text = m.encrypted_content ? await this.ring.decrypt(m.encrypted_content) : "";
976
+ content = `\uD83D\uDCCE ${content && content !== "[File attachment]" ? `${content} ` : ""}(attachment: open qrypt.chat to download)`;
905
977
  }
906
- out.push({
907
- id: m.id,
908
- conversationId: m.conversation_id,
909
- senderId: m.sender_id,
910
- sender: m.sender?.display_name || m.sender?.username || "unknown",
911
- username: m.sender?.username || "",
912
- mine: m.sender_id === this.me?.id,
913
- text,
914
- at: m.created_at
915
- });
978
+ raw.push({ ...m, content });
916
979
  }
980
+ const out = foldMessages(raw, this.me?.id).map((m) => ({
981
+ id: m.id,
982
+ conversationId: m.conversation_id,
983
+ senderId: m.sender_id,
984
+ sender: m.sender?.display_name || m.sender?.username || "unknown",
985
+ username: m.sender?.username || "",
986
+ mine: m.sender_id === this.me?.id,
987
+ text: m.content,
988
+ at: m.created_at,
989
+ reactions: m.reactions.map(({ emoji, count, mine, names }) => ({ emoji, count, mine, names })),
990
+ ...m.replyTo ? { replyTo: m.replyTo } : {}
991
+ }));
917
992
  return { messages: out, hasMore };
918
993
  }
919
- async send(conversationId, text) {
994
+ async react(conversationId, messageId, emoji, remove = false) {
995
+ return this.send(conversationId, reactionEnvelope(messageId, emoji, remove), { messageType: REACTION_TYPE });
996
+ }
997
+ async send(conversationId, text, { replyTo, messageType = "text" } = {}) {
920
998
  const { participants = [] } = await this.request(`/api/chat/conversations/${encodeURIComponent(conversationId)}/participants`);
921
999
  const userIds = participants.map((p) => p.user_id);
922
1000
  if (!userIds.length)
@@ -935,7 +1013,7 @@ class QcClient {
935
1013
  throw new QcError("No participant has a public key yet");
936
1014
  const { message } = await this.request("/api/messages/send", {
937
1015
  method: "POST",
938
- body: { conversationId, encryptedContents, messageType: "text" }
1016
+ body: { conversationId, encryptedContents, messageType, ...replyTo ? { replyToId: replyTo } : {} }
939
1017
  });
940
1018
  return { message, skipped: missing.length };
941
1019
  }
@@ -1221,7 +1299,8 @@ Usage:
1221
1299
  qc whoami who this terminal is signed in as
1222
1300
  qc chats list your chats
1223
1301
  qc read <chat> [-n 20] print the last messages of a chat
1224
- qc send <chat> <text> send a message (text "-" reads stdin)
1302
+ qc send <chat> <text> send a message (text "-" reads stdin; --reply <message-id> to reply)
1303
+ qc react <chat> <message-id> <emoji> [--remove] react to a message
1225
1304
  qc listen print new messages as they arrive (NDJSON with --json)
1226
1305
  qc mcp run as an MCP server on stdio (list_chats, read_chat, send_message)
1227
1306
 
@@ -1239,7 +1318,7 @@ function parseArgs2(argv) {
1239
1318
  const [k, v] = a.slice(2).split("=", 2);
1240
1319
  if (v !== undefined)
1241
1320
  args.flags[k] = v;
1242
- else if (["url", "n", "limit"].includes(k) && argv[i + 1] !== undefined)
1321
+ else if (["url", "n", "limit", "reply"].includes(k) && argv[i + 1] !== undefined)
1243
1322
  args.flags[k] = argv[++i];
1244
1323
  else
1245
1324
  args.flags[k] = true;
@@ -1339,10 +1418,27 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1339
1418
  const last = messages.slice(-Math.max(1, Number(flags.n || flags.limit || 20)));
1340
1419
  if (flags.json)
1341
1420
  return json(last);
1342
- for (const m of last)
1343
- out(`[${stamp(m.at)}] ${m.mine ? "you" : m.sender}: ${m.text}`);
1421
+ for (const m of last) {
1422
+ if (m.replyTo)
1423
+ out(` ┃ ${m.replyTo.name ? `${m.replyTo.name}: ` : ""}${m.replyTo.snippet}`);
1424
+ const reactions = m.reactions?.length ? ` [${m.reactions.map((r) => `${r.emoji}${r.count > 1 ? r.count : ""}`).join(" ")}]` : "";
1425
+ out(`[${stamp(m.at)}] ${m.mine ? "you" : m.sender}: ${m.text}${reactions} (${m.id.slice(0, 8)})`);
1426
+ }
1344
1427
  return;
1345
1428
  }
1429
+ case "react": {
1430
+ const [chatQuery, messageId, emoji] = rest;
1431
+ if (!chatQuery || !messageId || !emoji)
1432
+ throw new Error("Usage: qc react <chat> <message-id> <emoji> [--remove]");
1433
+ const c = await client(flags);
1434
+ const chat = findChat2(await c.conversations(), chatQuery);
1435
+ const { messages } = await c.messages(chat.id);
1436
+ const target = messages.filter((m) => m.id === messageId || m.id.startsWith(messageId));
1437
+ if (target.length !== 1)
1438
+ throw new Error(target.length ? `"${messageId}" matches ${target.length} messages` : `No message "${messageId}" in ${chat.title}`);
1439
+ await c.react(chat.id, target[0].id, emoji, !!flags.remove);
1440
+ return out(`${flags.remove ? "Removed" : "Reacted"} ${emoji} on ${chat.title}.`);
1441
+ }
1346
1442
  case "send": {
1347
1443
  if (!rest[0] || rest.length < 2)
1348
1444
  throw new Error('Usage: qc send <chat> <text> (text "-" reads stdin)');
@@ -1351,7 +1447,7 @@ async function main2(argv, { version = "0.0.0" } = {}) {
1351
1447
  throw new Error("Nothing to send.");
1352
1448
  const c = await client(flags);
1353
1449
  const chat = findChat2(await c.conversations(), rest[0]);
1354
- const { message, skipped } = await c.send(chat.id, text);
1450
+ const { message, skipped } = await c.send(chat.id, text, { replyTo: flags.reply || undefined });
1355
1451
  if (flags.json)
1356
1452
  return json({ id: message?.id, conversation: chat.id, skipped });
1357
1453
  return out(`Sent to ${chat.title}${skipped ? ` (${skipped} participant(s) have no key yet)` : ""}.`);
package/dist/commands.js CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  parseArgs2,
4
4
  findChat2,
5
5
  main2
6
- } from "./chunk-12bbzs4r.js";
6
+ } from "./chunk-c8qkdept.js";
7
7
  export {
8
8
  HELP2 as HELP,
9
9
  findChat2 as findChat,
package/dist/mcp.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  findChat2
3
- } from "./chunk-12bbzs4r.js";
3
+ } from "./chunk-c8qkdept.js";
4
4
 
5
5
  // src/cli/mcp.js
6
6
  import { createInterface } from "node:readline";
@@ -30,11 +30,27 @@ var TOOLS = [
30
30
  type: "object",
31
31
  properties: {
32
32
  chat: { type: "string", description: "Conversation id or part of its name" },
33
- text: { type: "string", description: "The message" }
33
+ text: { type: "string", description: "The message" },
34
+ reply_to: { type: "string", description: "Optional: id of the message this replies to (from read_chat)" }
34
35
  },
35
36
  required: ["chat", "text"],
36
37
  additionalProperties: false
37
38
  }
39
+ },
40
+ {
41
+ name: "react_to_message",
42
+ description: "React to a message with an emoji (Signal-style: one reaction per person; a new one replaces yours). End-to-end encrypted.",
43
+ inputSchema: {
44
+ type: "object",
45
+ properties: {
46
+ chat: { type: "string", description: "Conversation id or part of its name" },
47
+ message_id: { type: "string", description: "The message id, from read_chat" },
48
+ emoji: { type: "string", description: "The emoji, e.g. ❤️" },
49
+ remove: { type: "boolean", description: "Withdraw this reaction instead" }
50
+ },
51
+ required: ["chat", "message_id", "emoji"],
52
+ additionalProperties: false
53
+ }
38
54
  }
39
55
  ];
40
56
  async function callTool(client, name, args = {}) {
@@ -46,15 +62,32 @@ async function callTool(client, name, args = {}) {
46
62
  const chat = findChat2(await client.conversations(), args.chat);
47
63
  const { messages } = await client.messages(chat.id, { limit: 100 });
48
64
  const limit = Math.min(100, Math.max(1, Number(args.limit) || 20));
49
- return { chat: { id: chat.id, title: chat.title }, messages: messages.slice(-limit).map(({ sender, mine, text, at }) => ({ from: mine ? "me" : sender, text, at })) };
65
+ return {
66
+ chat: { id: chat.id, title: chat.title },
67
+ messages: messages.slice(-limit).map(({ id, sender, mine, text, at, reactions, replyTo }) => ({
68
+ id,
69
+ from: mine ? "me" : sender,
70
+ text,
71
+ at,
72
+ ...reactions?.length ? { reactions: reactions.map(({ emoji, count, mine: m }) => ({ emoji, count, mine: m })) } : {},
73
+ ...replyTo ? { reply_to: { id: replyTo.id, from: replyTo.name, text: replyTo.snippet } } : {}
74
+ }))
75
+ };
50
76
  }
51
77
  if (name === "send_message") {
52
78
  if (!args.text || typeof args.text !== "string")
53
79
  throw new Error("text is required");
54
80
  const chat = findChat2(await client.conversations(), args.chat);
55
- const { message, skipped } = await client.send(chat.id, args.text);
81
+ const { message, skipped } = await client.send(chat.id, args.text, { replyTo: typeof args.reply_to === "string" ? args.reply_to : undefined });
56
82
  return { sent: true, id: message?.id, chat: chat.title, skipped };
57
83
  }
84
+ if (name === "react_to_message") {
85
+ if (!args.emoji || typeof args.emoji !== "string")
86
+ throw new Error("emoji is required");
87
+ const chat = findChat2(await client.conversations(), args.chat);
88
+ await client.react(chat.id, String(args.message_id), args.emoji, !!args.remove);
89
+ return { reacted: !args.remove, emoji: args.emoji, chat: chat.title };
90
+ }
58
91
  throw new Error(`Unknown tool ${name}`);
59
92
  }
60
93
  async function handle(client, msg, { version }) {
package/dist/tui.js CHANGED
@@ -61,11 +61,19 @@ function transcriptLines(messages, width, theme) {
61
61
  ]
62
62
  });
63
63
  }
64
+ if (m.replyTo) {
65
+ const who = m.replyTo.name ? `${m.replyTo.name}: ` : "";
66
+ lines.push({ text: truncate(` ┃ ${who}${m.replyTo.snippet}`, width), fg: theme.muted });
67
+ }
64
68
  for (const part of String(m.text ?? "").split(`
65
69
  `)) {
66
70
  for (const line of wrap(part, Math.max(4, width - 2)))
67
71
  lines.push({ text: ` ${line}`, fg: theme.foreground });
68
72
  }
73
+ if (m.reactions?.length) {
74
+ const summary = m.reactions.map((r) => `${r.emoji}${r.count > 1 ? ` ${r.count}` : ""}`).join(" ");
75
+ lines.push({ text: truncate(` ${summary}`, width), fg: m.reactions.some((r) => r.mine) ? theme.accent : theme.muted });
76
+ }
69
77
  last = m;
70
78
  }
71
79
  return lines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@profullstack/qryptchat",
3
- "version": "0.1.1",
3
+ "version": "0.2.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": {