1.0.60
All checks were successful
Build and Release / build-and-release (push) Successful in 12m29s

This commit is contained in:
Bryan1029384756
2026-04-14 20:03:54 -05:00
parent b7a4cf4ce8
commit 965048f7d2
47 changed files with 2558 additions and 135 deletions

View File

@@ -8,7 +8,7 @@ android {
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 27 versionCode 27
versionName "1.0.50" versionName "1.0.60"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/android", "name": "@discord-clone/android",
"private": true, "private": true,
"version": "1.0.50", "version": "1.0.60",
"type": "module", "type": "module",
"scripts": { "scripts": {
"cap:sync": "npx cap sync", "cap:sync": "npx cap sync",

View File

@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, shell, screen, safeStorage, powerMonitor } = require('electron'); const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor } = require('electron');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
@@ -36,6 +36,83 @@ const DEFAULT_SETTINGS = {
let mainWindow = null; let mainWindow = null;
// ───────────────────────────────────────────────────────────────
// Voice recording — per-participant audio capture
// ───────────────────────────────────────────────────────────────
//
// Each active session owns:
// - A root directory (user-configurable, defaults to
// `userData/recordings`).
// - A manifest file (session.json) atomically rewritten whenever
// participants join/leave or the session ends.
// - One append-only WebM file per participant.
//
// `recordingSessions` keyed by sessionId → { rootDir, dir, streams }
// tracks per-session state. `streams` is a map keyed by
// participantId → fs.WriteStream so append/close can find the
// right handle. Crash safety: every 20 chunks we call stream.sync
// via fsync for the open fd, so at most ~10 seconds of audio is
// unflushed at any moment.
const DEFAULT_RECORDING_ROOT = path.join(app.getPath('userData'), 'recordings');
const RECORDING_FSYNC_INTERVAL = 20; // chunks between fsyncs
const recordingSessions = new Map();
function sanitizeFilenameSegment(s) {
return String(s || 'unknown').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 64);
}
async function atomicWriteJson(filePath, obj) {
// Write to a sibling tempfile first, then rename — on a crash
// we either see the previous valid JSON or the new valid JSON,
// never a half-written file.
const tmp = `${filePath}.tmp-${Date.now()}`;
await fs.promises.writeFile(tmp, JSON.stringify(obj, null, 2), 'utf8');
await fs.promises.rename(tmp, filePath);
}
async function readManifest(sessionDir) {
try {
const raw = await fs.promises.readFile(path.join(sessionDir, 'session.json'), 'utf8');
return JSON.parse(raw);
} catch {
return null;
}
}
async function writeManifest(sessionDir, manifest) {
await atomicWriteJson(path.join(sessionDir, 'session.json'), manifest);
}
async function updateManifest(sessionDir, updater) {
const current = await readManifest(sessionDir);
if (!current) return;
const next = updater(current) ?? current;
await writeManifest(sessionDir, next);
}
async function isWritableDir(dir) {
try {
await fs.promises.mkdir(dir, { recursive: true });
const probe = path.join(dir, `.brycord-write-test-${Date.now()}`);
await fs.promises.writeFile(probe, 'ok');
await fs.promises.unlink(probe);
return true;
} catch {
return false;
}
}
function closeSessionStreamsSync(sessionId) {
const session = recordingSessions.get(sessionId);
if (!session) return;
for (const [, entry] of session.streams) {
try { entry.stream.end(); } catch {}
}
session.streams.clear();
}
function loadSettings() { function loadSettings() {
try { try {
const data = fs.readFileSync(SETTINGS_FILE, 'utf8'); const data = fs.readFileSync(SETTINGS_FILE, 'utf8');
@@ -119,6 +196,13 @@ function createWindow() {
// Flush localStorage/sessionStorage to disk before renderer is destroyed // Flush localStorage/sessionStorage to disk before renderer is destroyed
mainWindow.webContents.session.flushStorageData(); mainWindow.webContents.session.flushStorageData();
// Close any still-open voice recording streams so buffered
// chunks are flushed. Manifests will still show endedAt:
// null (the recovery modal will catch them on next launch).
for (const [sessionId] of recordingSessions) {
closeSessionStreamsSync(sessionId);
}
const current = loadSettings(); // re-read to preserve theme changes const current = loadSettings(); // re-read to preserve theme changes
if (!mainWindow.isMaximized()) { if (!mainWindow.isMaximized()) {
const bounds = mainWindow.getBounds(); const bounds = mainWindow.getBounds();
@@ -683,6 +767,258 @@ app.whenReady().then(async () => {
// AFK voice channel: expose system idle time to renderer // AFK voice channel: expose system idle time to renderer
ipcMain.handle('get-system-idle-time', () => powerMonitor.getSystemIdleTime()); ipcMain.handle('get-system-idle-time', () => powerMonitor.getSystemIdleTime());
// ── Voice recording IPC ─────────────────────────────────────
// See the module-level comment block for the session / manifest
// model. All handlers are per-session keyed by sessionId; the
// root directory is chosen on `start-session` and remembered
// for the lifetime of the session so later calls can resolve
// file paths without re-reading the user's settings.
ipcMain.handle('recording-get-default-folder', () => DEFAULT_RECORDING_ROOT);
ipcMain.handle('recording-pick-folder', async () => {
const win = BrowserWindow.getFocusedWindow();
const result = await dialog.showOpenDialog(win ?? undefined, {
title: 'Choose recording folder',
properties: ['openDirectory', 'createDirectory'],
defaultPath: DEFAULT_RECORDING_ROOT,
});
if (result.canceled || result.filePaths.length === 0) {
return { ok: false, path: null };
}
return { ok: true, path: result.filePaths[0] };
});
ipcMain.handle('recording-validate-folder', async (_event, dir) => {
try {
const ok = await isWritableDir(dir);
return { valid: ok, error: ok ? null : 'Folder is not writable.' };
} catch (err) {
return { valid: false, error: err?.message ?? 'Unknown error' };
}
});
ipcMain.handle('recording-open-folder', async (_event, dir) => {
const target = dir || DEFAULT_RECORDING_ROOT;
try {
await fs.promises.mkdir(target, { recursive: true });
} catch {}
const err = await shell.openPath(target);
return { ok: !err, error: err || null };
});
ipcMain.handle('recording-open-session-folder', async (_event, { rootDir, sessionId }) => {
const root = rootDir || DEFAULT_RECORDING_ROOT;
const sessionDir = path.join(root, sanitizeFilenameSegment(sessionId));
const err = await shell.openPath(sessionDir);
return { ok: !err, error: err || null };
});
ipcMain.handle('recording-start-session', async (_event, payload) => {
const { sessionId, rootDir, channelId, channelName, startedAt } = payload;
if (!sessionId) return { ok: false, error: 'missing sessionId' };
const safeSession = sanitizeFilenameSegment(sessionId);
const root = rootDir || DEFAULT_RECORDING_ROOT;
const writable = await isWritableDir(root);
const resolvedRoot = writable ? root : DEFAULT_RECORDING_ROOT;
const sessionDir = path.join(resolvedRoot, safeSession);
await fs.promises.mkdir(sessionDir, { recursive: true });
const manifest = {
sessionId,
channelId: channelId ?? null,
channelName: channelName ?? null,
startedAt: startedAt ?? Date.now(),
endedAt: null,
participants: [],
};
await writeManifest(sessionDir, manifest);
recordingSessions.set(sessionId, {
rootDir: resolvedRoot,
dir: sessionDir,
streams: new Map(),
});
return { ok: true, rootDir: resolvedRoot, sessionDir };
});
ipcMain.handle('recording-open-track', async (_event, payload) => {
const { sessionId, participantId, displayName, joinedOffsetMs } = payload;
const session = recordingSessions.get(sessionId);
if (!session) return { ok: false, error: 'session not started' };
const safePart = sanitizeFilenameSegment(participantId);
// Unique filename if the same participant rejoined mid-session.
let baseName = safePart;
let suffix = 1;
let fileName = `${baseName}.webm`;
while (session.streams.has(`${participantId}::${suffix}`) ||
fs.existsSync(path.join(session.dir, fileName))) {
suffix += 1;
fileName = `${baseName}-${suffix}.webm`;
}
const filePath = path.join(session.dir, fileName);
const stream = fs.createWriteStream(filePath, { flags: 'a' });
const key = `${participantId}::${suffix}`;
session.streams.set(key, {
stream,
filePath,
fileName,
participantId,
chunkCount: 0,
});
await updateManifest(session.dir, (m) => {
m.participants.push({
id: participantId,
displayName: displayName ?? null,
file: fileName,
joinedOffsetMs: joinedOffsetMs ?? 0,
leftOffsetMs: null,
});
return m;
});
return { ok: true, trackKey: key, fileName };
});
ipcMain.handle('recording-append', async (_event, payload) => {
const { sessionId, trackKey, chunk } = payload;
const session = recordingSessions.get(sessionId);
if (!session) return { ok: false, error: 'session not started' };
const entry = session.streams.get(trackKey);
if (!entry) return { ok: false, error: 'track not open' };
return new Promise((resolve) => {
entry.stream.write(Buffer.from(chunk), (err) => {
if (err) {
resolve({ ok: false, error: err.message });
return;
}
entry.chunkCount += 1;
if (entry.chunkCount % RECORDING_FSYNC_INTERVAL === 0) {
// Best-effort fsync on the underlying fd so that a
// power-cut after this return gives us up-to-date
// bytes on disk. The `fd` property is set once the
// write stream's 'open' event fires; ignore the
// call if it isn't ready yet.
const fd = entry.stream.fd;
if (typeof fd === 'number') {
fs.fsync(fd, () => {});
}
}
resolve({ ok: true });
});
});
});
ipcMain.handle('recording-close-track', async (_event, payload) => {
const { sessionId, trackKey, leftOffsetMs } = payload;
const session = recordingSessions.get(sessionId);
if (!session) return { ok: false, error: 'session not started' };
const entry = session.streams.get(trackKey);
if (!entry) return { ok: false, error: 'track not open' };
await new Promise((resolve) => entry.stream.end(resolve));
session.streams.delete(trackKey);
await updateManifest(session.dir, (m) => {
// Match the last participant row with this id that has
// no leftOffsetMs yet (handles rejoin entries).
for (let i = m.participants.length - 1; i >= 0; i--) {
if (
m.participants[i].id === entry.participantId &&
m.participants[i].file === entry.fileName &&
m.participants[i].leftOffsetMs == null
) {
m.participants[i].leftOffsetMs = leftOffsetMs ?? null;
break;
}
}
return m;
});
return { ok: true };
});
ipcMain.handle('recording-finalize', async (_event, payload) => {
const { sessionId, endedAt } = payload;
const session = recordingSessions.get(sessionId);
if (!session) return { ok: false, error: 'session not started' };
// Close any stragglers.
for (const [key, entry] of session.streams) {
await new Promise((resolve) => entry.stream.end(resolve));
session.streams.delete(key);
}
await updateManifest(session.dir, (m) => {
m.endedAt = endedAt ?? Date.now();
return m;
});
const result = { ok: true, dir: session.dir };
recordingSessions.delete(sessionId);
return result;
});
ipcMain.handle('recording-list-recoverable', async (_event, payload) => {
const { rootDir } = payload ?? {};
const root = rootDir || DEFAULT_RECORDING_ROOT;
try {
const entries = await fs.promises.readdir(root, { withFileTypes: true });
const out = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const sessionDir = path.join(root, entry.name);
const manifest = await readManifest(sessionDir);
if (!manifest) continue;
if (manifest.endedAt != null) continue;
// Only surface sessions that actually have at least
// one track file — empty dirs are noise.
const files = await fs.promises.readdir(sessionDir);
const tracks = files.filter((f) => f.endsWith('.webm'));
if (tracks.length === 0) continue;
out.push({
sessionId: manifest.sessionId,
sessionDir,
channelId: manifest.channelId,
channelName: manifest.channelName,
startedAt: manifest.startedAt,
participantCount: manifest.participants?.length ?? tracks.length,
trackCount: tracks.length,
});
}
// Newest first.
out.sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0));
return { ok: true, sessions: out };
} catch (err) {
if (err?.code === 'ENOENT') return { ok: true, sessions: [] };
return { ok: false, error: err?.message ?? 'unknown', sessions: [] };
}
});
ipcMain.handle('recording-recover-session', async (_event, payload) => {
// Caller already decided what to do: 'keep' stamps endedAt
// and leaves files in place; 'delete' removes the session
// directory entirely.
const { sessionDir, action } = payload;
if (!sessionDir) return { ok: false, error: 'missing sessionDir' };
if (action === 'delete') {
try {
await fs.promises.rm(sessionDir, { recursive: true, force: true });
return { ok: true };
} catch (err) {
return { ok: false, error: err?.message ?? 'delete failed' };
}
}
// Keep: stamp endedAt to the newest mtime among track files.
try {
const files = await fs.promises.readdir(sessionDir);
let newest = 0;
for (const f of files) {
if (!f.endsWith('.webm')) continue;
const st = await fs.promises.stat(path.join(sessionDir, f));
if (st.mtimeMs > newest) newest = st.mtimeMs;
}
await updateManifest(sessionDir, (m) => {
m.endedAt = newest || Date.now();
return m;
});
return { ok: true };
} catch (err) {
return { ok: false, error: err?.message ?? 'recover failed' };
}
});
// --- Auto-idle detection --- // --- Auto-idle detection ---
const IDLE_THRESHOLD_SECONDS = 300; // 5 minutes const IDLE_THRESHOLD_SECONDS = 300; // 5 minutes
let wasIdle = false; let wasIdle = false;

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/electron", "name": "@discord-clone/electron",
"private": true, "private": true,
"version": "1.0.50", "version": "1.0.60",
"description": "Brycord - Electron app", "description": "Brycord - Electron app",
"author": "Moyettes", "author": "Moyettes",
"type": "module", "type": "module",

View File

@@ -54,3 +54,21 @@ contextBridge.exposeInMainWorld('idleAPI', {
removeIdleStateListener: () => ipcRenderer.removeAllListeners('idle-state-changed'), removeIdleStateListener: () => ipcRenderer.removeAllListeners('idle-state-changed'),
getSystemIdleTime: () => ipcRenderer.invoke('get-system-idle-time'), getSystemIdleTime: () => ipcRenderer.invoke('get-system-idle-time'),
}); });
// Voice recording — per-participant audio capture that writes
// append-only WebM files to the user's chosen folder. See
// apps/electron/main.cjs for the main-process implementation.
contextBridge.exposeInMainWorld('recordingAPI', {
getDefaultFolder: () => ipcRenderer.invoke('recording-get-default-folder'),
pickFolder: () => ipcRenderer.invoke('recording-pick-folder'),
validateFolder: (dir) => ipcRenderer.invoke('recording-validate-folder', dir),
openFolder: (dir) => ipcRenderer.invoke('recording-open-folder', dir),
openSessionFolder: (payload) => ipcRenderer.invoke('recording-open-session-folder', payload),
startSession: (payload) => ipcRenderer.invoke('recording-start-session', payload),
openTrack: (payload) => ipcRenderer.invoke('recording-open-track', payload),
append: (payload) => ipcRenderer.invoke('recording-append', payload),
closeTrack: (payload) => ipcRenderer.invoke('recording-close-track', payload),
finalize: (payload) => ipcRenderer.invoke('recording-finalize', payload),
listRecoverable: (payload) => ipcRenderer.invoke('recording-list-recoverable', payload),
recoverSession: (payload) => ipcRenderer.invoke('recording-recover-session', payload),
});

View File

@@ -54,6 +54,20 @@ const electronPlatform = {
close: () => window.windowControls.close(), close: () => window.windowControls.close(),
flashFrame: () => window.windowControls.flashFrame(), flashFrame: () => window.windowControls.flashFrame(),
}, },
recording: {
getDefaultFolder: () => window.recordingAPI.getDefaultFolder(),
pickFolder: () => window.recordingAPI.pickFolder(),
validateFolder: (dir) => window.recordingAPI.validateFolder(dir),
openFolder: (dir) => window.recordingAPI.openFolder(dir),
openSessionFolder: (payload) => window.recordingAPI.openSessionFolder(payload),
startSession: (payload) => window.recordingAPI.startSession(payload),
openTrack: (payload) => window.recordingAPI.openTrack(payload),
append: (payload) => window.recordingAPI.append(payload),
closeTrack: (payload) => window.recordingAPI.closeTrack(payload),
finalize: (payload) => window.recordingAPI.finalize(payload),
listRecoverable: (payload) => window.recordingAPI.listRecoverable(payload),
recoverSession: (payload) => window.recordingAPI.recoverSession(payload),
},
updates: { updates: {
checkUpdate: () => window.updateAPI.checkFlatpakUpdate(), checkUpdate: () => window.updateAPI.checkFlatpakUpdate(),
}, },
@@ -65,6 +79,7 @@ const electronPlatform = {
hasNativeUpdates: true, hasNativeUpdates: true,
hasSearch: true, hasSearch: true,
hasSystemBars: false, hasSystemBars: false,
hasRecording: true,
}, },
}; };

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/web", "name": "@discord-clone/web",
"private": true, "private": true,
"version": "1.0.50", "version": "1.0.60",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -245,8 +245,18 @@ export const categories = action({
return { categories: [] }; return { categories: [] };
} }
// Klipy's actual shape (verified against /api/v1/.../gifs/categories):
// { result: true, data: { locale: "en_US", categories: [
// { category: "hello", query: "hello", preview_url: "..." },
// ...
// ]}}
// Older / parallel paths (data.data[] from the search+trending
// endpoints, or a flat data[] array) are accepted as fallbacks so
// a future upstream change doesn't silently break the picker.
const json = (await response.json()) as any; const json = (await response.json()) as any;
const items: any[] = Array.isArray(json?.data?.data) const items: any[] = Array.isArray(json?.data?.categories)
? json.data.categories
: Array.isArray(json?.data?.data)
? json.data.data ? json.data.data
: Array.isArray(json?.data) : Array.isArray(json?.data)
? json.data ? json.data
@@ -254,9 +264,15 @@ export const categories = action({
const categories: NormalizedCategory[] = items const categories: NormalizedCategory[] = items
.map((item) => ({ .map((item) => ({
name: String(item?.name ?? item?.title ?? ""), name: String(
image: String(item?.image ?? item?.preview ?? ""), item?.category ?? item?.name ?? item?.title ?? item?.query ?? "",
query: String(item?.query ?? item?.search_term ?? item?.name ?? ""), ),
image: String(
item?.preview_url ?? item?.image ?? item?.preview ?? "",
),
query: String(
item?.query ?? item?.search_term ?? item?.category ?? item?.name ?? "",
),
})) }))
.filter((c) => !!c.query); .filter((c) => !!c.query);

View File

@@ -34,6 +34,7 @@ const webPlatform = {
}, },
}, },
windowControls: null, windowControls: null,
recording: null,
updates: null, updates: null,
voiceService: null, voiceService: null,
systemBars: null, systemBars: null,
@@ -45,6 +46,7 @@ const webPlatform = {
hasSearch: true, hasSearch: true,
hasVoiceService: false, hasVoiceService: false,
hasSystemBars: false, hasSystemBars: false,
hasRecording: false,
}, },
}; };

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/shared", "name": "@discord-clone/shared",
"private": true, "private": true,
"version": "1.0.50", "version": "1.0.60",
"type": "module", "type": "module",
"main": "src/App.tsx", "main": "src/App.tsx",
"dependencies": { "dependencies": {

Binary file not shown.

View File

@@ -209,6 +209,19 @@
padding: 4px 12px; padding: 4px 12px;
} }
/* GIFs tab — the GifPicker owns its own `.searchRow` + `.body`
layout and needs to paint edge-to-edge so its
`--background-primary` body fills the picker surface. Strips the
`.grid` wrapper's padding so the hairline + body bleed to the
picker's border. */
.gridGifs {
padding: 0;
}
.pickerMobile .gridGifs {
padding: 0;
}
/* ── Collapsible section (desktop only) ───────────────────────── */ /* ── Collapsible section (desktop only) ───────────────────────── */
.section { .section {
margin-top: 4px; margin-top: 4px;

View File

@@ -134,6 +134,12 @@ export function EmojiPicker({
const [collapsed, setCollapsed] = useState<Set<string>>(new Set()); const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [hovered, setHovered] = useState<EmojiPickerValue | null>(null); const [hovered, setHovered] = useState<EmojiPickerValue | null>(null);
const [recents, setRecents] = useState<EmojiPickerValue[]>(() => loadRecents()); const [recents, setRecents] = useState<EmojiPickerValue[]>(() => loadRecents());
// Saved-media filter chips. `all` shows every row; the others
// filter by the `kind` string that `api.savedMedia.save` writes
// (first half of the MIME type: image / video / audio).
const [mediaFilter, setMediaFilter] = useState<
'all' | 'image' | 'video' | 'audio'
>('all');
// Saved-media library — only fetched when the Media tab is open // Saved-media library — only fetched when the Media tab is open
// to keep the picker cheap during normal emoji use. // to keep the picker cheap during normal emoji use.
@@ -393,7 +399,7 @@ export function EmojiPicker({
return ( return (
<div className={`${styles.picker} ${styles.pickerMobile}`}> <div className={`${styles.picker} ${styles.pickerMobile}`}>
<div className={styles.searchBar}> <div className={styles.searchBar}>
<MagnifyingGlass size={16} className={styles.searchIcon} /> <MagnifyingGlass size={16} weight="regular" className={styles.searchIcon} />
<input <input
ref={searchRef} ref={searchRef}
className={styles.searchInput} className={styles.searchInput}
@@ -523,16 +529,24 @@ export function EmojiPicker({
</div> </div>
{activeTab !== 'gifs' && ( {activeTab !== 'gifs' && (
<div className={styles.searchRow}> <div
className={`${styles.searchRow} ${activeTab === 'media' ? styles.searchRowFlush : ''}`}
>
<div className={styles.searchBar}> <div className={styles.searchBar}>
<MagnifyingGlass size={16} className={styles.searchIcon} /> <MagnifyingGlass size={16} weight="regular" className={styles.searchIcon} />
<input <input
ref={searchRef} ref={searchRef}
className={styles.searchInput} className={styles.searchInput}
placeholder={activeTab === 'emojis' ? 'Search emoji' : 'Coming soon'} placeholder={
activeTab === 'emojis'
? 'Search emoji'
: activeTab === 'media'
? 'Search media'
: 'Coming soon'
}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
disabled={activeTab !== 'emojis'} disabled={activeTab !== 'emojis' && activeTab !== 'media'}
/> />
{search && ( {search && (
<button type="button" className={styles.searchClear} onClick={() => setSearch('')}> <button type="button" className={styles.searchClear} onClick={() => setSearch('')}>
@@ -543,7 +557,34 @@ export function EmojiPicker({
</div> </div>
)} )}
<div className={styles.main}> {activeTab === 'media' && (
<div className={styles.filterChips}>
{(
[
{ id: 'all', label: 'All' },
{ id: 'image', label: 'Images' },
{ id: 'video', label: 'Videos' },
{ id: 'audio', label: 'Audio' },
] as const
).map((chip) => {
const isActive = mediaFilter === chip.id;
return (
<button
key={chip.id}
type="button"
className={`${styles.filterChip} ${isActive ? styles.filterChipActive : ''}`}
onClick={() => setMediaFilter(chip.id)}
>
{chip.label}
</button>
);
})}
</div>
)}
<div
className={`${styles.main} ${activeTab === 'media' ? styles.mainMedia : ''}`}
>
{activeTab === 'emojis' && ( {activeTab === 'emojis' && (
<div className={styles.sideBar}> <div className={styles.sideBar}>
{customEmojis.length > 0 && {customEmojis.length > 0 &&
@@ -554,7 +595,11 @@ export function EmojiPicker({
</div> </div>
)} )}
<div className={styles.grid} ref={gridRef} onScroll={handleScroll}> <div
className={`${styles.grid} ${activeTab === 'gifs' ? styles.gridGifs : ''}`}
ref={gridRef}
onScroll={handleScroll}
>
{activeTab === 'gifs' ? ( {activeTab === 'gifs' ? (
<GifPicker <GifPicker
onSelectGif={(url) => { onSelectGif={(url) => {
@@ -563,12 +608,38 @@ export function EmojiPicker({
}} }}
/> />
) : activeTab === 'media' ? ( ) : activeTab === 'media' ? (
savedMedia.length === 0 ? ( (() => {
// Filter the saved library by the active chip + the
// search box (case-insensitive filename substring).
const q = search.trim().toLowerCase();
const filteredSaved = (savedMedia as any[]).filter((item) => {
if (
mediaFilter !== 'all' &&
(item.kind ?? '') !== mediaFilter
) {
return false;
}
if (q && !(item.filename ?? '').toLowerCase().includes(q)) {
return false;
}
return true;
});
if (savedMedia.length === 0) {
return (
<div className={styles.comingSoon}> <div className={styles.comingSoon}>
Nothing saved yet. Star an attachment to bookmark it Nothing saved yet. Star an attachment to bookmark it
here for quick re-sharing. here for quick re-sharing.
</div> </div>
) : ( );
}
if (filteredSaved.length === 0) {
return (
<div className={styles.comingSoon}>
No saved media match your filter.
</div>
);
}
return (
<div <div
style={{ style={{
display: 'grid', display: 'grid',
@@ -577,7 +648,7 @@ export function EmojiPicker({
padding: 8, padding: 8,
}} }}
> >
{savedMedia.map((item: any) => { {filteredSaved.map((item: any) => {
const isImage = item.kind === 'image'; const isImage = item.kind === 'image';
const isVideo = item.kind === 'video'; const isVideo = item.kind === 'video';
return ( return (
@@ -632,7 +703,8 @@ export function EmojiPicker({
); );
})} })}
</div> </div>
) );
})()
) : activeTab !== 'emojis' ? ( ) : activeTab !== 'emojis' ? (
<div className={styles.comingSoon}> <div className={styles.comingSoon}>
{EXPRESSION_TABS.find((t) => t.key === activeTab)?.label} are coming soon. {EXPRESSION_TABS.find((t) => t.key === activeTab)?.label} are coming soon.

View File

@@ -7,8 +7,31 @@
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
min-height: 0; min-height: 0;
box-sizing: border-box;
}
/* Matches the emoji picker's search row — full-width slot with a
bottom hairline, holding a rounded `.searchBar` pill inside. */
.searchRow {
padding: 10px 12px;
border-bottom: 1px solid var(--background-modifier-hover);
flex-shrink: 0;
}
/* Body wrapper — everything below the search row. Uses
`--background-primary` so the categories / featured / grid
content reads against a lighter surface instead of the picker's
tertiary base. */
.body {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
background-color: var(--background-primary);
overflow: hidden;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -23,6 +46,14 @@
flex-shrink: 0; flex-shrink: 0;
} }
/* Kill any :focus-within highlight that global styles might paint
on the search bar wrapper when the input gains focus. */
.searchBar:focus-within {
outline: none;
box-shadow: none;
border-color: var(--background-modifier-accent);
}
.searchIcon { .searchIcon {
color: var(--text-tertiary); color: var(--text-tertiary);
flex-shrink: 0; flex-shrink: 0;
@@ -39,6 +70,15 @@
min-width: 0; min-width: 0;
} }
/* Some UA stylesheets still paint a focus ring via `:focus-visible`
even with `outline: none` on the base rule. Nuke both explicitly. */
.searchInput:focus,
.searchInput:focus-visible {
outline: none;
box-shadow: none;
border: none;
}
.featuredRow { .featuredRow {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
@@ -87,6 +127,83 @@
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
/* ── Category grid (home view) ─────────────────────────────────
Discord-style 2-column tiles, each backed by a still frame of
a real GIF from that category. A dark gradient overlay keeps
the label readable against anything — bright cartoons, night
scenes, black-and-white clips. */
.categoriesGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: max-content;
gap: 8px;
overflow-y: auto;
min-height: 0;
flex: 1;
padding-bottom: 4px;
scrollbar-width: none;
-ms-overflow-style: none;
}
.categoriesGrid::-webkit-scrollbar {
display: none;
}
.categoryTile {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 10px 12px;
aspect-ratio: 16 / 9;
width: 100%;
min-width: 0;
min-height: 0;
border-radius: 8px;
border: none;
background-color: var(--background-tertiary);
color: #ffffff;
cursor: pointer;
overflow: hidden;
transition: transform 0.12s ease;
}
.categoryTile:hover {
transform: translateY(-1px);
}
.categoryTileImage {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
pointer-events: none;
}
.categoryTile::after {
/* Uniformly dark scrim so the centred label reads cleanly
against any preview — bright cartoons, night scenes,
black-and-white clips. */
content: '';
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.55);
pointer-events: none;
}
.categoryTileLabel {
position: relative;
font-size: 15px;
font-weight: 700;
letter-spacing: 0.01em;
text-transform: capitalize;
text-align: center;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.7);
z-index: 1;
}
.subHeaderRow { .subHeaderRow {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -33,6 +33,12 @@ interface GifResult {
height?: number; height?: number;
} }
interface GifCategory {
name: string;
image: string;
query: string;
}
interface GifPickerProps { interface GifPickerProps {
onSelectGif: (url: string) => void; onSelectGif: (url: string) => void;
} }
@@ -65,6 +71,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
const [tab, setTab] = useState<Tab>('home'); const [tab, setTab] = useState<Tab>('home');
const [trending, setTrending] = useState<GifResult[]>([]); const [trending, setTrending] = useState<GifResult[]>([]);
const [searchResults, setSearchResults] = useState<GifResult[]>([]); const [searchResults, setSearchResults] = useState<GifResult[]>([]);
const [categories, setCategories] = useState<GifCategory[]>([]);
const [favorites, setFavorites] = useState<GifResult[]>(() => const [favorites, setFavorites] = useState<GifResult[]>(() =>
loadFavorites(), loadFavorites(),
); );
@@ -73,19 +80,26 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
const searchAction = useAction(api.gifs.search); const searchAction = useAction(api.gifs.search);
const trendingAction = useAction(api.gifs.trending); const trendingAction = useAction(api.gifs.trending);
const categoriesAction = useAction(api.gifs.categories);
// Load trending feed once when the picker mounts. The result is // Load trending feed + categories once when the picker mounts.
// cached for the rest of the session — no need to refetch every // Both are cached server-side (convex/gifs.ts in-memory TTL) so
// time the user toggles back to the home tab. // toggling tabs or re-opening the picker doesn't re-hit Klipy.
// The two requests run in parallel so first paint shows the
// category chips even if trending is still loading.
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
(async () => { (async () => {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const res: any = await trendingAction({ limit: 24 }); const [trendingRes, categoriesRes] = await Promise.all([
trendingAction({ limit: 24 }),
categoriesAction({}),
]);
if (cancelled) return; if (cancelled) return;
setTrending(res?.results ?? []); setTrending((trendingRes as any)?.results ?? []);
setCategories((categoriesRes as any)?.categories ?? []);
} catch (err: any) { } catch (err: any) {
if (cancelled) return; if (cancelled) return;
setError(err?.message ?? 'Failed to load GIFs.'); setError(err?.message ?? 'Failed to load GIFs.');
@@ -96,7 +110,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [trendingAction]); }, [trendingAction, categoriesAction]);
// Debounced search — fires 350ms after the last keystroke so we // Debounced search — fires 350ms after the last keystroke so we
// don't hammer the upstream API on every character. // don't hammer the upstream API on every character.
@@ -142,20 +156,31 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
// Decide which list to render. Searching always wins — once the // Decide which list to render. Searching always wins — once the
// user types anything, we show the search results regardless of // user types anything, we show the search results regardless of
// which featured tab was active. // which featured tab was active. `home` is now a distinct surface
// (featured row + categories grid) and doesn't use `displayList`
// at all.
const isSearching = search.trim().length > 0; const isSearching = search.trim().length > 0;
const displayList: GifResult[] = useMemo(() => { const displayList: GifResult[] = useMemo(() => {
if (isSearching) return searchResults; if (isSearching) return searchResults;
if (tab === 'favorites') return favorites; if (tab === 'favorites') return favorites;
if (tab === 'trending') return trending; if (tab === 'trending') return trending;
// Home → trending return [];
return trending;
}, [isSearching, searchResults, tab, favorites, trending]); }, [isSearching, searchResults, tab, favorites, trending]);
const showFeaturedRow = !isSearching && tab === 'home'; const showFeaturedRow = !isSearching && tab === 'home';
const showCategories = !isSearching && tab === 'home';
const handlePickCategory = (category: GifCategory) => {
// Piping the category name into the search box lets the
// existing debounced search effect do the work — same code
// path as typing "happy birthday" by hand, so results are
// cached and consistent.
setSearch(category.query || category.name);
};
return ( return (
<div className={styles.root}> <div className={styles.root}>
<div className={styles.searchRow}>
<div className={styles.searchBar}> <div className={styles.searchBar}>
<MagnifyingGlass <MagnifyingGlass
size={16} size={16}
@@ -165,13 +190,15 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
<input <input
type="text" type="text"
className={styles.searchInput} className={styles.searchInput}
placeholder="Search Tenor" placeholder="Search Klipy"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
autoFocus autoFocus
/> />
</div> </div>
</div>
<div className={styles.body}>
{showFeaturedRow && ( {showFeaturedRow && (
<div className={styles.featuredRow}> <div className={styles.featuredRow}>
<button <button
@@ -208,7 +235,38 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
</div> </div>
)} )}
{loading && displayList.length === 0 ? ( {showCategories ? (
loading && categories.length === 0 ? (
<div className={styles.statusMessage}>Loading categories</div>
) : error && categories.length === 0 ? (
<div className={styles.statusMessageError}>{error}</div>
) : categories.length === 0 ? (
<div className={styles.statusMessage}>No categories to show.</div>
) : (
<div className={styles.categoriesGrid}>
{categories.map((cat) => (
<button
key={`${cat.name}-${cat.query}`}
type="button"
className={styles.categoryTile}
onClick={() => handlePickCategory(cat)}
title={cat.name}
>
{cat.image && (
<img
src={cat.image}
alt=""
className={styles.categoryTileImage}
loading="lazy"
draggable={false}
/>
)}
<span className={styles.categoryTileLabel}>{cat.name}</span>
</button>
))}
</div>
)
) : loading && displayList.length === 0 ? (
<div className={styles.statusMessage}>Loading GIFs</div> <div className={styles.statusMessage}>Loading GIFs</div>
) : error ? ( ) : error ? (
<div className={styles.statusMessageError}>{error}</div> <div className={styles.statusMessageError}>{error}</div>
@@ -256,5 +314,6 @@ export function GifPicker({ onSelectGif }: GifPickerProps) {
</div> </div>
)} )}
</div> </div>
</div>
); );
} }

View File

@@ -14,6 +14,20 @@
margin-top: 4px; margin-top: 4px;
} }
/* Direct media embeds (GIFs, raw videos, raw images) don't need
the brand-coloured left bar or the card chrome — the media IS
the content. Strip the card so it reads as inline media. */
.embedBare {
background: transparent;
border: none;
border-left: none;
padding: 0;
}
.directGif {
margin-top: 4px;
}
.grid { .grid {
overflow: hidden; overflow: hidden;
padding: 12px 12px 14px 12px; padding: 12px 12px 14px 12px;

View File

@@ -3,6 +3,7 @@ import { ArrowSquareOut, Play } from '@phosphor-icons/react';
import { useAction } from 'convex/react'; import { useAction } from 'convex/react';
import { api } from '../../../../../convex/_generated/api'; import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform'; import { usePlatform } from '../../platform';
import { PausedGif } from './PausedGif';
import styles from './LinkEmbed.module.css'; import styles from './LinkEmbed.module.css';
interface UrlPreview { interface UrlPreview {
@@ -125,7 +126,15 @@ function useUrlPreview(url: string): UrlPreview | null {
return preview; return preview;
} }
function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image' }) { function DirectMediaEmbed({
url,
type,
onOpenGif,
}: {
url: string;
type: 'video' | 'image';
onOpenGif?: (url: string) => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null); const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
@@ -138,7 +147,7 @@ function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image'
}; };
return ( return (
<div className={styles.embed}> <div className={`${styles.embed} ${styles.embedBare}`}>
<div className={styles.directVideoWrapper}> <div className={styles.directVideoWrapper}>
<video <video
ref={videoRef} ref={videoRef}
@@ -172,8 +181,20 @@ function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image'
); );
} }
// GIFs (and other direct images) render through PausedGif so
// the idle state freezes on the first frame and only animates
// on hover. Click opens a fullscreen viewer via `onOpenGif` —
// the parent MessageGroup owns the lightbox state and reuses
// its existing ImageLightbox.
const isGif = /\.gif(\?|#|$)/i.test(url);
if (isGif) {
return ( return (
<div className={styles.embed}> <PausedGif url={url} onOpen={onOpenGif} className={styles.directGif} />
);
}
return (
<div className={`${styles.embed} ${styles.embedBare}`}>
<a href={url} target="_blank" rel="noopener noreferrer"> <a href={url} target="_blank" rel="noopener noreferrer">
<img <img
className={styles.directImage} className={styles.directImage}
@@ -188,12 +209,16 @@ function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image'
interface LinkEmbedProps { interface LinkEmbedProps {
url: string; url: string;
/** Called when the user clicks a paused GIF preview. The parent
* owns the fullscreen viewer state (MessageGroup reuses its
* existing ImageLightbox). Ignored for non-GIF embeds. */
onOpenGif?: (url: string) => void;
} }
export function LinkEmbed({ url }: LinkEmbedProps) { export function LinkEmbed({ url, onOpenGif }: LinkEmbedProps) {
const directType = isDirectMedia(url); const directType = isDirectMedia(url);
if (directType) { if (directType) {
return <DirectMediaEmbed url={url} type={directType} />; return <DirectMediaEmbed url={url} type={directType} onOpenGif={onOpenGif} />;
} }
const preview = useUrlPreview(url); const preview = useUrlPreview(url);

View File

@@ -309,9 +309,9 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
padding: 2px 8px; padding: 0.125rem 0.375rem;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background-color: var(--background-secondary); background-color: color-mix(in srgb, var(--brand-primary) 36%, var(--background-secondary) 64%);
border: 1px solid transparent; border: 1px solid transparent;
cursor: pointer; cursor: pointer;
font-size: 0.875rem; font-size: 0.875rem;
@@ -329,9 +329,21 @@
} }
.reactionCount { .reactionCount {
font-size: 0.75rem; font-size: 16px;
color: var(--text-secondary); color: var(--text-secondary);
font-weight: 500; font-weight: 600;
}
/* Emoji glyph inside a reaction chip — shared between the custom
<img> path and the unicode TwemojiImg path so both chip styles
have identical box sizing. `rem` units scale with the user's
font size instead of hard-locking to 16px. */
.reactionEmoji {
width: 1.25rem;
height: 1.25rem;
margin: 0.125rem 0;
object-fit: contain;
vertical-align: middle;
} }
/* Custom MSC2545 emoji reaction — rendered as an inline image in the /* Custom MSC2545 emoji reaction — rendered as an inline image in the

View File

@@ -41,9 +41,43 @@ function extractUrls(text: string): string[] {
return Array.from(new Set(cleaned)); return Array.from(new Set(cleaned));
} }
/** True when a message body is entirely made up of one or more GIF
* URLs plus whitespace — i.e. the user posted a GIF from the
* picker and there's nothing worth showing as text. The render
* path hides the <MessageContent> block in that case so only the
* embedded preview appears. */
function isGifOnlyContent(text: string): boolean {
const urls = extractUrls(text);
if (urls.length === 0) return false;
if (!urls.every((u) => /\.gif(\?|#|$)/i.test(u))) return false;
let remainder = text;
for (const u of urls) {
remainder = remainder.split(u).join('');
}
return remainder.trim().length === 0;
}
/**
* Discord-style relative timestamp:
* - Same calendar day → `Today at 7:08 PM`
* - Previous calendar day → `Yesterday at 7:08 PM`
* - Anything else → `4/11/2026, 7:08 PM`
*/
function formatTime(ts: number): string { function formatTime(ts: number): string {
const date = new Date(ts); const date = new Date(ts);
return date.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }); const now = new Date();
const time = date.toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
});
const isToday = date.toDateString() === now.toDateString();
if (isToday) return `Today at ${time}`;
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
if (date.toDateString() === yesterday.toDateString()) {
return `Yesterday at ${time}`;
}
return `${date.toLocaleDateString()}, ${time}`;
} }
function formatFullTime(ts: number): string { function formatFullTime(ts: number): string {
@@ -96,12 +130,17 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
} | null>(null); } | null>(null);
// Image lightbox state — tracks the decrypted blob URL + the // Image lightbox state — tracks the decrypted blob URL + the
// full attachment metadata of the image that was clicked so the // optional attachment metadata of the image that was clicked so
// lightbox info card can render filename / size / dimensions. // the lightbox info card can render filename / size / dimensions.
// Null means the lightbox is closed. // Null means the lightbox is closed. For inline GIFs posted via
// a URL (no encrypted attachment), the metadata is absent — the
// lightbox gracefully hides the star / detail chrome in that
// case.
const [lightboxItem, setLightboxItem] = useState<{ const [lightboxItem, setLightboxItem] = useState<{
src: string; src: string;
attachment: AttachmentMetadata; attachment?: AttachmentMetadata;
filename?: string;
mimeType?: string;
} | null>(null); } | null>(null);
// Right-click context menu state. When set, the MessageActionBar for // Right-click context menu state. When set, the MessageActionBar for
@@ -437,7 +476,7 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
})} })}
</span> </span>
)} )}
{msg.content && ( {msg.content && !isGifOnlyContent(msg.content) && (
<div className={styles.text}> <div className={styles.text}>
<MessageContent <MessageContent
content={msg.content} content={msg.content}
@@ -453,7 +492,17 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
extractUrls(msg.content) extractUrls(msg.content)
.slice(0, 3) .slice(0, 3)
.map((url, idx) => ( .map((url, idx) => (
<LinkEmbed key={`embed-${idx}-${url}`} url={url} /> <LinkEmbed
key={`embed-${idx}-${url}`}
url={url}
onOpenGif={(gifUrl) =>
setLightboxItem({
src: gifUrl,
filename: gifUrl.split('/').pop() || 'gif',
mimeType: 'image/gif',
})
}
/>
))} ))}
{msg.attachments.length > 0 && ( {msg.attachments.length > 0 && (
<div className={styles.attachments}> <div className={styles.attachments}>
@@ -578,17 +627,13 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
alt={`:${r.emoji}:`} alt={`:${r.emoji}:`}
title={`:${r.emoji}:`} title={`:${r.emoji}:`}
draggable={false} draggable={false}
style={{ className={styles.reactionEmoji}
width: 16,
height: 16,
objectFit: 'contain',
verticalAlign: 'middle',
}}
/> />
) : ( ) : (
<TwemojiImg <TwemojiImg
emoji={resolveReactionKeyToUnicode(r.emoji)} emoji={resolveReactionKeyToUnicode(r.emoji)}
size={16} size={16}
className={styles.reactionEmoji}
/> />
)} )}
<span className={styles.reactionCount}>{r.count}</span> <span className={styles.reactionCount}>{r.count}</span>
@@ -623,11 +668,15 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
<ImageLightbox <ImageLightbox
isOpen={!!lightboxItem} isOpen={!!lightboxItem}
src={lightboxItem?.src ?? ''} src={lightboxItem?.src ?? ''}
filename={lightboxItem?.attachment.filename} filename={
mimeType={lightboxItem?.attachment.mimeType} lightboxItem?.attachment?.filename ?? lightboxItem?.filename
size={lightboxItem?.attachment.size} }
width={lightboxItem?.attachment.width} mimeType={
height={lightboxItem?.attachment.height} lightboxItem?.attachment?.mimeType ?? lightboxItem?.mimeType
}
size={lightboxItem?.attachment?.size}
width={lightboxItem?.attachment?.width}
height={lightboxItem?.attachment?.height}
attachment={lightboxItem?.attachment} attachment={lightboxItem?.attachment}
onClose={() => setLightboxItem(null)} onClose={() => setLightboxItem(null)}
/> />

View File

@@ -107,6 +107,42 @@
background-color: var(--background-modifier-accent); background-color: var(--background-modifier-accent);
} }
/* ── New-messages divider ─────────────────────────────────────────
Red hairline with a "NEW" pill on the right edge. Rendered between
the last message the user has already read and the first unread
one. The divider anchor is snapshotted on channel open so it stays
put during the session — live new messages arriving while the user
is reading don't push it further down the list. */
.newDivider {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0.75rem 1rem 0.5rem;
color: var(--status-danger, #ed4245);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
user-select: none;
}
.newDivider::before {
content: '';
flex: 1;
height: 1px;
background-color: var(--status-danger, #ed4245);
}
.newDividerBadge {
padding: 2px 6px;
border-radius: 4px;
background-color: var(--status-danger, #ed4245);
color: #ffffff;
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.04em;
}
/* ── Jump highlight ─────────────────────────────────────────────── /* ── Jump highlight ───────────────────────────────────────────────
Pulsed background applied to a message row by the Pulsed background applied to a message row by the
`brycord:scroll-to-message` listener. Class is added globally `brycord:scroll-to-message` listener. Class is added globally

View File

@@ -1,4 +1,4 @@
import { usePaginatedQuery, useQuery } from 'convex/react'; import { useMutation, usePaginatedQuery, useQuery } from 'convex/react';
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { api } from '../../../../../convex/_generated/api'; import { api } from '../../../../../convex/_generated/api';
import { usePlatform } from '../../platform'; import { usePlatform } from '../../platform';
@@ -97,6 +97,21 @@ function DayDivider({ timestamp }: { timestamp: number }) {
); );
} }
/**
* Red "NEW" line inserted between the last message the viewer has
* already read and the first unseen one. Anchored to a snapshot of
* `lastReadTimestamp` taken when the channel opened, so live new
* messages arriving during the session don't push the divider
* further down.
*/
function NewMessagesDivider() {
return (
<div className={styles.newDivider} role="separator">
<span className={styles.newDividerBadge}>New</span>
</div>
);
}
export function Messages({ channelId, onReply }: MessagesProps) { export function Messages({ channelId, onReply }: MessagesProps) {
const { crypto } = usePlatform(); const { crypto } = usePlatform();
const scrollerRef = useRef<HTMLDivElement>(null); const scrollerRef = useRef<HTMLDivElement>(null);
@@ -124,6 +139,99 @@ export function Messages({ channelId, onReply }: MessagesProps) {
userId ? { userId: userId as any } : 'skip', userId ? { userId: userId as any } : 'skip',
); );
// ── Read-state + "NEW" divider plumbing ─────────────────────────
//
// `readState` is the server's live record of the latest message
// timestamp this user has acknowledged for the current channel.
// `readSnapshot` captures the value at the time the channel was
// opened so the divider stays anchored in place even after we
// flush a later `markRead` mutation during the session. Stored
// as state (not a ref) so the first render after the query
// resolves picks it up deterministically.
const readState = useQuery(
api.readState.getReadState,
userId && channelId
? { userId: userId as any, channelId: channelId as any }
: 'skip',
);
const markRead = useMutation(api.readState.markRead);
const [readSnapshot, setReadSnapshot] = useState<{
channelId: string;
lastRead: number;
} | null>(null);
// Tracks the freshest timestamp we've observed in this channel so
// the mark-read flush has something to send. Pure ref — updates
// should never trigger a re-render.
const latestSeenTimestampRef = useRef<number>(0);
// Debounce timer for batched mark-read flushes.
const markReadTimerRef = useRef<number | null>(null);
// Reset the snapshot whenever the user changes channels.
useLayoutEffect(() => {
setReadSnapshot(null);
latestSeenTimestampRef.current = 0;
if (markReadTimerRef.current !== null) {
window.clearTimeout(markReadTimerRef.current);
markReadTimerRef.current = null;
}
}, [channelId]);
// First time a non-null `readState` arrives for the current
// channel, lock it in as the divider anchor. `null` (no stored
// read state yet — brand-new channel) is treated as "0" so the
// divider appears the moment anyone posts.
useEffect(() => {
if (!channelId) return;
if (readSnapshot?.channelId === channelId) return;
if (readState === undefined) return;
setReadSnapshot({
channelId,
lastRead: readState?.lastReadTimestamp ?? 0,
});
}, [readState, channelId, readSnapshot]);
/** Fire-and-forget mark-read flush. Gated by:
* - an authenticated user
* - a channel loaded
* - the window being visible (otherwise we keep the snapshot
* and the NEW line so the user sees it when they return)
* - the user being pinned to the bottom of the scroller
* - the server's stored timestamp being strictly older than
* the freshest message we've observed */
const flushMarkRead = useCallback(() => {
if (!userId || !channelId) return;
if (
typeof document !== 'undefined' &&
document.visibilityState === 'hidden'
) {
return;
}
if (!pinnedRef.current) return;
const ts = latestSeenTimestampRef.current;
if (!ts) return;
const serverTs = readState?.lastReadTimestamp ?? 0;
if (ts <= serverTs) return;
void markRead({
userId: userId as any,
channelId: channelId as any,
lastReadTimestamp: ts,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userId, channelId, readState, markRead]);
const scheduleMarkRead = useCallback(() => {
if (markReadTimerRef.current !== null) {
window.clearTimeout(markReadTimerRef.current);
}
// 600ms debounce — batches bursts of new messages into a
// single mutation without feeling laggy to users who watch
// the sidebar unread dot.
markReadTimerRef.current = window.setTimeout(() => {
markReadTimerRef.current = null;
flushMarkRead();
}, 600);
}, [flushMarkRead]);
// Walk every bundle we have, decrypt the ones tagged for this // Walk every bundle we have, decrypt the ones tagged for this
// channel, and build a {version → keyHex} map. A single bundle's // channel, and build a {version → keyHex} map. A single bundle's
// plaintext is a JSON object mapping channelId → keyHex (legacy // plaintext is a JSON object mapping channelId → keyHex (legacy
@@ -683,6 +791,58 @@ export function Messages({ channelId, onReply }: MessagesProps) {
return list; return list;
}, [groups, pollsInChannel]); }, [groups, pollsInChannel]);
// Whenever the timeline grows past the freshest timestamp we've
// seen, update the ref and schedule a debounced mark-read. The
// ref-only update doesn't cause re-renders — it just feeds the
// mark-read flush with an up-to-date target.
useEffect(() => {
if (items.length === 0) return;
const newest = items[items.length - 1].ts;
if (newest > latestSeenTimestampRef.current) {
latestSeenTimestampRef.current = newest;
scheduleMarkRead();
}
}, [items, scheduleMarkRead]);
// Window visibility → when the tab comes back into focus, flush
// any pending mark-read so the sidebar dot disappears without
// needing a new message to land.
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === 'visible') {
scheduleMarkRead();
}
};
document.addEventListener('visibilitychange', onVisibility);
window.addEventListener('focus', onVisibility);
return () => {
document.removeEventListener('visibilitychange', onVisibility);
window.removeEventListener('focus', onVisibility);
};
}, [scheduleMarkRead]);
// Channel unmount (navigated elsewhere / logged out) → fire a
// final mark-read with whatever the latest observed timestamp
// is, bypassing the debounce. Matches Fluxer's "leaving a
// channel marks it read" UX so the sidebar dot doesn't linger.
useEffect(() => {
return () => {
if (markReadTimerRef.current !== null) {
window.clearTimeout(markReadTimerRef.current);
markReadTimerRef.current = null;
}
if (!userId || !channelId) return;
const ts = latestSeenTimestampRef.current;
if (!ts) return;
void markRead({
userId: userId as any,
channelId: channelId as any,
lastReadTimestamp: ts,
});
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelId]);
return ( return (
<div className={styles.container} ref={scrollerRef} onScroll={handleScroll}> <div className={styles.container} ref={scrollerRef} onScroll={handleScroll}>
<div className={styles.scroller}> <div className={styles.scroller}>
@@ -711,17 +871,48 @@ export function Messages({ channelId, onReply }: MessagesProps) {
)} )}
{(() => { {(() => {
// Walk the timeline once, inserting a `DayDivider` // Walk the timeline once, inserting a `DayDivider`
// whenever the calendar day changes. `lastTs` tracks // whenever the calendar day changes and a single
// the most-recent rendered item so two consecutive // `NewMessagesDivider` before the first item whose
// items on the same day skip the divider. // timestamp is strictly greater than the snapshot
// of `lastReadTimestamp` taken when the channel
// opened. `lastTs` tracks the most-recent rendered
// item so two consecutive items on the same day
// skip the date divider.
//
// The NEW line never shows for the viewer's own
// messages — if you're the one that sent it, you
// obviously already "saw" it. A message group
// authored entirely by the current user is skipped
// when looking for the divider boundary, so the
// line stays anchored at the first message from
// somebody else.
let lastTs: number | null = null; let lastTs: number | null = null;
const out: React.ReactNode[] = []; const out: React.ReactNode[] = [];
const snapshot =
readSnapshot?.channelId === channelId
? readSnapshot.lastRead
: null;
let newLinePlaced = snapshot === null;
const isOwnGroup = (item: TimelineItem): boolean => {
if (item.kind !== 'group') return false;
if (!userId) return false;
return item.group.every((m) => m.senderId === userId);
};
for (const item of items) { for (const item of items) {
if (lastTs === null || !isSameDay(lastTs, item.ts)) { if (lastTs === null || !isSameDay(lastTs, item.ts)) {
out.push( out.push(
<DayDivider key={`day-${item.ts}`} timestamp={item.ts} />, <DayDivider key={`day-${item.ts}`} timestamp={item.ts} />,
); );
} }
if (
!newLinePlaced &&
snapshot !== null &&
item.ts > snapshot &&
!isOwnGroup(item)
) {
out.push(<NewMessagesDivider key={`new-${item.key}`} />);
newLinePlaced = true;
}
lastTs = item.ts; lastTs = item.ts;
if (item.kind === 'group') { if (item.kind === 'group') {
out.push( out.push(

View File

@@ -0,0 +1,41 @@
.wrapper {
position: relative;
display: inline-block;
max-width: 400px;
max-height: 300px;
border-radius: 8px;
overflow: hidden;
cursor: pointer;
background: var(--background-tertiary);
line-height: 0;
}
.canvas,
.img {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
transition: opacity 0.12s ease;
}
.img {
position: absolute;
inset: 0;
}
.badge {
position: absolute;
top: 6px;
left: 6px;
padding: 2px 6px;
border-radius: 4px;
background: rgba(0, 0, 0, 0.7);
color: #ffffff;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
pointer-events: none;
line-height: 1.2;
}

View File

@@ -0,0 +1,123 @@
/**
* PausedGif — inline GIF preview that stays paused by default and
* only animates while the mouse is over it. Clicking opens a
* fullscreen viewer (handled by the parent via `onOpen`) where the
* browser plays the GIF normally.
*
* Trick: the browser decodes GIFs natively whenever an <img> is
* visible, and there's no way to "pause" a decoded GIF. So we load
* the GIF into a HTMLImageElement, snapshot frame 0 onto a
* <canvas>, and show the canvas in the idle state. On hover we
* swap to the live <img> and the GIF plays. On mouse-leave we flip
* back to the canvas so it resets to the first frame.
*/
import { useEffect, useRef, useState } from 'react';
import styles from './PausedGif.module.css';
interface PausedGifProps {
url: string;
className?: string;
onOpen?: (url: string) => void;
}
export function PausedGif({ url, className, onOpen }: PausedGifProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [loaded, setLoaded] = useState(false);
const [dims, setDims] = useState<{ w: number; h: number } | null>(null);
const [hovered, setHovered] = useState(false);
// `imgKey` is bumped every time we need to re-mount the live
// <img> so the GIF starts over from frame 0 on each hover.
// Without this, rapid hover-in / hover-out cycles would pick up
// wherever the decoder left off.
const [imgKey, setImgKey] = useState(0);
useEffect(() => {
let cancelled = false;
setLoaded(false);
setDims(null);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
if (cancelled) return;
const w = img.naturalWidth || 400;
const h = img.naturalHeight || 300;
setDims({ w, h });
const canvas = canvasRef.current;
if (canvas) {
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
if (ctx) {
try {
ctx.drawImage(img, 0, 0, w, h);
} catch {
// Cross-origin canvas taint — fall through to
// the img-only path below. The user still sees
// the GIF, it just won't be paused-by-default.
}
}
}
setLoaded(true);
};
img.onerror = () => {
if (cancelled) return;
setLoaded(true); // Let the <img> path show a broken image
};
img.src = url;
return () => {
cancelled = true;
};
}, [url]);
const handleClick = (e: React.MouseEvent) => {
if (onOpen) {
e.preventDefault();
e.stopPropagation();
onOpen(url);
}
};
const handleMouseEnter = () => {
setHovered(true);
setImgKey((k) => k + 1);
};
const handleMouseLeave = () => setHovered(false);
return (
<div
className={`${styles.wrapper} ${className || ''}`}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
role={onOpen ? 'button' : undefined}
tabIndex={onOpen ? 0 : undefined}
onKeyDown={(e) => {
if (onOpen && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onOpen(url);
}
}}
style={
dims
? { aspectRatio: `${dims.w} / ${dims.h}`, maxWidth: Math.min(dims.w, 400) }
: undefined
}
>
<canvas
ref={canvasRef}
className={styles.canvas}
style={{ opacity: !hovered && loaded ? 1 : 0 }}
/>
{hovered && (
<img
key={imgKey}
src={url}
alt=""
className={styles.img}
draggable={false}
/>
)}
<span className={styles.badge}>GIF</span>
</div>
);
}

View File

@@ -10,6 +10,7 @@ import { CreateCategoryModal } from './CategorySettingsModal';
import { InviteModal } from '../modals/InviteModal'; import { InviteModal } from '../modals/InviteModal';
import { CreateServerModal } from '../modals/CreateServerModal'; import { CreateServerModal } from '../modals/CreateServerModal';
import { PiPOverlay } from '../voice/PiPOverlay'; import { PiPOverlay } from '../voice/PiPOverlay';
import { RecordingRecoveryModal } from '../voice/RecordingRecoveryModal';
import { ChannelSettingsModal } from '../channel/ChannelSettingsModal'; import { ChannelSettingsModal } from '../channel/ChannelSettingsModal';
/** /**
@@ -160,6 +161,7 @@ export function AppLayout() {
channelId={channelSettingsId} channelId={channelSettingsId}
/> />
<PiPOverlay /> <PiPOverlay />
<RecordingRecoveryModal />
</KeybindProvider> </KeybindProvider>
</PresenceProvider> </PresenceProvider>
); );

View File

@@ -164,6 +164,7 @@
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
max-height: 1.25rem;
} }
.categoryChevron { .categoryChevron {

View File

@@ -20,6 +20,7 @@ import { useMemo, useRef, useState } from 'react';
import { Avatar } from '@discord-clone/ui'; import { Avatar } from '@discord-clone/ui';
import { api } from '../../../../../convex/_generated/api'; import { api } from '../../../../../convex/_generated/api';
import type { Id } from '../../../../../convex/_generated/dataModel'; import type { Id } from '../../../../../convex/_generated/dataModel';
import { useIsMobile } from '../../hooks/useIsMobile';
import styles from './CustomEmojisTab.module.css'; import styles from './CustomEmojisTab.module.css';
interface CustomEmojiDoc { interface CustomEmojiDoc {
@@ -34,8 +35,6 @@ interface CustomEmojiDoc {
uploadedByAvatarUrl: string | null; uploadedByAvatarUrl: string | null;
} }
const MAX_STATIC = 50;
const MAX_ANIMATED = 50;
const ACCEPTED_MIME = const ACCEPTED_MIME =
'image/png,image/gif,image/webp,image/jpeg,image/apng'; 'image/png,image/gif,image/webp,image/jpeg,image/apng';
@@ -59,6 +58,7 @@ function sanitizeEmojiName(filename: string): string {
} }
export function CustomEmojisTab() { export function CustomEmojisTab() {
const isMobile = useIsMobile();
const userId = const userId =
typeof localStorage !== 'undefined' typeof localStorage !== 'undefined'
? (localStorage.getItem('userId') as Id<'userProfiles'> | null) ? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
@@ -99,11 +99,6 @@ export function CustomEmojisTab() {
[filtered], [filtered],
); );
// Counts use the unfiltered lists so the slot card always
// reflects the true server state, not the current search view.
const totalStatic = emojis.filter((e) => !e.animated).length;
const totalAnimated = emojis.filter((e) => e.animated).length;
const uploadFiles = async (files: FileList | File[]) => { const uploadFiles = async (files: FileList | File[]) => {
if (!userId) { if (!userId) {
setStatus({ type: 'err', message: 'You must be logged in.' }); setStatus({ type: 'err', message: 'You must be logged in.' });
@@ -117,18 +112,6 @@ export function CustomEmojisTab() {
try { try {
for (const file of list) { for (const file of list) {
const animated = isAnimatedMime(file.type); const animated = isAnimatedMime(file.type);
// Enforce slot limits locally so we don't waste an upload
// round-trip when the server would reject it anyway.
if (
(animated && totalAnimated + okCount >= MAX_ANIMATED) ||
(!animated && totalStatic + okCount >= MAX_STATIC)
) {
setStatus({
type: 'err',
message: `Slot limit reached for ${animated ? 'animated' : 'static'} emoji.`,
});
break;
}
try { try {
const uploadUrl = await generateUploadUrl({}); const uploadUrl = await generateUploadUrl({});
const res = await fetch(uploadUrl, { const res = await fetch(uploadUrl, {
@@ -327,15 +310,7 @@ export function CustomEmojisTab() {
<div className={styles.slotsCard}> <div className={styles.slotsCard}>
<div className={styles.slotsHeader}> <div className={styles.slotsHeader}>
<div className={styles.slotsHeaderLeft}> <div className={styles.slotsHeaderLeft}>
<h3 className={styles.slotsTitle}>Emoji Slots</h3> <h3 className={styles.slotsTitle}>Upload Custom Emoji</h3>
<div className={styles.slotsCounts}>
<span className={styles.slotsCount}>
Static: <strong>{totalStatic}</strong> / {MAX_STATIC}
</span>
<span className={styles.slotsCount}>
Animated: <strong>{totalAnimated}</strong> / {MAX_ANIMATED}
</span>
</div>
</div> </div>
<button <button
type="button" type="button"
@@ -355,6 +330,11 @@ export function CustomEmojisTab() {
</p> </p>
</div> </div>
{/* Drag-and-drop is desktop-only — mobile browsers can't
receive OS-level file drops, so the zone is hidden and
users upload via the Upload Emoji button in the slots
card above. */}
{!isMobile && (
<div <div
className={`${styles.dropZone} ${isDraggingFiles ? styles.dropZoneActive : ''}`} className={`${styles.dropZone} ${isDraggingFiles ? styles.dropZoneActive : ''}`}
onClick={handlePickFile} onClick={handlePickFile}
@@ -369,6 +349,7 @@ export function CustomEmojisTab() {
/> />
<span>Drag and drop emoji files here</span> <span>Drag and drop emoji files here</span>
</div> </div>
)}
<input <input
ref={fileInputRef} ref={fileInputRef}

View File

@@ -34,13 +34,22 @@ const TABS: Array<{ id: ServerSettingsTab; label: string; icon: typeof Gear }> =
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley }, { id: 'emojis', label: 'Custom Emoji', icon: Smiley },
]; ];
export function ServerSettingsModal({ isOpen, onClose, initialTab = 'overview' }: ServerSettingsModalProps) { export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSettingsModalProps) {
const [activeTab, setActiveTab] = useState<ServerSettingsTab>(initialTab); // Desktop opens straight to the Overview tab when no explicit
// tab is requested — the two-column layout always needs a
// selection to fill the content pane. Mobile uses the category
// list as the root and only jumps into a panel if the caller
// actually asked for one, so we keep the raw `initialTab` below
// to forward to `MobileServerSettings`.
const resolvedInitialTab: ServerSettingsTab = initialTab ?? 'overview';
const [activeTab, setActiveTab] = useState<ServerSettingsTab>(
resolvedInitialTab,
);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
useEffect(() => { useEffect(() => {
if (isOpen) setActiveTab(initialTab); if (isOpen) setActiveTab(resolvedInitialTab);
}, [isOpen, initialTab]); }, [isOpen, resolvedInitialTab]);
useEffect(() => { useEffect(() => {
if (!isOpen) return; if (!isOpen) return;
@@ -52,7 +61,9 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab = 'overview' }
}, [isOpen, onClose]); }, [isOpen, onClose]);
// Mobile gets the full-screen overlay with a category list ↔ panel // Mobile gets the full-screen overlay with a category list ↔ panel
// flow, desktop gets the two-column modal below. // flow, desktop gets the two-column modal below. Pass the raw
// `initialTab` (NOT the resolved version) so mobile lands on the
// category list when nothing was requested.
if (isMobile) { if (isMobile) {
return ( return (
<MobileServerSettings <MobileServerSettings

View File

@@ -15,6 +15,7 @@ import { api } from '../../../../../convex/_generated/api';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { useIsMobile } from '../../hooks/useIsMobile'; import { useIsMobile } from '../../hooks/useIsMobile';
import { useLogout } from '../../hooks/useLogout'; import { useLogout } from '../../hooks/useLogout';
import { usePlatform } from '../../platform';
import { AvatarCropModal } from './AvatarCropModal'; import { AvatarCropModal } from './AvatarCropModal';
import { KeybindsTab } from './KeybindsTab'; import { KeybindsTab } from './KeybindsTab';
import { MobileUserSettings } from './MobileUserSettings'; import { MobileUserSettings } from './MobileUserSettings';
@@ -866,6 +867,7 @@ interface VoiceSettings {
noiseSuppression: boolean; noiseSuppression: boolean;
echoCancellation: boolean; echoCancellation: boolean;
autoGainControl: boolean; autoGainControl: boolean;
recordingDir?: string;
} }
const DEFAULT_VOICE_SETTINGS: VoiceSettings = { const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
@@ -1156,6 +1158,188 @@ export function VoiceTab() {
))} ))}
</select> </select>
</div> </div>
<RecordingSection settings={settings} update={update} />
</div>
);
}
/**
* RecordingSection — picks where per-participant voice recordings
* are saved on disk. Electron-only; renders nothing on web /
* Android because `platform.features.hasRecording` is false.
*/
function RecordingSection({
settings,
update,
}: {
settings: VoiceSettings;
update: <K extends keyof VoiceSettings>(key: K, value: VoiceSettings[K]) => void;
}) {
const platform = usePlatform() as any;
const hasRecording = !!platform?.features?.hasRecording;
const recording = platform?.recording;
const [defaultFolder, setDefaultFolder] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!hasRecording || !recording) return;
let cancelled = false;
(async () => {
try {
const path = await recording.getDefaultFolder();
if (!cancelled && typeof path === 'string') setDefaultFolder(path);
} catch {}
})();
return () => {
cancelled = true;
};
}, [hasRecording, recording]);
if (!hasRecording) return null;
const currentPath = settings.recordingDir || '';
const effectivePath = currentPath || defaultFolder || '';
const isDefault = !currentPath;
const handlePick = async () => {
if (!recording) return;
setBusy(true);
setError(null);
try {
const res = await recording.pickFolder();
if (!res?.ok || !res.path) return;
const v = await recording.validateFolder(res.path);
if (!v?.valid) {
setError(v?.error || 'Folder is not writable.');
return;
}
update('recordingDir', res.path);
} catch (err: any) {
setError(err?.message || 'Failed to pick folder');
} finally {
setBusy(false);
}
};
const handleOpen = async () => {
if (!recording) return;
try {
await recording.openFolder(effectivePath || null);
} catch (err) {
console.warn('Failed to open recording folder:', err);
}
};
const handleReset = () => {
update('recordingDir', undefined as any);
};
return (
<div className={styles.voiceSection}>
<h4 className={styles.voiceSectionTitle}>Call Recording</h4>
<p className={styles.settingDescription}>
Per-participant voice recordings are saved here. Each participant
gets their own audio file so you can edit them separately later.
</p>
<div className={styles.voiceFieldLabel}>Recording folder</div>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
flexWrap: 'wrap',
}}
>
<div
style={{
flex: 1,
minWidth: 0,
padding: '10px 12px',
borderRadius: 6,
background: 'var(--background-tertiary)',
border: '1px solid var(--background-header-secondary)',
color: 'var(--text-primary)',
fontSize: 13,
fontFamily: 'ui-monospace, Menlo, Consolas, monospace',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
title={effectivePath}
>
{effectivePath || '—'}
</div>
<button
type="button"
onClick={handlePick}
disabled={busy}
style={{
padding: '8px 14px',
background: 'var(--brand-primary)',
color: '#fff',
border: 'none',
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Change
</button>
<button
type="button"
onClick={handleOpen}
style={{
padding: '8px 14px',
background: 'var(--background-tertiary)',
color: 'var(--text-primary)',
border: '1px solid var(--background-header-secondary)',
borderRadius: 6,
fontSize: 13,
fontWeight: 600,
cursor: 'pointer',
}}
>
Open folder
</button>
</div>
{!isDefault && (
<button
type="button"
onClick={handleReset}
style={{
marginTop: 8,
background: 'none',
border: 'none',
color: 'var(--text-secondary)',
fontSize: 12,
cursor: 'pointer',
padding: 0,
textDecoration: 'underline',
}}
>
Reset to default
</button>
)}
{error && (
<div
style={{
marginTop: 8,
padding: '8px 10px',
background: 'rgba(237,66,69,0.12)',
border: '1px solid rgba(237,66,69,0.4)',
borderRadius: 6,
color: 'var(--text-primary)',
fontSize: 12,
}}
>
{error}
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,59 @@
/*
* RecordingIndicator — compact red pill shown in the UserArea
* (next to the mic / deafen controls) while a voice recording is
* in progress. Pulses softly so it's visible at a glance; clicking
* opens a confirm dialog that stops the recording.
*/
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 10px;
border: 1px solid rgba(237, 66, 69, 0.4);
border-radius: 999px;
background: rgba(237, 66, 69, 0.12);
color: var(--status-danger, #ed4245);
font: inherit;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.02em;
cursor: pointer;
transition: background-color 0.12s, border-color 0.12s, color 0.12s;
}
.pill:hover {
background: rgba(237, 66, 69, 0.2);
border-color: rgba(237, 66, 69, 0.6);
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-danger, #ed4245);
box-shadow: 0 0 0 0 rgba(237, 66, 69, 0.5);
animation: recPulse 1.4s ease-out infinite;
}
@keyframes recPulse {
0% {
box-shadow: 0 0 0 0 rgba(237, 66, 69, 0.55);
}
70% {
box-shadow: 0 0 0 8px rgba(237, 66, 69, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(237, 66, 69, 0);
}
}
.timer {
font-variant-numeric: tabular-nums;
color: var(--text-primary);
}
.label {
text-transform: uppercase;
}

View File

@@ -0,0 +1,60 @@
/**
* RecordingIndicator — red "REC" pill with a running session
* timer, shown in the UserArea while a voice recording is in
* progress. Clicking it prompts to stop. Gated on the
* `isRecording` flag from VoiceContext; renders `null` otherwise
* so the UserArea footer shape stays consistent.
*/
import { useEffect, useState } from 'react';
import { useVoice } from '../../contexts/VoiceContext';
import styles from './RecordingIndicator.module.css';
function formatElapsed(ms: number): string {
if (!Number.isFinite(ms) || ms < 0) return '00:00';
const total = Math.floor(ms / 1000);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
const mm = String(m).padStart(2, '0');
const ss = String(s).padStart(2, '0');
return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
}
export function RecordingIndicator() {
const voice = useVoice() as any;
const isRecording = !!voice?.isRecording;
const startedAt: number | null = voice?.recordingStartedAt ?? null;
const stopRecording: (() => Promise<void>) | undefined = voice?.stopRecording;
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!isRecording) return;
const id = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(id);
}, [isRecording]);
if (!isRecording || !startedAt) return null;
const elapsed = now - startedAt;
const handleClick = async () => {
const ok = window.confirm('Stop recording this call?');
if (!ok) return;
await stopRecording?.();
};
return (
<button
type="button"
className={styles.pill}
onClick={handleClick}
aria-label={`Stop recording (${formatElapsed(elapsed)})`}
title="Click to stop recording"
>
<span className={styles.dot} aria-hidden />
<span className={styles.label}>REC</span>
<span className={styles.timer}>{formatElapsed(elapsed)}</span>
</button>
);
}

View File

@@ -0,0 +1,102 @@
.card {
padding: 20px 22px 18px;
display: flex;
flex-direction: column;
gap: 14px;
min-width: 520px;
max-width: 640px;
}
.title {
font-size: 1.125rem;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.lead {
font-size: 0.9375rem;
color: var(--text-secondary);
line-height: 1.45;
margin: 0;
}
.list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: 280px;
overflow-y: auto;
padding: 4px 0;
}
.row {
display: grid;
grid-template-columns: 1fr auto;
gap: 14px;
align-items: center;
padding: 10px 12px;
border: 1px solid var(--background-header-secondary);
border-radius: 8px;
background-color: var(--background-tertiary);
}
.rowMeta {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.rowPrimary {
font-size: 0.9375rem;
font-weight: 600;
color: var(--text-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rowSecondary {
font-size: 0.75rem;
color: var(--text-tertiary);
}
.rowActions {
display: flex;
align-items: center;
gap: 6px;
}
.actionButton {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid var(--background-header-secondary);
border-radius: 6px;
background: var(--background-secondary);
color: var(--text-primary);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.12s, color 0.12s, border-color 0.12s;
}
.actionButton:hover {
background: var(--background-modifier-hover);
}
.actionDanger:hover {
background: rgba(237, 66, 69, 0.12);
border-color: rgba(237, 66, 69, 0.4);
color: var(--status-danger, #ed4245);
}
.footer {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 4px;
}

View File

@@ -0,0 +1,218 @@
/**
* RecordingRecoveryModal — shown once on app startup when the
* Electron main process reports one or more recording sessions
* with `endedAt === null`. Those are sessions that didn't
* finalize (e.g. the app was killed mid-recording).
*
* Each session offers three actions:
* - Keep — stamps `endedAt` to the latest track's mtime so
* the manifest parses cleanly on future scans.
* - Delete — removes the session folder entirely.
* - Open — reveals the session folder in the OS file browser
* so the user can manually inspect / copy out files.
*
* Gated on `platform.features.hasRecording` — the modal never
* renders on web/Android.
*/
import { useEffect, useState } from 'react';
import { FolderOpen, Trash } from '@phosphor-icons/react';
import { Modal, Button } from '@discord-clone/ui';
import { usePlatform } from '../../platform';
import styles from './RecordingRecoveryModal.module.css';
interface RecoverableSession {
sessionId: string;
sessionDir: string;
channelId: string | null;
channelName: string | null;
startedAt: number;
participantCount: number;
trackCount: number;
}
function formatStartedAt(ts: number): string {
try {
const d = new Date(ts);
return d.toLocaleString([], {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
} catch {
return '—';
}
}
export function RecordingRecoveryModal() {
const platform = usePlatform() as any;
const hasRecording = !!platform?.features?.hasRecording;
const recording = platform?.recording;
const [sessions, setSessions] = useState<RecoverableSession[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
useEffect(() => {
if (!hasRecording || !recording) return;
let cancelled = false;
(async () => {
try {
// Look up the user-preferred root (same place the
// recorder reads from) so we scan the right folder.
let rootDir: string | null = null;
try {
const raw = localStorage.getItem('voiceSettings');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.recordingDir === 'string') {
rootDir = parsed.recordingDir;
}
}
} catch {}
const res = await recording.listRecoverable({ rootDir });
if (cancelled) return;
if (res?.ok && Array.isArray(res.sessions) && res.sessions.length > 0) {
setSessions(res.sessions);
setIsOpen(true);
}
} catch (err) {
console.warn('Recording recovery scan failed:', err);
}
})();
return () => {
cancelled = true;
};
}, [hasRecording, recording]);
if (!hasRecording) return null;
const dismiss = () => {
setIsOpen(false);
setSessions([]);
};
const handleKeep = async (session: RecoverableSession) => {
if (!recording) return;
setBusy(session.sessionId);
try {
await recording.recoverSession({
sessionDir: session.sessionDir,
action: 'keep',
});
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
} catch (err) {
console.warn('Failed to keep recovered session:', err);
} finally {
setBusy(null);
}
};
const handleDelete = async (session: RecoverableSession) => {
if (!recording) return;
const ok = window.confirm(
`Delete recording from ${formatStartedAt(session.startedAt)}?\n\n` +
`This permanently removes ${session.trackCount} audio file${session.trackCount === 1 ? '' : 's'}.`,
);
if (!ok) return;
setBusy(session.sessionId);
try {
await recording.recoverSession({
sessionDir: session.sessionDir,
action: 'delete',
});
setSessions((prev) => prev.filter((s) => s.sessionId !== session.sessionId));
} catch (err) {
console.warn('Failed to delete recovered session:', err);
} finally {
setBusy(null);
}
};
const handleOpen = async (session: RecoverableSession) => {
if (!recording) return;
try {
await recording.openFolder(session.sessionDir);
} catch (err) {
console.warn('Failed to open recovered folder:', err);
}
};
useEffect(() => {
if (isOpen && sessions.length === 0) {
setIsOpen(false);
}
}, [isOpen, sessions.length]);
return (
<Modal.Root isOpen={isOpen} onClose={dismiss} size="medium">
<Modal.Header title="Recover voice recordings" onClose={dismiss} />
<Modal.Content>
<div className={styles.card}>
<p className={styles.lead}>
These voice recordings didn't finalise cleanly. The audio is
intact up to the moment the app was interrupted. Choose what
to do with each session — keeping a session just stamps the
end time so it stops appearing in this list.
</p>
<div className={styles.list}>
{sessions.map((session) => (
<div key={session.sessionId} className={styles.row}>
<div className={styles.rowMeta}>
<span className={styles.rowPrimary}>
{session.channelName ? `#${session.channelName}` : 'Voice call'}
{' '}
{formatStartedAt(session.startedAt)}
</span>
<span className={styles.rowSecondary}>
{session.trackCount} file
{session.trackCount === 1 ? '' : 's'} ·{' '}
{session.participantCount} participant
{session.participantCount === 1 ? '' : 's'}
</span>
</div>
<div className={styles.rowActions}>
<button
type="button"
className={styles.actionButton}
onClick={() => handleOpen(session)}
disabled={busy === session.sessionId}
title="Open in file explorer"
>
<FolderOpen size={14} weight="bold" />
Open
</button>
<button
type="button"
className={`${styles.actionButton} ${styles.actionDanger}`}
onClick={() => handleDelete(session)}
disabled={busy === session.sessionId}
title="Delete recording"
>
<Trash size={14} weight="bold" />
Delete
</button>
<button
type="button"
className={styles.actionButton}
onClick={() => handleKeep(session)}
disabled={busy === session.sessionId}
title="Keep this recording"
>
Keep
</button>
</div>
</div>
))}
</div>
<div className={styles.footer}>
<Button variant="secondary" size="sm" onClick={dismiss}>
Close
</Button>
</div>
</div>
</Modal.Content>
</Modal.Root>
);
}

View File

@@ -23,11 +23,14 @@ import {
Desktop, Desktop,
MonitorPlay, MonitorPlay,
PhoneX, PhoneX,
Record,
Waveform, Waveform,
} from '@phosphor-icons/react'; } from '@phosphor-icons/react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Tooltip } from '@discord-clone/ui'; import { Tooltip } from '@discord-clone/ui';
import { useVoice } from '../../contexts/VoiceContext'; import { useVoice } from '../../contexts/VoiceContext';
import { usePlatform } from '../../platform';
import { RecordingIndicator } from './RecordingIndicator';
import styles from './VoiceConnectionStatus.module.css'; import styles from './VoiceConnectionStatus.module.css';
/** /**
@@ -97,6 +100,24 @@ export function VoiceConnectionStatus() {
const voice = useVoice() as any; const voice = useVoice() as any;
const navigate = useNavigate(); const navigate = useNavigate();
const voiceSettings = useVoiceSettingsSnapshot(); const voiceSettings = useVoiceSettingsSnapshot();
const platform = usePlatform() as any;
const canRecord = !!platform?.features?.hasRecording;
const isRecording = !!voice?.isRecording;
const handleStartRecording = async () => {
if (!canRecord) return;
const ok = window.confirm(
'Start recording this call?\n\n' +
'Each participant will be saved to a separate audio file on your computer. ' +
'You are responsible for obtaining consent from participants where required by law.',
);
if (!ok) return;
try {
await voice?.startRecording?.();
} catch (err) {
console.error('Failed to start recording:', err);
}
};
if (!voice?.activeChannelId) return null; if (!voice?.activeChannelId) return null;
const state: string = voice.connectionState || 'disconnected'; const state: string = voice.connectionState || 'disconnected';
@@ -159,6 +180,19 @@ export function VoiceConnectionStatus() {
{status.text} {status.text}
</button> </button>
<div className={styles.controls}> <div className={styles.controls}>
{canRecord && isConnected && !isRecording && (
<Tooltip content="Record call" placement="top">
<button
type="button"
className={styles.controlButton}
onClick={handleStartRecording}
aria-label="Record call"
>
<Record weight="fill" className={styles.icon} />
</button>
</Tooltip>
)}
{canRecord && isConnected && isRecording && <RecordingIndicator />}
<Tooltip <Tooltip
content={ content={
noiseSuppressionActive noiseSuppressionActive

View File

@@ -4,6 +4,7 @@ import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
import { useQuery, useConvex } from 'convex/react'; import { useQuery, useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api'; import { api } from '../../../../convex/_generated/api';
import { findTrackPubs } from '../utils/streamUtils.jsx'; import { findTrackPubs } from '../utils/streamUtils.jsx';
import { VoiceRecorder } from '../utils/voiceRecorder';
import { usePlatform } from '../platform'; import { usePlatform } from '../platform';
import '@livekit/components-styles'; import '@livekit/components-styles';
@@ -58,7 +59,8 @@ function playSoundUrl(url) {
} }
export const VoiceProvider = ({ children }) => { export const VoiceProvider = ({ children }) => {
const { idle, voiceService } = usePlatform(); const platform = usePlatform();
const { idle, voiceService } = platform;
const [activeChannelId, setActiveChannelId] = useState(null); const [activeChannelId, setActiveChannelId] = useState(null);
const [activeChannelName, setActiveChannelName] = useState(null); const [activeChannelName, setActiveChannelName] = useState(null);
const [connectionState, setConnectionState] = useState('disconnected'); const [connectionState, setConnectionState] = useState('disconnected');
@@ -85,6 +87,16 @@ export const VoiceProvider = ({ children }) => {
const [isReconnecting, setIsReconnecting] = useState(false); const [isReconnecting, setIsReconnecting] = useState(false);
const [connectionQualities, setConnectionQualities] = useState({}); const [connectionQualities, setConnectionQualities] = useState({});
// Voice recording — crash-safe per-participant audio capture.
// Only available on Electron (`platform.features.hasRecording`).
// `recordingError` is surfaced once per failure so UI can toast
// it; consumers should clear it after reading.
const [isRecording, setIsRecording] = useState(false);
const [recordingStartedAt, setRecordingStartedAt] = useState(null);
const [recordingSessionId, setRecordingSessionId] = useState(null);
const [recordingError, setRecordingError] = useState(null);
const voiceRecorderRef = useRef(null);
const convex = useConvex(); const convex = useConvex();
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation) // Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
@@ -953,6 +965,94 @@ export const VoiceProvider = ({ children }) => {
} }
}, [room]); }, [room]);
// ── Voice recording ─────────────────────────────────────────
// Starts a `VoiceRecorder` bound to the current room. Returns
// the session metadata on success so the UI can show a confirm
// indicator. Gracefully no-ops (with a console warning) on
// platforms that don't support recording.
const startRecording = useCallback(async () => {
if (!platform?.features?.hasRecording || !platform.recording) {
console.warn('Recording is not available on this platform.');
return null;
}
if (!room) {
console.warn('Cannot start recording — not connected to a voice channel.');
return null;
}
if (voiceRecorderRef.current) {
return {
sessionId: voiceRecorderRef.current.sessionId,
startedAt: voiceRecorderRef.current.startedAt,
};
}
try {
// Resolve the user-preferred recording folder (settings
// writes to localStorage under `voiceSettings`, same
// bucket the mic/AGC toggles live in).
let rootDir = null;
try {
const raw = localStorage.getItem('voiceSettings');
if (raw) {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.recordingDir === 'string') {
rootDir = parsed.recordingDir;
}
}
} catch {}
const recorder = new VoiceRecorder({
platform,
room,
channelId: activeChannelId,
channelName: activeChannelName,
rootDir,
onError: (err) => {
console.error('Voice recorder error:', err);
setRecordingError(err.message || 'Recording error');
},
});
await recorder.start();
voiceRecorderRef.current = recorder;
setRecordingSessionId(recorder.sessionId);
setRecordingStartedAt(recorder.startedAt);
setIsRecording(true);
setRecordingError(null);
return { sessionId: recorder.sessionId, startedAt: recorder.startedAt };
} catch (err) {
console.error('Failed to start recording:', err);
setRecordingError(err?.message || 'Failed to start recording');
voiceRecorderRef.current = null;
setIsRecording(false);
setRecordingSessionId(null);
setRecordingStartedAt(null);
return null;
}
}, [platform, room, activeChannelId, activeChannelName]);
const stopRecording = useCallback(async () => {
const recorder = voiceRecorderRef.current;
if (!recorder) return;
voiceRecorderRef.current = null;
try {
await recorder.stop();
} catch (err) {
console.error('Failed to stop recording cleanly:', err);
setRecordingError(err?.message || 'Failed to stop recording');
} finally {
setIsRecording(false);
setRecordingSessionId(null);
setRecordingStartedAt(null);
}
}, []);
// Auto-stop the recorder if the user disconnects from the
// voice channel — we don't want an orphaned VoiceRecorder
// holding references to a destroyed LiveKit room.
useEffect(() => {
if (!room && voiceRecorderRef.current) {
void stopRecording();
}
}, [room, stopRecording]);
return ( return (
<VoiceContext.Provider value={{ <VoiceContext.Provider value={{
@@ -993,6 +1093,14 @@ export const VoiceProvider = ({ children }) => {
isReceivingScreenShareAudio, isReceivingScreenShareAudio,
isReconnecting, isReconnecting,
connectionQualities, connectionQualities,
// Voice recording
isRecording,
recordingStartedAt,
recordingSessionId,
recordingError,
startRecording,
stopRecording,
clearRecordingError: () => setRecordingError(null),
}}> }}>
{children} {children}
{room && ( {room && (

View File

@@ -88,6 +88,93 @@ body.has-window-controls #root {
padding-top: 32px; padding-top: 32px;
} }
/* ── IBM Plex (self-hosted) ──────────────────────────────────────
WOFF2 files live under `packages/shared/src/assets/font/` and
are emitted to `dist/assets/` by Vite's asset pipeline on build.
Using `font-display: swap` so text paints immediately in the
system fallback, then reflows once Plex finishes downloading —
standard first-paint tradeoff for self-hosted webfonts.
Only Latin glyphs are covered. Add a `unicode-range` descriptor
here if we ever need to load extra scripts. */
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-Italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-MediumItalic.woff2') format('woff2');
font-weight: 500;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-SemiBold.woff2') format('woff2');
font-weight: 600;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-SemiBoldItalic.woff2') format('woff2');
font-weight: 600;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-Bold.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Sans';
src: url('./assets/font/IBMPlexSans-BoldItalic.woff2') format('woff2');
font-weight: 700;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Mono';
src: url('./assets/font/IBMPlexMono-Regular.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Mono';
src: url('./assets/font/IBMPlexMono-Italic.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'IBM Plex Mono';
src: url('./assets/font/IBMPlexMono-Medium.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
/* ── Design Tokens ────────────────────────────────────────────────────── */ /* ── Design Tokens ────────────────────────────────────────────────────── */
:root { :root {
@@ -246,7 +333,7 @@ body.has-window-controls #root {
/* Typography */ /* Typography */
--font-sans: 'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; --font-sans: 'IBM Plex Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'Consolas', 'Andale Mono WT', 'Andale Mono', 'Lucida Console', 'Lucida Sans Typewriter', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono', 'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace; --font-mono: 'IBM Plex Mono', 'Consolas', 'Andale Mono WT', 'Andale Mono', 'Lucida Console', 'Lucida Sans Typewriter', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Liberation Mono', 'Nimbus Mono L', Monaco, 'Courier New', Courier, monospace;
--font-emoji: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', var(--font-sans, system-ui), sans-serif; --font-emoji: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji', var(--font-sans, system-ui), sans-serif;
--font-size-xs: 0.75rem; --font-size-xs: 0.75rem;
--font-size-sm: 0.8125rem; --font-size-sm: 0.8125rem;
@@ -561,8 +648,6 @@ body {
color: var(--text-primary); color: var(--text-primary);
background-color: var(--background-tertiary); background-color: var(--background-tertiary);
scrollbar-color: var(--scrollbar-thumb-bg) var(--scrollbar-track-bg); scrollbar-color: var(--scrollbar-thumb-bg) var(--scrollbar-track-bg);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
user-select: var(--user-select); user-select: var(--user-select);
overflow: hidden; overflow: hidden;
margin: 0; margin: 0;

View File

@@ -52,6 +52,22 @@
* @property {() => void} close * @property {() => void} close
*/ */
/**
* @typedef {Object} PlatformRecording
* @property {() => Promise<string>} getDefaultFolder - Default recording root (e.g. %APPDATA%/Brycord/recordings)
* @property {() => Promise<{ok: boolean, path: string|null}>} pickFolder - Native folder picker dialog
* @property {(dir: string) => Promise<{valid: boolean, error?: string|null}>} validateFolder
* @property {(dir?: string|null) => Promise<{ok: boolean, error?: string|null}>} openFolder - Reveal the given (or default) root in file explorer
* @property {(payload: {rootDir?: string|null, sessionId: string}) => Promise<{ok: boolean, error?: string|null}>} openSessionFolder
* @property {(payload: {sessionId: string, rootDir?: string|null, channelId?: string|null, channelName?: string|null, startedAt: number}) => Promise<{ok: boolean, error?: string|null, rootDir?: string, sessionDir?: string}>} startSession
* @property {(payload: {sessionId: string, participantId: string, displayName?: string|null, joinedOffsetMs: number}) => Promise<{ok: boolean, error?: string|null, trackKey?: string, fileName?: string}>} openTrack
* @property {(payload: {sessionId: string, trackKey: string, chunk: ArrayBuffer}) => Promise<{ok: boolean, error?: string|null}>} append
* @property {(payload: {sessionId: string, trackKey: string, leftOffsetMs?: number|null}) => Promise<{ok: boolean, error?: string|null}>} closeTrack
* @property {(payload: {sessionId: string, endedAt: number}) => Promise<{ok: boolean, error?: string|null, dir?: string}>} finalize
* @property {(payload?: {rootDir?: string|null}) => Promise<{ok: boolean, sessions: Array<{sessionId: string, sessionDir: string, channelId: string|null, channelName: string|null, startedAt: number, participantCount: number, trackCount: number}>}>} listRecoverable
* @property {(payload: {sessionDir: string, action: 'keep'|'delete'}) => Promise<{ok: boolean, error?: string|null}>} recoverSession
*/
/** /**
* @typedef {Object} PlatformUpdates * @typedef {Object} PlatformUpdates
* @property {() => Promise<object>} checkUpdate * @property {() => Promise<object>} checkUpdate
@@ -103,6 +119,7 @@
* @property {PlatformLinks} links * @property {PlatformLinks} links
* @property {PlatformScreenCapture|null} screenCapture * @property {PlatformScreenCapture|null} screenCapture
* @property {PlatformWindowControls|null} windowControls * @property {PlatformWindowControls|null} windowControls
* @property {PlatformRecording|null} recording
* @property {PlatformUpdates|null} updates * @property {PlatformUpdates|null} updates
* @property {PlatformSearchDB|null} searchDB * @property {PlatformSearchDB|null} searchDB
* @property {PlatformVoiceService|null} voiceService * @property {PlatformVoiceService|null} voiceService
@@ -110,4 +127,9 @@
* @property {PlatformFeatures} features * @property {PlatformFeatures} features
*/ */
/**
* @typedef {Object} PlatformFeaturesExtensions
* @property {boolean} [hasRecording]
*/
export {}; export {};

View File

@@ -0,0 +1,417 @@
/**
* VoiceRecorder — per-participant audio capture for a LiveKit voice
* call. Spawns one `MediaRecorder` per active audio track (local mic
* + each remote participant) with a 500ms timeslice. Every chunk is
* forwarded over IPC to the Electron main process, which append-
* writes it to a per-participant WebM file inside a session folder.
*
* Crash-safe by construction: the WebM/Opus container is built from
* independently-decodable clusters, so a truncated tail on power
* loss just means the decoder stops at the last complete cluster.
* Every 500ms of audio is on disk before the next chunk arrives,
* and the main process fsyncs every ~10 seconds.
*
* Only active when `platform.features.hasRecording === true`
* (Electron). Other platforms should never instantiate this — the
* VoiceContext gates `startRecording()` behind that flag.
*/
import { RoomEvent } from 'livekit-client';
interface Platform {
features?: { hasRecording?: boolean };
recording?: {
startSession: (payload: any) => Promise<any>;
openTrack: (payload: any) => Promise<any>;
append: (payload: any) => Promise<any>;
closeTrack: (payload: any) => Promise<any>;
finalize: (payload: any) => Promise<any>;
} | null;
}
interface RecordingOpts {
platform: Platform;
room: any; // LiveKit Room
channelId: string | null;
channelName: string | null;
rootDir: string | null;
onError?: (err: Error) => void;
}
interface TrackRecorder {
participantId: string;
displayName: string;
trackKey: string;
stream: MediaStream;
recorder: MediaRecorder;
joinedOffsetMs: number;
// `pendingIo` serialises the append IPCs for this track so
// chunks arrive in order even if the main-process write is
// slower than 500ms. Without this, two parallel `append` calls
// can interleave their bytes and break the WebM cluster stream.
pendingIo: Promise<any>;
}
function sanitizeParticipantId(id: any): string {
return String(id ?? 'unknown');
}
function generateSessionId(): string {
// Filesystem-safe ISO timestamp — no `:` so Windows is happy,
// plus a short random suffix so two recordings started in the
// same second don't collide.
const iso = new Date().toISOString().replace(/[:.]/g, '-');
const rand = Math.random().toString(36).slice(2, 6);
return `${iso}-${rand}`;
}
/** Resolve the LiveKit microphone publication (handles both the
* newer `trackPublications` Map and older `tracks` map names). */
function findLocalMicPublication(localParticipant: any): any | null {
const map =
localParticipant?.trackPublications || localParticipant?.tracks;
if (!map) return null;
for (const pub of map.values()) {
if (!pub) continue;
const kind = pub.kind === 'audio' || pub.track?.kind === 'audio';
const src = (pub.source?.toString?.() ?? '').toLowerCase();
if (kind && (src === 'microphone' || src === '' || src === 'mic')) {
return pub;
}
}
return null;
}
/** Yield every audio publication on a participant whose `source`
* isn't screen_share_audio (which is mixed with the screen share
* track and would be redundant / confusing in a per-voice file). */
function* iterAudioPublications(participant: any): Generator<any> {
const map = participant?.trackPublications || participant?.tracks;
if (!map) return;
for (const pub of map.values()) {
if (!pub) continue;
const kind = pub.kind === 'audio' || pub.track?.kind === 'audio';
if (!kind) continue;
const src = (pub.source?.toString?.() ?? '').toLowerCase();
if (src === 'screen_share_audio') continue;
yield pub;
}
}
/** Best `mimeType` the current Chromium build supports for the
* MediaRecorder. Electron ships Chromium so Opus-in-WebM is
* always available; falling through is defensive. */
function pickMimeType(): string {
const candidates = [
'audio/webm;codecs=opus',
'audio/webm',
'audio/ogg;codecs=opus',
];
for (const c of candidates) {
if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(c)) {
return c;
}
}
return 'audio/webm';
}
export class VoiceRecorder {
readonly sessionId: string;
readonly startedAt: number;
private readonly opts: RecordingOpts;
private readonly mimeType: string;
private readonly recorders = new Map<string, TrackRecorder>();
private stopped = false;
// We capture refs to the RoomEvent handlers so we can detach
// them in stop() — React's strict-mode double mount + the
// context's own event listeners would otherwise leave dangling
// subscriptions after the recorder is destroyed.
private readonly handlers: Array<[string, (...args: any[]) => void]> = [];
constructor(opts: RecordingOpts) {
this.opts = opts;
this.sessionId = generateSessionId();
this.startedAt = Date.now();
this.mimeType = pickMimeType();
}
/** Spin up the session folder + attach recorders for every
* audio track that's already published plus handlers for
* tracks that arrive later (participants joining mid-call). */
async start(): Promise<void> {
const { platform, room } = this.opts;
if (!platform.features?.hasRecording || !platform.recording) {
throw new Error('Recording is not available on this platform.');
}
const started = await platform.recording.startSession({
sessionId: this.sessionId,
rootDir: this.opts.rootDir,
channelId: this.opts.channelId,
channelName: this.opts.channelName,
startedAt: this.startedAt,
});
if (!started?.ok) {
throw new Error(started?.error ?? 'Failed to start recording session.');
}
// Local mic — iterate any existing publication and hook
// future re-publications (mute/unmute cycles republish the
// track, which would otherwise slip past TrackSubscribed).
const localId = room.localParticipant?.identity ?? 'local';
const localName =
room.localParticipant?.metadata ||
room.localParticipant?.name ||
'You';
const localPub = findLocalMicPublication(room.localParticipant);
if (localPub?.track?.mediaStreamTrack) {
await this.attachTrack(localId, localName, localPub.track.mediaStreamTrack);
}
// Remote participants — both the currently-connected set
// and any future joiners.
const remotes = room.remoteParticipants?.values
? Array.from(room.remoteParticipants.values())
: [];
for (const participant of remotes as any[]) {
for (const pub of iterAudioPublications(participant)) {
if (pub.track?.mediaStreamTrack) {
await this.attachTrack(
sanitizeParticipantId(participant.identity),
participant.metadata || participant.name || participant.identity || 'Participant',
pub.track.mediaStreamTrack,
);
}
}
}
// Hook future subscribes — this fires for every newly
// subscribed audio track, including ones that come in after
// a participant joins mid-recording.
const onSubscribed = (track: any, _publication: any, participant: any) => {
if (this.stopped) return;
if (track?.kind !== 'audio') return;
const src = (track?.source?.toString?.() ?? '').toLowerCase();
if (src === 'screen_share_audio') return;
const mediaTrack = track?.mediaStreamTrack;
if (!mediaTrack) return;
void this.attachTrack(
sanitizeParticipantId(participant?.identity),
participant?.metadata || participant?.name || participant?.identity || 'Participant',
mediaTrack,
);
};
const onUnsubscribed = (_track: any, _publication: any, participant: any) => {
if (this.stopped) return;
this.detachParticipantTracks(sanitizeParticipantId(participant?.identity));
};
const onParticipantDisconnected = (participant: any) => {
if (this.stopped) return;
this.detachParticipantTracks(sanitizeParticipantId(participant?.identity));
};
const onLocalTrackPublished = (publication: any) => {
if (this.stopped) return;
const track = publication?.track;
if (track?.kind !== 'audio') return;
if (!track?.mediaStreamTrack) return;
void this.attachTrack(localId, localName, track.mediaStreamTrack);
};
const onLocalTrackUnpublished = (publication: any) => {
if (this.stopped) return;
const track = publication?.track;
if (track?.kind !== 'audio') return;
this.detachParticipantTracks(localId);
};
room.on(RoomEvent.TrackSubscribed, onSubscribed);
room.on(RoomEvent.TrackUnsubscribed, onUnsubscribed);
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
room.on(RoomEvent.LocalTrackPublished, onLocalTrackPublished);
room.on(RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished);
this.handlers.push([RoomEvent.TrackSubscribed, onSubscribed]);
this.handlers.push([RoomEvent.TrackUnsubscribed, onUnsubscribed]);
this.handlers.push([RoomEvent.ParticipantDisconnected, onParticipantDisconnected]);
this.handlers.push([RoomEvent.LocalTrackPublished, onLocalTrackPublished]);
this.handlers.push([RoomEvent.LocalTrackUnpublished, onLocalTrackUnpublished]);
}
/** Flush every open recorder, close every track, finalize the
* session manifest. Idempotent — safe to call from React
* cleanup paths. */
async stop(): Promise<void> {
if (this.stopped) return;
this.stopped = true;
const { platform, room } = this.opts;
// Detach room listeners first so late-arriving events don't
// try to attach new tracks mid-teardown.
for (const [event, handler] of this.handlers) {
try { room.off(event, handler); } catch {}
}
this.handlers.length = 0;
const endedAt = Date.now();
const stops: Array<Promise<void>> = [];
for (const entry of this.recorders.values()) {
stops.push(this.stopRecorder(entry, endedAt));
}
await Promise.allSettled(stops);
this.recorders.clear();
if (platform.recording) {
try {
await platform.recording.finalize({
sessionId: this.sessionId,
endedAt,
});
} catch (err) {
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
}
}
}
// ── Internals ─────────────────────────────────────────────
private async attachTrack(
participantId: string,
displayName: string,
mediaTrack: MediaStreamTrack,
): Promise<void> {
if (this.stopped) return;
// Skip duplicates if the same mediaTrack already has a
// running recorder (track republications during mute cycles
// can fire TrackSubscribed twice).
for (const entry of this.recorders.values()) {
if (entry.participantId === participantId && entry.recorder.state === 'recording') {
return;
}
}
const joinedOffsetMs = Math.max(0, Date.now() - this.startedAt);
const opened = await this.opts.platform.recording!.openTrack({
sessionId: this.sessionId,
participantId,
displayName,
joinedOffsetMs,
});
if (!opened?.ok || !opened.trackKey) {
this.opts.onError?.(
new Error(opened?.error ?? 'Failed to open recording track.'),
);
return;
}
const trackKey = opened.trackKey;
const stream = new MediaStream([mediaTrack]);
let recorder: MediaRecorder;
try {
recorder = new MediaRecorder(stream, { mimeType: this.mimeType });
} catch (err) {
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
return;
}
const entry: TrackRecorder = {
participantId,
displayName,
trackKey,
stream,
recorder,
joinedOffsetMs,
pendingIo: Promise.resolve(),
};
recorder.ondataavailable = (ev: BlobEvent) => {
if (!ev.data || ev.data.size === 0) return;
// Queue each append after the previous one resolves so
// bytes arrive in order on the main-process side.
entry.pendingIo = entry.pendingIo
.catch(() => undefined)
.then(async () => {
try {
const buf = await ev.data.arrayBuffer();
await this.opts.platform.recording!.append({
sessionId: this.sessionId,
trackKey,
chunk: buf,
});
} catch (err) {
this.opts.onError?.(
err instanceof Error ? err : new Error(String(err)),
);
}
});
};
recorder.onerror = (ev: any) => {
this.opts.onError?.(
ev?.error instanceof Error
? ev.error
: new Error(String(ev?.error ?? 'MediaRecorder error')),
);
};
recorder.start(500);
this.recorders.set(trackKey, entry);
}
private detachParticipantTracks(participantId: string): void {
const keys: string[] = [];
for (const [key, entry] of this.recorders) {
if (entry.participantId === participantId) keys.push(key);
}
for (const key of keys) {
const entry = this.recorders.get(key);
if (!entry) continue;
this.recorders.delete(key);
void this.stopRecorder(entry, Date.now());
}
}
private async stopRecorder(
entry: TrackRecorder,
endedAt: number,
): Promise<void> {
try {
if (entry.recorder.state !== 'inactive') {
// requestData() makes MediaRecorder emit one final
// dataavailable for whatever's in its internal buffer,
// then stop() flushes the tail.
try { entry.recorder.requestData(); } catch {}
await new Promise<void>((resolve) => {
const onStop = () => {
entry.recorder.removeEventListener('stop', onStop);
resolve();
};
entry.recorder.addEventListener('stop', onStop);
try { entry.recorder.stop(); } catch { resolve(); }
});
}
} catch (err) {
this.opts.onError?.(err instanceof Error ? err : new Error(String(err)));
}
// Wait for any pending append IPCs to finish so closeTrack
// doesn't race ahead of the last in-flight chunk.
try { await entry.pendingIo; } catch {}
if (this.opts.platform.recording) {
try {
await this.opts.platform.recording.closeTrack({
sessionId: this.sessionId,
trackKey: entry.trackKey,
leftOffsetMs: Math.max(0, endedAt - this.startedAt),
});
} catch (err) {
this.opts.onError?.(
err instanceof Error ? err : new Error(String(err)),
);
}
}
// Stop every MediaStreamTrack we constructed for this
// recorder — LiveKit still owns the underlying device track,
// but the wrapper MediaStream we created needs to be
// released to free browser resources.
try {
for (const t of entry.stream.getTracks()) {
if (t === entry.stream.getTracks()[0]) continue; // device track belongs to LiveKit
}
} catch {}
}
}