imsg-mcp 1.3.2 → 1.4.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/dist/tui.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import { jsx, jsxs, Fragment } from "react/jsx-runtime";
3
3
  import { parseArgs } from "node:util";
4
4
  import { useScreenSize, withFullScreen } from "fullscreen-ink";
5
- import { g as getImsgDbPath, a as getContactsDbPaths, b as getSlugsDbPath, j as sendToChat, k as sendToChatId, m as sendMessageAlt, r as registerCleanup, c as checkLocalAccess, f as formatAccessReport, q as installShutdownHandlers, x as startHeapMonitor, v as logStartup, F as enableFileLogging, B as stopHeapMonitor, A as logShutdown } from "./shutdown-pPAPSnk3.js";
5
+ import { b as getImsgDbPath, d as getContactsDbPaths, e as getSlugsDbPath, l as sendToChat, m as sendToChatId, n as sendMessageReliable, u as registerCleanup, c as checkLocalAccess, f as formatAccessReport, t as installShutdownHandlers, z as startHeapMonitor, x as logStartup, H as enableFileLogging, D as stopHeapMonitor, C as logShutdown } from "./shutdown-CqGQKFmr.js";
6
6
  import { spawnSync, execSync } from "node:child_process";
7
7
  import { resolveTuiConfig } from "./tui-config-Crn6TZPg.js";
8
- import { a as rankFuzzy, b as dispatchAnalytic, I as IMPLEMENTED_TYPES, c as readWatchdogState, o as onMemorySample, m as minMessageId, r as resolveRecipient, d as defaultCountryFromEnv, i as installWatchdog } from "./watchdog-Cbgm050b.js";
8
+ import { a as rankFuzzy, b as dispatchAnalytic, I as IMPLEMENTED_TYPES, o as onMemorySample, e as readWatchdogState, m as minMessageId, r as resolveRecipient, d as defaultCountryFromEnv, i as installWatchdog } from "./watchdog-CizaS0Jv.js";
9
9
  import { existsSync, writeFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { join } from "node:path";
@@ -14,7 +14,7 @@ import React, { createContext, useContext, useMemo, useEffect, useState, useRef,
14
14
  import { TextInput } from "@inkjs/ui";
15
15
  import { parseUserDate, formatJumpTarget } from "./dateParse-DJXMfq3a.js";
16
16
  import { e as extensionFor, t as toJSON, a as toCSV, b as toMarkdown } from "./exportFormats-wytSyBTp.js";
17
- import { h as hasNativeModule, I as IMessageDB } from "./imessage-db-Cz8MIIpG.js";
17
+ import { h as hasNativeModule, I as IMessageDB } from "./imessage-db-C8WKHFQA.js";
18
18
  const FC_LIST_TIMEOUT_MS = 1e3;
19
19
  let cached = null;
20
20
  function detectNerdFont() {
@@ -807,8 +807,10 @@ const COMPOSE_NEW_KEYS = [
807
807
  ];
808
808
  const FILTER_KEYS = [["Enter/Esc", "exit filter"]];
809
809
  const DRAWER_KEYS = [
810
- ["j/k", "scroll"],
811
- ["o", "open attachment"],
810
+ ["j/k", "select attachment"],
811
+ ["o", "open"],
812
+ ["s", "save"],
813
+ ["y", "copy path"],
812
814
  ["Esc/q", "close"]
813
815
  ];
814
816
  function HelpBar({ mode, focus }) {
@@ -1110,9 +1112,10 @@ function Label({ children }) {
1110
1112
  ": "
1111
1113
  ] }) });
1112
1114
  }
1113
- function MessageDrawer({ message: m, width, height }) {
1115
+ function MessageDrawer({ message: m, width, height, selectedAttachmentIdx = 0 }) {
1114
1116
  const theme = useTheme();
1115
1117
  const hasAttachments = m.attachments && m.attachments.length > 0;
1118
+ const multiAttachment = (m.attachments?.length ?? 0) > 1;
1116
1119
  return /* @__PURE__ */ jsxs(
1117
1120
  Box,
1118
1121
  {
@@ -1157,6 +1160,14 @@ function MessageDrawer({ message: m, width, height }) {
1157
1160
  /* @__PURE__ */ jsx(Label, { children: "GUID" }),
1158
1161
  /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, wrap: "truncate", children: m.guid })
1159
1162
  ] }),
