@howler/cli 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -10
- package/dist/bin.js +249 -125
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @howler/cli
|
|
2
2
|
|
|
3
|
-
Terminal UI client for [Howler](https://howler.pages.dev) encrypted messaging.
|
|
3
|
+
Terminal UI client for [Howler](https://howler.pages.dev) encrypted messaging. Same account, same E2E encryption, same conversations — from your terminal.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -8,33 +8,61 @@ Terminal UI client for [Howler](https://howler.pages.dev) encrypted messaging.
|
|
|
8
8
|
npm install -g @howler/cli
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
+
Or run without installing:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @howler/cli
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Getting started
|
|
18
|
+
|
|
19
|
+
1. Run `howler` in your terminal
|
|
20
|
+
2. On first launch, your browser opens to log in with your Howler account
|
|
21
|
+
3. Once authenticated, the TUI connects and loads your inbox
|
|
22
|
+
4. Use `Tab` to switch between the inbox panel (left) and conversation panel (right)
|
|
23
|
+
5. Press `Enter` on a chat to open it, type a message, and press `Enter` to send
|
|
24
|
+
|
|
25
|
+
If you don't have an account yet, sign up at https://howler.pages.dev.
|
|
26
|
+
|
|
11
27
|
## Usage
|
|
12
28
|
|
|
13
29
|
```bash
|
|
14
30
|
howler # Launch TUI (opens browser for login on first run)
|
|
15
|
-
howler --logout # Sign out
|
|
31
|
+
howler --logout # Sign out and clear stored credentials
|
|
16
32
|
```
|
|
17
33
|
|
|
18
|
-
|
|
34
|
+
## Keyboard shortcuts
|
|
19
35
|
|
|
20
36
|
| Key | Action |
|
|
21
37
|
|-----|--------|
|
|
22
|
-
| `Tab` / `Shift+Tab` | Switch between panels |
|
|
38
|
+
| `Tab` / `Shift+Tab` | Switch between inbox and conversation panels |
|
|
23
39
|
| `↑` / `↓` | Navigate list or scroll messages |
|
|
24
40
|
| `Enter` | Select chat / send message |
|
|
25
|
-
| `Escape` | Return to chat list |
|
|
26
|
-
| `1` `2` `3` `4` | Filter: All, 1:1, Groups, AI |
|
|
41
|
+
| `Escape` | Return to chat list or branch list |
|
|
42
|
+
| `1` `2` `3` `4` | Filter inbox: All, 1:1, Groups, AI |
|
|
27
43
|
| `l` | Load older messages |
|
|
44
|
+
| `/` | Open slash command autocomplete |
|
|
45
|
+
|
|
46
|
+
## Slash commands
|
|
28
47
|
|
|
29
|
-
|
|
48
|
+
Type `/` in the message input to see autocomplete suggestions.
|
|
30
49
|
|
|
31
50
|
| Command | Description |
|
|
32
51
|
|---------|-------------|
|
|
33
|
-
| `/vault <query>` | Search your Obsidian vault |
|
|
34
52
|
| `/agent <goal>` | Create an agent task (bot chats only) |
|
|
35
|
-
| `/
|
|
53
|
+
| `/addmember @username` | Add a member to the current group |
|
|
54
|
+
| `/change-branch` | Switch branch in repo bot chats |
|
|
55
|
+
| `/current-branch` | Show the active branch |
|
|
56
|
+
| `/exit` | Quit the TUI |
|
|
57
|
+
|
|
58
|
+
## Features
|
|
59
|
+
|
|
60
|
+
- **E2E encrypted** — Signal Protocol, same keys as the web app
|
|
61
|
+
- **Real-time** — messages appear instantly via Supabase subscriptions
|
|
62
|
+
- **Multi-device** — works alongside the web app on the same account
|
|
63
|
+
- **Bot/agent support** — interact with AI agents, view branch-based conversations
|
|
36
64
|
|
|
37
65
|
## Requirements
|
|
38
66
|
|
|
39
67
|
- Node.js 18+
|
|
40
|
-
- A Howler account (sign up at howler.pages.dev)
|
|
68
|
+
- A Howler account (sign up at https://howler.pages.dev)
|
package/dist/bin.js
CHANGED
|
@@ -201,6 +201,8 @@ var init_store = __esm({
|
|
|
201
201
|
init_db();
|
|
202
202
|
SignalProtocolStore = class _SignalProtocolStore {
|
|
203
203
|
static instance = null;
|
|
204
|
+
/** Callback invoked when a remote identity key changes. */
|
|
205
|
+
onIdentityKeyChanged = null;
|
|
204
206
|
static getInstance() {
|
|
205
207
|
if (!_SignalProtocolStore.instance) {
|
|
206
208
|
_SignalProtocolStore.instance = new _SignalProtocolStore();
|
|
@@ -230,7 +232,11 @@ var init_store = __esm({
|
|
|
230
232
|
const existingB64 = arrayBufferToBase64(existing.identityKey);
|
|
231
233
|
const newB64 = arrayBufferToBase64(publicKey);
|
|
232
234
|
if (existingB64 !== newB64) {
|
|
235
|
+
const previousKey = existing.identityKey;
|
|
233
236
|
await db2.put("trustedIdentities", { identityKey: publicKey }, encodedAddress);
|
|
237
|
+
if (this.onIdentityKeyChanged) {
|
|
238
|
+
this.onIdentityKeyChanged({ address: encodedAddress, previousKey, newKey: publicKey });
|
|
239
|
+
}
|
|
234
240
|
return true;
|
|
235
241
|
}
|
|
236
242
|
return false;
|
|
@@ -703,6 +709,7 @@ async function verifyDeviceExists(userId) {
|
|
|
703
709
|
var LEGACY_DEVICE_ID_KEY, LEGACY_DEVICE_NAME_KEY, LEGACY_DEVICE_CREATED_AT_KEY, cachedDeviceId, cachedDeviceName, cachedDeviceCreatedAt, cacheInitialized;
|
|
704
710
|
var init_device = __esm({
|
|
705
711
|
"../../packages/core/src/lib/device/index.ts"() {
|
|
712
|
+
"use strict";
|
|
706
713
|
init_define_import_meta_env();
|
|
707
714
|
init_supabase();
|
|
708
715
|
init_db();
|
|
@@ -784,7 +791,7 @@ async function fetchAllDeviceKeyBundles(userId) {
|
|
|
784
791
|
const bundle = await fetchPublicKeyBundle(userId, 1);
|
|
785
792
|
return bundle ? [bundle] : [];
|
|
786
793
|
}
|
|
787
|
-
console.log(`[Signal] Found ${keyBundles.length} key bundle(s) for user ${userId.slice(0, 8)}`);
|
|
794
|
+
if (define_import_meta_env_default.DEV) console.log(`[Signal] Found ${keyBundles.length} key bundle(s) for user ${userId.slice(0, 8)}`);
|
|
788
795
|
const bundles = await Promise.all(
|
|
789
796
|
keyBundles.map(
|
|
790
797
|
(d) => fetchPublicKeyBundle(userId, d.device_id)
|
|
@@ -893,13 +900,13 @@ async function decryptFromSender(ciphertext, senderUserId, deviceId = 1) {
|
|
|
893
900
|
const address = new SignalProtocolAddress(senderUserId, deviceId);
|
|
894
901
|
const addressKey = `${senderUserId}.${deviceId}`;
|
|
895
902
|
const existingSession = await signalStore.loadSession(addressKey);
|
|
896
|
-
console.log(`[Signal] Decrypting from ${senderUserId.slice(0, 8)}.${deviceId}, type: ${ciphertext.type}, hasSession: ${!!existingSession}`);
|
|
903
|
+
if (define_import_meta_env_default.DEV) console.log(`[Signal] Decrypting from ${senderUserId.slice(0, 8)}.${deviceId}, type: ${ciphertext.type}, hasSession: ${!!existingSession}`);
|
|
897
904
|
const sessionCipher = new SessionCipher(signalStore, address);
|
|
898
905
|
const binaryBody = base64ToBinaryString(ciphertext.body);
|
|
899
906
|
let plaintext;
|
|
900
907
|
try {
|
|
901
908
|
if (ciphertext.type === 3) {
|
|
902
|
-
console.log("[Signal] Decrypting PreKeyWhisperMessage (type 3) - will establish session");
|
|
909
|
+
if (define_import_meta_env_default.DEV) console.log("[Signal] Decrypting PreKeyWhisperMessage (type 3) - will establish session");
|
|
903
910
|
plaintext = await sessionCipher.decryptPreKeyWhisperMessage(binaryBody, "binary");
|
|
904
911
|
} else {
|
|
905
912
|
if (!existingSession) {
|
|
@@ -938,9 +945,9 @@ async function hasSessionWith(userId, deviceId = 1) {
|
|
|
938
945
|
return !!session;
|
|
939
946
|
}
|
|
940
947
|
async function resetSessionWith(userId) {
|
|
941
|
-
console.log("[Signal] Resetting encryption session with:", userId);
|
|
948
|
+
if (define_import_meta_env_default.DEV) console.log("[Signal] Resetting encryption session with:", userId);
|
|
942
949
|
await signalStore.removeAllSessions(userId);
|
|
943
|
-
console.log("[Signal] Session reset complete");
|
|
950
|
+
if (define_import_meta_env_default.DEV) console.log("[Signal] Session reset complete");
|
|
944
951
|
}
|
|
945
952
|
var init_crypto = __esm({
|
|
946
953
|
"../../packages/core/src/lib/signal/crypto.ts"() {
|
|
@@ -1032,6 +1039,17 @@ async function generateAESKey() {
|
|
|
1032
1039
|
async function exportKey(key) {
|
|
1033
1040
|
return crypto.subtle.exportKey("raw", key);
|
|
1034
1041
|
}
|
|
1042
|
+
async function sealKey(key) {
|
|
1043
|
+
const raw = await crypto.subtle.exportKey("raw", key);
|
|
1044
|
+
return crypto.subtle.importKey(
|
|
1045
|
+
"raw",
|
|
1046
|
+
raw,
|
|
1047
|
+
{ name: "AES-GCM", length: 256 },
|
|
1048
|
+
false,
|
|
1049
|
+
// non-extractable
|
|
1050
|
+
["encrypt", "decrypt"]
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1035
1053
|
async function importKey(keyData) {
|
|
1036
1054
|
return crypto.subtle.importKey(
|
|
1037
1055
|
"raw",
|
|
@@ -1085,18 +1103,19 @@ async function encryptAudioForRecipient(audioData, recipientUserId) {
|
|
|
1085
1103
|
}
|
|
1086
1104
|
async function encryptAudioForAllDevices(audioData, recipientUserId, senderUserId, transcript, providedAesKey) {
|
|
1087
1105
|
const aesKey = providedAesKey || await generateAESKey();
|
|
1088
|
-
const
|
|
1106
|
+
const keyBytes = await exportKey(aesKey);
|
|
1107
|
+
const sealedKey = await sealKey(aesKey);
|
|
1108
|
+
const { ciphertext, iv } = await encryptWithAES(audioData, sealedKey);
|
|
1089
1109
|
let encryptedTranscript;
|
|
1090
1110
|
let transcriptIv;
|
|
1091
1111
|
if (transcript && transcript.length > 0) {
|
|
1092
1112
|
const transcriptJson = JSON.stringify(transcript);
|
|
1093
1113
|
const transcriptBytes = new TextEncoder().encode(transcriptJson);
|
|
1094
|
-
const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer,
|
|
1114
|
+
const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer, sealedKey);
|
|
1095
1115
|
encryptedTranscript = transcriptCiphertext;
|
|
1096
1116
|
transcriptIv = arrayBufferToBase643(tIv.buffer);
|
|
1097
|
-
console.log(`[E2E] Encrypted transcript (${transcript.length} segments)`);
|
|
1117
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Encrypted transcript (${transcript.length} segments)`);
|
|
1098
1118
|
}
|
|
1099
|
-
const keyBytes = await exportKey(aesKey);
|
|
1100
1119
|
const [recipientBundles, senderBundles] = await Promise.all([
|
|
1101
1120
|
fetchAllDeviceKeyBundles(recipientUserId),
|
|
1102
1121
|
fetchAllDeviceKeyBundles(senderUserId)
|
|
@@ -1113,20 +1132,19 @@ async function encryptAudioForAllDevices(audioData, recipientUserId, senderUserI
|
|
|
1113
1132
|
if (allDevices.length === 0) {
|
|
1114
1133
|
throw new Error(`No devices found for users`);
|
|
1115
1134
|
}
|
|
1116
|
-
console.log(`[E2E] Encrypting for ${allDevices.length} device(s): ${recipientBundles.length} recipient + ${senderUserId !== recipientUserId ? senderBundles.length : 0} sender`);
|
|
1117
|
-
const encryptedKeys =
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
);
|
|
1129
|
-
console.log(`[E2E] Encrypted for ${encryptedKeys.length} device(s)`);
|
|
1135
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Encrypting for ${allDevices.length} device(s): ${recipientBundles.length} recipient + ${senderUserId !== recipientUserId ? senderBundles.length : 0} sender`);
|
|
1136
|
+
const encryptedKeys = [];
|
|
1137
|
+
for (const { userId, deviceId } of allDevices) {
|
|
1138
|
+
const encrypted = await encryptForRecipient(keyBytes, userId, deviceId);
|
|
1139
|
+
encryptedKeys.push({
|
|
1140
|
+
userId,
|
|
1141
|
+
deviceId,
|
|
1142
|
+
type: encrypted.type,
|
|
1143
|
+
body: encrypted.body,
|
|
1144
|
+
registrationId: encrypted.registrationId
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Encrypted for ${encryptedKeys.length} device(s)`);
|
|
1130
1148
|
const senderDeviceId = getLocalDeviceId();
|
|
1131
1149
|
if (!senderDeviceId) {
|
|
1132
1150
|
throw new Error("Sender device ID not found. Please refresh the page.");
|
|
@@ -1150,7 +1168,7 @@ async function encryptAudioForAllDevices(audioData, recipientUserId, senderUserI
|
|
|
1150
1168
|
};
|
|
1151
1169
|
}
|
|
1152
1170
|
async function decryptAudioFromSender(encryptedAudio, ivBase64, encryptedKey, senderUserId, encryptedKeys, myUserId, myDeviceId, encryptedTranscript, transcriptIv, encryptedAttachments, senderDeviceId) {
|
|
1153
|
-
console.log(`[E2E] decryptAudioFromSender called:`, {
|
|
1171
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] decryptAudioFromSender called:`, {
|
|
1154
1172
|
senderUserId,
|
|
1155
1173
|
myUserId,
|
|
1156
1174
|
myDeviceId,
|
|
@@ -1164,23 +1182,23 @@ async function decryptAudioFromSender(encryptedAudio, ivBase64, encryptedKey, se
|
|
|
1164
1182
|
let sessionDeviceId = 1;
|
|
1165
1183
|
if (senderDeviceId) {
|
|
1166
1184
|
sessionDeviceId = senderDeviceId;
|
|
1167
|
-
console.log(`[E2E] Using senderDeviceId from metadata: ${senderDeviceId}`);
|
|
1185
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Using senderDeviceId from metadata: ${senderDeviceId}`);
|
|
1168
1186
|
}
|
|
1169
1187
|
if (encryptedKeys && encryptedKeys.length > 0 && myUserId && myDeviceId) {
|
|
1170
1188
|
const availableDevices = encryptedKeys.filter((k) => k.userId === myUserId).map((k) => k.deviceId);
|
|
1171
|
-
console.log(`[E2E] Message encrypted for user ${myUserId} devices: [${availableDevices.join(", ")}], current device: ${myDeviceId}`);
|
|
1189
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Message encrypted for user ${myUserId} devices: [${availableDevices.join(", ")}], current device: ${myDeviceId}`);
|
|
1172
1190
|
const myKey = encryptedKeys.find((k) => k.userId === myUserId && k.deviceId === myDeviceId);
|
|
1173
1191
|
if (!senderDeviceId) {
|
|
1174
1192
|
const senderKey = encryptedKeys.find((k) => k.userId === senderUserId);
|
|
1175
1193
|
if (senderKey) {
|
|
1176
1194
|
sessionDeviceId = senderKey.deviceId;
|
|
1177
|
-
console.log(`[E2E] Fallback: using first sender device found: ${sessionDeviceId} (may be incorrect for multi-device senders)`);
|
|
1195
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Fallback: using first sender device found: ${sessionDeviceId} (may be incorrect for multi-device senders)`);
|
|
1178
1196
|
}
|
|
1179
1197
|
}
|
|
1180
1198
|
if (myKey) {
|
|
1181
1199
|
keyToDecrypt = { type: myKey.type, body: myKey.body };
|
|
1182
1200
|
sessionUserId = myUserId === senderUserId ? myUserId : senderUserId;
|
|
1183
|
-
console.log(`[E2E] Found key for user ${myUserId} device ${myDeviceId}, using session ${sessionUserId}.${sessionDeviceId}`);
|
|
1201
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Found key for user ${myUserId} device ${myDeviceId}, using session ${sessionUserId}.${sessionDeviceId}`);
|
|
1184
1202
|
} else {
|
|
1185
1203
|
console.error(`[E2E] No key for device ${myDeviceId}. Message was encrypted for devices: [${availableDevices.join(", ")}]`);
|
|
1186
1204
|
throw new Error(`This message was sent before this device was registered. Available devices: [${availableDevices.join(", ")}], your device: ${myDeviceId}`);
|
|
@@ -1198,18 +1216,18 @@ async function decryptAudioFromSender(encryptedAudio, ivBase64, encryptedKey, se
|
|
|
1198
1216
|
const transcriptBytes = await decryptWithAES(transcriptCiphertext, aesKey, tIv);
|
|
1199
1217
|
const transcriptJson = new TextDecoder().decode(transcriptBytes);
|
|
1200
1218
|
transcript = JSON.parse(transcriptJson);
|
|
1201
|
-
console.log(`[E2E] Decrypted transcript (${transcript.length} segments)`);
|
|
1219
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Decrypted transcript (${transcript.length} segments)`);
|
|
1202
1220
|
} catch (err) {
|
|
1203
1221
|
console.error("[E2E] Failed to decrypt transcript:", err);
|
|
1204
1222
|
}
|
|
1205
1223
|
}
|
|
1206
1224
|
let attachments;
|
|
1207
1225
|
if (encryptedAttachments && encryptedAttachments.length > 0) {
|
|
1208
|
-
console.log(`[E2E] Decrypting ${encryptedAttachments.length} attachment(s)...`);
|
|
1226
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Decrypting ${encryptedAttachments.length} attachment(s)...`);
|
|
1209
1227
|
attachments = [];
|
|
1210
1228
|
for (const encAtt of encryptedAttachments) {
|
|
1211
1229
|
if (!encAtt.encrypted || !encAtt.iv) {
|
|
1212
|
-
console.log(`[E2E] Skipping unencrypted attachment: ${encAtt.name}`);
|
|
1230
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Skipping unencrypted attachment: ${encAtt.name}`);
|
|
1213
1231
|
continue;
|
|
1214
1232
|
}
|
|
1215
1233
|
try {
|
|
@@ -1228,13 +1246,13 @@ async function decryptAudioFromSender(encryptedAudio, ivBase64, encryptedKey, se
|
|
|
1228
1246
|
mimeType: encAtt.mimeType,
|
|
1229
1247
|
size: encAtt.size
|
|
1230
1248
|
});
|
|
1231
|
-
console.log(`[E2E] Decrypted attachment: ${encAtt.name}`);
|
|
1249
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Decrypted attachment: ${encAtt.name}`);
|
|
1232
1250
|
} catch (err) {
|
|
1233
1251
|
console.error(`[E2E] Failed to decrypt attachment ${encAtt.name}:`, err);
|
|
1234
1252
|
}
|
|
1235
1253
|
}
|
|
1236
1254
|
if (attachments.length > 0) {
|
|
1237
|
-
console.log(`[E2E] Successfully decrypted ${attachments.length} attachment(s)`);
|
|
1255
|
+
if (define_import_meta_env_default.DEV) console.log(`[E2E] Successfully decrypted ${attachments.length} attachment(s)`);
|
|
1238
1256
|
}
|
|
1239
1257
|
}
|
|
1240
1258
|
return { audio: audioData, transcript, attachments };
|
|
@@ -1494,6 +1512,7 @@ async function isAdmin(groupId, userId) {
|
|
|
1494
1512
|
}
|
|
1495
1513
|
var init_members = __esm({
|
|
1496
1514
|
"../../packages/core/src/lib/groups/members.ts"() {
|
|
1515
|
+
"use strict";
|
|
1497
1516
|
init_define_import_meta_env();
|
|
1498
1517
|
init_supabase();
|
|
1499
1518
|
init_senderKeyStore();
|
|
@@ -1549,25 +1568,26 @@ __export(groupEncryption_exports, {
|
|
|
1549
1568
|
encryptAudioForGroup: () => encryptAudioForGroup
|
|
1550
1569
|
});
|
|
1551
1570
|
async function encryptAudioForGroup(groupId, audioData, _senderUserId, transcript, providedAesKey) {
|
|
1552
|
-
console.log("[Group E2E] Starting encryption for group:", groupId);
|
|
1571
|
+
if (define_import_meta_env_default.DEV) console.log("[Group E2E] Starting encryption for group:", groupId);
|
|
1553
1572
|
const members = await getGroupMembers(groupId);
|
|
1554
1573
|
const memberUserIds = members.map((m) => m.user_id);
|
|
1555
|
-
console.log(`[Group E2E] Encrypting for ${members.length} group members`);
|
|
1574
|
+
if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypting for ${members.length} group members`);
|
|
1556
1575
|
const aesKey = providedAesKey || await generateAESKey();
|
|
1557
|
-
const
|
|
1558
|
-
|
|
1576
|
+
const keyBytes = await exportKey(aesKey);
|
|
1577
|
+
const sealedKey = await sealKey(aesKey);
|
|
1578
|
+
const { ciphertext, iv } = await encryptWithAES(audioData, sealedKey);
|
|
1579
|
+
if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Audio encrypted (${ciphertext.byteLength} bytes)`);
|
|
1559
1580
|
let encryptedTranscript;
|
|
1560
1581
|
let transcriptIv;
|
|
1561
1582
|
if (transcript && transcript.length > 0) {
|
|
1562
1583
|
const transcriptJson = JSON.stringify(transcript);
|
|
1563
1584
|
const transcriptBytes = new TextEncoder().encode(transcriptJson);
|
|
1564
|
-
const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer,
|
|
1585
|
+
const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer, sealedKey);
|
|
1565
1586
|
encryptedTranscript = transcriptCiphertext;
|
|
1566
1587
|
transcriptIv = arrayBufferToBase643(tIv.buffer);
|
|
1567
|
-
console.log(`[Group E2E] Encrypted transcript (${transcript.length} segments)`);
|
|
1588
|
+
if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypted transcript (${transcript.length} segments)`);
|
|
1568
1589
|
}
|
|
1569
|
-
|
|
1570
|
-
console.log("[Group E2E] Fetching device bundles for all members...");
|
|
1590
|
+
if (define_import_meta_env_default.DEV) console.log("[Group E2E] Fetching device bundles for all members...");
|
|
1571
1591
|
const allBundles = await Promise.all(
|
|
1572
1592
|
memberUserIds.map((userId) => fetchAllDeviceKeyBundles(userId))
|
|
1573
1593
|
);
|
|
@@ -1582,22 +1602,21 @@ async function encryptAudioForGroup(groupId, audioData, _senderUserId, transcrip
|
|
|
1582
1602
|
if (allDevices.length === 0) {
|
|
1583
1603
|
throw new Error(`No devices found for group members`);
|
|
1584
1604
|
}
|
|
1585
|
-
console.log(`[Group E2E] Encrypting AES key for ${allDevices.length} device(s) across ${members.length} members`);
|
|
1605
|
+
if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypting AES key for ${allDevices.length} device(s) across ${members.length} members`);
|
|
1586
1606
|
const startTime = performance.now();
|
|
1587
|
-
const encryptedKeys =
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
);
|
|
1607
|
+
const encryptedKeys = [];
|
|
1608
|
+
for (const { userId, deviceId } of allDevices) {
|
|
1609
|
+
const encrypted = await encryptForRecipient(keyBytes, userId, deviceId);
|
|
1610
|
+
encryptedKeys.push({
|
|
1611
|
+
userId,
|
|
1612
|
+
deviceId,
|
|
1613
|
+
type: encrypted.type,
|
|
1614
|
+
body: encrypted.body,
|
|
1615
|
+
registrationId: encrypted.registrationId
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1599
1618
|
const encryptionTime = performance.now() - startTime;
|
|
1600
|
-
console.log(`[Group E2E] Encrypted for ${encryptedKeys.length} device(s) in ${encryptionTime.toFixed(0)}ms`);
|
|
1619
|
+
if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypted for ${encryptedKeys.length} device(s) in ${encryptionTime.toFixed(0)}ms`);
|
|
1601
1620
|
const senderDeviceId = getLocalDeviceId();
|
|
1602
1621
|
if (!senderDeviceId) {
|
|
1603
1622
|
throw new Error("Sender device ID not found. Please refresh the page.");
|
|
@@ -2028,7 +2047,9 @@ __export(cache_exports, {
|
|
|
2028
2047
|
getCachedAttachments: () => getCachedAttachments,
|
|
2029
2048
|
getCachedAudio: () => getCachedAudio,
|
|
2030
2049
|
getCachedTranscript: () => getCachedTranscript,
|
|
2031
|
-
|
|
2050
|
+
getTranscriptCacheProvider: () => getTranscriptCacheProvider,
|
|
2051
|
+
revokeAttachmentBlobUrls: () => revokeAttachmentBlobUrls,
|
|
2052
|
+
setTranscriptCacheProvider: () => setTranscriptCacheProvider
|
|
2032
2053
|
});
|
|
2033
2054
|
import { openDB as openDB2 } from "idb";
|
|
2034
2055
|
async function getAudioCacheDB() {
|
|
@@ -2079,7 +2100,7 @@ async function cacheAudio(howlerId, audioBlob, transcript, attachments) {
|
|
|
2079
2100
|
console.error("[AudioCache] Error caching audio:", err);
|
|
2080
2101
|
}
|
|
2081
2102
|
}
|
|
2082
|
-
async function
|
|
2103
|
+
async function cacheTranscriptInIndexedDB(howlerId, transcript) {
|
|
2083
2104
|
try {
|
|
2084
2105
|
const db2 = await getAudioCacheDB();
|
|
2085
2106
|
await db2.put("decryptedAudio", {
|
|
@@ -2095,7 +2116,7 @@ async function cacheTranscript(howlerId, transcript) {
|
|
|
2095
2116
|
console.error("[AudioCache] Error caching transcript:", err);
|
|
2096
2117
|
}
|
|
2097
2118
|
}
|
|
2098
|
-
async function
|
|
2119
|
+
async function getCachedTranscriptFromIndexedDB(howlerId) {
|
|
2099
2120
|
try {
|
|
2100
2121
|
const db2 = await getAudioCacheDB();
|
|
2101
2122
|
const cached = await db2.get("decryptedAudio", howlerId);
|
|
@@ -2188,15 +2209,30 @@ async function getCacheStats() {
|
|
|
2188
2209
|
return { count: 0, estimatedSize: 0 };
|
|
2189
2210
|
}
|
|
2190
2211
|
}
|
|
2191
|
-
|
|
2212
|
+
async function cacheTranscript(howlerId, transcript) {
|
|
2213
|
+
await transcriptCacheProvider.cacheTranscript(howlerId, transcript);
|
|
2214
|
+
}
|
|
2215
|
+
async function getCachedTranscript(howlerId) {
|
|
2216
|
+
return transcriptCacheProvider.getCachedTranscript(howlerId);
|
|
2217
|
+
}
|
|
2218
|
+
function setTranscriptCacheProvider(provider) {
|
|
2219
|
+
transcriptCacheProvider = provider;
|
|
2220
|
+
}
|
|
2221
|
+
function getTranscriptCacheProvider() {
|
|
2222
|
+
return transcriptCacheProvider;
|
|
2223
|
+
}
|
|
2224
|
+
var DB_NAME2, DB_VERSION2, dbInstance2, attachmentBlobUrlCache, transcriptCacheProvider;
|
|
2192
2225
|
var init_cache = __esm({
|
|
2193
2226
|
"../../packages/core/src/lib/audio/cache.ts"() {
|
|
2194
|
-
"use strict";
|
|
2195
2227
|
init_define_import_meta_env();
|
|
2196
2228
|
DB_NAME2 = "howler-audio-cache";
|
|
2197
2229
|
DB_VERSION2 = 3;
|
|
2198
2230
|
dbInstance2 = null;
|
|
2199
2231
|
attachmentBlobUrlCache = /* @__PURE__ */ new Map();
|
|
2232
|
+
transcriptCacheProvider = {
|
|
2233
|
+
getCachedTranscript: getCachedTranscriptFromIndexedDB,
|
|
2234
|
+
cacheTranscript: cacheTranscriptInIndexedDB
|
|
2235
|
+
};
|
|
2200
2236
|
}
|
|
2201
2237
|
});
|
|
2202
2238
|
|
|
@@ -2223,8 +2259,10 @@ __export(messages_exports, {
|
|
|
2223
2259
|
sendEncryptedTextMessage: () => sendEncryptedTextMessage,
|
|
2224
2260
|
sendGroupHowler: () => sendGroupHowler,
|
|
2225
2261
|
sendGroupMessage: () => sendGroupMessage,
|
|
2262
|
+
sendGroupPlainTextMessage: () => sendGroupPlainTextMessage,
|
|
2226
2263
|
sendGroupTextMessage: () => sendGroupTextMessage,
|
|
2227
2264
|
sendHowler: () => sendHowler,
|
|
2265
|
+
sendPlainTextMessage: () => sendPlainTextMessage,
|
|
2228
2266
|
updateHowlerTranscript: () => updateHowlerTranscript
|
|
2229
2267
|
});
|
|
2230
2268
|
function parseEncryptionMetadata(json) {
|
|
@@ -2285,6 +2323,36 @@ async function sendHowler(params) {
|
|
|
2285
2323
|
});
|
|
2286
2324
|
return data;
|
|
2287
2325
|
}
|
|
2326
|
+
async function sendPlainTextMessage(params) {
|
|
2327
|
+
return sendHowler({
|
|
2328
|
+
recipientId: params.recipientId,
|
|
2329
|
+
recordingUrl: "text://plain",
|
|
2330
|
+
transcript: params.transcript,
|
|
2331
|
+
parentHowlerId: params.parentHowlerId
|
|
2332
|
+
});
|
|
2333
|
+
}
|
|
2334
|
+
async function sendGroupPlainTextMessage(params) {
|
|
2335
|
+
const { data: { user } } = await db.auth.getUser();
|
|
2336
|
+
if (!user) throw new Error("Not authenticated");
|
|
2337
|
+
const { data, error } = await db.from("howlers").insert({
|
|
2338
|
+
sender_id: user.id,
|
|
2339
|
+
group_id: params.groupId,
|
|
2340
|
+
recipient_id: null,
|
|
2341
|
+
recording_url: "text://plain",
|
|
2342
|
+
duration_seconds: 0,
|
|
2343
|
+
parent_howler_id: params.parentHowlerId,
|
|
2344
|
+
transcript: JSON.stringify(params.transcript)
|
|
2345
|
+
}).select().single();
|
|
2346
|
+
if (error) throw error;
|
|
2347
|
+
const { data: senderData } = await db.from("users").select("first_name").eq("id", user.id).single();
|
|
2348
|
+
const { data: groupData } = await db.from("groups").select("name").eq("id", params.groupId).single();
|
|
2349
|
+
sendGroupPushNotification({
|
|
2350
|
+
groupId: params.groupId,
|
|
2351
|
+
senderName: senderData?.first_name || void 0,
|
|
2352
|
+
groupName: groupData?.name || void 0
|
|
2353
|
+
});
|
|
2354
|
+
return data;
|
|
2355
|
+
}
|
|
2288
2356
|
async function updateHowlerTranscript(howlerId, transcript) {
|
|
2289
2357
|
const { error } = await db.from("howlers").update({ transcript: JSON.stringify(transcript) }).eq("id", howlerId);
|
|
2290
2358
|
if (error) {
|
|
@@ -2751,12 +2819,13 @@ async function isEncryptionAvailable() {
|
|
|
2751
2819
|
return hasLocalKeys();
|
|
2752
2820
|
}
|
|
2753
2821
|
async function getDecryptedTranscript(howlerId) {
|
|
2754
|
-
const {
|
|
2755
|
-
return
|
|
2822
|
+
const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
|
|
2823
|
+
return getTranscriptCacheProvider2().getCachedTranscript(howlerId);
|
|
2756
2824
|
}
|
|
2757
2825
|
async function decryptTextOnlyTranscript(howlerId) {
|
|
2758
|
-
const {
|
|
2759
|
-
const
|
|
2826
|
+
const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
|
|
2827
|
+
const provider = getTranscriptCacheProvider2();
|
|
2828
|
+
const cached = await provider.getCachedTranscript(howlerId);
|
|
2760
2829
|
if (cached) return cached;
|
|
2761
2830
|
const { data: howler, error } = await db.from("howlers").select("id, sender_id, transcript, encryption_metadata").eq("id", howlerId).single();
|
|
2762
2831
|
if (error || !howler) return null;
|
|
@@ -2800,7 +2869,7 @@ async function decryptTextOnlyTranscript(howlerId) {
|
|
|
2800
2869
|
const plaintext = await decryptWithAES2(ciphertext, aesKey, iv);
|
|
2801
2870
|
const json = new TextDecoder().decode(plaintext);
|
|
2802
2871
|
const transcript = JSON.parse(json);
|
|
2803
|
-
await
|
|
2872
|
+
await provider.cacheTranscript(howlerId, transcript);
|
|
2804
2873
|
console.log("[E2E] Decrypted text-only transcript:", howlerId);
|
|
2805
2874
|
return transcript;
|
|
2806
2875
|
} catch (err) {
|
|
@@ -28613,6 +28682,7 @@ init_messages();
|
|
|
28613
28682
|
|
|
28614
28683
|
// src/client.ts
|
|
28615
28684
|
init_signal();
|
|
28685
|
+
init_cache();
|
|
28616
28686
|
|
|
28617
28687
|
// src/signal/index.ts
|
|
28618
28688
|
init_define_import_meta_env();
|
|
@@ -28681,6 +28751,8 @@ async function removeFile(path) {
|
|
|
28681
28751
|
}
|
|
28682
28752
|
var FileSignalStore = class _FileSignalStore {
|
|
28683
28753
|
static instance = null;
|
|
28754
|
+
/** Callback invoked when a remote identity key changes. */
|
|
28755
|
+
onIdentityKeyChanged = null;
|
|
28684
28756
|
static getInstance() {
|
|
28685
28757
|
if (!_FileSignalStore.instance) {
|
|
28686
28758
|
_FileSignalStore.instance = new _FileSignalStore();
|
|
@@ -28709,7 +28781,11 @@ var FileSignalStore = class _FileSignalStore {
|
|
|
28709
28781
|
const newB64 = arrayBufferToBase644(publicKey);
|
|
28710
28782
|
if (existing) {
|
|
28711
28783
|
if (existing.identityKey !== newB64) {
|
|
28784
|
+
const previousKey = base64ToArrayBuffer3(existing.identityKey);
|
|
28712
28785
|
await writeJSON(filePath, { identityKey: newB64 });
|
|
28786
|
+
if (this.onIdentityKeyChanged) {
|
|
28787
|
+
this.onIdentityKeyChanged({ address: encodedAddress, previousKey, newKey: publicKey });
|
|
28788
|
+
}
|
|
28713
28789
|
return true;
|
|
28714
28790
|
}
|
|
28715
28791
|
return false;
|
|
@@ -28915,10 +28991,44 @@ var FileSenderKeyStore = class _FileSenderKeyStore {
|
|
|
28915
28991
|
};
|
|
28916
28992
|
var fileSenderKeyStore = FileSenderKeyStore.getInstance();
|
|
28917
28993
|
|
|
28994
|
+
// src/hooks/transcriptCache.ts
|
|
28995
|
+
init_define_import_meta_env();
|
|
28996
|
+
import { join as join4 } from "node:path";
|
|
28997
|
+
import { homedir as homedir4 } from "node:os";
|
|
28998
|
+
import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4 } from "node:fs/promises";
|
|
28999
|
+
var CACHE_DIR = join4(homedir4(), ".howler", "transcript-cache");
|
|
29000
|
+
var dirEnsured = false;
|
|
29001
|
+
async function ensureDir4() {
|
|
29002
|
+
if (dirEnsured) return;
|
|
29003
|
+
await mkdir4(CACHE_DIR, { recursive: true, mode: 448 });
|
|
29004
|
+
dirEnsured = true;
|
|
29005
|
+
}
|
|
29006
|
+
async function getCachedTranscript2(howlerId) {
|
|
29007
|
+
try {
|
|
29008
|
+
await ensureDir4();
|
|
29009
|
+
const data = await readFile4(join4(CACHE_DIR, `${howlerId}.json`), "utf-8");
|
|
29010
|
+
return JSON.parse(data);
|
|
29011
|
+
} catch {
|
|
29012
|
+
return null;
|
|
29013
|
+
}
|
|
29014
|
+
}
|
|
29015
|
+
async function cacheTranscript2(howlerId, transcript) {
|
|
29016
|
+
try {
|
|
29017
|
+
await ensureDir4();
|
|
29018
|
+
await writeFile4(
|
|
29019
|
+
join4(CACHE_DIR, `${howlerId}.json`),
|
|
29020
|
+
JSON.stringify(transcript),
|
|
29021
|
+
{ mode: 384 }
|
|
29022
|
+
);
|
|
29023
|
+
} catch {
|
|
29024
|
+
}
|
|
29025
|
+
}
|
|
29026
|
+
|
|
28918
29027
|
// src/client.ts
|
|
28919
29028
|
function initializeClient() {
|
|
28920
29029
|
initializeSupabase(getSupabase());
|
|
28921
29030
|
setSignalStore(fileSignalStore);
|
|
29031
|
+
setTranscriptCacheProvider({ getCachedTranscript: getCachedTranscript2, cacheTranscript: cacheTranscript2 });
|
|
28922
29032
|
}
|
|
28923
29033
|
|
|
28924
29034
|
// src/components/ChatList.tsx
|
|
@@ -29493,39 +29603,6 @@ function useRealtimeMessages(conversation, currentUserId, onNewMessage, onUpdate
|
|
|
29493
29603
|
}, [conversation?.id, conversation?.type, currentUserId, retryTick]);
|
|
29494
29604
|
}
|
|
29495
29605
|
|
|
29496
|
-
// src/hooks/transcriptCache.ts
|
|
29497
|
-
init_define_import_meta_env();
|
|
29498
|
-
import { join as join4 } from "node:path";
|
|
29499
|
-
import { homedir as homedir4 } from "node:os";
|
|
29500
|
-
import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4 } from "node:fs/promises";
|
|
29501
|
-
var CACHE_DIR = join4(homedir4(), ".howler", "transcript-cache");
|
|
29502
|
-
var dirEnsured = false;
|
|
29503
|
-
async function ensureDir4() {
|
|
29504
|
-
if (dirEnsured) return;
|
|
29505
|
-
await mkdir4(CACHE_DIR, { recursive: true, mode: 448 });
|
|
29506
|
-
dirEnsured = true;
|
|
29507
|
-
}
|
|
29508
|
-
async function getCachedTranscript2(howlerId) {
|
|
29509
|
-
try {
|
|
29510
|
-
await ensureDir4();
|
|
29511
|
-
const data = await readFile4(join4(CACHE_DIR, `${howlerId}.json`), "utf-8");
|
|
29512
|
-
return JSON.parse(data);
|
|
29513
|
-
} catch {
|
|
29514
|
-
return null;
|
|
29515
|
-
}
|
|
29516
|
-
}
|
|
29517
|
-
async function cacheTranscript2(howlerId, transcript) {
|
|
29518
|
-
try {
|
|
29519
|
-
await ensureDir4();
|
|
29520
|
-
await writeFile4(
|
|
29521
|
-
join4(CACHE_DIR, `${howlerId}.json`),
|
|
29522
|
-
JSON.stringify(transcript),
|
|
29523
|
-
{ mode: 384 }
|
|
29524
|
-
);
|
|
29525
|
-
} catch {
|
|
29526
|
-
}
|
|
29527
|
-
}
|
|
29528
|
-
|
|
29529
29606
|
// src/hooks/useMessages.ts
|
|
29530
29607
|
async function getGroupMessages(groupId, limit = 50, beforeTimestamp) {
|
|
29531
29608
|
const { data, error } = await supabase.rpc("get_group_messages", {
|
|
@@ -29561,16 +29638,8 @@ async function getGroupMessages(groupId, limit = 50, beforeTimestamp) {
|
|
|
29561
29638
|
async function tryDecrypt(msg) {
|
|
29562
29639
|
if (!msg.isEncrypted || msg.recording_url === "text://plain") return msg;
|
|
29563
29640
|
try {
|
|
29564
|
-
const cached = await getCachedTranscript2(msg.id);
|
|
29565
|
-
if (cached && cached.length > 0) {
|
|
29566
|
-
return {
|
|
29567
|
-
...msg,
|
|
29568
|
-
transcript: JSON.stringify(cached)
|
|
29569
|
-
};
|
|
29570
|
-
}
|
|
29571
29641
|
const transcript = await decryptTextOnlyTranscript(msg.id);
|
|
29572
29642
|
if (transcript && transcript.length > 0) {
|
|
29573
|
-
await cacheTranscript2(msg.id, transcript);
|
|
29574
29643
|
return {
|
|
29575
29644
|
...msg,
|
|
29576
29645
|
transcript: JSON.stringify(transcript)
|
|
@@ -29581,12 +29650,26 @@ async function tryDecrypt(msg) {
|
|
|
29581
29650
|
}
|
|
29582
29651
|
return msg;
|
|
29583
29652
|
}
|
|
29653
|
+
function isCacheableTranscriptJson(transcript) {
|
|
29654
|
+
if (!transcript) return false;
|
|
29655
|
+
try {
|
|
29656
|
+
const parsed = JSON.parse(transcript);
|
|
29657
|
+
return Array.isArray(parsed) || typeof parsed === "string" || !!parsed;
|
|
29658
|
+
} catch {
|
|
29659
|
+
return false;
|
|
29660
|
+
}
|
|
29661
|
+
}
|
|
29584
29662
|
function useMessages(conversation, currentUserId) {
|
|
29585
29663
|
const [messages, setMessages] = useState3([]);
|
|
29586
29664
|
const [loading, setLoading] = useState3(!!conversation);
|
|
29587
29665
|
const [error, setError] = useState3(null);
|
|
29588
29666
|
const [hasMore, setHasMore] = useState3(false);
|
|
29589
29667
|
const decryptedIds = useRef3(/* @__PURE__ */ new Set());
|
|
29668
|
+
const transcriptCache = useRef3(/* @__PURE__ */ new Map());
|
|
29669
|
+
const rememberResolvedTranscript = useCallback2((msg) => {
|
|
29670
|
+
if (!isCacheableTranscriptJson(msg.transcript)) return;
|
|
29671
|
+
transcriptCache.current.set(msg.id, msg.transcript);
|
|
29672
|
+
}, []);
|
|
29590
29673
|
const fetchMessages = useCallback2(
|
|
29591
29674
|
(beforeTimestamp) => {
|
|
29592
29675
|
if (!conversation) return;
|
|
@@ -29612,27 +29695,35 @@ function useMessages(conversation, currentUserId) {
|
|
|
29612
29695
|
conversation.name,
|
|
29613
29696
|
"hasMore=" + result.hasMore
|
|
29614
29697
|
);
|
|
29698
|
+
const withCached = result.messages.map((m) => {
|
|
29699
|
+
const cached = transcriptCache.current.get(m.id);
|
|
29700
|
+
return cached ? { ...m, transcript: cached } : m;
|
|
29701
|
+
});
|
|
29615
29702
|
if (beforeTimestamp) {
|
|
29616
29703
|
setMessages((prev) => {
|
|
29617
29704
|
const ids = new Set(prev.map((m) => m.id));
|
|
29618
|
-
const newMsgs =
|
|
29705
|
+
const newMsgs = withCached.filter(
|
|
29619
29706
|
(m) => !ids.has(m.id)
|
|
29620
29707
|
);
|
|
29621
29708
|
return [...newMsgs, ...prev];
|
|
29622
29709
|
});
|
|
29623
29710
|
} else {
|
|
29624
|
-
setMessages(
|
|
29711
|
+
setMessages(withCached);
|
|
29625
29712
|
}
|
|
29626
29713
|
setHasMore(result.hasMore);
|
|
29627
29714
|
setLoading(false);
|
|
29628
29715
|
const encrypted = result.messages.filter(
|
|
29629
29716
|
(m) => m.isEncrypted && !decryptedIds.current.has(m.id)
|
|
29630
29717
|
);
|
|
29631
|
-
|
|
29632
|
-
|
|
29718
|
+
const needsDecrypt = encrypted.filter(
|
|
29719
|
+
(m) => !transcriptCache.current.has(m.id)
|
|
29720
|
+
);
|
|
29721
|
+
if (needsDecrypt.length > 0) {
|
|
29722
|
+
Promise.all(needsDecrypt.map(tryDecrypt)).then((decrypted) => {
|
|
29633
29723
|
const decryptedMap = /* @__PURE__ */ new Map();
|
|
29634
29724
|
for (const msg of decrypted) {
|
|
29635
29725
|
decryptedIds.current.add(msg.id);
|
|
29726
|
+
rememberResolvedTranscript(msg);
|
|
29636
29727
|
decryptedMap.set(msg.id, msg);
|
|
29637
29728
|
}
|
|
29638
29729
|
setMessages(
|
|
@@ -29663,6 +29754,7 @@ function useMessages(conversation, currentUserId) {
|
|
|
29663
29754
|
if (msg.isEncrypted && !decryptedIds.current.has(msg.id)) {
|
|
29664
29755
|
decryptedIds.current.add(msg.id);
|
|
29665
29756
|
tryDecrypt(msg).then((processed) => {
|
|
29757
|
+
rememberResolvedTranscript(processed);
|
|
29666
29758
|
setMessages((prev) => {
|
|
29667
29759
|
if (prev.some((m) => m.id === processed.id)) return prev;
|
|
29668
29760
|
return [...prev, processed];
|
|
@@ -29674,7 +29766,7 @@ function useMessages(conversation, currentUserId) {
|
|
|
29674
29766
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
|
29675
29767
|
return [...prev, msg];
|
|
29676
29768
|
});
|
|
29677
|
-
}, []);
|
|
29769
|
+
}, [rememberResolvedTranscript]);
|
|
29678
29770
|
const handleUpdateMessage = useCallback2(
|
|
29679
29771
|
(id, updates) => {
|
|
29680
29772
|
setMessages(
|
|
@@ -29686,7 +29778,9 @@ function useMessages(conversation, currentUserId) {
|
|
|
29686
29778
|
if (!msg || !msg.isEncrypted || msg.recording_url === "text://plain") return prev;
|
|
29687
29779
|
decryptedIds.current.delete(id);
|
|
29688
29780
|
decryptedIds.current.add(id);
|
|
29781
|
+
transcriptCache.current.delete(id);
|
|
29689
29782
|
tryDecrypt(msg).then((processed) => {
|
|
29783
|
+
rememberResolvedTranscript(processed);
|
|
29690
29784
|
setMessages(
|
|
29691
29785
|
(p) => p.map((m) => m.id === id ? processed : m)
|
|
29692
29786
|
);
|
|
@@ -29695,7 +29789,7 @@ function useMessages(conversation, currentUserId) {
|
|
|
29695
29789
|
});
|
|
29696
29790
|
}
|
|
29697
29791
|
},
|
|
29698
|
-
[]
|
|
29792
|
+
[rememberResolvedTranscript]
|
|
29699
29793
|
);
|
|
29700
29794
|
useRealtimeMessages(
|
|
29701
29795
|
conversation,
|
|
@@ -29714,11 +29808,15 @@ function useMessages(conversation, currentUserId) {
|
|
|
29714
29808
|
fetchMessages();
|
|
29715
29809
|
}, [fetchMessages]);
|
|
29716
29810
|
const addMessage = useCallback2((msg) => {
|
|
29811
|
+
if (msg.isEncrypted && msg.recording_url !== "text://plain" && isCacheableTranscriptJson(msg.transcript)) {
|
|
29812
|
+
decryptedIds.current.add(msg.id);
|
|
29813
|
+
rememberResolvedTranscript(msg);
|
|
29814
|
+
}
|
|
29717
29815
|
setMessages((prev) => {
|
|
29718
29816
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
|
29719
29817
|
return [...prev, msg];
|
|
29720
29818
|
});
|
|
29721
|
-
}, []);
|
|
29819
|
+
}, [rememberResolvedTranscript]);
|
|
29722
29820
|
return { messages, loading, error, hasMore, loadMore, refresh, addMessage };
|
|
29723
29821
|
}
|
|
29724
29822
|
|
|
@@ -29726,7 +29824,7 @@ function useMessages(conversation, currentUserId) {
|
|
|
29726
29824
|
init_define_import_meta_env();
|
|
29727
29825
|
init_messages();
|
|
29728
29826
|
import { useState as useState4, useCallback as useCallback3 } from "react";
|
|
29729
|
-
function useSendMessage(conversation, currentUserId) {
|
|
29827
|
+
function useSendMessage(conversation, currentUserId, encrypted = true) {
|
|
29730
29828
|
const [sending, setSending] = useState4(false);
|
|
29731
29829
|
const [error, setError] = useState4(null);
|
|
29732
29830
|
const send = useCallback3(
|
|
@@ -29739,14 +29837,27 @@ function useSendMessage(conversation, currentUserId) {
|
|
|
29739
29837
|
];
|
|
29740
29838
|
const isGroup = conversation.type === "group";
|
|
29741
29839
|
try {
|
|
29742
|
-
|
|
29743
|
-
|
|
29744
|
-
|
|
29745
|
-
|
|
29746
|
-
|
|
29747
|
-
|
|
29748
|
-
|
|
29749
|
-
|
|
29840
|
+
let data;
|
|
29841
|
+
if (encrypted) {
|
|
29842
|
+
data = isGroup ? await sendGroupTextMessage({
|
|
29843
|
+
groupId: conversation.id,
|
|
29844
|
+
transcript,
|
|
29845
|
+
parentHowlerId: options?.parentHowlerId
|
|
29846
|
+
}) : await sendEncryptedTextMessage({
|
|
29847
|
+
recipientId: conversation.id,
|
|
29848
|
+
transcript
|
|
29849
|
+
});
|
|
29850
|
+
} else {
|
|
29851
|
+
data = isGroup ? await sendGroupPlainTextMessage({
|
|
29852
|
+
groupId: conversation.id,
|
|
29853
|
+
transcript,
|
|
29854
|
+
parentHowlerId: options?.parentHowlerId
|
|
29855
|
+
}) : await sendPlainTextMessage({
|
|
29856
|
+
recipientId: conversation.id,
|
|
29857
|
+
transcript,
|
|
29858
|
+
parentHowlerId: options?.parentHowlerId
|
|
29859
|
+
});
|
|
29860
|
+
}
|
|
29750
29861
|
const msg = {
|
|
29751
29862
|
...data,
|
|
29752
29863
|
transcript: JSON.stringify(transcript),
|
|
@@ -29757,7 +29868,7 @@ function useSendMessage(conversation, currentUserId) {
|
|
|
29757
29868
|
last_name: null,
|
|
29758
29869
|
avatar_url: null
|
|
29759
29870
|
},
|
|
29760
|
-
isEncrypted:
|
|
29871
|
+
isEncrypted: encrypted
|
|
29761
29872
|
};
|
|
29762
29873
|
setSending(false);
|
|
29763
29874
|
return msg;
|
|
@@ -29768,7 +29879,7 @@ function useSendMessage(conversation, currentUserId) {
|
|
|
29768
29879
|
return null;
|
|
29769
29880
|
}
|
|
29770
29881
|
},
|
|
29771
|
-
[conversation, currentUserId]
|
|
29882
|
+
[conversation, currentUserId, encrypted]
|
|
29772
29883
|
);
|
|
29773
29884
|
return { send, sending, error };
|
|
29774
29885
|
}
|
|
@@ -30455,11 +30566,12 @@ var ConversationPanel = React2.forwardRef(
|
|
|
30455
30566
|
panelWidth: panelWidthProp
|
|
30456
30567
|
}, ref) {
|
|
30457
30568
|
const { messages, loading, error, hasMore, loadMore, addMessage } = useMessages(conversation, currentUserId);
|
|
30569
|
+
const [encryptionEnabled, setEncryptionEnabled] = React2.useState(true);
|
|
30458
30570
|
const {
|
|
30459
30571
|
send,
|
|
30460
30572
|
sending,
|
|
30461
30573
|
error: sendError
|
|
30462
|
-
} = useSendMessage(conversation, currentUserId);
|
|
30574
|
+
} = useSendMessage(conversation, currentUserId, encryptionEnabled);
|
|
30463
30575
|
const { branches, loading: branchesLoading, selectedBranch, selectBranch, filteredMessages, threadMap } = useBranches(conversation, messages);
|
|
30464
30576
|
const {
|
|
30465
30577
|
execute: executeCommand,
|
|
@@ -30558,6 +30670,17 @@ var ConversationPanel = React2.forwardRef(
|
|
|
30558
30670
|
}
|
|
30559
30671
|
return;
|
|
30560
30672
|
}
|
|
30673
|
+
if (text.trim().toLowerCase() === "/encrypt") {
|
|
30674
|
+
setEncryptionEnabled((prev) => {
|
|
30675
|
+
const next = !prev;
|
|
30676
|
+
setStatusMsg({
|
|
30677
|
+
text: next ? "Encryption ON" : "Encryption OFF (diagnostic mode)",
|
|
30678
|
+
color: next ? "green" : "yellow"
|
|
30679
|
+
});
|
|
30680
|
+
return next;
|
|
30681
|
+
});
|
|
30682
|
+
return;
|
|
30683
|
+
}
|
|
30561
30684
|
if (isCommand(text)) {
|
|
30562
30685
|
await executeCommand(text);
|
|
30563
30686
|
return;
|
|
@@ -30786,6 +30909,7 @@ var SLASH_COMMANDS = [
|
|
|
30786
30909
|
{ name: "/bot", args: "<name> <msg>", description: "Send to a bot" },
|
|
30787
30910
|
{ name: "/change-branch", args: "[name]", description: "Switch branch" },
|
|
30788
30911
|
{ name: "/current-branch", args: "", description: "Show current branch" },
|
|
30912
|
+
{ name: "/encrypt", args: "", description: "Toggle encryption on/off" },
|
|
30789
30913
|
{ name: "/exit", args: "", description: "Quit Howler TUI" },
|
|
30790
30914
|
{ name: "/vault", args: "<query>", description: "Search your vault" }
|
|
30791
30915
|
];
|