imsg-mcp 1.3.2 → 1.5.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 { u as dispatchAnalytic, I as IMPLEMENTED_TYPES, b as getImsgDbPath, d as getContactsDbPaths, e as getSlugsDbPath, l as sendToChat, m as sendToChatId, n as sendMessageReliable, w as registerCleanup, c as checkLocalAccess, f as formatAccessReport, v as installShutdownHandlers, B as startHeapMonitor, z as logStartup, L as enableFileLogging, F as stopHeapMonitor, E as logShutdown } from "./shutdown-Vh0gjb6m.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, o as onMemorySample, b as readWatchdogState, m as minMessageId, r as resolveRecipient, d as defaultCountryFromEnv, i as installWatchdog } from "./watchdog-B3T-dQT-.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-BrHJPOq7.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,34 @@ 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
+ const max = rows[0]?.score || 0;
1750
+ return /* @__PURE__ */ jsx(Box, { flexDirection: "column", children: rows.map((r, i) => /* @__PURE__ */ jsxs(Box, { gap: 1, children: [
1751
+ /* @__PURE__ */ jsx(Text, { color: theme.lineNum, children: String(i + 1).padStart(2) }),
1752
+ /* @__PURE__ */ jsx(Text, { color: theme.senderName, bold: true, children: r.contact.length > 24 ? `${r.contact.slice(0, 23)}…` : r.contact }),
1753
+ /* @__PURE__ */ jsxs(Text, { color: theme.info.label, children: [
1754
+ r.total,
1755
+ " msgs · ",
1756
+ Math.round(r.reciprocity * 100),
1757
+ "% recip ·",
1758
+ " ",
1759
+ Math.round(r.daysSinceLast),
1760
+ "d ago · score",
1761
+ " ",
1762
+ max > 0 ? Math.round(r.score / max * 100) : 0
1763
+ ] })
1764
+ ] }, r.contact)) });
1765
+ }
1714
1766
  function NoData() {
1715
1767
  const theme = useTheme();
1716
1768
  return /* @__PURE__ */ jsx(Text, { color: theme.help.desc, children: "No data in this window." });
@@ -2003,17 +2055,19 @@ const ANALYTIC_DESCRIPTION = {
2003
2055
  response_time_stats: "Reply latency (median, p95, mean)",
2004
2056
  daily_heatmap: "7×24 grid of activity by weekday × hour",
2005
2057
  tapback_summary: "Tapback reactions sent per contact",
2006
- year_in_review_wrapped: "Wrapped summary: top contacts, peak day, totals"
2058
+ year_in_review_wrapped: "Wrapped summary: top contacts, peak day, totals",
2059
+ relationship_leaderboard: "Top relationships by volume, reciprocity, and recency"
2007
2060
  };
2008
2061
  let instanceCounter = 0;
2009
2062
  function makeInstance(type) {
2010
2063
  instanceCounter += 1;
2011
- const state = { type, range: "30d", chatIdentifier: null };
2064
+ const range = type === "relationship_leaderboard" ? "all" : "30d";
2065
+ const state = { type, range, chatIdentifier: null };
2012
2066
  return {
2013
2067
  id: `analytics.${type}-${Date.now()}-${instanceCounter}`,
2014
2068
  moduleId: "analytics",
2015
2069
  title: `Analytics: ${ANALYTIC_LABEL[type]}`,
2016
- subtitle: "range: 30d",
2070
+ subtitle: `range: ${range}`,
2017
2071
  state
2018
2072
  };
2019
2073
  }
@@ -2161,6 +2215,7 @@ function ConversationItem({
2161
2215
  const truncatedName = truncateToWidth(name, nameW);
2162
2216
  const snippetW = Math.max(width - 6, 8);
2163
2217
  const truncatedSnippet = truncateToWidth(snippet, snippetW, "");
2218
+ const slugText = truncateSlugForRow(c.threadSlug ?? "", Math.max(width - 3, 10));
2164
2219
  return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", width, children: [
2165
2220
  /* @__PURE__ */ jsxs(
2166
2221
  Box,
@@ -2218,9 +2273,9 @@ function ConversationItem({
2218
2273
  justifyContent: "flex-end",
2219
2274
  paddingRight: 1,
2220
2275
  backgroundColor: theme.sidebar.slugBg,
2221
- children: /* @__PURE__ */ jsxs(Text, { color: theme.sidebar.slug, dimColor: true, italic: true, children: [
2276
+ children: /* @__PURE__ */ jsxs(Text, { color: theme.sidebar.slug, dimColor: true, italic: true, wrap: "truncate", children: [
2222
2277
  "~",
2223
- c.threadSlug
2278
+ slugText
2224
2279
  ] })
2225
2280
  }
2226
2281
  )
@@ -2230,6 +2285,16 @@ function ConversationItem({
2230
2285
  !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
2286
  ] });
2232
2287
  }
2288
+ function truncateSlugForRow(slug, maxCols) {
2289
+ if (visualWidth(slug) <= maxCols) return slug;
2290
+ const m = slug.match(/^(.*)(~[a-z]+~[0-9a-f]+)$/);
2291
+ if (m?.[1] !== void 0 && m[2] !== void 0) {
2292
+ const tailW = visualWidth(m[2]);
2293
+ const nameCols = Math.max(maxCols - tailW, 3);
2294
+ return `${truncateToWidth(m[1], nameCols)}${m[2]}`;
2295
+ }
2296
+ return truncateToWidth(slug, maxCols);
2297
+ }
2233
2298
  function DefaultModuleSidebarItem({
2234
2299
  instance,
2235
2300
  selected,
@@ -2514,13 +2579,17 @@ function MessageBubble({
2514
2579
  ] })
2515
2580
  )
2516
2581
  ] }) }),