1163
+ m.sendError !== void 0 && /* @__PURE__ */ jsxs(Box, { children: [
1164
+ /* @__PURE__ */ jsx(Label, { children: "Status" }),
1165
+ /* @__PURE__ */ jsxs(Text, { color: theme.edited, bold: true, children: [
1166
+ "NOT DELIVERED — send failed (error ",
1167
+ m.sendError,
1168
+ ")"
1169
+ ] })
1170
+ ] }),
1160
1171
  m.isEdited && /* @__PURE__ */ jsxs(Box, { children: [
1161
1172
  /* @__PURE__ */ jsx(Label, { children: "Status" }),
1162
1173
  /* @__PURE__ */ jsx(Text, { color: theme.edited, children: "Edited" })
@@ -1182,29 +1193,43 @@ function MessageDrawer({ message: m, width, height }) {
1182
1193
  m.attachments.length,
1183
1194
  "):"
1184
1195
  ] }),
1185
- m.attachments.map((att) => {
1196
+ m.attachments.map((att, i) => {
1197
+ const isSel = multiAttachment && i === selectedAttachmentIdx;
1186
1198
  return /* @__PURE__ */ jsxs(
1187
1199
  Box,
1188
1200
  {
1189
1201
  flexDirection: "column",
1190
- paddingLeft: 1,
1191
1202
  children: [
1192
- /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, wrap: "truncate", children: att.transferName ?? att.filename }),
1193
- /* @__PURE__ */ jsxs(Text, { color: theme.drawer.label, children: [
1203
+ /* @__PURE__ */ jsxs(Box, { children: [
1204
+ /* @__PURE__ */ jsx(Box, { flexShrink: 0, children: /* @__PURE__ */ jsx(Text, { color: isSel ? theme.sent.bg : theme.drawer.label, children: multiAttachment ? isSel ? "▸" : " " : " " }) }),
1205
+ /* @__PURE__ */ jsx(Text, { color: theme.drawer.value, bold: isSel, wrap: "truncate", children: att.transferName ?? att.filename })
1206
+ ] }),
1207
+ /* @__PURE__ */ jsx(Box, { paddingLeft: 1, children: /* @__PURE__ */ jsxs(Text, { color: theme.drawer.label, children: [
1194
1208
  att.mimeType ?? "unknown",
1195
1209
  " ·",
1196
1210
  " ",
1197
- att.totalBytes > 0 ? formatBytes(att.totalBytes) : "?",
1198
- /* @__PURE__ */ jsx(Text, { color: theme.senderName, children: " (press o to preview)" })
1199
- ] })
1211
+ att.totalBytes > 0 ? formatBytes(att.totalBytes) : "?"
1212
+ ] }) })
1200
1213
  ]
1201
1214
  },
1202
1215
  `${att.filename}-${att.transferName ?? ""}-${att.mimeType ?? ""}-${att.totalBytes}`
1203
1216
  );
1204
1217
  }),
1205
- /* @__PURE__ */ jsxs(Box, { marginTop: 1, children: [
1206
- /* @__PURE__ */ jsx(Text, { color: theme.help.key, children: "o" }),
1207
- /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: ": open attachment" })
1218
+ /* @__PURE__ */ jsxs(Box, { marginTop: 1, flexDirection: "column", children: [
1219
+ multiAttachment && /* @__PURE__ */ jsxs(Text, { color: theme.help.desc, children: [
1220
+ /* @__PURE__ */ jsx(Text, { color: theme.help.key, children: "j/k" }),
1221
+ ": select attachment"
1222
+ ] }),
1223
+ /* @__PURE__ */ jsxs(Text, { color: theme.help.desc, children: [
1224
+ /* @__PURE__ */ jsx(Text, { color: theme.help.key, children: "o" }),
1225
+ ": open",
1226
+ " ",
1227
+ /* @__PURE__ */ jsx(Text, { color: theme.help.key, children: "s" }),
1228
+ ": save to ~/Downloads",
1229
+ " ",
1230
+ /* @__PURE__ */ jsx(Text, { color: theme.help.key, children: "y" }),
1231
+ ": copy path"
1232
+ ] })
1208
1233
  ] })
1209
1234
  ] }),
