initial
This commit is contained in:
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user