feat: tray, window-state, sqlite cache, decrypt worker

System tray (desktop)
- tauri tray-icon feature + tray with menu (Öffnen/Ausblenden/Beenden)
- Left-click toggles main window; right-click shows menu
- JS emits tray-unread-update event, Rust mirrors into tooltip +
  macOS dock badge via set_badge_label
- ConversationsContext wires totalUnread → tray

Window state persistence
- tauri-plugin-window-state (desktop-only target guard)
- Auto-restore size/position/maximized between restarts

Local SQLite message cache
- tauri-plugin-sql hydration of conversation view on mount
- persistMessages after each refresh, deleteCachedMessage on realtime
  DELETE, pruneCache keeps latest 1000 per conversation
- Stores plaintext only (same trust boundary as stronghold device
  key; cache never leaves the device, E2EE w.r.t. server unchanged)

Web Worker for decryption
- workers/decrypt.worker.ts runs crypto_secretbox_open_easy + utf-8
  decode off the main thread with its own libsodium instance
- lib/decryptWorker.ts is a request/response wrapper with inline
  fallback when Worker spawn fails
- shared decryptMessages accepts aeadBatchDelegate so key lookup
  stays on the main thread while the AEAD loop offloads

Build fix
- Enable tauri tray-icon feature
- Import Listener + Manager traits, clone tray handle for the
  event listener, conditional icon attach