1210
1235
  /* @__PURE__ */ jsxs(Box, { flexDirection: "column", marginTop: 1, children: [
@@ -1527,7 +1552,8 @@ const ANALYTIC_LABEL = {
1527
1552
  response_time_stats: "Response Times",
1528
1553
  daily_heatmap: "Daily Heatmap",
1529
1554
  tapback_summary: "Tapback Summary",
1530
- year_in_review_wrapped: "Year in Review (Wrapped)"
1555
+ year_in_review_wrapped: "Year in Review (Wrapped)",
1556
+ relationship_leaderboard: "Relationship Leaderboard"
1531
1557
  };
1532
1558
  const RANGE_LABEL = {
1533
1559
  "30d": "Last 30 days",
@@ -1709,8 +1735,32 @@ function AnalyticsResultView({
1709
1735
  return /* @__PURE__ */ jsx(TapbacksView, { rows: data, height });
1710
1736
  case "year_in_review_wrapped":
1711
1737
  return /* @__PURE__ */ jsx(WrappedView, { result: data });
1738
+ case "relationship_leaderboard":
1739
+ return /* @__PURE__ */ jsx(LeaderboardView, { result: data, height });
1712
1740
  }
1713
1741
  }
1742
+ function LeaderboardView({
1743
+ result,
1744
+ height
1745
+ }) {
1746
+ const theme = useTheme();
1747
+ const rows = result.leaderboard.slice(0, Math.max(height - 3, 5));
1748
+ if (rows.length === 0) return /* @__PURE__ */ jsx(NoData, {});
1749
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: rows.map((r, i) => /* @__PURE__ */ jsxs(Box, { gap: 1, children: [
1750
+ /* @__PURE__ */ jsx(Text, { color: theme.lineNum, children: String(i + 1).padStart(2) }),
1751
+ /* @__PURE__ */ jsx(Text, { color: theme.senderName, bold: true, children: r.contact.length > 24 ? `${r.contact.slice(0, 23)}…` : r.contact }),
1752
+ /* @__PURE__ */ jsxs(Text, { color: theme.info.label, children: [
1753
+ r.total,
1754
+ " msgs · ",
1755
+ Math.round(r.reciprocity * 100),
1756
+ "% recip ·",
1757
+ " ",
1758
+ Math.round(r.daysSinceLast),
1759
+ "d ago · score ",
1760
+ r.score
1761
+ ] })
1762
+ ] }, r.contact)) });
1763
+ }
1714
1764
  function NoData() {
1715
1765
  const theme = useTheme();
1716
1766
  return /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "No data in this window." });
@@ -2003,7 +2053,8 @@ const ANALYTIC_DESCRIPTION = {
2003
2053
  response_time_stats: "Reply latency (median, p95, mean)",
2004
2054
  daily_heatmap: "7×24 grid of activity by weekday × hour",
2005
2055
  tapback_summary: "Tapback reactions sent per contact",
2006
- year_in_review_wrapped: "Wrapped summary: top contacts, peak day, totals"
2056
+ year_in_review_wrapped: "Wrapped summary: top contacts, peak day, totals",
2057
+ relationship_leaderboard: "Top relationships by volume, reciprocity, and recency"
2007
2058
  };
2008
2059
  let instanceCounter = 0;
2009
2060
  function makeInstance(type) {
@@ -2161,6 +2212,7 @@ function ConversationItem({
2161
2212
  const truncatedName = truncateToWidth(name, nameW);
2162
2213
  const snippetW = Math.max(width - 6, 8);
2163
2214
  const truncatedSnippet = truncateToWidth(snippet, snippetW, "");
2215
+ const slugText = truncateSlugForRow(c.threadSlug ?? "", Math.max(width - 3, 10));
2164
2216
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width, children: [
2165
2217
  /* @__PURE__ */ jsxs(
2166
2218
  Box,
@@ -2218,9 +2270,9 @@ function ConversationItem({
2218
2270
  justifyContent: "flex-end",
2219
2271
  paddingRight: 1,
2220
2272
  backgroundColor: theme.sidebar.slugBg,
2221
- children: /* @__PURE__ */ jsxs(Text, { color: theme.sidebar.slug, dimColor: true, italic: true, children: [
2273
+ children: /* @__PURE__ */ jsxs(Text, { color: theme.sidebar.slug, dimColor: true, italic: true, wrap: "truncate", children: [
2222
2274
  "~",
2223
- c.threadSlug
2275
+ slugText
2224
2276
  ] })
2225
2277
  }
2226
2278
  )
@@ -2230,6 +2282,16 @@ function ConversationItem({
2230
2282
  !isLast && /* @__PURE__ */ jsx(Box, { paddingX: 1, children: /* @__PURE__ */ jsx(Text, { color: theme.sidebar.separator, dimColor: true, children: theme.glyphs.separator.repeat(Math.max(width - 4, 1)) }) })
2231
2283
  ] });
2232
2284
  }
2285
+ function truncateSlugForRow(slug, maxCols) {
2286
+ if (visualWidth(slug) <= maxCols) return slug;
2287
+ const m = slug.match(/^(.*)(~[a-z]+~[0-9a-f]+)$/);
2288
+ if (m?.[1] !== void 0 && m[2] !== void 0) {
2289
+ const tailW = visualWidth(m[2]);
2290
+ const nameCols = Math.max(maxCols - tailW, 3);
2291
+ return `${truncateToWidth(m[1], nameCols)}${m[2]}`;
2292
+ }
2293
+ return truncateToWidth(slug, maxCols);
2294
+ }
2233
2295
  function DefaultModuleSidebarItem({
2234
2296
  instance,
2235
2297
  selected,
@@ -2514,13 +2576,17 @@ function MessageBubble({
2514
2576
  ] })
2515
2577
  )
2516
2578
  ] }) }),
2517
- /* @__PURE__ */ jsx(Text, { color: isSent ? theme.sentText : theme.receivedText, wrap: "truncate", children: text }),
2579
+ /* @__PURE__ */ jsx(Text, { color: isSent ? theme.sentText : theme.receivedText, wrap: "wrap", children: text }),
2518
2580
  reactions && /* @__PURE__ */ jsxs(Text, { children: [
2519
2581
  " ",
2520
2582
  reactions
2521
2583
  ] }),
2522
2584
  hasAttachments && /* @__PURE__ */ jsx(Text, { color: theme.attachment, children: " 📎" }),
2523
- m.isEdited && /* @__PURE__ */ jsx(Text, { color: theme.edited, children: " ✎" })
2585
+ m.isEdited && /* @__PURE__ */ jsx(Text, { color: theme.edited, children: " ✎" }),
2586
+ m.sendError !== void 0 && /* @__PURE__ */ jsxs(Text, { color: theme.edited, bold: true, children: [
2587
+ " ",
2588
+ "✗ not delivered"
2589
+ ] })
2524
2590
  ] }),
