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)
|
- **Backend**: Convex (reactive database + serverless functions)
|
||||||
- **Frontend**: React + Vite, shared codebase in `packages/shared/`
|
- **Frontend**: React + Vite, shared codebase in `packages/shared/`
|
||||||
- **Platforms**: Electron (`apps/electron/`), Web (`apps/web/`), Android via Capacitor (`apps/android/`)
|
- **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)
|
- **Auth**: Zero-knowledge custom auth via Convex mutations (getSalt, verifyUser, createUserWithProfile)
|
||||||
- **Real-time**: Convex reactive queries (`useQuery` auto-updates all connected clients)
|
- **Real-time**: Convex reactive queries (`useQuery` auto-updates all connected clients)
|
||||||
- **Voice/Video**: LiveKit (token generation via Convex Node action)
|
- **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/` |
|
| `@discord-clone/platform-web` | `packages/platform-web/src/` |
|
||||||
| `@shared` | `packages/shared/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())
|
## Platform Abstraction (usePlatform())
|
||||||
|
|
||||||
@@ -98,6 +98,7 @@ All platform-specific APIs are accessed via the `usePlatform()` hook:
|
|||||||
- `links` - openExternal, fetchMetadata
|
- `links` - openExternal, fetchMetadata
|
||||||
- `screenCapture` - getScreenSources
|
- `screenCapture` - getScreenSources
|
||||||
- `windowControls` - minimize, maximize, close (Electron only, null on web)
|
- `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)
|
- `updates` - checkUpdate (Electron only, null on web)
|
||||||
- `features` - hasWindowControls, hasScreenCapture, hasNativeUpdates
|
- `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
|
- `randomBytes(size)` returns hex string on both platforms
|
||||||
- Keys exchanged as PEM strings (SPKI public, PKCS8 private) for cross-platform interop
|
- Keys exchanged as PEM strings (SPKI public, PKCS8 private) for cross-platform interop
|
||||||
- TitleBar/UpdateBanner render conditionally based on `platform.features.*`
|
- 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
|
## Environment Variables
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ android {
|
|||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 27
|
versionCode 27
|
||||||
versionName "1.1.2"
|
versionName "1.1.3"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -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 path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ const fs = require('fs');
|
|||||||
const SESSION_FILE = path.join(app.getPath('userData'), 'secure-session.dat');
|
const SESSION_FILE = path.join(app.getPath('userData'), 'secure-session.dat');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const { checkForUpdates } = require('./updater.cjs');
|
const { checkForUpdates, getStatus: getUpdateStatus, onStatus: onUpdateStatus, downloadAndInstall: downloadAndInstallUpdate } = require('./updater.cjs');
|
||||||
|
|
||||||
function loadEnvVar(varName) {
|
function loadEnvVar(varName) {
|
||||||
const envFiles = [
|
const envFiles = [
|
||||||
@@ -191,6 +191,16 @@ function createWindow() {
|
|||||||
mainWindow.maximize();
|
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
|
// Save window state on close
|
||||||
mainWindow.on('close', () => {
|
mainWindow.on('close', () => {
|
||||||
// Flush localStorage/sessionStorage to disk before renderer is destroyed
|
// Flush localStorage/sessionStorage to disk before renderer is destroyed
|
||||||
@@ -249,14 +259,25 @@ app.whenReady().then(async () => {
|
|||||||
createWindow();
|
createWindow();
|
||||||
} else {
|
} else {
|
||||||
const splash = createSplashWindow();
|
const splash = createSplashWindow();
|
||||||
const noUpdate = await checkForUpdates(splash);
|
const shouldOpenMain = await checkForUpdates(splash);
|
||||||
if (noUpdate === false) {
|
if (shouldOpenMain) {
|
||||||
if (!splash.isDestroyed()) splash.close();
|
if (!splash.isDestroyed()) splash.close();
|
||||||
createWindow();
|
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', () => {
|
ipcMain.on('window-minimize', () => {
|
||||||
const win = BrowserWindow.getFocusedWindow();
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
if (win) win.minimize();
|
if (win) win.minimize();
|
||||||
@@ -272,8 +293,49 @@ app.whenReady().then(async () => {
|
|||||||
const win = BrowserWindow.getFocusedWindow();
|
const win = BrowserWindow.getFocusedWindow();
|
||||||
if (win) win.close();
|
if (win) win.close();
|
||||||
});
|
});
|
||||||
ipcMain.on('flash-frame', () => {
|
ipcMain.on('flash-frame', (_event, on) => {
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.flashFrame(true);
|
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)
|
// Helper to fetch metadata (Zero-Knowledge: Client fetches previews)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/electron",
|
"name": "@discord-clone/electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"description": "Brycord - Electron app",
|
"description": "Brycord - Electron app",
|
||||||
"author": "Moyettes",
|
"author": "Moyettes",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ contextBridge.exposeInMainWorld('windowControls', {
|
|||||||
minimize: () => ipcRenderer.send('window-minimize'),
|
minimize: () => ipcRenderer.send('window-minimize'),
|
||||||
maximize: () => ipcRenderer.send('window-maximize'),
|
maximize: () => ipcRenderer.send('window-maximize'),
|
||||||
close: () => ipcRenderer.send('window-close'),
|
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', {
|
contextBridge.exposeInMainWorld('appSettings', {
|
||||||
@@ -35,6 +41,13 @@ contextBridge.exposeInMainWorld('appSettings', {
|
|||||||
|
|
||||||
contextBridge.exposeInMainWorld('updateAPI', {
|
contextBridge.exposeInMainWorld('updateAPI', {
|
||||||
checkFlatpakUpdate: () => ipcRenderer.invoke('check-flatpak-update'),
|
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', {
|
contextBridge.exposeInMainWorld('sessionPersistence', {
|
||||||
|
|||||||
@@ -52,7 +52,13 @@ const electronPlatform = {
|
|||||||
minimize: () => window.windowControls.minimize(),
|
minimize: () => window.windowControls.minimize(),
|
||||||
maximize: () => window.windowControls.maximize(),
|
maximize: () => window.windowControls.maximize(),
|
||||||
close: () => window.windowControls.close(),
|
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: {
|
recording: {
|
||||||
getDefaultFolder: () => window.recordingAPI.getDefaultFolder(),
|
getDefaultFolder: () => window.recordingAPI.getDefaultFolder(),
|
||||||
@@ -70,6 +76,9 @@ const electronPlatform = {
|
|||||||
},
|
},
|
||||||
updates: {
|
updates: {
|
||||||
checkUpdate: () => window.updateAPI.checkFlatpakUpdate(),
|
checkUpdate: () => window.updateAPI.checkFlatpakUpdate(),
|
||||||
|
getStatus: () => window.updateAPI.getStatus(),
|
||||||
|
downloadAndInstall: () => window.updateAPI.downloadAndInstall(),
|
||||||
|
onStatusChanged: (cb) => window.updateAPI.onStatusChanged(cb),
|
||||||
},
|
},
|
||||||
systemBars: null,
|
systemBars: null,
|
||||||
searchDB,
|
searchDB,
|
||||||
@@ -80,6 +89,7 @@ const electronPlatform = {
|
|||||||
hasSearch: true,
|
hasSearch: true,
|
||||||
hasSystemBars: false,
|
hasSystemBars: false,
|
||||||
hasRecording: true,
|
hasRecording: true,
|
||||||
|
hasNotifications: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,52 @@ autoUpdater.logger = log;
|
|||||||
autoUpdater.autoDownload = false;
|
autoUpdater.autoDownload = false;
|
||||||
autoUpdater.autoInstallOnAppQuit = true;
|
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) {
|
function checkForUpdates(splashWindow) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
function sendToSplash(js) {
|
function sendToSplash(js) {
|
||||||
@@ -17,45 +63,133 @@ function checkForUpdates(splashWindow) {
|
|||||||
sendToSplash('setStatus("Checking for updates...")');
|
sendToSplash('setStatus("Checking for updates...")');
|
||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('update-available', () => {
|
autoUpdater.on('update-available', (info) => {
|
||||||
sendToSplash('setStatus("Downloading update...")');
|
const required = isRequired(info);
|
||||||
autoUpdater.downloadUpdate();
|
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) => {
|
autoUpdater.on('download-progress', (progress) => {
|
||||||
const percent = Math.round(progress.percent);
|
const percent = Math.round(progress.percent || 0);
|
||||||
sendToSplash(`setProgress(${percent})`);
|
lastCheckResult.progress = percent;
|
||||||
sendToSplash(`setStatus("Downloading update... ${percent}%")`);
|
lastCheckResult.downloading = true;
|
||||||
|
emitStatus();
|
||||||
|
if (lastCheckResult.required) {
|
||||||
|
sendToSplash(`setProgress(${percent})`);
|
||||||
|
sendToSplash(`setStatus("Downloading required update... ${percent}%")`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('update-downloaded', () => {
|
autoUpdater.on('update-downloaded', () => {
|
||||||
sendToSplash('setStatus("Installing update...")');
|
lastCheckResult.downloading = false;
|
||||||
sendToSplash('setProgress(100)');
|
lastCheckResult.downloaded = true;
|
||||||
setTimeout(() => {
|
lastCheckResult.progress = 100;
|
||||||
autoUpdater.quitAndInstall();
|
emitStatus();
|
||||||
}, 1500);
|
if (lastCheckResult.required) {
|
||||||
|
sendToSplash('setStatus("Installing update...")');
|
||||||
|
sendToSplash('setProgress(100)');
|
||||||
|
setTimeout(() => {
|
||||||
|
autoUpdater.quitAndInstall();
|
||||||
|
}, 1200);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('update-not-available', () => {
|
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('setStatus("Up to date!")');
|
||||||
sendToSplash('hideProgress()');
|
sendToSplash('hideProgress()');
|
||||||
setTimeout(() => resolve(false), 1000);
|
setTimeout(() => resolve(true), 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.on('error', (err) => {
|
autoUpdater.on('error', (err) => {
|
||||||
log.error('Auto-updater error:', err);
|
log.error('Auto-updater error:', err);
|
||||||
|
lastCheckResult.error = err?.message || String(err);
|
||||||
|
lastCheckResult.downloading = false;
|
||||||
|
emitStatus();
|
||||||
sendToSplash('setStatus("Update check failed")');
|
sendToSplash('setStatus("Update check failed")');
|
||||||
sendToSplash('hideProgress()');
|
sendToSplash('hideProgress()');
|
||||||
setTimeout(() => resolve(false), 2000);
|
setTimeout(() => resolve(true), 1500);
|
||||||
});
|
});
|
||||||
|
|
||||||
autoUpdater.checkForUpdates().catch((err) => {
|
autoUpdater.checkForUpdates().catch((err) => {
|
||||||
log.error('checkForUpdates failed:', err);
|
log.error('checkForUpdates failed:', err);
|
||||||
|
lastCheckResult.error = err?.message || String(err);
|
||||||
|
emitStatus();
|
||||||
sendToSplash('setStatus("Update check failed")');
|
sendToSplash('setStatus("Update check failed")');
|
||||||
sendToSplash('hideProgress()');
|
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",
|
"name": "@discord-clone/web",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
4
convex/_generated/api.d.ts
vendored
4
convex/_generated/api.d.ts
vendored
@@ -8,9 +8,11 @@
|
|||||||
* @module
|
* @module
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type * as audit from "../audit.js";
|
||||||
import type * as auth from "../auth.js";
|
import type * as auth from "../auth.js";
|
||||||
import type * as authActions from "../authActions.js";
|
import type * as authActions from "../authActions.js";
|
||||||
import type * as authGuard from "../authGuard.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 categories from "../categories.js";
|
||||||
import type * as channelKeys from "../channelKeys.js";
|
import type * as channelKeys from "../channelKeys.js";
|
||||||
import type * as channels from "../channels.js";
|
import type * as channels from "../channels.js";
|
||||||
@@ -43,9 +45,11 @@ import type {
|
|||||||
} from "convex/server";
|
} from "convex/server";
|
||||||
|
|
||||||
declare const fullApi: ApiFromModules<{
|
declare const fullApi: ApiFromModules<{
|
||||||
|
audit: typeof audit;
|
||||||
auth: typeof auth;
|
auth: typeof auth;
|
||||||
authActions: typeof authActions;
|
authActions: typeof authActions;
|
||||||
authGuard: typeof authGuard;
|
authGuard: typeof authGuard;
|
||||||
|
bans: typeof bans;
|
||||||
categories: typeof categories;
|
categories: typeof categories;
|
||||||
channelKeys: typeof channelKeys;
|
channelKeys: typeof channelKeys;
|
||||||
channels: typeof channels;
|
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 { v } from "convex/values";
|
||||||
import { getPublicStorageUrl } from "./storageUrl";
|
import { getPublicStorageUrl } from "./storageUrl";
|
||||||
import { getRolesForUser } from "./roles";
|
import { getRolesForUser } from "./roles";
|
||||||
|
import { isBanned } from "./bans";
|
||||||
|
|
||||||
async function sha256Hex(input: string): Promise<string> {
|
async function sha256Hex(input: string): Promise<string> {
|
||||||
const buffer = await crypto.subtle.digest(
|
const buffer = await crypto.subtle.digest(
|
||||||
@@ -62,6 +63,9 @@ export const verifyUser = mutation({
|
|||||||
const hashedDAK = await sha256Hex(args.dak);
|
const hashedDAK = await sha256Hex(args.dak);
|
||||||
|
|
||||||
if (hashedDAK === user.hashedAuthKey) {
|
if (hashedDAK === user.hashedAuthKey) {
|
||||||
|
if (await isBanned(ctx, user._id)) {
|
||||||
|
return { error: "You've been banned from this server." };
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
userId: user._id,
|
userId: user._id,
|
||||||
@@ -166,6 +170,7 @@ export const createUserWithProfile = mutation({
|
|||||||
create_invite: true,
|
create_invite: true,
|
||||||
embed_links: true,
|
embed_links: true,
|
||||||
attach_files: true,
|
attach_files: true,
|
||||||
|
ban_members: true,
|
||||||
},
|
},
|
||||||
isHoist: true,
|
isHoist: true,
|
||||||
});
|
});
|
||||||
@@ -205,6 +210,7 @@ export const getPublicKeys = query({
|
|||||||
customStatus: v.optional(v.string()),
|
customStatus: v.optional(v.string()),
|
||||||
joinSoundUrl: v.optional(v.union(v.string(), v.null())),
|
joinSoundUrl: v.optional(v.union(v.string(), v.null())),
|
||||||
accentColor: v.optional(v.string()),
|
accentColor: v.optional(v.string()),
|
||||||
|
bannerUrl: v.optional(v.union(v.string(), v.null())),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx) => {
|
handler: async (ctx) => {
|
||||||
@@ -219,6 +225,10 @@ export const getPublicKeys = query({
|
|||||||
if (u.joinSoundStorageId) {
|
if (u.joinSoundStorageId) {
|
||||||
joinSoundUrl = await getPublicStorageUrl(ctx, u.joinSoundStorageId);
|
joinSoundUrl = await getPublicStorageUrl(ctx, u.joinSoundStorageId);
|
||||||
}
|
}
|
||||||
|
let bannerUrl: string | null = null;
|
||||||
|
if (u.bannerStorageId) {
|
||||||
|
bannerUrl = await getPublicStorageUrl(ctx, u.bannerStorageId);
|
||||||
|
}
|
||||||
results.push({
|
results.push({
|
||||||
id: u._id,
|
id: u._id,
|
||||||
username: u.username,
|
username: u.username,
|
||||||
@@ -230,6 +240,7 @@ export const getPublicKeys = query({
|
|||||||
customStatus: u.customStatus,
|
customStatus: u.customStatus,
|
||||||
joinSoundUrl,
|
joinSoundUrl,
|
||||||
accentColor: u.accentColor,
|
accentColor: u.accentColor,
|
||||||
|
bannerUrl,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return results;
|
return results;
|
||||||
@@ -251,6 +262,8 @@ export const updateProfileInternal = internalMutation({
|
|||||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||||
removeJoinSound: v.optional(v.boolean()),
|
removeJoinSound: v.optional(v.boolean()),
|
||||||
accentColor: v.optional(v.string()),
|
accentColor: v.optional(v.string()),
|
||||||
|
bannerStorageId: v.optional(v.id("_storage")),
|
||||||
|
removeBanner: v.optional(v.boolean()),
|
||||||
},
|
},
|
||||||
returns: v.null(),
|
returns: v.null(),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -262,6 +275,17 @@ export const updateProfileInternal = internalMutation({
|
|||||||
if (args.joinSoundStorageId !== undefined) patch.joinSoundStorageId = args.joinSoundStorageId;
|
if (args.joinSoundStorageId !== undefined) patch.joinSoundStorageId = args.joinSoundStorageId;
|
||||||
if (args.removeJoinSound) patch.joinSoundStorageId = undefined;
|
if (args.removeJoinSound) patch.joinSoundStorageId = undefined;
|
||||||
if (args.accentColor !== undefined) patch.accentColor = args.accentColor;
|
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);
|
await ctx.db.patch(args.userId, patch);
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export const updateProfile = action({
|
|||||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||||
removeJoinSound: v.optional(v.boolean()),
|
removeJoinSound: v.optional(v.boolean()),
|
||||||
accentColor: v.optional(v.string()),
|
accentColor: v.optional(v.string()),
|
||||||
|
bannerStorageId: v.optional(v.id("_storage")),
|
||||||
|
removeBanner: v.optional(v.boolean()),
|
||||||
authTimestamp: v.number(),
|
authTimestamp: v.number(),
|
||||||
authSignature: v.string(),
|
authSignature: v.string(),
|
||||||
},
|
},
|
||||||
@@ -36,6 +38,8 @@ export const updateProfile = action({
|
|||||||
joinSoundStorageId: args.joinSoundStorageId,
|
joinSoundStorageId: args.joinSoundStorageId,
|
||||||
removeJoinSound: args.removeJoinSound,
|
removeJoinSound: args.removeJoinSound,
|
||||||
accentColor: args.accentColor,
|
accentColor: args.accentColor,
|
||||||
|
bannerStorageId: args.bannerStorageId,
|
||||||
|
removeBanner: args.removeBanner,
|
||||||
});
|
});
|
||||||
return null;
|
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 { GenericMutationCtx } from "convex/server";
|
||||||
import { DataModel, Id } from "./_generated/dataModel";
|
import { DataModel, Id } from "./_generated/dataModel";
|
||||||
import { internal } from "./_generated/api";
|
import { internal } from "./_generated/api";
|
||||||
|
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||||
|
|
||||||
type TableWithChannelIndex =
|
type TableWithChannelIndex =
|
||||||
| "channelKeys"
|
| "channelKeys"
|
||||||
@@ -74,6 +75,7 @@ export const create = mutation({
|
|||||||
categoryId: v.optional(v.id("categories")),
|
categoryId: v.optional(v.id("categories")),
|
||||||
topic: v.optional(v.string()),
|
topic: v.optional(v.string()),
|
||||||
position: v.optional(v.number()),
|
position: v.optional(v.number()),
|
||||||
|
actorId: v.optional(v.id("userProfiles")),
|
||||||
},
|
},
|
||||||
returns: v.object({ id: v.id("channels") }),
|
returns: v.object({ id: v.id("channels") }),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -112,6 +114,17 @@ export const create = mutation({
|
|||||||
position,
|
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 };
|
return { id };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -136,6 +149,7 @@ export const rename = mutation({
|
|||||||
args: {
|
args: {
|
||||||
id: v.id("channels"),
|
id: v.id("channels"),
|
||||||
name: v.string(),
|
name: v.string(),
|
||||||
|
actorId: v.optional(v.id("userProfiles")),
|
||||||
},
|
},
|
||||||
returns: v.object({
|
returns: v.object({
|
||||||
_id: v.id("channels"),
|
_id: v.id("channels"),
|
||||||
@@ -156,7 +170,20 @@ export const rename = mutation({
|
|||||||
throw new Error("Channel not found");
|
throw new Error("Channel not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldName = channel.name;
|
||||||
await ctx.db.patch(args.id, { name: args.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 };
|
return { ...channel, name: args.name };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -205,7 +232,10 @@ export const reorderChannels = mutation({
|
|||||||
|
|
||||||
// Delete channel + cascade messages and keys
|
// Delete channel + cascade messages and keys
|
||||||
export const remove = mutation({
|
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() }),
|
returns: v.object({ success: v.boolean() }),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
const channel = await ctx.db.get(args.id);
|
const channel = await ctx.db.get(args.id);
|
||||||
@@ -213,6 +243,9 @@ export const remove = mutation({
|
|||||||
throw new Error("Channel not found");
|
throw new Error("Channel not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deletedName = channel.name;
|
||||||
|
const deletedType = channel.type;
|
||||||
|
|
||||||
// Delete reactions for all messages in this channel
|
// Delete reactions for all messages in this channel
|
||||||
const messages = await ctx.db
|
const messages = await ctx.db
|
||||||
.query("messages")
|
.query("messages")
|
||||||
@@ -240,6 +273,17 @@ export const remove = mutation({
|
|||||||
|
|
||||||
await ctx.db.delete(args.id);
|
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 };
|
return { success: true };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { paginationOptsValidator } from "convex/server";
|
|||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { getPublicStorageUrl } from "./storageUrl";
|
import { getPublicStorageUrl } from "./storageUrl";
|
||||||
import { getRolesForUser } from "./roles";
|
import { getRolesForUser } from "./roles";
|
||||||
|
import { isBanned } from "./bans";
|
||||||
|
|
||||||
const DEFAULT_ROLE_COLOR = "#99aab5";
|
const DEFAULT_ROLE_COLOR = "#99aab5";
|
||||||
|
|
||||||
@@ -252,6 +253,9 @@ export const sendInternal = internalMutation({
|
|||||||
if (args.ciphertext.length > MAX_CIPHERTEXT_CHARS) {
|
if (args.ciphertext.length > MAX_CIPHERTEXT_CHARS) {
|
||||||
throw new Error("Message too large");
|
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", {
|
const id = await ctx.db.insert("messages", {
|
||||||
channelId: args.channelId,
|
channelId: args.channelId,
|
||||||
senderId: args.senderId,
|
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({
|
export const getLatestMessageTimestamps = query({
|
||||||
args: {
|
args: {
|
||||||
channelIds: v.array(v.id("channels")),
|
channelIds: v.array(v.id("channels")),
|
||||||
@@ -91,6 +95,8 @@ export const getLatestMessageTimestamps = query({
|
|||||||
v.object({
|
v.object({
|
||||||
channelId: v.id("channels"),
|
channelId: v.id("channels"),
|
||||||
latestTimestamp: v.number(),
|
latestTimestamp: v.number(),
|
||||||
|
messageId: v.optional(v.id("messages")),
|
||||||
|
senderId: v.optional(v.id("userProfiles")),
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
@@ -105,6 +111,8 @@ export const getLatestMessageTimestamps = query({
|
|||||||
results.push({
|
results.push({
|
||||||
channelId,
|
channelId,
|
||||||
latestTimestamp: Math.floor(latestMsg._creationTime),
|
latestTimestamp: Math.floor(latestMsg._creationTime),
|
||||||
|
messageId: latestMsg._id,
|
||||||
|
senderId: latestMsg.senderId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const PERMISSION_KEYS = [
|
|||||||
"move_members",
|
"move_members",
|
||||||
"mute_members",
|
"mute_members",
|
||||||
"manage_nicknames",
|
"manage_nicknames",
|
||||||
|
"ban_members",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export async function getRolesForUser(
|
export async function getRolesForUser(
|
||||||
@@ -30,6 +31,31 @@ export async function getRolesForUser(
|
|||||||
return roles.filter((r): r is Doc<"roles"> => r !== null);
|
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
|
// List all roles
|
||||||
export const list = query({
|
export const list = query({
|
||||||
args: {},
|
args: {},
|
||||||
@@ -249,15 +275,22 @@ export const getMyPermissions = query({
|
|||||||
move_members: v.boolean(),
|
move_members: v.boolean(),
|
||||||
mute_members: v.boolean(),
|
mute_members: v.boolean(),
|
||||||
manage_nicknames: v.boolean(),
|
manage_nicknames: v.boolean(),
|
||||||
|
ban_members: v.boolean(),
|
||||||
}),
|
}),
|
||||||
handler: async (ctx, args) => {
|
handler: async (ctx, args) => {
|
||||||
|
const user = await ctx.db.get(args.userId);
|
||||||
const roles = await getRolesForUser(ctx, 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> = {};
|
const finalPerms: Record<string, boolean> = {};
|
||||||
for (const key of PERMISSION_KEYS) {
|
for (const key of PERMISSION_KEYS) {
|
||||||
finalPerms[key] = roles.some(
|
finalPerms[key] =
|
||||||
(role) => (role.permissions as Record<string, boolean>)?.[key]
|
isSuper ||
|
||||||
);
|
roles.some(
|
||||||
|
(role) => (role.permissions as Record<string, boolean>)?.[key],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return finalPerms as {
|
return finalPerms as {
|
||||||
@@ -270,6 +303,7 @@ export const getMyPermissions = query({
|
|||||||
move_members: boolean;
|
move_members: boolean;
|
||||||
mute_members: boolean;
|
mute_members: boolean;
|
||||||
manage_nicknames: boolean;
|
manage_nicknames: boolean;
|
||||||
|
ban_members: boolean;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export default defineSchema({
|
|||||||
customStatus: v.optional(v.string()),
|
customStatus: v.optional(v.string()),
|
||||||
joinSoundStorageId: v.optional(v.id("_storage")),
|
joinSoundStorageId: v.optional(v.id("_storage")),
|
||||||
accentColor: v.optional(v.string()),
|
accentColor: v.optional(v.string()),
|
||||||
|
bannerStorageId: v.optional(v.id("_storage")),
|
||||||
}).index("by_username", ["username"]),
|
}).index("by_username", ["username"]),
|
||||||
|
|
||||||
categories: defineTable({
|
categories: defineTable({
|
||||||
@@ -191,6 +192,31 @@ export default defineSchema({
|
|||||||
.index("by_poll_user_emoji", ["pollId", "userId", "emoji"])
|
.index("by_poll_user_emoji", ["pollId", "userId", "emoji"])
|
||||||
.index("by_user", ["userId"]),
|
.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({
|
savedMedia: defineTable({
|
||||||
userId: v.id("userProfiles"),
|
userId: v.id("userProfiles"),
|
||||||
// Convex storage URL — also the dedupe key for a single user.
|
// Convex storage URL — also the dedupe key for a single user.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { query, mutation, internalMutation } from "./_generated/server";
|
import { query, mutation, internalMutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
import { getRolesForUser } from "./roles";
|
import { getRolesForUser } from "./roles";
|
||||||
|
import { AUDIT_ACTIONS, logAudit } from "./audit";
|
||||||
|
|
||||||
export const get = query({
|
export const get = query({
|
||||||
args: {},
|
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;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -85,6 +93,7 @@ export const updateName = mutation({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existing = await ctx.db.query("serverSettings").first();
|
const existing = await ctx.db.query("serverSettings").first();
|
||||||
|
const oldName = existing?.serverName;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await ctx.db.patch(existing._id, { serverName: name });
|
await ctx.db.patch(existing._id, { serverName: name });
|
||||||
} else {
|
} 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;
|
return null;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,50 @@ import SearchDatabase from '@discord-clone/shared/src/utils/SearchDatabase';
|
|||||||
|
|
||||||
const searchDB = new SearchDatabase(searchStorage, crypto);
|
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 = {
|
const webPlatform = {
|
||||||
crypto,
|
crypto,
|
||||||
session,
|
session,
|
||||||
@@ -34,6 +78,7 @@ const webPlatform = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
windowControls: null,
|
windowControls: null,
|
||||||
|
notifications: makeWebNotifications(),
|
||||||
recording: null,
|
recording: null,
|
||||||
updates: null,
|
updates: null,
|
||||||
voiceService: null,
|
voiceService: null,
|
||||||
@@ -47,6 +92,7 @@ const webPlatform = {
|
|||||||
hasVoiceService: false,
|
hasVoiceService: false,
|
||||||
hasSystemBars: false,
|
hasSystemBars: false,
|
||||||
hasRecording: false,
|
hasRecording: false,
|
||||||
|
hasNotifications: typeof window !== 'undefined' && typeof window.Notification !== 'undefined',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@discord-clone/shared",
|
"name": "@discord-clone/shared",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.2",
|
"version": "1.1.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "src/App.tsx",
|
"main": "src/App.tsx",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -109,8 +109,26 @@ export function AttachmentVideo({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = videoRef.current;
|
const el = videoRef.current;
|
||||||
if (!el) return;
|
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 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 onPlay = () => setIsPlaying(true);
|
||||||
const onPause = () => setIsPlaying(false);
|
const onPause = () => setIsPlaying(false);
|
||||||
const onEnded = () => setIsPlaying(false);
|
const onEnded = () => setIsPlaying(false);
|
||||||
@@ -147,6 +165,15 @@ export function AttachmentVideo({
|
|||||||
const handleStartPlay = useCallback(() => {
|
const handleStartPlay = useCallback(() => {
|
||||||
const el = videoRef.current;
|
const el = videoRef.current;
|
||||||
if (!el) return;
|
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);
|
setHasStarted(true);
|
||||||
void el.play().catch(() => {});
|
void el.play().catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import {
|
|||||||
ChartBar,
|
ChartBar,
|
||||||
Gif,
|
Gif,
|
||||||
ImageSquare,
|
ImageSquare,
|
||||||
|
Lock,
|
||||||
|
Microphone,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
PlusCircle,
|
PlusCircle,
|
||||||
Smiley,
|
Smiley,
|
||||||
Sticker,
|
Sticker,
|
||||||
|
Trash,
|
||||||
X,
|
X,
|
||||||
} from '@phosphor-icons/react';
|
} from '@phosphor-icons/react';
|
||||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from '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 [mentionQuery, setMentionQuery] = useState<string | null>(null);
|
||||||
const mentionRef = useRef<MentionAutocompleteHandle>(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 userId = typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||||
const keybinds = useKeybinds();
|
const keybinds = useKeybinds();
|
||||||
const username =
|
const username =
|
||||||
@@ -560,7 +609,10 @@ export function ChannelTextarea({
|
|||||||
return new Uint8Array(matches.map((b) => parseInt(b, 16)));
|
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.
|
// 1. Encrypt the file with a fresh per-file AES key.
|
||||||
const fileKey = await crypto.randomBytes(32);
|
const fileKey = await crypto.randomBytes(32);
|
||||||
const buf = new Uint8Array(await file.arrayBuffer());
|
const buf = new Uint8Array(await file.arrayBuffer());
|
||||||
@@ -596,10 +648,235 @@ export function ChannelTextarea({
|
|||||||
key: fileKey,
|
key: fileKey,
|
||||||
iv: encrypted.iv,
|
iv: encrypted.iv,
|
||||||
...(dims && { width: dims.width, height: dims.height }),
|
...(dims && { width: dims.width, height: dims.height }),
|
||||||
|
...(extra || {}),
|
||||||
};
|
};
|
||||||
await sendAttachmentMessage(metadata);
|
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
|
// Stage files as pending attachments above the composer instead of
|
||||||
// uploading immediately. The actual encrypt+upload runs in doSend()
|
// uploading immediately. The actual encrypt+upload runs in doSend()
|
||||||
// when the user presses Send.
|
// when the user presses Send.
|
||||||
@@ -824,7 +1101,14 @@ export function ChannelTextarea({
|
|||||||
: `Message #${channelName}`;
|
: `Message #${channelName}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.outer}>
|
<div
|
||||||
|
className={styles.outer}
|
||||||
|
style={
|
||||||
|
isMobile && isRecording
|
||||||
|
? { paddingLeft: 0, paddingRight: 0 }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{replyTo && onCancelReply && (
|
{replyTo && onCancelReply && (
|
||||||
<div className={styles.replyBar}>
|
<div className={styles.replyBar}>
|
||||||
<span className={styles.replyText}>
|
<span className={styles.replyText}>
|
||||||
@@ -854,12 +1138,130 @@ export function ChannelTextarea({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<PendingAttachmentRow
|
{!(isMobile && isRecording) && (
|
||||||
attachments={pendingAttachments}
|
<PendingAttachmentRow
|
||||||
onRemove={removePendingAttachment}
|
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}>
|
<div className={styles.uploadColumn}>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -896,70 +1298,271 @@ export function ChannelTextarea({
|
|||||||
{isEmpty && <div className={styles.placeholder}>{placeholder}</div>}
|
{isEmpty && <div className={styles.placeholder}>{placeholder}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.buttonContainer} ref={expressionButtonsRef}>
|
{isRecording && !isMobile ? (
|
||||||
<Tooltip
|
<div
|
||||||
content="GIFs"
|
className={styles.buttonContainer}
|
||||||
shortcut={keybinds.getCombo('popouts.openGifPicker')}
|
style={{ alignItems: 'center', gap: 10, paddingRight: 6 }}
|
||||||
placement="top"
|
|
||||||
>
|
>
|
||||||
<button
|
<span
|
||||||
type="button"
|
aria-hidden="true"
|
||||||
className={styles.textareaButton}
|
style={{
|
||||||
onClick={() => openPickerOnTab('gifs')}
|
width: 10,
|
||||||
aria-label="GIFs"
|
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"
|
||||||
|
shortcut={keybinds.getCombo('popouts.openGifPicker')}
|
||||||
|
placement="top"
|
||||||
>
|
>
|
||||||
<Gif size={26} className={styles.textareaButtonIcon} />
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</Tooltip>
|
className={styles.textareaButton}
|
||||||
<Tooltip content="Saved Media" placement="top">
|
onClick={() => openPickerOnTab('gifs')}
|
||||||
<button
|
aria-label="GIFs"
|
||||||
type="button"
|
>
|
||||||
className={styles.textareaButton}
|
<Gif size={26} className={styles.textareaButtonIcon} />
|
||||||
onClick={() => openPickerOnTab('media')}
|
</button>
|
||||||
aria-label="Saved Media"
|
</Tooltip>
|
||||||
|
<Tooltip content="Saved Media" placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.textareaButton}
|
||||||
|
onClick={() => openPickerOnTab('media')}
|
||||||
|
aria-label="Saved Media"
|
||||||
|
>
|
||||||
|
<ImageSquare size={26} className={styles.textareaButtonIcon} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Stickers" placement="top">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.textareaButton}
|
||||||
|
onClick={() => openPickerOnTab('stickers')}
|
||||||
|
aria-label="Stickers"
|
||||||
|
>
|
||||||
|
<Sticker size={26} className={styles.textareaButtonIcon} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip
|
||||||
|
content="Emoji"
|
||||||
|
shortcut={keybinds.getCombo('popouts.openEmojiPicker')}
|
||||||
|
placement="top"
|
||||||
>
|
>
|
||||||
<ImageSquare size={26} className={styles.textareaButtonIcon} />
|
<button
|
||||||
</button>
|
ref={emojiButtonRef}
|
||||||
</Tooltip>
|
type="button"
|
||||||
<Tooltip content="Stickers" placement="top">
|
className={styles.textareaButton}
|
||||||
<button
|
onClick={toggleEmojiPicker}
|
||||||
type="button"
|
aria-label="Emoji"
|
||||||
className={styles.textareaButton}
|
>
|
||||||
onClick={() => openPickerOnTab('stickers')}
|
<Smiley size={26} className={styles.textareaButtonIcon} />
|
||||||
aria-label="Stickers"
|
</button>
|
||||||
>
|
</Tooltip>
|
||||||
<Sticker size={26} className={styles.textareaButtonIcon} />
|
</div>
|
||||||
</button>
|
)}
|
||||||
</Tooltip>
|
</>
|
||||||
<Tooltip
|
)}
|
||||||
content="Emoji"
|
|
||||||
shortcut={keybinds.getCombo('popouts.openEmojiPicker')}
|
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
ref={emojiButtonRef}
|
|
||||||
type="button"
|
|
||||||
className={styles.textareaButton}
|
|
||||||
onClick={toggleEmojiPicker}
|
|
||||||
aria-label="Emoji"
|
|
||||||
>
|
|
||||||
<Smiley size={26} className={styles.textareaButtonIcon} />
|
|
||||||
</button>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.sendColumn}>
|
<div className={styles.sendColumn} style={{ position: 'relative' }}>
|
||||||
<button
|
{isMobile && isEmpty && pendingAttachments.length === 0 && !isLocked ? (
|
||||||
type="button"
|
<>
|
||||||
className={styles.sendButton}
|
{/* Lock indicator: appears above the mic while the
|
||||||
onClick={doSend}
|
user is holding but has not yet dragged up far
|
||||||
disabled={(isEmpty && pendingAttachments.length === 0) || !channelKey || isUploading}
|
enough to lock. Fills briefly when the drag
|
||||||
aria-label="Send message"
|
enters the lock zone. */}
|
||||||
>
|
{isRecording && !isLocked && isHoldingRef.current && (
|
||||||
<ArrowUp size={22} weight="bold" />
|
<div
|
||||||
</button>
|
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}
|
||||||
|
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>
|
||||||
</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 && (
|
{mentionQuery !== null && (
|
||||||
<MentionAutocomplete
|
<MentionAutocomplete
|
||||||
@@ -1055,6 +1658,19 @@ export function ChannelTextarea({
|
|||||||
<ChartBar size={20} />
|
<ChartBar size={20} />
|
||||||
<span>Create Poll</span>
|
<span>Create Poll</span>
|
||||||
</button>
|
</button>
|
||||||
|
{!isMobile && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.plusMenuItem}
|
||||||
|
onClick={() => {
|
||||||
|
setShowPlusMenu(false);
|
||||||
|
void startVoiceRecording();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Microphone size={20} />
|
||||||
|
<span>Record Voice Message</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { usePlatform } from '../../platform';
|
import { usePlatform } from '../../platform';
|
||||||
import { AttachmentAudio } from './AttachmentAudio';
|
import { AttachmentAudio } from './AttachmentAudio';
|
||||||
import { AttachmentVideo } from './AttachmentVideo';
|
import { AttachmentVideo } from './AttachmentVideo';
|
||||||
|
import { VoiceMessagePlayer } from './VoiceMessagePlayer';
|
||||||
|
|
||||||
export interface AttachmentMetadata {
|
export interface AttachmentMetadata {
|
||||||
type: 'attachment';
|
type: 'attachment';
|
||||||
@@ -15,6 +16,15 @@ export interface AttachmentMetadata {
|
|||||||
iv: string;
|
iv: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: 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;
|
const TAG_HEX_LEN = 32;
|
||||||
@@ -239,14 +249,23 @@ export function EncryptedAttachment({ metadata, onImageClick, className }: Attac
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: 360,
|
width: metadata.isVoiceMessage ? 280 : 360,
|
||||||
height: 96,
|
height: metadata.isVoiceMessage ? 44 : 96,
|
||||||
backgroundColor: 'var(--background-tertiary)',
|
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 (
|
return (
|
||||||
<AttachmentAudio
|
<AttachmentAudio
|
||||||
src={url}
|
src={url}
|
||||||
|
|||||||
@@ -37,6 +37,17 @@
|
|||||||
white-space: pre-wrap;
|
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 {
|
.editor:empty::before {
|
||||||
content: attr(data-placeholder);
|
content: attr(data-placeholder);
|
||||||
color: var(--text-tertiary);
|
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.
|
// because a wrong fluid ratio shifts height when the real image lands.
|
||||||
const EMBED_IMG_FALLBACK = { w: 400, h: 210 } as const;
|
const EMBED_IMG_FALLBACK = { w: 400, h: 210 } as const;
|
||||||
|
|
||||||
// Direct inline video embeds default to 16:9 — most web video ships at
|
// Natural `videoWidth` / `videoHeight` (intrinsic pixel dimensions)
|
||||||
// that ratio. A wrong default just means a little blank space above or
|
// learned from the first `loadedmetadata` event per URL. Fills in
|
||||||
// below the video, not a scroll jump.
|
// the wrapper aspect-ratio + size so portrait, square, and other
|
||||||
const DIRECT_VIDEO_FALLBACK_RATIO = '16 / 9';
|
// 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 {
|
function normaliseMetadata(raw: any): UrlPreview | null {
|
||||||
if (!raw || typeof raw !== 'object') return null;
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
@@ -208,6 +229,12 @@ function DirectMediaEmbed({
|
|||||||
}) {
|
}) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const [playing, setPlaying] = useState(false);
|
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') {
|
if (type === 'video') {
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
@@ -220,10 +247,12 @@ function DirectMediaEmbed({
|
|||||||
// `preload="metadata"` leaves the <video> element at zero height
|
// `preload="metadata"` leaves the <video> element at zero height
|
||||||
// until `loadedmetadata` fires — that was a measurable source of
|
// until `loadedmetadata` fires — that was a measurable source of
|
||||||
// scroll jump. Wrap it in an aspect-ratio box so the space is
|
// scroll jump. Wrap it in an aspect-ratio box so the space is
|
||||||
// reserved from the first paint. 16:9 is the overwhelming majority
|
// reserved from the first paint. First mount uses a 16:9
|
||||||
// of web video; when the real metadata lands and differs slightly
|
// fallback; subsequent mounts hydrate from the module cache so
|
||||||
// the ResizeObserver catches it, but the gross box is already
|
// repeat views of the same URL jump straight to the real
|
||||||
// there.
|
// 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
|
// 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
|
// 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
|
// blank rectangle for `<video preload="metadata">` because it
|
||||||
// only fetches container metadata, not frames.
|
// only fetches container metadata, not frames.
|
||||||
const posterSrc = url.includes('#') ? url : `${url}#t=0.1`;
|
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 (
|
return (
|
||||||
<div className={`${styles.embed} ${styles.embedBare}`}>
|
<div className={`${styles.embed} ${styles.embedBare}`}>
|
||||||
<div
|
<div
|
||||||
className={styles.directVideoWrapper}
|
className={styles.directVideoWrapper}
|
||||||
style={{
|
style={{
|
||||||
aspectRatio: DIRECT_VIDEO_FALLBACK_RATIO,
|
width: fit.w,
|
||||||
width: 400,
|
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
|
aspectRatio: ratio,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<video
|
<video
|
||||||
@@ -247,7 +282,17 @@ function DirectMediaEmbed({
|
|||||||
src={posterSrc}
|
src={posterSrc}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
style={{ width: '100%', height: '100%' }}
|
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(
|
window.dispatchEvent(
|
||||||
new CustomEvent('brycord:attachment-loaded'),
|
new CustomEvent('brycord:attachment-loaded'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -44,6 +44,11 @@
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.underline {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.strikethrough {
|
.strikethrough {
|
||||||
text-decoration: line-through;
|
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 { getTwemojiUrl } from '../../utils/twemoji';
|
||||||
import styles from './MessageContent.module.css';
|
import styles from './MessageContent.module.css';
|
||||||
|
|
||||||
@@ -26,21 +26,252 @@ const CUSTOM_EMOJI_REGEX = /:([a-z0-9_]+):/gi;
|
|||||||
|
|
||||||
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
|
const URL_REGEX = /https?:\/\/[^\s<>"']+/gi;
|
||||||
|
|
||||||
// Escape user-supplied strings for safe inclusion in a regex.
|
const CODE_FENCE_REGEX = /```(?:([a-zA-Z0-9_+\-]+)\n)?([\s\S]*?)```/g;
|
||||||
function escapeRegex(s: string): string {
|
|
||||||
return s.replace(/[.*+?^${}()|[\]\\]/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 {
|
interface Token {
|
||||||
type: 'emoji' | 'mention' | 'customEmoji' | 'url';
|
type: 'emoji' | 'mention' | 'customEmoji' | 'url';
|
||||||
index: number;
|
index: number;
|
||||||
length: number;
|
length: number;
|
||||||
text: string;
|
text: string;
|
||||||
// For customEmoji / url: the resolved URL.
|
|
||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the earliest token (emoji or mention) in `text` starting at `from`.
|
|
||||||
function findNextToken(
|
function findNextToken(
|
||||||
text: string,
|
text: string,
|
||||||
from: number,
|
from: number,
|
||||||
@@ -49,14 +280,10 @@ function findNextToken(
|
|||||||
): Token | null {
|
): Token | null {
|
||||||
let best: Token | null = 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;
|
URL_REGEX.lastIndex = from;
|
||||||
const urlMatch = URL_REGEX.exec(text);
|
const urlMatch = URL_REGEX.exec(text);
|
||||||
if (urlMatch) {
|
if (urlMatch) {
|
||||||
let matchText = urlMatch[0];
|
let matchText = urlMatch[0].replace(/[),.;!?]+$/, '');
|
||||||
const trimmed = matchText.replace(/[),.;!?]+$/, '');
|
|
||||||
matchText = trimmed;
|
|
||||||
best = {
|
best = {
|
||||||
type: 'url',
|
type: 'url',
|
||||||
index: urlMatch.index,
|
index: urlMatch.index,
|
||||||
@@ -66,7 +293,6 @@ function findNextToken(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emoji — next match at or after `from`.
|
|
||||||
EMOJI_REGEX.lastIndex = from;
|
EMOJI_REGEX.lastIndex = from;
|
||||||
const emojiMatch = EMOJI_REGEX.exec(text);
|
const emojiMatch = EMOJI_REGEX.exec(text);
|
||||||
if (emojiMatch && (!best || emojiMatch.index < best.index)) {
|
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) {
|
if (customEmojiMap.size > 0) {
|
||||||
CUSTOM_EMOJI_REGEX.lastIndex = from;
|
CUSTOM_EMOJI_REGEX.lastIndex = from;
|
||||||
let m: RegExpExecArray | null;
|
let m: RegExpExecArray | null;
|
||||||
@@ -98,18 +322,19 @@ function findNextToken(
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
// Unknown shortcode — keep searching past this match.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// @everyone
|
|
||||||
const everyoneIdx = text.indexOf('@everyone', from);
|
const everyoneIdx = text.indexOf('@everyone', from);
|
||||||
if (everyoneIdx !== -1 && (!best || everyoneIdx < best.index)) {
|
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(
|
const sorted = [...members].sort(
|
||||||
(a, b) => (b.displayName?.length ?? 0) - (a.displayName?.length ?? 0),
|
(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;
|
const genericRe = /@[\w]+/g;
|
||||||
genericRe.lastIndex = from;
|
genericRe.lastIndex = from;
|
||||||
const gm = genericRe.exec(text);
|
const gm = genericRe.exec(text);
|
||||||
@@ -134,17 +358,12 @@ function findNextToken(
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(
|
function renderInlineText(text: string, ctx: RenderCtx, keyPrefix: string): ReactNode[] {
|
||||||
text: string,
|
|
||||||
members: MentionMember[],
|
|
||||||
customEmojiMap: Map<string, string>,
|
|
||||||
keyPrefix: string,
|
|
||||||
): ReactNode[] {
|
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
let cursor = 0;
|
let cursor = 0;
|
||||||
let safety = 0;
|
let safety = 0;
|
||||||
while (cursor < text.length && safety++ < 10000) {
|
while (cursor < text.length && safety++ < 10000) {
|
||||||
const tok = findNextToken(text, cursor, members, customEmojiMap);
|
const tok = findNextToken(text, cursor, ctx.members, ctx.customEmojiMap);
|
||||||
if (!tok) {
|
if (!tok) {
|
||||||
parts.push(<span key={`${keyPrefix}t${cursor}`}>{text.slice(cursor)}</span>);
|
parts.push(<span key={`${keyPrefix}t${cursor}`}>{text.slice(cursor)}</span>);
|
||||||
break;
|
break;
|
||||||
@@ -197,11 +416,11 @@ function renderContent(
|
|||||||
if (parts.length === 0) {
|
if (parts.length === 0) {
|
||||||
parts.push(<span key={`${keyPrefix}t0`}>{text}</span>);
|
parts.push(<span key={`${keyPrefix}t0`}>{text}</span>);
|
||||||
}
|
}
|
||||||
// Silence unused escapeRegex in case linter complains.
|
|
||||||
void escapeRegex;
|
|
||||||
return parts;
|
return parts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Public component ---------------------------------------------------
|
||||||
|
|
||||||
export function MessageContent({
|
export function MessageContent({
|
||||||
content,
|
content,
|
||||||
members = [],
|
members = [],
|
||||||
@@ -209,5 +428,7 @@ export function MessageContent({
|
|||||||
}: MessageContentProps) {
|
}: MessageContentProps) {
|
||||||
const map = new Map<string, string>();
|
const map = new Map<string, string>();
|
||||||
for (const e of customEmojis) map.set(e.name.toLowerCase(), e.url);
|
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 { EmojiPicker, type EmojiPickerValue } from './EmojiPicker';
|
||||||
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
|
import { EncryptedAttachment, type AttachmentMetadata } from './EncryptedAttachment';
|
||||||
import { ImageLightbox } from './ImageLightbox';
|
import { ImageLightbox } from './ImageLightbox';
|
||||||
|
import { InlineMessageEditor } from './InlineMessageEditor';
|
||||||
import { LinkEmbed } from './LinkEmbed';
|
import { LinkEmbed } from './LinkEmbed';
|
||||||
import type { DecryptedMessage } from './Messages';
|
import type { DecryptedMessage } from './Messages';
|
||||||
import { MessageActionBar } from './MessageActionBar';
|
import { MessageActionBar } from './MessageActionBar';
|
||||||
@@ -32,6 +33,10 @@ interface MessageGroupProps {
|
|||||||
messages: DecryptedMessage[];
|
messages: DecryptedMessage[];
|
||||||
channelId: string;
|
channelId: string;
|
||||||
onReply?: (eventId: string, username: string) => void;
|
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 first = messages[0];
|
||||||
const myUserId =
|
const myUserId =
|
||||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null;
|
||||||
@@ -266,6 +271,32 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
|||||||
id: string;
|
id: string;
|
||||||
preview: PinnedMessage;
|
preview: PinnedMessage;
|
||||||
} | null>(null);
|
} | 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 handleDelete = (messageId: string) => {
|
||||||
const msg = messages.find((m) => m.id === messageId);
|
const msg = messages.find((m) => m.id === messageId);
|
||||||
if (!msg) return;
|
if (!msg) return;
|
||||||
@@ -331,6 +362,7 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
|||||||
setLocalMenuOpenFor(open ? msg.id : null)
|
setLocalMenuOpenFor(open ? msg.id : null)
|
||||||
}
|
}
|
||||||
onReply={() => onReply?.(msg.id, first.authorName)}
|
onReply={() => onReply?.(msg.id, first.authorName)}
|
||||||
|
onEdit={onEditMessage ? () => handleStartEdit(msg.id) : undefined}
|
||||||
onDelete={() => handleDelete(msg.id)}
|
onDelete={() => handleDelete(msg.id)}
|
||||||
onReact={(e) => openReactPicker(msg.id, e?.currentTarget ?? null)}
|
onReact={(e) => openReactPicker(msg.id, e?.currentTarget ?? null)}
|
||||||
onQuickReact={async (emoji) => {
|
onQuickReact={async (emoji) => {
|
||||||
@@ -473,17 +505,27 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
|||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{msg.content && !isGifOnlyContent(msg.content) && (
|
{editingMessageId === msg.id ? (
|
||||||
<div className={styles.text}>
|
<div className={styles.text}>
|
||||||
<MessageContent
|
<InlineMessageEditor
|
||||||
content={msg.content}
|
initialContent={msg.content}
|
||||||
members={mentionMembers}
|
onSave={(newText) => handleSaveEdit(msg.id, newText)}
|
||||||
customEmojis={customEmojiList}
|
onCancel={handleCancelEdit}
|
||||||
/>
|
/>
|
||||||
{msg.editedTimestamp && (
|
|
||||||
<span className={styles.editedTag}> (edited)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
msg.content && !isGifOnlyContent(msg.content) && (
|
||||||
|
<div className={styles.text}>
|
||||||
|
<MessageContent
|
||||||
|
content={msg.content}
|
||||||
|
members={mentionMembers}
|
||||||
|
customEmojis={customEmojiList}
|
||||||
|
/>
|
||||||
|
{msg.editedTimestamp && (
|
||||||
|
<span className={styles.editedTag}> (edited)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
{msg.content &&
|
{msg.content &&
|
||||||
extractUrls(msg.content)
|
extractUrls(msg.content)
|
||||||
@@ -802,9 +844,11 @@ export function MessageGroup({ messages, channelId, onReply }: MessageGroupProps
|
|||||||
/* forward not implemented yet */
|
/* forward not implemented yet */
|
||||||
}}
|
}}
|
||||||
onEdit={
|
onEdit={
|
||||||
mobileSheetForMsg.senderId === myUserId
|
mobileSheetForMsg.senderId === myUserId && onEditMessage
|
||||||
? () => {
|
? () => {
|
||||||
/* edit not implemented via sheet yet */
|
const id = mobileSheetForMsg.id;
|
||||||
|
setMobileSheetForMsg(null);
|
||||||
|
handleStartEdit(id);
|
||||||
}
|
}
|
||||||
: undefined
|
: 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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { api } from '../../../../../convex/_generated/api';
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
import { usePlatform } from '../../platform';
|
import { usePlatform } from '../../platform';
|
||||||
@@ -57,12 +57,26 @@ const TAG_LENGTH = 32;
|
|||||||
const decryptionCache = new Map<string, string>();
|
const decryptionCache = new Map<string, string>();
|
||||||
const MAX_CACHE = 2000;
|
const MAX_CACHE = 2000;
|
||||||
|
|
||||||
function namespacedKey(userId: string | null, id: string): string {
|
// Cache keys are `user:id[:source]`. Passing `source` (the current
|
||||||
return `${userId ?? 'anon'}:${id}`;
|
// 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) {
|
function cacheSet(
|
||||||
const key = namespacedKey(userId, id);
|
userId: string | null,
|
||||||
|
id: string,
|
||||||
|
content: string,
|
||||||
|
source?: string,
|
||||||
|
) {
|
||||||
|
const key = namespacedKey(userId, id, source);
|
||||||
if (decryptionCache.size >= MAX_CACHE) {
|
if (decryptionCache.size >= MAX_CACHE) {
|
||||||
const firstKey = decryptionCache.keys().next().value;
|
const firstKey = decryptionCache.keys().next().value;
|
||||||
if (firstKey !== undefined) decryptionCache.delete(firstKey);
|
if (firstKey !== undefined) decryptionCache.delete(firstKey);
|
||||||
@@ -70,8 +84,12 @@ function cacheSet(userId: string | null, id: string, content: string) {
|
|||||||
decryptionCache.set(key, content);
|
decryptionCache.set(key, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cacheGet(userId: string | null, id: string): string | undefined {
|
function cacheGet(
|
||||||
return decryptionCache.get(namespacedKey(userId, id));
|
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,
|
// 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());
|
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
|
// Reply previews — separate map keyed by the *child* message id so
|
||||||
// the per-row render can grab the parent's plaintext without
|
// the per-row render can grab the parent's plaintext without
|
||||||
// re-decrypting on every paint. Filled by the effect below.
|
// 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;
|
if (!pagedMessages || pagedMessages.length === 0) return;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
let next: Map<string, string> | null = null;
|
let next: Map<string, string> | null = null;
|
||||||
|
const sourceRef = plaintextSourceRef.current;
|
||||||
for (const msg of pagedMessages as any[]) {
|
for (const msg of pagedMessages as any[]) {
|
||||||
const id = msg.id as string;
|
const id = msg.id as string;
|
||||||
if (decryptedMap.has(id)) continue;
|
const source = msg.ciphertext as string;
|
||||||
const cached = cacheGet(userId, id);
|
// 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 (cached !== undefined) {
|
||||||
if (!next) next = new Map(decryptedMap);
|
if (!next) next = new Map(decryptedMap);
|
||||||
next.set(id, cached);
|
next.set(id, cached);
|
||||||
|
sourceRef.set(id, source);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -450,12 +483,20 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
nonce: string;
|
nonce: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
key: string;
|
key: string;
|
||||||
|
ciphertext: string;
|
||||||
};
|
};
|
||||||
const jobs: Job[] = [];
|
const jobs: Job[] = [];
|
||||||
|
const sourceRef = plaintextSourceRef.current;
|
||||||
for (const msg of pagedMessages as any[]) {
|
for (const msg of pagedMessages as any[]) {
|
||||||
const id = msg.id as string;
|
const id = msg.id as string;
|
||||||
if (decryptedMap.has(id)) continue;
|
const source = msg.ciphertext as string;
|
||||||
if (cacheGet(userId, id) !== undefined) continue;
|
// 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) {
|
if (!msg.ciphertext || msg.ciphertext.length < TAG_LENGTH) {
|
||||||
jobs.push({ kind: 'sentinel', id, value: '[Invalid Encrypted Message]' });
|
jobs.push({ kind: 'sentinel', id, value: '[Invalid Encrypted Message]' });
|
||||||
continue;
|
continue;
|
||||||
@@ -474,6 +515,7 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
tag: msg.ciphertext.slice(-TAG_LENGTH),
|
tag: msg.ciphertext.slice(-TAG_LENGTH),
|
||||||
nonce: msg.nonce,
|
nonce: msg.nonce,
|
||||||
key: keyForVersion,
|
key: keyForVersion,
|
||||||
|
ciphertext: msg.ciphertext,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (jobs.length === 0) return;
|
if (jobs.length === 0) return;
|
||||||
@@ -484,7 +526,7 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
jobs.map(async (j) => {
|
jobs.map(async (j) => {
|
||||||
if (j.kind === 'sentinel') {
|
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 {
|
try {
|
||||||
const plaintext = await crypto.decryptData(
|
const plaintext = await crypto.decryptData(
|
||||||
@@ -493,19 +535,22 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
j.nonce,
|
j.nonce,
|
||||||
j.tag,
|
j.tag,
|
||||||
);
|
);
|
||||||
return { id: j.id, value: plaintext, cache: true };
|
return { id: j.id, value: plaintext, cache: true, source: j.ciphertext };
|
||||||
} catch {
|
} 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;
|
if (cancelled) return;
|
||||||
const next = new Map(decryptedMap);
|
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
if (r.cache) cacheSet(userId, r.id, r.value);
|
if (r.cache && r.source) cacheSet(userId, r.id, r.value, r.source);
|
||||||
next.set(r.id, r.value);
|
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 () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -708,6 +753,55 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
});
|
});
|
||||||
}, [pagedMessages, decryptedMap, replyPreviewMap, channelId]);
|
}, [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
|
// Pinned-to-bottom tracking. Matches the new UI's approach: a single
|
||||||
// boolean in a ref, updated on every onScroll event via isNearBottom.
|
// boolean in a ref, updated on every onScroll event via isNearBottom.
|
||||||
// MutationObserver + ResizeObserver below drive all auto-scroll
|
// MutationObserver + ResizeObserver below drive all auto-scroll
|
||||||
@@ -1172,6 +1266,7 @@ export function Messages({ channelId, onReply }: MessagesProps) {
|
|||||||
messages={item.group as any}
|
messages={item.group as any}
|
||||||
channelId={channelId}
|
channelId={channelId}
|
||||||
onReply={onReply}
|
onReply={onReply}
|
||||||
|
onEditMessage={handleEditMessage}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
} else {
|
} 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 { MobileCreateCategoryPage } from './MobileCreateCategoryPage';
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
import { UpdateBanner } from './UpdateBanner';
|
import { UpdateBanner } from './UpdateBanner';
|
||||||
|
import { NotificationManager } from './NotificationManager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppLayout — checks session via sessionStorage (matches App.tsx AuthGuard),
|
* AppLayout — checks session via sessionStorage (matches App.tsx AuthGuard),
|
||||||
@@ -273,6 +274,7 @@ export function AppLayout() {
|
|||||||
)}
|
)}
|
||||||
<RecordingRecoveryModal />
|
<RecordingRecoveryModal />
|
||||||
<UpdateBanner />
|
<UpdateBanner />
|
||||||
|
<NotificationManager myUserId={myUserId} />
|
||||||
</KeybindProvider>
|
</KeybindProvider>
|
||||||
</PresenceProvider>
|
</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 { useEffect } from 'react';
|
||||||
import { Minus, Square, X } from '@phosphor-icons/react';
|
import { Minus, Square, X } from '@phosphor-icons/react';
|
||||||
import { usePlatform } from '../../platform';
|
import { usePlatform } from '../../platform';
|
||||||
|
import { HeaderUpdateIcon } from './HeaderUpdateIcon';
|
||||||
import styles from './TitleBar.module.css';
|
import styles from './TitleBar.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +31,7 @@ export function TitleBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.bar}>
|
<div className={styles.bar}>
|
||||||
|
<HeaderUpdateIcon />
|
||||||
<div className={styles.buttons}>
|
<div className={styles.buttons}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ export function UserAreaProfilePopout({
|
|||||||
// row. Reactive via `useQuery` so the popout updates instantly
|
// row. Reactive via `useQuery` so the popout updates instantly
|
||||||
// when the value changes in Settings → My Account.
|
// when the value changes in Settings → My Account.
|
||||||
const accentColor = (me as any)?.accentColor || DEFAULT_ACCENT_COLOR;
|
const accentColor = (me as any)?.accentColor || DEFAULT_ACCENT_COLOR;
|
||||||
|
const bannerUrl: string | null = (me as any)?.bannerUrl ?? null;
|
||||||
const presence = me.status || 'online';
|
const presence = me.status || 'online';
|
||||||
const bio = me.aboutMe?.trim();
|
const bio = me.aboutMe?.trim();
|
||||||
const displayName = me.displayName || me.username || 'User';
|
const displayName = me.displayName || me.username || 'User';
|
||||||
@@ -233,7 +234,15 @@ export function UserAreaProfilePopout({
|
|||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<div
|
<div
|
||||||
className={styles.banner}
|
className={styles.banner}
|
||||||
style={{ backgroundColor: accentColor }}
|
style={
|
||||||
|
bannerUrl
|
||||||
|
? {
|
||||||
|
backgroundImage: `url("${bannerUrl}")`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}
|
||||||
|
: { backgroundColor: accentColor }
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<div className={styles.avatarWrap}>
|
<div className={styles.avatarWrap}>
|
||||||
<Avatar
|
<Avatar
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ export function MemberProfileModal({
|
|||||||
const avatarUrl = profile?.avatarUrl ?? null;
|
const avatarUrl = profile?.avatarUrl ?? null;
|
||||||
const bio = profile?.aboutMe?.trim();
|
const bio = profile?.aboutMe?.trim();
|
||||||
const accent = (profile as any)?.accentColor || DEFAULT_ACCENT;
|
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 storedStatus = (profile?.status as string | undefined) || 'offline';
|
||||||
const livePresence = resolveStatus(storedStatus, member.userId);
|
const livePresence = resolveStatus(storedStatus, member.userId);
|
||||||
const statusDisplay = getStatusDisplay(livePresence);
|
const statusDisplay = getStatusDisplay(livePresence);
|
||||||
@@ -225,7 +226,15 @@ export function MemberProfileModal({
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.banner}
|
className={styles.banner}
|
||||||
style={{ backgroundColor: accent }}
|
style={
|
||||||
|
bannerUrl
|
||||||
|
? {
|
||||||
|
backgroundImage: `url("${bannerUrl}")`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}
|
||||||
|
: { backgroundColor: accent }
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.headerRow}>
|
<div className={styles.headerRow}>
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ export function MemberProfilePopout({
|
|||||||
// My Account. Falls back to the brand default when the user
|
// My Account. Falls back to the brand default when the user
|
||||||
// hasn't picked one yet.
|
// hasn't picked one yet.
|
||||||
const accent = (fullUser as any)?.accentColor || DEFAULT_ACCENT;
|
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
|
// Position: anchor to the LEFT of the clicked row. The member
|
||||||
// list sits on the right edge so the popout flows inward. Clamp
|
// list sits on the right edge so the popout flows inward. Clamp
|
||||||
@@ -153,10 +154,20 @@ export function MemberProfilePopout({
|
|||||||
style={positionStyle}
|
style={positionStyle}
|
||||||
onClick={(e) => e.stopPropagation()}
|
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
|
<div
|
||||||
className={styles.banner}
|
className={styles.banner}
|
||||||
style={{ backgroundColor: accent }}
|
style={
|
||||||
|
bannerUrl
|
||||||
|
? {
|
||||||
|
backgroundImage: `url("${bannerUrl}")`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}
|
||||||
|
: { backgroundColor: accent }
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.avatarWrap}>
|
<div className={styles.avatarWrap}>
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export function MobileMemberProfileSheet({
|
|||||||
const avatarUrl = profile?.avatarUrl ?? null;
|
const avatarUrl = profile?.avatarUrl ?? null;
|
||||||
const bio = profile?.aboutMe?.trim();
|
const bio = profile?.aboutMe?.trim();
|
||||||
const accent = (profile as any)?.accentColor || DEFAULT_ACCENT;
|
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 storedStatus = (profile?.status as string | undefined) || 'offline';
|
||||||
const presence = mapPresence(resolveStatus(storedStatus, member.userId));
|
const presence = mapPresence(resolveStatus(storedStatus, member.userId));
|
||||||
|
|
||||||
@@ -172,7 +173,15 @@ export function MobileMemberProfileSheet({
|
|||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div
|
<div
|
||||||
className={styles.banner}
|
className={styles.banner}
|
||||||
style={{ backgroundColor: accent }}
|
style={
|
||||||
|
bannerUrl
|
||||||
|
? {
|
||||||
|
backgroundImage: `url("${bannerUrl}")`,
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
}
|
||||||
|
: { backgroundColor: accent }
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className={styles.bannerHandle} />
|
<div className={styles.bannerHandle} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,13 +16,17 @@ import { useQuery } from 'convex/react';
|
|||||||
import {
|
import {
|
||||||
CaretLeft,
|
CaretLeft,
|
||||||
CaretRight,
|
CaretRight,
|
||||||
|
ClockCounterClockwise,
|
||||||
Gear,
|
Gear,
|
||||||
|
Prohibit,
|
||||||
ShieldStar,
|
ShieldStar,
|
||||||
Smiley,
|
Smiley,
|
||||||
X,
|
X,
|
||||||
} from '@phosphor-icons/react';
|
} from '@phosphor-icons/react';
|
||||||
import { api } from '../../../../../convex/_generated/api';
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
import {
|
import {
|
||||||
|
AuditLogTab,
|
||||||
|
BansTab,
|
||||||
EmojisTab,
|
EmojisTab,
|
||||||
OverviewTab,
|
OverviewTab,
|
||||||
type ServerSettingsTab,
|
type ServerSettingsTab,
|
||||||
@@ -48,6 +52,8 @@ const TABS: Array<{
|
|||||||
{ id: 'overview', label: 'Overview', icon: Gear },
|
{ id: 'overview', label: 'Overview', icon: Gear },
|
||||||
{ id: 'roles', label: 'Roles & Permissions', icon: ShieldStar },
|
{ id: 'roles', label: 'Roles & Permissions', icon: ShieldStar },
|
||||||
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
|
{ 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 {
|
function getInitials(name: string): string {
|
||||||
@@ -227,6 +233,8 @@ export function MobileServerSettings({
|
|||||||
<div className={`${styles.body} ${styles.bodyPanel}`}>
|
<div className={`${styles.body} ${styles.bodyPanel}`}>
|
||||||
{activeTab === 'overview' && <OverviewTab />}
|
{activeTab === 'overview' && <OverviewTab />}
|
||||||
{activeTab === 'emojis' && <EmojisTab />}
|
{activeTab === 'emojis' && <EmojisTab />}
|
||||||
|
{activeTab === 'bans' && <BansTab />}
|
||||||
|
{activeTab === 'audit' && <AuditLogTab />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
|
|||||||
@@ -9,7 +9,17 @@
|
|||||||
* still hold the old Matrix-based code and are not imported.
|
* still hold the old Matrix-based code and are not imported.
|
||||||
*/
|
*/
|
||||||
import { useMutation, useQuery } from 'convex/react';
|
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 { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { api } from '../../../../../convex/_generated/api';
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
@@ -20,7 +30,7 @@ import { MobileServerSettings } from './MobileServerSettings';
|
|||||||
import { useRolesView } from './RolesView';
|
import { useRolesView } from './RolesView';
|
||||||
import userStyles from './UserSettingsModal.module.css';
|
import userStyles from './UserSettingsModal.module.css';
|
||||||
|
|
||||||
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis';
|
export type ServerSettingsTab = 'overview' | 'roles' | 'emojis' | 'bans' | 'audit';
|
||||||
|
|
||||||
interface ServerSettingsModalProps {
|
interface ServerSettingsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -32,6 +42,8 @@ const TABS: Array<{ id: ServerSettingsTab; label: string; icon: typeof Gear }> =
|
|||||||
{ id: 'overview', label: 'Overview', icon: Gear },
|
{ id: 'overview', label: 'Overview', icon: Gear },
|
||||||
{ id: 'roles', label: 'Roles', icon: ShieldStar },
|
{ id: 'roles', label: 'Roles', icon: ShieldStar },
|
||||||
{ id: 'emojis', label: 'Custom Emoji', icon: Smiley },
|
{ 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) {
|
export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSettingsModalProps) {
|
||||||
@@ -150,6 +162,8 @@ export function ServerSettingsModal({ isOpen, onClose, initialTab }: ServerSetti
|
|||||||
{activeTab === 'overview' && <OverviewTab />}
|
{activeTab === 'overview' && <OverviewTab />}
|
||||||
{inRolesView && rolesView.content}
|
{inRolesView && rolesView.content}
|
||||||
{activeTab === 'emojis' && <CustomEmojisTab />}
|
{activeTab === 'emojis' && <CustomEmojisTab />}
|
||||||
|
{activeTab === 'bans' && <BansTab />}
|
||||||
|
{activeTab === 'audit' && <AuditLogTab />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,6 +356,7 @@ const PERMISSION_KEYS = [
|
|||||||
'move_members',
|
'move_members',
|
||||||
'mute_members',
|
'mute_members',
|
||||||
'manage_nicknames',
|
'manage_nicknames',
|
||||||
|
'ban_members',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
type PermissionKey = (typeof PERMISSION_KEYS)[number];
|
||||||
@@ -892,3 +907,362 @@ const dangerBtnStyle: React.CSSProperties = {
|
|||||||
fontSize: 14,
|
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 { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { api } from '../../../../../convex/_generated/api';
|
import { api } from '../../../../../convex/_generated/api';
|
||||||
|
import { useKeybinds } from '../../contexts/KeybindContext';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import { useIsMobile } from '../../hooks/useIsMobile';
|
import { useIsMobile } from '../../hooks/useIsMobile';
|
||||||
import { useLogout } from '../../hooks/useLogout';
|
import { useLogout } from '../../hooks/useLogout';
|
||||||
@@ -218,6 +219,14 @@ export function AccountTab() {
|
|||||||
const [joinSoundError, setJoinSoundError] = useState<string | null>(null);
|
const [joinSoundError, setJoinSoundError] = useState<string | null>(null);
|
||||||
const [joinSoundFilename, setJoinSoundFilename] = 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(() => {
|
useEffect(() => {
|
||||||
if (me) {
|
if (me) {
|
||||||
setDisplayName(me.displayName ?? '');
|
setDisplayName(me.displayName ?? '');
|
||||||
@@ -230,8 +239,9 @@ export function AccountTab() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||||
|
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||||
};
|
};
|
||||||
}, [avatarPreview]);
|
}, [avatarPreview, bannerPreview]);
|
||||||
|
|
||||||
const pickAvatar = () => fileInputRef.current?.click();
|
const pickAvatar = () => fileInputRef.current?.click();
|
||||||
|
|
||||||
@@ -278,6 +288,33 @@ export function AccountTab() {
|
|||||||
return storageId;
|
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 pickJoinSound = () => joinSoundInputRef.current?.click();
|
||||||
|
|
||||||
const handleJoinSoundFile = async (
|
const handleJoinSoundFile = async (
|
||||||
@@ -367,8 +404,20 @@ export function AccountTab() {
|
|||||||
const storageId = await uploadAvatar(pendingAvatarBlob);
|
const storageId = await uploadAvatar(pendingAvatarBlob);
|
||||||
patch.avatarStorageId = storageId;
|
patch.avatarStorageId = storageId;
|
||||||
}
|
}
|
||||||
|
if (pendingBannerBlob) {
|
||||||
|
const storageId = await uploadAvatar(pendingBannerBlob);
|
||||||
|
patch.bannerStorageId = storageId;
|
||||||
|
} else if (removeBannerPending) {
|
||||||
|
patch.removeBanner = true;
|
||||||
|
}
|
||||||
await updateProfile(patch as any);
|
await updateProfile(patch as any);
|
||||||
setPendingAvatarBlob(null);
|
setPendingAvatarBlob(null);
|
||||||
|
setPendingBannerBlob(null);
|
||||||
|
setRemoveBannerPending(false);
|
||||||
|
if (bannerPreview) {
|
||||||
|
URL.revokeObjectURL(bannerPreview);
|
||||||
|
setBannerPreview(null);
|
||||||
|
}
|
||||||
setStatus('Saved');
|
setStatus('Saved');
|
||||||
setTimeout(() => setStatus(null), 1500);
|
setTimeout(() => setStatus(null), 1500);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -379,6 +428,13 @@ export function AccountTab() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const currentAvatar = avatarPreview ?? me?.avatarUrl ?? null;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -405,7 +461,12 @@ export function AccountTab() {
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
height: 72,
|
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 }}>
|
<div style={{ position: 'absolute', top: 36, left: 18 }}>
|
||||||
@@ -477,6 +538,13 @@ export function AccountTab() {
|
|||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
onChange={handleFileChange}
|
onChange={handleFileChange}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
ref={bannerInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleBannerFile}
|
||||||
|
/>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
<FieldRow label="Username" value={me?.username ?? '—'} readOnly />
|
<FieldRow label="Username" value={me?.username ?? '—'} readOnly />
|
||||||
@@ -546,6 +614,82 @@ export function AccountTab() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -891,6 +1035,13 @@ interface VoiceSettings {
|
|||||||
echoCancellation: boolean;
|
echoCancellation: boolean;
|
||||||
autoGainControl: boolean;
|
autoGainControl: boolean;
|
||||||
recordingDir?: string;
|
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 = {
|
const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
|
||||||
@@ -901,6 +1052,8 @@ const DEFAULT_VOICE_SETTINGS: VoiceSettings = {
|
|||||||
noiseSuppression: true,
|
noiseSuppression: true,
|
||||||
echoCancellation: false,
|
echoCancellation: false,
|
||||||
autoGainControl: true,
|
autoGainControl: true,
|
||||||
|
inputMode: 'voice-activity',
|
||||||
|
pushToTalkReleaseDelayMs: 200,
|
||||||
};
|
};
|
||||||
|
|
||||||
function loadVoiceSettings(): VoiceSettings {
|
function loadVoiceSettings(): VoiceSettings {
|
||||||
@@ -1112,6 +1265,8 @@ export function VoiceTab() {
|
|||||||
<MicTest settings={settings} />
|
<MicTest settings={settings} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<VoiceInputModeSection settings={settings} update={update} />
|
||||||
|
|
||||||
<div className={styles.voiceSection}>
|
<div className={styles.voiceSection}>
|
||||||
<h4 className={styles.voiceSectionTitle}>Audio Processing</h4>
|
<h4 className={styles.voiceSectionTitle}>Audio Processing</h4>
|
||||||
<p className={styles.settingDescription}>
|
<p className={styles.settingDescription}>
|
||||||
@@ -1533,6 +1688,89 @@ export function SecurityTab() {
|
|||||||
* the test is live tears down and re-creates the graph with the
|
* the test is live tears down and re-creates the graph with the
|
||||||
* new constraints.
|
* 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 }) {
|
function MicTest({ settings }: { settings: VoiceSettings }) {
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
const [level, setLevel] = useState(0);
|
const [level, setLevel] = useState(0);
|
||||||
|
|||||||
@@ -29,6 +29,13 @@ export interface KeybindAction {
|
|||||||
description: string;
|
description: string;
|
||||||
category: KeybindCategory;
|
category: KeybindCategory;
|
||||||
defaultCombo: string;
|
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[] = [
|
const DEFAULT_ACTIONS: KeybindAction[] = [
|
||||||
@@ -53,6 +60,15 @@ const DEFAULT_ACTIONS: KeybindAction[] = [
|
|||||||
category: 'voice',
|
category: 'voice',
|
||||||
defaultCombo: '',
|
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',
|
id: 'navigation.goToDMs',
|
||||||
label: 'Go to Direct Messages',
|
label: 'Go to Direct Messages',
|
||||||
@@ -242,43 +258,109 @@ export function KeybindProvider({ children }: { children: ReactNode }) {
|
|||||||
// wins against components that use keydown for their own shortcuts
|
// wins against components that use keydown for their own shortcuts
|
||||||
// — rebinding in settings disables the default behaviour cleanly.
|
// — rebinding in settings disables the default behaviour cleanly.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 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' ||
|
||||||
|
el.isContentEditable
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
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 (
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const combo = eventToCombo(e);
|
const combo = eventToCombo(e);
|
||||||
if (!combo) return;
|
if (!combo) return;
|
||||||
|
const editable = isInEditableField(e.target);
|
||||||
|
|
||||||
for (const action of DEFAULT_ACTIONS) {
|
for (const action of DEFAULT_ACTIONS) {
|
||||||
if ((combos[action.id] ?? '') === combo) {
|
if ((combos[action.id] ?? '') !== combo) continue;
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
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(
|
window.dispatchEvent(
|
||||||
new CustomEvent(`brycord:keybind:${action.id}`),
|
new CustomEvent(`brycord:keybind:${action.id}:down`),
|
||||||
);
|
);
|
||||||
return;
|
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(
|
||||||
|
new CustomEvent(`brycord:keybind:${action.id}`),
|
||||||
|
);
|
||||||
|
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('keydown', onKeyDown, { capture: true });
|
||||||
|
window.addEventListener('keyup', onKeyUp, { capture: true });
|
||||||
|
window.addEventListener('blur', onBlur);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('keydown', onKeyDown, {
|
window.removeEventListener('keydown', onKeyDown, {
|
||||||
capture: true,
|
capture: true,
|
||||||
} as EventListenerOptions);
|
} as EventListenerOptions);
|
||||||
|
window.removeEventListener('keyup', onKeyUp, {
|
||||||
|
capture: true,
|
||||||
|
} as EventListenerOptions);
|
||||||
|
window.removeEventListener('blur', onBlur);
|
||||||
};
|
};
|
||||||
}, [combos]);
|
}, [combos]);
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,84 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
const [isReconnecting, setIsReconnecting] = useState(false);
|
const [isReconnecting, setIsReconnecting] = useState(false);
|
||||||
const [connectionQualities, setConnectionQualities] = useState({});
|
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.
|
// Voice recording — crash-safe per-participant audio capture.
|
||||||
// Only available on Electron (`platform.features.hasRecording`).
|
// Only available on Electron (`platform.features.hasRecording`).
|
||||||
// `recordingError` is surfaced once per failure so UI can toast
|
// `recordingError` is surfaced once per failure so UI can toast
|
||||||
@@ -759,15 +837,18 @@ export const VoiceProvider = ({ children }) => {
|
|||||||
}
|
}
|
||||||
}, [voiceStates, activeChannelId, room, convex, myUserId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!myUserId || !room) return;
|
if (!myUserId || !room) return;
|
||||||
if (isServerMuted(myUserId)) {
|
const serverMuted = isServerMuted(myUserId);
|
||||||
room.localParticipant.setMicrophoneEnabled(false);
|
const pttBlocks = inputMode === 'push-to-talk' && !isPttActive;
|
||||||
} else if (!isMuted && !isDeafened) {
|
const shouldEnable = !isMuted && !isDeafened && !serverMuted && !pttBlocks;
|
||||||
room.localParticipant.setMicrophoneEnabled(true);
|
room.localParticipant.setMicrophoneEnabled(shouldEnable);
|
||||||
}
|
}, [voiceStates, room, myUserId, isMuted, isDeafened, inputMode, isPttActive]);
|
||||||
}, [voiceStates, room, myUserId]);
|
|
||||||
|
|
||||||
// Re-apply personal mutes/volumes when room or participants change
|
// Re-apply personal mutes/volumes when room or participants change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -893,6 +893,12 @@ img[alt] {
|
|||||||
100% { background-color: transparent; }
|
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 {
|
.searchHighlight {
|
||||||
animation: searchFlash 2s ease-out;
|
animation: searchFlash 2s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,14 @@
|
|||||||
* @property {() => void} close
|
* @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
|
* @typedef {Object} PlatformRecording
|
||||||
* @property {() => Promise<string>} getDefaultFolder - Default recording root (e.g. %APPDATA%/Brycord/recordings)
|
* @property {() => Promise<string>} getDefaultFolder - Default recording root (e.g. %APPDATA%/Brycord/recordings)
|
||||||
@@ -109,6 +117,7 @@
|
|||||||
* @property {boolean} hasVoiceService
|
* @property {boolean} hasVoiceService
|
||||||
* @property {boolean} hasSystemBars
|
* @property {boolean} hasSystemBars
|
||||||
* @property {boolean} [hasBackButton]
|
* @property {boolean} [hasBackButton]
|
||||||
|
* @property {boolean} [hasNotifications]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,6 +129,7 @@
|
|||||||
* @property {PlatformLinks} links
|
* @property {PlatformLinks} links
|
||||||
* @property {PlatformScreenCapture|null} screenCapture
|
* @property {PlatformScreenCapture|null} screenCapture
|
||||||
* @property {PlatformWindowControls|null} windowControls
|
* @property {PlatformWindowControls|null} windowControls
|
||||||
|
* @property {PlatformNotifications|null} notifications
|
||||||
* @property {PlatformRecording|null} recording
|
* @property {PlatformRecording|null} recording
|
||||||
* @property {PlatformUpdates|null} updates
|
* @property {PlatformUpdates|null} updates
|
||||||
* @property {PlatformSearchDB|null} searchDB
|
* @property {PlatformSearchDB|null} searchDB
|
||||||
|
|||||||
Reference in New Issue
Block a user