diff --git a/CLAUDE.md b/CLAUDE.md index 807f12f..724b8d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E - **Backend**: Convex (reactive database + serverless functions) - **Frontend**: React + Vite, shared codebase in `packages/shared/` - **Platforms**: Electron (`apps/electron/`), Web (`apps/web/`), Android via Capacitor (`apps/android/`) -- **Platform Abstraction**: `usePlatform()` hook provides crypto, session, settings, idle, links, screenCapture, windowControls, updates APIs +- **Platform Abstraction**: `usePlatform()` hook provides crypto, session, settings, idle, links, screenCapture, windowControls, notifications, updates APIs - **Auth**: Zero-knowledge custom auth via Convex mutations (getSalt, verifyUser, createUserWithProfile) - **Real-time**: Convex reactive queries (`useQuery` auto-updates all connected clients) - **Voice/Video**: LiveKit (token generation via Convex Node action) @@ -86,7 +86,7 @@ All Vite configs use `envDir: '../../'` to pick up root `.env.local`. | `@discord-clone/platform-web` | `packages/platform-web/src/` | | `@shared` | `packages/shared/src/` | -Convex imports from shared components use relative path `../../../../convex/_generated/api` (4 levels up from shared src subdirs). +Convex imports from shared code use a relative path whose depth depends on the file location: `../../../../convex/_generated/api` from `packages/shared/src//file.tsx` (4 up), `../../../../../convex/_generated/api` from `packages/shared/src///file.tsx` (5 up — applies to `components/layout/`, `components/channel/`, etc.). Count: go up until you're at the repo root, then into `convex/`. ## Platform Abstraction (usePlatform()) @@ -98,6 +98,7 @@ All platform-specific APIs are accessed via the `usePlatform()` hook: - `links` - openExternal, fetchMetadata - `screenCapture` - getScreenSources - `windowControls` - minimize, maximize, close (Electron only, null on web) +- `notifications` - show, setBadge, flashFrame, ensurePermission (Electron: native; Web: Notification API + Badging where available; null on Android for now) - `updates` - checkUpdate (Electron only, null on web) - `features` - hasWindowControls, hasScreenCapture, hasNativeUpdates @@ -123,6 +124,13 @@ All platform-specific APIs are accessed via the `usePlatform()` hook: - `randomBytes(size)` returns hex string on both platforms - Keys exchanged as PEM strings (SPKI public, PKCS8 private) for cross-platform interop - TitleBar/UpdateBanner render conditionally based on `platform.features.*` +- `MessageContent.tsx` parses Discord-style markdown (**bold**, *italic*, __underline__, ~~strike~~, `code`, ```codeblock```, > blockquote, ||spoiler||) on render — raw text is stored; parsing happens after decrypt. Inline emoji/mention/URL/custom-emoji tokenization runs inside each text leaf +- `NotificationManager` (mounted in `AppLayout`) watches `readState.getLatestMessageTimestamps` across all channels. On a new `messageId` when the window is unfocused and `senderId !== self`, it calls `platform.notifications.show` + flash + badge. Own sends and initial snapshot are suppressed. Focus auto-clears flash/badge +- Electron update flow is **check-only on launch** (no auto-install). `updater.cjs` emits status events; `platform.updates.{getStatus,downloadAndInstall,onStatusChanged}` expose it. `HeaderUpdateIcon` (mounted in `TitleBar`) renders a green download icon for optional updates and a full-screen blocker for required ones. Mark a release required by starting its release notes with `[REQUIRED]` +- Moderation: `bans` table blocks login (`auth.verifyUser`) and message send (`messages.sendInternal`). `auditLog` table is append-only; `audit.logAudit(ctx, {...})` is the helper that mutations call (best-effort — never throws). Permission check: `roles.hasPermission(ctx, userId, key)` — treats `isAdmin` and the `Owner` role as superusers so new permission keys like `ban_members` work without a migration. Server Settings → Bans + Audit Log tabs (desktop + mobile) +- Profile banner: `userProfiles.bannerStorageId` (optional), resolved to `bannerUrl` in `auth.getPublicKeys`. `auth.updateProfileInternal` takes `bannerStorageId` + `removeBanner` (the remove path also `ctx.storage.delete`s the blob). All four profile card surfaces (`MemberProfilePopout`, `MemberProfileModal`, `MobileMemberProfileSheet`, `UserAreaProfilePopout`) render the image when present, fall back to accent color when not +- Voice messages: mic button in `ChannelTextarea` records via `MediaRecorder` (picks `audio/webm;codecs=opus` where supported), stages the resulting `File` through the existing attachment pipeline — no new backend. Receivers render it via the standard `AttachmentAudio` player. Filename convention: `voice-message-{timestamp}.{webm|ogg|m4a}`. Voice-recorded messages set `isVoiceMessage: true` + `peaks: number[]` + `durationSec` in the attachment metadata; `EncryptedAttachment` dispatches those to `VoiceMessagePlayer` (pill with play button + waveform) instead of the full audio card +- Push-to-talk: `voiceSettings.inputMode` is `'voice-activity'` (default) or `'push-to-talk'`. Paired with the `voice.pushToTalk` keybind (marked `pressAndHold: true`). `KeybindContext` dispatches `brycord:keybind:voice.pushToTalk:down` / `:up` events — pressAndHold actions never `preventDefault`, so binding PTT to a letter still lets you type. `VoiceContext` reads the settings via the `brycord:voice-settings-changed` window event, listens for the PTT events, and routes them through a configurable release-delay timer before reconciling the LiveKit mic track. All mic-on/mic-off sources (user mute, deafen, server mute, PTT gate) converge on a single `setMicrophoneEnabled` effect ## Environment Variables diff --git a/apps/android/android/app/build.gradle b/apps/android/android/app/build.gradle index 6fb1a9e..2c3fa6c 100644 --- a/apps/android/android/app/build.gradle +++ b/apps/android/android/app/build.gradle @@ -8,7 +8,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode 27 - versionName "1.1.2" + versionName "1.1.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" aaptOptions { // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. diff --git a/apps/electron/main.cjs b/apps/electron/main.cjs index 87603d9..3625c8c 100644 --- a/apps/electron/main.cjs +++ b/apps/electron/main.cjs @@ -1,4 +1,4 @@ -const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor } = require('electron'); +const { app, BrowserWindow, dialog, ipcMain, shell, screen, safeStorage, powerMonitor, Notification, nativeImage } = require('electron'); const path = require('path'); const fs = require('fs'); @@ -6,7 +6,7 @@ const fs = require('fs'); const SESSION_FILE = path.join(app.getPath('userData'), 'secure-session.dat'); const https = require('https'); const http = require('http'); -const { checkForUpdates } = require('./updater.cjs'); +const { checkForUpdates, getStatus: getUpdateStatus, onStatus: onUpdateStatus, downloadAndInstall: downloadAndInstallUpdate } = require('./updater.cjs'); function loadEnvVar(varName) { const envFiles = [ @@ -191,6 +191,16 @@ function createWindow() { mainWindow.maximize(); } + // Auto-clear attention state whenever the user comes back — no + // point in flashing / badging a window the user is already + // looking at. + mainWindow.on('focus', () => { + if (mainWindow.isDestroyed()) return; + mainWindow.flashFrame(false); + mainWindow.setOverlayIcon(null, ''); + try { app.setBadgeCount(0); } catch {} + }); + // Save window state on close mainWindow.on('close', () => { // Flush localStorage/sessionStorage to disk before renderer is destroyed @@ -249,14 +259,25 @@ app.whenReady().then(async () => { createWindow(); } else { const splash = createSplashWindow(); - const noUpdate = await checkForUpdates(splash); - if (noUpdate === false) { + const shouldOpenMain = await checkForUpdates(splash); + if (shouldOpenMain) { if (!splash.isDestroyed()) splash.close(); createWindow(); } - // If update downloaded, quitAndInstall handles restart + // Required-update path: splash stays; updater runs quitAndInstall itself. } + // Forward updater status changes to the renderer so the header + // icon and the required-update blocker can react in real time. + onUpdateStatus((status) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('update:status', status); + } + }); + + ipcMain.handle('update:get-status', () => getUpdateStatus()); + ipcMain.handle('update:download-and-install', () => downloadAndInstallUpdate()); + ipcMain.on('window-minimize', () => { const win = BrowserWindow.getFocusedWindow(); if (win) win.minimize(); @@ -272,8 +293,49 @@ app.whenReady().then(async () => { const win = BrowserWindow.getFocusedWindow(); if (win) win.close(); }); - ipcMain.on('flash-frame', () => { - if (mainWindow && !mainWindow.isDestroyed()) mainWindow.flashFrame(true); + ipcMain.on('flash-frame', (_event, on) => { + if (!mainWindow || mainWindow.isDestroyed()) return; + // `on` optional for backward compat — legacy call sites pass + // no arg and expect a one-shot flash. + mainWindow.flashFrame(on !== false); + }); + + ipcMain.on('notification:show', (_event, opts) => { + if (!Notification.isSupported()) return; + const { title, body, silent } = opts || {}; + const n = new Notification({ + title: String(title ?? 'Brycord'), + body: String(body ?? ''), + silent: !!silent, + icon: path.join(__dirname, 'icon.png'), + }); + n.on('click', () => { + if (mainWindow && !mainWindow.isDestroyed()) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } + }); + n.show(); + }); + + // Windows: overlay icon on the taskbar button (1x1 badge). Clearing + // is `(null, '')`. `count <= 0` clears. Other OSes fall back to + // `app.setBadgeCount` which is a no-op on platforms that don't + // support it. + ipcMain.on('notification:set-badge', (_event, count) => { + if (!mainWindow || mainWindow.isDestroyed()) return; + const n = Math.max(0, Math.floor(Number(count) || 0)); + if (n === 0) { + mainWindow.setOverlayIcon(null, ''); + } else { + const badgePath = path.join(__dirname, 'icon.png'); + try { + const img = nativeImage.createFromPath(badgePath); + mainWindow.setOverlayIcon(img, `${n} unread`); + } catch {} + } + try { app.setBadgeCount(n); } catch {} }); // Helper to fetch metadata (Zero-Knowledge: Client fetches previews) diff --git a/apps/electron/package.json b/apps/electron/package.json index 4eef553..2d76208 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/electron", "private": true, - "version": "1.1.2", + "version": "1.1.3", "description": "Brycord - Electron app", "author": "Moyettes", "type": "module", diff --git a/apps/electron/preload.cjs b/apps/electron/preload.cjs index b1bcbef..134db72 100644 --- a/apps/electron/preload.cjs +++ b/apps/electron/preload.cjs @@ -25,7 +25,13 @@ contextBridge.exposeInMainWorld('windowControls', { minimize: () => ipcRenderer.send('window-minimize'), maximize: () => ipcRenderer.send('window-maximize'), close: () => ipcRenderer.send('window-close'), - flashFrame: () => ipcRenderer.send('flash-frame'), + flashFrame: (on) => ipcRenderer.send('flash-frame', on !== false), +}); + +contextBridge.exposeInMainWorld('notificationAPI', { + show: (opts) => ipcRenderer.send('notification:show', opts), + setBadge: (count) => ipcRenderer.send('notification:set-badge', count), + flashFrame: (on) => ipcRenderer.send('flash-frame', on !== false), }); contextBridge.exposeInMainWorld('appSettings', { @@ -35,6 +41,13 @@ contextBridge.exposeInMainWorld('appSettings', { contextBridge.exposeInMainWorld('updateAPI', { checkFlatpakUpdate: () => ipcRenderer.invoke('check-flatpak-update'), + getStatus: () => ipcRenderer.invoke('update:get-status'), + downloadAndInstall: () => ipcRenderer.invoke('update:download-and-install'), + onStatusChanged: (callback) => { + const handler = (_event, status) => callback(status); + ipcRenderer.on('update:status', handler); + return () => ipcRenderer.removeListener('update:status', handler); + }, }); contextBridge.exposeInMainWorld('sessionPersistence', { diff --git a/apps/electron/src/platform/index.js b/apps/electron/src/platform/index.js index 7186fef..8101cc7 100644 --- a/apps/electron/src/platform/index.js +++ b/apps/electron/src/platform/index.js @@ -52,7 +52,13 @@ const electronPlatform = { minimize: () => window.windowControls.minimize(), maximize: () => window.windowControls.maximize(), close: () => window.windowControls.close(), - flashFrame: () => window.windowControls.flashFrame(), + flashFrame: (on) => window.windowControls.flashFrame(on), + }, + notifications: { + show: (opts) => window.notificationAPI.show(opts), + setBadge: (count) => window.notificationAPI.setBadge(count), + flashFrame: (on) => window.notificationAPI.flashFrame(on), + ensurePermission: async () => 'granted', }, recording: { getDefaultFolder: () => window.recordingAPI.getDefaultFolder(), @@ -70,6 +76,9 @@ const electronPlatform = { }, updates: { checkUpdate: () => window.updateAPI.checkFlatpakUpdate(), + getStatus: () => window.updateAPI.getStatus(), + downloadAndInstall: () => window.updateAPI.downloadAndInstall(), + onStatusChanged: (cb) => window.updateAPI.onStatusChanged(cb), }, systemBars: null, searchDB, @@ -80,6 +89,7 @@ const electronPlatform = { hasSearch: true, hasSystemBars: false, hasRecording: true, + hasNotifications: true, }, }; diff --git a/apps/electron/updater.cjs b/apps/electron/updater.cjs index bd61459..775f2cb 100644 --- a/apps/electron/updater.cjs +++ b/apps/electron/updater.cjs @@ -5,6 +5,52 @@ autoUpdater.logger = log; autoUpdater.autoDownload = false; autoUpdater.autoInstallOnAppQuit = true; +// Remembered across the process so the main window can ask for it +// once the renderer is ready. Cleared on successful download so we +// don't mislead about a pending install. +let lastCheckResult = { + hasUpdate: false, + required: false, + latestVersion: null, + currentVersion: null, + releaseNotes: null, + downloading: false, + downloaded: false, + progress: 0, + error: null, +}; + +const statusListeners = new Set(); + +function emitStatus() { + for (const cb of statusListeners) { + try { cb({ ...lastCheckResult }); } catch (err) { log.warn('update status listener threw', err); } + } +} + +function onStatus(cb) { + statusListeners.add(cb); + return () => statusListeners.delete(cb); +} + +function getStatus() { + return { ...lastCheckResult }; +} + +// A release is "required" when its notes start with the `[REQUIRED]` +// marker. Keeping the signal in release notes means no new feed file +// or schema change — publishers just prefix the message. +function isRequired(info) { + const notes = typeof info?.releaseNotes === 'string' ? info.releaseNotes : ''; + return /^\s*\[REQUIRED\]/i.test(notes); +} + +// Splash-phase check. Resolves once we know whether to open the main +// window (optional or no update) or to force an install (required). +// - No update / error → resolve true (main window should open) +// - Optional update → resolve true (main window should open; header +// icon surfaces the update inside the app) +// - Required update → download + quitAndInstall; never resolves function checkForUpdates(splashWindow) { return new Promise((resolve) => { function sendToSplash(js) { @@ -17,45 +63,133 @@ function checkForUpdates(splashWindow) { sendToSplash('setStatus("Checking for updates...")'); }); - autoUpdater.on('update-available', () => { - sendToSplash('setStatus("Downloading update...")'); - autoUpdater.downloadUpdate(); + autoUpdater.on('update-available', (info) => { + const required = isRequired(info); + lastCheckResult = { + hasUpdate: true, + required, + latestVersion: info?.version ?? null, + currentVersion: autoUpdater.currentVersion?.version ?? null, + releaseNotes: typeof info?.releaseNotes === 'string' ? info.releaseNotes : null, + downloading: required, + downloaded: false, + progress: 0, + error: null, + }; + emitStatus(); + if (required) { + sendToSplash('setStatus("Downloading required update...")'); + autoUpdater.downloadUpdate().catch((err) => { + log.error('downloadUpdate (required) failed:', err); + sendToSplash('setStatus("Update failed — opening anyway")'); + lastCheckResult.error = err?.message || 'download failed'; + emitStatus(); + setTimeout(() => resolve(true), 1500); + }); + } else { + // Optional — fall through to open main window. The header + // icon will surface the offer inside the app. + sendToSplash('setStatus("Update available — continuing")'); + setTimeout(() => resolve(true), 400); + } }); autoUpdater.on('download-progress', (progress) => { - const percent = Math.round(progress.percent); - sendToSplash(`setProgress(${percent})`); - sendToSplash(`setStatus("Downloading update... ${percent}%")`); + const percent = Math.round(progress.percent || 0); + lastCheckResult.progress = percent; + lastCheckResult.downloading = true; + emitStatus(); + if (lastCheckResult.required) { + sendToSplash(`setProgress(${percent})`); + sendToSplash(`setStatus("Downloading required update... ${percent}%")`); + } }); autoUpdater.on('update-downloaded', () => { - sendToSplash('setStatus("Installing update...")'); - sendToSplash('setProgress(100)'); - setTimeout(() => { - autoUpdater.quitAndInstall(); - }, 1500); + lastCheckResult.downloading = false; + lastCheckResult.downloaded = true; + lastCheckResult.progress = 100; + emitStatus(); + if (lastCheckResult.required) { + sendToSplash('setStatus("Installing update...")'); + sendToSplash('setProgress(100)'); + setTimeout(() => { + autoUpdater.quitAndInstall(); + }, 1200); + } }); autoUpdater.on('update-not-available', () => { + lastCheckResult = { + hasUpdate: false, + required: false, + latestVersion: null, + currentVersion: autoUpdater.currentVersion?.version ?? null, + releaseNotes: null, + downloading: false, + downloaded: false, + progress: 0, + error: null, + }; + emitStatus(); sendToSplash('setStatus("Up to date!")'); sendToSplash('hideProgress()'); - setTimeout(() => resolve(false), 1000); + setTimeout(() => resolve(true), 500); }); autoUpdater.on('error', (err) => { log.error('Auto-updater error:', err); + lastCheckResult.error = err?.message || String(err); + lastCheckResult.downloading = false; + emitStatus(); sendToSplash('setStatus("Update check failed")'); sendToSplash('hideProgress()'); - setTimeout(() => resolve(false), 2000); + setTimeout(() => resolve(true), 1500); }); autoUpdater.checkForUpdates().catch((err) => { log.error('checkForUpdates failed:', err); + lastCheckResult.error = err?.message || String(err); + emitStatus(); sendToSplash('setStatus("Update check failed")'); sendToSplash('hideProgress()'); - setTimeout(() => resolve(false), 2000); + setTimeout(() => resolve(true), 1500); }); }); } -module.exports = { checkForUpdates }; +// Start downloading an optional update from the renderer. The +// splash flow handles required updates itself so the renderer only +// ever reaches here for optional ones. +async function downloadAndInstall() { + if (!lastCheckResult.hasUpdate) return { ok: false, error: 'no update' }; + if (lastCheckResult.downloaded) { + autoUpdater.quitAndInstall(); + return { ok: true }; + } + if (lastCheckResult.downloading) return { ok: true }; + try { + lastCheckResult.downloading = true; + lastCheckResult.progress = 0; + lastCheckResult.error = null; + emitStatus(); + await autoUpdater.downloadUpdate(); + // The 'update-downloaded' handler bumps status; install triggers + // the quit-and-install flow the next tick. + autoUpdater.quitAndInstall(); + return { ok: true }; + } catch (err) { + log.error('downloadAndInstall failed:', err); + lastCheckResult.downloading = false; + lastCheckResult.error = err?.message || String(err); + emitStatus(); + return { ok: false, error: lastCheckResult.error }; + } +} + +module.exports = { + checkForUpdates, + getStatus, + onStatus, + downloadAndInstall, +}; diff --git a/apps/web/package.json b/apps/web/package.json index de3e051..4145e7a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/web", "private": true, - "version": "1.1.2", + "version": "1.1.3", "type": "module", "scripts": { "dev": "vite", diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index d123040..ae296d6 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -8,9 +8,11 @@ * @module */ +import type * as audit from "../audit.js"; import type * as auth from "../auth.js"; import type * as authActions from "../authActions.js"; import type * as authGuard from "../authGuard.js"; +import type * as bans from "../bans.js"; import type * as categories from "../categories.js"; import type * as channelKeys from "../channelKeys.js"; import type * as channels from "../channels.js"; @@ -43,9 +45,11 @@ import type { } from "convex/server"; declare const fullApi: ApiFromModules<{ + audit: typeof audit; auth: typeof auth; authActions: typeof authActions; authGuard: typeof authGuard; + bans: typeof bans; categories: typeof categories; channelKeys: typeof channelKeys; channels: typeof channels; diff --git a/convex/audit.ts b/convex/audit.ts new file mode 100644 index 0000000..cec3137 --- /dev/null +++ b/convex/audit.ts @@ -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, + args: { + actorId: Id<"userProfiles">; + action: string; + targetType?: string; + targetId?: string; + targetName?: string; + metadata?: unknown; + }, +): Promise { + 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, + userId: Id<"userProfiles">, +): Promise { + 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 }, + })); + }, +}); diff --git a/convex/auth.ts b/convex/auth.ts index 1233414..b8529e7 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -2,6 +2,7 @@ import { query, mutation, internalQuery, internalMutation } from "./_generated/s import { v } from "convex/values"; import { getPublicStorageUrl } from "./storageUrl"; import { getRolesForUser } from "./roles"; +import { isBanned } from "./bans"; async function sha256Hex(input: string): Promise { const buffer = await crypto.subtle.digest( @@ -62,6 +63,9 @@ export const verifyUser = mutation({ const hashedDAK = await sha256Hex(args.dak); if (hashedDAK === user.hashedAuthKey) { + if (await isBanned(ctx, user._id)) { + return { error: "You've been banned from this server." }; + } return { success: true, userId: user._id, @@ -166,6 +170,7 @@ export const createUserWithProfile = mutation({ create_invite: true, embed_links: true, attach_files: true, + ban_members: true, }, isHoist: true, }); @@ -205,6 +210,7 @@ export const getPublicKeys = query({ customStatus: v.optional(v.string()), joinSoundUrl: v.optional(v.union(v.string(), v.null())), accentColor: v.optional(v.string()), + bannerUrl: v.optional(v.union(v.string(), v.null())), }) ), handler: async (ctx) => { @@ -219,6 +225,10 @@ export const getPublicKeys = query({ if (u.joinSoundStorageId) { joinSoundUrl = await getPublicStorageUrl(ctx, u.joinSoundStorageId); } + let bannerUrl: string | null = null; + if (u.bannerStorageId) { + bannerUrl = await getPublicStorageUrl(ctx, u.bannerStorageId); + } results.push({ id: u._id, username: u.username, @@ -230,6 +240,7 @@ export const getPublicKeys = query({ customStatus: u.customStatus, joinSoundUrl, accentColor: u.accentColor, + bannerUrl, }); } return results; @@ -251,6 +262,8 @@ export const updateProfileInternal = internalMutation({ joinSoundStorageId: v.optional(v.id("_storage")), removeJoinSound: v.optional(v.boolean()), accentColor: v.optional(v.string()), + bannerStorageId: v.optional(v.id("_storage")), + removeBanner: v.optional(v.boolean()), }, returns: v.null(), handler: async (ctx, args) => { @@ -262,6 +275,17 @@ export const updateProfileInternal = internalMutation({ if (args.joinSoundStorageId !== undefined) patch.joinSoundStorageId = args.joinSoundStorageId; if (args.removeJoinSound) patch.joinSoundStorageId = undefined; if (args.accentColor !== undefined) patch.accentColor = args.accentColor; + if (args.bannerStorageId !== undefined) patch.bannerStorageId = args.bannerStorageId; + if (args.removeBanner) { + // Drop the blob from storage too so orphaned banners don't + // accumulate. Matches how the rest of this file treats one-off + // user uploads. + const existing = await ctx.db.get(args.userId); + if (existing?.bannerStorageId) { + try { await ctx.storage.delete(existing.bannerStorageId); } catch {} + } + patch.bannerStorageId = undefined; + } await ctx.db.patch(args.userId, patch); return null; }, diff --git a/convex/authActions.ts b/convex/authActions.ts index 0b22072..5760013 100644 --- a/convex/authActions.ts +++ b/convex/authActions.ts @@ -20,6 +20,8 @@ export const updateProfile = action({ joinSoundStorageId: v.optional(v.id("_storage")), removeJoinSound: v.optional(v.boolean()), accentColor: v.optional(v.string()), + bannerStorageId: v.optional(v.id("_storage")), + removeBanner: v.optional(v.boolean()), authTimestamp: v.number(), authSignature: v.string(), }, @@ -36,6 +38,8 @@ export const updateProfile = action({ joinSoundStorageId: args.joinSoundStorageId, removeJoinSound: args.removeJoinSound, accentColor: args.accentColor, + bannerStorageId: args.bannerStorageId, + removeBanner: args.removeBanner, }); return null; }, diff --git a/convex/bans.ts b/convex/bans.ts new file mode 100644 index 0000000..479dbde --- /dev/null +++ b/convex/bans.ts @@ -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, + userId: Id<"userProfiles">, +): Promise { + 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 }; + }, +}); diff --git a/convex/channels.ts b/convex/channels.ts index da8a2d0..9004f35 100644 --- a/convex/channels.ts +++ b/convex/channels.ts @@ -3,6 +3,7 @@ import { v } from "convex/values"; import { GenericMutationCtx } from "convex/server"; import { DataModel, Id } from "./_generated/dataModel"; import { internal } from "./_generated/api"; +import { AUDIT_ACTIONS, logAudit } from "./audit"; type TableWithChannelIndex = | "channelKeys" @@ -74,6 +75,7 @@ export const create = mutation({ categoryId: v.optional(v.id("categories")), topic: v.optional(v.string()), position: v.optional(v.number()), + actorId: v.optional(v.id("userProfiles")), }, returns: v.object({ id: v.id("channels") }), handler: async (ctx, args) => { @@ -112,6 +114,17 @@ export const create = mutation({ position, }); + if (args.actorId) { + await logAudit(ctx, { + actorId: args.actorId, + action: AUDIT_ACTIONS.CHANNEL_CREATE, + targetType: "channel", + targetId: id, + targetName: args.name, + metadata: { type: args.type || "text" }, + }); + } + return { id }; }, }); @@ -136,6 +149,7 @@ export const rename = mutation({ args: { id: v.id("channels"), name: v.string(), + actorId: v.optional(v.id("userProfiles")), }, returns: v.object({ _id: v.id("channels"), @@ -156,7 +170,20 @@ export const rename = mutation({ throw new Error("Channel not found"); } + const oldName = channel.name; await ctx.db.patch(args.id, { name: args.name }); + + if (args.actorId && oldName !== args.name) { + await logAudit(ctx, { + actorId: args.actorId, + action: AUDIT_ACTIONS.CHANNEL_RENAME, + targetType: "channel", + targetId: args.id, + targetName: args.name, + metadata: { from: oldName, to: args.name }, + }); + } + return { ...channel, name: args.name }; }, }); @@ -205,7 +232,10 @@ export const reorderChannels = mutation({ // Delete channel + cascade messages and keys export const remove = mutation({ - args: { id: v.id("channels") }, + args: { + id: v.id("channels"), + actorId: v.optional(v.id("userProfiles")), + }, returns: v.object({ success: v.boolean() }), handler: async (ctx, args) => { const channel = await ctx.db.get(args.id); @@ -213,6 +243,9 @@ export const remove = mutation({ throw new Error("Channel not found"); } + const deletedName = channel.name; + const deletedType = channel.type; + // Delete reactions for all messages in this channel const messages = await ctx.db .query("messages") @@ -240,6 +273,17 @@ export const remove = mutation({ await ctx.db.delete(args.id); + if (args.actorId) { + await logAudit(ctx, { + actorId: args.actorId, + action: AUDIT_ACTIONS.CHANNEL_DELETE, + targetType: "channel", + targetId: args.id, + targetName: deletedName, + metadata: { type: deletedType }, + }); + } + return { success: true }; }, }); diff --git a/convex/messages.ts b/convex/messages.ts index a014bc3..d8e1125 100644 --- a/convex/messages.ts +++ b/convex/messages.ts @@ -3,6 +3,7 @@ import { paginationOptsValidator } from "convex/server"; import { v } from "convex/values"; import { getPublicStorageUrl } from "./storageUrl"; import { getRolesForUser } from "./roles"; +import { isBanned } from "./bans"; const DEFAULT_ROLE_COLOR = "#99aab5"; @@ -252,6 +253,9 @@ export const sendInternal = internalMutation({ if (args.ciphertext.length > MAX_CIPHERTEXT_CHARS) { throw new Error("Message too large"); } + if (await isBanned(ctx, args.senderId)) { + throw new Error("You've been banned from this server."); + } const id = await ctx.db.insert("messages", { channelId: args.channelId, senderId: args.senderId, diff --git a/convex/readState.ts b/convex/readState.ts index 154f3f9..6f64b5a 100644 --- a/convex/readState.ts +++ b/convex/readState.ts @@ -82,7 +82,11 @@ export const getAllReadStates = query({ }, }); -// Get the latest message timestamp per channel (used by Sidebar) +// Get the latest message timestamp per channel (used by Sidebar). +// Also surfaces `messageId` and `senderId` so the notification +// observer can detect genuinely new messages (not just timestamp +// ticks from edits) and suppress notifications for the user's own +// sends without an extra round-trip. export const getLatestMessageTimestamps = query({ args: { channelIds: v.array(v.id("channels")), @@ -91,6 +95,8 @@ export const getLatestMessageTimestamps = query({ v.object({ channelId: v.id("channels"), latestTimestamp: v.number(), + messageId: v.optional(v.id("messages")), + senderId: v.optional(v.id("userProfiles")), }) ), handler: async (ctx, args) => { @@ -105,6 +111,8 @@ export const getLatestMessageTimestamps = query({ results.push({ channelId, latestTimestamp: Math.floor(latestMsg._creationTime), + messageId: latestMsg._id, + senderId: latestMsg.senderId, }); } } diff --git a/convex/roles.ts b/convex/roles.ts index c658298..1354b26 100644 --- a/convex/roles.ts +++ b/convex/roles.ts @@ -13,6 +13,7 @@ const PERMISSION_KEYS = [ "move_members", "mute_members", "manage_nicknames", + "ban_members", ] as const; export async function getRolesForUser( @@ -30,6 +31,31 @@ export async function getRolesForUser( return roles.filter((r): r is Doc<"roles"> => r !== null); } +/** + * Server-side permission check. Use before any mutation that's + * supposed to be gated — the client-side `getMyPermissions` hides + * UI, but a crafted client can still call the mutation. + * + * Treats `isAdmin` bootstrap flag and the "Owner" role as granting + * every permission, including ones that don't yet exist on the role + * row. That keeps future permission additions working for the + * original server owner without requiring a migration pass. + */ +export async function hasPermission( + ctx: GenericQueryCtx, + userId: Id<"userProfiles">, + key: (typeof PERMISSION_KEYS)[number], +): Promise { + 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 | undefined)?.[key] === true, + ); +} + // List all roles export const list = query({ args: {}, @@ -249,15 +275,22 @@ export const getMyPermissions = query({ move_members: v.boolean(), mute_members: v.boolean(), manage_nicknames: v.boolean(), + ban_members: v.boolean(), }), handler: async (ctx, args) => { + const user = await ctx.db.get(args.userId); const roles = await getRolesForUser(ctx, args.userId); + // isAdmin or Owner-role bearers get everything — same logic as + // `hasPermission`. Keeps UI and server enforcement consistent. + const isSuper = !!user?.isAdmin || roles.some((r) => r.name === "Owner"); const finalPerms: Record = {}; for (const key of PERMISSION_KEYS) { - finalPerms[key] = roles.some( - (role) => (role.permissions as Record)?.[key] - ); + finalPerms[key] = + isSuper || + roles.some( + (role) => (role.permissions as Record)?.[key], + ); } return finalPerms as { @@ -270,6 +303,7 @@ export const getMyPermissions = query({ move_members: boolean; mute_members: boolean; manage_nicknames: boolean; + ban_members: boolean; }; }, }); diff --git a/convex/schema.ts b/convex/schema.ts index 721bf25..0cbcb72 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -18,6 +18,7 @@ export default defineSchema({ customStatus: v.optional(v.string()), joinSoundStorageId: v.optional(v.id("_storage")), accentColor: v.optional(v.string()), + bannerStorageId: v.optional(v.id("_storage")), }).index("by_username", ["username"]), categories: defineTable({ @@ -191,6 +192,31 @@ export default defineSchema({ .index("by_poll_user_emoji", ["pollId", "userId", "emoji"]) .index("by_user", ["userId"]), + // Banned users — presence of a row = login + send blocked for that + // user. Unique by `userId` (enforced by the only caller path — + // `bans.ban` does an upsert, not raw insert). + bans: defineTable({ + userId: v.id("userProfiles"), + bannedBy: v.id("userProfiles"), + reason: v.optional(v.string()), + createdAt: v.number(), + }).index("by_user", ["userId"]), + + // Append-only log of admin-ish actions: channel / role edits, bans, + // server settings changes. Target fields are stringly-typed so + // entries can point at any table without a discriminated union. + auditLog: defineTable({ + actorId: v.id("userProfiles"), + action: v.string(), + targetType: v.optional(v.string()), + targetId: v.optional(v.string()), + targetName: v.optional(v.string()), + metadata: v.optional(v.any()), + createdAt: v.number(), + }) + .index("by_created_at", ["createdAt"]) + .index("by_actor", ["actorId"]), + savedMedia: defineTable({ userId: v.id("userProfiles"), // Convex storage URL — also the dedupe key for a single user. diff --git a/convex/serverSettings.ts b/convex/serverSettings.ts index 6c753bf..a26a5a8 100644 --- a/convex/serverSettings.ts +++ b/convex/serverSettings.ts @@ -1,6 +1,7 @@ import { query, mutation, internalMutation } from "./_generated/server"; import { v } from "convex/values"; import { getRolesForUser } from "./roles"; +import { AUDIT_ACTIONS, logAudit } from "./audit"; export const get = query({ args: {}, @@ -58,6 +59,13 @@ export const update = mutation({ }); } + await logAudit(ctx, { + actorId: args.userId, + action: AUDIT_ACTIONS.SERVER_SETTINGS_UPDATE, + targetType: "server", + metadata: { afkChannelId: args.afkChannelId, afkTimeout: args.afkTimeout }, + }); + return null; }, }); @@ -85,6 +93,7 @@ export const updateName = mutation({ } const existing = await ctx.db.query("serverSettings").first(); + const oldName = existing?.serverName; if (existing) { await ctx.db.patch(existing._id, { serverName: name }); } else { @@ -94,6 +103,16 @@ export const updateName = mutation({ }); } + if (oldName !== name) { + await logAudit(ctx, { + actorId: args.userId, + action: AUDIT_ACTIONS.SERVER_SETTINGS_UPDATE, + targetType: "server", + targetName: name, + metadata: { field: "serverName", from: oldName, to: name }, + }); + } + return null; }, }); diff --git a/packages/platform-web/src/index.js b/packages/platform-web/src/index.js index 0091c32..c1455da 100644 --- a/packages/platform-web/src/index.js +++ b/packages/platform-web/src/index.js @@ -11,6 +11,50 @@ import SearchDatabase from '@discord-clone/shared/src/utils/SearchDatabase'; const searchDB = new SearchDatabase(searchStorage, crypto); +function makeWebNotifications() { + if (typeof window === 'undefined' || typeof window.Notification === 'undefined') { + return null; + } + return { + show({ title, body, silent }) { + if (Notification.permission !== 'granted') return; + try { + const n = new Notification(String(title ?? 'Brycord'), { + body: String(body ?? ''), + silent: !!silent, + }); + n.onclick = () => { + try { window.focus(); } catch {} + try { n.close(); } catch {} + }; + } catch {} + }, + // Web has the Badging API on some browsers (Chrome, Edge). No-op + // where unavailable instead of throwing. + setBadge(count) { + const n = Math.max(0, Math.floor(Number(count) || 0)); + try { + if (n === 0) navigator.clearAppBadge?.(); + else navigator.setAppBadge?.(n); + } catch {} + }, + // Web has no taskbar-flash equivalent — title bounce is the + // closest thing but gets intrusive fast. No-op for now. + flashFrame() {}, + async ensurePermission() { + if (!('Notification' in window)) return 'unavailable'; + if (Notification.permission === 'granted') return 'granted'; + if (Notification.permission === 'denied') return 'denied'; + try { + const result = await Notification.requestPermission(); + return result; + } catch { + return 'default'; + } + }, + }; +} + const webPlatform = { crypto, session, @@ -34,6 +78,7 @@ const webPlatform = { }, }, windowControls: null, + notifications: makeWebNotifications(), recording: null, updates: null, voiceService: null, @@ -47,6 +92,7 @@ const webPlatform = { hasVoiceService: false, hasSystemBars: false, hasRecording: false, + hasNotifications: typeof window !== 'undefined' && typeof window.Notification !== 'undefined', }, }; diff --git a/packages/shared/package.json b/packages/shared/package.json index 6e1326f..e5d28ab 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -1,7 +1,7 @@ { "name": "@discord-clone/shared", "private": true, - "version": "1.1.2", + "version": "1.1.3", "type": "module", "main": "src/App.tsx", "dependencies": { diff --git a/packages/shared/src/components/channel/AttachmentVideo.tsx b/packages/shared/src/components/channel/AttachmentVideo.tsx index c7fd470..31a721b 100644 --- a/packages/shared/src/components/channel/AttachmentVideo.tsx +++ b/packages/shared/src/components/channel/AttachmentVideo.tsx @@ -109,8 +109,26 @@ export function AttachmentVideo({ useEffect(() => { const el = videoRef.current; if (!el) return; + // Android WebView won't paint any frame for a `