2525
2591
  m.isReply && (() => {
2526
2592
  let replyText = m.replyTo?.replyToText ?? null;
@@ -2531,7 +2597,7 @@ function MessageBubble({
2531
2597
  return /* @__PURE__ */ jsxs(Box, { children: [
2532
2598
  lineNum !== void 0 && /* @__PURE__ */ jsx(Text, { children: " " }),
2533
2599
  /* @__PURE__ */ jsx(Text, { children: " " }),
2534
- /* @__PURE__ */ jsxs(Text, { color: theme.replyContext, italic: true, children: [
2600
+ /* @__PURE__ */ jsxs(Text, { color: theme.replyContext, italic: true, wrap: "truncate", children: [
2535
2601
  " ↩ ",
2536
2602
  display
2537
2603
  ] })
@@ -2553,7 +2619,7 @@ function PendingBubble({ text, status }) {
2553
2619
  ] }),
2554
2620
  /* @__PURE__ */ jsx(Text, { color: theme.timestamp, children: "now".padEnd(14) }),
2555
2621
  /* @__PURE__ */ jsx(Text, { color: theme.sent.bg, bold: true, children: `${theme.glyphs.sent} Me: ` }),
2556
- /* @__PURE__ */ jsx(Text, { color: theme.pending.fg, wrap: "truncate", children: text })
2622
+ /* @__PURE__ */ jsx(Text, { color: theme.pending.fg, wrap: "wrap", children: text })
2557
2623
  ] });
2558
2624
  }
2559
2625
  function ThreadPane({
@@ -2589,10 +2655,10 @@ function ThreadPane({
2589
2655
  if (cursorIdx >= messages.length - NEAR_END && pending.length === 0) {
2590
2656
  const end2 = messages.length - 1;
2591
2657
  let start2 = end2;
2592
- let totalLines2 = lineHeight(messages, end2, maxBubbleW);
2593
- while (start2 > 0 && totalLines2 + lineHeight(messages, start2 - 1, maxBubbleW) <= msgAreaHeight) {
2658
+ let totalLines2 = lineHeight(messages, end2, maxBubbleW, isGroup);
2659
+ while (start2 > 0 && totalLines2 + lineHeight(messages, start2 - 1, maxBubbleW, isGroup) <= msgAreaHeight) {
2594
2660
  start2--;
2595
- totalLines2 += lineHeight(messages, start2, maxBubbleW);
2661
+ totalLines2 += lineHeight(messages, start2, maxBubbleW, isGroup);
2596
2662
  }
2597
2663
  return { visibleStart: start2, visibleEnd: end2 + 1 };
2598
2664
  }
@@ -2601,24 +2667,24 @@ function ThreadPane({
2601
2667
  const targetAbove = Math.floor(msgAreaHeight * 0.4);
2602
2668
  while (start > 0 && linesAbove < targetAbove) {
2603
2669
  start--;
2604
- linesAbove += lineHeight(messages, start, maxBubbleW);
2670
+ linesAbove += lineHeight(messages, start, maxBubbleW, isGroup);
2605
2671
  }
2606
2672
  let end = cursorIdx;
2607
- let totalLines = linesAbove + lineHeight(messages, cursorIdx, maxBubbleW);
2673
+ let totalLines = linesAbove + lineHeight(messages, cursorIdx, maxBubbleW, isGroup);
2608
2674
  while (end < total - 1 && totalLines < msgAreaHeight) {
2609
2675
  end++;
2610
2676
  if (end < messages.length) {
2611
- totalLines += lineHeight(messages, end, maxBubbleW);
2677
+ totalLines += lineHeight(messages, end, maxBubbleW, isGroup);
2612
2678
  } else {
2613
2679
  totalLines += 1;
2614
2680
  }
2615
2681
  }
2616
2682
  while (start > 0 && totalLines < msgAreaHeight) {
2617
2683
  start--;
2618
- totalLines += lineHeight(messages, start, maxBubbleW);
2684
+ totalLines += lineHeight(messages, start, maxBubbleW, isGroup);
2619
2685
  }
2620
2686
  return { visibleStart: start, visibleEnd: end + 1 };
2621
- }, [messages, pending.length, selectedMsgIdx, msgAreaHeight, maxBubbleW]);
2687
+ }, [messages, pending.length, selectedMsgIdx, msgAreaHeight, maxBubbleW, isGroup]);
2622
2688
  const visibleMessages = messages.slice(visibleStart, Math.min(visibleEnd, messages.length));
2623
2689
  const messagesByGuid = useMemo(() => {
2624
2690
  const map = /* @__PURE__ */ new Map();
@@ -2748,22 +2814,27 @@ function ThreadPane({
2748
2814
  }
2749
2815
  );
2750
2816
  }
2751
- function lineHeight(messages, i, bubbleWidth) {
2817
+ function wrapRows(text, innerW) {
2818
+ let rows = 0;
2819
+ for (const line of text.split("\n")) {
2820
+ rows += Math.max(1, Math.ceil(visualWidth(line) / innerW));
2821
+ }
2822
+ return rows;
2823
+ }
2824
+ function lineHeight(messages, i, bubbleWidth, isGroup) {
2752
2825
  if (i < 0 || i >= messages.length) return 1;
2753
2826
  const msg = messages[i];
2754
- const innerW = Math.max(20, bubbleWidth - 4);
2827
+ const firstInGroup = isGroupStart(msg, i > 0 ? messages[i - 1] : void 0);
2828
+ const senderPad = isGroup && firstInGroup && !msg.isFromMe ? 14 : 0;
2829
+ const innerW = Math.max(16, bubbleWidth - 25 - senderPad);
2755
2830
  const text = msg.text ?? "";
2756
- let h = Math.max(1, Math.ceil(text.length / innerW));
2757
- if (msg.isReply) {
2758
- const replyLen = msg.replyTo?.replyToText?.length ?? 0;
2759
- h += Math.max(1, Math.ceil(replyLen / Math.max(20, innerW - 4)));
2760
- }
2831
+ let h = wrapRows(text, innerW);
2832
+ if (msg.isReply) h += 1;
2761
2833
  if (i === 0) {
2762
2834
  h += 1;
2763
2835
  } else if (isDifferentDay(messages[i - 1].date, msg.date)) {
2764
2836
  h += 2;
2765
2837
  }
2766
- if (msg.attachments && msg.attachments.length > 0) h += 1;
2767
2838
  return h;
2768
2839
  }
2769
2840
  function nextGroupBoundary(messages, fromIdx) {
@@ -2836,7 +2907,6 @@ function useDevStats(visible) {
2836
2907
  const lastTimeRef = useRef(Date.now());
2837
2908
  const lastQueryMsRef = useRef(null);
2838
2909
  useEffect(() => {
2839
- if (!visible) return;
2840
2910
  const sample = () => {
2841
2911
  const now = Date.now();
2842
2912
  const elapsed = now - lastTimeRef.current;
@@ -2863,9 +2933,13 @@ function useDevStats(visible) {
2863
2933
  });
2864
2934
  };
2865
2935
  sample();
2866
- const timer = setInterval(sample, 2e3);
2867
- timer.unref();
2868
- return () => clearInterval(timer);
2936
+ if (visible) {
2937
+ const timer = setInterval(sample, 2e3);
2938
+ timer.unref();
2939
+ return () => clearInterval(timer);
2940
+ }
2941
+ const unsubscribe = onMemorySample(() => sample());
2942
+ return unsubscribe;
2869
2943
  }, [visible]);
2870
2944
  const recordQueryTime = useCallback((ms) => {
2871
2945
  lastQueryMsRef.current = Math.round(ms * 10) / 10;
@@ -3027,7 +3101,8 @@ function useImsg() {
3027
3101
  if (slugRecord.isGroup) {
3028
3102
  return slugRecord.displayName && !slugRecord.displayName.startsWith("chat") ? sendToChat(slugRecord.displayName, text) : sendToChatId(slugRecord.chatGuid, text);
3029
3103
  }
3030
- return sendMessageAlt(slugRecord.chatIdentifier, text);
3104
+ const preferred = db.getPreferredSendService(slugRecord.chatIdentifier) ?? (slugRecord.service === "SMS" ? "SMS" : "iMessage");
3105
+ return sendMessageReliable(slugRecord.chatIdentifier, text, preferred);
3031
3106
  },
3032
3107
  [getDb]
3033
3108
  );
@@ -3051,9 +3126,12 @@ function useImsg() {
3051
3126
  error: `Ambiguous: ${resolution.candidates.length} matches for "${input}". Pick a specific handle.`
3052
3127
  };
3053
3128
  }
3054
- return sendMessageAlt(resolution.handle, text);
3129
+ const db = getDb();
3130
+ const conv = await db.findChatByHandle(resolution.handle);
3131
+ const preferred = conv && !conv.isGroupChat ? db.getPreferredSendService(conv.chatIdentifier) ?? (conv.serviceType === "SMS" ? "SMS" : "iMessage") : void 0;
3132
+ return sendMessageReliable(resolution.handle, text, preferred);
3055
3133
  },
3056
- [resolveRecipientInput]
3134
+ [resolveRecipientInput, getDb]
3057
3135
  );
3058
3136
  const refresh = useCallback(() => {
3059
3137
  getDb().scheduleBackgroundRefresh();
@@ -3124,6 +3202,7 @@ const initialState = {
3124
3202
  status: "Loading...",
3125
3203
  numBuffer: "",
3126
3204
  showDevStats: false,
3205
+ drawerAttachmentIdx: 0,
3127
3206
  conversationLoadedCount: 0,
3128
3207
  conversationLoadingMore: false,
3129
3208
  messageOldestLoadedId: null,
@@ -3382,9 +3461,11 @@ function reducer(state, action) {
3382
3461
  case "EXIT_FILTER":
3383
3462
  return { ...state, mode: "browse", filterQuery: "" };
3384
3463
  case "OPEN_DRAWER":
3385
- return { ...state, mode: "drawer" };
3464
+ return { ...state, mode: "drawer", drawerAttachmentIdx: 0 };
3386
3465
  case "CLOSE_DRAWER":
3387
3466
  return { ...state, mode: "browse" };
3467
+ case "SET_DRAWER_ATTACHMENT":
3468
+ return { ...state, drawerAttachmentIdx: Math.max(0, action.index) };
3388
3469
  case "SET_NUM_BUFFER":
3389
3470
  return { ...state, numBuffer: action.value };
3390
3471
  case "TOGGLE_DEV_STATS":
@@ -3476,8 +3557,8 @@ function App() {
3476
3557
  const commands2 = useMemo(() => allCommands(state), [state]);
3477
3558
  const commandCtx = useMemo(() => ({ state, dispatch, imsg }), [state, imsg]);
3478
3559
  const loadMessages = useCallback(
3479
- async (idx) => {
3480
- const conv = state.conversations[idx];
3560
+ async (idx, convOverride) => {
3561
+ const conv = convOverride ?? state.conversations[idx];
3481
3562
  if (!conv) return;
3482
3563
  dispatch({
3483
3564
  type: "SET_LOADING",
@@ -3498,11 +3579,15 @@ function App() {
3498
3579
  dispatch({ type: "SET_CONVERSATIONS", data: convs });
3499
3580
  if (convs.length > 0) {
3500
3581
  const prevSlug = selected?.threadSlug;
3582
+ let targetIdx = Math.min(Math.max(state.selectedIdx, 0), convs.length - 1);
3501
3583
  if (prevSlug) {
3502
3584
  const idx = convs.findIndex((c) => c.threadSlug === prevSlug);
3503
- if (idx >= 0) dispatch({ type: "SELECT", index: idx, visibleCount: sidebarVisibleCount });
3585
+ if (idx >= 0) {
3586
+ dispatch({ type: "SELECT", index: idx, visibleCount: sidebarVisibleCount });
3587
+ targetIdx = idx;
3588
+ }
3504
3589
  }
3505
- await loadMessages(state.selectedIdx);
3590
+ await loadMessages(targetIdx, convs[targetIdx]);
3506
3591
  }
3507
3592
  imsg.refresh();
3508
3593
  dispatch({ type: "SET_LOADING", loading: false, status: "" });
@@ -3590,11 +3675,19 @@ function App() {
3590
3675
  try {
3591
3676
  const msgs = await imsg.loadMessages(selected.chatIdentifier);
3592
3677
  const found = msgs.some((m) => m.isFromMe && m.text?.includes(text));
3593
- if (found) {
3678
+ const failed = msgs.some(
3679
+ (m) => m.isFromMe && m.sendError !== void 0 && m.text?.includes(text)
3680
+ );
3681
+ if (failed) {
3682
+ dispatch({ type: "SET_MESSAGES", data: msgs });
3683
+ dispatch({ type: "FAIL_PENDING", text });
3684
+ } else if (found) {
3594
3685
  dispatch({ type: "SET_MESSAGES", data: msgs });
3595
3686
  dispatch({ type: "RESOLVE_PENDING", text });
3596
3687
  } else if (attempt < 7) {
3597
3688
  pollTimerRef.current = setTimeout(poll, 1500);
3689
+ } else {
3690
+ dispatch({ type: "FAIL_PENDING", text });
3598
3691
  }
3599
3692
  } catch {
3600
3693
  dispatch({ type: "FAIL_PENDING", text });
@@ -3690,10 +3783,29 @@ function App() {
3690
3783
  return;
3691
3784
  }
3692
3785
  if (state.mode === "drawer") {
3786
+ const attCount = selectedMsg?.attachments?.length ?? 0;
3693
3787
  if (key.escape || input === "q") {
3694
3788
  dispatch({ type: "CLOSE_DRAWER" });
3789
+ } else if ((input === "j" || key.downArrow) && attCount > 1) {
3790
+ dispatch({
3791
+ type: "SET_DRAWER_ATTACHMENT",
3792
+ index: Math.min(state.drawerAttachmentIdx + 1, attCount - 1)
3793
+ });
3794
+ } else if ((input === "k" || key.upArrow) && attCount > 1) {
3795
+ dispatch({ type: "SET_DRAWER_ATTACHMENT", index: state.drawerAttachmentIdx - 1 });
3695
3796
  } else if (input === "o" && selectedMsg) {
3696
- openAttachment(selectedMsg);
3797
+ openAttachment(selectedMsg, state.drawerAttachmentIdx);
3798
+ } else if (input === "s" && selectedMsg) {
3799
+ saveAttachment(selectedMsg, state.drawerAttachmentIdx);
3800
+ } else if (input === "y" && selectedMsg) {
3801
+ const att = selectedMsg.attachments?.[state.drawerAttachmentIdx];
3802
+ if (att?.filename) {
3803
+ const filepath = att.filename.replace(/^~/, process.env.HOME ?? "~");
3804
+ execSync("pbcopy", { input: filepath });
3805
+ dispatch({ type: "SET_STATUS", status: "Attachment path copied." });
3806
+ } else {
3807
+ dispatch({ type: "SET_STATUS", status: "No attachment to copy." });
3808
+ }
3697
3809
  }
3698
3810
  return;
3699
3811
  }
@@ -3938,9 +4050,16 @@ function App() {
3938
4050
  const prev = prevGroupBoundary(state.messages, state.selectedMsgIdx);
3939
4051
  dispatch({ type: "SELECT_MSG", index: prev });
3940
4052
  } else if (input === "o") {
3941
- openAttachment(
3942
- state.selectedMsgIdx >= 0 ? state.messages[state.selectedMsgIdx] : void 0
3943
- );
4053
+ const msg = state.selectedMsgIdx >= 0 ? state.messages[state.selectedMsgIdx] : void 0;
4054
+ if (msg && (msg.attachments?.length ?? 0) > 1) {
4055
+ dispatch({ type: "OPEN_DRAWER" });
4056
+ dispatch({
4057
+ type: "SET_STATUS",
4058
+ status: `${msg.attachments?.length} attachments — j/k select, o open, s save`
4059
+ });
4060
+ } else {
4061
+ openAttachment(msg);
4062
+ }
3944
4063
  }
3945
4064
  }
3946
4065
  });
@@ -4037,7 +4156,7 @@ function App() {
4037
4156
  state.selectionAnchor,
4038
4157
  selected
4039
4158
  ]);
4040
- function openAttachment(msg) {
4159
+ function openAttachment(msg, attIdx = 0) {
4041
4160
  if (!msg) {
4042
4161
  dispatch({ type: "SET_STATUS", status: "No message selected." });
4043
4162
  return;
@@ -4046,7 +4165,7 @@ function App() {
4046
4165
  dispatch({ type: "SET_STATUS", status: "No attachment on this message." });
4047
4166
  return;
4048
4167
  }
4049
- const att = msg.attachments[0];
4168
+ const att = msg.attachments[attIdx] ?? msg.attachments[0];
4050
4169
  if (!att.filename) {
4051
4170
  dispatch({ type: "SET_STATUS", status: "Attachment has no file path." });
4052
4171
  return;
@@ -4064,6 +4183,35 @@ function App() {
4064
4183
  }
4065
4184
  });
4066
4185
  }
4186
+ function saveAttachment(msg, attIdx) {
4187
+ const att = msg.attachments?.[attIdx];
4188
+ if (!att?.filename) {
4189
+ dispatch({ type: "SET_STATUS", status: "No attachment to save." });
4190
+ return;
4191
+ }
4192
+ const src = att.filename.replace(/^~/, process.env.HOME ?? "~");
4193
+ import("node:fs").then((fs) => {
4194
+ import("node:path").then((path) => {
4195
+ try {
4196
+ const base = att.transferName ?? path.basename(src);
4197
+ const dir = join(homedir(), "Downloads");
4198
+ const ext = path.extname(base);
4199
+ const stem = base.slice(0, base.length - ext.length);
4200
+ let dest = path.join(dir, base);
4201
+ for (let n = 1; fs.existsSync(dest); n++) {
4202
+ dest = path.join(dir, `${stem}-${n}${ext}`);
4203
+ }
4204
+ fs.copyFileSync(src, dest);
4205
+ dispatch({ type: "SET_STATUS", status: `Saved to ${dest.replace(homedir(), "~")}` });
4206
+ } catch (e) {
4207
+ dispatch({
4208
+ type: "SET_STATUS",
4209
+ status: `Save failed: ${e instanceof Error ? e.message : String(e)}`
4210
+ });
4211
+ }
4212
+ });
4213
+ });
4214
+ }
4067
4215
  const handleMouse = useCallback(
4068
4216
  (event) => {
4069
4217
  if (event.type === "click") {
@@ -4163,7 +4311,15 @@ function App() {
4163
4311
  onSubmitCompose: (text) => text.trim() && dispatch({ type: "CONFIRM_SEND" })
4164
4312
  }
4165
4313
  ),
4166
- state.mode === "drawer" && selectedMsg && /* @__PURE__ */ jsx(MessageDrawer, { message: selectedMsg, width: drawerWidth, height: bodyHeight }),
4314
+ state.mode === "drawer" && selectedMsg && /* @__PURE__ */ jsx(
4315
+ MessageDrawer,
4316
+ {
4317
+ message: selectedMsg,
4318
+ width: drawerWidth,
4319
+ height: bodyHeight,
4320
+ selectedAttachmentIdx: state.drawerAttachmentIdx
4321
+ }
4322
+ ),
4167
4323
  state.showDevStats && /* @__PURE__ */ jsx(DevStats, { stats: devStats, width: devStatsWidth })
4168
4324
  ] }),
4169
4325
  state.mode === "date-jump" && /* @__PURE__ */ jsx(