diff --git a/apps/android/android/app/build.gradle b/apps/android/android/app/build.gradle index c97b2bb..15dbdab 100644 --- a/apps/android/android/app/build.gradle +++ b/apps/android/android/app/build.gradle @@ -8,7 +8,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 27 - versionName "1.0.50" + versionName "1.0.60" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/apps/android/package.json b/apps/android/package.json index 61be39d..1d66597 100644 --- a/apps/android/package.json +++ b/apps/android/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/android", "private": true, - "version": "1.0.50", + "version": "1.0.60", "type": "module", "scripts": { "cap:sync": "npx cap sync", diff --git a/apps/electron/main.cjs b/apps/electron/main.cjs index 55dd91f..87603d9 100644 --- a/apps/electron/main.cjs +++ b/apps/electron/main.cjs @@ -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 fs = require('fs'); @@ -36,6 +36,83 @@ const DEFAULT_SETTINGS = { 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() { try { const data = fs.readFileSync(SETTINGS_FILE, 'utf8'); @@ -119,6 +196,13 @@ function createWindow() { // Flush localStorage/sessionStorage to disk before renderer is destroyed 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 if (!mainWindow.isMaximized()) { const bounds = mainWindow.getBounds(); @@ -683,6 +767,258 @@ app.whenReady().then(async () => { // AFK voice channel: expose system idle time to renderer 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 --- const IDLE_THRESHOLD_SECONDS = 300; // 5 minutes let wasIdle = false; diff --git a/apps/electron/package.json b/apps/electron/package.json index 1f12f03..2079bfa 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/electron", "private": true, - "version": "1.0.50", + "version": "1.0.60", "description": "Brycord - Electron app", "author": "Moyettes", "type": "module", diff --git a/apps/electron/preload.cjs b/apps/electron/preload.cjs index 3932688..b1bcbef 100644 --- a/apps/electron/preload.cjs +++ b/apps/electron/preload.cjs @@ -54,3 +54,21 @@ contextBridge.exposeInMainWorld('idleAPI', { removeIdleStateListener: () => ipcRenderer.removeAllListeners('idle-state-changed'), 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), +}); diff --git a/apps/electron/src/platform/index.js b/apps/electron/src/platform/index.js index 37caf96..7186fef 100644 --- a/apps/electron/src/platform/index.js +++ b/apps/electron/src/platform/index.js @@ -54,6 +54,20 @@ const electronPlatform = { close: () => window.windowControls.close(), 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: { checkUpdate: () => window.updateAPI.checkFlatpakUpdate(), }, @@ -65,6 +79,7 @@ const electronPlatform = { hasNativeUpdates: true, hasSearch: true, hasSystemBars: false, + hasRecording: true, }, }; diff --git a/apps/web/package.json b/apps/web/package.json index dddd1f1..9b86024 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/web", "private": true, - "version": "1.0.50", + "version": "1.0.60", "type": "module", "scripts": { "dev": "vite", diff --git a/convex/gifs.ts b/convex/gifs.ts index 766d6cb..1b247d5 100644 --- a/convex/gifs.ts +++ b/convex/gifs.ts @@ -245,18 +245,34 @@ export const categories = action({ 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 items: any[] = Array.isArray(json?.data?.data) - ? json.data.data - : Array.isArray(json?.data) - ? json.data - : []; + const items: any[] = Array.isArray(json?.data?.categories) + ? json.data.categories + : Array.isArray(json?.data?.data) + ? json.data.data + : Array.isArray(json?.data) + ? json.data + : []; const categories: NormalizedCategory[] = items .map((item) => ({ - name: String(item?.name ?? item?.title ?? ""), - image: String(item?.image ?? item?.preview ?? ""), - query: String(item?.query ?? item?.search_term ?? item?.name ?? ""), + name: String( + item?.category ?? item?.name ?? item?.title ?? item?.query ?? "", + ), + image: String( + item?.preview_url ?? item?.image ?? item?.preview ?? "", + ), + query: String( + item?.query ?? item?.search_term ?? item?.category ?? item?.name ?? "", + ), })) .filter((c) => !!c.query); diff --git a/packages/platform-web/src/index.js b/packages/platform-web/src/index.js index c6f8f3b..578da41 100644 --- a/packages/platform-web/src/index.js +++ b/packages/platform-web/src/index.js @@ -34,6 +34,7 @@ const webPlatform = { }, }, windowControls: null, + recording: null, updates: null, voiceService: null, systemBars: null, @@ -45,6 +46,7 @@ const webPlatform = { hasSearch: true, hasVoiceService: false, hasSystemBars: false, + hasRecording: false, }, }; diff --git a/packages/shared/package.json b/packages/shared/package.json index bbf8381..032b5d5 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/shared", "private": true, - "version": "1.0.50", + "version": "1.0.60", "type": "module", "main": "src/App.tsx", "dependencies": { diff --git a/packages/shared/src/assets/font/IBMPlexMono-Italic.woff2 b/packages/shared/src/assets/font/IBMPlexMono-Italic.woff2 new file mode 100644 index 0000000..1c2895f Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexMono-Italic.woff2 differ diff --git a/packages/shared/src/assets/font/IBMPlexMono-Medium.woff2 b/packages/shared/src/assets/font/IBMPlexMono-Medium.woff2 new file mode 100644 index 0000000..910df3e Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexMono-Medium.woff2 differ diff --git a/packages/shared/src/assets/font/IBMPlexMono-Regular.woff2 b/packages/shared/src/assets/font/IBMPlexMono-Regular.woff2 new file mode 100644 index 0000000..4297ee3 Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexMono-Regular.woff2 differ diff --git a/packages/shared/src/assets/font/IBMPlexSans-Bold.woff2 b/packages/shared/src/assets/font/IBMPlexSans-Bold.woff2 new file mode 100644 index 0000000..0d0b09a Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexSans-Bold.woff2 differ diff --git a/packages/shared/src/assets/font/IBMPlexSans-BoldItalic.woff2 b/packages/shared/src/assets/font/IBMPlexSans-BoldItalic.woff2 new file mode 100644 index 0000000..0b2ebb2 Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexSans-BoldItalic.woff2 differ diff --git a/packages/shared/src/assets/font/IBMPlexSans-Italic.woff2 b/packages/shared/src/assets/font/IBMPlexSans-Italic.woff2 new file mode 100644 index 0000000..d3671db Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexSans-Italic.woff2 differ diff --git a/packages/shared/src/assets/font/IBMPlexSans-Medium.woff2 b/packages/shared/src/assets/font/IBMPlexSans-Medium.woff2 new file mode 100644 index 0000000..b142a66 Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexSans-Medium.woff2 differ diff --git a/packages/shared/src/assets/font/IBMPlexSans-MediumItalic.woff2 b/packages/shared/src/assets/font/IBMPlexSans-MediumItalic.woff2 new file mode 100644 index 0000000..3ce2c37 Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexSans-MediumItalic.woff2 differ diff --git a/packages/shared/src/assets/font/IBMPlexSans-Regular.woff2 b/packages/shared/src/assets/font/IBMPlexSans-Regular.woff2 new file mode 100644 index 0000000..231a1ad Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexSans-Regular.woff2 differ diff --git a/packages/shared/src/assets/font/IBMPlexSans-SemiBold.woff2 b/packages/shared/src/assets/font/IBMPlexSans-SemiBold.woff2 new file mode 100644 index 0000000..b96a19c Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexSans-SemiBold.woff2 differ diff --git a/packages/shared/src/assets/font/IBMPlexSans-SemiBoldItalic.woff2 b/packages/shared/src/assets/font/IBMPlexSans-SemiBoldItalic.woff2 new file mode 100644 index 0000000..2855012 Binary files /dev/null and b/packages/shared/src/assets/font/IBMPlexSans-SemiBoldItalic.woff2 differ diff --git a/packages/shared/src/components/channel/EmojiPicker.module.css b/packages/shared/src/components/channel/EmojiPicker.module.css index d33a02e..1f5dfe7 100644 --- a/packages/shared/src/components/channel/EmojiPicker.module.css +++ b/packages/shared/src/components/channel/EmojiPicker.module.css @@ -209,6 +209,19 @@ 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) ───────────────────────── */ .section { margin-top: 4px; diff --git a/packages/shared/src/components/channel/EmojiPicker.tsx b/packages/shared/src/components/channel/EmojiPicker.tsx index 60658ef..256c4b4 100644 --- a/packages/shared/src/components/channel/EmojiPicker.tsx +++ b/packages/shared/src/components/channel/EmojiPicker.tsx @@ -134,6 +134,12 @@ export function EmojiPicker({ const [collapsed, setCollapsed] = useState>(new Set()); const [hovered, setHovered] = useState(null); const [recents, setRecents] = useState(() => 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 // to keep the picker cheap during normal emoji use. @@ -393,7 +399,7 @@ export function EmojiPicker({ return (
- + {activeTab !== 'gifs' && ( -
+
- + setSearch(e.target.value)} - disabled={activeTab !== 'emojis'} + disabled={activeTab !== 'emojis' && activeTab !== 'media'} /> {search && (
)} -
+ {activeTab === 'media' && ( +
+ {( + [ + { 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 ( + + ); + })} +
+ )} + +
{activeTab === 'emojis' && (
{customEmojis.length > 0 && @@ -554,7 +595,11 @@ export function EmojiPicker({
)} -
+
{activeTab === 'gifs' ? ( { @@ -563,12 +608,38 @@ export function EmojiPicker({ }} /> ) : activeTab === 'media' ? ( - savedMedia.length === 0 ? ( -
- Nothing saved yet. Star an attachment to bookmark it - here for quick re-sharing. -
- ) : ( + (() => { + // 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 ( +
+ Nothing saved yet. Star an attachment to bookmark it + here for quick re-sharing. +
+ ); + } + if (filteredSaved.length === 0) { + return ( +
+ No saved media match your filter. +
+ ); + } + return (
- {savedMedia.map((item: any) => { + {filteredSaved.map((item: any) => { const isImage = item.kind === 'image'; const isVideo = item.kind === 'video'; return ( @@ -632,7 +703,8 @@ export function EmojiPicker({ ); })}
- ) + ); + })() ) : activeTab !== 'emojis' ? (
{EXPRESSION_TABS.find((t) => t.key === activeTab)?.label} are coming soon. diff --git a/packages/shared/src/components/channel/GifPicker.module.css b/packages/shared/src/components/channel/GifPicker.module.css index ec8599f..739ee23 100644 --- a/packages/shared/src/components/channel/GifPicker.module.css +++ b/packages/shared/src/components/channel/GifPicker.module.css @@ -7,8 +7,31 @@ flex-direction: column; height: 100%; 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; padding: 10px 12px; + background-color: var(--background-primary); + overflow: hidden; box-sizing: border-box; } @@ -23,6 +46,14 @@ 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 { color: var(--text-tertiary); flex-shrink: 0; @@ -39,6 +70,15 @@ 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 { display: grid; grid-template-columns: 1fr 1fr; @@ -87,6 +127,83 @@ 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 { display: flex; align-items: center; diff --git a/packages/shared/src/components/channel/GifPicker.tsx b/packages/shared/src/components/channel/GifPicker.tsx index e607062..63bf260 100644 --- a/packages/shared/src/components/channel/GifPicker.tsx +++ b/packages/shared/src/components/channel/GifPicker.tsx @@ -33,6 +33,12 @@ interface GifResult { height?: number; } +interface GifCategory { + name: string; + image: string; + query: string; +} + interface GifPickerProps { onSelectGif: (url: string) => void; } @@ -65,6 +71,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) { const [tab, setTab] = useState('home'); const [trending, setTrending] = useState([]); const [searchResults, setSearchResults] = useState([]); + const [categories, setCategories] = useState([]); const [favorites, setFavorites] = useState(() => loadFavorites(), ); @@ -73,19 +80,26 @@ export function GifPicker({ onSelectGif }: GifPickerProps) { const searchAction = useAction(api.gifs.search); const trendingAction = useAction(api.gifs.trending); + const categoriesAction = useAction(api.gifs.categories); - // Load trending feed once when the picker mounts. The result is - // cached for the rest of the session — no need to refetch every - // time the user toggles back to the home tab. + // Load trending feed + categories once when the picker mounts. + // Both are cached server-side (convex/gifs.ts in-memory TTL) so + // 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(() => { let cancelled = false; (async () => { setLoading(true); setError(null); try { - const res: any = await trendingAction({ limit: 24 }); + const [trendingRes, categoriesRes] = await Promise.all([ + trendingAction({ limit: 24 }), + categoriesAction({}), + ]); if (cancelled) return; - setTrending(res?.results ?? []); + setTrending((trendingRes as any)?.results ?? []); + setCategories((categoriesRes as any)?.categories ?? []); } catch (err: any) { if (cancelled) return; setError(err?.message ?? 'Failed to load GIFs.'); @@ -96,7 +110,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) { return () => { cancelled = true; }; - }, [trendingAction]); + }, [trendingAction, categoriesAction]); // Debounced search — fires 350ms after the last keystroke so we // don't hammer the upstream API on every character. @@ -142,36 +156,49 @@ export function GifPicker({ onSelectGif }: GifPickerProps) { // Decide which list to render. Searching always wins — once the // 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 displayList: GifResult[] = useMemo(() => { if (isSearching) return searchResults; if (tab === 'favorites') return favorites; if (tab === 'trending') return trending; - // Home → trending - return trending; + return []; }, [isSearching, searchResults, tab, favorites, trending]); 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 (
-
- - setSearch(e.target.value)} - autoFocus - /> +
+
+ + setSearch(e.target.value)} + autoFocus + /> +
+
{showFeaturedRow && (
+ ))} +
+ ) + ) : loading && displayList.length === 0 ? (
Loading GIFs…
) : error ? (
{error}
@@ -255,6 +313,7 @@ export function GifPicker({ onSelectGif }: GifPickerProps) { })}
)} +
); } diff --git a/packages/shared/src/components/channel/LinkEmbed.module.css b/packages/shared/src/components/channel/LinkEmbed.module.css index 3be6218..440ee95 100644 --- a/packages/shared/src/components/channel/LinkEmbed.module.css +++ b/packages/shared/src/components/channel/LinkEmbed.module.css @@ -14,6 +14,20 @@ 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 { overflow: hidden; padding: 12px 12px 14px 12px; diff --git a/packages/shared/src/components/channel/LinkEmbed.tsx b/packages/shared/src/components/channel/LinkEmbed.tsx index 007c2cb..f3b97ea 100644 --- a/packages/shared/src/components/channel/LinkEmbed.tsx +++ b/packages/shared/src/components/channel/LinkEmbed.tsx @@ -3,6 +3,7 @@ import { ArrowSquareOut, Play } from '@phosphor-icons/react'; import { useAction } from 'convex/react'; import { api } from '../../../../../convex/_generated/api'; import { usePlatform } from '../../platform'; +import { PausedGif } from './PausedGif'; import styles from './LinkEmbed.module.css'; interface UrlPreview { @@ -125,7 +126,15 @@ function useUrlPreview(url: string): UrlPreview | null { 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(null); const [playing, setPlaying] = useState(false); @@ -138,7 +147,7 @@ function DirectMediaEmbed({ url, type }: { url: string; type: 'video' | 'image' }; return ( -
+