feat: voice messages, offline queue, delivery ticks, volume slider, admin + scaling
- Voice messages: MediaRecorder → encrypted attachment, custom waveform player via OfflineAudioContext, 60s limit + live mic-level meter - Offline message queue: localStorage outbox, exponential backoff retries, optimistic pending bubble with retry/discard - Delivery indicator: message_deliveries table + RLS (reciprocal receipts), ✓ / ✓✓ / ✓✓-blue tick states, group-aware (all members must ack) - Per-participant volume slider in calls via right-click tile menu, persisted to localStorage, applied to attached audio elements - Group call scaling: grid up to 12 tiles with pagination, active-speaker auto-promotion in fullscreen - Push notifications scaffolding: service worker, VAPID subscription registration, notify-push edge function skeleton - Backup recovery code: 24-char base32 code (~120 bits entropy) as alternative decrypt path, restore UI with mode toggle - Admin panel: conversations list, audit log (admin_audit_log table + admin_log_action RPC), audit entry on user flag toggle - Search v2: sender filter, attachment-only toggle, date range - Reactions pop animation (scale 0.4→1.15→1 on count change) - Message list windowing (150 default, expand via IntersectionObserver) - Stub cleanup: removed dead ScreenshareStub from CallParticipantTile Fixes: - Focus-triggered flicker: dropped window.focus listeners in three spots, throttled visibilitychange/online wake-refreshes to 30s, keep existing data visible during background re-syncs (no more spinner on every click) - Voice attachment audio element collapsed to 0px on peer side — now forces 280px min-width on bubble Migrations (push required): 20260421000001_message_deliveries.sql 20260421000002_admin_audit_log.sql Server TODO: VAPID keys + notify-push edge function deploy
This commit is contained in:
@@ -13,6 +13,16 @@ import {
|
||||
import { bytesToPgHex, pgBytesToBytes } from '@chat-app/shared/supabase';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
enqueueOutbox,
|
||||
getOutbox,
|
||||
isDue,
|
||||
markAttempt,
|
||||
type OutboxItem,
|
||||
removeOutbox,
|
||||
shouldGiveUp,
|
||||
subscribeOutbox,
|
||||
} from './messageOutbox';
|
||||
import { devLocalSecretStore } from './secretStore';
|
||||
import { supabase } from './supabase';
|
||||
|
||||
@@ -53,10 +63,26 @@ function rowToMessage(row: Record<string, unknown>): MessageWithCipher {
|
||||
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
|
||||
send: (text: string, images?: File[], replyToId?: string | null) => Promise<void>;
|
||||
refresh: () => Promise<void>;
|
||||
pending: OutboxItem[];
|
||||
retryPending: (id: string) => void;
|
||||
cancelPending: (id: string) => void;
|
||||
} {
|
||||
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
|
||||
const [pending, setPending] = useState<OutboxItem[]>(() =>
|
||||
conversationId ? getOutbox(conversationId) : [],
|
||||
);
|
||||
const privateKeyRef = useRef<Uint8Array | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
setPending([]);
|
||||
return;
|
||||
}
|
||||
return subscribeOutbox((byConv) => {
|
||||
setPending(byConv[conversationId] ?? []);
|
||||
});
|
||||
}, [conversationId]);
|
||||
|
||||
// Load own private key once per (user, device).
|
||||
useEffect(() => {
|
||||
privateKeyRef.current = null;
|
||||
@@ -85,7 +111,12 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const refresh = useCallback(async () => {
|
||||
if (!conversationId) return;
|
||||
try {
|
||||
setState((prev) => ({ ...prev, loading: true }));
|
||||
// Only show the loading spinner on the *initial* fetch — subsequent
|
||||
// refreshes (focus/visibility) replace messages in-place to avoid
|
||||
// flickering an empty state on every wake.
|
||||
setState((prev) =>
|
||||
prev.messages.length === 0 ? { ...prev, loading: true } : prev,
|
||||
);
|
||||
const rows = await fetchConversationMessages(supabase, conversationId);
|
||||
const decrypted = await decryptBatch(rows);
|
||||
setState({ messages: decrypted, loading: false, error: null });
|
||||
@@ -311,9 +342,16 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
|
||||
// Refresh + reconnect on wake from background throttle (mostly Windows
|
||||
// WebView2). Without this, messages inserted while the window is
|
||||
// minimised never arrive until the user explicitly reloads.
|
||||
// minimised never arrive until the user explicitly reloads. Throttled to
|
||||
// at most once per AWAKE_THROTTLE_MS so the inevitable cluster of
|
||||
// visibility/online events on focus does not cause a flicker storm.
|
||||
let lastAwakeRefresh = 0;
|
||||
const AWAKE_THROTTLE_MS = 30_000;
|
||||
const onAwake = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = Date.now();
|
||||
if (now - lastAwakeRefresh < AWAKE_THROTTLE_MS) return;
|
||||
lastAwakeRefresh = now;
|
||||
void refresh();
|
||||
try {
|
||||
channel.subscribe();
|
||||
@@ -322,17 +360,40 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
}
|
||||
};
|
||||
document.addEventListener('visibilitychange', onAwake);
|
||||
window.addEventListener('focus', onAwake);
|
||||
window.addEventListener('online', onAwake);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', onAwake);
|
||||
window.removeEventListener('focus', onAwake);
|
||||
window.removeEventListener('online', onAwake);
|
||||
void supabase.removeChannel(channel);
|
||||
};
|
||||
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
|
||||
|
||||
const sendText = useCallback(
|
||||
async (convId: string, uid: string, did: string, priv: Uint8Array, text: string, replyToId: string | null): Promise<void> => {
|
||||
const msg = await sendEncryptedMessage({
|
||||
client: supabase,
|
||||
conversationId: convId,
|
||||
plaintext: text,
|
||||
senderUserId: uid,
|
||||
senderDeviceId: did,
|
||||
senderPrivateKey: priv,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
});
|
||||
setState((prev) => {
|
||||
if (prev.messages.some((m) => m.id === msg.id)) return prev;
|
||||
return {
|
||||
...prev,
|
||||
messages: [
|
||||
...prev.messages,
|
||||
{ ...msg, plaintext: text } as DecryptedMessage,
|
||||
],
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string, images: File[] = [], replyToId: string | null = null) => {
|
||||
const trimmed = text.trim();
|
||||
@@ -340,6 +401,25 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv) throw new Error('private key not loaded');
|
||||
|
||||
// Text-only path is retryable — if the network is down or the server
|
||||
// rejects transiently, stash in the outbox and keep the UI optimistic.
|
||||
// Attachments can't be deferred (large payloads, uploaded separately),
|
||||
// so those still surface the error immediately.
|
||||
if (images.length === 0) {
|
||||
try {
|
||||
await sendText(conversationId, userId, deviceId, priv, trimmed, replyToId);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'send failed';
|
||||
enqueueOutbox({
|
||||
conversationId,
|
||||
text: trimmed,
|
||||
replyToId,
|
||||
error: msg,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
|
||||
// (so the public attachment row can reference the blob-level nonce).
|
||||
const handles: AttachmentHandle[] = [];
|
||||
@@ -401,10 +481,104 @@ export function useConversationMessages({ conversationId, userId, deviceId }: Ar
|
||||
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
|
||||
}
|
||||
},
|
||||
[conversationId, userId, deviceId],
|
||||
[conversationId, userId, deviceId, sendText],
|
||||
);
|
||||
|
||||
return useMemo(() => ({ ...state, send, refresh }), [state, send, refresh]);
|
||||
// Drain outbox: retry due items, remove on success, record attempt on fail.
|
||||
// Runs on online event, window focus, and a 10s interval.
|
||||
useEffect(() => {
|
||||
if (!conversationId || !userId || !deviceId) return;
|
||||
|
||||
let draining = false;
|
||||
const drain = async (): Promise<void> => {
|
||||
if (draining) return;
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv) return;
|
||||
if (typeof navigator !== 'undefined' && navigator.onLine === false) return;
|
||||
draining = true;
|
||||
try {
|
||||
const items = getOutbox(conversationId).filter(isDue);
|
||||
for (const item of items) {
|
||||
if (shouldGiveUp(item)) continue;
|
||||
try {
|
||||
await sendText(
|
||||
conversationId,
|
||||
userId,
|
||||
deviceId,
|
||||
priv,
|
||||
item.text,
|
||||
item.replyToId,
|
||||
);
|
||||
removeOutbox(conversationId, item.id);
|
||||
} catch (err: unknown) {
|
||||
markAttempt(
|
||||
conversationId,
|
||||
item.id,
|
||||
err instanceof Error ? err.message : 'send failed',
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
draining = false;
|
||||
}
|
||||
};
|
||||
|
||||
const interval = window.setInterval(() => {
|
||||
void drain();
|
||||
}, 10_000);
|
||||
const onOnline = () => void drain();
|
||||
window.addEventListener('online', onOnline);
|
||||
window.addEventListener('focus', onOnline);
|
||||
// Kick once immediately on mount for stale queued items.
|
||||
void drain();
|
||||
|
||||
return () => {
|
||||
window.clearInterval(interval);
|
||||
window.removeEventListener('online', onOnline);
|
||||
window.removeEventListener('focus', onOnline);
|
||||
};
|
||||
}, [conversationId, userId, deviceId, sendText]);
|
||||
|
||||
const retryPending = useCallback(
|
||||
(id: string) => {
|
||||
if (!conversationId) return;
|
||||
// Force due now; the drain loop will pick it up on next tick.
|
||||
markAttempt(conversationId, id, null);
|
||||
// Manual kick: mutate nextAttemptAt by re-enqueuing? Simpler — just
|
||||
// trigger a drain-ish by queueing a microtask. The interval picks up
|
||||
// due items within 10s, but for UX we also eagerly try here.
|
||||
const priv = privateKeyRef.current;
|
||||
if (!priv || !userId || !deviceId) return;
|
||||
const item = getOutbox(conversationId).find((x) => x.id === id);
|
||||
if (!item) return;
|
||||
void (async () => {
|
||||
try {
|
||||
await sendText(conversationId, userId, deviceId, priv, item.text, item.replyToId);
|
||||
removeOutbox(conversationId, id);
|
||||
} catch (err: unknown) {
|
||||
markAttempt(
|
||||
conversationId,
|
||||
id,
|
||||
err instanceof Error ? err.message : 'send failed',
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[conversationId, userId, deviceId, sendText],
|
||||
);
|
||||
|
||||
const cancelPending = useCallback(
|
||||
(id: string) => {
|
||||
if (!conversationId) return;
|
||||
removeOutbox(conversationId, id);
|
||||
},
|
||||
[conversationId],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({ ...state, send, refresh, pending, retryPending, cancelPending }),
|
||||
[state, send, refresh, pending, retryPending, cancelPending],
|
||||
);
|
||||
}
|
||||
|
||||
// Best-effort image dimension probe. Falls back silently on non-images.
|
||||
|
||||
Reference in New Issue
Block a user