@howler/cli 0.2.3 → 0.2.4

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.
Files changed (2) hide show
  1. package/dist/bin.js +216 -121
  2. package/package.json +1 -1
package/dist/bin.js CHANGED
@@ -841,6 +841,28 @@ import {
841
841
  SessionCipher,
842
842
  SignalProtocolAddress
843
843
  } from "@privacyresearch/libsignal-protocol-typescript";
844
+ async function withDecryptSessionLock(sessionKey, work) {
845
+ const previous = decryptSessionLocks.get(sessionKey) ?? Promise.resolve();
846
+ let release;
847
+ const current = new Promise((resolve) => {
848
+ release = resolve;
849
+ });
850
+ decryptSessionLocks.set(sessionKey, previous.then(() => current));
851
+ await previous;
852
+ try {
853
+ return await work();
854
+ } finally {
855
+ release();
856
+ const active = decryptSessionLocks.get(sessionKey);
857
+ if (active) {
858
+ void active.finally(() => {
859
+ if (decryptSessionLocks.get(sessionKey) === active) {
860
+ decryptSessionLocks.delete(sessionKey);
861
+ }
862
+ });
863
+ }
864
+ }
865
+ }
844
866
  async function getOrCreateSession(recipientUserId, deviceId = 1) {
845
867
  const address = new SignalProtocolAddress(recipientUserId, deviceId);
846
868
  const addressKey = `${recipientUserId}.${deviceId}`;
@@ -899,35 +921,37 @@ async function encryptForRecipient(data, recipientUserId, deviceId = 1) {
899
921
  async function decryptFromSender(ciphertext, senderUserId, deviceId = 1) {
900
922
  const address = new SignalProtocolAddress(senderUserId, deviceId);
901
923
  const addressKey = `${senderUserId}.${deviceId}`;
902
- const existingSession = await signalStore.loadSession(addressKey);
903
- if (define_import_meta_env_default.DEV) console.log(`[Signal] Decrypting from ${senderUserId.slice(0, 8)}.${deviceId}, type: ${ciphertext.type}, hasSession: ${!!existingSession}`);
904
- const sessionCipher = new SessionCipher(signalStore, address);
905
- const binaryBody = base64ToBinaryString(ciphertext.body);
906
- let plaintext;
907
- try {
908
- if (ciphertext.type === 3) {
909
- if (define_import_meta_env_default.DEV) console.log("[Signal] Decrypting PreKeyWhisperMessage (type 3) - will establish session");
910
- plaintext = await sessionCipher.decryptPreKeyWhisperMessage(binaryBody, "binary");
911
- } else {
912
- if (!existingSession) {
913
- console.error("[Signal] No existing session for WhisperMessage (type 1) - this will fail");
924
+ return withDecryptSessionLock(addressKey, async () => {
925
+ const existingSession = await signalStore.loadSession(addressKey);
926
+ if (define_import_meta_env_default.DEV) console.log(`[Signal] Decrypting from ${senderUserId.slice(0, 8)}.${deviceId}, type: ${ciphertext.type}, hasSession: ${!!existingSession}`);
927
+ const sessionCipher = new SessionCipher(signalStore, address);
928
+ const binaryBody = base64ToBinaryString(ciphertext.body);
929
+ let plaintext;
930
+ try {
931
+ if (ciphertext.type === 3) {
932
+ if (define_import_meta_env_default.DEV) console.log("[Signal] Decrypting PreKeyWhisperMessage (type 3) - will establish session");
933
+ plaintext = await sessionCipher.decryptPreKeyWhisperMessage(binaryBody, "binary");
934
+ } else {
935
+ if (!existingSession) {
936
+ console.error("[Signal] No existing session for WhisperMessage (type 1) - this will fail");
937
+ }
938
+ plaintext = await sessionCipher.decryptWhisperMessage(binaryBody, "binary");
914
939
  }
915
- plaintext = await sessionCipher.decryptWhisperMessage(binaryBody, "binary");
916
- }
917
- } catch (err) {
918
- const errorMsg = err instanceof Error ? err.message : String(err);
919
- console.error(`[Signal] Decryption failed:`, {
920
- error: errorMsg,
921
- sender: `${senderUserId.slice(0, 8)}.${deviceId}`,
922
- messageType: ciphertext.type,
923
- hadSession: !!existingSession
924
- });
925
- if (errorMsg.includes("Bad MAC") && existingSession) {
926
- console.error("[Signal] Bad MAC with existing session - session may be corrupted");
940
+ } catch (err) {
941
+ const errorMsg = err instanceof Error ? err.message : String(err);
942
+ console.error(`[Signal] Decryption failed:`, {
943
+ error: errorMsg,
944
+ sender: `${senderUserId.slice(0, 8)}.${deviceId}`,
945
+ messageType: ciphertext.type,
946
+ hadSession: !!existingSession
947
+ });
948
+ if (errorMsg.includes("Bad MAC") && existingSession) {
949
+ console.error("[Signal] Bad MAC with existing session - session may be corrupted");
950
+ }
951
+ throw err;
927
952
  }
928
- throw err;
929
- }
930
- return plaintext;
953
+ return plaintext;
954
+ });
931
955
  }
932
956
  async function encryptMessage(message, recipientUserId) {
933
957
  const encoder = new TextEncoder();
@@ -949,12 +973,14 @@ async function resetSessionWith(userId) {
949
973
  await signalStore.removeAllSessions(userId);
950
974
  if (define_import_meta_env_default.DEV) console.log("[Signal] Session reset complete");
951
975
  }
976
+ var decryptSessionLocks;
952
977
  var init_crypto = __esm({
953
978
  "../../packages/core/src/lib/signal/crypto.ts"() {
954
979
  "use strict";
955
980
  init_define_import_meta_env();
956
981
  init_store();
957
982
  init_supabaseKeys();
983
+ decryptSessionLocks = /* @__PURE__ */ new Map();
958
984
  }
959
985
  });
960
986
 
@@ -2244,6 +2270,7 @@ __export(messages_exports, {
2244
2270
  clearDecryptedAudioCache: () => clearDecryptedAudioCache,
2245
2271
  connectHowlerToParent: () => connectHowlerToParent,
2246
2272
  decryptTextOnlyTranscript: () => decryptTextOnlyTranscript,
2273
+ decryptTextOnlyTranscriptBatch: () => decryptTextOnlyTranscriptBatch,
2247
2274
  getBlobUrlCount: () => getBlobUrlCount,
2248
2275
  getConversation: () => getConversation,
2249
2276
  getConversations: () => getConversations,
@@ -2824,41 +2851,45 @@ async function getDecryptedTranscript(howlerId) {
2824
2851
  const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2825
2852
  return getTranscriptCacheProvider2().getCachedTranscript(howlerId);
2826
2853
  }
2827
- async function decryptTextOnlyTranscript(howlerId) {
2828
- const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2829
- const provider = getTranscriptCacheProvider2();
2830
- const cached = await provider.getCachedTranscript(howlerId);
2831
- if (cached) return cached;
2832
- const { data: howler, error } = await db.from("howlers").select("id, sender_id, transcript, encryption_metadata").eq("id", howlerId).single();
2833
- if (error || !howler) return null;
2834
- const meta = parseEncryptionMetadata(howler.encryption_metadata);
2835
- if (!meta?.transcriptEncrypted || !meta.transcriptIv) return null;
2836
- if (!howler.transcript || typeof howler.transcript !== "string") return null;
2837
- const { data: { user } } = await db.auth.getUser();
2838
- if (!user) return null;
2839
- const { getLocalDeviceId: getLocalDeviceId2 } = await Promise.resolve().then(() => (init_device(), device_exports));
2840
- const myDeviceId = getLocalDeviceId2();
2841
- if (!myDeviceId) return null;
2854
+ function resolveTranscriptDecryptionTarget(howler, meta, currentUserId, myDeviceId) {
2842
2855
  let keyToDecrypt = meta.encryptedKey;
2843
2856
  let sessionUserId = howler.sender_id;
2844
2857
  const sessionDeviceId = meta.senderDeviceId ?? 1;
2845
2858
  if (meta.encryptedKeys && meta.encryptedKeys.length > 0) {
2846
2859
  const myKey = meta.encryptedKeys.find(
2847
- (k) => k.userId === user.id && k.deviceId === myDeviceId
2860
+ (k) => k.userId === currentUserId && k.deviceId === myDeviceId
2848
2861
  );
2849
2862
  if (!myKey) {
2850
2863
  const deviceList = meta.encryptedKeys.map(
2851
2864
  (k) => `${k.userId.slice(0, 8)}.${k.deviceId}`
2852
2865
  ).join(", ");
2853
- console.warn(`[E2E] No key for device ${myDeviceId} in [${deviceList}] \u2014 message ${howlerId.slice(0, 8)}`);
2866
+ console.warn(`[E2E] No key for device ${myDeviceId} in [${deviceList}] \u2014 message ${howler.id.slice(0, 8)}`);
2854
2867
  return null;
2855
2868
  }
2856
2869
  keyToDecrypt = { type: myKey.type, body: myKey.body, registrationId: myKey.registrationId };
2857
- sessionUserId = user.id === howler.sender_id ? user.id : howler.sender_id;
2870
+ sessionUserId = currentUserId === howler.sender_id ? currentUserId : howler.sender_id;
2858
2871
  }
2872
+ return {
2873
+ encryptedKey: keyToDecrypt,
2874
+ sessionUserId,
2875
+ sessionDeviceId
2876
+ };
2877
+ }
2878
+ async function decryptTextOnlyTranscriptRow(howler, currentUserId, myDeviceId, provider) {
2879
+ const cached = await provider.getCachedTranscript(howler.id);
2880
+ if (cached) return cached;
2881
+ const meta = parseEncryptionMetadata(howler.encryption_metadata);
2882
+ if (!meta?.transcriptEncrypted || !meta.transcriptIv) return null;
2883
+ if (!howler.transcript || typeof howler.transcript !== "string") return null;
2884
+ const decryptionTarget = resolveTranscriptDecryptionTarget(howler, meta, currentUserId, myDeviceId);
2885
+ if (!decryptionTarget) return null;
2859
2886
  try {
2860
2887
  const { decryptFromSender: decryptFromSender2, base64ToArrayBuffer: base64ToArrayBuffer5, decryptWithAES: decryptWithAES2 } = await Promise.resolve().then(() => (init_signal(), signal_exports));
2861
- const keyBytes = await decryptFromSender2(keyToDecrypt, sessionUserId, sessionDeviceId);
2888
+ const keyBytes = await decryptFromSender2(
2889
+ decryptionTarget.encryptedKey,
2890
+ decryptionTarget.sessionUserId,
2891
+ decryptionTarget.sessionDeviceId
2892
+ );
2862
2893
  const aesKey = await crypto.subtle.importKey(
2863
2894
  "raw",
2864
2895
  keyBytes,
@@ -2871,14 +2902,46 @@ async function decryptTextOnlyTranscript(howlerId) {
2871
2902
  const plaintext = await decryptWithAES2(ciphertext, aesKey, iv);
2872
2903
  const json = new TextDecoder().decode(plaintext);
2873
2904
  const transcript = JSON.parse(json);
2874
- await provider.cacheTranscript(howlerId, transcript);
2875
- console.log("[E2E] Decrypted text-only transcript:", howlerId);
2905
+ await provider.cacheTranscript(howler.id, transcript);
2906
+ console.log("[E2E] Decrypted text-only transcript:", howler.id);
2876
2907
  return transcript;
2877
2908
  } catch (err) {
2878
- console.error("[E2E] Failed to decrypt text-only transcript:", howlerId, err);
2909
+ console.error("[E2E] Failed to decrypt text-only transcript:", howler.id, err);
2879
2910
  return null;
2880
2911
  }
2881
2912
  }
2913
+ async function decryptTextOnlyTranscriptBatch(messages) {
2914
+ if (messages.length === 0) return /* @__PURE__ */ new Map();
2915
+ const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2916
+ const provider = getTranscriptCacheProvider2();
2917
+ const { data: { user } } = await db.auth.getUser();
2918
+ if (!user) return /* @__PURE__ */ new Map();
2919
+ const { getLocalDeviceId: getLocalDeviceId2 } = await Promise.resolve().then(() => (init_device(), device_exports));
2920
+ const myDeviceId = getLocalDeviceId2();
2921
+ if (!myDeviceId) return /* @__PURE__ */ new Map();
2922
+ const decrypted = /* @__PURE__ */ new Map();
2923
+ for (const message of messages) {
2924
+ const transcript = await decryptTextOnlyTranscriptRow(
2925
+ message,
2926
+ user.id,
2927
+ myDeviceId,
2928
+ provider
2929
+ );
2930
+ if (transcript) {
2931
+ decrypted.set(message.id, transcript);
2932
+ }
2933
+ }
2934
+ return decrypted;
2935
+ }
2936
+ async function decryptTextOnlyTranscript(howlerId) {
2937
+ const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
2938
+ const cached = await getTranscriptCacheProvider2().getCachedTranscript(howlerId);
2939
+ if (cached) return cached;
2940
+ const { data: howler, error } = await db.from("howlers").select("id, sender_id, transcript, encryption_metadata").eq("id", howlerId).single();
2941
+ if (error || !howler) return null;
2942
+ const batch = await decryptTextOnlyTranscriptBatch([howler]);
2943
+ return batch.get(howlerId) ?? null;
2944
+ }
2882
2945
  var db, PUBLIC_USER_COLUMNS, blobUrlCache, blobUrlCount;
2883
2946
  var init_messages = __esm({
2884
2947
  "../../packages/core/src/lib/messages/index.ts"() {
@@ -29637,21 +29700,6 @@ async function getGroupMessages(groupId, limit = 50, beforeTimestamp) {
29637
29700
  const trimmed = messages.slice(0, limit).reverse();
29638
29701
  return { messages: trimmed, hasMore };
29639
29702
  }
29640
- async function tryDecrypt(msg) {
29641
- if (!msg.isEncrypted || msg.recording_url === "text://plain") return msg;
29642
- try {
29643
- const transcript = await decryptTextOnlyTranscript(msg.id);
29644
- if (transcript && transcript.length > 0) {
29645
- return {
29646
- ...msg,
29647
- transcript: JSON.stringify(transcript)
29648
- };
29649
- }
29650
- } catch (err) {
29651
- console.error("[TUI] Decrypt failed:", msg.id, err);
29652
- }
29653
- return msg;
29654
- }
29655
29703
  function isCacheableTranscriptJson(transcript) {
29656
29704
  if (!transcript) return false;
29657
29705
  try {
@@ -29661,16 +29709,88 @@ function isCacheableTranscriptJson(transcript) {
29661
29709
  return false;
29662
29710
  }
29663
29711
  }
29712
+ function shouldResolveTranscript(msg) {
29713
+ return msg.isEncrypted && msg.recording_url !== "text://plain";
29714
+ }
29664
29715
  function useMessages(conversation, currentUserId) {
29665
29716
  const [messages, setMessages] = useState3([]);
29666
29717
  const [loading, setLoading] = useState3(!!conversation);
29667
29718
  const [error, setError] = useState3(null);
29668
29719
  const [hasMore, setHasMore] = useState3(false);
29669
- const decryptedIds = useRef3(/* @__PURE__ */ new Set());
29720
+ const resolvedIds = useRef3(/* @__PURE__ */ new Set());
29721
+ const inFlightIds = useRef3(/* @__PURE__ */ new Set());
29722
+ const activeConversationId = useRef3(conversation?.id ?? null);
29670
29723
  const transcriptCache = useRef3(/* @__PURE__ */ new Map());
29671
29724
  const rememberResolvedTranscript = useCallback2((msg) => {
29672
- if (!isCacheableTranscriptJson(msg.transcript)) return;
29725
+ if (!isCacheableTranscriptJson(msg.transcript)) return false;
29673
29726
  transcriptCache.current.set(msg.id, msg.transcript);
29727
+ resolvedIds.current.add(msg.id);
29728
+ inFlightIds.current.delete(msg.id);
29729
+ return true;
29730
+ }, []);
29731
+ const applyCachedTranscripts = useCallback2((nextMessages) => {
29732
+ return nextMessages.map((message) => {
29733
+ const cached = transcriptCache.current.get(message.id);
29734
+ return cached ? { ...message, transcript: cached } : message;
29735
+ });
29736
+ }, []);
29737
+ const prewarmTranscriptCache = useCallback2(async (nextMessages) => {
29738
+ const messagesToCheck = nextMessages.filter(
29739
+ (message) => shouldResolveTranscript(message) && !transcriptCache.current.has(message.id)
29740
+ );
29741
+ if (messagesToCheck.length === 0) return;
29742
+ const cachedEntries = await Promise.all(
29743
+ messagesToCheck.map(async (message) => {
29744
+ const transcript = await getDecryptedTranscript(message.id);
29745
+ return { id: message.id, transcript };
29746
+ })
29747
+ );
29748
+ for (const entry of cachedEntries) {
29749
+ if (!entry.transcript) continue;
29750
+ transcriptCache.current.set(entry.id, JSON.stringify(entry.transcript));
29751
+ resolvedIds.current.add(entry.id);
29752
+ }
29753
+ }, []);
29754
+ const resolveMessagesInBackground = useCallback2((nextMessages) => {
29755
+ const targetConversationId = activeConversationId.current;
29756
+ const messagesToResolve = nextMessages.filter(
29757
+ (message) => shouldResolveTranscript(message) && !transcriptCache.current.has(message.id) && !resolvedIds.current.has(message.id) && !inFlightIds.current.has(message.id)
29758
+ );
29759
+ if (messagesToResolve.length === 0) return;
29760
+ for (const message of messagesToResolve) {
29761
+ inFlightIds.current.add(message.id);
29762
+ }
29763
+ void decryptTextOnlyTranscriptBatch(
29764
+ messagesToResolve.map((message) => ({
29765
+ id: message.id,
29766
+ sender_id: message.sender_id,
29767
+ transcript: message.transcript,
29768
+ encryption_metadata: message.encryption_metadata
29769
+ }))
29770
+ ).then((decrypted) => {
29771
+ const updatedTranscripts = /* @__PURE__ */ new Map();
29772
+ for (const message of messagesToResolve) {
29773
+ inFlightIds.current.delete(message.id);
29774
+ const transcript = decrypted.get(message.id);
29775
+ if (!transcript) continue;
29776
+ const json = JSON.stringify(transcript);
29777
+ transcriptCache.current.set(message.id, json);
29778
+ resolvedIds.current.add(message.id);
29779
+ updatedTranscripts.set(message.id, json);
29780
+ }
29781
+ if (updatedTranscripts.size === 0 || targetConversationId !== activeConversationId.current) return;
29782
+ setMessages(
29783
+ (prev) => prev.map((message) => {
29784
+ const transcript = updatedTranscripts.get(message.id);
29785
+ return transcript ? { ...message, transcript } : message;
29786
+ })
29787
+ );
29788
+ }).catch((err) => {
29789
+ for (const message of messagesToResolve) {
29790
+ inFlightIds.current.delete(message.id);
29791
+ }
29792
+ console.error("[TUI] Background transcript decrypt failed:", err);
29793
+ });
29674
29794
  }, []);
29675
29795
  const fetchMessages = useCallback2(
29676
29796
  (beforeTimestamp) => {
@@ -29689,7 +29809,7 @@ function useMessages(conversation, currentUserId) {
29689
29809
  isGroup ? "(group path)" : "(1:1 path)"
29690
29810
  );
29691
29811
  const promise = isGroup ? getGroupMessages(conversation.id, 50, beforeTimestamp) : getConversation(conversation.id, 50, beforeTimestamp);
29692
- promise.then((result) => {
29812
+ promise.then(async (result) => {
29693
29813
  console.error(
29694
29814
  "[TUI] Fetched",
29695
29815
  result.messages.length,
@@ -29697,10 +29817,9 @@ function useMessages(conversation, currentUserId) {
29697
29817
  conversation.name,
29698
29818
  "hasMore=" + result.hasMore
29699
29819
  );
29700
- const withCached = result.messages.map((m) => {
29701
- const cached = transcriptCache.current.get(m.id);
29702
- return cached ? { ...m, transcript: cached } : m;
29703
- });
29820
+ await prewarmTranscriptCache(result.messages);
29821
+ if (activeConversationId.current !== conversation.id) return;
29822
+ const withCached = applyCachedTranscripts(result.messages);
29704
29823
  if (beforeTimestamp) {
29705
29824
  setMessages((prev) => {
29706
29825
  const ids = new Set(prev.map((m) => m.id));
@@ -29714,37 +29833,22 @@ function useMessages(conversation, currentUserId) {
29714
29833
  }
29715
29834
  setHasMore(result.hasMore);
29716
29835
  setLoading(false);
29717
- const encrypted = result.messages.filter(
29718
- (m) => m.isEncrypted && !decryptedIds.current.has(m.id)
29719
- );
29720
- const needsDecrypt = encrypted.filter(
29721
- (m) => !transcriptCache.current.has(m.id)
29722
- );
29723
- if (needsDecrypt.length > 0) {
29724
- Promise.all(needsDecrypt.map(tryDecrypt)).then((decrypted) => {
29725
- const decryptedMap = /* @__PURE__ */ new Map();
29726
- for (const msg of decrypted) {
29727
- decryptedIds.current.add(msg.id);
29728
- rememberResolvedTranscript(msg);
29729
- decryptedMap.set(msg.id, msg);
29730
- }
29731
- setMessages(
29732
- (prev) => prev.map((m) => decryptedMap.get(m.id) ?? m)
29733
- );
29734
- });
29735
- }
29836
+ resolveMessagesInBackground(result.messages);
29736
29837
  }).catch((err) => {
29838
+ if (activeConversationId.current !== conversation.id) return;
29737
29839
  const message = err instanceof Error ? err.message : String(err);
29738
29840
  console.error("[TUI] Message fetch failed:", conversation?.name, message);
29739
29841
  setError(message);
29740
29842
  setLoading(false);
29741
29843
  });
29742
29844
  },
29743
- [conversation]
29845
+ [applyCachedTranscripts, conversation, prewarmTranscriptCache, resolveMessagesInBackground]
29744
29846
  );
29745
29847
  useEffect3(() => {
29848
+ activeConversationId.current = conversation?.id ?? null;
29746
29849
  if (conversation) {
29747
- decryptedIds.current.clear();
29850
+ resolvedIds.current.clear();
29851
+ inFlightIds.current.clear();
29748
29852
  fetchMessages();
29749
29853
  } else {
29750
29854
  setMessages([]);
@@ -29753,45 +29857,37 @@ function useMessages(conversation, currentUserId) {
29753
29857
  }
29754
29858
  }, [conversation, fetchMessages]);
29755
29859
  const handleNewMessage = useCallback2((msg) => {
29756
- if (msg.isEncrypted && !decryptedIds.current.has(msg.id)) {
29757
- decryptedIds.current.add(msg.id);
29758
- tryDecrypt(msg).then((processed) => {
29759
- rememberResolvedTranscript(processed);
29760
- setMessages((prev) => {
29761
- if (prev.some((m) => m.id === processed.id)) return prev;
29762
- return [...prev, processed];
29763
- });
29860
+ if (shouldResolveTranscript(msg) && rememberResolvedTranscript(msg)) {
29861
+ setMessages((prev) => {
29862
+ if (prev.some((m) => m.id === msg.id)) return prev;
29863
+ return [...prev, msg];
29764
29864
  });
29765
29865
  return;
29766
29866
  }
29867
+ const cachedTranscript = transcriptCache.current.get(msg.id);
29868
+ const withCachedTranscript = cachedTranscript ? { ...msg, transcript: cachedTranscript } : msg;
29767
29869
  setMessages((prev) => {
29768
- if (prev.some((m) => m.id === msg.id)) return prev;
29769
- return [...prev, msg];
29870
+ if (prev.some((m) => m.id === withCachedTranscript.id)) return prev;
29871
+ return [...prev, withCachedTranscript];
29770
29872
  });
29771
- }, [rememberResolvedTranscript]);
29873
+ resolveMessagesInBackground([msg]);
29874
+ }, [rememberResolvedTranscript, resolveMessagesInBackground]);
29772
29875
  const handleUpdateMessage = useCallback2(
29773
29876
  (id, updates) => {
29877
+ const existing = messages.find((message) => message.id === id);
29878
+ const updatedMessage = existing ? { ...existing, ...updates } : null;
29774
29879
  setMessages(
29775
29880
  (prev) => prev.map((m) => m.id === id ? { ...m, ...updates } : m)
29776
29881
  );
29777
- if (updates.transcript && typeof updates.transcript === "string") {
29778
- setMessages((prev) => {
29779
- const msg = prev.find((m) => m.id === id);
29780
- if (!msg || !msg.isEncrypted || msg.recording_url === "text://plain") return prev;
29781
- decryptedIds.current.delete(id);
29782
- decryptedIds.current.add(id);
29783
- transcriptCache.current.delete(id);
29784
- tryDecrypt(msg).then((processed) => {
29785
- rememberResolvedTranscript(processed);
29786
- setMessages(
29787
- (p) => p.map((m) => m.id === id ? processed : m)
29788
- );
29789
- });
29790
- return prev;
29791
- });
29882
+ if (updatedMessage && typeof updates.transcript === "string" && shouldResolveTranscript(updatedMessage)) {
29883
+ transcriptCache.current.delete(id);
29884
+ resolvedIds.current.delete(id);
29885
+ inFlightIds.current.delete(id);
29886
+ if (rememberResolvedTranscript(updatedMessage)) return;
29887
+ resolveMessagesInBackground([updatedMessage]);
29792
29888
  }
29793
29889
  },
29794
- [rememberResolvedTranscript]
29890
+ [messages, rememberResolvedTranscript, resolveMessagesInBackground]
29795
29891
  );
29796
29892
  useRealtimeMessages(
29797
29893
  conversation,
@@ -29810,8 +29906,7 @@ function useMessages(conversation, currentUserId) {
29810
29906
  fetchMessages();
29811
29907
  }, [fetchMessages]);
29812
29908
  const addMessage = useCallback2((msg) => {
29813
- if (msg.isEncrypted && msg.recording_url !== "text://plain" && isCacheableTranscriptJson(msg.transcript)) {
29814
- decryptedIds.current.add(msg.id);
29909
+ if (shouldResolveTranscript(msg) && isCacheableTranscriptJson(msg.transcript)) {
29815
29910
  rememberResolvedTranscript(msg);
29816
29911
  }
29817
29912
  setMessages((prev) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howler/cli",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "Howler TUI — encrypted messaging from your terminal",
6
6
  "bin": {