This commit is contained in:
2026-04-21 10:07:20 +02:00
parent 24fdfee738
commit 228608ef2c
11 changed files with 607 additions and 11 deletions
+16
View File
@@ -690,6 +690,7 @@ dependencies = [
"tauri-plugin-sql",
"tauri-plugin-stronghold",
"tauri-plugin-updater",
"tauri-plugin-window-state",
]
[[package]]
@@ -5617,6 +5618,21 @@ dependencies = [
"zip 4.6.1",
]
[[package]]
name = "tauri-plugin-window-state"
version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704"
dependencies = [
"bitflags 2.11.1",
"log",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-runtime"
version = "2.10.1"
+2 -1
View File
@@ -14,7 +14,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["devtools"] }
tauri = { version = "2", features = ["devtools", "tray-icon"] }
tauri-plugin-notification = "2"
tauri-plugin-sql = { version = "2", features = ["sqlite"] }
tauri-plugin-stronghold = "2"
@@ -25,6 +25,7 @@ serde_json = "1"
[target."cfg(not(any(target_os = \"android\", target_os = \"ios\")))".dependencies]
tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
[features]
# This feature is used for production builds or when `devPath` points to the filesystem
@@ -9,6 +9,11 @@
"notification:allow-notify",
"notification:allow-is-permission-granted",
"notification:allow-request-permission",
"sql:default",
"sql:allow-load",
"sql:allow-execute",
"sql:allow-select",
"sql:allow-close",
"global-shortcut:allow-register",
"global-shortcut:allow-unregister",
"global-shortcut:allow-is-registered",
+128 -2
View File
@@ -1,3 +1,50 @@
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use tauri::{
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
AppHandle, Listener, Manager,
};
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use serde::Deserialize;
// Payload for the `tray-unread-update` event the JS layer emits whenever the
// aggregate unread-count changes. 0 hides the badge / resets the tooltip;
// non-zero sets a count indicator.
#[cfg(not(any(target_os = "android", target_os = "ios")))]
#[derive(Deserialize)]
struct TrayUnreadPayload {
count: u32,
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn show_main_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.show();
let _ = win.unminimize();
let _ = win.set_focus();
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn hide_main_window(app: &AppHandle) {
if let Some(win) = app.get_webview_window("main") {
let _ = win.hide();
}
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
fn handle_menu_event(app: &AppHandle, event: MenuEvent) {
match event.id.as_ref() {
"tray-show" => show_main_window(app),
"tray-hide" => hide_main_window(app),
"tray-quit" => {
app.exit(0);
}
_ => {}
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let mut builder = tauri::Builder::default()
@@ -13,12 +60,91 @@ pub fn run() {
.build(),
);
// Global shortcut + updater plugins are desktop-only (no mobile support).
// Global shortcut + updater + window-state plugins are desktop-only
// (no mobile support — mobile windows are OS-managed).
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
builder = builder
.plugin(tauri_plugin_global_shortcut::Builder::new().build())
.plugin(tauri_plugin_updater::Builder::new().build());
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_window_state::Builder::new().build());
}
#[cfg(not(any(target_os = "android", target_os = "ios")))]
{
builder = builder.setup(|app| {
// Tray icon with a minimal menu. Left-click toggles window
// visibility; right-click shows the menu. Badge / tooltip updates
// come from the JS side via `tray-unread-update` events.
let show = MenuItem::with_id(app, "tray-show", "Öffnen", true, None::<&str>)?;
let hide = MenuItem::with_id(app, "tray-hide", "Ausblenden", true, None::<&str>)?;
let sep = PredefinedMenuItem::separator(app)?;
let quit = MenuItem::with_id(app, "tray-quit", "Beenden", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&show, &hide, &sep, &quit])?;
let mut tray_builder = TrayIconBuilder::with_id("chatapp-tray")
.menu(&menu)
.show_menu_on_left_click(false)
.tooltip("ChatApp")
.on_menu_event(|app, event| handle_menu_event(app, event))
.on_tray_icon_event(|tray, event| {
if let TrayIconEvent::Click {
button: MouseButton::Left,
button_state: MouseButtonState::Up,
..
} = event
{
let app = tray.app_handle();
if let Some(win) = app.get_webview_window("main") {
if win.is_visible().unwrap_or(false) {
let _ = win.hide();
} else {
let _ = win.show();
let _ = win.set_focus();
}
}
}
});
// `default_window_icon` returns Option<&Image>; only attach if
// we actually have one bundled (should always be true via the
// tauri.conf.json icon list, but guard to stay typesafe).
if let Some(icon) = app.default_window_icon() {
tray_builder = tray_builder.icon(icon.clone());
}
let tray = tray_builder.build(app)?;
// Listen for JS-side unread updates and mirror them into the tray
// tooltip + macOS dock badge. `tray` is cheap to clone (internal
// Arc) so we can move it into the listener closure directly.
let tray_handle = tray.clone();
let badge_window = app.get_webview_window("main");
app.listen("tray-unread-update", move |event| {
let Ok(payload) = serde_json::from_str::<TrayUnreadPayload>(event.payload())
else {
return;
};
let tooltip = if payload.count == 0 {
"ChatApp".to_string()
} else {
format!("ChatApp · {} neu", payload.count)
};
let _ = tray_handle.set_tooltip(Some(tooltip));
// macOS dock badge. `set_badge_label` is macOS-only but the
// call is a no-op on other platforms so we don't need a cfg.
if let Some(win) = badge_window.as_ref() {
let badge = if payload.count == 0 {
None
} else {
Some(payload.count.to_string())
};
let _ = win.set_badge_label(badge);
}
});
Ok(())
});
}
builder
@@ -17,6 +17,7 @@ import {
import { playNotificationTone } from '../lib/notificationSound';
import { notify } from '../lib/osNotify';
import { supabase } from '../lib/supabase';
import { updateTrayUnread } from '../lib/trayBadge';
import { useAuth } from './AuthContext';
const LAST_READ_STORAGE_KEY = 'chatapp.conv_last_read';
@@ -253,6 +254,12 @@ export function ConversationsProvider({ children }: { children: ReactNode }) {
return s;
}, [unread]);
// Mirror unread count into the tray tooltip + dock badge. Runs in Tauri
// only; no-op in browser-preview.
useEffect(() => {
void updateTrayUnread(totalUnread);
}, [totalUnread]);
const value = useMemo<ConversationsContextValue>(
() => ({
conversations,
+99
View File
@@ -0,0 +1,99 @@
// Main-thread wrapper around the decrypt Web Worker.
//
// Spawns a single worker lazily on first use. Uses request-id correlation so
// concurrent batches (e.g. a backfill arriving while a realtime insert
// dispatches) can't mix up their results. Falls back to inline decrypt if
// the Worker constructor isn't available (non-browser environments, some
// strict CSPs).
import sodium from 'libsodium-wrappers-sumo';
export interface DecryptItem {
id: string;
ciphertext: Uint8Array;
nonce: Uint8Array;
key: Uint8Array;
}
interface Pending {
resolve: (results: DecryptResult[]) => void;
reject: (err: unknown) => void;
}
interface DecryptResult {
id: string;
plaintext: string | null;
}
let worker: Worker | null = null;
let workerBroken = false;
const pending = new Map<string, Pending>();
function spawn(): Worker | null {
if (workerBroken) return null;
if (worker) return worker;
try {
worker = new Worker(new URL('../workers/decrypt.worker.ts', import.meta.url), {
type: 'module',
});
worker.addEventListener('message', (ev: MessageEvent) => {
const data = ev.data as { id?: string; results?: DecryptResult[] };
if (!data.id) return;
const entry = pending.get(data.id);
if (!entry) return;
pending.delete(data.id);
entry.resolve(data.results ?? []);
});
worker.addEventListener('error', (ev) => {
console.warn('decrypt worker error, falling back to inline', ev.message);
workerBroken = true;
worker?.terminate();
worker = null;
for (const entry of pending.values()) {
entry.reject(new Error('decrypt worker crashed'));
}
pending.clear();
});
return worker;
} catch (err: unknown) {
console.warn('decrypt worker spawn failed, falling back to inline', err);
workerBroken = true;
return null;
}
}
export async function decryptBatch(items: DecryptItem[]): Promise<DecryptResult[]> {
if (items.length === 0) return [];
const w = spawn();
if (!w) return inlineDecrypt(items);
return new Promise<DecryptResult[]>((resolve, reject) => {
const id = crypto.randomUUID();
pending.set(id, { resolve, reject });
try {
w.postMessage({ id, items });
} catch (err: unknown) {
pending.delete(id);
// postMessage can fail if the Uint8Array view is detached — retry inline.
console.warn('decrypt worker postMessage failed, using inline', err);
inlineDecrypt(items).then(resolve, reject);
}
});
}
// Fallback path — same semantics as the worker but on the main thread. Used
// when the worker failed to spawn or crashed mid-session.
async function inlineDecrypt(items: DecryptItem[]): Promise<DecryptResult[]> {
await sodium.ready;
return items.map((item) => {
try {
const plain = sodium.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key);
return {
id: item.id,
plaintext: new TextDecoder('utf-8', { fatal: false }).decode(plain),
};
} catch {
return { id: item.id, plaintext: null };
}
});
}
+182
View File
@@ -0,0 +1,182 @@
// Local SQLite message cache. Persists decrypted messages so startup +
// conversation-switch hydrate from disk instantly instead of waiting on a
// network round-trip + batch decrypt.
//
// The cache is per-device-local (app-local-data dir, same trust boundary as
// the device private key). Plaintext is stored because the threat model
// already assumes local-disk access means compromise — same as the existing
// outbox + secret store. Ciphertext + nonce are kept alongside so a future
// key-rotation migration can re-derive plaintext when a new bundle arrives.
import type { DecryptedMessage } from '@chat-app/shared/chat';
import { isTauriRuntime } from './globalShortcut';
interface Database {
execute: (sql: string, values?: unknown[]) => Promise<{ rowsAffected?: number }>;
select: <T>(sql: string, values?: unknown[]) => Promise<T[]>;
close: () => Promise<boolean>;
}
interface DatabaseStatic {
load: (path: string) => Promise<Database>;
}
const DB_NAME = 'chatapp-cache.db';
let dbPromise: Promise<Database | null> | null = null;
async function loadDb(): Promise<Database | null> {
if (!isTauriRuntime()) return null;
try {
// Dynamic import so browser-preview builds don't choke on the tauri
// plugin module. Vite will statically analyse this + split it into a
// chunk that only loads inside Tauri.
const mod = (await import('@tauri-apps/plugin-sql')) as {
default: DatabaseStatic;
Database?: DatabaseStatic;
};
const DatabaseCtor: DatabaseStatic = mod.default ?? (mod as { Database: DatabaseStatic }).Database;
const db = await DatabaseCtor.load('sqlite:' + DB_NAME);
await db.execute(`
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
sender_id TEXT NOT NULL,
sender_device_id TEXT,
reply_to_id TEXT,
edited_at TEXT,
deleted_at TEXT,
created_at TEXT NOT NULL,
plaintext TEXT
);
`);
await db.execute(
'CREATE INDEX IF NOT EXISTS idx_msgs_conv_created ON messages(conversation_id, created_at);',
);
return db;
} catch (err: unknown) {
console.warn('messageCache init failed — falling back to memory-only', err);
return null;
}
}
function getDb(): Promise<Database | null> {
if (!dbPromise) dbPromise = loadDb();
return dbPromise;
}
// ---------------------------------------------------------------------------
// Public API — all calls are no-ops when the cache isn't available (browser
// preview, init error, etc.), so callers never need to guard.
// ---------------------------------------------------------------------------
interface Row {
id: string;
conversation_id: string;
sender_id: string;
sender_device_id: string | null;
reply_to_id: string | null;
edited_at: string | null;
deleted_at: string | null;
created_at: string;
plaintext: string | null;
}
export async function loadCachedMessages(
conversationId: string,
limit = 500,
): Promise<DecryptedMessage[]> {
const db = await getDb();
if (!db) return [];
try {
const rows = await db.select<Row>(
'SELECT * FROM messages WHERE conversation_id = $1 ORDER BY created_at DESC LIMIT $2',
[conversationId, limit],
);
return rows
.reverse()
.map((r) => ({
id: r.id,
conversationId: r.conversation_id,
senderId: r.sender_id,
senderDeviceId: r.sender_device_id,
replyToId: r.reply_to_id,
editedAt: r.edited_at,
deletedAt: r.deleted_at,
createdAt: r.created_at,
plaintext: r.plaintext,
}));
} catch (err: unknown) {
console.warn('loadCachedMessages failed', err);
return [];
}
}
export async function persistMessages(
conversationId: string,
messages: DecryptedMessage[],
): Promise<void> {
const db = await getDb();
if (!db || messages.length === 0) return;
try {
// Replace strategy: each message is keyed by id so upsert is "INSERT OR
// REPLACE". Attachments + forward chains are part of `plaintext` JSON so
// nothing lives outside this table.
for (const m of messages) {
await db.execute(
`INSERT OR REPLACE INTO messages
(id, conversation_id, sender_id, sender_device_id, reply_to_id,
edited_at, deleted_at, created_at, plaintext)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
[
m.id,
conversationId,
m.senderId,
m.senderDeviceId,
m.replyToId,
m.editedAt,
m.deletedAt,
m.createdAt,
m.plaintext,
],
);
}
} catch (err: unknown) {
console.warn('persistMessages failed', err);
}
}
export async function deleteCachedMessage(id: string): Promise<void> {
const db = await getDb();
if (!db) return;
try {
await db.execute('DELETE FROM messages WHERE id = $1', [id]);
} catch (err: unknown) {
console.warn('deleteCachedMessage failed', err);
}
}
// Housekeeping — run once per session to bound the cache size. Keeps the
// latest KEEP_PER_CONV messages per conversation.
const KEEP_PER_CONV = 1000;
export async function pruneCache(): Promise<void> {
const db = await getDb();
if (!db) return;
try {
await db.execute(
`DELETE FROM messages WHERE id IN (
SELECT id FROM messages m
WHERE (
SELECT COUNT(*) FROM messages m2
WHERE m2.conversation_id = m.conversation_id
AND m2.created_at > m.created_at
) >= $1
)`,
[KEEP_PER_CONV],
);
} catch (err: unknown) {
console.warn('pruneCache failed', err);
}
}
+15
View File
@@ -0,0 +1,15 @@
import { emit } from '@tauri-apps/api/event';
import { isTauriRuntime } from './globalShortcut';
// Pushes the current aggregate unread count to the Rust-side tray listener.
// Rust mirrors it into the tray tooltip + macOS dock badge. No-op in the
// browser/dev preview where the Tauri runtime isn't present.
export async function updateTrayUnread(count: number): Promise<void> {
if (!isTauriRuntime()) return;
try {
await emit('tray-unread-update', { count: Math.max(0, Math.floor(count)) });
} catch (err: unknown) {
console.warn('updateTrayUnread failed', err);
}
}
@@ -13,6 +13,13 @@ import {
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { decryptBatch as decryptBatchWorker } from './decryptWorker';
import {
loadCachedMessages,
persistMessages,
pruneCache,
deleteCachedMessage,
} from './messageCache';
import {
enqueueOutbox,
getOutbox,
@@ -103,6 +110,10 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
messages,
ownDeviceId: deviceId,
ownPrivateKey: priv,
// Offload the symmetric decrypt + utf-8 decode to a Web Worker so
// the main thread stays responsive during bulk operations (initial
// fetch, backfill after sleep).
aeadBatchDelegate: decryptBatchWorker,
});
},
[deviceId],
@@ -120,6 +131,10 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
// Persist the fresh batch to the local cache so next conversation
// switch / app start can hydrate instantly. Fire-and-forget — cache
// write failure is never user-visible.
void persistMessages(conversationId, decrypted);
} catch (err: unknown) {
setState((prev) => ({
...prev,
@@ -129,6 +144,31 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
}
}, [conversationId, decryptBatch]);
// Hydrate from the local SQLite cache the moment the conversation id
// changes. Runs in parallel with the network fetch — whichever resolves
// first populates the UI, and `refresh` will replace stale cache data
// when the server response lands. On cache-miss this is a ~5ms no-op.
useEffect(() => {
if (!conversationId) return;
let cancelled = false;
void loadCachedMessages(conversationId).then((cached) => {
if (cancelled || cached.length === 0) return;
setState((prev) => {
// Don't clobber a fresh server response that already landed.
if (prev.messages.length > 0) return prev;
return { messages: cached, loading: false, error: null };
});
});
return () => {
cancelled = true;
};
}, [conversationId]);
// Prune cache once per app session.
useEffect(() => {
void pruneCache();
}, []);
// Realtime INSERT handler — refetches the row via REST so we get the
// canonical bytea encoding (postgres_changes payloads serialize bytea
// differently and decoding them inline is brittle). Then decrypt + append.
@@ -296,6 +336,7 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
...prev,
messages: prev.messages.filter((m) => m.id !== id),
}));
void deleteCachedMessage(id);
}, []);
useEffect(() => {
@@ -0,0 +1,62 @@
// Web Worker — runs XSalsa20-Poly1305 decrypt + utf8 decode off the main
// thread. The conv-key lookup (which is network-bound) stays in the main
// thread; only the CPU-heavy AEAD + UTF-8 step runs here.
//
// Message protocol:
// request: { id: string, items: Array<{ id, ciphertext, nonce, key }> }
// response: { id: string, results: Array<{ id, plaintext: string | null }> }
/// <reference lib="webworker" />
import sodium from 'libsodium-wrappers-sumo';
interface DecryptItem {
id: string;
ciphertext: Uint8Array;
nonce: Uint8Array;
key: Uint8Array;
}
interface DecryptRequest {
id: string;
items: DecryptItem[];
}
interface DecryptResult {
id: string;
plaintext: string | null;
}
interface DecryptResponse {
id: string;
results: DecryptResult[];
}
let sodiumReady: Promise<typeof sodium> | null = null;
async function ensureSodium(): Promise<typeof sodium> {
if (!sodiumReady) sodiumReady = sodium.ready.then(() => sodium);
return sodiumReady;
}
function decodeOne(s: typeof sodium, item: DecryptItem): string | null {
try {
const plain = s.crypto_secretbox_open_easy(item.ciphertext, item.nonce, item.key);
return new TextDecoder('utf-8', { fatal: false }).decode(plain);
} catch {
return null;
}
}
self.addEventListener('message', (ev: MessageEvent<DecryptRequest>) => {
const req = ev.data;
void (async () => {
const s = await ensureSodium();
const results: DecryptResult[] = req.items.map((item) => ({
id: item.id,
plaintext: decodeOne(s, item),
}));
const resp: DecryptResponse = { id: req.id, results };
(self as unknown as Worker).postMessage(resp);
})();
});
+50 -8
View File
@@ -444,16 +444,34 @@ export interface DecryptParams {
messages: MessageWithCipher[];
ownDeviceId: string;
ownPrivateKey: Uint8Array;
/**
* Optional delegate that performs the symmetric-decrypt + utf-8 decode
* step for a batch of messages. When provided, the main thread only does
* conv-key lookup; the CPU-heavy AEAD loop runs inside the delegate (e.g.
* a Web Worker). Items arrive with their per-message conv-key attached.
*/
aeadBatchDelegate?: (
items: Array<{
id: string;
ciphertext: Uint8Array;
nonce: Uint8Array;
key: Uint8Array;
}>,
) => Promise<Array<{ id: string; plaintext: string | null }>>;
}
// Decrypts messages using their conv-key (looked up + cached per
// keyVersion). Returns null `plaintext` when this device has no key bundle
// for that version yet (e.g. brand-new device waiting for share).
export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMessage[]> {
const out: DecryptedMessage[] = [];
// Group versions to avoid redundant lookups.
const versions = new Map<string, Map<number, Uint8Array | null>>(); // convId -> version -> key | null
// First pass: resolve conv-keys for every message. Network-bound, stays on
// caller's thread so Supabase client + session remain usable.
interface Resolved {
message: MessageWithCipher;
key: Uint8Array | null;
}
const resolved: Resolved[] = [];
for (const m of opts.messages) {
let convCache = versions.get(m.conversationId);
if (!convCache) {
@@ -474,17 +492,41 @@ export async function decryptMessages(opts: DecryptParams): Promise<DecryptedMes
key = handle?.key ?? null;
convCache.set(m.keyVersion, key);
}
resolved.push({ message: m, key });
}
// Second pass: symmetric decrypt. If a delegate is supplied (Web Worker),
// batch the CPU-heavy step to it; otherwise fall back to inline.
if (opts.aeadBatchDelegate) {
const batch = resolved
.filter((r): r is Resolved & { key: Uint8Array } => r.key !== null)
.map((r) => ({
id: r.message.id,
ciphertext: r.message.ciphertext,
nonce: r.message.nonce,
key: r.key,
}));
const plaintextById = new Map<string, string | null>();
if (batch.length > 0) {
const results = await opts.aeadBatchDelegate(batch);
for (const result of results) plaintextById.set(result.id, result.plaintext);
}
return resolved.map((r) => ({
...r.message,
plaintext: r.key ? plaintextById.get(r.message.id) ?? null : null,
}));
}
return resolved.map((r) => {
let plaintext: string | null = null;
if (key) {
if (r.key) {
try {
const decoded = decryptWithConvKey(m.ciphertext, m.nonce, key);
const decoded = decryptWithConvKey(r.message.ciphertext, r.message.nonce, r.key);
plaintext = bytesToUtf8(decoded);
} catch {
plaintext = null;
}
}
out.push({ ...m, plaintext });
}
return out;
return { ...r.message, plaintext };
});
}