12 KiB
CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Update this file when making significant changes.
See also: CONVEX_RULES.md | CONVEX_EXAMPLES.md
Architecture
- Monorepo: npm workspaces (
packages/*,apps/*) - 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, notifications, updates APIs - Auth: Zero-knowledge custom auth via Convex mutations (getSalt, verifyUser, createUserWithProfile)
- Real-time: Convex reactive queries (
useQueryauto-updates all connected clients) - Voice/Video: LiveKit (token generation via Convex Node action)
- E2E Encryption: Platform-specific crypto (Electron: Node crypto via IPC, Web: Web Crypto API)
- File Storage: Convex built-in storage (
generateUploadUrl+getUrl)
Development Commands
# Install
npm install # Installs all workspaces
# Backend
npx convex dev # Start Convex dev server (creates .env.local)
# Frontend (run alongside backend)
npm run dev:web # Web app at localhost:5173
npm run dev:electron # Electron app (Vite + Electron concurrently)
# Production builds
npm run build:web # Web production build -> apps/web/dist
npm run build:electron # Electron build with electron-builder
npm run build:android # Web build + Capacitor sync
# Android
cd apps/android && npx cap sync && npx cap open android
# Preview
cd apps/web && npx vite preview # Preview web production build
No test framework or linter is configured in this project.
Project Structure
Discord Clone/
├── convex/ # Backend (Convex functions + schema)
├── packages/
│ ├── shared/ # Shared React app (all components, pages, contexts, styles)
│ │ └── src/
│ │ ├── components/ # All UI components
│ │ ├── pages/ # Login, Register, Chat
│ │ ├── contexts/ # VoiceContext, ThemeContext, PresenceContext
│ │ ├── platform/ # PlatformProvider + usePlatform hook
│ │ ├── styles/ # themes.css
│ │ ├── utils/ # userPreferences.js, streamUtils.jsx
│ │ ├── assets/ # sounds, icons, emojis, fonts
│ │ ├── App.jsx # Router + AuthGuard
│ │ └── index.css # Global styles
│ └── platform-web/ # Web/Capacitor platform implementations
│ └── src/ # Web Crypto API, localStorage session/settings, Page Visibility idle
├── apps/
│ ├── electron/ # Electron desktop app (main.cjs, preload.cjs, updater.cjs)
│ │ └── src/main.jsx # Entry: PlatformProvider + HashRouter
│ ├── web/ # Web browser app (PWA enabled via VitePWA)
│ │ └── src/main.jsx # Entry: PlatformProvider + BrowserRouter
│ └── android/ # Capacitor Android wrapper
├── package.json # Root workspace config
├── .env.local # Convex + LiveKit + Klipy keys
└── CLAUDE.md
Vite & Import Aliases
All Vite configs use envDir: '../../' to pick up root .env.local.
| Alias | Resolves to |
|---|---|
@discord-clone/shared |
packages/shared/src/ |
@discord-clone/platform-web |
packages/platform-web/src/ |
@shared |
packages/shared/src/ |
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())
All platform-specific APIs are accessed via the usePlatform() hook:
crypto- generateKeys, randomBytes, sha256, signMessage, verifySignature, deriveAuthKeys, encryptData, decryptData, decryptBatch, verifyBatch, publicEncrypt, privateDecryptsession- save, load, clearsettings- get, setidle- getSystemIdleTime, onIdleStateChanged, removeIdleStateListenerlinks- openExternal, fetchMetadatascreenCapture- getScreenSourceswindowControls- 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
Important Patterns
- Channel IDs use Convex
_id(notid) - all references usechannel._id - Auth: client hashes DAK -> HAK before sending, server does string comparison
- First user bootstrap: createUserWithProfile creates Owner + @everyone roles
- Convex queries are reactive - no need for manual refresh or socket listeners
- File uploads use Convex storage:
generateUploadUrl-> POST blob ->getFileUrl - Typing indicators use scheduled functions for TTL cleanup
- CSS uses Discord dark theme colors via
:rootvariables (--bg-primary: #313338,--bg-secondary: #2b2d31,--bg-tertiary: #1e1f22) - Sidebar width is 312px (72px server strip + 240px channel panel)
- Channels grouped by
categoryIdwith collapsible headers and @dnd-kit drag-and-drop - Members list groups by hoisted roles (isHoist) then Online/Offline
- Unread tracking:
channelReadStatetable per user/channel. ChatArea shows red "NEW" divider, Sidebar shows white dot - Server name from
serverSettingssingleton, editable via Server Settings (requiresmanage_channels) - AFK voice channel: VoiceContext polls idle time, auto-moves idle users
- Custom join sounds: stored as
joinSoundStorageIdonuserProfiles - Server icon:
serverSettingsstoresiconStorageId, resolved toiconUrl userPreferences.jssetUserPreftakes optionalsettingsparam for disk persistence via platform- Module-scope functions needing crypto accept it as parameter (e.g.,
encryptKeyForUsers(users, channelId, keyHex, crypto)) 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.tsxparses 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 leafNotificationManager(mounted inAppLayout) watchesreadState.getLatestMessageTimestampsacross all channels. On a newmessageIdwhen the window is unfocused andsenderId !== self, it callsplatform.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.cjsemits status events;platform.updates.{getStatus,downloadAndInstall,onStatusChanged}expose it.HeaderUpdateIcon(mounted inTitleBar) 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:
banstable blocks login (auth.verifyUser) and message send (messages.sendInternal).auditLogtable is append-only;audit.logAudit(ctx, {...})is the helper that mutations call (best-effort — never throws). Permission check:roles.hasPermission(ctx, userId, key)— treatsisAdminand theOwnerrole as superusers so new permission keys likeban_memberswork without a migration. Server Settings → Bans + Audit Log tabs (desktop + mobile) - Profile banner:
userProfiles.bannerStorageId(optional), resolved tobannerUrlinauth.getPublicKeys.auth.updateProfileInternaltakesbannerStorageId+removeBanner(the remove path alsoctx.storage.deletes 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
ChannelTextarearecords viaMediaRecorder(picksaudio/webm;codecs=opuswhere supported), stages the resultingFilethrough the existing attachment pipeline — no new backend. Receivers render it via the standardAttachmentAudioplayer. Filename convention:voice-message-{timestamp}.{webm|ogg|m4a}. Voice-recorded messages setisVoiceMessage: true+peaks: number[]+durationSecin the attachment metadata;EncryptedAttachmentdispatches those toVoiceMessagePlayer(pill with play button + waveform) instead of the full audio card - Push-to-talk:
voiceSettings.inputModeis'voice-activity'(default) or'push-to-talk'. Paired with thevoice.pushToTalkkeybind (markedpressAndHold: true).KeybindContextdispatchesbrycord:keybind:voice.pushToTalk:down/:upevents — pressAndHold actions neverpreventDefault, so binding PTT to a letter still lets you type.VoiceContextreads the settings via thebrycord:voice-settings-changedwindow 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 singlesetMicrophoneEnabledeffect - Plaintext cache:
SearchDatabasehas aplaintext_cachetable (per-channel cap of 500) that persists decrypted message bodies encrypted at rest by the existing search DB key.SearchContextexposescachePlaintexts/getChannelPlaintexts.Messages.tsxseeds the in-memorydecryptionCachefrom it on cold channel open (source-scoped on the full ciphertext so edits naturally invalidate), and writes back after each successful decrypt batch. Cuts the "empty bubbles then fill in" wave on app restart - Webcam video: camera publish already lived in
VoiceContext.setCameravia LiveKitsetCameraEnabled. Now also propagatesisCameraOnthroughvoiceStates.updateState.CameraTileattaches a participant's camera track to a<video>element (mirrored for the local preview,mutedlocally).CameraGrid(mounted inVoiceCallViewabove the audio-only tile grid) renders one tile per participant withisCameraOn: true, force-including the local user immediately whenvoice.isCameraOnflips so the preview doesn't lag behind the Convex round-trip - Electron lifecycle (tray + launch options):
main.cjsmaintainsisQuitting+ aTraywith Show / Toggle Mute / Toggle Deafen / Quit. Close handler is split into twoon('close')listeners — the first intercepts close whenminimizeToTrayOnCloseis on and hides instead; the second does the normal state-save.platform.lifecycle.{get,set,show,onTrayAction}expose this to the renderer (Electron only — web =null). Launch section in the Appearance tab toggleslaunchAtStartup(viaapp.setLoginItemSettings),startMinimized, andminimizeToTrayOnClose. Tray menu Mute/Deafen routes throughbrycord:keybind:voice.toggleMute|toggleDeafenso it converges on the same voice action path the hotkeys already use
Environment Variables
In .env.local at project root:
CONVEX_DEPLOYMENT- Convex deployment URL (set bynpx convex dev)VITE_CONVEX_URL- Convex URL for frontend (set bynpx convex dev)VITE_LIVEKIT_URL- LiveKit server URLLIVEKIT_API_KEY- LiveKit API key (used in Convex Node action)LIVEKIT_API_SECRET- LiveKit API secret (used in Convex Node action)KLIPY_API_KEY- Klipy GIF API customer id (used inconvex/gifs.ts). Replaces the oldTENOR_API_KEYafter Tenor's shutdown — the legacy var name is still read as a fallback so existing deployments only need to swap the value.