2517
- /* @__PURE__ */ jsx(Text, { color: isSent ? theme.sentText : theme.receivedText, wrap: "truncate", children: text }),
2582
+ /* @__PURE__ */ jsx(Text, { color: isSent ? theme.sentText : theme.receivedText, wrap: "wrap", children: text }),
2518
2583
  reactions && /* @__PURE__ */ jsxs(Text, { children: [
2519
2584
  " ",
2520
2585
  reactions
2521
2586
  ] }),
2522
2587
  hasAttachments && /* @__PURE__ */ jsx(Text, { color: theme.attachment, children: " 📎" }),
2523
- m.isEdited && /* @__PURE__ */ jsx(Text, { color: theme.edited, children: " ✎" })
2588
+ m.isEdited && /* @__PURE__ */ jsx(Text, { color: theme.edited, children: " ✎" }),
2589
+ m.sendError !== void 0 && /* @__PURE__ */ jsxs(Text, { color: theme.edited, bold: true, children: [
2590
+ " ",
2591
+ "✗ not delivered"
2592
+ ] })
2524
2593
  ] }),
2525
2594
  m.isReply && (() => {
2526
2595
  let replyText = m.replyTo?.replyToText ?? null;
@@ -2531,7 +2600,7 @@ function MessageBubble({
2531
2600
  return /* @__PURE__ */ jsxs(Box, { children: [
2532
2601
  lineNum !== void 0 && /* @__PURE__ */ jsx(Text, { children: " " }),
2533
2602
  /* @__PURE__ */ jsx(Text, { children: " " }),
2534
- /* @__PURE__ */ jsxs(Text, { color: theme.replyContext, italic: true, children: [
2603
+ /* @__PURE__ */ jsxs(Text, { color: theme.replyContext, italic: true, wrap: "truncate", children: [
2535
2604
  " ↩ ",
2536
2605
  display
2537
2606
  ] })
@@ -2553,7 +2622,7 @@ function PendingBubble({ text, status }) {
2553
2622
  ] }),
2554
2623
  /* @__PURE__ */ jsx(Text, { color: theme.timestamp, children: "now".padEnd(14) }),
2555
2624
  /* @__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 })
2625
+ /* @__PURE__ */ jsx(Text, { color: theme.pending.fg, wrap: "wrap", children: text })
2557
2626
  ] });
2558
2627
  }
2559
2628
  function ThreadPane({
@@ -2589,10 +2658,10 @@ function ThreadPane({
2589
2658
  if (cursorIdx >= messages.length - NEAR_END && pending.length === 0) {
2590
2659
  const end2 = messages.length - 1;
2591
2660
  let start2 = end2;
2592
- let totalLines2 = lineHeight(messages, end2, maxBubbleW);
2593
- while (start2 > 0 && totalLines2 + lineHeight(messages, start2 - 1, maxBubbleW) <= msgAreaHeight) {
2661
+ let totalLines2 = lineHeight(messages, end2, maxBubbleW, isGroup);
2662
+ while (start2 > 0 && totalLines2 + lineHeight(messages, start2 - 1, maxBubbleW, isGroup) <= msgAreaHeight) {
2594
2663
  start2--;
2595
- totalLines2 += lineHeight(messages, start2, maxBubbleW);
2664
+ totalLines2 += lineHeight(messages, start2, maxBubbleW, isGroup);
2596
2665
  }
2597
2666
  return { visibleStart: start2, visibleEnd: end2 + 1 };
2598
2667
  }
@@ -2601,24 +2670,24 @@ function ThreadPane({
2601
2670
  const targetAbove = Math.floor(msgAreaHeight * 0.4);
2602
2671
  while (start > 0 && linesAbove < targetAbove) {
2603
2672
  start--;
2604
- linesAbove += lineHeight(messages, start, maxBubbleW);
2673
+ linesAbove += lineHeight(messages, start, maxBubbleW, isGroup);
2605
2674
  }
2606
2675
  let end = cursorIdx;
2607
- let totalLines = linesAbove + lineHeight(messages, cursorIdx, maxBubbleW);
2676
+ let totalLines = linesAbove + lineHeight(messages, cursorIdx, maxBubbleW, isGroup);
2608
2677
  while (end < total - 1 && totalLines < msgAreaHeight) {
2609
2678
  end++;
2610
2679
  if (end < messages.length) {
2611
- totalLines += lineHeight(messages, end, maxBubbleW);
2680
+ totalLines += lineHeight(messages, end, maxBubbleW, isGroup);
2612
2681
  } else {
2613
2682
  totalLines += 1;
2614
2683
  }
2615
2684
  }
2616
2685
  while (start > 0 && totalLines < msgAreaHeight) {
2617
2686
  start--;
2618
- totalLines += lineHeight(messages, start, maxBubbleW);
2687
+ totalLines += lineHeight(messages, start, maxBubbleW, isGroup);
2619
2688
  }
2620
2689
  return { visibleStart: start, visibleEnd: end + 1 };
2621
- }, [messages, pending.length, selectedMsgIdx, msgAreaHeight, maxBubbleW]);
2690
+ }, [messages, pending.length, selectedMsgIdx, msgAreaHeight, maxBubbleW, isGroup]);
2622
2691
  const visibleMessages = messages.slice(visibleStart, Math.min(visibleEnd, messages.length));
2623
2692
  const messagesByGuid = useMemo(() => {
2624
2693
  const map = /* @__PURE__ */ new Map();
@@ -2748,22 +2817,27 @@ function ThreadPane({
2748
2817
  }
2749
2818
  );
2750
2819
  }
2751
- function lineHeight(messages, i, bubbleWidth) {
2820
+ function wrapRows(text, innerW) {
2821
+ let rows = 0;
2822
+ for (const line of text.split("\n")) {
2823
+ rows += Math.max(1, Math.ceil(visualWidth(line) / innerW));
2824
+ }
2825
+ return rows;
2826
+ }
2827
+ function lineHeight(messages, i, bubbleWidth, isGroup) {
2752
2828
  if (i < 0 || i >= messages.length) return 1;
2753
2829
  const msg = messages[i];
2754
- const innerW = Math.max(20, bubbleWidth - 4);
2830
+ const firstInGroup = isGroupStart(msg, i > 0 ? messages[i - 1] : void 0);
2831
+ const senderPad = isGroup && firstInGroup && !msg.isFromMe ? 14 : 0;
2832
+ const innerW = Math.max(16, bubbleWidth - 25 - senderPad);
2755
2833
  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
- }
2834
+ let h = wrapRows(text, innerW);
2835
+ if (msg.isReply) h += 1;
2761
2836
  if (i === 0) {
2762
2837
  h += 1;
2763
2838
  } else if (isDifferentDay(messages[i - 1].date, msg.date)) {
2764
2839
  h += 2;
2765
2840
  }
2766
- if (msg.attachments && msg.attachments.length > 0) h += 1;
2767
2841
  return h;
2768
2842
  }
2769
2843
  function nextGroupBoundary(messages, fromIdx) {
@@ -2836,7 +2910,6 @@ function useDevStats(visible) {
2836
2910
  const lastTimeRef = useRef(Date.now());
2837
2911
  const lastQueryMsRef = useRef(null);
2838
2912
  useEffect(() => {
2839
- if (!visible) return;
2840
2913
  const sample = () => {
2841
2914
  const now = Date.now();
2842
2915
  const elapsed = now - lastTimeRef.current;
@@ -2863,9 +2936,13 @@ function useDevStats(visible) {
2863
2936
  });
2864
2937
  };
2865
2938
  sample();
2866
- const timer = setInterval(sample, 2e3);
2867
- timer.unref();
2868
- return () => clearInterval(timer);
2939
+ if (visible) {
2940
+ const timer = setInterval(sample, 2e3);
2941
+ timer.unref();
2942
+ return () => clearInterval(timer);
2943
+ }
2944
+ const unsubscribe = onMemorySample(() => sample());
2945
+ return unsubscribe;
2869
2946
  }, [visible]);
2870
2947
  const recordQueryTime = useCallback((ms) => {
2871
2948
  lastQueryMsRef.current = Math.round(ms * 10) / 10;
@@ -3027,7 +3104,8 @@ function useImsg() {
3027
3104
  if (slugRecord.isGroup) {
3028
3105
  return slugRecord.displayName && !slugRecord.displayName.startsWith("chat") ? sendToChat(slugRecord.displayName, text) : sendToChatId(slugRecord.chatGuid, text);
3029
3106
  }
3030
- return sendMessageAlt(slugRecord.chatIdentifier, text);
3107
+ const preferred = db.getPreferredSendService(slugRecord.chatIdentifier) ?? (slugRecord.service === "SMS" ? "SMS" : "iMessage");
3108
+ return sendMessageReliable(slugRecord.chatIdentifier, text, preferred);
3031
3109
  },
3032
3110
  [getDb]
3033
3111
  );
@@ -3051,9 +3129,12 @@ function useImsg() {
3051
3129
  error: `Ambiguous: ${resolution.candidates.length} matches for "${input}". Pick a specific handle.`
3052
3130
  };
3053
3131
  }
3054
- return sendMessageAlt(resolution.handle, text);
3132
+ const db = getDb();
3133
+ const conv = await db.findChatByHandle(resolution.handle);
3134
+ const preferred = conv && !conv.isGroupChat ? db.getPreferredSendService(conv.chatIdentifier) ?? (conv.serviceType === "SMS" ? "SMS" : "iMessage") : void 0;
3135
+ return sendMessageReliable(resolution.handle, text, preferred);
3055
3136
  },
3056
- [resolveRecipientInput]
3137
+ [resolveRecipientInput, getDb]
3057
3138
  );
3058
3139
  const refresh = useCallback(() => {
3059
3140
  getDb().scheduleBackgroundRefresh();
@@ -3124,6 +3205,7 @@ const initialState = {
3124
3205
  status: "Loading...",
3125
3206
  numBuffer: "",
3126
3207
  showDevStats: false,
3208
+ drawerAttachmentIdx: 0,
3127
3209
  conversationLoadedCount: 0,
3128
3210
  conversationLoadingMore: false,
3129
3211
  messageOldestLoadedId: null,
@@ -3382,9 +3464,11 @@ function reducer(state, action) {
3382
3464
  case "EXIT_FILTER":
3383
3465
  return { ...state, mode: "browse", filterQuery: "" };
3384
3466
  case "OPEN_DRAWER":
3385
- return { ...state, mode: "drawer" };
3467
+ return { ...state, mode: "drawer", drawerAttachmentIdx: 0 };
3386
3468
  case "CLOSE_DRAWER":
3387
3469
  return { ...state, mode: "browse" };
3470
+ case "SET_DRAWER_ATTACHMENT":
3471
+ return { ...state, drawerAttachmentIdx: Math.max(0, action.index) };
3388
3472
  case "SET_NUM_BUFFER":
3389
3473
  return { ...state, numBuffer: action.value };
3390
3474
  case "TOGGLE_DEV_STATS":
@@ -3476,8 +3560,8 @@ function App() {
3476
3560
  const commands2 = useMemo(() => allCommands(state), [state]);
3477
3561
  const commandCtx = useMemo(() => ({ state, dispatch, imsg }), [state, imsg]);
3478
3562
  const loadMessages = useCallback(
3479
- async (idx) => {
3480
- const conv = state.conversations[idx];
3563
+ async (idx, convOverride) => {
3564
+ const conv = convOverride ?? state.conversations[idx];
3481
3565
  if (!conv) return;
3482
3566
  dispatch({
3483
3567
  type: "SET_LOADING",
@@ -3498,11 +3582,15 @@ function App() {
3498
3582
  dispatch({ type: "SET_CONVERSATIONS", data: convs });
3499
3583
  if (convs.length > 0) {
3500
3584
  const prevSlug = selected?.threadSlug;
3585
+ let targetIdx = Math.min(Math.max(state.selectedIdx, 0), convs.length - 1);
3501
3586
  if (prevSlug) {
3502
3587
  const idx = convs.findIndex((c) => c.threadSlug === prevSlug);
3503
- if (idx >= 0) dispatch({ type: "SELECT", index: idx, visibleCount: sidebarVisibleCount });
3588
+ if (idx >= 0) {
3589
+ dispatch({ type: "SELECT", index: idx, visibleCount: sidebarVisibleCount });
3590
+ targetIdx = idx;
3591
+ }
3504
3592
  }
3505
- await loadMessages(state.selectedIdx);
3593
+ await loadMessages(targetIdx, convs[targetIdx]);
3506
3594
  }
3507
3595
  imsg.refresh();
3508
3596
  dispatch({ type: "SET_LOADING", loading: false, status: "" });
@@ -3527,11 +3615,11 @@ function App() {
3527
3615
  selected.chatIdentifier,
3528
3616
  state.messageOldestLoadedId
3529
3617
  );
3530
- if (olderMsgs.length === 0) {
3618
+ const newOldestId = minMessageId(olderMsgs) ?? -1;
3619
+ if (olderMsgs.length === 0 || newOldestId < 0 || newOldestId >= state.messageOldestLoadedId) {
3531
3620
  dispatch({ type: "PREPEND_MESSAGES", data: [], oldestId: -1 });
3532
3621
  return;
3533
3622
  }
3534
- const newOldestId = minMessageId(olderMsgs) ?? -1;
3535
3623
  dispatch({ type: "PREPEND_MESSAGES", data: olderMsgs, oldestId: newOldestId });
3536
3624
  }, [imsg, selected, state.messageLoadingOlder, state.messageOldestLoadedId]);
3537
3625
  useEffect(() => {
@@ -3590,11 +3678,19 @@ function App() {
3590
3678
  try {
3591
3679
  const msgs = await imsg.loadMessages(selected.chatIdentifier);
3592
3680
  const found = msgs.some((m) => m.isFromMe && m.text?.includes(text));
3593
- if (found) {
3681
+ const failed = msgs.some(
3682
+ (m) => m.isFromMe && m.sendError !== void 0 && m.text?.includes(text)
3683
+ );
3684
+ if (failed) {
3685
+ dispatch({ type: "SET_MESSAGES", data: msgs });
3686
+ dispatch({ type: "FAIL_PENDING", text });
3687
+ } else if (found) {
3594
3688
  dispatch({ type: "SET_MESSAGES", data: msgs });
3595
3689
  dispatch({ type: "RESOLVE_PENDING", text });
3596
3690
  } else if (attempt < 7) {
3597
3691
  pollTimerRef.current = setTimeout(poll, 1500);
3692
+ } else {
3693
+ dispatch({ type: "FAIL_PENDING", text });
3598
3694
  }
3599
3695
  } catch {
3600
3696
  dispatch({ type: "FAIL_PENDING", text });
@@ -3681,6 +3777,9 @@ function App() {
3681
3777
  }
3682
3778
  return;
3683
3779
  }
3780
+ if (state.mode === "compose-new") {
3781
+ return;
3782
+ }
3684
3783
  if (state.mode === "confirm") {
3685
3784
  if (key.return) {
3686
3785
  await sendMessage();
@@ -3690,10 +3789,29 @@ function App() {
3690
3789
  return;
3691
3790
  }
3692
3791
  if (state.mode === "drawer") {
3792
+ const attCount = selectedMsg?.attachments?.length ?? 0;
3693
3793
  if (key.escape || input === "q") {
3694
3794
  dispatch({ type: "CLOSE_DRAWER" });
3795
+ } else if ((input === "j" || key.downArrow) && attCount > 1) {
3796
+ dispatch({
3797
+ type: "SET_DRAWER_ATTACHMENT",
3798
+ index: Math.min(state.drawerAttachmentIdx + 1, attCount - 1)
3799
+ });
3800
+ } else if ((input === "k" || key.upArrow) && attCount > 1) {
3801
+ dispatch({ type: "SET_DRAWER_ATTACHMENT", index: state.drawerAttachmentIdx - 1 });
3695
3802
  } else if (input === "o" && selectedMsg) {
3696
- openAttachment(selectedMsg);
3803
+ openAttachment(selectedMsg, state.drawerAttachmentIdx);
3804
+ } else if (input === "s" && selectedMsg) {
3805
+ saveAttachment(selectedMsg, state.drawerAttachmentIdx);
3806
+ } else if (input === "y" && selectedMsg) {
3807
+ const att = selectedMsg.attachments?.[state.drawerAttachmentIdx];
3808
+ if (att?.filename) {
3809
+ const filepath = att.filename.replace(/^~/, process.env.HOME ?? "~");
3810
+ execSync("pbcopy", { input: filepath });
3811
+ dispatch({ type: "SET_STATUS", status: "Attachment path copied." });
3812
+ } else {
3813
+ dispatch({ type: "SET_STATUS", status: "No attachment to copy." });
3814
+ }
3697
3815
  }
3698
3816
  return;
3699
3817
  }
@@ -3938,9 +4056,16 @@ function App() {
3938
4056
  const prev = prevGroupBoundary(state.messages, state.selectedMsgIdx);
3939
4057
  dispatch({ type: "SELECT_MSG", index: prev });
3940
4058
  } else if (input === "o") {
3941
- openAttachment(
3942
- state.selectedMsgIdx >= 0 ? state.messages[state.selectedMsgIdx] : void 0
3943
- );
4059
+ const msg = state.selectedMsgIdx >= 0 ? state.messages[state.selectedMsgIdx] : void 0;
4060
+ if (msg && (msg.attachments?.length ?? 0) > 1) {
4061
+ dispatch({ type: "OPEN_DRAWER" });
4062
+ dispatch({
4063
+ type: "SET_STATUS",
4064
+ status: `${msg.attachments?.length} attachments — j/k select, o open, s save`
4065
+ });
4066
+ } else {
4067
+ openAttachment(msg);
4068
+ }
3944
4069
  }
3945
4070
  }
3946
4071
  });
@@ -4037,7 +4162,7 @@ function App() {
4037
4162
  state.selectionAnchor,
4038
4163
  selected
4039
4164
  ]);
4040
- function openAttachment(msg) {
4165
+ function openAttachment(msg, attIdx = 0) {
4041
4166
  if (!msg) {
4042
4167
  dispatch({ type: "SET_STATUS", status: "No message selected." });
4043
4168
  return;
@@ -4046,7 +4171,7 @@ function App() {
4046
4171
  dispatch({ type: "SET_STATUS", status: "No attachment on this message." });
4047
4172
  return;
4048
4173
  }
4049
- const att = msg.attachments[0];
4174
+ const att = msg.attachments[attIdx] ?? msg.attachments[0];
4050
4175
  if (!att.filename) {
4051
4176
  dispatch({ type: "SET_STATUS", status: "Attachment has no file path." });
4052
4177
  return;
@@ -4064,6 +4189,35 @@ function App() {
4064
4189
  }
4065
4190
  });
4066
4191
  }
4192
+ function saveAttachment(msg, attIdx) {
4193
+ const att = msg.attachments?.[attIdx];
4194
+ if (!att?.filename) {
4195
+ dispatch({ type: "SET_STATUS", status: "No attachment to save." });
4196
+ return;
4197
+ }
4198
+ const src = att.filename.replace(/^~/, process.env.HOME ?? "~");
4199
+ import("node:fs").then((fs) => {
4200
+ import("node:path").then((path) => {
4201
+ try {
4202
+ const base = att.transferName ?? path.basename(src);
4203
+ const dir = join(homedir(), "Downloads");
4204
+ const ext = path.extname(base);
4205
+ const stem = base.slice(0, base.length - ext.length);
4206
+ let dest = path.join(dir, base);
4207
+ for (let n = 1; fs.existsSync(dest); n++) {
4208
+ dest = path.join(dir, `${stem}-${n}${ext}`);
4209
+ }
4210
+ fs.copyFileSync(src, dest);
4211
+ dispatch({ type: "SET_STATUS", status: `Saved to ${dest.replace(homedir(), "~")}` });
4212
+ } catch (e) {
4213
+ dispatch({
4214
+ type: "SET_STATUS",
4215
+ status: `Save failed: ${e instanceof Error ? e.message : String(e)}`
4216
+ });
4217
+ }
4218
+ });
4219
+ });
4220
+ }
4067
4221
  const handleMouse = useCallback(
4068
4222
  (event) => {
4069
4223
  if (event.type === "click") {
@@ -4163,7 +4317,15 @@ function App() {
4163
4317
  onSubmitCompose: (text) => text.trim() && dispatch({ type: "CONFIRM_SEND" })
4164
4318
  }
4165
4319
  ),
4166
- state.mode === "drawer" && selectedMsg && /* @__PURE__ */ jsx(MessageDrawer, { message: selectedMsg, width: drawerWidth, height: bodyHeight }),
4320
+ state.mode === "drawer" && selectedMsg && /* @__PURE__ */ jsx(
4321
+ MessageDrawer,
4322
+ {
4323
+ message: selectedMsg,
4324
+ width: drawerWidth,
4325
+ height: bodyHeight,
4326
+ selectedAttachmentIdx: state.drawerAttachmentIdx
4327
+ }
4328
+ ),
4167
4329
  state.showDevStats && /* @__PURE__ */ jsx(DevStats, { stats: devStats, width: devStatsWidth })
4168
4330
  ] }),
4169
4331
  state.mode === "date-jump" && /* @__PURE__ */ jsx(