@howler/cli 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -8
- package/dist/bin.js +250 -124
- 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,64 @@ 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
52
|
| `/vault <query>` | Search your Obsidian vault |
|
|
34
53
|
| `/agent <goal>` | Create an agent task (bot chats only) |
|
|
35
54
|
| `/bot <name> <message>` | Send a message to a named bot |
|
|
55
|
+
| `/addmember @username` | Add a member to the current group |
|
|
56
|
+
| `/change-branch` | Switch branch in repo bot chats |
|
|
57
|
+
| `/current-branch` | Show the active branch |
|
|
58
|
+
| `/exit` | Quit the TUI |
|
|
59
|
+
|
|
60
|
+
## Features
|
|
61
|
+
|
|
62
|
+
- **E2E encrypted** — Signal Protocol, same keys as the web app
|
|
63
|
+
- **Real-time** — messages appear instantly via Supabase subscriptions
|
|
64
|
+
- **Multi-device** — works alongside the web app on the same account
|
|
65
|
+
- **Bot/agent support** — interact with AI agents, view branch-based conversations
|
|
66
|
+
- **Vault search** — query your Obsidian vault from the terminal
|
|
36
67
|
|
|
37
68
|
## Requirements
|
|
38
69
|
|
|
39
70
|
- Node.js 18+
|
|
40
|
-
- A Howler account (sign up at howler.pages.dev)
|
|
71
|
+
- 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 };
|
|
@@ -1312,6 +1330,7 @@ __export(signal_exports, {
|
|
|
1312
1330
|
});
|
|
1313
1331
|
var init_signal = __esm({
|
|
1314
1332
|
"../../packages/core/src/lib/signal/index.ts"() {
|
|
1333
|
+
"use strict";
|
|
1315
1334
|
init_define_import_meta_env();
|
|
1316
1335
|
init_db();
|
|
1317
1336
|
init_store();
|
|
@@ -1494,6 +1513,7 @@ async function isAdmin(groupId, userId) {
|
|
|
1494
1513
|
}
|
|
1495
1514
|
var init_members = __esm({
|
|
1496
1515
|
"../../packages/core/src/lib/groups/members.ts"() {
|
|
1516
|
+
"use strict";
|
|
1497
1517
|
init_define_import_meta_env();
|
|
1498
1518
|
init_supabase();
|
|
1499
1519
|
init_senderKeyStore();
|
|
@@ -1549,25 +1569,26 @@ __export(groupEncryption_exports, {
|
|
|
1549
1569
|
encryptAudioForGroup: () => encryptAudioForGroup
|
|
1550
1570
|
});
|
|
1551
1571
|
async function encryptAudioForGroup(groupId, audioData, _senderUserId, transcript, providedAesKey) {
|
|
1552
|
-
console.log("[Group E2E] Starting encryption for group:", groupId);
|
|
1572
|
+
if (define_import_meta_env_default.DEV) console.log("[Group E2E] Starting encryption for group:", groupId);
|
|
1553
1573
|
const members = await getGroupMembers(groupId);
|
|
1554
1574
|
const memberUserIds = members.map((m) => m.user_id);
|
|
1555
|
-
console.log(`[Group E2E] Encrypting for ${members.length} group members`);
|
|
1575
|
+
if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypting for ${members.length} group members`);
|
|
1556
1576
|
const aesKey = providedAesKey || await generateAESKey();
|
|
1557
|
-
const
|
|
1558
|
-
|
|
1577
|
+
const keyBytes = await exportKey(aesKey);
|
|
1578
|
+
const sealedKey = await sealKey(aesKey);
|
|
1579
|
+
const { ciphertext, iv } = await encryptWithAES(audioData, sealedKey);
|
|
1580
|
+
if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Audio encrypted (${ciphertext.byteLength} bytes)`);
|
|
1559
1581
|
let encryptedTranscript;
|
|
1560
1582
|
let transcriptIv;
|
|
1561
1583
|
if (transcript && transcript.length > 0) {
|
|
1562
1584
|
const transcriptJson = JSON.stringify(transcript);
|
|
1563
1585
|
const transcriptBytes = new TextEncoder().encode(transcriptJson);
|
|
1564
|
-
const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer,
|
|
1586
|
+
const { ciphertext: transcriptCiphertext, iv: tIv } = await encryptWithAES(transcriptBytes.buffer, sealedKey);
|
|
1565
1587
|
encryptedTranscript = transcriptCiphertext;
|
|
1566
1588
|
transcriptIv = arrayBufferToBase643(tIv.buffer);
|
|
1567
|
-
console.log(`[Group E2E] Encrypted transcript (${transcript.length} segments)`);
|
|
1589
|
+
if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypted transcript (${transcript.length} segments)`);
|
|
1568
1590
|
}
|
|
1569
|
-
|
|
1570
|
-
console.log("[Group E2E] Fetching device bundles for all members...");
|
|
1591
|
+
if (define_import_meta_env_default.DEV) console.log("[Group E2E] Fetching device bundles for all members...");
|
|
1571
1592
|
const allBundles = await Promise.all(
|
|
1572
1593
|
memberUserIds.map((userId) => fetchAllDeviceKeyBundles(userId))
|
|
1573
1594
|
);
|
|
@@ -1582,22 +1603,21 @@ async function encryptAudioForGroup(groupId, audioData, _senderUserId, transcrip
|
|
|
1582
1603
|
if (allDevices.length === 0) {
|
|
1583
1604
|
throw new Error(`No devices found for group members`);
|
|
1584
1605
|
}
|
|
1585
|
-
console.log(`[Group E2E] Encrypting AES key for ${allDevices.length} device(s) across ${members.length} members`);
|
|
1606
|
+
if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypting AES key for ${allDevices.length} device(s) across ${members.length} members`);
|
|
1586
1607
|
const startTime = performance.now();
|
|
1587
|
-
const encryptedKeys =
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
);
|
|
1608
|
+
const encryptedKeys = [];
|
|
1609
|
+
for (const { userId, deviceId } of allDevices) {
|
|
1610
|
+
const encrypted = await encryptForRecipient(keyBytes, userId, deviceId);
|
|
1611
|
+
encryptedKeys.push({
|
|
1612
|
+
userId,
|
|
1613
|
+
deviceId,
|
|
1614
|
+
type: encrypted.type,
|
|
1615
|
+
body: encrypted.body,
|
|
1616
|
+
registrationId: encrypted.registrationId
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1599
1619
|
const encryptionTime = performance.now() - startTime;
|
|
1600
|
-
console.log(`[Group E2E] Encrypted for ${encryptedKeys.length} device(s) in ${encryptionTime.toFixed(0)}ms`);
|
|
1620
|
+
if (define_import_meta_env_default.DEV) console.log(`[Group E2E] Encrypted for ${encryptedKeys.length} device(s) in ${encryptionTime.toFixed(0)}ms`);
|
|
1601
1621
|
const senderDeviceId = getLocalDeviceId();
|
|
1602
1622
|
if (!senderDeviceId) {
|
|
1603
1623
|
throw new Error("Sender device ID not found. Please refresh the page.");
|
|
@@ -2028,7 +2048,9 @@ __export(cache_exports, {
|
|
|
2028
2048
|
getCachedAttachments: () => getCachedAttachments,
|
|
2029
2049
|
getCachedAudio: () => getCachedAudio,
|
|
2030
2050
|
getCachedTranscript: () => getCachedTranscript,
|
|
2031
|
-
|
|
2051
|
+
getTranscriptCacheProvider: () => getTranscriptCacheProvider,
|
|
2052
|
+
revokeAttachmentBlobUrls: () => revokeAttachmentBlobUrls,
|
|
2053
|
+
setTranscriptCacheProvider: () => setTranscriptCacheProvider
|
|
2032
2054
|
});
|
|
2033
2055
|
import { openDB as openDB2 } from "idb";
|
|
2034
2056
|
async function getAudioCacheDB() {
|
|
@@ -2079,7 +2101,7 @@ async function cacheAudio(howlerId, audioBlob, transcript, attachments) {
|
|
|
2079
2101
|
console.error("[AudioCache] Error caching audio:", err);
|
|
2080
2102
|
}
|
|
2081
2103
|
}
|
|
2082
|
-
async function
|
|
2104
|
+
async function cacheTranscriptInIndexedDB(howlerId, transcript) {
|
|
2083
2105
|
try {
|
|
2084
2106
|
const db2 = await getAudioCacheDB();
|
|
2085
2107
|
await db2.put("decryptedAudio", {
|
|
@@ -2095,7 +2117,7 @@ async function cacheTranscript(howlerId, transcript) {
|
|
|
2095
2117
|
console.error("[AudioCache] Error caching transcript:", err);
|
|
2096
2118
|
}
|
|
2097
2119
|
}
|
|
2098
|
-
async function
|
|
2120
|
+
async function getCachedTranscriptFromIndexedDB(howlerId) {
|
|
2099
2121
|
try {
|
|
2100
2122
|
const db2 = await getAudioCacheDB();
|
|
2101
2123
|
const cached = await db2.get("decryptedAudio", howlerId);
|
|
@@ -2188,7 +2210,19 @@ async function getCacheStats() {
|
|
|
2188
2210
|
return { count: 0, estimatedSize: 0 };
|
|
2189
2211
|
}
|
|
2190
2212
|
}
|
|
2191
|
-
|
|
2213
|
+
async function cacheTranscript(howlerId, transcript) {
|
|
2214
|
+
await transcriptCacheProvider.cacheTranscript(howlerId, transcript);
|
|
2215
|
+
}
|
|
2216
|
+
async function getCachedTranscript(howlerId) {
|
|
2217
|
+
return transcriptCacheProvider.getCachedTranscript(howlerId);
|
|
2218
|
+
}
|
|
2219
|
+
function setTranscriptCacheProvider(provider) {
|
|
2220
|
+
transcriptCacheProvider = provider;
|
|
2221
|
+
}
|
|
2222
|
+
function getTranscriptCacheProvider() {
|
|
2223
|
+
return transcriptCacheProvider;
|
|
2224
|
+
}
|
|
2225
|
+
var DB_NAME2, DB_VERSION2, dbInstance2, attachmentBlobUrlCache, transcriptCacheProvider;
|
|
2192
2226
|
var init_cache = __esm({
|
|
2193
2227
|
"../../packages/core/src/lib/audio/cache.ts"() {
|
|
2194
2228
|
"use strict";
|
|
@@ -2197,6 +2231,10 @@ var init_cache = __esm({
|
|
|
2197
2231
|
DB_VERSION2 = 3;
|
|
2198
2232
|
dbInstance2 = null;
|
|
2199
2233
|
attachmentBlobUrlCache = /* @__PURE__ */ new Map();
|
|
2234
|
+
transcriptCacheProvider = {
|
|
2235
|
+
getCachedTranscript: getCachedTranscriptFromIndexedDB,
|
|
2236
|
+
cacheTranscript: cacheTranscriptInIndexedDB
|
|
2237
|
+
};
|
|
2200
2238
|
}
|
|
2201
2239
|
});
|
|
2202
2240
|
|
|
@@ -2223,8 +2261,10 @@ __export(messages_exports, {
|
|
|
2223
2261
|
sendEncryptedTextMessage: () => sendEncryptedTextMessage,
|
|
2224
2262
|
sendGroupHowler: () => sendGroupHowler,
|
|
2225
2263
|
sendGroupMessage: () => sendGroupMessage,
|
|
2264
|
+
sendGroupPlainTextMessage: () => sendGroupPlainTextMessage,
|
|
2226
2265
|
sendGroupTextMessage: () => sendGroupTextMessage,
|
|
2227
2266
|
sendHowler: () => sendHowler,
|
|
2267
|
+
sendPlainTextMessage: () => sendPlainTextMessage,
|
|
2228
2268
|
updateHowlerTranscript: () => updateHowlerTranscript
|
|
2229
2269
|
});
|
|
2230
2270
|
function parseEncryptionMetadata(json) {
|
|
@@ -2285,6 +2325,36 @@ async function sendHowler(params) {
|
|
|
2285
2325
|
});
|
|
2286
2326
|
return data;
|
|
2287
2327
|
}
|
|
2328
|
+
async function sendPlainTextMessage(params) {
|
|
2329
|
+
return sendHowler({
|
|
2330
|
+
recipientId: params.recipientId,
|
|
2331
|
+
recordingUrl: "text://plain",
|
|
2332
|
+
transcript: params.transcript,
|
|
2333
|
+
parentHowlerId: params.parentHowlerId
|
|
2334
|
+
});
|
|
2335
|
+
}
|
|
2336
|
+
async function sendGroupPlainTextMessage(params) {
|
|
2337
|
+
const { data: { user } } = await db.auth.getUser();
|
|
2338
|
+
if (!user) throw new Error("Not authenticated");
|
|
2339
|
+
const { data, error } = await db.from("howlers").insert({
|
|
2340
|
+
sender_id: user.id,
|
|
2341
|
+
group_id: params.groupId,
|
|
2342
|
+
recipient_id: null,
|
|
2343
|
+
recording_url: "text://plain",
|
|
2344
|
+
duration_seconds: 0,
|
|
2345
|
+
parent_howler_id: params.parentHowlerId,
|
|
2346
|
+
transcript: JSON.stringify(params.transcript)
|
|
2347
|
+
}).select().single();
|
|
2348
|
+
if (error) throw error;
|
|
2349
|
+
const { data: senderData } = await db.from("users").select("first_name").eq("id", user.id).single();
|
|
2350
|
+
const { data: groupData } = await db.from("groups").select("name").eq("id", params.groupId).single();
|
|
2351
|
+
sendGroupPushNotification({
|
|
2352
|
+
groupId: params.groupId,
|
|
2353
|
+
senderName: senderData?.first_name || void 0,
|
|
2354
|
+
groupName: groupData?.name || void 0
|
|
2355
|
+
});
|
|
2356
|
+
return data;
|
|
2357
|
+
}
|
|
2288
2358
|
async function updateHowlerTranscript(howlerId, transcript) {
|
|
2289
2359
|
const { error } = await db.from("howlers").update({ transcript: JSON.stringify(transcript) }).eq("id", howlerId);
|
|
2290
2360
|
if (error) {
|
|
@@ -2751,12 +2821,13 @@ async function isEncryptionAvailable() {
|
|
|
2751
2821
|
return hasLocalKeys();
|
|
2752
2822
|
}
|
|
2753
2823
|
async function getDecryptedTranscript(howlerId) {
|
|
2754
|
-
const {
|
|
2755
|
-
return
|
|
2824
|
+
const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
|
|
2825
|
+
return getTranscriptCacheProvider2().getCachedTranscript(howlerId);
|
|
2756
2826
|
}
|
|
2757
2827
|
async function decryptTextOnlyTranscript(howlerId) {
|
|
2758
|
-
const {
|
|
2759
|
-
const
|
|
2828
|
+
const { getTranscriptCacheProvider: getTranscriptCacheProvider2 } = await Promise.resolve().then(() => (init_cache(), cache_exports));
|
|
2829
|
+
const provider = getTranscriptCacheProvider2();
|
|
2830
|
+
const cached = await provider.getCachedTranscript(howlerId);
|
|
2760
2831
|
if (cached) return cached;
|
|
2761
2832
|
const { data: howler, error } = await db.from("howlers").select("id, sender_id, transcript, encryption_metadata").eq("id", howlerId).single();
|
|
2762
2833
|
if (error || !howler) return null;
|
|
@@ -2800,7 +2871,7 @@ async function decryptTextOnlyTranscript(howlerId) {
|
|
|
2800
2871
|
const plaintext = await decryptWithAES2(ciphertext, aesKey, iv);
|
|
2801
2872
|
const json = new TextDecoder().decode(plaintext);
|
|
2802
2873
|
const transcript = JSON.parse(json);
|
|
2803
|
-
await
|
|
2874
|
+
await provider.cacheTranscript(howlerId, transcript);
|
|
2804
2875
|
console.log("[E2E] Decrypted text-only transcript:", howlerId);
|
|
2805
2876
|
return transcript;
|
|
2806
2877
|
} catch (err) {
|
|
@@ -28613,6 +28684,7 @@ init_messages();
|
|
|
28613
28684
|
|
|
28614
28685
|
// src/client.ts
|
|
28615
28686
|
init_signal();
|
|
28687
|
+
init_cache();
|
|
28616
28688
|
|
|
28617
28689
|
// src/signal/index.ts
|
|
28618
28690
|
init_define_import_meta_env();
|
|
@@ -28681,6 +28753,8 @@ async function removeFile(path) {
|
|
|
28681
28753
|
}
|
|
28682
28754
|
var FileSignalStore = class _FileSignalStore {
|
|
28683
28755
|
static instance = null;
|
|
28756
|
+
/** Callback invoked when a remote identity key changes. */
|
|
28757
|
+
onIdentityKeyChanged = null;
|
|
28684
28758
|
static getInstance() {
|
|
28685
28759
|
if (!_FileSignalStore.instance) {
|
|
28686
28760
|
_FileSignalStore.instance = new _FileSignalStore();
|
|
@@ -28709,7 +28783,11 @@ var FileSignalStore = class _FileSignalStore {
|
|
|
28709
28783
|
const newB64 = arrayBufferToBase644(publicKey);
|
|
28710
28784
|
if (existing) {
|
|
28711
28785
|
if (existing.identityKey !== newB64) {
|
|
28786
|
+
const previousKey = base64ToArrayBuffer3(existing.identityKey);
|
|
28712
28787
|
await writeJSON(filePath, { identityKey: newB64 });
|
|
28788
|
+
if (this.onIdentityKeyChanged) {
|
|
28789
|
+
this.onIdentityKeyChanged({ address: encodedAddress, previousKey, newKey: publicKey });
|
|
28790
|
+
}
|
|
28713
28791
|
return true;
|
|
28714
28792
|
}
|
|
28715
28793
|
return false;
|
|
@@ -28915,10 +28993,44 @@ var FileSenderKeyStore = class _FileSenderKeyStore {
|
|
|
28915
28993
|
};
|
|
28916
28994
|
var fileSenderKeyStore = FileSenderKeyStore.getInstance();
|
|
28917
28995
|
|
|
28996
|
+
// src/hooks/transcriptCache.ts
|
|
28997
|
+
init_define_import_meta_env();
|
|
28998
|
+
import { join as join4 } from "node:path";
|
|
28999
|
+
import { homedir as homedir4 } from "node:os";
|
|
29000
|
+
import { readFile as readFile4, writeFile as writeFile4, mkdir as mkdir4 } from "node:fs/promises";
|
|
29001
|
+
var CACHE_DIR = join4(homedir4(), ".howler", "transcript-cache");
|
|
29002
|
+
var dirEnsured = false;
|
|
29003
|
+
async function ensureDir4() {
|
|
29004
|
+
if (dirEnsured) return;
|
|
29005
|
+
await mkdir4(CACHE_DIR, { recursive: true, mode: 448 });
|
|
29006
|
+
dirEnsured = true;
|
|
29007
|
+
}
|
|
29008
|
+
async function getCachedTranscript2(howlerId) {
|
|
29009
|
+
try {
|
|
29010
|
+
await ensureDir4();
|
|
29011
|
+
const data = await readFile4(join4(CACHE_DIR, `${howlerId}.json`), "utf-8");
|
|
29012
|
+
return JSON.parse(data);
|
|
29013
|
+
} catch {
|
|
29014
|
+
return null;
|
|
29015
|
+
}
|
|
29016
|
+
}
|
|
29017
|
+
async function cacheTranscript2(howlerId, transcript) {
|
|
29018
|
+
try {
|
|
29019
|
+
await ensureDir4();
|
|
29020
|
+
await writeFile4(
|
|
29021
|
+
join4(CACHE_DIR, `${howlerId}.json`),
|
|
29022
|
+
JSON.stringify(transcript),
|
|
29023
|
+
{ mode: 384 }
|
|
29024
|
+
);
|
|
29025
|
+
} catch {
|
|
29026
|
+
}
|
|
29027
|
+
}
|
|
29028
|
+
|
|
28918
29029
|
// src/client.ts
|
|
28919
29030
|
function initializeClient() {
|
|
28920
29031
|
initializeSupabase(getSupabase());
|
|
28921
29032
|
setSignalStore(fileSignalStore);
|
|
29033
|
+
setTranscriptCacheProvider({ getCachedTranscript: getCachedTranscript2, cacheTranscript: cacheTranscript2 });
|
|
28922
29034
|
}
|
|
28923
29035
|
|
|
28924
29036
|
// src/components/ChatList.tsx
|
|
@@ -29493,39 +29605,6 @@ function useRealtimeMessages(conversation, currentUserId, onNewMessage, onUpdate
|
|
|
29493
29605
|
}, [conversation?.id, conversation?.type, currentUserId, retryTick]);
|
|
29494
29606
|
}
|
|
29495
29607
|
|
|
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
29608
|
// src/hooks/useMessages.ts
|
|
29530
29609
|
async function getGroupMessages(groupId, limit = 50, beforeTimestamp) {
|
|
29531
29610
|
const { data, error } = await supabase.rpc("get_group_messages", {
|
|
@@ -29561,16 +29640,8 @@ async function getGroupMessages(groupId, limit = 50, beforeTimestamp) {
|
|
|
29561
29640
|
async function tryDecrypt(msg) {
|
|
29562
29641
|
if (!msg.isEncrypted || msg.recording_url === "text://plain") return msg;
|
|
29563
29642
|
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
29643
|
const transcript = await decryptTextOnlyTranscript(msg.id);
|
|
29572
29644
|
if (transcript && transcript.length > 0) {
|
|
29573
|
-
await cacheTranscript2(msg.id, transcript);
|
|
29574
29645
|
return {
|
|
29575
29646
|
...msg,
|
|
29576
29647
|
transcript: JSON.stringify(transcript)
|
|
@@ -29581,12 +29652,26 @@ async function tryDecrypt(msg) {
|
|
|
29581
29652
|
}
|
|
29582
29653
|
return msg;
|
|
29583
29654
|
}
|
|
29655
|
+
function isCacheableTranscriptJson(transcript) {
|
|
29656
|
+
if (!transcript) return false;
|
|
29657
|
+
try {
|
|
29658
|
+
const parsed = JSON.parse(transcript);
|
|
29659
|
+
return Array.isArray(parsed) || typeof parsed === "string" || !!parsed;
|
|
29660
|
+
} catch {
|
|
29661
|
+
return false;
|
|
29662
|
+
}
|
|
29663
|
+
}
|
|
29584
29664
|
function useMessages(conversation, currentUserId) {
|
|
29585
29665
|
const [messages, setMessages] = useState3([]);
|
|
29586
29666
|
const [loading, setLoading] = useState3(!!conversation);
|
|
29587
29667
|
const [error, setError] = useState3(null);
|
|
29588
29668
|
const [hasMore, setHasMore] = useState3(false);
|
|
29589
29669
|
const decryptedIds = useRef3(/* @__PURE__ */ new Set());
|
|
29670
|
+
const transcriptCache = useRef3(/* @__PURE__ */ new Map());
|
|
29671
|
+
const rememberResolvedTranscript = useCallback2((msg) => {
|
|
29672
|
+
if (!isCacheableTranscriptJson(msg.transcript)) return;
|
|
29673
|
+
transcriptCache.current.set(msg.id, msg.transcript);
|
|
29674
|
+
}, []);
|
|
29590
29675
|
const fetchMessages = useCallback2(
|
|
29591
29676
|
(beforeTimestamp) => {
|
|
29592
29677
|
if (!conversation) return;
|
|
@@ -29612,27 +29697,35 @@ function useMessages(conversation, currentUserId) {
|
|
|
29612
29697
|
conversation.name,
|
|
29613
29698
|
"hasMore=" + result.hasMore
|
|
29614
29699
|
);
|
|
29700
|
+
const withCached = result.messages.map((m) => {
|
|
29701
|
+
const cached = transcriptCache.current.get(m.id);
|
|
29702
|
+
return cached ? { ...m, transcript: cached } : m;
|
|
29703
|
+
});
|
|
29615
29704
|
if (beforeTimestamp) {
|
|
29616
29705
|
setMessages((prev) => {
|
|
29617
29706
|
const ids = new Set(prev.map((m) => m.id));
|
|
29618
|
-
const newMsgs =
|
|
29707
|
+
const newMsgs = withCached.filter(
|
|
29619
29708
|
(m) => !ids.has(m.id)
|
|
29620
29709
|
);
|
|
29621
29710
|
return [...newMsgs, ...prev];
|
|
29622
29711
|
});
|
|
29623
29712
|
} else {
|
|
29624
|
-
setMessages(
|
|
29713
|
+
setMessages(withCached);
|
|
29625
29714
|
}
|
|
29626
29715
|
setHasMore(result.hasMore);
|
|
29627
29716
|
setLoading(false);
|
|
29628
29717
|
const encrypted = result.messages.filter(
|
|
29629
29718
|
(m) => m.isEncrypted && !decryptedIds.current.has(m.id)
|
|
29630
29719
|
);
|
|
29631
|
-
|
|
29632
|
-
|
|
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) => {
|
|
29633
29725
|
const decryptedMap = /* @__PURE__ */ new Map();
|
|
29634
29726
|
for (const msg of decrypted) {
|
|
29635
29727
|
decryptedIds.current.add(msg.id);
|
|
29728
|
+
rememberResolvedTranscript(msg);
|
|
29636
29729
|
decryptedMap.set(msg.id, msg);
|
|
29637
29730
|
}
|
|
29638
29731
|
setMessages(
|
|
@@ -29663,6 +29756,7 @@ function useMessages(conversation, currentUserId) {
|
|
|
29663
29756
|
if (msg.isEncrypted && !decryptedIds.current.has(msg.id)) {
|
|
29664
29757
|
decryptedIds.current.add(msg.id);
|
|
29665
29758
|
tryDecrypt(msg).then((processed) => {
|
|
29759
|
+
rememberResolvedTranscript(processed);
|
|
29666
29760
|
setMessages((prev) => {
|
|
29667
29761
|
if (prev.some((m) => m.id === processed.id)) return prev;
|
|
29668
29762
|
return [...prev, processed];
|
|
@@ -29674,7 +29768,7 @@ function useMessages(conversation, currentUserId) {
|
|
|
29674
29768
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
|
29675
29769
|
return [...prev, msg];
|
|
29676
29770
|
});
|
|
29677
|
-
}, []);
|
|
29771
|
+
}, [rememberResolvedTranscript]);
|
|
29678
29772
|
const handleUpdateMessage = useCallback2(
|
|
29679
29773
|
(id, updates) => {
|
|
29680
29774
|
setMessages(
|
|
@@ -29686,7 +29780,9 @@ function useMessages(conversation, currentUserId) {
|
|
|
29686
29780
|
if (!msg || !msg.isEncrypted || msg.recording_url === "text://plain") return prev;
|
|
29687
29781
|
decryptedIds.current.delete(id);
|
|
29688
29782
|
decryptedIds.current.add(id);
|
|
29783
|
+
transcriptCache.current.delete(id);
|
|
29689
29784
|
tryDecrypt(msg).then((processed) => {
|
|
29785
|
+
rememberResolvedTranscript(processed);
|
|
29690
29786
|
setMessages(
|
|
29691
29787
|
(p) => p.map((m) => m.id === id ? processed : m)
|
|
29692
29788
|
);
|
|
@@ -29695,7 +29791,7 @@ function useMessages(conversation, currentUserId) {
|
|
|
29695
29791
|
});
|
|
29696
29792
|
}
|
|
29697
29793
|
},
|
|
29698
|
-
[]
|
|
29794
|
+
[rememberResolvedTranscript]
|
|
29699
29795
|
);
|
|
29700
29796
|
useRealtimeMessages(
|
|
29701
29797
|
conversation,
|
|
@@ -29714,11 +29810,15 @@ function useMessages(conversation, currentUserId) {
|
|
|
29714
29810
|
fetchMessages();
|
|
29715
29811
|
}, [fetchMessages]);
|
|
29716
29812
|
const addMessage = useCallback2((msg) => {
|
|
29813
|
+
if (msg.isEncrypted && msg.recording_url !== "text://plain" && isCacheableTranscriptJson(msg.transcript)) {
|
|
29814
|
+
decryptedIds.current.add(msg.id);
|
|
29815
|
+
rememberResolvedTranscript(msg);
|
|
29816
|
+
}
|
|
29717
29817
|
setMessages((prev) => {
|
|
29718
29818
|
if (prev.some((m) => m.id === msg.id)) return prev;
|
|
29719
29819
|
return [...prev, msg];
|
|
29720
29820
|
});
|
|
29721
|
-
}, []);
|
|
29821
|
+
}, [rememberResolvedTranscript]);
|
|
29722
29822
|
return { messages, loading, error, hasMore, loadMore, refresh, addMessage };
|
|
29723
29823
|
}
|
|
29724
29824
|
|
|
@@ -29726,7 +29826,7 @@ function useMessages(conversation, currentUserId) {
|
|
|
29726
29826
|
init_define_import_meta_env();
|
|
29727
29827
|
init_messages();
|
|
29728
29828
|
import { useState as useState4, useCallback as useCallback3 } from "react";
|
|
29729
|
-
function useSendMessage(conversation, currentUserId) {
|
|
29829
|
+
function useSendMessage(conversation, currentUserId, encrypted = true) {
|
|
29730
29830
|
const [sending, setSending] = useState4(false);
|
|
29731
29831
|
const [error, setError] = useState4(null);
|
|
29732
29832
|
const send = useCallback3(
|
|
@@ -29739,14 +29839,27 @@ function useSendMessage(conversation, currentUserId) {
|
|
|
29739
29839
|
];
|
|
29740
29840
|
const isGroup = conversation.type === "group";
|
|
29741
29841
|
try {
|
|
29742
|
-
|
|
29743
|
-
|
|
29744
|
-
|
|
29745
|
-
|
|
29746
|
-
|
|
29747
|
-
|
|
29748
|
-
|
|
29749
|
-
|
|
29842
|
+
let data;
|
|
29843
|
+
if (encrypted) {
|
|
29844
|
+
data = isGroup ? await sendGroupTextMessage({
|
|
29845
|
+
groupId: conversation.id,
|
|
29846
|
+
transcript,
|
|
29847
|
+
parentHowlerId: options?.parentHowlerId
|
|
29848
|
+
}) : await sendEncryptedTextMessage({
|
|
29849
|
+
recipientId: conversation.id,
|
|
29850
|
+
transcript
|
|
29851
|
+
});
|
|
29852
|
+
} else {
|
|
29853
|
+
data = isGroup ? await sendGroupPlainTextMessage({
|
|
29854
|
+
groupId: conversation.id,
|
|
29855
|
+
transcript,
|
|
29856
|
+
parentHowlerId: options?.parentHowlerId
|
|
29857
|
+
}) : await sendPlainTextMessage({
|
|
29858
|
+
recipientId: conversation.id,
|
|
29859
|
+
transcript,
|
|
29860
|
+
parentHowlerId: options?.parentHowlerId
|
|
29861
|
+
});
|
|
29862
|
+
}
|
|
29750
29863
|
const msg = {
|
|
29751
29864
|
...data,
|
|
29752
29865
|
transcript: JSON.stringify(transcript),
|
|
@@ -29757,7 +29870,7 @@ function useSendMessage(conversation, currentUserId) {
|
|
|
29757
29870
|
last_name: null,
|
|
29758
29871
|
avatar_url: null
|
|
29759
29872
|
},
|
|
29760
|
-
isEncrypted:
|
|
29873
|
+
isEncrypted: encrypted
|
|
29761
29874
|
};
|
|
29762
29875
|
setSending(false);
|
|
29763
29876
|
return msg;
|
|
@@ -29768,7 +29881,7 @@ function useSendMessage(conversation, currentUserId) {
|
|
|
29768
29881
|
return null;
|
|
29769
29882
|
}
|
|
29770
29883
|
},
|
|
29771
|
-
[conversation, currentUserId]
|
|
29884
|
+
[conversation, currentUserId, encrypted]
|
|
29772
29885
|
);
|
|
29773
29886
|
return { send, sending, error };
|
|
29774
29887
|
}
|
|
@@ -30455,11 +30568,12 @@ var ConversationPanel = React2.forwardRef(
|
|
|
30455
30568
|
panelWidth: panelWidthProp
|
|
30456
30569
|
}, ref) {
|
|
30457
30570
|
const { messages, loading, error, hasMore, loadMore, addMessage } = useMessages(conversation, currentUserId);
|
|
30571
|
+
const [encryptionEnabled, setEncryptionEnabled] = React2.useState(true);
|
|
30458
30572
|
const {
|
|
30459
30573
|
send,
|
|
30460
30574
|
sending,
|
|
30461
30575
|
error: sendError
|
|
30462
|
-
} = useSendMessage(conversation, currentUserId);
|
|
30576
|
+
} = useSendMessage(conversation, currentUserId, encryptionEnabled);
|
|
30463
30577
|
const { branches, loading: branchesLoading, selectedBranch, selectBranch, filteredMessages, threadMap } = useBranches(conversation, messages);
|
|
30464
30578
|
const {
|
|
30465
30579
|
execute: executeCommand,
|
|
@@ -30558,6 +30672,17 @@ var ConversationPanel = React2.forwardRef(
|
|
|
30558
30672
|
}
|
|
30559
30673
|
return;
|
|
30560
30674
|
}
|
|
30675
|
+
if (text.trim().toLowerCase() === "/encrypt") {
|
|
30676
|
+
setEncryptionEnabled((prev) => {
|
|
30677
|
+
const next = !prev;
|
|
30678
|
+
setStatusMsg({
|
|
30679
|
+
text: next ? "Encryption ON" : "Encryption OFF (diagnostic mode)",
|
|
30680
|
+
color: next ? "green" : "yellow"
|
|
30681
|
+
});
|
|
30682
|
+
return next;
|
|
30683
|
+
});
|
|
30684
|
+
return;
|
|
30685
|
+
}
|
|
30561
30686
|
if (isCommand(text)) {
|
|
30562
30687
|
await executeCommand(text);
|
|
30563
30688
|
return;
|
|
@@ -30786,6 +30911,7 @@ var SLASH_COMMANDS = [
|
|
|
30786
30911
|
{ name: "/bot", args: "<name> <msg>", description: "Send to a bot" },
|
|
30787
30912
|
{ name: "/change-branch", args: "[name]", description: "Switch branch" },
|
|
30788
30913
|
{ name: "/current-branch", args: "", description: "Show current branch" },
|
|
30914
|
+
{ name: "/encrypt", args: "", description: "Toggle encryption on/off" },
|
|
30789
30915
|
{ name: "/exit", args: "", description: "Quit Howler TUI" },
|
|
30790
30916
|
{ name: "/vault", args: "<query>", description: "Search your vault" }
|
|
30791
30917
|
];
|