1.1.3
This commit is contained in:
12
CLAUDE.md
12
CLAUDE.md
@@ -12,7 +12,7 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
|
||||
- **Backend**: Convex (reactive database + serverless functions)
|
||||
- **Frontend**: React + Vite, shared codebase in `packages/shared/`
|
||||
- **Platforms**: Electron (`apps/electron/`), Web (`apps/web/`), Android via Capacitor (`apps/android/`)
|
||||
- **Platform Abstraction**: `usePlatform()` hook provides crypto, session, settings, idle, links, screenCapture, windowControls, updates APIs
|
||||
- **Platform Abstraction**: `usePlatform()` hook provides crypto, session, settings, idle, links, screenCapture, windowControls, notifications, updates APIs
|
||||
- **Auth**: Zero-knowledge custom auth via Convex mutations (getSalt, verifyUser, createUserWithProfile)
|
||||
- **Real-time**: Convex reactive queries (`useQuery` auto-updates all connected clients)
|
||||
- **Voice/Video**: LiveKit (token generation via Convex Node action)
|
||||
@@ -86,7 +86,7 @@ All Vite configs use `envDir: '../../'` to pick up root `.env.local`.
|
||||
| `@discord-clone/platform-web` | `packages/platform-web/src/` |
|
||||
| `@shared` | `packages/shared/src/` |
|
||||
|
||||
Convex imports from shared components use relative path `../../../../convex/_generated/api` (4 levels up from shared src subdirs).
|
||||
Convex imports from shared code use a relative path whose depth depends on the file location: `../../../../convex/_generated/api` from `packages/shared/src/<dir>/file.tsx` (4 up), `../../../../../convex/_generated/api` from `packages/shared/src/<dir>/<subdir>/file.tsx` (5 up — applies to `components/layout/`, `components/channel/`, etc.). Count: go up until you're at the repo root, then into `convex/`.
|
||||
|
||||
## Platform Abstraction (usePlatform())
|
||||
|
||||
@@ -98,6 +98,7 @@ All platform-specific APIs are accessed via the `usePlatform()` hook:
|
||||
- `links` - openExternal, fetchMetadata
|
||||
- `screenCapture` - getScreenSources
|
||||
- `windowControls` - minimize, maximize, close (Electron only, null on web)
|
||||
- `notifications` - show, setBadge, flashFrame, ensurePermission (Electron: native; Web: Notification API + Badging where available; null on Android for now)
|
||||
- `updates` - checkUpdate (Electron only, null on web)
|
||||
- `features` - hasWindowControls, hasScreenCapture, hasNativeUpdates
|
||||
|
||||
@@ -123,6 +124,13 @@ All platform-specific APIs are accessed via the `usePlatform()` hook:
|
||||
- `randomBytes(size)` returns hex string on both platforms
|
||||
- Keys exchanged as PEM strings (SPKI public, PKCS8 private) for cross-platform interop
|
||||
- TitleBar/UpdateBanner render conditionally based on `platform.features.*`
|
||||
- `MessageContent.tsx` parses Discord-style markdown (**bold**, *italic*, __underline__, ~~strike~~, `code`, ```codeblock```, > blockquote, ||spoiler||) on render — raw text is stored; parsing happens after decrypt. Inline emoji/mention/URL/custom-emoji tokenization runs inside each text leaf
|
||||
- `NotificationManager` (mounted in `AppLayout`) watches `readState.getLatestMessageTimestamps` across all channels. On a new `messageId` when the window is unfocused and `senderId !== self`, it calls `platform.notifications.show` + flash + badge. Own sends and initial snapshot are suppressed. Focus auto-clears flash/badge
|
||||
- Electron update flow is **check-only on launch** (no auto-install). `updater.cjs` emits status events; `platform.updates.{getStatus,downloadAndInstall,onStatusChanged}` expose it. `HeaderUpdateIcon` (mounted in `TitleBar`) renders a green download icon for optional updates and a full-screen blocker for required ones. Mark a release required by starting its release notes with `[REQUIRED]`
|
||||
- Moderation: `bans` table blocks login (`auth.verifyUser`) and message send (`messages.sendInternal`). `auditLog` table is append-only; `audit.logAudit(ctx, {...})` is the helper that mutations call (best-effort — never throws). Permission check: `roles.hasPermission(ctx, userId, key)` — treats `isAdmin` and the `Owner` role as superusers so new permission keys like `ban_members` work without a migration. Server Settings → Bans + Audit Log tabs (desktop + mobile)
|
||||
- Profile banner: `userProfiles.bannerStorageId` (optional), resolved to `bannerUrl` in `auth.getPublicKeys`. `auth.updateProfileInternal` takes `bannerStorageId` + `removeBanner` (the remove path also `ctx.storage.delete`s the blob). All four profile card surfaces (`MemberProfilePopout`, `MemberProfileModal`, `MobileMemberProfileSheet`, `UserAreaProfilePopout`) render the image when present, fall back to accent color when not
|
||||
- Voice messages: mic button in `ChannelTextarea` records via `MediaRecorder` (picks `audio/webm;codecs=opus` where supported), stages the resulting `File` through the existing attachment pipeline — no new backend. Receivers render it via the standard `AttachmentAudio` player. Filename convention: `voice-message-{timestamp}.{webm|ogg|m4a}`. Voice-recorded messages set `isVoiceMessage: true` + `peaks: number[]` + `durationSec` in the attachment metadata; `EncryptedAttachment` dispatches those to `VoiceMessagePlayer` (pill with play button + waveform) instead of the full audio card
|
||||
- Push-to-talk: `voiceSettings.inputMode` is `'voice-activity'` (default) or `'push-to-talk'`. Paired with the `voice.pushToTalk` keybind (marked `pressAndHold: true`). `KeybindContext` dispatches `brycord:keybind:voice.pushToTalk:down` / `:up` events — pressAndHold actions never `preventDefault`, so binding PTT to a letter still lets you type. `VoiceContext` reads the settings via the `brycord:voice-settings-changed` window event, listens for the PTT events, and routes them through a configurable release-delay timer before reconciling the LiveKit mic track. All mic-on/mic-off sources (user mute, deafen, server mute, PTT gate) converge on a single `setMicrophoneEnabled` effect
|
||||
|
||||
## Environment Variables
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 27
|
||||
versionName "1.1.2"
|
||||
versionName "1.1.3"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor } = require('electron');
|
||||
const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor, Notification, nativeImage } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -6,7 +6,7 @@ const fs = require('fs');
|
||||
const SESSION_FILE = path.join(app.getPath('userData'), 'secure-session.dat');
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { checkForUpdates } = require('./updater.cjs');
|
||||
const { checkForUpdates, getStatus: getUpdateStatus, onStatus: onUpdateStatus, downloadAndInstall: downloadAndInstallUpdate } = require('./updater.cjs');
|
||||
|
||||
function loadEnvVar(varName) {
|
||||
const envFiles = [
|
||||
@@ -191,6 +191,16 @@ function createWindow() {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
|
||||
// Auto-clear attention state whenever the user comes back — no
|
||||
// point in flashing / badging a window the user is already
|
||||
// looking at.
|
||||
mainWindow.on('focus', () => {
|
||||
if (mainWindow.isDestroyed()) return;
|
||||
mainWindow.flashFrame(false);
|
||||
mainWindow.setOverlayIcon(null, '');
|
||||
try { app.setBadgeCount(0); } catch {}
|
||||
});
|
||||
|
||||
// Save window state on close
|
||||
mainWindow.on('close', () => {
|
||||
// Flush localStorage/sessionStorage to disk before renderer is destroyed
|
||||
@@ -249,14 +259,25 @@ app.whenReady().then(async () => {
|
||||
createWindow();
|
||||
} else {
|
||||
const splash = createSplashWindow();
|
||||
const noUpdate = await checkForUpdates(splash);
|
||||
if (noUpdate === false) {
|
||||
const shouldOpenMain = await checkForUpdates(splash);
|
||||
if (shouldOpenMain) {
|
||||
if (!splash.isDestroyed()) splash.close();
|
||||
createWindow();
|
||||
}
|
||||
// If update downloaded, quitAndInstall handles restart
|
||||
// Required-update path: splash stays; updater runs quitAndInstall itself.
|
||||
}
|
||||
|
||||
// Forward updater status changes to the renderer so the header
|
||||
// icon and the required-update blocker can react in real time.
|
||||
onUpdateStatus((status) => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('update:status', status);
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('update:get-status', () => getUpdateStatus());
|
||||
ipcMain.handle('update:download-and-install', () => downloadAndInstallUpdate());
|
||||
|
||||
ipcMain.on('window-minimize', () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
if (win) win.minimize();
|
||||
@@ -272,8 +293,49 @@ app.whenReady().then(async () => {
|
||||
const win = BrowserWindow.getFocusedWindow();
|
||||
if (win) win.close();
|
||||
});
|
||||
ipcMain.on('flash-frame', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.flashFrame(true);
|
||||
ipcMain.on('flash-frame', (_event, on) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
// `on` optional for backward compat — legacy call sites pass
|
||||
// no arg and expect a one-shot flash.
|
||||
mainWindow.flashFrame(on !== false);
|
||||
});
|
||||
|
||||
ipcMain.on('notification:show', (_event, opts) => {
|
||||
if (!Notification.isSupported()) return;
|
||||
const { title, body, silent } = opts || {};
|
||||
const n = new Notification({
|
||||
title: String(title ?? 'Brycord'),
|
||||
body: String(body ?? ''),
|
||||
silent: !!silent,
|
||||
icon: path.join(__dirname, 'icon.png'),
|
||||
});
|
||||
n.on('click', () => {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
}
|
||||
});
|
||||
n.show();
|
||||
});
|
||||
|
||||
// Windows: overlay icon on the taskbar button (1x1 badge). Clearing
|
||||
// is `(null, '')`. `count <= 0` clears. Other OSes fall back to
|
||||
// `app.setBadgeCount` which is a no-op on platforms that don't
|
||||
// support it.
|
||||
ipcMain.on('notification:set-badge', (_event, count) => {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
if (n === 0) {
|
||||
mainWindow.setOverlayIcon(null, '');
|
||||
} else {
|
||||
const badgePath = path.join(__dirname, 'icon.png');
|
||||
try {
|
||||
const img = nativeImage.createFromPath(badgePath);
|
||||
mainWindow.setOverlayIcon(img, `${n} unread`);
|
||||
} catch {}
|
||||
}
|
||||
try { app.setBadgeCount(n); } catch {}
|
||||
});
|
||||
|
||||
// Helper to fetch metadata (Zero-Knowledge: Client fetches previews)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/electron",
|
||||
"private": true,
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"description": "Brycord - Electron app",
|
||||
"author": "Moyettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -25,7 +25,13 @@ contextBridge.exposeInMainWorld('windowControls', {
|
||||
minimize: () => ipcRenderer.send('window-minimize'),
|
||||
maximize: () => ipcRenderer.send('window-maximize'),
|
||||
close: () => ipcRenderer.send('window-close'),
|
||||
flashFrame: () => ipcRenderer.send('flash-frame'),
|
||||
flashFrame: (on) => ipcRenderer.send('flash-frame', on !== false),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('notificationAPI', {
|
||||
show: (opts) => ipcRenderer.send('notification:show', opts),
|
||||
setBadge: (count) => ipcRenderer.send('notification:set-badge', count),
|
||||
flashFrame: (on) => ipcRenderer.send('flash-frame', on !== false),
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('appSettings', {
|
||||
@@ -35,6 +41,13 @@ contextBridge.exposeInMainWorld('appSettings', {
|
||||
|
||||
contextBridge.exposeInMainWorld('updateAPI', {
|
||||
checkFlatpakUpdate: () => ipcRenderer.invoke('check-flatpak-update'),
|
||||
getStatus: () => ipcRenderer.invoke('update:get-status'),
|
||||
downloadAndInstall: () => ipcRenderer.invoke('update:download-and-install'),
|
||||
onStatusChanged: (callback) => {
|
||||
const handler = (_event, status) => callback(status);
|
||||
ipcRenderer.on('update:status', handler);
|
||||
return () => ipcRenderer.removeListener('update:status', handler);
|
||||
},
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('sessionPersistence', {
|
||||
|
||||
@@ -52,7 +52,13 @@ const electronPlatform = {
|
||||
minimize: () => window.windowControls.minimize(),
|
||||
maximize: () => window.windowControls.maximize(),
|
||||
close: () => window.windowControls.close(),
|
||||
flashFrame: () => window.windowControls.flashFrame(),
|
||||
flashFrame: (on) => window.windowControls.flashFrame(on),
|
||||
},
|
||||
notifications: {
|
||||
show: (opts) => window.notificationAPI.show(opts),
|
||||
setBadge: (count) => window.notificationAPI.setBadge(count),
|
||||
flashFrame: (on) => window.notificationAPI.flashFrame(on),
|
||||
ensurePermission: async () => 'granted',
|
||||
},
|
||||
recording: {
|
||||
getDefaultFolder: () => window.recordingAPI.getDefaultFolder(),
|
||||
@@ -70,6 +76,9 @@ const electronPlatform = {
|
||||
},
|
||||
updates: {
|
||||
checkUpdate: () => window.updateAPI.checkFlatpakUpdate(),
|
||||
getStatus: () => window.updateAPI.getStatus(),
|
||||
downloadAndInstall: () => window.updateAPI.downloadAndInstall(),
|
||||
onStatusChanged: (cb) => window.updateAPI.onStatusChanged(cb),
|
||||
},
|
||||
systemBars: null,
|
||||
searchDB,
|
||||
@@ -80,6 +89,7 @@ const electronPlatform = {
|
||||
hasSearch: true,
|
||||
hasSystemBars: false,
|
||||
hasRecording: true,
|
||||
hasNotifications: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,52 @@ autoUpdater.logger = log;
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
|
||||
// Remembered across the process so the main window can ask for it
|
||||
// once the renderer is ready. Cleared on successful download so we
|
||||
// don't mislead about a pending install.
|
||||
let lastCheckResult = {
|
||||
hasUpdate: false,
|
||||
required: false,
|
||||
latestVersion: null,
|
||||
currentVersion: null,
|
||||
releaseNotes: null,
|
||||
downloading: false,
|
||||
downloaded: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const statusListeners = new Set();
|
||||
|
||||
function emitStatus() {
|
||||
for (const cb of statusListeners) {
|
||||
try { cb({ ...lastCheckResult }); } catch (err) { log.warn('update status listener threw', err); }
|
||||
}
|
||||
}
|
||||
|
||||
function onStatus(cb) {
|
||||
statusListeners.add(cb);
|
||||
return () => statusListeners.delete(cb);
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
return { ...lastCheckResult };
|
||||
}
|
||||
|
||||
// A release is "required" when its notes start with the `[REQUIRED]`
|
||||
// marker. Keeping the signal in release notes means no new feed file
|
||||
// or schema change — publishers just prefix the message.
|
||||
function isRequired(info) {
|
||||
const notes = typeof info?.releaseNotes === 'string' ? info.releaseNotes : '';
|
||||
return /^\s*\[REQUIRED\]/i.test(notes);
|
||||
}
|
||||
|
||||
// Splash-phase check. Resolves once we know whether to open the main
|
||||
// window (optional or no update) or to force an install (required).
|
||||
// - No update / error → resolve true (main window should open)
|
||||
// - Optional update → resolve true (main window should open; header
|
||||
// icon surfaces the update inside the app)
|
||||
// - Required update → download + quitAndInstall; never resolves
|
||||
function checkForUpdates(splashWindow) {
|
||||
return new Promise((resolve) => {
|
||||
function sendToSplash(js) {
|
||||
@@ -17,45 +63,133 @@ function checkForUpdates(splashWindow) {
|
||||
sendToSplash('setStatus("Checking for updates...")');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', () => {
|
||||
sendToSplash('setStatus("Downloading update...")');
|
||||
autoUpdater.downloadUpdate();
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
const required = isRequired(info);
|
||||
lastCheckResult = {
|
||||
hasUpdate: true,
|
||||
required,
|
||||
latestVersion: info?.version ?? null,
|
||||
currentVersion: autoUpdater.currentVersion?.version ?? null,
|
||||
releaseNotes: typeof info?.releaseNotes === 'string' ? info.releaseNotes : null,
|
||||
downloading: required,
|
||||
downloaded: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
};
|
||||
emitStatus();
|
||||
if (required) {
|
||||
sendToSplash('setStatus("Downloading required update...")');
|
||||
autoUpdater.downloadUpdate().catch((err) => {
|
||||
log.error('downloadUpdate (required) failed:', err);
|
||||
sendToSplash('setStatus("Update failed — opening anyway")');
|
||||
lastCheckResult.error = err?.message || 'download failed';
|
||||
emitStatus();
|
||||
setTimeout(() => resolve(true), 1500);
|
||||
});
|
||||
} else {
|
||||
// Optional — fall through to open main window. The header
|
||||
// icon will surface the offer inside the app.
|
||||
sendToSplash('setStatus("Update available — continuing")');
|
||||
setTimeout(() => resolve(true), 400);
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
const percent = Math.round(progress.percent);
|
||||
const percent = Math.round(progress.percent || 0);
|
||||
lastCheckResult.progress = percent;
|
||||
lastCheckResult.downloading = true;
|
||||
emitStatus();
|
||||
if (lastCheckResult.required) {
|
||||
sendToSplash(`setProgress(${percent})`);
|
||||
sendToSplash(`setStatus("Downloading update... ${percent}%")`);
|
||||
sendToSplash(`setStatus("Downloading required update... ${percent}%")`);
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', () => {
|
||||
lastCheckResult.downloading = false;
|
||||
lastCheckResult.downloaded = true;
|
||||
lastCheckResult.progress = 100;
|
||||
emitStatus();
|
||||
if (lastCheckResult.required) {
|
||||
sendToSplash('setStatus("Installing update...")');
|
||||
sendToSplash('setProgress(100)');
|
||||
setTimeout(() => {
|
||||
autoUpdater.quitAndInstall();
|
||||
}, 1500);
|
||||
}, 1200);
|
||||
}
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
lastCheckResult = {
|
||||
hasUpdate: false,
|
||||
required: false,
|
||||
latestVersion: null,
|
||||
currentVersion: autoUpdater.currentVersion?.version ?? null,
|
||||
releaseNotes: null,
|
||||
downloading: false,
|
||||
downloaded: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
};
|
||||
emitStatus();
|
||||
sendToSplash('setStatus("Up to date!")');
|
||||
sendToSplash('hideProgress()');
|
||||
setTimeout(() => resolve(false), 1000);
|
||||
setTimeout(() => resolve(true), 500);
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
log.error('Auto-updater error:', err);
|
||||
lastCheckResult.error = err?.message || String(err);
|
||||
lastCheckResult.downloading = false;
|
||||
emitStatus();
|
||||
sendToSplash('setStatus("Update check failed")');
|
||||
sendToSplash('hideProgress()');
|
||||
setTimeout(() => resolve(false), 2000);
|
||||
setTimeout(() => resolve(true), 1500);
|
||||
});
|
||||
|
||||
autoUpdater.checkForUpdates().catch((err) => {
|
||||
log.error('checkForUpdates failed:', err);
|
||||
lastCheckResult.error = err?.message || String(err);
|
||||
emitStatus();
|
||||
sendToSplash('setStatus("Update check failed")');
|
||||
sendToSplash('hideProgress()');
|
||||
setTimeout(() => resolve(false), 2000);
|
||||
setTimeout(() => resolve(true), 1500);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { checkForUpdates };
|
||||
// Start downloading an optional update from the renderer. The
|
||||
// splash flow handles required updates itself so the renderer only
|
||||
// ever reaches here for optional ones.
|
||||
async function downloadAndInstall() {
|
||||
if (!lastCheckResult.hasUpdate) return { ok: false, error: 'no update' };
|
||||
if (lastCheckResult.downloaded) {
|
||||
autoUpdater.quitAndInstall();
|
||||
return { ok: true };
|
||||
}
|
||||
if (lastCheckResult.downloading) return { ok: true };
|
||||
try {
|
||||
lastCheckResult.downloading = true;
|
||||
lastCheckResult.progress = 0;
|
||||
lastCheckResult.error = null;
|
||||
emitStatus();
|
||||
await autoUpdater.downloadUpdate();
|
||||
// The 'update-downloaded' handler bumps status; install triggers
|
||||
// the quit-and-install flow the next tick.
|
||||
autoUpdater.quitAndInstall();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
log.error('downloadAndInstall failed:', err);
|
||||
lastCheckResult.downloading = false;
|
||||
lastCheckResult.error = err?.message || String(err);
|
||||
emitStatus();
|
||||
return { ok: false, error: lastCheckResult.error };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkForUpdates,
|
||||
getStatus,
|
||||
onStatus,
|
||||
downloadAndInstall,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/web",
|
||||
"private": true,
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -8,9 +8,11 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as audit from "../audit.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as authActions from "../authActions.js";
|
||||
import type * as authGuard from "../authGuard.js";
|
||||
import type * as bans from "../bans.js";
|
||||
import type * as categories from "../categories.js";
|
||||
import type * as channelKeys from "../channelKeys.js";
|
||||
import type * as channels from "../channels.js";
|
||||
@@ -43,9 +45,11 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
audit: typeof audit;
|
||||
auth: typeof auth;
|
||||
authActions: typeof authActions;
|
||||
authGuard: typeof authGuard;
|
||||
bans: typeof bans;
|
||||
categories: typeof categories;
|
||||
channelKeys: typeof channelKeys;
|
||||
channels: typeof channels;
|
||||
|
||||
127
convex/audit.ts
Normal file
127
convex/audit.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { query } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import type {
|
||||
GenericMutationCtx,
|
||||
GenericQueryCtx,
|
||||
} from "convex/server";
|
||||
import type { DataModel, Id } from "./_generated/dataModel";
|
||||
import { hasPermission } from "./roles";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
|
||||
/**
|
||||
* Known audit actions. Client-facing label mapping lives in the
|
||||
* settings UI; the backend just stores strings so future actions
|
||||
* don't require a schema change.
|
||||
*/
|
||||
export const AUDIT_ACTIONS = {
|
||||
CHANNEL_CREATE: "channel.create",
|
||||
CHANNEL_DELETE: "channel.delete",
|
||||
CHANNEL_RENAME: "channel.rename",
|
||||
CHANNEL_UPDATE_TOPIC: "channel.update_topic",
|
||||
ROLE_CREATE: "role.create",
|
||||
ROLE_DELETE: "role.delete",
|
||||
ROLE_UPDATE: "role.update",
|
||||
ROLE_ASSIGN: "role.assign",
|
||||
ROLE_UNASSIGN: "role.unassign",
|
||||
SERVER_SETTINGS_UPDATE: "server.settings_update",
|
||||
BAN_ADD: "ban.add",
|
||||
BAN_REMOVE: "ban.remove",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Internal helper — call from mutations that mutate server state.
|
||||
* Silent on failure: an audit-write that throws would roll back the
|
||||
* real mutation, which is worse than a missing log entry.
|
||||
*/
|
||||
export async function logAudit(
|
||||
ctx: GenericMutationCtx<DataModel>,
|
||||
args: {
|
||||
actorId: Id<"userProfiles">;
|
||||
action: string;
|
||||
targetType?: string;
|
||||
targetId?: string;
|
||||
targetName?: string;
|
||||
metadata?: unknown;
|
||||
},
|
||||
): Promise<void> {
|
||||
try {
|
||||
await ctx.db.insert("auditLog", {
|
||||
actorId: args.actorId,
|
||||
action: args.action,
|
||||
targetType: args.targetType,
|
||||
targetId: args.targetId,
|
||||
targetName: args.targetName,
|
||||
metadata: args.metadata,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Audit is best-effort. Don't block the caller.
|
||||
}
|
||||
}
|
||||
|
||||
// Any moderator-adjacent permission is enough to view the log. We
|
||||
// don't want to leak the log to @everyone but equally don't want to
|
||||
// hide it behind a narrow permission no role has by default.
|
||||
async function canViewAuditLog(
|
||||
ctx: GenericQueryCtx<DataModel>,
|
||||
userId: Id<"userProfiles">,
|
||||
): Promise<boolean> {
|
||||
return (
|
||||
(await hasPermission(ctx, userId, "ban_members")) ||
|
||||
(await hasPermission(ctx, userId, "manage_channels")) ||
|
||||
(await hasPermission(ctx, userId, "manage_roles")) ||
|
||||
(await hasPermission(ctx, userId, "manage_messages"))
|
||||
);
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
returns: v.array(v.any()),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await canViewAuditLog(ctx, args.actorId))) {
|
||||
throw new Error("Not authorized to view the audit log.");
|
||||
}
|
||||
const limit = Math.min(Math.max(args.limit ?? 200, 1), 500);
|
||||
const rows = await ctx.db
|
||||
.query("auditLog")
|
||||
.withIndex("by_created_at")
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
|
||||
// Walk rows once, de-duping actors via the map itself. Iterating
|
||||
// a Set widens the element type and breaks `ctx.db.get`'s
|
||||
// narrowing — using the map as its own seen-set avoids that.
|
||||
const actors = new Map<
|
||||
string,
|
||||
{ username: string; displayName?: string; avatarUrl: string | null }
|
||||
>();
|
||||
for (const r of rows) {
|
||||
if (actors.has(r.actorId)) continue;
|
||||
const user = await ctx.db.get(r.actorId);
|
||||
if (!user) continue;
|
||||
let avatarUrl: string | null = null;
|
||||
if (user.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
|
||||
}
|
||||
actors.set(r.actorId, {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return rows.map((r) => ({
|
||||
_id: r._id,
|
||||
action: r.action,
|
||||
targetType: r.targetType,
|
||||
targetId: r.targetId,
|
||||
targetName: r.targetName,
|
||||
metadata: r.metadata,
|
||||
createdAt: r.createdAt,
|
||||
actor: actors.get(r.actorId) ?? { username: "unknown", avatarUrl: null },
|
||||
}));
|
||||
},
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import { query, mutation, internalQuery, internalMutation } from "./_generated/s
|
||||
import { v } from "convex/values";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
import { getRolesForUser } from "./roles";
|
||||
import { isBanned } from "./bans";
|
||||
|
||||
async function sha256Hex(input: string): Promise<string> {
|
||||
const buffer = await crypto.subtle.digest(
|
||||
@@ -62,6 +63,9 @@ export const verifyUser = mutation({
|
||||
const hashedDAK = await sha256Hex(args.dak);
|
||||
|
||||
if (hashedDAK === user.hashedAuthKey) {
|
||||
if (await isBanned(ctx, user._id)) {
|
||||
return { error: "You've been banned from this server." };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
userId: user._id,
|
||||
@@ -166,6 +170,7 @@ export const createUserWithProfile = mutation({
|
||||
create_invite: true,
|
||||
embed_links: true,
|
||||
attach_files: true,
|
||||
ban_members: true,
|
||||
},
|
||||
isHoist: true,
|
||||
});
|
||||
@@ -205,6 +210,7 @@ export const getPublicKeys = query({
|
||||
customStatus: v.optional(v.string()),
|
||||
joinSoundUrl: v.optional(v.union(v.string(), v.null())),
|
||||
accentColor: v.optional(v.string()),
|
||||
bannerUrl: v.optional(v.union(v.string(), v.null())),
|
||||
})
|
||||
),
|
||||
handler: async (ctx) => {
|
||||
@@ -219,6 +225,10 @@ export const getPublicKeys = query({
|
||||
if (u.joinSoundStorageId) {
|
||||
joinSoundUrl = await getPublicStorageUrl(ctx, u.joinSoundStorageId);
|
||||
}
|
||||
let bannerUrl: string | null = null;
|
||||
if (u.bannerStorageId) {
|
||||
bannerUrl = await getPublicStorageUrl(ctx, u.bannerStorageId);
|
||||
}
|
||||
results.push({
|
||||
id: u._id,
|
||||
username: u.username,
|
||||
@@ -230,6 +240,7 @@ export const getPublicKeys = query({
|
||||
customStatus: u.customStatus,
|
||||
joinSoundUrl,
|
||||
accentColor: u.accentColor,
|
||||
bannerUrl,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
@@ -251,6 +262,8 @@ export const updateProfileInternal = internalMutation({
|
||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||
removeJoinSound: v.optional(v.boolean()),
|
||||
accentColor: v.optional(v.string()),
|
||||
bannerStorageId: v.optional(v.id("_storage")),
|
||||
removeBanner: v.optional(v.boolean()),
|
||||
},
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
@@ -262,6 +275,17 @@ export const updateProfileInternal = internalMutation({
|
||||
if (args.joinSoundStorageId !== undefined) patch.joinSoundStorageId = args.joinSoundStorageId;
|
||||
if (args.removeJoinSound) patch.joinSoundStorageId = undefined;
|
||||
if (args.accentColor !== undefined) patch.accentColor = args.accentColor;
|
||||
if (args.bannerStorageId !== undefined) patch.bannerStorageId = args.bannerStorageId;
|
||||
if (args.removeBanner) {
|
||||
// Drop the blob from storage too so orphaned banners don't
|
||||
// accumulate. Matches how the rest of this file treats one-off
|
||||
// user uploads.
|
||||
const existing = await ctx.db.get(args.userId);
|
||||
if (existing?.bannerStorageId) {
|
||||
try { await ctx.storage.delete(existing.bannerStorageId); } catch {}
|
||||
}
|
||||
patch.bannerStorageId = undefined;
|
||||
}
|
||||
await ctx.db.patch(args.userId, patch);
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -20,6 +20,8 @@ export const updateProfile = action({
|
||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||
removeJoinSound: v.optional(v.boolean()),
|
||||
accentColor: v.optional(v.string()),
|
||||
bannerStorageId: v.optional(v.id("_storage")),
|
||||
removeBanner: v.optional(v.boolean()),
|
||||
authTimestamp: v.number(),
|
||||
authSignature: v.string(),
|
||||
},
|
||||
@@ -36,6 +38,8 @@ export const updateProfile = action({
|
||||
joinSoundStorageId: args.joinSoundStorageId,
|
||||
removeJoinSound: args.removeJoinSound,
|
||||
accentColor: args.accentColor,
|
||||
bannerStorageId: args.bannerStorageId,
|
||||
removeBanner: args.removeBanner,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
|
||||
160
convex/bans.ts
Normal file
160
convex/bans.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import type { GenericQueryCtx } from "convex/server";
|
||||
import type { DataModel, Id } from "./_generated/dataModel";
|
||||
import { hasPermission, getRolesForUser } from "./roles";
|
||||
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
|
||||
/**
|
||||
* Returns true if the user is currently banned. Callers should
|
||||
* surface this as an explicit `{ error: "Banned" }` at auth time or
|
||||
* throw from sensitive mutations like `messages.send`.
|
||||
*/
|
||||
export async function isBanned(
|
||||
ctx: GenericQueryCtx<DataModel>,
|
||||
userId: Id<"userProfiles">,
|
||||
): Promise<boolean> {
|
||||
const row = await ctx.db
|
||||
.query("bans")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.first();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
export const isUserBanned = query({
|
||||
args: { userId: v.id("userProfiles") },
|
||||
returns: v.boolean(),
|
||||
handler: async (ctx, args) => isBanned(ctx, args.userId),
|
||||
});
|
||||
|
||||
// List all bans with actor + target enrichment for the Bans tab.
|
||||
export const list = query({
|
||||
args: { actorId: v.id("userProfiles") },
|
||||
returns: v.array(v.any()),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
|
||||
throw new Error("You don't have permission to view bans.");
|
||||
}
|
||||
const bans = await ctx.db.query("bans").collect();
|
||||
const out = [];
|
||||
for (const b of bans) {
|
||||
const user = await ctx.db.get(b.userId);
|
||||
const actor = await ctx.db.get(b.bannedBy);
|
||||
let avatarUrl: string | null = null;
|
||||
if (user?.avatarStorageId) {
|
||||
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
|
||||
}
|
||||
out.push({
|
||||
_id: b._id,
|
||||
userId: b.userId,
|
||||
bannedBy: b.bannedBy,
|
||||
reason: b.reason ?? null,
|
||||
createdAt: b.createdAt,
|
||||
user: user
|
||||
? {
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
avatarUrl,
|
||||
}
|
||||
: null,
|
||||
actor: actor
|
||||
? { username: actor.username, displayName: actor.displayName }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
out.sort((a, b) => b.createdAt - a.createdAt);
|
||||
return out;
|
||||
},
|
||||
});
|
||||
|
||||
export const ban = mutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
userId: v.id("userProfiles"),
|
||||
reason: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({ success: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
|
||||
throw new Error("You don't have permission to ban members.");
|
||||
}
|
||||
if (args.actorId === args.userId) {
|
||||
throw new Error("You can't ban yourself.");
|
||||
}
|
||||
const target = await ctx.db.get(args.userId);
|
||||
if (!target) throw new Error("User not found.");
|
||||
|
||||
// Refuse to ban isAdmin or Owner-role bearers. Single-server
|
||||
// deployment can't afford to lock itself out of administration.
|
||||
if (target.isAdmin) {
|
||||
throw new Error("Server admins can't be banned.");
|
||||
}
|
||||
const targetRoles = await getRolesForUser(ctx, args.userId);
|
||||
if (targetRoles.some((r) => r.name === "Owner")) {
|
||||
throw new Error("The Owner can't be banned.");
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("bans")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.first();
|
||||
if (existing) {
|
||||
// Update reason + actor if re-banning.
|
||||
await ctx.db.patch(existing._id, {
|
||||
bannedBy: args.actorId,
|
||||
reason: args.reason,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("bans", {
|
||||
userId: args.userId,
|
||||
bannedBy: args.actorId,
|
||||
reason: args.reason,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.BAN_ADD,
|
||||
targetType: "user",
|
||||
targetId: args.userId,
|
||||
targetName: target.displayName ?? target.username,
|
||||
metadata: args.reason ? { reason: args.reason } : undefined,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const unban = mutation({
|
||||
args: {
|
||||
actorId: v.id("userProfiles"),
|
||||
userId: v.id("userProfiles"),
|
||||
},
|
||||
returns: v.object({ success: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
if (!(await hasPermission(ctx, args.actorId, "ban_members"))) {
|
||||
throw new Error("You don't have permission to unban members.");
|
||||
}
|
||||
const row = await ctx.db
|
||||
.query("bans")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.first();
|
||||
if (!row) return { success: true };
|
||||
|
||||
const target = await ctx.db.get(args.userId);
|
||||
await ctx.db.delete(row._id);
|
||||
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.BAN_REMOVE,
|
||||
targetType: "user",
|
||||
targetId: args.userId,
|
||||
targetName: target?.displayName ?? target?.username,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { v } from "convex/values";
|
||||
import { GenericMutationCtx } from "convex/server";
|
||||
import { DataModel, Id } from "./_generated/dataModel";
|
||||
import { internal } from "./_generated/api";
|
||||
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||
|
||||
type TableWithChannelIndex =
|
||||
| "channelKeys"
|
||||
@@ -74,6 +75,7 @@ export const create = mutation({
|
||||
categoryId: v.optional(v.id("categories")),
|
||||
topic: v.optional(v.string()),
|
||||
position: v.optional(v.number()),
|
||||
actorId: v.optional(v.id("userProfiles")),
|
||||
},
|
||||
returns: v.object({ id: v.id("channels") }),
|
||||
handler: async (ctx, args) => {
|
||||
@@ -112,6 +114,17 @@ export const create = mutation({
|
||||
position,
|
||||
});
|
||||
|
||||
if (args.actorId) {
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.CHANNEL_CREATE,
|
||||
targetType: "channel",
|
||||
targetId: id,
|
||||
targetName: args.name,
|
||||
metadata: { type: args.type || "text" },
|
||||
});
|
||||
}
|
||||
|
||||
return { id };
|
||||
},
|
||||
});
|
||||
@@ -136,6 +149,7 @@ export const rename = mutation({
|
||||
args: {
|
||||
id: v.id("channels"),
|
||||
name: v.string(),
|
||||
actorId: v.optional(v.id("userProfiles")),
|
||||
},
|
||||
returns: v.object({
|
||||
_id: v.id("channels"),
|
||||
@@ -156,7 +170,20 @@ export const rename = mutation({
|
||||
throw new Error("Channel not found");
|
||||
}
|
||||
|
||||
const oldName = channel.name;
|
||||
await ctx.db.patch(args.id, { name: args.name });
|
||||
|
||||
if (args.actorId && oldName !== args.name) {
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.CHANNEL_RENAME,
|
||||
targetType: "channel",
|
||||
targetId: args.id,
|
||||
targetName: args.name,
|
||||
metadata: { from: oldName, to: args.name },
|
||||
});
|
||||
}
|
||||
|
||||
return { ...channel, name: args.name };
|
||||
},
|
||||
});
|
||||
@@ -205,7 +232,10 @@ export const reorderChannels = mutation({
|
||||
|
||||
// Delete channel + cascade messages and keys
|
||||
export const remove = mutation({
|
||||
args: { id: v.id("channels") },
|
||||
args: {
|
||||
id: v.id("channels"),
|
||||
actorId: v.optional(v.id("userProfiles")),
|
||||
},
|
||||
returns: v.object({ success: v.boolean() }),
|
||||
handler: async (ctx, args) => {
|
||||
const channel = await ctx.db.get(args.id);
|
||||
@@ -213,6 +243,9 @@ export const remove = mutation({
|
||||
throw new Error("Channel not found");
|
||||
}
|
||||
|
||||
const deletedName = channel.name;
|
||||
const deletedType = channel.type;
|
||||
|
||||
// Delete reactions for all messages in this channel
|
||||
const messages = await ctx.db
|
||||
.query("messages")
|
||||
@@ -240,6 +273,17 @@ export const remove = mutation({
|
||||
|
||||
await ctx.db.delete(args.id);
|
||||
|
||||
if (args.actorId) {
|
||||
await logAudit(ctx, {
|
||||
actorId: args.actorId,
|
||||
action: AUDIT_ACTIONS.CHANNEL_DELETE,
|
||||
targetType: "channel",
|
||||
targetId: args.id,
|
||||
targetName: deletedName,
|
||||
metadata: { type: deletedType },
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { paginationOptsValidator } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { getPublicStorageUrl } from "./storageUrl";
|
||||
import { getRolesForUser } from "./roles";
|
||||
import { isBanned } from "./bans";
|
||||
|
||||
const DEFAULT_ROLE_COLOR = "#99aab5";
|
||||
|
||||
@@ -252,6 +253,9 @@ export const sendInternal = internalMutation({
|
||||
if (args.ciphertext.length > MAX_CIPHERTEXT_CHARS) {
|
||||
throw new Error("Message too large");
|
||||
}
|
||||
if (await isBanned(ctx, args.senderId)) {
|
||||
throw new Error("You've been banned from this server.");
|
||||
}
|
||||
const id = await ctx.db.insert("messages", {
|
||||
channelId: args.channelId,
|
||||
senderId: args.senderId,
|
||||
|
||||
@@ -82,7 +82,11 @@ export const getAllReadStates = query({
|
||||
},
|
||||
});
|
||||
|
||||
// Get the latest message timestamp per channel (used by Sidebar)
|
||||
// Get the latest message timestamp per channel (used by Sidebar).
|
||||
// Also surfaces `messageId` and `senderId` so the notification
|
||||
// observer can detect genuinely new messages (not just timestamp
|
||||
// ticks from edits) and suppress notifications for the user's own
|
||||
// sends without an extra round-trip.
|
||||
export const getLatestMessageTimestamps = query({
|
||||
args: {
|
||||
channelIds: v.array(v.id("channels")),
|
||||
@@ -91,6 +95,8 @@ export const getLatestMessageTimestamps = query({
|
||||
v.object({
|
||||
channelId: v.id("channels"),
|
||||
latestTimestamp: v.number(),
|
||||
messageId: v.optional(v.id("messages")),
|
||||
senderId: v.optional(v.id("userProfiles")),
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
@@ -105,6 +111,8 @@ export const getLatestMessageTimestamps = query({
|
||||
results.push({
|
||||
channelId,
|
||||
latestTimestamp: Math.floor(latestMsg._creationTime),
|
||||
messageId: latestMsg._id,
|
||||
senderId: latestMsg.senderId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const PERMISSION_KEYS = [
|
||||
"move_members",
|
||||
"mute_members",
|
||||
"manage_nicknames",
|
||||
"ban_members",
|
||||
] as const;
|
||||
|
||||
export async function getRolesForUser(
|
||||
@@ -30,6 +31,31 @@ export async function getRolesForUser(
|
||||
return roles.filter((r): r is Doc<"roles"> => r !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side permission check. Use before any mutation that's
|
||||
* supposed to be gated — the client-side `getMyPermissions` hides
|
||||
* UI, but a crafted client can still call the mutation.
|
||||
*
|
||||
* Treats `isAdmin` bootstrap flag and the "Owner" role as granting
|
||||
* every permission, including ones that don't yet exist on the role
|
||||
* row. That keeps future permission additions working for the
|
||||
* original server owner without requiring a migration pass.
|
||||
*/
|
||||
export async function hasPermission(
|
||||
ctx: GenericQueryCtx<DataModel>,
|
||||
userId: Id<"userProfiles">,
|
||||
key: (typeof PERMISSION_KEYS)[number],
|
||||
): Promise<boolean> {
|
||||
const user = await ctx.db.get(userId);
|
||||
if (!user) return false;
|
||||
if (user.isAdmin) return true;
|
||||
const roles = await getRolesForUser(ctx, userId);
|
||||
if (roles.some((r) => r.name === "Owner")) return true;
|
||||
return roles.some(
|
||||
(r) => (r.permissions as Record<string, boolean> | undefined)?.[key] === true,
|
||||
);
|
||||
}
|
||||
|
||||
// List all roles
|
||||
export const list = query({
|
||||
args: {},
|
||||
@@ -249,14 +275,21 @@ export const getMyPermissions = query({
|
||||
move_members: v.boolean(),
|
||||
mute_members: v.boolean(),
|
||||
manage_nicknames: v.boolean(),
|
||||
ban_members: v.boolean(),
|
||||
}),
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
const roles = await getRolesForUser(ctx, args.userId);
|
||||
// isAdmin or Owner-role bearers get everything — same logic as
|
||||
// `hasPermission`. Keeps UI and server enforcement consistent.
|
||||
const isSuper = !!user?.isAdmin || roles.some((r) => r.name === "Owner");
|
||||
|
||||
const finalPerms: Record<string, boolean> = {};
|
||||
for (const key of PERMISSION_KEYS) {
|
||||
finalPerms[key] = roles.some(
|
||||
(role) => (role.permissions as Record<string, boolean>)?.[key]
|
||||
finalPerms[key] =
|
||||
isSuper ||
|
||||
roles.some(
|
||||
(role) => (role.permissions as Record<string, boolean>)?.[key],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -270,6 +303,7 @@ export const getMyPermissions = query({
|
||||
move_members: boolean;
|
||||
mute_members: boolean;
|
||||
manage_nicknames: boolean;
|
||||
ban_members: boolean;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export default defineSchema({
|
||||
customStatus: v.optional(v.string()),
|
||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||
accentColor: v.optional(v.string()),
|
||||
bannerStorageId: v.optional(v.id("_storage")),
|
||||
}).index("by_username", ["username"]),
|
||||
|
||||
categories: defineTable({
|
||||
@@ -191,6 +192,31 @@ export default defineSchema({
|
||||
.index("by_poll_user_emoji", ["pollId", "userId", "emoji"])
|
||||
.index("by_user", ["userId"]),
|
||||
|
||||
// Banned users — presence of a row = login + send blocked for that
|
||||
// user. Unique by `userId` (enforced by the only caller path —
|
||||
// `bans.ban` does an upsert, not raw insert).
|
||||
bans: defineTable({
|
||||
userId: v.id("userProfiles"),
|
||||
bannedBy: v.id("userProfiles"),
|
||||
reason: v.optional(v.string()),
|
||||
createdAt: v.number(),
|
||||
}).index("by_user", ["userId"]),
|
||||
|
||||
// Append-only log of admin-ish actions: channel / role edits, bans,
|
||||
// server settings changes. Target fields are stringly-typed so
|
||||
// entries can point at any table without a discriminated union.
|
||||
auditLog: defineTable({
|
||||
actorId: v.id("userProfiles"),
|
||||
action: v.string(),
|
||||
targetType: v.optional(v.string()),
|
||||
targetId: v.optional(v.string()),
|
||||
targetName: v.optional(v.string()),
|
||||
metadata: v.optional(v.any()),
|
||||
createdAt: v.number(),
|
||||
})
|
||||
.index("by_created_at", ["createdAt"])
|
||||
.index("by_actor", ["actorId"]),
|
||||
|
||||
savedMedia: defineTable({
|
||||
userId: v.id("userProfiles"),
|
||||
// Convex storage URL — also the dedupe key for a single user.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { query, mutation, internalMutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
import { getRolesForUser } from "./roles";
|
||||
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||
|
||||
export const get = query({
|
||||
args: {},
|
||||
@@ -58,6 +59,13 @@ export const update = mutation({
|
||||
});
|
||||
}
|
||||
|
||||
await logAudit(ctx, {
|
||||
actorId: args.userId,
|
||||
action: AUDIT_ACTIONS.SERVER_SETTINGS_UPDATE,
|
||||
targetType: "server",
|
||||
metadata: { afkChannelId: args.afkChannelId, afkTimeout: args.afkTimeout },
|
||||
});
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
@@ -85,6 +93,7 @@ export const updateName = mutation({
|
||||
}
|
||||
|
||||
const existing = await ctx.db.query("serverSettings").first();
|
||||
const oldName = existing?.serverName;
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, { serverName: name });
|
||||
} else {
|
||||
@@ -94,6 +103,16 @@ export const updateName = mutation({
|
||||
});
|
||||
}
|
||||
|
||||
if (oldName !== name) {
|
||||
await logAudit(ctx, {
|
||||
actorId: args.userId,
|
||||
action: AUDIT_ACTIONS.SERVER_SETTINGS_UPDATE,
|
||||
targetType: "server",
|
||||
targetName: name,
|
||||
metadata: { field: "serverName", from: oldName, to: name },
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
@@ -11,6 +11,50 @@ import SearchDatabase from '@discord-clone/shared/src/utils/SearchDatabase';
|
||||
|
||||
const searchDB = new SearchDatabase(searchStorage, crypto);
|
||||
|
||||
function makeWebNotifications() {
|
||||
if (typeof window === 'undefined' || typeof window.Notification === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
show({ title, body, silent }) {
|
||||
if (Notification.permission !== 'granted') return;
|
||||
try {
|
||||
const n = new Notification(String(title ?? 'Brycord'), {
|
||||
body: String(body ?? ''),
|
||||
silent: !!silent,
|
||||
});
|
||||
n.onclick = () => {
|
||||
try { window.focus(); } catch {}
|
||||
try { n.close(); } catch {}
|
||||
};
|
||||
} catch {}
|
||||
},
|
||||
// Web has the Badging API on some browsers (Chrome, Edge). No-op
|
||||
// where unavailable instead of throwing.
|
||||
setBadge(count) {
|
||||
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||
try {
|
||||
if (n === 0) navigator.clearAppBadge?.();
|
||||
else navigator.setAppBadge?.(n);
|
||||
} catch {}
|
||||
},
|
||||
// Web has no taskbar-flash equivalent — title bounce is the
|
||||
// closest thing but gets intrusive fast. No-op for now.
|
||||
flashFrame() {},
|
||||
async ensurePermission() {
|
||||
if (!('Notification' in window)) return 'unavailable';
|
||||
if (Notification.permission === 'granted') return 'granted';
|
||||
if (Notification.permission === 'denied') return 'denied';
|
||||
try {
|
||||
const result = await Notification.requestPermission();
|
||||
return result;
|
||||
} catch {
|
||||
return 'default';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const webPlatform = {
|
||||
crypto,
|
||||
session,
|
||||
@@ -34,6 +78,7 @@ const webPlatform = {
|
||||
},
|
||||
},
|
||||
windowControls: null,
|
||||
notifications: makeWebNotifications(),
|
||||
recording: null,
|
||||
updates: null,
|
||||
voiceService: null,
|
||||
@@ -47,6 +92,7 @@ const webPlatform = {
|
||||
hasVoiceService: false,
|
||||
hasSystemBars: false,
|
||||
hasRecording: false,
|
||||
hasNotifications: typeof window !== 'undefined' && typeof window.Notification !== 'undefined',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@discord-clone/shared",
|
||||
"private": true,
|
||||
"version": "1.1.2",
|
||||
"version": "1.1.3",
|
||||
"type": "module",
|
||||
"main": "src/App.tsx",
|
||||
"dependencies": {
|
||||
|
||||
@@ -109,8 +109,26 @@ export function AttachmentVideo({
|
||||
useEffect(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
// Android WebView won't paint any frame for a `<video preload="metadata">`
|
||||
// backed by a blob URL — the `#t=0.1` media-fragment trick used by
|
||||
// LinkEmbed doesn't apply to blob URLs. Programmatically seeking to
|
||||
// 0.1s does the same thing cross-browser: it triggers a decode of
|
||||
// that frame so the element renders a pseudo-poster instead of a
|
||||
// black rectangle. Gated on `paused && currentTime === 0` so a user
|
||||
// who hit play before metadata arrived isn't yanked forward.
|
||||
let seeded = false;
|
||||
const onTime = () => setCurrentTime(el.currentTime);
|
||||
const onDur = () => setDuration(el.duration);
|
||||
const onDur = () => {
|
||||
setDuration(el.duration);
|
||||
if (!seeded && el.paused && el.currentTime === 0) {
|
||||
seeded = true;
|
||||
try {
|
||||
el.currentTime = 0.1;
|
||||
} catch {
|
||||
/* some browsers reject the assignment pre-ready — safe to ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onEnded = () => setIsPlaying(false);
|
||||
@@ -147,6 +165,15 @@ export function AttachmentVideo({
|
||||
const handleStartPlay = useCallback(() => {
|
||||
const el = videoRef.current;
|
||||
if (!el) return;
|
||||
// If we seeded currentTime to 0.1s as an Android pseudo-poster,
|
||||
// rewind so playback starts at the real beginning.
|
||||
if (el.currentTime > 0 && el.currentTime < 0.2) {
|
||||
try {
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
/* ignore — falling through to play() is fine */
|
||||
}
|
||||
}
|
||||
setHasStarted(true);
|
||||
void el.play().catch(() => {});
|
||||
}, []);
|
||||
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
ChartBar,
|
||||
Gif,
|
||||
ImageSquare,
|
||||
Lock,
|
||||
Microphone,
|
||||
Paperclip,
|
||||
PlusCircle,
|
||||
Smiley,
|
||||
Sticker,
|
||||
Trash,
|
||||
X,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
@@ -110,6 +113,52 @@ export function ChannelTextarea({
|
||||
const [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||
const mentionRef = useRef<MentionAutocompleteHandle>(null);
|
||||
|
||||
// Voice-message recording state. While `isRecording` is true the
|
||||
// button row swaps into a cancel/stop surface. The MediaRecorder
|
||||
// writes chunks into `recordingChunksRef`; on stop we assemble
|
||||
// them into a single File and stage it as a regular attachment —
|
||||
// the receiver's existing `AttachmentAudio` renderer takes it
|
||||
// from there. Never persists past the component lifetime.
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [recordingSeconds, setRecordingSeconds] = useState(0);
|
||||
const [recordingError, setRecordingError] = useState<string | null>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const micStreamRef = useRef<MediaStream | null>(null);
|
||||
const recordingChunksRef = useRef<BlobPart[]>([]);
|
||||
const recordingMimeRef = useRef<string>('audio/webm');
|
||||
const recordingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
// `pendingOutcomeRef.current` is 'send' or 'cancel', captured by
|
||||
// the click handler and read inside the MediaRecorder 'stop'
|
||||
// event so the handler can decide whether to stage the file.
|
||||
const pendingOutcomeRef = useRef<'send' | 'cancel' | null>(null);
|
||||
// Hold-to-record state (mobile). `isHoldingRef` is set from the
|
||||
// pointer-down handler and cleared on up / cancel / leave — the
|
||||
// async `startVoiceRecording` re-checks it after the mic permission
|
||||
// resolves so a release during the permission prompt cancels cleanly.
|
||||
// `recordingStartedAtRef` gates the release so a quick tap (<1s)
|
||||
// doesn't post empty audio.
|
||||
const isHoldingRef = useRef(false);
|
||||
const recordingStartedAtRef = useRef(0);
|
||||
const HOLD_SEND_THRESHOLD_MS = 1000;
|
||||
// Drag-up-to-lock: once the pointer moves beyond the threshold
|
||||
// above its start position, we commit to a locked recording
|
||||
// (composer swaps to the recording bar) and stop tracking the
|
||||
// hold — release is then via the bar's send/cancel buttons.
|
||||
const dragStartYRef = useRef<number | null>(null);
|
||||
const LOCK_DRAG_THRESHOLD_PX = 60;
|
||||
const [isLocked, setIsLocked] = useState(false);
|
||||
// Waveform samples driven by an AnalyserNode tapped off the same
|
||||
// MediaStream we hand to MediaRecorder. Values are 0..1 RMS.
|
||||
const [waveformLevels, setWaveformLevels] = useState<number[]>([]);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const sampleIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const WAVEFORM_MAX_SAMPLES = 40;
|
||||
// Full sample history kept in a ref so React doesn't churn on every
|
||||
// 80ms tick. Used to compute the final `peaks` array we ship in the
|
||||
// voice-message metadata (downsampled to a fixed bar count on send).
|
||||
const allLevelsRef = useRef<number[]>([]);
|
||||
|
||||
const userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||
const keybinds = useKeybinds();
|
||||
const username =
|
||||
@@ -560,7 +609,10 @@ export function ChannelTextarea({
|
||||
return new Uint8Array(matches.map((b) => parseInt(b, 16)));
|
||||
};
|
||||
|
||||
const uploadOneFile = async (file: File) => {
|
||||
const uploadOneFile = async (
|
||||
file: File,
|
||||
extra?: Record<string, unknown>,
|
||||
) => {
|
||||
// 1. Encrypt the file with a fresh per-file AES key.
|
||||
const fileKey = await crypto.randomBytes(32);
|
||||
const buf = new Uint8Array(await file.arrayBuffer());
|
||||
@@ -596,10 +648,235 @@ export function ChannelTextarea({
|
||||
key: fileKey,
|
||||
iv: encrypted.iv,
|
||||
...(dims && { width: dims.width, height: dims.height }),
|
||||
...(extra || {}),
|
||||
};
|
||||
await sendAttachmentMessage(metadata);
|
||||
};
|
||||
|
||||
/**
|
||||
* Voice messages — records a short clip via MediaRecorder and
|
||||
* stages it as a regular audio attachment on stop. No new backend
|
||||
* plumbing: the blob rides the existing encrypt + upload path,
|
||||
* and the receiver renders it through `AttachmentAudio` like any
|
||||
* other `audio/*` file. Permission prompt is synchronous with the
|
||||
* button press so browsers surface the prompt on a user gesture.
|
||||
*/
|
||||
function pickRecorderMime(): string {
|
||||
if (typeof MediaRecorder === 'undefined') return '';
|
||||
const candidates = [
|
||||
'audio/webm;codecs=opus',
|
||||
'audio/webm',
|
||||
'audio/ogg;codecs=opus',
|
||||
'audio/mp4',
|
||||
];
|
||||
for (const c of candidates) {
|
||||
if (MediaRecorder.isTypeSupported(c)) return c;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const stopRecordingTimer = () => {
|
||||
if (recordingTimerRef.current) {
|
||||
clearInterval(recordingTimerRef.current);
|
||||
recordingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const stopMicStream = () => {
|
||||
if (micStreamRef.current) {
|
||||
for (const track of micStreamRef.current.getTracks()) {
|
||||
try { track.stop(); } catch {}
|
||||
}
|
||||
micStreamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const stopWaveformAnalyser = () => {
|
||||
if (sampleIntervalRef.current) {
|
||||
clearInterval(sampleIntervalRef.current);
|
||||
sampleIntervalRef.current = null;
|
||||
}
|
||||
if (audioCtxRef.current) {
|
||||
try { void audioCtxRef.current.close(); } catch {}
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
};
|
||||
|
||||
const startWaveformAnalyser = (stream: MediaStream) => {
|
||||
try {
|
||||
const Ctx =
|
||||
(window as any).AudioContext ?? (window as any).webkitAudioContext;
|
||||
if (!Ctx) return;
|
||||
const audioCtx: AudioContext = new Ctx();
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
const analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
audioCtxRef.current = audioCtx;
|
||||
analyserRef.current = analyser;
|
||||
const data = new Uint8Array(analyser.frequencyBinCount);
|
||||
sampleIntervalRef.current = setInterval(() => {
|
||||
if (!analyserRef.current) return;
|
||||
analyserRef.current.getByteTimeDomainData(data);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
const v = (data[i] - 128) / 128;
|
||||
sum += v * v;
|
||||
}
|
||||
const rms = Math.sqrt(sum / data.length);
|
||||
// Amplify so quiet speech still shows a visible bar;
|
||||
// clamp at 1 to keep the renderer bounded.
|
||||
const level = Math.min(1, rms * 2.5);
|
||||
allLevelsRef.current.push(level);
|
||||
setWaveformLevels((prev) => {
|
||||
const next =
|
||||
prev.length >= WAVEFORM_MAX_SAMPLES
|
||||
? [...prev.slice(prev.length - WAVEFORM_MAX_SAMPLES + 1), level]
|
||||
: [...prev, level];
|
||||
return next;
|
||||
});
|
||||
}, 80);
|
||||
} catch {
|
||||
// Waveform is best-effort; recording continues without it.
|
||||
}
|
||||
};
|
||||
|
||||
const startVoiceRecording = async () => {
|
||||
setRecordingError(null);
|
||||
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
|
||||
setRecordingError('Recording is not supported here.');
|
||||
return;
|
||||
}
|
||||
if (typeof MediaRecorder === 'undefined') {
|
||||
setRecordingError('Recording is not supported here.');
|
||||
return;
|
||||
}
|
||||
let stream: MediaStream;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} catch (err: any) {
|
||||
setRecordingError(
|
||||
err?.name === 'NotAllowedError'
|
||||
? 'Microphone access was denied.'
|
||||
: 'Could not start recording.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const mime = pickRecorderMime();
|
||||
let rec: MediaRecorder;
|
||||
try {
|
||||
rec = mime ? new MediaRecorder(stream, { mimeType: mime }) : new MediaRecorder(stream);
|
||||
} catch (err: any) {
|
||||
setRecordingError(err?.message ?? 'Could not start recording.');
|
||||
for (const t of stream.getTracks()) try { t.stop(); } catch {}
|
||||
return;
|
||||
}
|
||||
recordingChunksRef.current = [];
|
||||
recordingMimeRef.current = rec.mimeType || mime || 'audio/webm';
|
||||
pendingOutcomeRef.current = null;
|
||||
rec.ondataavailable = (e) => {
|
||||
if (e.data && e.data.size > 0) recordingChunksRef.current.push(e.data);
|
||||
};
|
||||
rec.onstop = () => {
|
||||
stopRecordingTimer();
|
||||
stopMicStream();
|
||||
stopWaveformAnalyser();
|
||||
setWaveformLevels([]);
|
||||
setIsLocked(false);
|
||||
setIsRecording(false);
|
||||
const outcome = pendingOutcomeRef.current;
|
||||
pendingOutcomeRef.current = null;
|
||||
const chunks = recordingChunksRef.current;
|
||||
recordingChunksRef.current = [];
|
||||
if (outcome !== 'send' || chunks.length === 0) return;
|
||||
const type = recordingMimeRef.current;
|
||||
const blob = new Blob(chunks, { type });
|
||||
const ext = type.includes('mp4')
|
||||
? 'm4a'
|
||||
: type.includes('ogg')
|
||||
? 'ogg'
|
||||
: 'webm';
|
||||
const filename = `voice-message-${Date.now()}.${ext}`;
|
||||
const file = new File([blob], filename, { type });
|
||||
// Finalize peaks + duration for the voice-message metadata.
|
||||
// We send the full sample array (80ms cadence) and let the
|
||||
// receiver downsample — keeps the wire payload compact even
|
||||
// on long recordings while preserving waveform fidelity.
|
||||
const startedAt = recordingStartedAtRef.current;
|
||||
const durationSec =
|
||||
startedAt > 0 ? (Date.now() - startedAt) / 1000 : 0;
|
||||
const peaks = allLevelsRef.current.slice();
|
||||
allLevelsRef.current = [];
|
||||
// Send voice messages immediately instead of staging as a
|
||||
// pending attachment — parity with WhatsApp / Discord.
|
||||
void uploadOneFile(file, {
|
||||
isVoiceMessage: true,
|
||||
peaks,
|
||||
durationSec,
|
||||
}).catch((err) => {
|
||||
console.error('Voice-message send failed:', err);
|
||||
setRecordingError(err?.message ?? 'Failed to send voice message.');
|
||||
});
|
||||
};
|
||||
mediaRecorderRef.current = rec;
|
||||
micStreamRef.current = stream;
|
||||
setRecordingSeconds(0);
|
||||
setWaveformLevels([]);
|
||||
allLevelsRef.current = [];
|
||||
setIsRecording(true);
|
||||
rec.start(250);
|
||||
recordingStartedAtRef.current = Date.now();
|
||||
recordingTimerRef.current = setInterval(() => {
|
||||
setRecordingSeconds((s) => s + 1);
|
||||
}, 1000);
|
||||
startWaveformAnalyser(stream);
|
||||
|
||||
// Hold-to-record: if the user already released while we were
|
||||
// waiting on the permission prompt, stop immediately. Sending
|
||||
// still respects the hold-threshold check in the up-handler.
|
||||
// If a lock was committed during the prompt, we leave the
|
||||
// recording running and let the bar's buttons finish it.
|
||||
if (isHoldingRef.current === false && !isLocked) {
|
||||
stopVoiceRecording('cancel');
|
||||
}
|
||||
};
|
||||
|
||||
const stopVoiceRecording = (outcome: 'send' | 'cancel') => {
|
||||
const rec = mediaRecorderRef.current;
|
||||
if (!rec) {
|
||||
setIsRecording(false);
|
||||
stopRecordingTimer();
|
||||
stopMicStream();
|
||||
return;
|
||||
}
|
||||
pendingOutcomeRef.current = outcome;
|
||||
try {
|
||||
rec.stop();
|
||||
} catch {
|
||||
// If `stop` throws (already stopped), run the cleanup path
|
||||
// by hand so the UI doesn't get stuck in the recording state.
|
||||
stopRecordingTimer();
|
||||
stopMicStream();
|
||||
setIsRecording(false);
|
||||
pendingOutcomeRef.current = null;
|
||||
}
|
||||
mediaRecorderRef.current = null;
|
||||
};
|
||||
|
||||
// Stop any active recording on unmount so the mic LED / permission
|
||||
// indicator doesn't linger after the user leaves the channel.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (mediaRecorderRef.current) {
|
||||
try { mediaRecorderRef.current.stop(); } catch {}
|
||||
}
|
||||
stopRecordingTimer();
|
||||
stopMicStream();
|
||||
stopWaveformAnalyser();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Stage files as pending attachments above the composer instead of
|
||||
// uploading immediately. The actual encrypt+upload runs in doSend()
|
||||
// when the user presses Send.
|
||||
@@ -824,7 +1101,14 @@ export function ChannelTextarea({
|
||||
: `Message #${channelName}`;
|
||||
|
||||
return (
|
||||
<div className={styles.outer}>
|
||||
<div
|
||||
className={styles.outer}
|
||||
style={
|
||||
isMobile && isRecording
|
||||
? { paddingLeft: 0, paddingRight: 0 }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{replyTo && onCancelReply && (
|
||||
<div className={styles.replyBar}>
|
||||
<span className={styles.replyText}>
|
||||
@@ -854,12 +1138,130 @@ export function ChannelTextarea({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!(isMobile && isRecording) && (
|
||||
<PendingAttachmentRow
|
||||
attachments={pendingAttachments}
|
||||
onRemove={removePendingAttachment}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.mainWrapper}>
|
||||
<div
|
||||
className={styles.mainWrapper}
|
||||
style={
|
||||
isMobile && isRecording
|
||||
? {
|
||||
background: 'var(--brand-primary)',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
// .outer drops its horizontal padding while recording,
|
||||
// so the bar fills edge-to-edge. Internal padding keeps
|
||||
// the trash / send buttons off the screen edges.
|
||||
padding: '8px',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isMobile && isRecording ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => stopVoiceRecording('cancel')}
|
||||
aria-label="Cancel voice message"
|
||||
title="Cancel"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
minWidth: 36,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--background-primary)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<Trash size={18} weight="fill" />
|
||||
</button>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 36,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '0 12px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--background-primary)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--status-danger, #da373c)',
|
||||
animation: 'brycord-record-pulse 1.4s ease-out infinite',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{Math.floor(recordingSeconds / 60)}:
|
||||
{(recordingSeconds % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
height: 22,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const shown =
|
||||
waveformLevels.length < WAVEFORM_MAX_SAMPLES
|
||||
? [
|
||||
...Array(
|
||||
WAVEFORM_MAX_SAMPLES - waveformLevels.length,
|
||||
).fill(0),
|
||||
...waveformLevels,
|
||||
]
|
||||
: waveformLevels;
|
||||
return shown.map((lvl, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
flex: '1 1 auto',
|
||||
height: `${Math.max(10, lvl * 100)}%`,
|
||||
background: 'rgba(255, 255, 255, 0.9)',
|
||||
borderRadius: 2,
|
||||
minWidth: 2,
|
||||
opacity: lvl === 0 ? 0.35 : 1,
|
||||
}}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.uploadColumn}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -896,6 +1298,38 @@ export function ChannelTextarea({
|
||||
{isEmpty && <div className={styles.placeholder}>{placeholder}</div>}
|
||||
</div>
|
||||
|
||||
{isRecording && !isMobile ? (
|
||||
<div
|
||||
className={styles.buttonContainer}
|
||||
style={{ alignItems: 'center', gap: 10, paddingRight: 6 }}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--status-danger, #da373c)',
|
||||
boxShadow: '0 0 0 0 rgba(218, 55, 60, 0.6)',
|
||||
animation: 'brycord-record-pulse 1.4s ease-out infinite',
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: 'var(--text-primary)', fontSize: 13, fontVariantNumeric: 'tabular-nums' }}>
|
||||
{Math.floor(recordingSeconds / 60)}:
|
||||
{(recordingSeconds % 60).toString().padStart(2, '0')}
|
||||
</span>
|
||||
<Tooltip content="Cancel" placement="top">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.textareaButton}
|
||||
onClick={() => stopVoiceRecording('cancel')}
|
||||
aria-label="Cancel recording"
|
||||
>
|
||||
<Trash size={22} className={styles.textareaButtonIcon} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.buttonContainer} ref={expressionButtonsRef}>
|
||||
<Tooltip
|
||||
content="GIFs"
|
||||
@@ -947,19 +1381,188 @@ export function ChannelTextarea({
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className={styles.sendColumn}>
|
||||
<div className={styles.sendColumn} style={{ position: 'relative' }}>
|
||||
{isMobile && isEmpty && pendingAttachments.length === 0 && !isLocked ? (
|
||||
<>
|
||||
{/* Lock indicator: appears above the mic while the
|
||||
user is holding but has not yet dragged up far
|
||||
enough to lock. Fills briefly when the drag
|
||||
enters the lock zone. */}
|
||||
{isRecording && !isLocked && isHoldingRef.current && (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 'calc(100% + 8px)',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 10,
|
||||
background: 'var(--background-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'hsl(138.353 calc(1 * 38.117%) 56.275% / 1)',
|
||||
pointerEvents: 'none',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.35)',
|
||||
}}
|
||||
>
|
||||
<Lock size={18} weight="fill" />
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sendButton}
|
||||
onClick={doSend}
|
||||
disabled={(isEmpty && pendingAttachments.length === 0) || !channelKey || isUploading}
|
||||
aria-label="Send message"
|
||||
style={
|
||||
isRecording
|
||||
? {
|
||||
background: '#fff',
|
||||
color: 'var(--brand-primary)',
|
||||
}
|
||||
: { background: 'var(--background-tertiary)' }
|
||||
}
|
||||
onPointerDown={(e) => {
|
||||
// Hold-to-record. Pointer capture so the release
|
||||
// fires on this button even if the finger drifts
|
||||
// outside — we cancel on explicit pointerleave /
|
||||
// pointercancel instead.
|
||||
e.preventDefault();
|
||||
try {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
if (isRecording || isHoldingRef.current) return;
|
||||
isHoldingRef.current = true;
|
||||
dragStartYRef.current = e.clientY;
|
||||
void startVoiceRecording();
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
if (!isHoldingRef.current || isLocked) return;
|
||||
if (dragStartYRef.current === null) return;
|
||||
const deltaY = e.clientY - dragStartYRef.current;
|
||||
if (deltaY <= -LOCK_DRAG_THRESHOLD_PX) {
|
||||
// Commit to locked mode — release pointer
|
||||
// capture so the user can lift their finger
|
||||
// freely, and drop the hold flag so the up-
|
||||
// handler doesn't double-stop the recorder.
|
||||
setIsLocked(true);
|
||||
isHoldingRef.current = false;
|
||||
dragStartYRef.current = null;
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
}
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (!isHoldingRef.current) return;
|
||||
isHoldingRef.current = false;
|
||||
dragStartYRef.current = null;
|
||||
try {
|
||||
e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
} catch {}
|
||||
const elapsed = recordingStartedAtRef.current
|
||||
? Date.now() - recordingStartedAtRef.current
|
||||
: 0;
|
||||
stopVoiceRecording(
|
||||
elapsed >= HOLD_SEND_THRESHOLD_MS ? 'send' : 'cancel',
|
||||
);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
if (!isHoldingRef.current) return;
|
||||
isHoldingRef.current = false;
|
||||
dragStartYRef.current = null;
|
||||
stopVoiceRecording('cancel');
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
// Leaving the button while still holding is
|
||||
// treated as a cancel UNLESS the user has
|
||||
// already committed to locked mode (in which
|
||||
// case the drag just moved on to the lock
|
||||
// indicator above).
|
||||
if (!isHoldingRef.current || isLocked) return;
|
||||
isHoldingRef.current = false;
|
||||
dragStartYRef.current = null;
|
||||
stopVoiceRecording('cancel');
|
||||
}}
|
||||
aria-label="Hold to record voice message"
|
||||
title="Hold to record"
|
||||
>
|
||||
{isRecording ? (
|
||||
<ArrowUp size={22} weight="bold" />
|
||||
) : (
|
||||
<Microphone size={22} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.sendButton}
|
||||
style={
|
||||
isMobile && isRecording
|
||||
? { background: '#fff', color: 'var(--brand-primary)' }
|
||||
: undefined
|
||||
}
|
||||
onClick={isRecording ? () => stopVoiceRecording('send') : doSend}
|
||||
disabled={
|
||||
isRecording
|
||||
? recordingSeconds < 1
|
||||
: (isEmpty && pendingAttachments.length === 0) || !channelKey || isUploading
|
||||
}
|
||||
aria-label={isRecording ? 'Send voice message' : 'Send message'}
|
||||
>
|
||||
<ArrowUp size={22} weight="bold" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{recordingError && (
|
||||
<div
|
||||
style={{
|
||||
margin: '4px 12px',
|
||||
padding: '6px 10px',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(218, 55, 60, 0.15)',
|
||||
color: 'var(--status-danger, #da373c)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
role="status"
|
||||
>
|
||||
{recordingError}
|
||||
</div>
|
||||
)}
|
||||
{isMobile &&
|
||||
isRecording &&
|
||||
!isLocked &&
|
||||
createPortal(
|
||||
<div
|
||||
role="status"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 'max(12px, env(safe-area-inset-top))',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '8px 14px',
|
||||
borderRadius: 999,
|
||||
background: 'var(--brand-primary, #5865f2)',
|
||||
color: '#fff',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
boxShadow: '0 6px 20px rgba(0, 0, 0, 0.35)',
|
||||
zIndex: 15000,
|
||||
pointerEvents: 'none',
|
||||
maxWidth: 'calc(100vw - 24px)',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Hold to record. Drag up to lock, or release to send.
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{mentionQuery !== null && (
|
||||
<MentionAutocomplete
|
||||
@@ -1055,6 +1658,19 @@ export function ChannelTextarea({
|
||||
<ChartBar size={20} />
|
||||
<span>Create Poll</span>
|
||||
</button>
|
||||
{!isMobile && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.plusMenuItem}
|
||||
onClick={() => {
|
||||
setShowPlusMenu(false);
|
||||
void startVoiceRecording();
|
||||
}}
|
||||
>
|
||||
<Microphone size={20} />
|
||||
<span>Record Voice Message</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { AttachmentAudio } from './AttachmentAudio';
|
||||
import { AttachmentVideo } from './AttachmentVideo';
|
||||
import { VoiceMessagePlayer } from './VoiceMessagePlayer';
|
||||
|
||||
export interface AttachmentMetadata {
|
||||
type: 'attachment';
|
||||
@@ -15,6 +16,15 @@ export interface AttachmentMetadata {
|
||||
iv: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
/** True when the attachment was recorded in-app as a voice message
|
||||
* (distinguishes it from a regular audio upload so the receiver
|
||||
* renders the Discord-style pill instead of the full audio card). */
|
||||
isVoiceMessage?: boolean;
|
||||
/** Pre-computed amplitude samples (0..1) captured during recording.
|
||||
* Lets the receiver draw the waveform without decoding the blob. */
|
||||
peaks?: number[];
|
||||
/** Recording duration in seconds, also captured at send time. */
|
||||
durationSec?: number;
|
||||
}
|
||||
|
||||
const TAG_HEX_LEN = 32;
|
||||
@@ -239,14 +249,23 @@ export function EncryptedAttachment({ metadata, onImageClick, className }: Attac
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: 360,
|
||||
height: 96,
|
||||
width: metadata.isVoiceMessage ? 280 : 360,
|
||||
height: metadata.isVoiceMessage ? 44 : 96,
|
||||
backgroundColor: 'var(--background-tertiary)',
|
||||
borderRadius: 'var(--radius-lg)',
|
||||
borderRadius: metadata.isVoiceMessage ? 999 : 'var(--radius-lg)',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (metadata.isVoiceMessage) {
|
||||
return (
|
||||
<VoiceMessagePlayer
|
||||
src={url}
|
||||
peaks={metadata.peaks ?? []}
|
||||
durationSec={metadata.durationSec ?? 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AttachmentAudio
|
||||
src={url}
|
||||
|
||||
@@ -37,6 +37,17 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* global.css has a `:focus-visible { outline: 2px solid … }` rule
|
||||
that ties on specificity with the `.editor` selector above — the
|
||||
cascade picks whichever loads last, and in practice the global
|
||||
rule wins, painting a blue ring around the editable box. Bumping
|
||||
specificity via `.editor:focus` / `:focus-visible` pins the
|
||||
outline off for this contenteditable. */
|
||||
.editor:focus,
|
||||
.editor:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-tertiary);
|
||||
|
||||
@@ -65,10 +65,31 @@ const embedImageDimsCache = new Map<string, { w: number; h: number }>();
|
||||
// because a wrong fluid ratio shifts height when the real image lands.
|
||||
const EMBED_IMG_FALLBACK = { w: 400, h: 210 } as const;
|
||||
|
||||
// Direct inline video embeds default to 16:9 — most web video ships at
|
||||
// that ratio. A wrong default just means a little blank space above or
|
||||
// below the video, not a scroll jump.
|
||||
const DIRECT_VIDEO_FALLBACK_RATIO = '16 / 9';
|
||||
// Natural `videoWidth` / `videoHeight` (intrinsic pixel dimensions)
|
||||
// learned from the first `loadedmetadata` event per URL. Fills in
|
||||
// the wrapper aspect-ratio + size so portrait, square, and other
|
||||
// non-16:9 videos don't letterbox or distort.
|
||||
const directVideoDimsCache = new Map<string, { w: number; h: number }>();
|
||||
|
||||
// Embed sizing cap. Mirrors the `.directVideo` CSS max-width /
|
||||
// max-height so the JS-computed wrapper matches the legacy ceiling
|
||||
// while letting aspect-ratio drive layout below it.
|
||||
const DIRECT_VIDEO_MAX_W = 400;
|
||||
const DIRECT_VIDEO_MAX_H = 300;
|
||||
|
||||
// Fallback wrapper size used until `loadedmetadata` fires. Pre-sized
|
||||
// at 16:9 — the overwhelming majority of web video — so the first
|
||||
// paint reserves real space instead of collapsing to the 16×9 that a
|
||||
// literal (16, 9) scale-to-fit would produce.
|
||||
const DIRECT_VIDEO_FALLBACK = { w: 400, h: 225 } as const;
|
||||
|
||||
function fitDirectVideo(w: number, h: number) {
|
||||
if (w <= 0 || h <= 0) return DIRECT_VIDEO_FALLBACK;
|
||||
// Shrink-only: tiny source videos render at their natural size
|
||||
// instead of being blown up to the 400×300 cap.
|
||||
const scale = Math.min(DIRECT_VIDEO_MAX_W / w, DIRECT_VIDEO_MAX_H / h, 1);
|
||||
return { w: Math.round(w * scale), h: Math.round(h * scale) };
|
||||
}
|
||||
|
||||
function normaliseMetadata(raw: any): UrlPreview | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
@@ -208,6 +229,12 @@ function DirectMediaEmbed({
|
||||
}) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
// Lives at the component root so the hook call order is stable
|
||||
// regardless of `type`. Image branches don't read it, but it
|
||||
// still needs to be declared every render.
|
||||
const [videoDims, setVideoDims] = useState<
|
||||
{ w: number; h: number } | null
|
||||
>(() => (type === 'video' ? directVideoDimsCache.get(url) ?? null : null));
|
||||
|
||||
if (type === 'video') {
|
||||
const handlePlay = () => {
|
||||
@@ -220,10 +247,12 @@ function DirectMediaEmbed({
|
||||
// `preload="metadata"` leaves the <video> element at zero height
|
||||
// until `loadedmetadata` fires — that was a measurable source of
|
||||
// scroll jump. Wrap it in an aspect-ratio box so the space is
|
||||
// reserved from the first paint. 16:9 is the overwhelming majority
|
||||
// of web video; when the real metadata lands and differs slightly
|
||||
// the ResizeObserver catches it, but the gross box is already
|
||||
// there.
|
||||
// reserved from the first paint. First mount uses a 16:9
|
||||
// fallback; subsequent mounts hydrate from the module cache so
|
||||
// repeat views of the same URL jump straight to the real
|
||||
// proportions. When `onLoadedMetadata` fires we read
|
||||
// `videoWidth` / `videoHeight` off the element and swap the
|
||||
// wrapper if the cache entry was missing or wrong.
|
||||
//
|
||||
// Appending `#t=0.1` is a media-fragment hint that forces the
|
||||
// browser to seek to 0.1s, which makes it decode and paint that
|
||||
@@ -231,14 +260,20 @@ function DirectMediaEmbed({
|
||||
// blank rectangle for `<video preload="metadata">` because it
|
||||
// only fetches container metadata, not frames.
|
||||
const posterSrc = url.includes('#') ? url : `${url}#t=0.1`;
|
||||
const fit = videoDims
|
||||
? fitDirectVideo(videoDims.w, videoDims.h)
|
||||
: DIRECT_VIDEO_FALLBACK;
|
||||
const ratio = videoDims
|
||||
? `${videoDims.w} / ${videoDims.h}`
|
||||
: `${DIRECT_VIDEO_FALLBACK.w} / ${DIRECT_VIDEO_FALLBACK.h}`;
|
||||
return (
|
||||
<div className={`${styles.embed} ${styles.embedBare}`}>
|
||||
<div
|
||||
className={styles.directVideoWrapper}
|
||||
style={{
|
||||
aspectRatio: DIRECT_VIDEO_FALLBACK_RATIO,
|
||||
width: 400,
|
||||
width: fit.w,
|
||||
maxWidth: '100%',
|
||||
aspectRatio: ratio,
|
||||
}}
|
||||
>
|
||||
<video
|
||||
@@ -247,7 +282,17 @@ function DirectMediaEmbed({
|
||||
src={posterSrc}
|
||||
preload="metadata"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
onLoadedMetadata={() => {
|
||||
onLoadedMetadata={(e) => {
|
||||
const v = e.currentTarget;
|
||||
const w = v.videoWidth;
|
||||
const h = v.videoHeight;
|
||||
if (w > 0 && h > 0) {
|
||||
const prev = directVideoDimsCache.get(url);
|
||||
if (!prev || prev.w !== w || prev.h !== h) {
|
||||
directVideoDimsCache.set(url, { w, h });
|
||||
setVideoDims({ w, h });
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('brycord:attachment-loaded'),
|
||||
);
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.underline {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Fragment, useState, type ReactNode } from 'react';
|
||||
import { getTwemojiUrl } from '../../utils/twemoji';
|
||||
import styles from './MessageContent.module.css';
|
||||
|
||||
@@ -26,21 +26,252 @@ const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/gi;
|
||||
|
||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
|
||||
|
||||
// Escape user-supplied strings for safe inclusion in a regex.
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const CODE_FENCE_REGEX = /```(?:([a-zA-Z0-9_+\-]+)\n)?([\s\S]*?)```/g;
|
||||
|
||||
// --- Markdown AST --------------------------------------------------------
|
||||
|
||||
type InlineMarkType = 'bold' | 'italic' | 'underline' | 'strike' | 'spoiler';
|
||||
|
||||
type MdNode =
|
||||
| { type: 'text'; text: string }
|
||||
| { type: InlineMarkType; children: MdNode[] }
|
||||
| { type: 'inlineCode'; text: string }
|
||||
| { type: 'codeBlock'; lang: string | null; text: string }
|
||||
| { type: 'blockquote'; children: MdNode[] };
|
||||
|
||||
interface InlineDelim {
|
||||
open: string;
|
||||
close: string;
|
||||
type: InlineMarkType | 'inlineCode';
|
||||
}
|
||||
|
||||
// Longer delimiters first so "**" is checked before "*".
|
||||
const INLINE_DELIMS: InlineDelim[] = [
|
||||
{ open: '**', close: '**', type: 'bold' },
|
||||
{ open: '__', close: '__', type: 'underline' },
|
||||
{ open: '~~', close: '~~', type: 'strike' },
|
||||
{ open: '||', close: '||', type: 'spoiler' },
|
||||
{ open: '`', close: '`', type: 'inlineCode' },
|
||||
{ open: '*', close: '*', type: 'italic' },
|
||||
{ open: '_', close: '_', type: 'italic' },
|
||||
];
|
||||
|
||||
function parseMessage(text: string): MdNode[] {
|
||||
return parseBlocks(text);
|
||||
}
|
||||
|
||||
// Block pass 1: fenced code blocks. Content inside is verbatim.
|
||||
function parseBlocks(text: string): MdNode[] {
|
||||
const nodes: MdNode[] = [];
|
||||
CODE_FENCE_REGEX.lastIndex = 0;
|
||||
let cursor = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = CODE_FENCE_REGEX.exec(text))) {
|
||||
if (m.index > cursor) {
|
||||
nodes.push(...parseBlockquotes(text.slice(cursor, m.index)));
|
||||
}
|
||||
nodes.push({
|
||||
type: 'codeBlock',
|
||||
lang: m[1] ?? null,
|
||||
text: m[2].replace(/\n$/, ''),
|
||||
});
|
||||
cursor = m.index + m[0].length;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
nodes.push(...parseBlockquotes(text.slice(cursor)));
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
// Block pass 2: group runs of "> " lines into blockquote nodes.
|
||||
function parseBlockquotes(text: string): MdNode[] {
|
||||
const nodes: MdNode[] = [];
|
||||
const lines = text.split('\n');
|
||||
const isQuote = (s: string) => s === '>' || s.startsWith('> ');
|
||||
let i = 0;
|
||||
while (i < lines.length) {
|
||||
if (isQuote(lines[i])) {
|
||||
const quoted: string[] = [];
|
||||
while (i < lines.length && isQuote(lines[i])) {
|
||||
quoted.push(lines[i] === '>' ? '' : lines[i].slice(2));
|
||||
i++;
|
||||
}
|
||||
nodes.push({ type: 'blockquote', children: parseInline(quoted.join('\n')) });
|
||||
} else {
|
||||
const chunk: string[] = [];
|
||||
while (i < lines.length && !isQuote(lines[i])) {
|
||||
chunk.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
nodes.push(...parseInline(chunk.join('\n')));
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
function parseInline(text: string): MdNode[] {
|
||||
const out: MdNode[] = [];
|
||||
let cursor = 0;
|
||||
while (cursor < text.length) {
|
||||
const found = findInlineToken(text, cursor);
|
||||
if (!found) {
|
||||
if (cursor < text.length) out.push({ type: 'text', text: text.slice(cursor) });
|
||||
break;
|
||||
}
|
||||
if (found.index > cursor) {
|
||||
out.push({ type: 'text', text: text.slice(cursor, found.index) });
|
||||
}
|
||||
out.push(found.node);
|
||||
cursor = found.end;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Walk positions left-to-right. At each position, try delimiters longest-first;
|
||||
// the first one with a valid closer wins.
|
||||
function findInlineToken(
|
||||
text: string,
|
||||
from: number,
|
||||
): { index: number; end: number; node: MdNode } | null {
|
||||
for (let i = from; i < text.length; i++) {
|
||||
// Escape: `\*` means literal `*` — skip delim parsing at this position.
|
||||
if (i > 0 && text[i - 1] === '\\') continue;
|
||||
for (const d of INLINE_DELIMS) {
|
||||
if (!text.startsWith(d.open, i)) continue;
|
||||
const contentStart = i + d.open.length;
|
||||
const closeIdx = findUnescapedClose(text, contentStart, d.close);
|
||||
if (closeIdx < 0 || closeIdx === contentStart) continue;
|
||||
const inner = text.slice(contentStart, closeIdx);
|
||||
const end = closeIdx + d.close.length;
|
||||
let node: MdNode;
|
||||
if (d.type === 'inlineCode') {
|
||||
node = { type: 'inlineCode', text: inner };
|
||||
} else {
|
||||
node = { type: d.type, children: parseInline(inner) };
|
||||
}
|
||||
return { index: i, end, node };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findUnescapedClose(text: string, from: number, close: string): number {
|
||||
let j = from;
|
||||
while (j <= text.length - close.length) {
|
||||
if (text.startsWith(close, j) && text[j - 1] !== '\\') return j;
|
||||
j++;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// --- Render --------------------------------------------------------------
|
||||
|
||||
interface RenderCtx {
|
||||
members: MentionMember[];
|
||||
customEmojiMap: Map<string, string>;
|
||||
}
|
||||
|
||||
function renderNodes(nodes: MdNode[], ctx: RenderCtx, keyPrefix: string): ReactNode[] {
|
||||
return nodes.map((n, i) => renderNode(n, ctx, `${keyPrefix}-${i}`));
|
||||
}
|
||||
|
||||
function renderNode(n: MdNode, ctx: RenderCtx, key: string): ReactNode {
|
||||
switch (n.type) {
|
||||
case 'text':
|
||||
return <Fragment key={key}>{renderInlineText(n.text, ctx, key)}</Fragment>;
|
||||
case 'bold':
|
||||
return (
|
||||
<span key={key} className={styles.bold}>
|
||||
{renderNodes(n.children, ctx, key)}
|
||||
</span>
|
||||
);
|
||||
case 'italic':
|
||||
return (
|
||||
<span key={key} className={styles.italic}>
|
||||
{renderNodes(n.children, ctx, key)}
|
||||
</span>
|
||||
);
|
||||
case 'underline':
|
||||
return (
|
||||
<span key={key} className={styles.underline}>
|
||||
{renderNodes(n.children, ctx, key)}
|
||||
</span>
|
||||
);
|
||||
case 'strike':
|
||||
return (
|
||||
<span key={key} className={styles.strikethrough}>
|
||||
{renderNodes(n.children, ctx, key)}
|
||||
</span>
|
||||
);
|
||||
case 'spoiler':
|
||||
return <Spoiler key={key}>{renderNodes(n.children, ctx, key)}</Spoiler>;
|
||||
case 'inlineCode':
|
||||
return (
|
||||
<code key={key} className={styles.inlineCode}>
|
||||
{n.text}
|
||||
</code>
|
||||
);
|
||||
case 'codeBlock':
|
||||
return <CodeBlock key={key} lang={n.lang} text={n.text} />;
|
||||
case 'blockquote':
|
||||
return (
|
||||
<div key={key} className={styles.blockquote}>
|
||||
<div className={styles.blockquoteBorder} />
|
||||
<div className={styles.blockquoteContent}>
|
||||
{renderNodes(n.children, ctx, key)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function Spoiler({ children }: { children: ReactNode }) {
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const cls = `${styles.spoiler} ${revealed ? styles.spoilerRevealed : styles.spoilerHidden}`;
|
||||
return (
|
||||
<span
|
||||
className={cls}
|
||||
role="button"
|
||||
tabIndex={revealed ? -1 : 0}
|
||||
onClick={(e) => {
|
||||
if (revealed) return;
|
||||
e.stopPropagation();
|
||||
setRevealed(true);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (revealed) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setRevealed(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeBlock({ lang, text }: { lang: string | null; text: string }) {
|
||||
return (
|
||||
<div className={styles.codeBlock}>
|
||||
{lang ? <div className={styles.codeBlockHeader}>{lang}</div> : null}
|
||||
<pre className={styles.codeBlockBody}>
|
||||
<code>{text}</code>
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Inline text scanner (emoji / mention / url / custom emoji) ----------
|
||||
|
||||
interface Token {
|
||||
type: 'emoji' | 'mention' | 'customEmoji' | 'url';
|
||||
index: number;
|
||||
length: number;
|
||||
text: string;
|
||||
// For customEmoji / url: the resolved URL.
|
||||
url?: string;
|
||||
}
|
||||
|
||||
// Find the earliest token (emoji or mention) in `text` starting at `from`.
|
||||
function findNextToken(
|
||||
text: string,
|
||||
from: number,
|
||||
@@ -49,14 +280,10 @@ function findNextToken(
|
||||
): Token | null {
|
||||
let best: Token | null = null;
|
||||
|
||||
// URL — earliest match at or after `from`. Trailing punctuation
|
||||
// is commonly prose, not part of the URL.
|
||||
URL_REGEX.lastIndex = from;
|
||||
const urlMatch = URL_REGEX.exec(text);
|
||||
if (urlMatch) {
|
||||
let matchText = urlMatch[0];
|
||||
const trimmed = matchText.replace(/[),.;!?]+$/, '');
|
||||
matchText = trimmed;
|
||||
let matchText = urlMatch[0].replace(/[),.;!?]+$/, '');
|
||||
best = {
|
||||
type: 'url',
|
||||
index: urlMatch.index,
|
||||
@@ -66,7 +293,6 @@ function findNextToken(
|
||||
};
|
||||
}
|
||||
|
||||
// Emoji — next match at or after `from`.
|
||||
EMOJI_REGEX.lastIndex = from;
|
||||
const emojiMatch = EMOJI_REGEX.exec(text);
|
||||
if (emojiMatch && (!best || emojiMatch.index < best.index)) {
|
||||
@@ -78,8 +304,6 @@ function findNextToken(
|
||||
};
|
||||
}
|
||||
|
||||
// Custom emoji `:shortcode:` — match only when we have a registered
|
||||
// emoji with that name. Unknown shortcodes fall through as plain text.
|
||||
if (customEmojiMap.size > 0) {
|
||||
CUSTOM_EMOJI_REGEX.lastIndex = from;
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -98,18 +322,19 @@ function findNextToken(
|
||||
}
|
||||
break;
|
||||
}
|
||||
// Unknown shortcode — keep searching past this match.
|
||||
}
|
||||
}
|
||||
|
||||
// @everyone
|
||||
const everyoneIdx = text.indexOf('@everyone', from);
|
||||
if (everyoneIdx !== -1 && (!best || everyoneIdx < best.index)) {
|
||||
best = { type: 'mention', index: everyoneIdx, length: '@everyone'.length, text: '@everyone' };
|
||||
best = {
|
||||
type: 'mention',
|
||||
index: everyoneIdx,
|
||||
length: '@everyone'.length,
|
||||
text: '@everyone',
|
||||
};
|
||||
}
|
||||
|
||||
// @{DisplayName} — prefer longest display name match so "@Alice Smith"
|
||||
// beats "@Alice". Sort members by descending display-name length.
|
||||
const sorted = [...members].sort(
|
||||
(a, b) => (b.displayName?.length ?? 0) - (a.displayName?.length ?? 0),
|
||||
);
|
||||
@@ -123,7 +348,6 @@ function findNextToken(
|
||||
}
|
||||
}
|
||||
|
||||
// Generic @word fallback — single run of word chars after an @.
|
||||
const genericRe = /@[\w]+/g;
|
||||
genericRe.lastIndex = from;
|
||||
const gm = genericRe.exec(text);
|
||||
@@ -134,17 +358,12 @@ function findNextToken(
|
||||
return best;
|
||||
}
|
||||
|
||||
function renderContent(
|
||||
text: string,
|
||||
members: MentionMember[],
|
||||
customEmojiMap: Map<string, string>,
|
||||
keyPrefix: string,
|
||||
): ReactNode[] {
|
||||
function renderInlineText(text: string, ctx: RenderCtx, keyPrefix: string): ReactNode[] {
|
||||
const parts: ReactNode[] = [];
|
||||
let cursor = 0;
|
||||
let safety = 0;
|
||||
while (cursor < text.length && safety++ < 10000) {
|
||||
const tok = findNextToken(text, cursor, members, customEmojiMap);
|
||||
const tok = findNextToken(text, cursor, ctx.members, ctx.customEmojiMap);
|
||||
if (!tok) {
|
||||
parts.push(<span key={`${keyPrefix}t${cursor}`}>{text.slice(cursor)}</span>);
|
||||
break;
|
||||
@@ -197,11 +416,11 @@ function renderContent(
|
||||
if (parts.length === 0) {
|
||||
parts.push(<span key={`${keyPrefix}t0`}>{text}</span>);
|
||||
}
|
||||
// Silence unused escapeRegex in case linter complains.
|
||||
void escapeRegex;
|
||||
return parts;
|
||||
}
|
||||
|
||||
// --- Public component ---------------------------------------------------
|
||||
|
||||
export function MessageContent({
|
||||
content,
|
||||
members = [],
|
||||
@@ -209,5 +428,7 @@ export function MessageContent({
|
||||
}: MessageContentProps) {
|
||||
const map = new Map<string, string>();
|
||||
for (const e of customEmojis) map.set(e.name.toLowerCase(), e.url);
|
||||
return <>{renderContent(content, members, map, 'mc')}</>;
|
||||
const tree = parseMessage(content);
|
||||
const ctx: RenderCtx = { members, customEmojiMap: map };
|
||||
return <>{renderNodes(tree, ctx, 'mc')}</>;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
|
||||
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
|
||||
import { ImageLightbox } from './ImageLightbox';
|
||||
import { InlineMessageEditor } from './InlineMessageEditor';
|
||||
import { LinkEmbed } from './LinkEmbed';
|
||||
import type { DecryptedMessage } from './Messages';
|
||||
import { MessageActionBar } from './MessageActionBar';
|
||||
@@ -32,6 +33,10 @@ interface MessageGroupProps {
|
||||
messages: DecryptedMessage[];
|
||||
channelId: string;
|
||||
onReply?: (eventId: string, username: string) => void;
|
||||
/** Supplied by Messages.tsx, which owns the channel keys needed
|
||||
* to re-encrypt the edited payload. Throws on failure so the
|
||||
* InlineMessageEditor can surface the error inline. */
|
||||
onEditMessage?: (messageId: string, newText: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +73,7 @@ function formatFullTime(ts: number): string {
|
||||
});
|
||||
}
|
||||
|
||||
export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps) {
|
||||
export function MessageGroup({ messages, channelId, onReply, onEditMessage }: MessageGroupProps) {
|
||||
const first = messages[0];
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||
@@ -266,6 +271,32 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
id: string;
|
||||
preview: PinnedMessage;
|
||||
} | null>(null);
|
||||
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
||||
const handleStartEdit = (messageId: string) => {
|
||||
const msg = messages.find((m) => m.id === messageId);
|
||||
if (!msg || msg.senderId !== myUserId) return;
|
||||
setEditingMessageId(messageId);
|
||||
};
|
||||
const handleCancelEdit = () => setEditingMessageId(null);
|
||||
const handleSaveEdit = async (messageId: string, newText: string) => {
|
||||
if (!onEditMessage) return;
|
||||
const msg = messages.find((m) => m.id === messageId);
|
||||
if (!msg) return;
|
||||
// Blanking an existing body on save is treated as cancel —
|
||||
// delete flows through its own confirmation modal so we don't
|
||||
// accidentally "edit" a message into emptiness.
|
||||
if (newText.trim().length === 0 && msg.attachments.length === 0) {
|
||||
setEditingMessageId(null);
|
||||
return;
|
||||
}
|
||||
if (newText === msg.content) {
|
||||
setEditingMessageId(null);
|
||||
return;
|
||||
}
|
||||
await onEditMessage(messageId, newText);
|
||||
setEditingMessageId(null);
|
||||
};
|
||||
|
||||
const handleDelete = (messageId: string) => {
|
||||
const msg = messages.find((m) => m.id === messageId);
|
||||
if (!msg) return;
|
||||
@@ -331,6 +362,7 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
setLocalMenuOpenFor(open ? msg.id : null)
|
||||
}
|
||||
onReply={() => onReply?.(msg.id, first.authorName)}
|
||||
onEdit={onEditMessage ? () => handleStartEdit(msg.id) : undefined}
|
||||
onDelete={() => handleDelete(msg.id)}
|
||||
onReact={(e) => openReactPicker(msg.id, e?.currentTarget ?? null)}
|
||||
onQuickReact={async (emoji) => {
|
||||
@@ -473,7 +505,16 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{msg.content && !isGifOnlyContent(msg.content) && (
|
||||
{editingMessageId === msg.id ? (
|
||||
<div className={styles.text}>
|
||||
<InlineMessageEditor
|
||||
initialContent={msg.content}
|
||||
onSave={(newText) => handleSaveEdit(msg.id, newText)}
|
||||
onCancel={handleCancelEdit}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
msg.content && !isGifOnlyContent(msg.content) && (
|
||||
<div className={styles.text}>
|
||||
<MessageContent
|
||||
content={msg.content}
|
||||
@@ -484,6 +525,7 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
<span className={styles.editedTag}> (edited)</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{msg.content &&
|
||||
extractUrls(msg.content)
|
||||
@@ -802,9 +844,11 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
||||
/* forward not implemented yet */
|
||||
}}
|
||||
onEdit={
|
||||
mobileSheetForMsg.senderId === myUserId
|
||||
mobileSheetForMsg.senderId === myUserId && onEditMessage
|
||||
? () => {
|
||||
/* edit not implemented via sheet yet */
|
||||
const id = mobileSheetForMsg.id;
|
||||
setMobileSheetForMsg(null);
|
||||
handleStartEdit(id);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMutation, usePaginatedQuery, useQuery } from 'convex/react';
|
||||
import { useAction, useMutation, usePaginatedQuery, useQuery } from 'convex/react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { usePlatform } from '../../platform';
|
||||
@@ -57,12 +57,26 @@ const TAG_LENGTH = 32;
|
||||
const decryptionCache = new Map<string, string>();
|
||||
const MAX_CACHE = 2000;
|
||||
|
||||
function namespacedKey(userId: string | null, id: string): string {
|
||||
return `${userId ?? 'anon'}:${id}`;
|
||||
// Cache keys are `user:id[:source]`. Passing `source` (the current
|
||||
// ciphertext) scopes cache hits to a specific version of the
|
||||
// message, so when messages.edit swaps the ciphertext a subsequent
|
||||
// fetch naturally misses and re-decrypts. The orphaned entry under
|
||||
// the old source ages out via MAX_CACHE LRU.
|
||||
function namespacedKey(
|
||||
userId: string | null,
|
||||
id: string,
|
||||
source?: string,
|
||||
): string {
|
||||
return source ? `${userId ?? 'anon'}:${id}:${source}` : `${userId ?? 'anon'}:${id}`;
|
||||
}
|
||||
|
||||
function cacheSet(userId: string | null, id: string, content: string) {
|
||||
const key = namespacedKey(userId, id);
|
||||
function cacheSet(
|
||||
userId: string | null,
|
||||
id: string,
|
||||
content: string,
|
||||
source?: string,
|
||||
) {
|
||||
const key = namespacedKey(userId, id, source);
|
||||
if (decryptionCache.size >= MAX_CACHE) {
|
||||
const firstKey = decryptionCache.keys().next().value;
|
||||
if (firstKey !== undefined) decryptionCache.delete(firstKey);
|
||||
@@ -70,8 +84,12 @@ function cacheSet(userId: string | null, id: string, content: string) {
|
||||
decryptionCache.set(key, content);
|
||||
}
|
||||
|
||||
function cacheGet(userId: string | null, id: string): string | undefined {
|
||||
return decryptionCache.get(namespacedKey(userId, id));
|
||||
function cacheGet(
|
||||
userId: string | null,
|
||||
id: string,
|
||||
source?: string,
|
||||
): string | undefined {
|
||||
return decryptionCache.get(namespacedKey(userId, id, source));
|
||||
}
|
||||
|
||||
// Exposed for the logout hook to flush plaintext from memory proactively,
|
||||
@@ -320,6 +338,15 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
|
||||
const [decryptedMap, setDecryptedMap] = useState<Map<string, string>>(new Map());
|
||||
|
||||
// Fingerprint of each decrypted entry's source ciphertext.
|
||||
// `messages.edit` changes the ciphertext while keeping the id,
|
||||
// so the decrypt effects below gate on "has id AND source
|
||||
// matches" — without this, the stale plaintext would stay pinned
|
||||
// until the next reload. Populated from cache hits and successful
|
||||
// decrypts. Refs (not state) so staleness checks see the latest
|
||||
// value without a re-render in between.
|
||||
const plaintextSourceRef = useRef<Map<string, string>>(new Map());
|
||||
|
||||
// Reply previews — separate map keyed by the *child* message id so
|
||||
// the per-row render can grab the parent's plaintext without
|
||||
// re-decrypting on every paint. Filled by the effect below.
|
||||
@@ -347,13 +374,19 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
if (!pagedMessages || pagedMessages.length === 0) return;
|
||||
let changed = false;
|
||||
let next: Map<string, string> | null = null;
|
||||
const sourceRef = plaintextSourceRef.current;
|
||||
for (const msg of pagedMessages as any[]) {
|
||||
const id = msg.id as string;
|
||||
if (decryptedMap.has(id)) continue;
|
||||
const cached = cacheGet(userId, id);
|
||||
const source = msg.ciphertext as string;
|
||||
// Already decrypted against this exact ciphertext — nothing
|
||||
// to hydrate. If the source has changed (edit), fall through
|
||||
// so we can try the cache keyed by the new source.
|
||||
if (decryptedMap.has(id) && sourceRef.get(id) === source) continue;
|
||||
const cached = cacheGet(userId, id, source);
|
||||
if (cached !== undefined) {
|
||||
if (!next) next = new Map(decryptedMap);
|
||||
next.set(id, cached);
|
||||
sourceRef.set(id, source);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
@@ -450,12 +483,20 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
nonce: string;
|
||||
tag: string;
|
||||
key: string;
|
||||
ciphertext: string;
|
||||
};
|
||||
const jobs: Job[] = [];
|
||||
const sourceRef = plaintextSourceRef.current;
|
||||
for (const msg of pagedMessages as any[]) {
|
||||
const id = msg.id as string;
|
||||
if (decryptedMap.has(id)) continue;
|
||||
if (cacheGet(userId, id) !== undefined) continue;
|
||||
const source = msg.ciphertext as string;
|
||||
// Gate on (has decrypted state) AND (source matches).
|
||||
// The source check is what lets edits re-decrypt: after
|
||||
// `messages.edit` swaps the ciphertext, the entry in
|
||||
// `plaintextSourceRef` still points at the old source,
|
||||
// so this branch falls through to queue a fresh decrypt.
|
||||
if (decryptedMap.has(id) && sourceRef.get(id) === source) continue;
|
||||
if (cacheGet(userId, id, source) !== undefined) continue;
|
||||
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
|
||||
jobs.push({ kind: 'sentinel', id, value: '[Invalid Encrypted Message]' });
|
||||
continue;
|
||||
@@ -474,6 +515,7 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
tag: msg.ciphertext.slice(-TAG_LENGTH),
|
||||
nonce: msg.nonce,
|
||||
key: keyForVersion,
|
||||
ciphertext: msg.ciphertext,
|
||||
});
|
||||
}
|
||||
if (jobs.length === 0) return;
|
||||
@@ -484,7 +526,7 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
const results = await Promise.all(
|
||||
jobs.map(async (j) => {
|
||||
if (j.kind === 'sentinel') {
|
||||
return { id: j.id, value: j.value, cache: false };
|
||||
return { id: j.id, value: j.value, cache: false, source: null as string | null };
|
||||
}
|
||||
try {
|
||||
const plaintext = await crypto.decryptData(
|
||||
@@ -493,19 +535,22 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
j.nonce,
|
||||
j.tag,
|
||||
);
|
||||
return { id: j.id, value: plaintext, cache: true };
|
||||
return { id: j.id, value: plaintext, cache: true, source: j.ciphertext };
|
||||
} catch {
|
||||
return { id: j.id, value: '[Unable to decrypt]', cache: false };
|
||||
return { id: j.id, value: '[Unable to decrypt]', cache: false, source: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
if (cancelled) return;
|
||||
const next = new Map(decryptedMap);
|
||||
for (const r of results) {
|
||||
if (r.cache) cacheSet(userId, r.id, r.value);
|
||||
next.set(r.id, r.value);
|
||||
if (r.cache && r.source) cacheSet(userId, r.id, r.value, r.source);
|
||||
if (r.source) plaintextSourceRef.current.set(r.id, r.source);
|
||||
}
|
||||
setDecryptedMap(next);
|
||||
setDecryptedMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const r of results) next.set(r.id, r.value);
|
||||
return next;
|
||||
});
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -708,6 +753,55 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
});
|
||||
}, [pagedMessages, decryptedMap, replyPreviewMap, channelId]);
|
||||
|
||||
// ── Edit flow ───────────────────────────────────────────────
|
||||
// Mirrors the send path: we re-encrypt the new plaintext under
|
||||
// the message's *original* keyVersion key (so the server's
|
||||
// unchanged `keyVersion` still decrypts), re-sign the ciphertext,
|
||||
// and produce an `edit:...` auth signature for the action guard.
|
||||
// Bails silently if we no longer have the key for that version —
|
||||
// shouldn't happen in practice (rotations keep old entries) but
|
||||
// we'd rather cancel than corrupt a message.
|
||||
const editMessageAction = useAction(api.messageActions.edit);
|
||||
const handleEditMessage = useCallback(
|
||||
async (messageId: string, newText: string) => {
|
||||
if (!pagedMessages || !userId) throw new Error('Not ready.');
|
||||
const signingKey =
|
||||
typeof sessionStorage !== 'undefined'
|
||||
? sessionStorage.getItem('signingKey')
|
||||
: null;
|
||||
if (!signingKey) throw new Error('No signing key in session.');
|
||||
const raw = (pagedMessages as any[]).find((m: any) => m.id === messageId);
|
||||
if (!raw) throw new Error('Message not in current page.');
|
||||
const msgKeyVersion = Number(raw.key_version ?? 1);
|
||||
const key = channelKeysByVersion.get(msgKeyVersion);
|
||||
if (!key) throw new Error('Missing channel key for this message.');
|
||||
// Messages with attachments wrap the caption as { text } — preserve
|
||||
// that envelope so the attachment list doesn't get dropped. Pure
|
||||
// text messages stay plain strings for forward-compat with older
|
||||
// decryption paths that don't `JSON.parse`.
|
||||
const hadAttachments = Array.isArray(raw?.attachments) && raw.attachments.length > 0;
|
||||
const payload = hadAttachments ? JSON.stringify({ text: newText }) : newText;
|
||||
const { content, iv, tag } = await crypto.encryptData(payload, key);
|
||||
const ciphertext = content + tag;
|
||||
const signature = await crypto.signMessage(signingKey, ciphertext);
|
||||
const authTimestamp = Date.now();
|
||||
const authSignature = await crypto.signMessage(
|
||||
signingKey,
|
||||
`edit:${messageId}:${userId}:${authTimestamp}`,
|
||||
);
|
||||
await editMessageAction({
|
||||
id: messageId as any,
|
||||
userId: userId as any,
|
||||
ciphertext,
|
||||
nonce: iv,
|
||||
signature,
|
||||
authTimestamp,
|
||||
authSignature,
|
||||
});
|
||||
},
|
||||
[pagedMessages, userId, channelKeysByVersion, crypto, editMessageAction],
|
||||
);
|
||||
|
||||
// Pinned-to-bottom tracking. Matches the new UI's approach: a single
|
||||
// boolean in a ref, updated on every onScroll event via isNearBottom.
|
||||
// MutationObserver + ResizeObserver below drive all auto-scroll
|
||||
@@ -1172,6 +1266,7 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
||||
messages={item.group as any}
|
||||
channelId={channelId}
|
||||
onReply={onReply}
|
||||
onEditMessage={handleEditMessage}
|
||||
/>,
|
||||
);
|
||||
} else {
|
||||
|
||||
214
packages/shared/src/components/channel/VoiceMessagePlayer.tsx
Normal file
214
packages/shared/src/components/channel/VoiceMessagePlayer.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Pause, Play } from '@phosphor-icons/react';
|
||||
|
||||
interface VoiceMessagePlayerProps {
|
||||
src: string;
|
||||
/** 0..1 amplitude samples captured while the message was recorded.
|
||||
* Empty arrays render a flat row — still useful for rare cases
|
||||
* where peaks weren't captured (older sends, permission blips). */
|
||||
peaks: number[];
|
||||
/** Duration captured at send time. `<audio>`'s own metadata is
|
||||
* unreliable for short WebM recordings, so we treat this as the
|
||||
* authoritative duration and only fall back to the element if it's
|
||||
* finite + nonzero. */
|
||||
durationSec: number;
|
||||
}
|
||||
|
||||
const BAR_COUNT = 40;
|
||||
const BAR_GAP = 2;
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (!Number.isFinite(seconds) || seconds < 0) return '0:00';
|
||||
const total = Math.floor(seconds);
|
||||
const m = Math.floor(total / 60);
|
||||
const s = total % 60;
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Downsample an arbitrary-length peaks array into exactly `BAR_COUNT`
|
||||
// bars by averaging buckets. Upsamples (repeats) when the input is
|
||||
// shorter than BAR_COUNT so the row never looks sparse.
|
||||
function resampleToBars(peaks: number[]): number[] {
|
||||
if (peaks.length === 0) return new Array(BAR_COUNT).fill(0.1);
|
||||
if (peaks.length === BAR_COUNT) return peaks;
|
||||
const out: number[] = new Array(BAR_COUNT);
|
||||
if (peaks.length < BAR_COUNT) {
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const idx = Math.floor((i / BAR_COUNT) * peaks.length);
|
||||
out[i] = peaks[idx] ?? 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const bucketSize = peaks.length / BAR_COUNT;
|
||||
for (let i = 0; i < BAR_COUNT; i++) {
|
||||
const start = Math.floor(i * bucketSize);
|
||||
const end = Math.floor((i + 1) * bucketSize);
|
||||
let sum = 0;
|
||||
let n = 0;
|
||||
for (let j = start; j < end; j++) {
|
||||
sum += peaks[j] ?? 0;
|
||||
n += 1;
|
||||
}
|
||||
out[i] = n > 0 ? sum / n : 0;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function VoiceMessagePlayer({ src, peaks, durationSec }: VoiceMessagePlayerProps) {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [elementDuration, setElementDuration] = useState(0);
|
||||
|
||||
const bars = useMemo(() => resampleToBars(peaks), [peaks]);
|
||||
// Prefer the send-time duration; the `<audio>` element's own
|
||||
// metadata can return `Infinity` for short WebM clips until playback
|
||||
// reaches the end, which makes progress math look broken.
|
||||
const effectiveDuration =
|
||||
durationSec > 0
|
||||
? durationSec
|
||||
: Number.isFinite(elementDuration) && elementDuration > 0
|
||||
? elementDuration
|
||||
: 0;
|
||||
|
||||
useEffect(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el) return;
|
||||
const onTime = () => setCurrentTime(el.currentTime);
|
||||
const onDuration = () => setElementDuration(el.duration);
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
setCurrentTime(0);
|
||||
};
|
||||
el.addEventListener('timeupdate', onTime);
|
||||
el.addEventListener('loadedmetadata', onDuration);
|
||||
el.addEventListener('durationchange', onDuration);
|
||||
el.addEventListener('play', onPlay);
|
||||
el.addEventListener('pause', onPause);
|
||||
el.addEventListener('ended', onEnded);
|
||||
return () => {
|
||||
el.removeEventListener('timeupdate', onTime);
|
||||
el.removeEventListener('loadedmetadata', onDuration);
|
||||
el.removeEventListener('durationchange', onDuration);
|
||||
el.removeEventListener('play', onPlay);
|
||||
el.removeEventListener('pause', onPause);
|
||||
el.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleTogglePlay = useCallback(() => {
|
||||
const el = audioRef.current;
|
||||
if (!el || !src) return;
|
||||
if (el.paused) {
|
||||
void el.play().catch(() => {});
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
}, [src]);
|
||||
|
||||
const handleBarClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const el = audioRef.current;
|
||||
if (!el || effectiveDuration <= 0) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const ratio = Math.max(
|
||||
0,
|
||||
Math.min(1, (e.clientX - rect.left) / rect.width),
|
||||
);
|
||||
el.currentTime = ratio * effectiveDuration;
|
||||
setCurrentTime(el.currentTime);
|
||||
},
|
||||
[effectiveDuration],
|
||||
);
|
||||
|
||||
const progress =
|
||||
effectiveDuration > 0
|
||||
? Math.max(0, Math.min(1, currentTime / effectiveDuration))
|
||||
: 0;
|
||||
const playedBarIdx = Math.floor(progress * BAR_COUNT);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '6px 12px 6px 6px',
|
||||
background: 'var(--background-secondary)',
|
||||
borderRadius: 999,
|
||||
maxWidth: 340,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePlay}
|
||||
aria-label={isPlaying ? 'Pause voice message' : 'Play voice message'}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
minWidth: 32,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--brand-primary, #5865f2)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} weight="fill" />
|
||||
) : (
|
||||
<Play size={16} weight="fill" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
onClick={handleBarClick}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 80,
|
||||
height: 24,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: BAR_GAP,
|
||||
cursor: effectiveDuration > 0 ? 'pointer' : 'default',
|
||||
}}
|
||||
>
|
||||
{bars.map((lvl, i) => (
|
||||
<span
|
||||
key={i}
|
||||
style={{
|
||||
flex: '1 1 auto',
|
||||
height: `${Math.max(15, lvl * 100)}%`,
|
||||
background:
|
||||
i < playedBarIdx
|
||||
? 'var(--brand-primary, #5865f2)'
|
||||
: 'var(--text-tertiary, rgba(255, 255, 255, 0.35))',
|
||||
borderRadius: 2,
|
||||
minWidth: 2,
|
||||
transition: 'background 0.1s linear',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 12,
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{formatTime(currentTime)} / {formatTime(effectiveDuration)}
|
||||
</span>
|
||||
<audio ref={audioRef} src={src || undefined} preload="metadata" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { MobileCreateChannelPage } from './MobileCreateChannelPage';
|
||||
import { MobileCreateCategoryPage } from './MobileCreateCategoryPage';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { UpdateBanner } from './UpdateBanner';
|
||||
import { NotificationManager } from './NotificationManager';
|
||||
|
||||
/**
|
||||
* AppLayout — checks session via sessionStorage (matches App.tsx AuthGuard),
|
||||
@@ -273,6 +274,7 @@ export function AppLayout() {
|
||||
)}
|
||||
<RecordingRecoveryModal />
|
||||
<UpdateBanner />
|
||||
<NotificationManager myUserId={myUserId} />
|
||||
</KeybindProvider>
|
||||
</PresenceProvider>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
.wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
.button {
|
||||
width: 46px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: hsl(138.353 calc(1 * 38.117%) 56.275% / 1);
|
||||
transition: background-color 0.12s, filter 0.12s;
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
background-color: var(--background-modifier-hover);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.popover {
|
||||
position: absolute;
|
||||
top: 34px;
|
||||
right: 0;
|
||||
min-width: 260px;
|
||||
background: var(--background-floating, var(--background-secondary));
|
||||
border: 1px solid var(--background-tertiary);
|
||||
border-radius: var(--radius-md, 8px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
padding: 12px 14px;
|
||||
color: var(--text-primary);
|
||||
z-index: 20001;
|
||||
}
|
||||
|
||||
.popoverTitle {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.popoverSubtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.progressTrack {
|
||||
height: 4px;
|
||||
background: var(--background-tertiary);
|
||||
border-radius: var(--radius-full, 999px);
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: hsl(138.353 calc(1 * 38.117%) 56.275% / 1);
|
||||
transition: width 0.2s;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: hsl(138.353 calc(1 * 38.117%) 56.275% / 1);
|
||||
color: #0a1d12;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
.primaryBtn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
padding: 6px 12px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.secondaryBtn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Required-update blocker overlay */
|
||||
.blocker {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 30000;
|
||||
}
|
||||
|
||||
.blockerCard {
|
||||
background: var(--background-primary);
|
||||
border-radius: var(--radius-lg, 12px);
|
||||
padding: 24px 28px;
|
||||
max-width: 420px;
|
||||
width: calc(100% - 32px);
|
||||
text-align: center;
|
||||
border: 1px solid var(--background-tertiary);
|
||||
}
|
||||
|
||||
.blockerCard h2 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.blockerCard p {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.blockerCard .primaryBtn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
194
packages/shared/src/components/layout/HeaderUpdateIcon.tsx
Normal file
194
packages/shared/src/components/layout/HeaderUpdateIcon.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { usePlatform } from '../../platform';
|
||||
import styles from './HeaderUpdateIcon.module.css';
|
||||
|
||||
interface UpdateStatus {
|
||||
hasUpdate: boolean;
|
||||
required: boolean;
|
||||
latestVersion: string | null;
|
||||
currentVersion: string | null;
|
||||
releaseNotes: string | null;
|
||||
downloading: boolean;
|
||||
downloaded: boolean;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const INITIAL: UpdateStatus = {
|
||||
hasUpdate: false,
|
||||
required: false,
|
||||
latestVersion: null,
|
||||
currentVersion: null,
|
||||
releaseNotes: null,
|
||||
downloading: false,
|
||||
downloaded: false,
|
||||
progress: 0,
|
||||
error: null,
|
||||
};
|
||||
|
||||
// Strip the `[REQUIRED]` marker from the notes so the popover doesn't
|
||||
// repeat information the UI itself already conveys.
|
||||
function cleanNotes(notes: string | null): string {
|
||||
if (!notes) return '';
|
||||
return notes.replace(/^\s*\[REQUIRED\]\s*/i, '').trim();
|
||||
}
|
||||
|
||||
function UpdateArrow() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2a1 1 0 0 1 1 1v10.59l3.3-3.3a1 1 0 1 1 1.4 1.42l-5 5a1 1 0 0 1-1.4 0l-5-5a1 1 0 1 1 1.4-1.42l3.3 3.3V3a1 1 0 0 1 1-1M3 20a1 1 0 1 0 0 2h18a1 1 0 1 0 0-2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function HeaderUpdateIcon() {
|
||||
const platform = usePlatform() as any;
|
||||
const updates = platform?.updates ?? null;
|
||||
const hasInApp =
|
||||
typeof updates?.getStatus === 'function' &&
|
||||
typeof updates?.downloadAndInstall === 'function';
|
||||
|
||||
const [status, setStatus] = useState<UpdateStatus>(INITIAL);
|
||||
const [open, setOpen] = useState(false);
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasInApp) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const current = await updates.getStatus();
|
||||
if (!cancelled && current) setStatus({ ...INITIAL, ...current });
|
||||
} catch {}
|
||||
})();
|
||||
const off = updates.onStatusChanged?.((next: UpdateStatus) => {
|
||||
setStatus({ ...INITIAL, ...next });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (typeof off === 'function') off();
|
||||
};
|
||||
}, [hasInApp, updates]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocClick = (e: MouseEvent) => {
|
||||
if (wrapRef.current && !wrapRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', onDocClick);
|
||||
return () => document.removeEventListener('mousedown', onDocClick);
|
||||
}, [open]);
|
||||
|
||||
if (!hasInApp) return null;
|
||||
if (!status.hasUpdate) return null;
|
||||
|
||||
const versionLabel = status.latestVersion ? `v${status.latestVersion}` : 'a new version';
|
||||
const currentLabel = status.currentVersion ? ` (you have v${status.currentVersion})` : '';
|
||||
const notes = cleanNotes(status.releaseNotes);
|
||||
|
||||
const onInstall = () => {
|
||||
if (!updates?.downloadAndInstall) return;
|
||||
void updates.downloadAndInstall();
|
||||
};
|
||||
|
||||
// Required update: render as a blocking overlay instead of a
|
||||
// silent icon. The user must update to continue.
|
||||
if (status.required) {
|
||||
return (
|
||||
<div className={styles.blocker} role="alertdialog" aria-modal="true">
|
||||
<div className={styles.blockerCard}>
|
||||
<h2>Update required</h2>
|
||||
<p>
|
||||
{`${versionLabel} is required to keep using the app${currentLabel}.`}
|
||||
{notes ? `\n\n${notes}` : ''}
|
||||
</p>
|
||||
{status.downloading && (
|
||||
<div className={styles.progressTrack}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${Math.max(2, status.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
onClick={onInstall}
|
||||
disabled={status.downloading}
|
||||
>
|
||||
{status.downloading
|
||||
? `Downloading… ${Math.round(status.progress)}%`
|
||||
: status.downloaded
|
||||
? 'Install and restart'
|
||||
: 'Update now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrap} ref={wrapRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.button}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-label={`Update to ${versionLabel}`}
|
||||
title={`Update to ${versionLabel}`}
|
||||
>
|
||||
<UpdateArrow />
|
||||
</button>
|
||||
{open && (
|
||||
<div className={styles.popover} role="dialog">
|
||||
<div className={styles.popoverTitle}>{`Update to ${versionLabel}`}</div>
|
||||
<div className={styles.popoverSubtitle}>
|
||||
{notes ||
|
||||
`A new version is available${currentLabel}. You can keep using the current version, or update now.`}
|
||||
</div>
|
||||
{status.downloading && (
|
||||
<div className={styles.progressTrack}>
|
||||
<div
|
||||
className={styles.progressFill}
|
||||
style={{ width: `${Math.max(2, status.progress)}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.secondaryBtn}
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={status.downloading}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryBtn}
|
||||
onClick={onInstall}
|
||||
disabled={status.downloading}
|
||||
>
|
||||
{status.downloading
|
||||
? `…${Math.round(status.progress)}%`
|
||||
: status.downloaded
|
||||
? 'Restart'
|
||||
: 'Update now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
112
packages/shared/src/components/layout/NotificationManager.tsx
Normal file
112
packages/shared/src/components/layout/NotificationManager.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useQuery } from 'convex/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { usePlatform } from '../../platform';
|
||||
|
||||
interface Props {
|
||||
myUserId: string | null;
|
||||
}
|
||||
|
||||
// Silent observer that fires OS notifications when new messages
|
||||
// arrive in any channel the user can see while the app window is
|
||||
// unfocused. Own sends are ignored. Nothing is rendered — this
|
||||
// component is just a place to park the effect at app-layout scope
|
||||
// so it stays mounted across navigations.
|
||||
export function NotificationManager({ myUserId }: Props) {
|
||||
const platform = usePlatform() as any;
|
||||
const notifications = platform?.notifications ?? null;
|
||||
const channels = useQuery(api.channels.list);
|
||||
const channelIds = (channels ?? [])
|
||||
.filter((c: any) => c.type === 'text' || c.type === 'dm')
|
||||
.map((c: any) => c._id);
|
||||
const latest = useQuery(
|
||||
api.readState.getLatestMessageTimestamps,
|
||||
channelIds.length > 0 ? { channelIds } : 'skip',
|
||||
);
|
||||
|
||||
// `seenMessageIds` starts populated from the first query result so
|
||||
// the app doesn't fire a barrage on mount. After initialization,
|
||||
// every fresh messageId triggers a single notification.
|
||||
const seenRef = useRef<Map<string, string> | null>(null);
|
||||
const focusedRef = useRef<boolean>(
|
||||
typeof document !== 'undefined' ? document.hasFocus() : true,
|
||||
);
|
||||
const permissionAskedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const onFocus = () => {
|
||||
focusedRef.current = true;
|
||||
notifications?.setBadge?.(0);
|
||||
notifications?.flashFrame?.(false);
|
||||
};
|
||||
const onBlur = () => {
|
||||
focusedRef.current = false;
|
||||
};
|
||||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'visible' && document.hasFocus()) {
|
||||
focusedRef.current = true;
|
||||
notifications?.setBadge?.(0);
|
||||
notifications?.flashFrame?.(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('blur', onBlur);
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
};
|
||||
}, [notifications]);
|
||||
|
||||
// One-shot permission request on first mount where we have a real
|
||||
// notifications API. `ensurePermission` is a no-op on Electron
|
||||
// (always granted) and prompts the browser on web.
|
||||
useEffect(() => {
|
||||
if (permissionAskedRef.current) return;
|
||||
if (!notifications?.ensurePermission) return;
|
||||
permissionAskedRef.current = true;
|
||||
void notifications.ensurePermission();
|
||||
}, [notifications]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!latest || !myUserId) return;
|
||||
const channelNameById = new Map<string, string>();
|
||||
for (const c of channels ?? []) channelNameById.set(c._id, c.name ?? '');
|
||||
|
||||
// Initialize on first real payload — no notifications for the
|
||||
// historical state. From here on, any new `messageId` means a
|
||||
// genuinely fresh message.
|
||||
if (seenRef.current === null) {
|
||||
const init = new Map<string, string>();
|
||||
for (const row of latest) {
|
||||
if (row.messageId) init.set(row.channelId, row.messageId);
|
||||
}
|
||||
seenRef.current = init;
|
||||
return;
|
||||
}
|
||||
|
||||
const seen = seenRef.current;
|
||||
let unreadDelta = 0;
|
||||
for (const row of latest) {
|
||||
if (!row.messageId) continue;
|
||||
const prev = seen.get(row.channelId);
|
||||
if (prev === row.messageId) continue;
|
||||
seen.set(row.channelId, row.messageId);
|
||||
if (prev === undefined) continue; // first sight of a channel mid-session
|
||||
if (row.senderId === myUserId) continue;
|
||||
if (focusedRef.current) continue;
|
||||
|
||||
unreadDelta += 1;
|
||||
const name = channelNameById.get(row.channelId) || 'channel';
|
||||
const isDm = (channels ?? []).find((c: any) => c._id === row.channelId)?.type === 'dm';
|
||||
const title = isDm ? 'New direct message' : `New message in #${name}`;
|
||||
notifications?.show?.({ title, body: '' });
|
||||
notifications?.flashFrame?.(true);
|
||||
}
|
||||
if (unreadDelta > 0) notifications?.setBadge?.(unreadDelta);
|
||||
}, [latest, channels, myUserId, notifications]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Minus, Square, X } from '@phosphor-icons/react';
|
||||
import { usePlatform } from '../../platform';
|
||||
import { HeaderUpdateIcon } from './HeaderUpdateIcon';
|
||||
import styles from './TitleBar.module.css';
|
||||
|
||||
/**
|
||||
@@ -30,6 +31,7 @@ export function TitleBar() {
|
||||
|
||||
return (
|
||||
<div className={styles.bar}>
|
||||
<HeaderUpdateIcon />
|
||||
<div className={styles.buttons}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -169,6 +169,7 @@ export function UserAreaProfilePopout({
|
||||
// row. Reactive via `useQuery` so the popout updates instantly
|
||||
// when the value changes in Settings → My Account.
|
||||
const accentColor = (me as any)?.accentColor || DEFAULT_ACCENT_COLOR;
|
||||
const bannerUrl: string | null = (me as any)?.bannerUrl ?? null;
|
||||
const presence = me.status || 'online';
|
||||
const bio = me.aboutMe?.trim();
|
||||
const displayName = me.displayName || me.username || 'User';
|
||||
@@ -233,7 +234,15 @@ export function UserAreaProfilePopout({
|
||||
<div className={styles.header}>
|
||||
<div
|
||||
className={styles.banner}
|
||||
style={{ backgroundColor: accentColor }}
|
||||
style={
|
||||
bannerUrl
|
||||
? {
|
||||
backgroundImage: `url("${bannerUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
: { backgroundColor: accentColor }
|
||||
}
|
||||
/>
|
||||
<div className={styles.avatarWrap}>
|
||||
<Avatar
|
||||
|
||||
@@ -136,6 +136,7 @@ export function MemberProfileModal({
|
||||
const avatarUrl = profile?.avatarUrl ?? null;
|
||||
const bio = profile?.aboutMe?.trim();
|
||||
const accent = (profile as any)?.accentColor || DEFAULT_ACCENT;
|
||||
const bannerUrl: string | null = (profile as any)?.bannerUrl ?? null;
|
||||
const storedStatus = (profile?.status as string | undefined) || 'offline';
|
||||
const livePresence = resolveStatus(storedStatus, member.userId);
|
||||
const statusDisplay = getStatusDisplay(livePresence);
|
||||
@@ -225,7 +226,15 @@ export function MemberProfileModal({
|
||||
|
||||
<div
|
||||
className={styles.banner}
|
||||
style={{ backgroundColor: accent }}
|
||||
style={
|
||||
bannerUrl
|
||||
? {
|
||||
backgroundImage: `url("${bannerUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
: { backgroundColor: accent }
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={styles.headerRow}>
|
||||
|
||||
@@ -108,6 +108,7 @@ export function MemberProfilePopout({
|
||||
// My Account. Falls back to the brand default when the user
|
||||
// hasn't picked one yet.
|
||||
const accent = (fullUser as any)?.accentColor || DEFAULT_ACCENT;
|
||||
const bannerUrl: string | null = (fullUser as any)?.bannerUrl ?? null;
|
||||
|
||||
// Position: anchor to the LEFT of the clicked row. The member
|
||||
// list sits on the right edge so the popout flows inward. Clamp
|
||||
@@ -153,10 +154,20 @@ export function MemberProfilePopout({
|
||||
style={positionStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Banner — solid accent color. */}
|
||||
{/* Banner — uploaded image if present, otherwise a solid
|
||||
accent color. `object-fit: cover` is handled via CSS
|
||||
so the band doesn't stretch the image. */}
|
||||
<div
|
||||
className={styles.banner}
|
||||
style={{ backgroundColor: accent }}
|
||||
style={
|
||||
bannerUrl
|
||||
? {
|
||||
backgroundImage: `url("${bannerUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
: { backgroundColor: accent }
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={styles.avatarWrap}>
|
||||
|
||||
@@ -117,6 +117,7 @@ export function MobileMemberProfileSheet({
|
||||
const avatarUrl = profile?.avatarUrl ?? null;
|
||||
const bio = profile?.aboutMe?.trim();
|
||||
const accent = (profile as any)?.accentColor || DEFAULT_ACCENT;
|
||||
const bannerUrl: string | null = (profile as any)?.bannerUrl ?? null;
|
||||
const storedStatus = (profile?.status as string | undefined) || 'offline';
|
||||
const presence = mapPresence(resolveStatus(storedStatus, member.userId));
|
||||
|
||||
@@ -172,7 +173,15 @@ export function MobileMemberProfileSheet({
|
||||
<div className={styles.root}>
|
||||
<div
|
||||
className={styles.banner}
|
||||
style={{ backgroundColor: accent }}
|
||||
style={
|
||||
bannerUrl
|
||||
? {
|
||||
backgroundImage: `url("${bannerUrl}")`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}
|
||||
: { backgroundColor: accent }
|
||||
}
|
||||
>
|
||||
<div className={styles.bannerHandle} />
|
||||
</div>
|
||||
|
||||
@@ -16,13 +16,17 @@ import { useQuery } from 'convex/react';
|
||||
import {
|
||||
CaretLeft,
|
||||
CaretRight,
|
||||
ClockCounterClockwise,
|
||||
Gear,
|
||||
Prohibit,
|
||||
ShieldStar,
|
||||
Smiley,
|
||||
X,
|
||||
} from '@phosphor-icons/react';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import {
|
||||
AuditLogTab,
|
||||
BansTab,
|
||||
EmojisTab,
|
||||
OverviewTab,
|
||||
type ServerSettingsTab,
|
||||
@@ -48,6 +52,8 @@ const TABS: Array<{
|
||||
{ id: 'overview', label: 'Overview', icon: Gear },
|
||||
{ id: 'roles', label: 'Roles & Permissions', icon: ShieldStar },
|
||||
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
|
||||
{ id: 'bans', label: 'Bans', icon: Prohibit },
|
||||
{ id: 'audit', label: 'Audit Log', icon: ClockCounterClockwise },
|
||||
];
|
||||
|
||||
function getInitials(name: string): string {
|
||||
@@ -227,6 +233,8 @@ export function MobileServerSettings({
|
||||
<div className={`${styles.body} ${styles.bodyPanel}`}>
|
||||
{activeTab === 'overview' && <OverviewTab />}
|
||||
{activeTab === 'emojis' && <EmojisTab />}
|
||||
{activeTab === 'bans' && <BansTab />}
|
||||
{activeTab === 'audit' && <AuditLogTab />}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
|
||||
@@ -9,7 +9,17 @@
|
||||
* still hold the old Matrix-based code and are not imported.
|
||||
*/
|
||||
import { useMutation, useQuery } from 'convex/react';
|
||||
import { Gear, Plus, ShieldStar, Smiley, Trash, UploadSimple, X } from '@phosphor-icons/react';
|
||||
import {
|
||||
ClockCounterClockwise,
|
||||
Gear,
|
||||
Plus,
|
||||
Prohibit,
|
||||
ShieldStar,
|
||||
Smiley,
|
||||
Trash,
|
||||
UploadSimple,
|
||||
X,
|
||||
} from '@phosphor-icons/react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
@@ -20,7 +30,7 @@ import { MobileServerSettings } from './MobileServerSettings';
|
||||
import { useRolesView } from './RolesView';
|
||||
import userStyles from './UserSettingsModal.module.css';
|
||||
|
||||
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis';
|
||||
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis' | 'bans' | 'audit';
|
||||
|
||||
interface ServerSettingsModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -32,6 +42,8 @@ const TABS: Array<{ id: ServerSettingsTab; label: string; icon: typeof Gear }> =
|
||||
{ id: 'overview', label: 'Overview', icon: Gear },
|
||||
{ id: 'roles', label: 'Roles', icon: ShieldStar },
|
||||
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
|
||||
{ id: 'bans', label: 'Bans', icon: Prohibit },
|
||||
{ id: 'audit', label: 'Audit Log', icon: ClockCounterClockwise },
|
||||
];
|
||||
|
||||
export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSettingsModalProps) {
|
||||
@@ -150,6 +162,8 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
|
||||
{activeTab === 'overview' && <OverviewTab />}
|
||||
{inRolesView && rolesView.content}
|
||||
{activeTab === 'emojis' && <CustomEmojisTab />}
|
||||
{activeTab === 'bans' && <BansTab />}
|
||||
{activeTab === 'audit' && <AuditLogTab />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -342,6 +356,7 @@ const PERMISSION_KEYS = [
|
||||
'move_members',
|
||||
'mute_members',
|
||||
'manage_nicknames',
|
||||
'ban_members',
|
||||
] as const;
|
||||
|
||||
type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||
@@ -892,3 +907,362 @@ const dangerBtnStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------- */
|
||||
/* Bans */
|
||||
/* ------------------------------------------------------------------- */
|
||||
|
||||
function formatRelative(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const s = Math.max(0, Math.floor(diff / 1000));
|
||||
if (s < 60) return `${s}s ago`;
|
||||
const m = Math.floor(s / 60);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
const h = Math.floor(m / 60);
|
||||
if (h < 24) return `${h}h ago`;
|
||||
const d = Math.floor(h / 24);
|
||||
if (d < 30) return `${d}d ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function BansTab() {
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined'
|
||||
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
|
||||
: null;
|
||||
const bans = useQuery(api.bans.list, myUserId ? { actorId: myUserId } : 'skip');
|
||||
const allUsers = useQuery(api.auth.getPublicKeys, {}) ?? [];
|
||||
const banMutation = useMutation(api.bans.ban);
|
||||
const unbanMutation = useMutation(api.bans.unban);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickedUserId, setPickedUserId] = useState<string>('');
|
||||
const [reason, setReason] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const bannedIdSet = useMemo(
|
||||
() => new Set((bans ?? []).map((b: any) => b.userId)),
|
||||
[bans],
|
||||
);
|
||||
|
||||
const bannableUsers = useMemo(
|
||||
() =>
|
||||
(allUsers as any[]).filter(
|
||||
(u) => u.id !== myUserId && !bannedIdSet.has(u.id),
|
||||
),
|
||||
[allUsers, bannedIdSet, myUserId],
|
||||
);
|
||||
|
||||
const handleBan = async () => {
|
||||
if (!myUserId || !pickedUserId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await banMutation({
|
||||
actorId: myUserId,
|
||||
userId: pickedUserId as Id<'userProfiles'>,
|
||||
reason: reason.trim() || undefined,
|
||||
});
|
||||
setPickerOpen(false);
|
||||
setPickedUserId('');
|
||||
setReason('');
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to ban user.');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUnban = async (userId: string) => {
|
||||
if (!myUserId) return;
|
||||
try {
|
||||
await unbanMutation({
|
||||
actorId: myUserId,
|
||||
userId: userId as Id<'userProfiles'>,
|
||||
});
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? 'Failed to unban user.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={userStyles.profileHeader}>
|
||||
<h2 className={userStyles.profileSubheading}>Bans</h2>
|
||||
<p className={userStyles.profileDescription}>
|
||||
Banned users can't log in or send messages. Unbanning restores access.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
marginBottom: 12,
|
||||
borderRadius: 6,
|
||||
background: 'rgba(248, 113, 113, 0.12)',
|
||||
color: '#f87171',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pickerOpen ? (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
borderRadius: 8,
|
||||
background: 'var(--background-secondary)',
|
||||
}}
|
||||
>
|
||||
<Label>User</Label>
|
||||
<select
|
||||
value={pickedUserId}
|
||||
onChange={(e) => setPickedUserId(e.target.value)}
|
||||
style={inputStyle}
|
||||
>
|
||||
<option value="">Select a user…</option>
|
||||
{bannableUsers.map((u: any) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.displayName || u.username}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div style={{ height: 12 }} />
|
||||
<Label>Reason (optional)</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={reason}
|
||||
onChange={(e) => setReason(e.target.value)}
|
||||
maxLength={200}
|
||||
style={inputStyle}
|
||||
/>
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBan}
|
||||
disabled={busy || !pickedUserId}
|
||||
style={dangerBtnStyle}
|
||||
>
|
||||
{busy ? 'Banning…' : 'Ban user'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPickerOpen(false);
|
||||
setPickedUserId('');
|
||||
setReason('');
|
||||
setError(null);
|
||||
}}
|
||||
style={{ ...primaryBtnStyle, background: 'var(--background-tertiary)' }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPickerOpen(true)}
|
||||
style={{ ...primaryBtnStyle, marginBottom: 16, display: 'inline-flex', alignItems: 'center', gap: 6 }}
|
||||
>
|
||||
<Plus size={16} weight="bold" /> Ban a user
|
||||
</button>
|
||||
)}
|
||||
|
||||
{bans === undefined ? (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Loading bans…</div>
|
||||
) : bans.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>No one is banned.</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{bans.map((b: any) => (
|
||||
<div
|
||||
key={b._id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 12,
|
||||
borderRadius: 8,
|
||||
background: 'var(--background-secondary)',
|
||||
border: '1px solid var(--background-tertiary)',
|
||||
}}
|
||||
>
|
||||
{b.user?.avatarUrl ? (
|
||||
<img
|
||||
src={b.user.avatarUrl}
|
||||
alt=""
|
||||
style={{ width: 40, height: 40, borderRadius: '50%' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--background-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{(b.user?.displayName || b.user?.username || '?').slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ color: 'var(--text-primary)', fontWeight: 600, fontSize: 14 }}>
|
||||
{b.user?.displayName || b.user?.username || 'Unknown user'}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{b.reason ? `“${b.reason}” · ` : ''}
|
||||
banned by {b.actor?.displayName || b.actor?.username || 'unknown'} · {formatRelative(b.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUnban(b.userId)}
|
||||
style={{ ...primaryBtnStyle, background: 'var(--background-tertiary)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
Unban
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- */
|
||||
/* Audit Log */
|
||||
/* ------------------------------------------------------------------- */
|
||||
|
||||
const AUDIT_LABELS: Record<string, string> = {
|
||||
'channel.create': 'created channel',
|
||||
'channel.delete': 'deleted channel',
|
||||
'channel.rename': 'renamed channel',
|
||||
'channel.update_topic': 'updated channel topic',
|
||||
'role.create': 'created role',
|
||||
'role.delete': 'deleted role',
|
||||
'role.update': 'updated role',
|
||||
'role.assign': 'assigned role',
|
||||
'role.unassign': 'removed role',
|
||||
'server.settings_update': 'updated server settings',
|
||||
'ban.add': 'banned',
|
||||
'ban.remove': 'unbanned',
|
||||
};
|
||||
|
||||
export function AuditLogTab() {
|
||||
const myUserId =
|
||||
typeof localStorage !== 'undefined'
|
||||
? (localStorage.getItem('userId') as Id<'userProfiles'> | null)
|
||||
: null;
|
||||
const entries = useQuery(
|
||||
api.audit.list,
|
||||
myUserId ? { actorId: myUserId, limit: 200 } : 'skip',
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={userStyles.profileHeader}>
|
||||
<h2 className={userStyles.profileSubheading}>Audit Log</h2>
|
||||
<p className={userStyles.profileDescription}>
|
||||
Recent admin actions, newest first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{entries === undefined ? (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>Loading…</div>
|
||||
) : entries.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
|
||||
Nothing logged yet.
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{entries.map((e: any) => {
|
||||
const label = AUDIT_LABELS[e.action] ?? e.action;
|
||||
const actorName = e.actor?.displayName || e.actor?.username || 'Someone';
|
||||
return (
|
||||
<div
|
||||
key={e._id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
padding: '10px 12px',
|
||||
borderRadius: 6,
|
||||
background: 'var(--background-secondary)',
|
||||
}}
|
||||
>
|
||||
{e.actor?.avatarUrl ? (
|
||||
<img
|
||||
src={e.actor.avatarUrl}
|
||||
alt=""
|
||||
style={{ width: 28, height: 28, borderRadius: '50%' }}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--background-tertiary)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--text-primary)',
|
||||
fontWeight: 700,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{actorName.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
color: 'var(--text-primary)',
|
||||
fontSize: 13,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
<strong>{actorName}</strong> {label}
|
||||
{e.targetName ? <> <strong>{e.targetName}</strong></> : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
fontSize: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={new Date(e.createdAt).toLocaleString()}
|
||||
>
|
||||
{formatRelative(e.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { api } from '../../../../../convex/_generated/api';
|
||||
import { useKeybinds } from '../../contexts/KeybindContext';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||
import { useLogout } from '../../hooks/useLogout';
|
||||
@@ -218,6 +219,14 @@ export function AccountTab() {
|
||||
const [joinSoundError, setJoinSoundError] = useState<string | null>(null);
|
||||
const [joinSoundFilename, setJoinSoundFilename] = useState<string | null>(null);
|
||||
|
||||
// Banner state. Same flow as avatar: pick a file → stage a local
|
||||
// object URL → on Save upload + patch. "Remove" sets a flag that
|
||||
// instructs the server to clear the stored blob on next save.
|
||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingBannerBlob, setPendingBannerBlob] = useState<Blob | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string | null>(null);
|
||||
const [removeBannerPending, setRemoveBannerPending] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (me) {
|
||||
setDisplayName(me.displayName ?? '');
|
||||
@@ -230,8 +239,9 @@ export function AccountTab() {
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
};
|
||||
}, [avatarPreview]);
|
||||
}, [avatarPreview, bannerPreview]);
|
||||
|
||||
const pickAvatar = () => fileInputRef.current?.click();
|
||||
|
||||
@@ -278,6 +288,33 @@ export function AccountTab() {
|
||||
return storageId;
|
||||
};
|
||||
|
||||
const pickBanner = () => bannerInputRef.current?.click();
|
||||
|
||||
const handleBannerFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setStatus('Pick an image file.');
|
||||
return;
|
||||
}
|
||||
if (file.size > AVATAR_MAX_SIZE) {
|
||||
setStatus('Banner must be under 10 MB.');
|
||||
return;
|
||||
}
|
||||
setPendingBannerBlob(file);
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
setBannerPreview(URL.createObjectURL(file));
|
||||
setRemoveBannerPending(false);
|
||||
};
|
||||
|
||||
const handleRemoveBanner = () => {
|
||||
setPendingBannerBlob(null);
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
setBannerPreview(null);
|
||||
setRemoveBannerPending(true);
|
||||
};
|
||||
|
||||
const pickJoinSound = () => joinSoundInputRef.current?.click();
|
||||
|
||||
const handleJoinSoundFile = async (
|
||||
@@ -367,8 +404,20 @@ export function AccountTab() {
|
||||
const storageId = await uploadAvatar(pendingAvatarBlob);
|
||||
patch.avatarStorageId = storageId;
|
||||
}
|
||||
if (pendingBannerBlob) {
|
||||
const storageId = await uploadAvatar(pendingBannerBlob);
|
||||
patch.bannerStorageId = storageId;
|
||||
} else if (removeBannerPending) {
|
||||
patch.removeBanner = true;
|
||||
}
|
||||
await updateProfile(patch as any);
|
||||
setPendingAvatarBlob(null);
|
||||
setPendingBannerBlob(null);
|
||||
setRemoveBannerPending(false);
|
||||
if (bannerPreview) {
|
||||
URL.revokeObjectURL(bannerPreview);
|
||||
setBannerPreview(null);
|
||||
}
|
||||
setStatus('Saved');
|
||||
setTimeout(() => setStatus(null), 1500);
|
||||
} catch (err: any) {
|
||||
@@ -379,6 +428,13 @@ export function AccountTab() {
|
||||
};
|
||||
|
||||
const currentAvatar = avatarPreview ?? me?.avatarUrl ?? null;
|
||||
// Resolve the banner to show in the preview card. Local blob
|
||||
// beats server URL; a pending "remove" clears both.
|
||||
const currentBanner = bannerPreview
|
||||
? bannerPreview
|
||||
: removeBannerPending
|
||||
? null
|
||||
: ((me as any)?.bannerUrl ?? null);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -405,7 +461,12 @@ export function AccountTab() {
|
||||
<div
|
||||
style={{
|
||||
height: 72,
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
|
||||
background: currentBanner
|
||||
? undefined
|
||||
: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
|
||||
backgroundImage: currentBanner ? `url("${currentBanner}")` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: 36, left: 18 }}>
|
||||
@@ -477,6 +538,13 @@ export function AccountTab() {
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
/>
|
||||
<input
|
||||
ref={bannerInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleBannerFile}
|
||||
/>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<FieldRow label="Username" value={me?.username ?? '—'} readOnly />
|
||||
@@ -546,6 +614,82 @@ export function AccountTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.04em',
|
||||
textTransform: 'uppercase',
|
||||
color: 'var(--text-tertiary)',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
Banner Image
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||
{currentBanner ? (
|
||||
<img
|
||||
src={currentBanner}
|
||||
alt="Banner preview"
|
||||
style={{
|
||||
width: 160,
|
||||
height: 48,
|
||||
borderRadius: 6,
|
||||
objectFit: 'cover',
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
width: 160,
|
||||
height: 48,
|
||||
borderRadius: 6,
|
||||
background: `linear-gradient(135deg, ${accentColor}, ${accentColor}aa)`,
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={pickBanner}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
background: 'var(--background-tertiary)',
|
||||
color: 'var(--text-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
Upload Banner
|
||||
</button>
|
||||
{currentBanner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveBanner}
|
||||
style={{
|
||||
padding: '8px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--background-modifier-accent)',
|
||||
background: 'transparent',
|
||||
color: 'var(--text-secondary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>
|
||||
Overrides the accent color behind your profile.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
@@ -891,6 +1035,13 @@ interface VoiceSettings {
|
||||
echoCancellation: boolean;
|
||||
autoGainControl: boolean;
|
||||
recordingDir?: string;
|
||||
// Voice activity is the default — the green speaking bubble
|
||||
// already drives this. Push-to-talk is opt-in and paired with the
|
||||
// `voice.pushToTalk` keybind.
|
||||
inputMode: 'voice-activity' | 'push-to-talk';
|
||||
// Short tail after the PTT key is released so the last syllable
|
||||
// doesn't get chopped. Stored in ms; exposed as a slider in the UI.
|
||||
pushToTalkReleaseDelayMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
|
||||
@@ -901,6 +1052,8 @@ const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
|
||||
noiseSuppression: true,
|
||||
echoCancellation: false,
|
||||
autoGainControl: true,
|
||||
inputMode: 'voice-activity',
|
||||
pushToTalkReleaseDelayMs: 200,
|
||||
};
|
||||
|
||||
function loadVoiceSettings(): VoiceSettings {
|
||||
@@ -1112,6 +1265,8 @@ export function VoiceTab() {
|
||||
<MicTest settings={settings} />
|
||||
</div>
|
||||
|
||||
<VoiceInputModeSection settings={settings} update={update} />
|
||||
|
||||
<div className={styles.voiceSection}>
|
||||
<h4 className={styles.voiceSectionTitle}>Audio Processing</h4>
|
||||
<p className={styles.settingDescription}>
|
||||
@@ -1533,6 +1688,89 @@ export function SecurityTab() {
|
||||
* the test is live tears down and re-creates the graph with the
|
||||
* new constraints.
|
||||
*/
|
||||
/**
|
||||
* Voice input mode — radio group for Voice Activity (default) vs
|
||||
* Push to Talk, plus a keybind display + release-delay slider when
|
||||
* PTT is selected. The keybind itself is rebound from the Keybinds
|
||||
* tab; this is just a convenient inline pointer + shortcut preview.
|
||||
*/
|
||||
function VoiceInputModeSection({
|
||||
settings,
|
||||
update,
|
||||
}: {
|
||||
settings: VoiceSettings;
|
||||
update: <K extends keyof VoiceSettings>(key: K, value: VoiceSettings[K]) => void;
|
||||
}) {
|
||||
const keybinds = useKeybinds();
|
||||
const pttCombo = keybinds.getCombo('voice.pushToTalk');
|
||||
return (
|
||||
<div className={styles.voiceSection}>
|
||||
<h4 className={styles.voiceSectionTitle}>Input Mode</h4>
|
||||
<p className={styles.settingDescription}>
|
||||
Voice Activity transmits whenever you speak. Push to Talk only
|
||||
transmits while you hold the bound key.
|
||||
</p>
|
||||
|
||||
<label className={styles.voiceRadioRow}>
|
||||
<input
|
||||
type="radio"
|
||||
name="voice-input-mode"
|
||||
checked={settings.inputMode === 'voice-activity'}
|
||||
onChange={() => update('inputMode', 'voice-activity')}
|
||||
/>
|
||||
<div>
|
||||
<div className={styles.voiceRadioLabel}>Voice Activity</div>
|
||||
<div className={styles.voiceRadioHelp}>
|
||||
Auto-transmit while speaking (default).
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={styles.voiceRadioRow}>
|
||||
<input
|
||||
type="radio"
|
||||
name="voice-input-mode"
|
||||
checked={settings.inputMode === 'push-to-talk'}
|
||||
onChange={() => update('inputMode', 'push-to-talk')}
|
||||
/>
|
||||
<div>
|
||||
<div className={styles.voiceRadioLabel}>Push to Talk</div>
|
||||
<div className={styles.voiceRadioHelp}>
|
||||
Hold a key to transmit. Binding:{' '}
|
||||
<strong>{pttCombo || 'Unbound — set in Keybinds tab'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{settings.inputMode === 'push-to-talk' && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className={styles.voiceVolumeHeader}>
|
||||
<span className={styles.voiceFieldLabel}>Release Delay</span>
|
||||
<span className={styles.voiceVolumeValue}>
|
||||
{settings.pushToTalkReleaseDelayMs}ms
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1000}
|
||||
step={20}
|
||||
value={settings.pushToTalkReleaseDelayMs}
|
||||
onChange={(e) =>
|
||||
update('pushToTalkReleaseDelayMs', Number(e.target.value))
|
||||
}
|
||||
className={styles.voiceSlider}
|
||||
/>
|
||||
<p className={styles.settingDescription} style={{ marginTop: 4 }}>
|
||||
How long to keep transmitting after you release the key —
|
||||
avoids clipping the end of a word.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MicTest({ settings }: { settings: VoiceSettings }) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [level, setLevel] = useState(0);
|
||||
|
||||
@@ -29,6 +29,13 @@ export interface KeybindAction {
|
||||
description: string;
|
||||
category: KeybindCategory;
|
||||
defaultCombo: string;
|
||||
/** Press-and-hold actions (push-to-talk, walkie-talkie style).
|
||||
* Instead of a single `brycord:keybind:<id>` event on keydown,
|
||||
* the dispatcher fires `brycord:keybind:<id>:down` on the first
|
||||
* keydown (no `e.repeat`) and `brycord:keybind:<id>:up` on
|
||||
* keyup. These events do NOT preventDefault, so binding PTT to
|
||||
* a letter doesn't break typing in text fields. */
|
||||
pressAndHold?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_ACTIONS: KeybindAction[] = [
|
||||
@@ -53,6 +60,15 @@ const DEFAULT_ACTIONS: KeybindAction[] = [
|
||||
category: 'voice',
|
||||
defaultCombo: '',
|
||||
},
|
||||
{
|
||||
id: 'voice.pushToTalk',
|
||||
label: 'Push to Talk',
|
||||
description:
|
||||
'Hold to transmit your mic while the voice input mode is set to Push to Talk.',
|
||||
category: 'voice',
|
||||
defaultCombo: '',
|
||||
pressAndHold: true,
|
||||
},
|
||||
{
|
||||
id: 'navigation.goToDMs',
|
||||
label: 'Go to Direct Messages',
|
||||
@@ -242,29 +258,50 @@ export function KeybindProvider({ children }: { children: ReactNode }) {
|
||||
// wins against components that use keydown for their own shortcuts
|
||||
// — rebinding in settings disables the default behaviour cleanly.
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
// Never intercept keys typed into inputs / contenteditable —
|
||||
// a bare `Escape` would otherwise cancel active composition.
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target) {
|
||||
const tag = target.tagName;
|
||||
if (
|
||||
// Track currently-held press-and-hold actions so keydown repeats
|
||||
// (browser auto-repeat while the key stays pressed) only fire
|
||||
// a single `:down` event per physical press, and so we can emit
|
||||
// a matching `:up` when the key is released.
|
||||
const heldPressAndHold = new Set<string>();
|
||||
|
||||
const isInEditableField = (target: EventTarget | null): boolean => {
|
||||
const el = target as HTMLElement | null;
|
||||
if (!el) return false;
|
||||
const tag = el.tagName;
|
||||
return (
|
||||
tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
// Allow Ctrl- / Ctrl+Shift- combinations through —
|
||||
// those are deliberate shortcuts, never accidental
|
||||
// typing. Plain keys still wait for focus to leave.
|
||||
if (!(e.ctrlKey || e.metaKey)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
el.isContentEditable
|
||||
);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
const combo = eventToCombo(e);
|
||||
if (!combo) return;
|
||||
const editable = isInEditableField(e.target);
|
||||
|
||||
for (const action of DEFAULT_ACTIONS) {
|
||||
if ((combos[action.id] ?? '') === combo) {
|
||||
if ((combos[action.id] ?? '') !== combo) continue;
|
||||
|
||||
if (action.pressAndHold) {
|
||||
// Fire `:down` once per physical press. Deliberately
|
||||
// DO NOT preventDefault — press-and-hold bindings
|
||||
// coexist with typing so binding PTT to a letter
|
||||
// doesn't swallow that letter in an input.
|
||||
if (e.repeat) return;
|
||||
if (heldPressAndHold.has(action.id)) return;
|
||||
heldPressAndHold.add(action.id);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(`brycord:keybind:${action.id}:down`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-hold (one-shot) actions: original behaviour —
|
||||
// swallow the key and fire the action, but only when
|
||||
// the target is not an editable field (unless the user
|
||||
// used a Ctrl/Meta shortcut, which is always deliberate).
|
||||
if (editable && !(e.ctrlKey || e.metaKey)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
window.dispatchEvent(
|
||||
@@ -272,13 +309,58 @@ export function KeybindProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
// Fire `:up` for any currently-held press-and-hold action
|
||||
// whose combo key was just released. We match on the single
|
||||
// key (`e.key`) rather than a full combo because the combo
|
||||
// includes modifiers that may be released in any order.
|
||||
if (heldPressAndHold.size === 0) return;
|
||||
const released = e.key.length === 1 ? e.key.toUpperCase() : e.key;
|
||||
for (const action of DEFAULT_ACTIONS) {
|
||||
if (!action.pressAndHold) continue;
|
||||
if (!heldPressAndHold.has(action.id)) continue;
|
||||
const combo = combos[action.id] ?? '';
|
||||
if (!combo) continue;
|
||||
// `combo` is like "Ctrl+Shift+V" — the final segment is
|
||||
// the main key. A release of any of the component keys
|
||||
// counts as "stop holding".
|
||||
const parts = combo.split('+');
|
||||
if (parts.includes(released) || released === 'Control' ||
|
||||
released === 'Shift' || released === 'Alt' ||
|
||||
released === 'Meta') {
|
||||
heldPressAndHold.delete(action.id);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(`brycord:keybind:${action.id}:up`),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Safety net — if focus leaves the window while a PTT key is
|
||||
// held, browsers usually don't fire keyup. Release everything
|
||||
// so the mic doesn't stay hot forever.
|
||||
const onBlur = () => {
|
||||
for (const id of heldPressAndHold) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(`brycord:keybind:${id}:up`),
|
||||
);
|
||||
}
|
||||
heldPressAndHold.clear();
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', onKeyDown, { capture: true });
|
||||
window.addEventListener('keyup', onKeyUp, { capture: true });
|
||||
window.addEventListener('blur', onBlur);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown, {
|
||||
capture: true,
|
||||
} as EventListenerOptions);
|
||||
window.removeEventListener('keyup', onKeyUp, {
|
||||
capture: true,
|
||||
} as EventListenerOptions);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
};
|
||||
}, [combos]);
|
||||
|
||||
|
||||
@@ -92,6 +92,84 @@ export const VoiceProvider = ({ children }) => {
|
||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||
const [connectionQualities, setConnectionQualities] = useState({});
|
||||
|
||||
// Voice-input mode state. "voice-activity" is the default and the
|
||||
// LiveKit track stays enabled whenever the user isn't muted. In
|
||||
// "push-to-talk" we flip the mic off until the bound key is held.
|
||||
// Settings live in localStorage (see UserSettingsModal) and are
|
||||
// broadcast via `brycord:voice-settings-changed` on change.
|
||||
const [inputMode, setInputMode] = useState('voice-activity');
|
||||
const [pttReleaseDelayMs, setPttReleaseDelayMs] = useState(200);
|
||||
const [isPttActive, setIsPttActive] = useState(false);
|
||||
const pttReleaseTimerRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
const readSettings = () => {
|
||||
try {
|
||||
const raw = localStorage.getItem('voiceSettings');
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed?.inputMode === 'push-to-talk' || parsed?.inputMode === 'voice-activity') {
|
||||
setInputMode(parsed.inputMode);
|
||||
}
|
||||
if (typeof parsed?.pushToTalkReleaseDelayMs === 'number') {
|
||||
setPttReleaseDelayMs(parsed.pushToTalkReleaseDelayMs);
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed blob */
|
||||
}
|
||||
};
|
||||
readSettings();
|
||||
const onChange = () => readSettings();
|
||||
window.addEventListener('brycord:voice-settings-changed', onChange);
|
||||
return () => window.removeEventListener('brycord:voice-settings-changed', onChange);
|
||||
}, []);
|
||||
|
||||
// Subscribe to the `voice.pushToTalk` keybind's down/up events while
|
||||
// PTT mode is selected. On release, honor the configured delay
|
||||
// before flipping the mic off so the last syllable isn't clipped.
|
||||
useEffect(() => {
|
||||
if (inputMode !== 'push-to-talk') {
|
||||
// Flipping back to voice activity resets any pending hold
|
||||
// so the next keydown starts fresh.
|
||||
setIsPttActive(false);
|
||||
if (pttReleaseTimerRef.current) {
|
||||
clearTimeout(pttReleaseTimerRef.current);
|
||||
pttReleaseTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const onDown = () => {
|
||||
if (pttReleaseTimerRef.current) {
|
||||
clearTimeout(pttReleaseTimerRef.current);
|
||||
pttReleaseTimerRef.current = null;
|
||||
}
|
||||
setIsPttActive(true);
|
||||
};
|
||||
const onUp = () => {
|
||||
if (pttReleaseDelayMs <= 0) {
|
||||
setIsPttActive(false);
|
||||
return;
|
||||
}
|
||||
if (pttReleaseTimerRef.current) {
|
||||
clearTimeout(pttReleaseTimerRef.current);
|
||||
}
|
||||
pttReleaseTimerRef.current = setTimeout(() => {
|
||||
setIsPttActive(false);
|
||||
pttReleaseTimerRef.current = null;
|
||||
}, pttReleaseDelayMs);
|
||||
};
|
||||
window.addEventListener('brycord:keybind:voice.pushToTalk:down', onDown);
|
||||
window.addEventListener('brycord:keybind:voice.pushToTalk:up', onUp);
|
||||
return () => {
|
||||
window.removeEventListener('brycord:keybind:voice.pushToTalk:down', onDown);
|
||||
window.removeEventListener('brycord:keybind:voice.pushToTalk:up', onUp);
|
||||
if (pttReleaseTimerRef.current) {
|
||||
clearTimeout(pttReleaseTimerRef.current);
|
||||
pttReleaseTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [inputMode, pttReleaseDelayMs]);
|
||||
|
||||
// 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
|
||||
@@ -759,15 +837,18 @@ export const VoiceProvider = ({ children }) => {
|
||||
}
|
||||
}, [voiceStates, activeChannelId, room, convex, myUserId]);
|
||||
|
||||
// Enforce server mute: force-disable mic when server muted, restore when lifted
|
||||
// Reconcile the mic track against every source of "mic should be
|
||||
// off": user mute, deafen, server mute, and — when the input mode
|
||||
// is push-to-talk — the PTT not being currently held. Runs on any
|
||||
// change so the UI stays in sync without each feature owning its
|
||||
// own enable/disable path.
|
||||
useEffect(() => {
|
||||
if (!myUserId || !room) return;
|
||||
if (isServerMuted(myUserId)) {
|
||||
room.localParticipant.setMicrophoneEnabled(false);
|
||||
} else if (!isMuted && !isDeafened) {
|
||||
room.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
}, [voiceStates, room, myUserId]);
|
||||
const serverMuted = isServerMuted(myUserId);
|
||||
const pttBlocks = inputMode === 'push-to-talk' && !isPttActive;
|
||||
const shouldEnable = !isMuted && !isDeafened && !serverMuted && !pttBlocks;
|
||||
room.localParticipant.setMicrophoneEnabled(shouldEnable);
|
||||
}, [voiceStates, room, myUserId, isMuted, isDeafened, inputMode, isPttActive]);
|
||||
|
||||
// Re-apply personal mutes/volumes when room or participants change
|
||||
useEffect(() => {
|
||||
|
||||
@@ -893,6 +893,12 @@ img[alt] {
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
|
||||
@keyframes brycord-record-pulse {
|
||||
0% { box-shadow: 0 0 0 0 rgba(218, 55, 60, 0.55); }
|
||||
70% { box-shadow: 0 0 0 10px rgba(218, 55, 60, 0); }
|
||||
100% { box-shadow: 0 0 0 0 rgba(218, 55, 60, 0); }
|
||||
}
|
||||
|
||||
.searchHighlight {
|
||||
animation: searchFlash 2s ease-out;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,14 @@
|
||||
* @property {() => void} close
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformNotifications
|
||||
* @property {(opts: {title: string, body?: string, silent?: boolean}) => void|Promise<void>} show - Show a desktop/system notification
|
||||
* @property {(count: number) => void} setBadge - Set unread badge/overlay count (0 clears)
|
||||
* @property {(on: boolean) => void} flashFrame - Flash the window/taskbar to draw attention
|
||||
* @property {() => Promise<'granted'|'denied'|'default'|'unavailable'>} ensurePermission - Request permission if needed; resolves the current state
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} PlatformRecording
|
||||
* @property {() => Promise<string>} getDefaultFolder - Default recording root (e.g. %APPDATA%/Brycord/recordings)
|
||||
@@ -109,6 +117,7 @@
|
||||
* @property {boolean} hasVoiceService
|
||||
* @property {boolean} hasSystemBars
|
||||
* @property {boolean} [hasBackButton]
|
||||
* @property {boolean} [hasNotifications]
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -120,6 +129,7 @@
|
||||
* @property {PlatformLinks} links
|
||||
* @property {PlatformScreenCapture|null} screenCapture
|
||||
* @property {PlatformWindowControls|null} windowControls
|
||||
* @property {PlatformNotifications|null} notifications
|
||||
* @property {PlatformRecording|null} recording
|
||||
* @property {PlatformUpdates|null} updates
|
||||
* @property {PlatformSearchDB|null} searchDB
|
||||
|
||||
Reference in New Issue
Block a user