This commit is contained in:
2026-04-18 23:11:35 +02:00
commit f7cfd2a86e
196 changed files with 35538 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
# apps/desktop/src/lib
Desktop-local helpers that depend on Tauri plugins.
Expected contents:
- `stronghold.ts` — wraps `tauri-plugin-stronghold` for secret storage; adapter for `@chat-app/shared/auth`.
- `sqlite.ts` — wraps `tauri-plugin-sql` (SQLite), exposes migration runner.
- `notifications.ts` — wraps `tauri-plugin-notification`.
- `sodium.ts` — binds `libsodium-wrappers` (WASM) to the crypto adapter interface.
- `store.ts` — Zustand store setup (auth state, chat state slices).
+76
View File
@@ -0,0 +1,76 @@
import { check as checkUpdate } from '@tauri-apps/plugin-updater';
import { isTauriRuntime } from './globalShortcut';
export interface UpdateState {
available: boolean;
version: string | null;
notes: string | null;
downloading: boolean;
downloaded: boolean;
error: string | null;
}
export const IDLE_UPDATE_STATE: UpdateState = {
available: false,
version: null,
notes: null,
downloading: false,
downloaded: false,
error: null,
};
type UpdateHandle = Awaited<ReturnType<typeof checkUpdate>>;
let cachedUpdate: UpdateHandle | null = null;
export async function checkForUpdate(): Promise<UpdateState> {
if (!isTauriRuntime()) {
return { ...IDLE_UPDATE_STATE };
}
try {
const update = await checkUpdate();
if (!update) {
cachedUpdate = null;
return { ...IDLE_UPDATE_STATE };
}
cachedUpdate = update;
return {
available: true,
version: update.version ?? null,
notes: update.body ?? null,
downloading: false,
downloaded: false,
error: null,
};
} catch (err: unknown) {
console.warn('checkForUpdate failed', err);
return {
...IDLE_UPDATE_STATE,
error: err instanceof Error ? err.message : 'update check failed',
};
}
}
// Downloads + installs the previously checked update. On Windows the app
// quits during install (passive installer); on macOS/Linux tauri triggers
// a relaunch automatically.
export async function installUpdate(
onProgress?: (downloaded: number, total: number | null) => void,
): Promise<void> {
if (!cachedUpdate) {
throw new Error('no pending update — call checkForUpdate() first');
}
let total: number | null = null;
let downloaded = 0;
await cachedUpdate.downloadAndInstall((event) => {
if (event.event === 'Started') {
total = event.data.contentLength ?? null;
downloaded = 0;
} else if (event.event === 'Progress') {
downloaded += event.data.chunkLength;
}
onProgress?.(downloaded, total);
});
cachedUpdate = null;
}
+107
View File
@@ -0,0 +1,107 @@
// Audio quality preferences for outgoing voice/music. `voice` is the default
// — Opus 48 kbps stereo with full DSP (echo cancellation + noise suppression
// + AGC). `hifi` bumps to 96 kbps stereo Opus and disables all DSP so music,
// instruments, or broadcast-style voice streams stay uncoloured.
const STORAGE_KEY = 'chatapp.audio';
export type AudioQuality = 'voice' | 'hifi';
export interface AudioSettings {
quality: AudioQuality;
}
const DEFAULTS: AudioSettings = {
quality: 'voice',
};
export interface AudioQualityParams {
label: string;
bitrateKbps: number;
stereo: boolean;
sampleRateHz: number;
// DSP toggles — off for hifi so music isn't coloured by noise-suppression.
echoCancellation: boolean;
noiseSuppression: boolean;
autoGainControl: boolean;
}
const PARAMS: Record<AudioQuality, AudioQualityParams> = {
voice: {
label: 'Voice · 48 kbps',
bitrateKbps: 48,
stereo: false,
sampleRateHz: 48_000,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
hifi: {
label: 'HiFi · 510 kbps Stereo',
bitrateKbps: 510, // Opus max, ~CD-quality stereo
stereo: true,
sampleRateHz: 48_000,
echoCancellation: false,
noiseSuppression: false,
autoGainControl: false,
},
};
export const AUDIO_QUALITY_ORDER: ReadonlyArray<AudioQuality> = ['voice', 'hifi'];
export function getAudioQualityParams(q: AudioQuality): AudioQualityParams {
return PARAMS[q];
}
type Listener = (s: AudioSettings) => void;
const listeners = new Set<Listener>();
let cached: AudioSettings | null = null;
function isQuality(v: unknown): v is AudioQuality {
return v === 'voice' || v === 'hifi';
}
function read(): AudioSettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<AudioSettings>;
cached = {
quality: isQuality(parsed.quality) ? parsed.quality : DEFAULTS.quality,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: AudioSettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getAudioSettings(): AudioSettings {
return read();
}
export function updateAudioSettings(patch: Partial<AudioSettings>): AudioSettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribeAudioSettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
+111
View File
@@ -0,0 +1,111 @@
import { ExternalE2EEKeyProvider } from 'livekit-client';
// Vite-native Worker import. `?worker` triggers a dedicated build chunk
// shipped as a classic/module worker. The default export is the Worker
// constructor; we instantiate once per tab and reuse.
import LivekitE2EEWorker from 'livekit-client/e2ee-worker?worker';
const STORAGE_KEY = 'chatapp.e2ee';
export interface CallE2EESettings {
enabled: boolean;
}
// Default ON — the whole point of this project is zero-knowledge, so honour
// that for the SFU path too. Can be turned off for debugging or if a user's
// browser lacks RTCRtpScriptTransform / Insertable Streams support.
const DEFAULTS: CallE2EESettings = { enabled: true };
type Listener = (s: CallE2EESettings) => void;
const listeners = new Set<Listener>();
let cached: CallE2EESettings | null = null;
function read(): CallE2EESettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<CallE2EESettings>;
cached = {
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: CallE2EESettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getCallE2EESettings(): CallE2EESettings {
return read();
}
export function updateCallE2EESettings(patch: Partial<CallE2EESettings>): CallE2EESettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribeCallE2EESettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
// Shared single Worker instance — LiveKit supports reusing it across rooms.
let workerInstance: Worker | null = null;
function getWorker(): Worker {
if (!workerInstance) {
workerInstance = new LivekitE2EEWorker();
}
return workerInstance;
}
// Feature detection — Insertable Streams (RTCRtpScriptTransform or the older
// encodedStreams API) is required for LiveKit E2EE. Returns false on browsers
// that can't encrypt the media path (the caller then skips the `e2ee` option).
export function isE2EESupported(): boolean {
if (typeof window === 'undefined') return false;
const hasScriptTransform =
typeof (window as unknown as { RTCRtpScriptTransform?: unknown }).RTCRtpScriptTransform !==
'undefined';
const sender = (window as unknown as { RTCRtpSender?: { prototype?: unknown } }).RTCRtpSender;
const hasEncodedStreams =
!!sender?.prototype &&
'createEncodedStreams' in (sender.prototype as Record<string, unknown>);
return hasScriptTransform || hasEncodedStreams;
}
interface E2EEBundle {
keyProvider: ExternalE2EEKeyProvider;
worker: Worker;
}
// Derives a stable per-conversation passphrase entirely client-side. The
// passphrase is never transmitted anywhere; each member computes it locally
// from the conversation id they already hold via RLS-protected Supabase data.
// A stronger variant would ship a random per-conversation secret through the
// existing E2E envelope system — deferred to M3.
export async function createCallE2EE(conversationId: string): Promise<E2EEBundle> {
const keyProvider = new ExternalE2EEKeyProvider();
const salt = 'chat-app-voice-e2ee-v1';
const enc = new TextEncoder();
const buf = await crypto.subtle.digest('SHA-256', enc.encode(salt + ':' + conversationId));
const hex = Array.from(new Uint8Array(buf))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
await keyProvider.setKey(hex);
return { keyProvider, worker: getWorker() };
}
+73
View File
@@ -0,0 +1,73 @@
// One-shot call sound effects via WebAudio. Generated on the fly so no
// binary assets needed. Intentionally short + low-volume — these fire
// multiple times per call and shouldn't feel intrusive.
type Sfx = 'join' | 'leave' | 'end';
let ctx: AudioContext | null = null;
function getCtx(): AudioContext | null {
if (ctx) return ctx;
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) return null;
ctx = new AudioCtx();
return ctx;
}
function beep(freq: number, durationSec: number, delaySec: number, gain = 0.15): void {
const c = getCtx();
if (!c) return;
const osc = c.createOscillator();
const g = c.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
osc.connect(g);
g.connect(c.destination);
const t0 = c.currentTime + delaySec;
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t0 + durationSec);
osc.start(t0);
osc.stop(t0 + durationSec + 0.02);
}
export async function playSfx(kind: Sfx): Promise<void> {
const c = getCtx();
if (!c) return;
if (c.state === 'suspended') {
try {
await c.resume();
} catch {
/* ignore */
}
}
switch (kind) {
case 'join':
// Rising two-note chirp — someone entered.
beep(523.25, 0.12, 0, 0.16); // C5
beep(783.99, 0.18, 0.1, 0.16); // G5
break;
case 'leave':
// Falling two-note — someone left.
beep(659.25, 0.12, 0, 0.14); // E5
beep(329.63, 0.18, 0.1, 0.14); // E4
break;
case 'end':
// Soft descending thud — call ended.
beep(440, 0.18, 0, 0.14); // A4
beep(293.66, 0.26, 0.14, 0.14); // D4
break;
}
}
export function playJoinBeep(): Promise<void> {
return playSfx('join');
}
export function playLeaveBeep(): Promise<void> {
return playSfx('leave');
}
export function playEndBeep(): Promise<void> {
return playSfx('end');
}
+29
View File
@@ -0,0 +1,29 @@
import type { CryptoBackend } from '@chat-app/shared/crypto';
import _sodium from 'libsodium-wrappers';
// Build the libsodium-backed CryptoBackend. Awaits the WASM ready-gate once,
// then returns a synchronous implementation of the CryptoBackend contract.
export async function createLibsodiumBackend(): Promise<CryptoBackend> {
await _sodium.ready;
const s = _sodium;
return {
name: 'libsodium-wrappers',
nonceLength: s.crypto_box_NONCEBYTES,
publicKeyLength: s.crypto_box_PUBLICKEYBYTES,
privateKeyLength: s.crypto_box_SECRETKEYBYTES,
secretboxKeyLength: s.crypto_secretbox_KEYBYTES,
secretboxNonceLength: s.crypto_secretbox_NONCEBYTES,
randomBytes: (n: number) => s.randombytes_buf(n),
generateKeyPair: () => {
const kp = s.crypto_box_keypair();
return { publicKey: kp.publicKey, privateKey: kp.privateKey };
},
box: (plaintext, nonce, recipientPublicKey, senderPrivateKey) =>
s.crypto_box_easy(plaintext, nonce, recipientPublicKey, senderPrivateKey),
boxOpen: (ciphertext, nonce, senderPublicKey, recipientPrivateKey) =>
s.crypto_box_open_easy(ciphertext, nonce, senderPublicKey, recipientPrivateKey),
secretbox: (plaintext, nonce, key) => s.crypto_secretbox_easy(plaintext, nonce, key),
secretboxOpen: (ciphertext, nonce, key) => s.crypto_secretbox_open_easy(ciphertext, nonce, key),
};
}
+73
View File
@@ -0,0 +1,73 @@
import {
deviceIdStorageKey,
listOwnDevices,
loadDevicePrivateKey,
provisionNewDevice,
touchDeviceLastSeen,
type DeviceRecord,
} from '@chat-app/shared/auth';
import type { DevicePlatform } from '@chat-app/shared/supabase';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
export function detectDesktopPlatform(): DevicePlatform {
const ua =
typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
? navigator.userAgent.toLowerCase()
: '';
if (ua.includes('mac')) return 'macos';
if (ua.includes('win')) return 'windows';
return 'linux';
}
export function readLocalDeviceId(userId: string): string | null {
return window.localStorage.getItem(deviceIdStorageKey(userId));
}
function writeLocalDeviceId(userId: string, deviceId: string): void {
window.localStorage.setItem(deviceIdStorageKey(userId), deviceId);
}
export function clearLocalDeviceId(userId: string): void {
window.localStorage.removeItem(deviceIdStorageKey(userId));
}
// Look up the current install's device record. Returns null when either:
// - no device id is cached locally, or
// - the cached id was deleted server-side (e.g. wiped from Studio).
// In both cases the UI should prompt the user to register a fresh device.
export async function findExistingDevice(userId: string): Promise<DeviceRecord | null> {
const cachedId = readLocalDeviceId(userId);
if (!cachedId) return null;
const all = await listOwnDevices(supabase);
const hit = all.find((d) => d.id === cachedId) ?? null;
if (!hit) return null;
const priv = await loadDevicePrivateKey(devLocalSecretStore, userId, hit.id);
if (!priv) {
// Server row exists but we lost the private key locally — treat as fresh install.
return null;
}
void touchDeviceLastSeen(supabase, hit.id).catch(() => {
/* non-fatal */
});
return hit;
}
export async function registerCurrentDevice(params: {
userId: string;
name: string;
}): Promise<DeviceRecord> {
const device = await provisionNewDevice({
client: supabase,
secretStore: devLocalSecretStore,
userId: params.userId,
name: params.name,
platform: detectDesktopPlatform(),
});
writeLocalDeviceId(params.userId, device.id);
return device;
}
+14
View File
@@ -0,0 +1,14 @@
function required(name: string, value: string | undefined): string {
if (!value || value.length === 0) {
throw new Error(
`Missing env var ${name}. Copy apps/desktop/.env.example to apps/desktop/.env and fill it.`,
);
}
return value;
}
export const env = {
supabaseUrl: required('VITE_SUPABASE_URL', import.meta.env.VITE_SUPABASE_URL),
supabaseAnonKey: required('VITE_SUPABASE_ANON_KEY', import.meta.env.VITE_SUPABASE_ANON_KEY),
authRedirectUrl: required('VITE_AUTH_REDIRECT_URL', import.meta.env.VITE_AUTH_REDIRECT_URL),
};
+57
View File
@@ -0,0 +1,57 @@
import {
isRegistered,
register,
type ShortcutEvent,
unregister,
} from '@tauri-apps/plugin-global-shortcut';
// Maps a KeyboardEvent.code (what our PTT settings store) into the shortcut
// string accepted by tauri-plugin-global-shortcut. The plugin follows the
// [keyboard-types] crate naming which mostly matches DOM `event.code`, but
// single-key aliases (e.g. "Space", "F5") work as-is.
export function codeToShortcut(code: string): string {
if (code.startsWith('Key')) return code.slice(3); // KeyV -> V
if (code.startsWith('Digit')) return code.slice(5); // Digit1 -> 1
// Space, F1..F24, Escape, Enter, Tab, Arrow*, etc. pass through unchanged.
return code;
}
export async function registerPttShortcut(
code: string,
onPress: () => void,
onRelease: () => void,
): Promise<boolean> {
const shortcut = codeToShortcut(code);
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
await register(shortcut, (event: ShortcutEvent) => {
if (event.state === 'Pressed') onPress();
else if (event.state === 'Released') onRelease();
});
return true;
} catch (err: unknown) {
console.warn('registerPttShortcut failed', { code, err });
return false;
}
}
export async function unregisterPttShortcut(code: string): Promise<void> {
const shortcut = codeToShortcut(code);
try {
if (await isRegistered(shortcut)) {
await unregister(shortcut);
}
} catch (err: unknown) {
console.warn('unregisterPttShortcut failed', { code, err });
}
}
// Detects whether we're running under Tauri. When running in a pure web
// preview (vite dev in a browser without Tauri), importing the plugin still
// works but calls fall through to window.__TAURI_INTERNALS__ which doesn't
// exist — use this guard to skip registration cleanly.
export function isTauriRuntime(): boolean {
return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window;
}
+46
View File
@@ -0,0 +1,46 @@
import {
DEFAULT_LOCALE,
detectBrowserLocale,
initI18n,
isSupportedLocale,
type SupportedLocale,
} from '@chat-app/shared/i18n';
const LOCAL_STORAGE_KEY = 'chatapp.locale';
export function getCachedLocale(): SupportedLocale | null {
try {
const raw = window.localStorage.getItem(LOCAL_STORAGE_KEY);
return isSupportedLocale(raw) ? raw : null;
} catch {
return null;
}
}
export function cacheLocale(locale: SupportedLocale): void {
try {
window.localStorage.setItem(LOCAL_STORAGE_KEY, locale);
} catch {
// Ignore (private mode, quota, etc.). Profile row remains source of truth.
}
}
// Precedence for the very first paint (before we know the user):
// 1. cached choice from a previous session
// 2. navigator language(s)
// 3. DEFAULT_LOCALE (en)
export function resolveInitialLocale(): SupportedLocale {
return (
getCachedLocale() ??
detectBrowserLocale(typeof navigator !== 'undefined' ? navigator.languages : undefined) ??
DEFAULT_LOCALE
);
}
export function bootstrapI18n(): void {
const initialLocale = resolveInitialLocale();
initI18n({
initialLocale,
onLanguageChanged: (locale) => cacheLocale(locale),
});
}
+45
View File
@@ -0,0 +1,45 @@
// Two-tone notification chime generated via WebAudio. No asset file needed.
// Throttled so a burst of messages doesn't turn into a machine gun.
let lastPlay = 0;
export function playNotificationTone(): void {
const now = Date.now();
if (now - lastPlay < 800) return;
lastPlay = now;
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) return;
const ctx = new AudioCtx();
const master = ctx.createGain();
master.connect(ctx.destination);
master.gain.value = 0.12;
const tones: { freq: number; delay: number }[] = [
{ freq: 880, delay: 0 },
{ freq: 1320, delay: 0.08 },
];
for (const { freq, delay } of tones) {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
osc.connect(gain);
gain.connect(master);
const t0 = ctx.currentTime + delay;
gain.gain.setValueAtTime(0, t0);
gain.gain.linearRampToValueAtTime(1, t0 + 0.015);
gain.gain.exponentialRampToValueAtTime(0.001, t0 + 0.28);
osc.start(t0);
osc.stop(t0 + 0.3);
}
window.setTimeout(() => {
void ctx.close().catch(() => {
/* ignore */
});
}, 600);
}
+50
View File
@@ -0,0 +1,50 @@
import {
isPermissionGranted,
requestPermission,
sendNotification,
} from '@tauri-apps/plugin-notification';
// Tracks whether permission has already been requested this session so we
// don't spam the OS prompt. Actual permission state lives in the OS.
let permissionChecked = false;
let permissionGranted = false;
export async function ensureNotificationPermission(): Promise<boolean> {
if (permissionChecked) return permissionGranted;
permissionChecked = true;
try {
let granted = await isPermissionGranted();
if (!granted) {
const result = await requestPermission();
granted = result === 'granted';
}
permissionGranted = granted;
} catch (err: unknown) {
// Not running under Tauri (e.g. web preview) — fall back silently.
permissionGranted = false;
console.warn('notification permission check failed', err);
}
return permissionGranted;
}
export function isAppFocused(): boolean {
return typeof document !== 'undefined' && !document.hidden && document.hasFocus();
}
interface NotifyOpts {
title: string;
body?: string;
// Force notification even when app is focused. Default: suppress if focused.
force?: boolean;
}
export async function notify({ title, body, force = false }: NotifyOpts): Promise<void> {
if (!force && isAppFocused()) return;
const granted = await ensureNotificationPermission();
if (!granted) return;
try {
sendNotification({ title, ...(body ? { body } : {}) });
} catch (err: unknown) {
console.error('sendNotification failed', err);
}
}
+83
View File
@@ -0,0 +1,83 @@
// Local-only user preferences for push-to-talk. Stored in localStorage because
// the server is zero-knowledge and doesn't need to know input-device details.
const STORAGE_KEY = 'chatapp.ptt';
export interface PttSettings {
enabled: boolean;
// KeyboardEvent.code of the hold-to-talk key (e.g. 'Space', 'KeyV').
key: string;
// Human-readable label derived from the key — kept in settings so we don't
// re-derive it on every render. Updated together with `key`.
keyLabel: string;
}
const DEFAULTS: PttSettings = {
enabled: false,
key: 'Space',
keyLabel: 'Space',
};
type Listener = (s: PttSettings) => void;
const listeners = new Set<Listener>();
let cached: PttSettings | null = null;
function read(): PttSettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<PttSettings>;
cached = {
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : DEFAULTS.enabled,
key: typeof parsed.key === 'string' && parsed.key ? parsed.key : DEFAULTS.key,
keyLabel:
typeof parsed.keyLabel === 'string' && parsed.keyLabel
? parsed.keyLabel
: DEFAULTS.keyLabel,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: PttSettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getPttSettings(): PttSettings {
return read();
}
export function updatePttSettings(patch: Partial<PttSettings>): PttSettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribePttSettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
// Turns a KeyboardEvent.code into a short human label (best-effort).
export function keyCodeToLabel(code: string): string {
if (code === 'Space') return 'Space';
if (code.startsWith('Key')) return code.slice(3);
if (code.startsWith('Digit')) return code.slice(5);
if (code.startsWith('Numpad')) return 'Num' + code.slice(6);
if (code.startsWith('Arrow')) return code.slice(5) + ' Arrow';
return code;
}
+76
View File
@@ -0,0 +1,76 @@
// Looping WebAudio ringtones. Two patterns:
// - outgoing: long calling tone, 3s cycle
// - incoming: classic "ring ring" double beep, 2s cycle
type Pattern = 'outgoing' | 'incoming';
class Ringtone {
private ctx: AudioContext | null = null;
private interval: number | null = null;
private pattern: Pattern | null = null;
start(pattern: Pattern): void {
if (this.pattern === pattern) return; // already playing this pattern
this.stop();
const AudioCtx =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!AudioCtx) return;
this.ctx = new AudioCtx();
this.pattern = pattern;
const play = pattern === 'outgoing' ? this.playOutgoing : this.playIncoming;
play.call(this);
this.interval = window.setInterval(
() => play.call(this),
pattern === 'outgoing' ? 3000 : 2000,
);
}
stop(): void {
if (this.interval !== null) {
window.clearInterval(this.interval);
this.interval = null;
}
if (this.ctx) {
void this.ctx.close().catch(() => {
/* ignore */
});
this.ctx = null;
}
this.pattern = null;
}
private beep(freq: number, durationSec: number, delaySec: number, gain = 0.18): void {
const ctx = this.ctx;
if (!ctx) return;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = 'sine';
osc.frequency.value = freq;
osc.connect(g);
g.connect(ctx.destination);
const t0 = ctx.currentTime + delaySec;
g.gain.setValueAtTime(0, t0);
g.gain.linearRampToValueAtTime(gain, t0 + 0.02);
g.gain.exponentialRampToValueAtTime(0.001, t0 + durationSec);
osc.start(t0);
osc.stop(t0 + durationSec + 0.02);
}
private playOutgoing(): void {
// Soft calling tone — single warm note.
this.beep(440, 0.4, 0, 0.14);
this.beep(440, 0.4, 0.6, 0.14);
}
private playIncoming(): void {
// Classic double-ring "ring ring".
this.beep(880, 0.18, 0, 0.22);
this.beep(660, 0.18, 0.22, 0.22);
this.beep(880, 0.18, 0.6, 0.22);
this.beep(660, 0.18, 0.82, 0.22);
}
}
export const ringtone = new Ringtone();
+139
View File
@@ -0,0 +1,139 @@
// Screen-share quality presets modelled on Discord's tiers. Values are the
// upper bounds — LiveKit + WebRTC's congestion control dynamically drop to
// lower spatial/temporal layers (SVC with VP9) when the uplink degrades, so
// these numbers behave as "up to" caps, not constant bitrates.
const STORAGE_KEY = 'chatapp.screenshare';
export type ScreenSharePreset =
| 'auto'
| '720p30'
| '720p60'
| '1080p30'
| '1080p60'
| '1440p60'
| '4k60';
export interface ScreenShareSettings {
preset: ScreenSharePreset;
}
const DEFAULTS: ScreenShareSettings = {
preset: 'auto',
};
export interface PresetParams {
dims: { width: number; height: number } | null; // null = browser picks native
framerate: number;
bitrateKbps: number;
label: string;
}
const PRESET_PARAMS: Record<ScreenSharePreset, PresetParams> = {
auto: { dims: null, framerate: 60, bitrateKbps: 8000, label: 'Auto (Original)' },
'720p30': {
dims: { width: 1280, height: 720 },
framerate: 30,
bitrateKbps: 2500,
label: '720p · 30 fps',
},
'720p60': {
dims: { width: 1280, height: 720 },
framerate: 60,
bitrateKbps: 3500,
label: '720p · 60 fps',
},
'1080p30': {
dims: { width: 1920, height: 1080 },
framerate: 30,
bitrateKbps: 4000,
label: '1080p · 30 fps',
},
'1080p60': {
dims: { width: 1920, height: 1080 },
framerate: 60,
bitrateKbps: 6000,
label: '1080p · 60 fps',
},
'1440p60': {
dims: { width: 2560, height: 1440 },
framerate: 60,
bitrateKbps: 8000,
label: '1440p · 60 fps',
},
'4k60': {
dims: { width: 3840, height: 2160 },
framerate: 60,
bitrateKbps: 10_000,
label: '4K · 60 fps',
},
};
export const PRESET_ORDER: ReadonlyArray<ScreenSharePreset> = [
'auto',
'720p30',
'720p60',
'1080p30',
'1080p60',
'1440p60',
'4k60',
];
export function getPresetParams(p: ScreenSharePreset): PresetParams {
return PRESET_PARAMS[p];
}
type Listener = (s: ScreenShareSettings) => void;
const listeners = new Set<Listener>();
let cached: ScreenShareSettings | null = null;
function isPreset(v: unknown): v is ScreenSharePreset {
return typeof v === 'string' && v in PRESET_PARAMS;
}
function read(): ScreenShareSettings {
if (cached) return cached;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
cached = DEFAULTS;
return cached;
}
const parsed = JSON.parse(raw) as Partial<ScreenShareSettings>;
cached = {
preset: isPreset(parsed.preset) ? parsed.preset : DEFAULTS.preset,
};
return cached;
} catch {
cached = DEFAULTS;
return cached;
}
}
function write(s: ScreenShareSettings): void {
cached = s;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
} catch {
/* quota / private mode */
}
for (const l of listeners) l(s);
}
export function getScreenShareSettings(): ScreenShareSettings {
return read();
}
export function updateScreenShareSettings(
patch: Partial<ScreenShareSettings>,
): ScreenShareSettings {
const next = { ...read(), ...patch };
write(next);
return next;
}
export function subscribeScreenShareSettings(listener: Listener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
+22
View File
@@ -0,0 +1,22 @@
import { base64FromBytes, bytesFromBase64, type SecretStore } from '@chat-app/shared/auth';
// M1 dev-only impl: persists secrets as base64 in localStorage.
// Swap this out for a tauri-plugin-stronghold implementation before release.
// The SecretStore interface stays identical so callers won't notice.
const PREFIX = 'chatapp.secret:';
export const devLocalSecretStore: SecretStore = {
async getSecret(key: string): Promise<Uint8Array | null> {
const raw = window.localStorage.getItem(PREFIX + key);
if (!raw) return null;
return bytesFromBase64(raw);
},
async setSecret(key: string, value: Uint8Array): Promise<void> {
const encoded = await base64FromBytes(value);
window.localStorage.setItem(PREFIX + key, encoded);
},
async removeSecret(key: string): Promise<void> {
window.localStorage.removeItem(PREFIX + key);
},
};
+15
View File
@@ -0,0 +1,15 @@
import type { KeyValueStore } from '@chat-app/shared/supabase';
// Tauri webview exposes a persistent per-app-identifier localStorage.
// Good enough for session tokens. Private keys go to Stronghold later.
export const localStorageAdapter: KeyValueStore = {
async getItem(key: string): Promise<string | null> {
return window.localStorage.getItem(key);
},
async setItem(key: string, value: string): Promise<void> {
window.localStorage.setItem(key, value);
},
async removeItem(key: string): Promise<void> {
window.localStorage.removeItem(key);
},
};
+13
View File
@@ -0,0 +1,13 @@
import { createClient } from '@chat-app/shared/supabase';
import { env } from './env';
import { localStorageAdapter } from './storage';
export const supabase = createClient({
url: env.supabaseUrl,
anonKey: env.supabaseAnonKey,
sessionStorage: localStorageAdapter,
// We manually parse the callback URL in App.tsx because Tauri webview
// loads a fresh route rather than appending hash params to the current one.
detectSessionInUrl: false,
});
+94
View File
@@ -0,0 +1,94 @@
import type { RealtimeChannel } from '@supabase/supabase-js';
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
// Observes the `call-presence:<conversationId>` channel for every conversation
// the user is a member of and returns the first one (if any) that currently
// has peers other than the viewer in the call. Enables a "call is live —
// rejoin" affordance in the sidebar for conversations the viewer never
// joined, mirroring Discord's active-voice indicator.
//
// Uses polling on each channel's `presenceState()` to side-step Supabase's
// topic-based channel dedupe (see useCallPresence.ts).
export function useAnyActiveCall(
conversationIds: readonly string[],
myId: string | null,
): { conversationId: string; userIds: string[] } | null {
const [byConv, setByConv] = useState<Record<string, string[]>>({});
const key = conversationIds.join(',');
useEffect(() => {
if (conversationIds.length === 0) {
setByConv({});
return;
}
const tracked: Array<{ id: string; ch: RealtimeChannel; owns: boolean }> = [];
for (const id of conversationIds) {
const ch = supabase.channel('call-presence:' + id, {
config: {
presence: {
key: 'obs-' + Math.random().toString(36).slice(2, 8),
enabled: true,
},
},
});
const owns = ch.state === 'closed';
if (owns) void ch.subscribe();
tracked.push({ id, ch, owns });
}
const resync = () => {
setByConv((prev) => {
const next: Record<string, string[]> = {};
let changed = false;
for (const { id, ch } of tracked) {
const presState = ch.presenceState() as Record<
string,
Array<Record<string, unknown>>
>;
const ids = new Set<string>();
for (const list of Object.values(presState)) {
for (const e of list) {
const uid = e?.userId;
if (typeof uid === 'string') ids.add(uid);
}
}
const arr = Array.from(ids).sort();
next[id] = arr;
const prevArr = prev[id] ?? [];
if (prevArr.length !== arr.length || !arr.every((v, i) => v === prevArr[i])) {
changed = true;
}
}
// Also pick up removed conv ids.
if (!changed && Object.keys(prev).length !== Object.keys(next).length) {
changed = true;
}
return changed ? next : prev;
});
};
resync();
const pollId = window.setInterval(resync, 1500);
return () => {
window.clearInterval(pollId);
for (const { ch, owns } of tracked) {
if (owns) void supabase.removeChannel(ch);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
for (const id of conversationIds) {
const users = byConv[id] ?? [];
const others = myId ? users.filter((u) => u !== myId) : users;
if (others.length > 0) {
return { conversationId: id, userIds: others };
}
}
return null;
}
+72
View File
@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
// Watches the `call-presence:<conversationId>` realtime channel and returns
// the set of userIds currently in that call. Independent of whether the
// viewer is in the room — used to show "Active call · Join" affordances.
//
// Supabase realtime 2.103+ dedupes channels by topic: calling
// `supabase.channel(topic)` returns an existing channel if one already exists.
// That means an observer can't safely register `.on('presence', ...)` because
// the tracker (CallContext) may have already subscribed it. We side-step this
// by polling `presenceState()` on the (shared or fresh) channel.
export function useCallPresence(conversationId: string | undefined): string[] {
const [activeUserIds, setActiveUserIds] = useState<string[]>([]);
useEffect(() => {
if (!conversationId) {
setActiveUserIds([]);
return;
}
const channel = supabase.channel('call-presence:' + conversationId, {
config: {
presence: {
key: 'observer-' + Math.random().toString(36).slice(2, 8),
// Supabase realtime only sends presence_state/diff events to a
// channel that has presence enabled. Without `enabled: true` (and
// no `.on('presence', ...)` bindings) the server treats the channel
// as non-presence and `presenceState()` never populates.
enabled: true,
},
},
});
// If Supabase returned a fresh (closed) channel, we own it and must
// subscribe/remove it. If it returned an already-subscribed channel
// (tracker owns it), we just read state.
const ownsChannel = channel.state === 'closed';
if (ownsChannel) {
void channel.subscribe();
}
const resync = () => {
const state = channel.presenceState() as Record<string, Array<Record<string, unknown>>>;
const ids = new Set<string>();
for (const list of Object.values(state)) {
for (const entry of list) {
const uid = entry?.userId;
if (typeof uid === 'string') ids.add(uid);
}
}
setActiveUserIds((prev) => {
const next = Array.from(ids).sort();
if (prev.length === next.length && prev.every((v, i) => v === next[i])) return prev;
return next;
});
};
resync();
const pollId = window.setInterval(resync, 1500);
return () => {
window.clearInterval(pollId);
if (ownsChannel) {
void supabase.removeChannel(channel);
}
};
}, [conversationId]);
return activeUserIds;
}
@@ -0,0 +1,266 @@
import { loadDevicePrivateKey } from '@chat-app/shared/auth';
import {
type AttachmentHandle,
type ChatMessage,
type DecryptedMessage,
decryptMessages,
encryptAndUploadAttachment,
fetchConversationMessages,
fetchOwnEnvelopes,
fetchSenderDeviceKeys,
insertAttachmentRow,
MAX_ATTACHMENT_BYTES,
sendEncryptedMessage,
} from '@chat-app/shared/chat';
import { bytesToPgHex } from '@chat-app/shared/supabase';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { devLocalSecretStore } from './secretStore';
import { supabase } from './supabase';
interface State {
messages: DecryptedMessage[];
loading: boolean;
error: string | null;
}
interface Args {
conversationId: string | undefined;
userId: string | undefined;
deviceId: string | undefined;
}
type MessageChangePayload = {
eventType: 'INSERT' | 'UPDATE' | 'DELETE';
new: Record<string, unknown>;
old: Record<string, unknown>;
};
function rowToMessage(row: Record<string, unknown>): ChatMessage {
return {
id: String(row.id),
conversationId: String(row.conversation_id),
senderId: String(row.sender_id),
senderDeviceId: row.sender_device_id ? String(row.sender_device_id) : null,
replyToId: row.reply_to_id ? String(row.reply_to_id) : null,
editedAt: row.edited_at ? String(row.edited_at) : null,
deletedAt: row.deleted_at ? String(row.deleted_at) : null,
createdAt: String(row.created_at),
};
}
export function useConversationMessages({ conversationId, userId, deviceId }: Args): State & {
send: (text: string, images?: File[]) => Promise<void>;
refresh: () => Promise<void>;
} {
const [state, setState] = useState<State>({ messages: [], loading: true, error: null });
const privateKeyRef = useRef<Uint8Array | null>(null);
// Load own private key once per (user, device).
useEffect(() => {
privateKeyRef.current = null;
if (!userId || !deviceId) return;
void loadDevicePrivateKey(devLocalSecretStore, userId, deviceId).then((pk) => {
privateKeyRef.current = pk;
});
}, [userId, deviceId]);
const decryptBatch = useCallback(
async (messages: ChatMessage[]): Promise<DecryptedMessage[]> => {
const priv = privateKeyRef.current;
if (!priv || !deviceId || messages.length === 0) {
return messages.map((m) => ({ ...m, plaintext: null }));
}
const ids = messages.map((m) => m.id);
const senderDeviceIds = messages
.map((m) => m.senderDeviceId)
.filter((v): v is string => v != null);
const [envelopes, senderKeys] = await Promise.all([
fetchOwnEnvelopes(supabase, ids, deviceId),
fetchSenderDeviceKeys(supabase, senderDeviceIds),
]);
return decryptMessages({
messages,
envelopes,
senderKeys,
ownPrivateKey: priv,
});
},
[deviceId],
);
const refresh = useCallback(async () => {
if (!conversationId) return;
try {
setState((prev) => ({ ...prev, loading: true }));
const rows = await fetchConversationMessages(supabase, conversationId);
const decrypted = await decryptBatch(rows);
setState({ messages: decrypted, loading: false, error: null });
} catch (err: unknown) {
setState((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : 'failed to load messages',
}));
}
}, [conversationId, decryptBatch]);
// Realtime INSERT handler — decrypt + append (with retry for envelope race).
const handleInsert = useCallback(
async (row: Record<string, unknown>) => {
if (!deviceId) return;
const msg = rowToMessage(row);
let decrypted: DecryptedMessage = { ...msg, plaintext: null };
for (let attempt = 0; attempt < 6; attempt++) {
const [d] = await decryptBatch([msg]);
if (d) {
decrypted = d;
if (d.plaintext !== null) break;
}
await new Promise((r) => window.setTimeout(r, 120 * (attempt + 1)));
}
setState((prev) => {
if (prev.messages.some((m) => m.id === decrypted.id)) return prev;
return { ...prev, messages: [...prev.messages, decrypted] };
});
},
[deviceId, decryptBatch],
);
const handleUpdate = useCallback(
async (row: Record<string, unknown>) => {
const partial = rowToMessage(row);
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === partial.id);
if (idx === -1) return prev;
const existing = prev.messages[idx];
if (!existing) return prev;
const next = [...prev.messages];
next[idx] = {
...existing,
editedAt: partial.editedAt,
deletedAt: partial.deletedAt,
};
return { ...prev, messages: next };
});
if (partial.editedAt && !partial.deletedAt) {
const [decrypted] = await decryptBatch([partial]);
if (!decrypted) return;
setState((prev) => {
const idx = prev.messages.findIndex((m) => m.id === decrypted.id);
if (idx === -1) return prev;
const next = [...prev.messages];
next[idx] = decrypted;
return { ...prev, messages: next };
});
}
},
[decryptBatch],
);
const handleDelete = useCallback((row: Record<string, unknown>) => {
const id = String(row.id);
setState((prev) => ({
...prev,
messages: prev.messages.filter((m) => m.id !== id),
}));
}, []);
useEffect(() => {
if (!conversationId || !userId || !deviceId) return;
void refresh();
const channel = supabase
.channel('conv:' + conversationId)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'messages',
filter: 'conversation_id=eq.' + conversationId,
},
(payload: MessageChangePayload) => {
if (payload.eventType === 'INSERT') {
void handleInsert(payload.new);
} else if (payload.eventType === 'UPDATE') {
void handleUpdate(payload.new);
} else if (payload.eventType === 'DELETE') {
handleDelete(payload.old);
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [conversationId, userId, deviceId, refresh, handleInsert, handleUpdate, handleDelete]);
const send = useCallback(
async (text: string, images: File[] = []) => {
const trimmed = text.trim();
if ((!trimmed && images.length === 0) || !conversationId || !userId || !deviceId) return;
const priv = privateKeyRef.current;
if (!priv) throw new Error('private key not loaded');
// 1. Upload + encrypt each image. Collect handles + raw blob nonces
// (so the public attachment row can reference the blob-level nonce).
const handles: AttachmentHandle[] = [];
const blobNonceHexByHandleId = new Map<string, string>();
for (const file of images) {
if (file.size > MAX_ATTACHMENT_BYTES) {
throw new Error('attachment exceeds max size (10 MB)');
}
const dims = await readImageDimensions(file);
const res = await encryptAndUploadAttachment({
client: supabase,
conversationId,
file,
mimeType: file.type || 'application/octet-stream',
sizeBytes: file.size,
...(dims.width !== undefined ? { width: dims.width } : {}),
...(dims.height !== undefined ? { height: dims.height } : {}),
});
handles.push(res.handle);
blobNonceHexByHandleId.set(res.handle.id, bytesToPgHex(res.nonce));
}
// 2. Send message (inserts messages + envelopes in one helper).
const msg = await sendEncryptedMessage({
client: supabase,
conversationId,
plaintext: trimmed,
senderUserId: userId,
senderDeviceId: deviceId,
senderPrivateKey: priv,
...(handles.length > 0 ? { attachmentHandles: handles } : {}),
});
// 3. Insert public attachment metadata rows pointing at the new message.
for (const h of handles) {
const blobNonce = blobNonceHexByHandleId.get(h.id) ?? '\\x';
await insertAttachmentRow(supabase, msg.id, h, blobNonce);
}
},
[conversationId, userId, deviceId],
);
return useMemo(() => ({ ...state, send, refresh }), [state, send, refresh]);
}
// Best-effort image dimension probe. Falls back silently on non-images.
async function readImageDimensions(file: File): Promise<{ width?: number; height?: number }> {
if (!file.type.startsWith('image/')) return {};
const url = URL.createObjectURL(file);
try {
return await new Promise<{ width?: number; height?: number }>((resolve) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => resolve({});
img.src = url;
});
} finally {
URL.revokeObjectURL(url);
}
}
+53
View File
@@ -0,0 +1,53 @@
import { type Friendship, listFriendships } from '@chat-app/shared/friends';
import { useCallback, useEffect, useState } from 'react';
import { supabase } from './supabase';
interface FriendshipsState {
friendships: Friendship[];
loading: boolean;
error: string | null;
}
// Subscribes to the `friendships` realtime channel and re-pulls the typed
// list whenever an INSERT/UPDATE/DELETE touches one of the caller's rows.
export function useFriendships(userId: string | undefined): FriendshipsState & {
refresh: () => Promise<void>;
} {
const [state, setState] = useState<FriendshipsState>({
friendships: [],
loading: true,
error: null,
});
const refresh = useCallback(async () => {
try {
const items = await listFriendships(supabase);
setState({ friendships: items, loading: false, error: null });
} catch (err: unknown) {
setState((prev) => ({
...prev,
loading: false,
error: err instanceof Error ? err.message : 'failed to load friendships',
}));
}
}, []);
useEffect(() => {
if (!userId) return;
void refresh();
const channel = supabase
.channel('friendships:' + userId)
.on('postgres_changes', { event: '*', schema: 'public', table: 'friendships' }, () => {
void refresh();
})
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [userId, refresh]);
return { ...state, refresh };
}
+110
View File
@@ -0,0 +1,110 @@
import {
addReaction,
listReactionsForMessages,
type MessageReaction,
removeReaction,
} from '@chat-app/shared/chat';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { supabase } from './supabase';
export interface AggregatedReaction {
emoji: string;
count: number;
userIds: string[];
mine: boolean;
}
export interface UseMessageReactionsResult {
byMessage: Map<string, AggregatedReaction[]>;
toggle: (messageId: string, emoji: string) => Promise<void>;
}
// Batch-fetches reactions for the given message ids + subscribes to the
// message_reactions table. Re-pulls on any change (batch is cheap).
export function useMessageReactions(
messageIds: string[],
myId: string | undefined,
): UseMessageReactionsResult {
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
const [rows, setRows] = useState<MessageReaction[]>([]);
const refresh = useCallback(async () => {
if (messageIds.length === 0) {
setRows([]);
return;
}
try {
const data = await listReactionsForMessages(supabase, messageIds);
setRows(data);
} catch (err: unknown) {
console.error('listReactionsForMessages failed', err);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [idsKey]);
useEffect(() => {
void refresh();
if (messageIds.length === 0) return;
const channel = supabase
.channel('reactions:' + idsKey.slice(0, 32))
.on(
'postgres_changes',
{ event: '*', schema: 'public', table: 'message_reactions' },
(payload: { new: Record<string, unknown>; old: Record<string, unknown> }) => {
const mid =
(payload.new?.message_id as string | undefined) ??
(payload.old?.message_id as string | undefined);
if (mid && messageIds.includes(mid)) {
void refresh();
}
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [idsKey, refresh]);
const byMessage = useMemo(() => {
const out = new Map<string, AggregatedReaction[]>();
for (const r of rows) {
const list = out.get(r.messageId) ?? [];
const existing = list.find((a) => a.emoji === r.emoji);
if (existing) {
existing.count += 1;
existing.userIds.push(r.userId);
if (r.userId === myId) existing.mine = true;
} else {
list.push({
emoji: r.emoji,
count: 1,
userIds: [r.userId],
mine: r.userId === myId,
});
}
out.set(r.messageId, list);
}
return out;
}, [rows, myId]);
const toggle = useCallback(
async (messageId: string, emoji: string) => {
if (!myId) return;
const current = byMessage.get(messageId) ?? [];
const existing = current.find((a) => a.emoji === emoji);
if (existing?.mine) {
await removeReaction(supabase, messageId, emoji);
} else {
await addReaction(supabase, messageId, emoji);
}
await refresh();
},
[byMessage, myId, refresh],
);
return { byMessage, toggle };
}
+60
View File
@@ -0,0 +1,60 @@
import { listPeerReadsForMessages, markMessagesRead } from '@chat-app/shared/chat';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { supabase } from './supabase';
// Tracks which of our own messages the peer has read. For groups this would
// return a per-message Map<userId, readAt>; M1 is DM-focused so we just return
// a Set of message ids read by the single peer.
export function useMessageReads(
messageIds: string[],
peerUserId: string | undefined,
): { peerReadSet: Set<string>; refresh: () => Promise<void> } {
const idsKey = useMemo(() => messageIds.join(','), [messageIds]);
const [peerReadSet, setPeerReadSet] = useState<Set<string>>(new Set());
const refresh = useCallback(async () => {
if (!peerUserId || messageIds.length === 0) {
setPeerReadSet(new Set());
return;
}
try {
const s = await listPeerReadsForMessages(supabase, messageIds, peerUserId);
setPeerReadSet(s);
} catch (err: unknown) {
console.error('listPeerReadsForMessages failed', err);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [peerUserId, idsKey]);
useEffect(() => {
void refresh();
if (!peerUserId) return;
const channel = supabase
.channel('reads:' + peerUserId)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'message_reads',
filter: 'user_id=eq.' + peerUserId,
},
() => {
void refresh();
},
)
.subscribe();
return () => {
void supabase.removeChannel(channel);
};
}, [peerUserId, refresh]);
return { peerReadSet, refresh };
}
// Mark a batch of messages as read. Caller uses this when they arrive while
// the conversation is actively being viewed.
export async function markRead(messageIds: string[]): Promise<void> {
await markMessagesRead(supabase, messageIds);
}
+51
View File
@@ -0,0 +1,51 @@
import type { PresenceState } from '@chat-app/shared/supabase';
import { useEffect, useState } from 'react';
import { supabase } from './supabase';
// Subscribe to a single peer's presence_state via Supabase realtime.
// Returns null until the first row arrives, or when userId is undefined.
export function usePeerPresence(userId: string | undefined): PresenceState | null {
const [presence, setPresence] = useState<PresenceState | null>(null);
useEffect(() => {
if (!userId) {
setPresence(null);
return;
}
let cancelled = false;
void supabase
.from('profiles')
.select('presence_state')
.eq('user_id', userId)
.maybeSingle()
.then(({ data }) => {
if (!cancelled) setPresence(data?.presence_state ?? null);
});
const channel = supabase
.channel('peer-presence:' + userId)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'profiles',
filter: 'user_id=eq.' + userId,
},
(payload: { new: Record<string, unknown> }) => {
const next = payload.new['presence_state'];
if (typeof next === 'string') setPresence(next as PresenceState);
},
)
.subscribe();
return () => {
cancelled = true;
void supabase.removeChannel(channel);
};
}, [userId]);
return presence;
}
+95
View File
@@ -0,0 +1,95 @@
import type { RealtimeChannel } from '@supabase/supabase-js';
import { useCallback, useEffect, useRef, useState } from 'react';
import { supabase } from './supabase';
// Typing events live on a Realtime BROADCAST channel — no DB writes.
// Each typer pings once every 2s while actively typing. Receivers keep a
// per-user timestamp and show the indicator for 4s after the last ping.
const SEND_THROTTLE_MS = 2000;
const RECEIVE_TTL_MS = 4000;
export interface UseTypingChannel {
typingUserIds: string[];
notifyTyping: () => void;
notifyStopTyping: () => void;
}
export function useTypingChannel(
conversationId: string | undefined,
myId: string | undefined,
): UseTypingChannel {
const [typingUserIds, setTypingUserIds] = useState<string[]>([]);
const channelRef = useRef<RealtimeChannel | null>(null);
const lastSentRef = useRef(0);
const receivedRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
receivedRef.current = new Map();
setTypingUserIds([]);
if (!conversationId || !myId) return;
const channel = supabase.channel('typing:' + conversationId, {
config: { broadcast: { self: false } },
});
channelRef.current = channel;
channel.on('broadcast', { event: 'typing' }, (msg) => {
const payload = msg.payload as { userId?: string; stop?: boolean };
const uid = payload.userId;
if (!uid || uid === myId) return;
if (payload.stop) {
receivedRef.current.delete(uid);
} else {
receivedRef.current.set(uid, Date.now());
}
setTypingUserIds(collectRecent(receivedRef.current));
});
void channel.subscribe();
const interval = window.setInterval(() => {
const active = collectRecent(receivedRef.current);
setTypingUserIds((prev) => {
if (prev.length === active.length && prev.every((v, i) => v === active[i])) return prev;
return active;
});
}, 1000);
return () => {
window.clearInterval(interval);
void supabase.removeChannel(channel);
channelRef.current = null;
};
}, [conversationId, myId]);
const notifyTyping = useCallback(() => {
const ch = channelRef.current;
if (!ch || !myId) return;
const now = Date.now();
if (now - lastSentRef.current < SEND_THROTTLE_MS) return;
lastSentRef.current = now;
void ch.send({ type: 'broadcast', event: 'typing', payload: { userId: myId } });
}, [myId]);
const notifyStopTyping = useCallback(() => {
const ch = channelRef.current;
if (!ch || !myId) return;
lastSentRef.current = 0;
void ch.send({ type: 'broadcast', event: 'typing', payload: { userId: myId, stop: true } });
}, [myId]);
return { typingUserIds, notifyTyping, notifyStopTyping };
}
function collectRecent(map: Map<string, number>): string[] {
const now = Date.now();
const out: string[] = [];
for (const [uid, ts] of map) {
if (now - ts < RECEIVE_TTL_MS) out.push(uid);
else map.delete(uid);
}
return out.sort();
}