feat(ui): add Button, Modal, Spinner, Toast, and Tooltip components with styles
All checks were successful
Build and Release / build-and-release (push) Successful in 13m12s

- Implemented Button component with various props for customization.
- Created Modal component with header, content, and footer subcomponents.
- Added Spinner component for loading indicators.
- Developed Toast component for displaying notifications.
- Introduced Tooltip component for contextual hints with keyboard shortcuts.
- Added corresponding CSS modules for styling each component.
- Updated index file to export new components.
- Configured TypeScript settings for the UI package.
This commit is contained in:
Bryan1029384756
2026-04-14 09:02:14 -05:00
parent 9ef839938e
commit b7a4cf4ce8
376 changed files with 52619 additions and 167641 deletions

View File

@@ -45,7 +45,18 @@
"Bash(python -c \"import base64; print\\(base64.b64encode\\(open\\(r''C:\\\\Users\\\\bryan\\\\Desktop\\\\Discord Clone\\\\discord-clone-release.keystore'',''rb''\\).read\\(\\)\\).decode\\(\\)\\)\")", "Bash(python -c \"import base64; print\\(base64.b64encode\\(open\\(r''C:\\\\Users\\\\bryan\\\\Desktop\\\\Discord Clone\\\\discord-clone-release.keystore'',''rb''\\).read\\(\\)\\).decode\\(\\)\\)\")",
"WebFetch(domain:gitea.moyettes.com)", "WebFetch(domain:gitea.moyettes.com)",
"Bash(grep:*)", "Bash(grep:*)",
"WebFetch(domain:addyosmani.com)" "WebFetch(domain:addyosmani.com)",
"Bash(cp -r \"Brycord New UI/apps/web/src/components/auth/\"* \"packages/shared/src/components/auth/\")",
"Bash(cp -r \"Brycord New UI/apps/web/src/components/channel/\"* \"packages/shared/src/components/channel/\")",
"Bash(cp -r \"Brycord New UI/apps/web/src/components/dm/\"* \"packages/shared/src/components/dm/\")",
"Bash(cp -r \"Brycord New UI/apps/web/src/components/layout/\"* \"packages/shared/src/components/layout/\")",
"Bash(cp -r \"Brycord New UI/apps/web/src/components/member/\"* \"packages/shared/src/components/member/\")",
"Read(//tmp/**)",
"Bash(timeout 15 npm run dev:web)",
"Bash(sed -n '1,30p' packages/shared/src/components/channel/ChannelHeader.tsx)",
"Bash(sed -n '1,30p' packages/shared/src/components/member/MemberListContainer.tsx)",
"Bash(timeout 20 npm run dev:web)",
"Bash(timeout 8 npm run dev:web)"
] ]
} }
} }

5
.gitignore vendored
View File

@@ -9,8 +9,11 @@ apps/electron/dist
apps/web/dist apps/web/dist
# Legacy # Legacy
discord-html-copy discord-html-copy
todo todo
# Reference codebases used to port UI / features — not part of the build
fluxer
Brycord New UI
# Deploy # Deploy
deploy/server/node_modules deploy/server/node_modules

111
CLAUDE.md
View File

@@ -1,3 +1,7 @@
# 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.** **Update this file when making significant changes.**
See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_EXAMPLES.md) See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_EXAMPLES.md)
@@ -15,6 +19,33 @@ See also: [CONVEX_RULES.md](./CONVEX_RULES.md) | [CONVEX_EXAMPLES.md](./CONVEX_E
- **E2E Encryption**: Platform-specific crypto (Electron: Node crypto via IPC, Web: Web Crypto API) - **E2E Encryption**: Platform-specific crypto (Electron: Node crypto via IPC, Web: Web Crypto API)
- **File Storage**: Convex built-in storage (`generateUploadUrl` + `getUrl`) - **File Storage**: Convex built-in storage (`generateUploadUrl` + `getUrl`)
## Development Commands
```bash
# 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 ## Project Structure
``` ```
@@ -33,66 +64,29 @@ Discord Clone/
│ │ ├── App.jsx # Router + AuthGuard │ │ ├── App.jsx # Router + AuthGuard
│ │ └── index.css # Global styles │ │ └── index.css # Global styles
│ └── platform-web/ # Web/Capacitor platform implementations │ └── platform-web/ # Web/Capacitor platform implementations
│ └── src/ │ └── src/ # Web Crypto API, localStorage session/settings, Page Visibility idle
│ ├── crypto.js # Web Crypto API (RSA-OAEP, Ed25519, AES-256-GCM, scrypt)
│ ├── session.js # localStorage session persistence
│ ├── settings.js # localStorage settings
│ ├── idle.js # Page Visibility API idle detection
│ └── index.js # Bundles all web platform APIs
├── apps/ ├── apps/
│ ├── electron/ # Electron desktop app │ ├── electron/ # Electron desktop app (main.cjs, preload.cjs, updater.cjs)
│ │ ── main.cjs # Electron main process │ │ ── src/main.jsx # Entry: PlatformProvider + HashRouter
│ ├── preload.cjs # IPC bridge (window.* APIs) │ ├── web/ # Web browser app (PWA enabled via VitePWA)
│ │ ── updater.cjs # electron-updater integration │ │ ── src/main.jsx # Entry: PlatformProvider + BrowserRouter
│ │ ├── splash.html # Update splash screen
│ │ └── src/
│ │ ├── main.jsx # Entry: PlatformProvider + HashRouter
│ │ └── platform/index.js # Electron platform (delegates to window.* APIs)
│ ├── web/ # Web browser app
│ │ └── src/
│ │ └── main.jsx # Entry: PlatformProvider + BrowserRouter
│ └── android/ # Capacitor Android wrapper │ └── android/ # Capacitor Android wrapper
│ └── capacitor.config.ts # Points webDir at ../web/dist
├── package.json # Root workspace config ├── package.json # Root workspace config
├── .env.local # Convex + LiveKit + Tenor keys ├── .env.local # Convex + LiveKit + Klipy keys
└── CLAUDE.md └── CLAUDE.md
``` ```
## Key Convex Files (convex/) ## Vite & Import Aliases
- `schema.ts` - Full schema: userProfiles (with avatarStorageId, aboutMe, customStatus, joinSoundStorageId), categories (name, position), channels (with categoryId, topic, position), messages, messageReactions, channelKeys, roles, userRoles, invites, dmParticipants, typingIndicators, voiceStates, channelReadState, serverSettings (serverName, afkChannelId, afkTimeout, iconStorageId) All Vite configs use `envDir: '../../'` to pick up root `.env.local`.
- `auth.ts` - getSalt, verifyUser, createUserWithProfile, getPublicKeys (includes avatarUrl, aboutMe, customStatus, joinSoundUrl), updateProfile (includes joinSoundStorageId, removeJoinSound), updateStatus, getMyJoinSoundUrl
- `categories.ts` - list, create, rename, remove, reorder
- `channels.ts` - list, get, create (with categoryId/topic/position), rename, remove (cascade), updateTopic, moveChannel, reorderChannels
- `members.ts` - getChannelMembers (includes isHoist on roles, avatarUrl, aboutMe, customStatus)
- `channelKeys.ts` - uploadKeys, getKeysForUser
- `messages.ts` - list (with reactions + username), send, edit, pin, listPinned, remove (with manage_messages permission check)
- `reactions.ts` - add, remove
- `serverSettings.ts` - get (resolves iconUrl), update (manage_channels permission), updateName (manage_channels permission), updateIcon (manage_channels permission), clearAfkChannel (internal)
- `typing.ts` - startTyping, stopTyping, getTyping, cleanExpired (scheduled)
- `dms.ts` - openDM, listDMs
- `invites.ts` - create, use, revoke
- `roles.ts` - list, create, update, remove, listMembers, assign, unassign, getMyPermissions
- `voiceState.ts` - join, leave, updateState, getAll (includes joinSoundUrl per user), afkMove (self-move to AFK channel)
- `voice.ts` - getToken (Node action, livekit-server-sdk)
- `files.ts` - generateUploadUrl, getFileUrl
- `gifs.ts` - search, categories (Node actions, Tenor API)
- `readState.ts` - getReadState, markRead, getAllReadStates, getLatestMessageTimestamps (unread tracking)
## Shared Frontend (packages/shared/src/) | Alias | Resolves to |
|-------|-------------|
| `@discord-clone/shared` | `packages/shared/src/` |
| `@discord-clone/platform-web` | `packages/platform-web/src/` |
| `@shared` | `packages/shared/src/` |
- `App.jsx` - Router + AuthGuard (uses `usePlatform().session`) Convex imports from shared components use relative path `../../../../convex/_generated/api` (4 levels up from shared src subdirs).
- `pages/Login.jsx` - Convex auth (uses `usePlatform().crypto` for key derivation)
- `pages/Register.jsx` - Convex auth (uses `usePlatform().crypto` for key generation)
- `pages/Chat.jsx` - useQuery for channels, categories, channelKeys, DMs
- `components/ChatArea.jsx` - Messages, typing, reactions via Convex queries/mutations
- `components/Sidebar.jsx` - Channel/category creation, key distribution, invites, drag-and-drop via @dnd-kit
- `contexts/VoiceContext.jsx` - Voice state via Convex + LiveKit + idle detection via `usePlatform().idle`
- `components/TitleBar.jsx` - Conditional: only renders if `platform.features.hasWindowControls`
- `components/UpdateBanner.jsx` - Conditional: only renders if `platform.features.hasNativeUpdates`
- `components/ScreenShareModal.jsx` - Uses `usePlatform().screenCapture`
- `components/MessageItem.jsx` - Message rendering, link opening via `usePlatform().links`
- `platform/PlatformProvider.jsx` - React context providing platform APIs via `usePlatform()` hook
## Platform Abstraction (usePlatform()) ## Platform Abstraction (usePlatform())
@@ -112,7 +106,6 @@ All platform-specific APIs are accessed via the `usePlatform()` hook:
- Channel IDs use Convex `_id` (not `id`) - all references use `channel._id` - Channel IDs use Convex `_id` (not `id`) - all references use `channel._id`
- Auth: client hashes DAK -> HAK before sending, server does string comparison - Auth: client hashes DAK -> HAK before sending, server does string comparison
- First user bootstrap: createUserWithProfile creates Owner + @everyone roles - First user bootstrap: createUserWithProfile creates Owner + @everyone roles
- Vite configs use `envDir: '../../'` to pick up root `.env.local`
- Convex queries are reactive - no need for manual refresh or socket listeners - Convex queries are reactive - no need for manual refresh or socket listeners
- File uploads use Convex storage: `generateUploadUrl` -> POST blob -> `getFileUrl` - File uploads use Convex storage: `generateUploadUrl` -> POST blob -> `getFileUrl`
- Typing indicators use scheduled functions for TTL cleanup - Typing indicators use scheduled functions for TTL cleanup
@@ -126,6 +119,10 @@ All platform-specific APIs are accessed via the `usePlatform()` hook:
- Custom join sounds: stored as `joinSoundStorageId` on `userProfiles` - Custom join sounds: stored as `joinSoundStorageId` on `userProfiles`
- Server icon: `serverSettings` stores `iconStorageId`, resolved to `iconUrl` - Server icon: `serverSettings` stores `iconStorageId`, resolved to `iconUrl`
- `userPreferences.js` `setUserPref` takes optional `settings` param for disk persistence via platform - `userPreferences.js` `setUserPref` takes optional `settings` param 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.*`
## Environment Variables ## Environment Variables
@@ -135,12 +132,4 @@ In `.env.local` at project root:
- `VITE_LIVEKIT_URL` - LiveKit server URL - `VITE_LIVEKIT_URL` - LiveKit server URL
- `LIVEKIT_API_KEY` - LiveKit API key (used in Convex Node action) - `LIVEKIT_API_KEY` - LiveKit API key (used in Convex Node action)
- `LIVEKIT_API_SECRET` - LiveKit API secret (used in Convex Node action) - `LIVEKIT_API_SECRET` - LiveKit API secret (used in Convex Node action)
- `TENOR_API_KEY` - Tenor GIF API key (used in Convex Node action) - `KLIPY_API_KEY` - Klipy GIF API customer id (used in `convex/gifs.ts`). Replaces the old `TENOR_API_KEY` after Tenor's shutdown — the legacy var name is still read as a fallback so existing deployments only need to swap the value.
## Running the App
1. `npm install` (installs all workspaces)
2. `npx convex dev` (starts Convex backend, creates `.env.local`)
3. Electron: `npm run dev:electron`
4. Web: `npm run dev:web`
5. Android: `npm run build:web && cd apps/android && npx cap sync && npx cap open android`

View File

@@ -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.0.40" versionName "1.0.50"
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.

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/android", "name": "@discord-clone/android",
"private": true, "private": true,
"version": "1.0.40", "version": "1.0.50",
"type": "module", "type": "module",
"scripts": { "scripts": {
"cap:sync": "npx cap sync", "cap:sync": "npx cap sync",

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/electron", "name": "@discord-clone/electron",
"private": true, "private": true,
"version": "1.0.40", "version": "1.0.50",
"description": "Brycord - Electron app", "description": "Brycord - Electron app",
"author": "Moyettes", "author": "Moyettes",
"type": "module", "type": "module",

View File

@@ -7,11 +7,8 @@ import App from '@discord-clone/shared/src/App';
import { ThemeProvider } from '@discord-clone/shared/src/contexts/ThemeContext'; import { ThemeProvider } from '@discord-clone/shared/src/contexts/ThemeContext';
import { VoiceProvider } from '@discord-clone/shared/src/contexts/VoiceContext'; import { VoiceProvider } from '@discord-clone/shared/src/contexts/VoiceContext';
import { SearchProvider } from '@discord-clone/shared/src/contexts/SearchContext'; import { SearchProvider } from '@discord-clone/shared/src/contexts/SearchContext';
import { UpdateProvider } from '@discord-clone/shared/src/components/UpdateBanner';
import TitleBar from '@discord-clone/shared/src/components/TitleBar';
import electronPlatform from './platform'; import electronPlatform from './platform';
import '@discord-clone/shared/src/styles/themes.css'; import '@discord-clone/shared/src/global.css';
import '@discord-clone/shared/src/index.css';
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL); const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
@@ -19,18 +16,15 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<PlatformProvider platform={electronPlatform}> <PlatformProvider platform={electronPlatform}>
<ThemeProvider> <ThemeProvider>
<UpdateProvider>
<ConvexProvider client={convex}> <ConvexProvider client={convex}>
<SearchProvider> <SearchProvider>
<VoiceProvider> <VoiceProvider>
<TitleBar />
<HashRouter> <HashRouter>
<App /> <App />
</HashRouter> </HashRouter>
</VoiceProvider> </VoiceProvider>
</SearchProvider> </SearchProvider>
</ConvexProvider> </ConvexProvider>
</UpdateProvider>
</ThemeProvider> </ThemeProvider>
</PlatformProvider> </PlatformProvider>
</React.StrictMode>, </React.StrictMode>,

View File

@@ -10,6 +10,25 @@ export default defineConfig({
dedupe: ['react', 'react-dom'], dedupe: ['react', 'react-dom'],
alias: { alias: {
'@discord-clone/shared': path.resolve(__dirname, '../../packages/shared'), '@discord-clone/shared': path.resolve(__dirname, '../../packages/shared'),
'@discord-clone/ui': path.resolve(__dirname, '../../packages/ui/src'),
'@discord-clone/constants': path.resolve(__dirname, '../../packages/constants/src'),
'@brycord/ui': path.resolve(__dirname, '../../packages/ui/src'),
'@brycord/constants': path.resolve(__dirname, '../../packages/constants/src'),
'@brycord/matrix-client': path.resolve(__dirname, '../../packages/shared/src/_shims/matrix-client.ts'),
'@app/stores': path.resolve(__dirname, '../../packages/shared/src/_shims/stores'),
'@app/actions': path.resolve(__dirname, '../../packages/shared/src/_shims/actions'),
'@app/components': path.resolve(__dirname, '../../packages/shared/src/components'),
'@app/hooks': path.resolve(__dirname, '../../packages/shared/src/_shims/app/hooks'),
'@app/utils': path.resolve(__dirname, '../../packages/shared/src/_shims/app/utils'),
'@app/data': path.resolve(__dirname, '../../packages/shared/src/_shims/app/data'),
'@app': path.resolve(__dirname, '../../packages/shared/src/_shims/app'),
'mobx-react-lite': path.resolve(__dirname, '../../packages/shared/src/_shims/mobx-react-lite.ts'),
mobx: path.resolve(__dirname, '../../packages/shared/src/_shims/mobx.ts'),
},
},
css: {
modules: {
localsConvention: 'camelCaseOnly',
}, },
}, },
build: { build: {

View File

@@ -1,7 +1,7 @@
{ {
"name": "@discord-clone/web", "name": "@discord-clone/web",
"private": true, "private": true,
"version": "1.0.40", "version": "1.0.50",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -7,10 +7,8 @@ import App from '@discord-clone/shared/src/App';
import { ThemeProvider } from '@discord-clone/shared/src/contexts/ThemeContext'; import { ThemeProvider } from '@discord-clone/shared/src/contexts/ThemeContext';
import { VoiceProvider } from '@discord-clone/shared/src/contexts/VoiceContext'; import { VoiceProvider } from '@discord-clone/shared/src/contexts/VoiceContext';
import { SearchProvider } from '@discord-clone/shared/src/contexts/SearchContext'; import { SearchProvider } from '@discord-clone/shared/src/contexts/SearchContext';
import { UpdateProvider } from '@discord-clone/shared/src/components/UpdateBanner';
import webPlatform from '@discord-clone/platform-web'; import webPlatform from '@discord-clone/platform-web';
import '@discord-clone/shared/src/styles/themes.css'; import '@discord-clone/shared/src/global.css';
import '@discord-clone/shared/src/index.css';
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL); const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
@@ -19,7 +17,6 @@ ReactDOM.createRoot(document.getElementById('root')).render(
<PlatformProvider platform={webPlatform}> <PlatformProvider platform={webPlatform}>
<ThemeProvider> <ThemeProvider>
<ConvexProvider client={convex}> <ConvexProvider client={convex}>
<UpdateProvider>
<SearchProvider> <SearchProvider>
<VoiceProvider> <VoiceProvider>
<BrowserRouter> <BrowserRouter>
@@ -27,7 +24,6 @@ ReactDOM.createRoot(document.getElementById('root')).render(
</BrowserRouter> </BrowserRouter>
</VoiceProvider> </VoiceProvider>
</SearchProvider> </SearchProvider>
</UpdateProvider>
</ConvexProvider> </ConvexProvider>
</ThemeProvider> </ThemeProvider>
</PlatformProvider> </PlatformProvider>

View File

@@ -72,6 +72,25 @@ export default defineConfig({
alias: { alias: {
'@discord-clone/shared': path.resolve(__dirname, '../../packages/shared'), '@discord-clone/shared': path.resolve(__dirname, '../../packages/shared'),
'@discord-clone/platform-web': path.resolve(__dirname, '../../packages/platform-web'), '@discord-clone/platform-web': path.resolve(__dirname, '../../packages/platform-web'),
'@discord-clone/ui': path.resolve(__dirname, '../../packages/ui/src'),
'@discord-clone/constants': path.resolve(__dirname, '../../packages/constants/src'),
'@brycord/ui': path.resolve(__dirname, '../../packages/ui/src'),
'@brycord/constants': path.resolve(__dirname, '../../packages/constants/src'),
'@brycord/matrix-client': path.resolve(__dirname, '../../packages/shared/src/_shims/matrix-client.ts'),
'@app/stores': path.resolve(__dirname, '../../packages/shared/src/_shims/stores'),
'@app/actions': path.resolve(__dirname, '../../packages/shared/src/_shims/actions'),
'@app/components': path.resolve(__dirname, '../../packages/shared/src/components'),
'@app/hooks': path.resolve(__dirname, '../../packages/shared/src/_shims/app/hooks'),
'@app/utils': path.resolve(__dirname, '../../packages/shared/src/_shims/app/utils'),
'@app/data': path.resolve(__dirname, '../../packages/shared/src/_shims/app/data'),
'@app': path.resolve(__dirname, '../../packages/shared/src/_shims/app'),
'mobx-react-lite': path.resolve(__dirname, '../../packages/shared/src/_shims/mobx-react-lite.ts'),
mobx: path.resolve(__dirname, '../../packages/shared/src/_shims/mobx.ts'),
},
},
css: {
modules: {
localsConvention: 'camelCaseOnly',
}, },
}, },
build: { build: {

View File

@@ -17,13 +17,16 @@ import type * as dms from "../dms.js";
import type * as files from "../files.js"; import type * as files from "../files.js";
import type * as gifs from "../gifs.js"; import type * as gifs from "../gifs.js";
import type * as invites from "../invites.js"; import type * as invites from "../invites.js";
import type * as links from "../links.js";
import type * as members from "../members.js"; import type * as members from "../members.js";
import type * as messages from "../messages.js"; import type * as messages from "../messages.js";
import type * as polls from "../polls.js";
import type * as presence from "../presence.js"; import type * as presence from "../presence.js";
import type * as reactions from "../reactions.js"; import type * as reactions from "../reactions.js";
import type * as readState from "../readState.js"; import type * as readState from "../readState.js";
import type * as recovery from "../recovery.js"; import type * as recovery from "../recovery.js";
import type * as roles from "../roles.js"; import type * as roles from "../roles.js";
import type * as savedMedia from "../savedMedia.js";
import type * as serverSettings from "../serverSettings.js"; import type * as serverSettings from "../serverSettings.js";
import type * as storageUrl from "../storageUrl.js"; import type * as storageUrl from "../storageUrl.js";
import type * as typing from "../typing.js"; import type * as typing from "../typing.js";
@@ -46,13 +49,16 @@ declare const fullApi: ApiFromModules<{
files: typeof files; files: typeof files;
gifs: typeof gifs; gifs: typeof gifs;
invites: typeof invites; invites: typeof invites;
links: typeof links;
members: typeof members; members: typeof members;
messages: typeof messages; messages: typeof messages;
polls: typeof polls;
presence: typeof presence; presence: typeof presence;
reactions: typeof reactions; reactions: typeof reactions;
readState: typeof readState; readState: typeof readState;
recovery: typeof recovery; recovery: typeof recovery;
roles: typeof roles; roles: typeof roles;
savedMedia: typeof savedMedia;
serverSettings: typeof serverSettings; serverSettings: typeof serverSettings;
storageUrl: typeof storageUrl; storageUrl: typeof storageUrl;
typing: typeof typing; typing: typeof typing;

View File

@@ -208,6 +208,7 @@ export const getPublicKeys = query({
aboutMe: v.optional(v.string()), aboutMe: v.optional(v.string()),
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()),
}) })
), ),
handler: async (ctx) => { handler: async (ctx) => {
@@ -232,6 +233,7 @@ export const getPublicKeys = query({
aboutMe: u.aboutMe, aboutMe: u.aboutMe,
customStatus: u.customStatus, customStatus: u.customStatus,
joinSoundUrl, joinSoundUrl,
accentColor: u.accentColor,
}); });
} }
return results; return results;
@@ -248,6 +250,7 @@ export const updateProfile = mutation({
customStatus: v.optional(v.string()), customStatus: v.optional(v.string()),
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()),
}, },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -258,6 +261,7 @@ export const updateProfile = mutation({
if (args.customStatus !== undefined) patch.customStatus = args.customStatus; if (args.customStatus !== undefined) patch.customStatus = args.customStatus;
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;
await ctx.db.patch(args.userId, patch); await ctx.db.patch(args.userId, patch);
return null; return null;
}, },

View File

@@ -1,6 +1,78 @@
import { query, mutation } from "./_generated/server"; import { query, mutation } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
/**
* Rotate the symmetric key for a DM channel. Inserts a brand-new
* versioned row for each participant — existing rows are left alone
* so previously-encrypted messages remain decryptable.
*
* The caller proves they're a DM participant by passing their own
* userId; the server cross-checks against `dmParticipants` for the
* channel. Every recipient userId in `entries` must also be a
* participant — no leaking keys to random users.
*
* The new rows are tagged with `maxExistingVersion + 1`.
*/
export const rotateDMKey = mutation({
args: {
channelId: v.id("channels"),
initiatorUserId: v.id("userProfiles"),
entries: v.array(
v.object({
userId: v.id("userProfiles"),
encryptedKeyBundle: v.string(),
}),
),
},
returns: v.object({ keyVersion: v.number() }),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.channelId);
if (!channel) throw new Error("Channel not found");
if (channel.type !== "dm") {
throw new Error("rotateDMKey is only supported for DM channels");
}
// Verify every (initiator + entries) userId is in dmParticipants.
const participants = await ctx.db
.query("dmParticipants")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
const participantSet = new Set(participants.map((p) => p.userId as string));
if (!participantSet.has(args.initiatorUserId as unknown as string)) {
throw new Error("Not a participant in this DM");
}
for (const entry of args.entries) {
if (!participantSet.has(entry.userId as unknown as string)) {
throw new Error("Target userId is not a participant in this DM");
}
}
// Find the current max keyVersion for this channel. New rows go
// one above that. If no rows exist yet, start at 2 so legacy
// messages tagged version 1 still hit their original key.
const existing = await ctx.db
.query("channelKeys")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.collect();
const maxVersion = existing.reduce(
(m, k) => (k.keyVersion > m ? k.keyVersion : m),
0,
);
const newVersion = maxVersion + 1;
for (const entry of args.entries) {
await ctx.db.insert("channelKeys", {
channelId: args.channelId,
userId: entry.userId,
encryptedKeyBundle: entry.encryptedKeyBundle,
keyVersion: newVersion,
});
}
return { keyVersion: newVersion };
},
});
// Batch upsert encrypted key bundles // Batch upsert encrypted key bundles
export const uploadKeys = mutation({ export const uploadKeys = mutation({
args: { args: {

View File

@@ -12,12 +12,20 @@ export const list = query({
emojis.map(async (emoji) => { emojis.map(async (emoji) => {
const src = await getPublicStorageUrl(ctx, emoji.storageId); const src = await getPublicStorageUrl(ctx, emoji.storageId);
const user = await ctx.db.get(emoji.uploadedBy); const user = await ctx.db.get(emoji.uploadedBy);
let avatarUrl: string | null = null;
if (user?.avatarStorageId) {
avatarUrl = await getPublicStorageUrl(ctx, user.avatarStorageId);
}
return { return {
_id: emoji._id, _id: emoji._id,
name: emoji.name, name: emoji.name,
src, src,
createdAt: emoji.createdAt, createdAt: emoji.createdAt,
animated: emoji.animated ?? false,
uploadedById: emoji.uploadedBy,
uploadedByUsername: user?.username || "Unknown", uploadedByUsername: user?.username || "Unknown",
uploadedByDisplayName: user?.displayName || null,
uploadedByAvatarUrl: avatarUrl,
}; };
}) })
); );
@@ -30,6 +38,7 @@ export const upload = mutation({
userId: v.id("userProfiles"), userId: v.id("userProfiles"),
name: v.string(), name: v.string(),
storageId: v.id("_storage"), storageId: v.id("_storage"),
animated: v.optional(v.boolean()),
}, },
returns: v.null(), returns: v.null(),
handler: async (ctx, args) => { handler: async (ctx, args) => {
@@ -64,6 +73,7 @@ export const upload = mutation({
name, name,
storageId: args.storageId, storageId: args.storageId,
uploadedBy: args.userId, uploadedBy: args.userId,
animated: args.animated ?? false,
createdAt: Date.now(), createdAt: Date.now(),
}); });
@@ -71,6 +81,53 @@ export const upload = mutation({
}, },
}); });
/**
* Rename a custom emoji in place. Enforces the same name validation
* as `upload` and rejects collisions with other existing emojis so
* two rows can't end up sharing a shortcode.
*/
export const rename = mutation({
args: {
userId: v.id("userProfiles"),
emojiId: v.id("customEmojis"),
name: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const roles = await getRolesForUser(ctx, args.userId);
const canManage = roles.some(
(role) => (role.permissions as Record<string, boolean>)?.["manage_channels"]
);
if (!canManage) {
throw new Error("You don't have permission to manage emojis");
}
const emoji = await ctx.db.get(args.emojiId);
if (!emoji) throw new Error("Emoji not found");
const name = args.name.trim();
if (!/^[a-zA-Z0-9_]+$/.test(name)) {
throw new Error("Emoji name can only contain letters, numbers, and underscores");
}
if (name.length < 2 || name.length > 32) {
throw new Error("Emoji name must be between 2 and 32 characters");
}
if (name !== emoji.name) {
const clash = await ctx.db
.query("customEmojis")
.withIndex("by_name", (q) => q.eq("name", name))
.first();
if (clash && clash._id !== args.emojiId) {
throw new Error(`A custom emoji named "${name}" already exists`);
}
}
await ctx.db.patch(args.emojiId, { name });
return null;
},
});
export const remove = mutation({ export const remove = mutation({
args: { args: {
userId: v.id("userProfiles"), userId: v.id("userProfiles"),

View File

@@ -3,41 +3,265 @@
import { action } from "./_generated/server"; import { action } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
// Search GIFs via Tenor API /**
* GIF search action — backed by the Klipy GIF API (Tenor's
* Google-shutdown replacement). Klipy embeds the customer id in the
* URL path and returns results under `data.data[]` with sized
* variants under `file.{hd|md|sm}.gif.url`. We normalize the response
* into a flat `{results: [{id, title, url, previewUrl, width, height}]}`
* shape so callers don't have to know which provider is upstream.
*
* Reads `KLIPY_API_KEY` from the Convex environment. Falls back to
* the legacy `TENOR_API_KEY` env var so existing deployments keep
* working as soon as the old key is replaced with a Klipy customer
* id — no `convex env set` rename required.
*/
interface NormalizedGif {
id: string;
title: string;
url: string;
previewUrl: string;
width?: number;
height?: number;
}
interface GifSearchResponse {
results: NormalizedGif[];
}
/**
* In-memory TTL cache for Klipy responses. Convex Node actions run on
* warm container instances, so this survives between invocations on
* the same worker until it's recycled. Best-effort only — a cold
* start will re-fetch upstream. Search queries live for 5 minutes,
* trending / categories for 30 minutes since they change slowly.
*/
interface CacheEntry<T> {
value: T;
expiresAt: number;
}
const CACHE = new Map<string, CacheEntry<unknown>>();
const MAX_CACHE_SIZE = 200;
const SEARCH_TTL_MS = 5 * 60 * 1000;
const TRENDING_TTL_MS = 30 * 60 * 1000;
const CATEGORIES_TTL_MS = 30 * 60 * 1000;
function cacheGet<T>(key: string): T | null {
const entry = CACHE.get(key);
if (!entry) return null;
if (entry.expiresAt < Date.now()) {
CACHE.delete(key);
return null;
}
return entry.value as T;
}
function cacheSet<T>(key: string, value: T, ttlMs: number): void {
if (CACHE.size >= MAX_CACHE_SIZE) {
// Drop the oldest insertion — Map iteration order is insertion order.
const oldest = CACHE.keys().next().value;
if (oldest !== undefined) CACHE.delete(oldest);
}
CACHE.set(key, { value, expiresAt: Date.now() + ttlMs });
}
function normalizeGifItems(items: any[]): NormalizedGif[] {
return items
.map((item, idx): NormalizedGif => {
const file = item?.file ?? {};
const md = file?.md?.gif ?? file?.sm?.gif ?? file?.hd?.gif ?? {};
const previewVariant =
file?.sm?.gif ?? file?.md?.gif ?? file?.hd?.gif ?? {};
const fullUrl: string = md?.url ?? item?.url ?? "";
const previewUrl: string = previewVariant?.url ?? fullUrl;
const width: number | undefined =
typeof md?.width === "number" ? md.width : undefined;
const height: number | undefined =
typeof md?.height === "number" ? md.height : undefined;
return {
id: String(item?.slug ?? item?.id ?? `${idx}`),
title: String(item?.title ?? ""),
url: fullUrl,
previewUrl,
width,
height,
};
})
.filter((r) => !!r.url);
}
export const search = action({ export const search = action({
args: { args: {
q: v.string(), q: v.string(),
limit: v.optional(v.number()), limit: v.optional(v.number()),
}, },
returns: v.any(), returns: v.any(),
handler: async (_ctx, args) => { handler: async (_ctx, args): Promise<GifSearchResponse> => {
const apiKey = process.env.TENOR_API_KEY; const apiKey = process.env.KLIPY_API_KEY || process.env.TENOR_API_KEY;
if (!apiKey) { if (!apiKey) {
console.warn("TENOR_API_KEY missing"); console.warn("KLIPY_API_KEY missing");
return { results: [] }; return { results: [] };
} }
const limit = args.limit || 8; const limit = Math.min(Math.max(args.limit ?? 24, 1), 50);
const url = `https://tenor.googleapis.com/v2/search?q=${encodeURIComponent(args.q)}&key=${apiKey}&limit=${limit}`; const query = args.q.trim().toLowerCase();
const cacheKey = `search:${query}:${limit}`;
const cached = cacheGet<GifSearchResponse>(cacheKey);
if (cached) return cached;
// Klipy customer id goes in the path; per-page caps the result
// set without a separate `limit` query param.
const url = `https://api.klipy.com/api/v1/${encodeURIComponent(apiKey)}/gifs/search?q=${encodeURIComponent(args.q)}&per_page=${limit}&page=1&locale=en`;
let response: Response;
try {
response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent":
"Brycord/1.0 (+https://brycord.com) Klipy-Client",
},
});
} catch (err) {
console.error("Klipy fetch error:", err);
return { results: [] };
}
const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
console.error("Tenor API Error:", response.statusText); console.error("Klipy API error:", response.status, response.statusText);
return { results: [] }; return { results: [] };
} }
return await response.json(); const json = (await response.json()) as any;
const items: any[] = Array.isArray(json?.data?.data) ? json.data.data : [];
const results = normalizeGifItems(items);
const payload: GifSearchResponse = { results };
cacheSet(cacheKey, payload, SEARCH_TTL_MS);
return payload;
}, },
}); });
// Get GIF categories /**
* Trending GIFs — used for the picker's home feed when no query is
* typed. Returns the same normalized shape as `search` so callers
* can use a single render path for both.
*/
export const trending = action({
args: {
limit: v.optional(v.number()),
},
returns: v.any(),
handler: async (_ctx, args): Promise<GifSearchResponse> => {
const apiKey = process.env.KLIPY_API_KEY || process.env.TENOR_API_KEY;
if (!apiKey) return { results: [] };
const limit = Math.min(Math.max(args.limit ?? 24, 1), 50);
const cacheKey = `trending:${limit}`;
const cached = cacheGet<GifSearchResponse>(cacheKey);
if (cached) return cached;
const url = `https://api.klipy.com/api/v1/${encodeURIComponent(apiKey)}/gifs/trending?per_page=${limit}&page=1&locale=en`;
let response: Response;
try {
response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent": "Brycord/1.0 (+https://brycord.com) Klipy-Client",
},
});
} catch (err) {
console.error("Klipy trending fetch error:", err);
return { results: [] };
}
if (!response.ok) {
console.error(
"Klipy trending API error:",
response.status,
response.statusText,
);
return { results: [] };
}
const json = (await response.json()) as any;
const items: any[] = Array.isArray(json?.data?.data) ? json.data.data : [];
const results = normalizeGifItems(items);
const payload: GifSearchResponse = { results };
cacheSet(cacheKey, payload, TRENDING_TTL_MS);
return payload;
},
});
/**
* Trending categories — Klipy exposes `/categories` returning a list
* of slugs the picker can show as quick-search chips. Normalized
* into `{categories: [{name, image, query}]}` so the consumer
* doesn't depend on the upstream shape.
*/
interface NormalizedCategory {
name: string;
image: string;
query: string;
}
export const categories = action({ export const categories = action({
args: {}, args: {},
returns: v.any(), returns: v.any(),
handler: async () => { handler: async (): Promise<{ categories: NormalizedCategory[] }> => {
// Return static categories (same as the JSON file in backend) const apiKey = process.env.KLIPY_API_KEY || process.env.TENOR_API_KEY;
// These are loaded from the frontend data file if (!apiKey) {
return { categories: [] }; return { categories: [] };
}
const cacheKey = `categories`;
const cached = cacheGet<{ categories: NormalizedCategory[] }>(cacheKey);
if (cached) return cached;
const url = `https://api.klipy.com/api/v1/${encodeURIComponent(apiKey)}/gifs/categories?locale=en`;
let response: Response;
try {
response = await fetch(url, {
headers: {
Accept: "application/json",
"User-Agent":
"Brycord/1.0 (+https://brycord.com) Klipy-Client",
},
});
} catch (err) {
console.error("Klipy categories fetch error:", err);
return { categories: [] };
}
if (!response.ok) {
console.error(
"Klipy categories API error:",
response.status,
response.statusText,
);
return { categories: [] };
}
const json = (await response.json()) as any;
const items: any[] = Array.isArray(json?.data?.data)
? json.data.data
: Array.isArray(json?.data)
? json.data
: [];
const categories: NormalizedCategory[] = items
.map((item) => ({
name: String(item?.name ?? item?.title ?? ""),
image: String(item?.image ?? item?.preview ?? ""),
query: String(item?.query ?? item?.search_term ?? item?.name ?? ""),
}))
.filter((c) => !!c.query);
const payload = { categories };
cacheSet(cacheKey, payload, CATEGORIES_TTL_MS);
return payload;
}, },
}); });

116
convex/links.ts Normal file
View File

@@ -0,0 +1,116 @@
"use node";
import { action } from "./_generated/server";
import { v } from "convex/values";
export const fetchPreview = action({
args: { url: v.string() },
returns: v.union(
v.object({
url: v.string(),
title: v.optional(v.string()),
description: v.optional(v.string()),
image: v.optional(v.string()),
siteName: v.optional(v.string()),
}),
v.null(),
),
handler: async (_ctx, args) => {
try {
// Validate URL + prevent loopback SSRF
const u = new URL(args.url);
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
const res = await fetch(u.toString(), {
method: "GET",
headers: {
// Discordbot User-Agent — a lot of sites (YouTube included)
// only emit og: metadata when they recognise a known crawler,
// and the generic Brycord UA gets routed to consent / interstitial
// pages that never include the tags we're after.
"User-Agent":
"Mozilla/5.0 (compatible; Discordbot/2.0; +https://discordapp.com)",
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
},
signal: controller.signal,
redirect: "follow",
});
clearTimeout(timeout);
if (!res.ok) return null;
const contentType = res.headers.get("content-type") || "";
if (!contentType.includes("text/html")) return null;
// Read up to 512 KB so giant pages don't DOS the action
const reader = res.body?.getReader();
if (!reader) return null;
const chunks: Uint8Array[] = [];
let total = 0;
const MAX = 512 * 1024;
while (total < MAX) {
const { value, done } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
total += value.length;
}
}
try { await reader.cancel(); } catch {}
const merged = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
merged.set(c, offset);
offset += c.length;
}
const html = new TextDecoder("utf-8").decode(merged);
// Parse OG / twitter / <title> tags with regex — no DOM in Node
const pick = (re: RegExp): string | undefined => {
const m = html.match(re);
return m ? decodeEntities(m[1].trim()) : undefined;
};
const title =
pick(/<meta[^>]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i) ??
pick(/<meta[^>]+name=["']twitter:title["'][^>]+content=["']([^"']+)["']/i) ??
pick(/<title[^>]*>([^<]+)<\/title>/i);
const description =
pick(/<meta[^>]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i) ??
pick(/<meta[^>]+name=["']twitter:description["'][^>]+content=["']([^"']+)["']/i) ??
pick(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']+)["']/i);
let image =
pick(/<meta[^>]+property=["']og:image(?::secure_url)?["'][^>]+content=["']([^"']+)["']/i) ??
pick(/<meta[^>]+name=["']twitter:image(?::src)?["'][^>]+content=["']([^"']+)["']/i);
const siteName =
pick(/<meta[^>]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i);
// Resolve relative image URLs
if (image) {
try {
image = new URL(image, u).toString();
} catch {}
}
if (!title && !description && !image) return null;
return { url: u.toString(), title, description, image, siteName };
} catch {
return null;
}
},
});
function decodeEntities(s: string): string {
return s
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n)));
}

View File

@@ -4,6 +4,8 @@ import { v } from "convex/values";
import { getPublicStorageUrl } from "./storageUrl"; import { getPublicStorageUrl } from "./storageUrl";
import { getRolesForUser } from "./roles"; import { getRolesForUser } from "./roles";
const DEFAULT_ROLE_COLOR = "#99aab5";
async function enrichMessage(ctx: any, msg: any, userId?: any) { async function enrichMessage(ctx: any, msg: any, userId?: any) {
const sender = await ctx.db.get(msg.senderId); const sender = await ctx.db.get(msg.senderId);
@@ -12,19 +14,112 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
avatarUrl = await getPublicStorageUrl(ctx, sender.avatarStorageId); avatarUrl = await getPublicStorageUrl(ctx, sender.avatarStorageId);
} }
// Highest-position role with a non-default colour — mirrors how
// Discord tints usernames in chat. The Owner role is deliberately
// skipped so owners fall through to the next non-default role's
// colour (per product decision: we don't want every owner's name
// to glow in the bootstrap pink). Default grey (`#99aab5`) is
// treated as "no colour" so regular users fall back to
// `--text-primary`.
let senderRoleColor: string | null = null;
try {
const senderRoleDocs = await ctx.db
.query("userRoles")
.withIndex("by_user", (q: any) => q.eq("userId", msg.senderId))
.collect();
let best: { position: number; color: string } | null = null;
for (const ur of senderRoleDocs) {
const role = await ctx.db.get(ur.roleId);
if (!role) continue;
if ((role as any).name === "Owner") continue;
const color = (role as any).color as string | undefined;
const position = (role as any).position ?? 0;
if (!color || color.toLowerCase() === DEFAULT_ROLE_COLOR) continue;
if (!best || position > best.position) {
best = { position, color };
}
}
senderRoleColor = best?.color ?? null;
} catch {
senderRoleColor = null;
}
const reactionDocs = await ctx.db const reactionDocs = await ctx.db
.query("messageReactions") .query("messageReactions")
.withIndex("by_message", (q: any) => q.eq("messageId", msg._id)) .withIndex("by_message", (q: any) => q.eq("messageId", msg._id))
.collect(); .collect();
const reactions: Record<string, { count: number; me: boolean }> = {}; // Accumulate into a Map so we don't use emoji surrogates as object
// field names — Convex's return-value validator rejects non-ASCII
// field names, which is what caused "Field name 👍 has invalid
// character" errors on any channel with a unicode reaction.
//
// For each emoji we also collect a capped list of reactor profiles
// (up to MAX_REACTION_USERS) so the client can render the hover
// tooltip + the full reactions modal without a second round-trip.
// Reactor profiles are cached per-message so the same user picked
// for multiple emojis only costs one db lookup.
const MAX_REACTION_USERS = 100;
const profileCache = new Map<
string,
{ userId: string; username: string; displayName: string | null }
>();
const resolveProfile = async (reactorUserId: any) => {
const key = String(reactorUserId);
const cached = profileCache.get(key);
if (cached) return cached;
const profile = await ctx.db.get(reactorUserId);
const shaped = {
userId: key,
username: profile?.username || "Unknown",
displayName: profile?.displayName || null,
};
profileCache.set(key, shaped);
return shaped;
};
interface ReactionAccumulator {
count: number;
me: boolean;
users: Array<{
userId: string;
username: string;
displayName: string | null;
}>;
}
const reactionMap = new Map<string, ReactionAccumulator>();
for (const r of reactionDocs) { for (const r of reactionDocs) {
const entry = (reactions[r.emoji] ??= { count: 0, me: false }); let entry = reactionMap.get(r.emoji);
if (!entry) {
entry = { count: 0, me: false, users: [] };
reactionMap.set(r.emoji, entry);
}
entry.count++; entry.count++;
if (userId && r.userId === userId) { if (userId && r.userId === userId) {
entry.me = true; entry.me = true;
} }
if (entry.users.length < MAX_REACTION_USERS) {
entry.users.push(await resolveProfile(r.userId));
} }
}
const reactions: Array<{
emoji: string;
count: number;
me: boolean;
users: Array<{
userId: string;
username: string;
displayName: string | null;
}>;
}> = [];
reactionMap.forEach((info, emoji) => {
reactions.push({
emoji,
count: info.count,
me: info.me,
users: info.users,
});
});
let replyToUsername: string | null = null; let replyToUsername: string | null = null;
let replyToDisplayName: string | null = null; let replyToDisplayName: string | null = null;
@@ -58,7 +153,8 @@ async function enrichMessage(ctx: any, msg: any, userId?: any) {
displayName: sender?.displayName || null, displayName: sender?.displayName || null,
public_signing_key: sender?.publicSigningKey || "", public_signing_key: sender?.publicSigningKey || "",
avatarUrl, avatarUrl,
reactions: Object.keys(reactions).length > 0 ? reactions : null, senderRoleColor,
reactions: reactions.length > 0 ? reactions : null,
replyToId: msg.replyTo || null, replyToId: msg.replyTo || null,
replyToUsername, replyToUsername,
replyToDisplayName, replyToDisplayName,
@@ -92,6 +188,43 @@ export const list = query({
}, },
}); });
/**
* Pull the latest N messages from each of the given channel IDs in a
* single round-trip. Used by SearchPanel to scan across every channel
* the user can read without spinning up N independent useQuery hooks
* (React would error on hook-count drift between renders).
*
* `perChannelLimit` is clamped to 200 server-side to keep payload
* bounded even if the client over-asks.
*/
export const searchScan = query({
args: {
channelIds: v.array(v.id("channels")),
perChannelLimit: v.optional(v.number()),
userId: v.optional(v.id("userProfiles")),
},
returns: v.any(),
handler: async (ctx, args) => {
const limit = Math.min(Math.max(args.perChannelLimit ?? 100, 1), 200);
const out: Array<{
channelId: string;
messages: any[];
}> = [];
for (const channelId of args.channelIds) {
const rows = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.take(limit);
const enriched = await Promise.all(
rows.map((msg) => enrichMessage(ctx, msg, args.userId)),
);
out.push({ channelId, messages: enriched });
}
return out;
},
});
export const send = mutation({ export const send = mutation({
args: { args: {
channelId: v.id("channels"), channelId: v.id("channels"),

358
convex/polls.ts Normal file
View File

@@ -0,0 +1,358 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
const pollOptionValidator = v.object({
id: v.string(),
text: v.string(),
});
const pollDocValidator = v.object({
_id: v.id("polls"),
_creationTime: v.number(),
channelId: v.id("channels"),
createdBy: v.id("userProfiles"),
question: v.string(),
options: v.array(pollOptionValidator),
allowMultiple: v.boolean(),
disclosed: v.boolean(),
closed: v.boolean(),
closesAt: v.optional(v.number()),
createdAt: v.number(),
});
const pollReactionValidator = v.object({
emoji: v.string(),
count: v.number(),
me: v.boolean(),
});
const pollResultsValidator = v.object({
poll: pollDocValidator,
totals: v.record(v.string(), v.number()),
totalVotes: v.number(),
myVote: v.union(v.array(v.string()), v.null()),
// Aggregated as an array — arrays keep emoji out of object field
// names (Convex's return-value validator rejects non-ASCII fields,
// same issue we hit on messages.list).
reactions: v.array(pollReactionValidator),
});
export const create = mutation({
args: {
channelId: v.id("channels"),
createdBy: v.id("userProfiles"),
question: v.string(),
options: v.array(pollOptionValidator),
allowMultiple: v.boolean(),
disclosed: v.boolean(),
closesAt: v.optional(v.number()),
},
returns: v.id("polls"),
handler: async (ctx, args) => {
const question = args.question.trim();
if (question.length === 0) {
throw new Error("Poll question cannot be empty");
}
if (question.length > 500) {
throw new Error("Poll question is too long");
}
const cleanOptions = args.options
.map((o) => ({ id: o.id, text: o.text.trim() }))
.filter((o) => o.text.length > 0);
if (cleanOptions.length < 2) {
throw new Error("Polls need at least 2 options");
}
if (cleanOptions.length > 20) {
throw new Error("Polls support at most 20 options");
}
// Enforce unique option ids so vote diffing is unambiguous.
const seen = new Set<string>();
for (const opt of cleanOptions) {
if (seen.has(opt.id)) {
throw new Error("Duplicate option id");
}
seen.add(opt.id);
}
const pollId = await ctx.db.insert("polls", {
channelId: args.channelId,
createdBy: args.createdBy,
question,
options: cleanOptions,
allowMultiple: args.allowMultiple,
disclosed: args.disclosed,
closed: false,
closesAt: args.closesAt,
createdAt: Date.now(),
});
return pollId;
},
});
export const vote = mutation({
args: {
pollId: v.id("polls"),
userId: v.id("userProfiles"),
optionIds: v.array(v.string()),
},
returns: v.null(),
handler: async (ctx, args) => {
const poll = await ctx.db.get(args.pollId);
if (!poll) throw new Error("Poll not found");
if (poll.closed) throw new Error("Poll is closed");
if (poll.closesAt && poll.closesAt < Date.now()) {
throw new Error("Poll has expired");
}
// Validate the submitted option ids exist on the poll.
const validIds = new Set(poll.options.map((o) => o.id));
for (const id of args.optionIds) {
if (!validIds.has(id)) {
throw new Error(`Unknown option id "${id}"`);
}
}
if (args.optionIds.length === 0) {
throw new Error("Select at least one option");
}
if (!poll.allowMultiple && args.optionIds.length > 1) {
throw new Error("This poll only allows one answer");
}
// Upsert: one row per (pollId, userId).
const existing = await ctx.db
.query("pollVotes")
.withIndex("by_poll_and_user", (q) =>
q.eq("pollId", args.pollId).eq("userId", args.userId),
)
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
optionIds: args.optionIds,
votedAt: Date.now(),
});
} else {
await ctx.db.insert("pollVotes", {
pollId: args.pollId,
userId: args.userId,
optionIds: args.optionIds,
votedAt: Date.now(),
});
}
return null;
},
});
export const clearVote = mutation({
args: {
pollId: v.id("polls"),
userId: v.id("userProfiles"),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("pollVotes")
.withIndex("by_poll_and_user", (q) =>
q.eq("pollId", args.pollId).eq("userId", args.userId),
)
.unique();
if (existing) {
await ctx.db.delete(existing._id);
}
return null;
},
});
export const close = mutation({
args: {
pollId: v.id("polls"),
userId: v.id("userProfiles"),
},
returns: v.null(),
handler: async (ctx, args) => {
const poll = await ctx.db.get(args.pollId);
if (!poll) throw new Error("Poll not found");
if (poll.createdBy !== args.userId) {
throw new Error("Only the poll creator can close it");
}
if (poll.closed) return null;
await ctx.db.patch(args.pollId, { closed: true });
return null;
},
});
export const remove = mutation({
args: {
pollId: v.id("polls"),
userId: v.id("userProfiles"),
},
returns: v.null(),
handler: async (ctx, args) => {
const poll = await ctx.db.get(args.pollId);
if (!poll) return null;
if (poll.createdBy !== args.userId) {
throw new Error("Only the poll creator can delete it");
}
const votes = await ctx.db
.query("pollVotes")
.withIndex("by_poll", (q) => q.eq("pollId", args.pollId))
.collect();
await Promise.all(votes.map((v) => ctx.db.delete(v._id)));
const reactions = await ctx.db
.query("pollReactions")
.withIndex("by_poll", (q) => q.eq("pollId", args.pollId))
.collect();
await Promise.all(reactions.map((r) => ctx.db.delete(r._id)));
await ctx.db.delete(args.pollId);
return null;
},
});
export const listByChannel = query({
args: {
channelId: v.id("channels"),
},
returns: v.array(pollDocValidator),
handler: async (ctx, args) => {
const polls = await ctx.db
.query("polls")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.collect();
return polls;
},
});
export const get = query({
args: {
pollId: v.id("polls"),
userId: v.optional(v.id("userProfiles")),
},
returns: v.union(pollResultsValidator, v.null()),
handler: async (ctx, args) => {
const poll = await ctx.db.get(args.pollId);
if (!poll) return null;
const votes = await ctx.db
.query("pollVotes")
.withIndex("by_poll", (q) => q.eq("pollId", args.pollId))
.collect();
// Tally totals per option. Voters that picked multiple options
// each contribute a +1 to every option they picked.
const totals: Record<string, number> = {};
for (const opt of poll.options) {
totals[opt.id] = 0;
}
for (const vote of votes) {
for (const id of vote.optionIds) {
if (totals[id] !== undefined) {
totals[id] += 1;
}
}
}
let myVote: string[] | null = null;
if (args.userId) {
const mine = votes.find((v) => v.userId === args.userId);
myVote = mine ? mine.optionIds : null;
}
// Aggregate reactions into an array of {emoji, count, me} rows.
// Using a Map avoids putting unicode surrogates into object field
// names, which Convex's return-value validator would reject.
const reactionDocs = await ctx.db
.query("pollReactions")
.withIndex("by_poll", (q) => q.eq("pollId", args.pollId))
.collect();
const reactionMap = new Map<string, { count: number; me: boolean }>();
for (const r of reactionDocs) {
let entry = reactionMap.get(r.emoji);
if (!entry) {
entry = { count: 0, me: false };
reactionMap.set(r.emoji, entry);
}
entry.count++;
if (args.userId && r.userId === args.userId) {
entry.me = true;
}
}
const reactions: Array<{ emoji: string; count: number; me: boolean }> = [];
reactionMap.forEach((info, emoji) => {
reactions.push({ emoji, count: info.count, me: info.me });
});
return {
poll,
totals,
totalVotes: votes.length,
myVote,
reactions,
};
},
});
/**
* Toggle-add a reaction on a poll. Idempotent per (pollId, userId,
* emoji) — re-adding the same reaction is a no-op.
*/
export const addReaction = mutation({
args: {
pollId: v.id("polls"),
userId: v.id("userProfiles"),
emoji: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("pollReactions")
.withIndex("by_poll_user_emoji", (q) =>
q
.eq("pollId", args.pollId)
.eq("userId", args.userId)
.eq("emoji", args.emoji),
)
.unique();
if (!existing) {
await ctx.db.insert("pollReactions", {
pollId: args.pollId,
userId: args.userId,
emoji: args.emoji,
});
}
return null;
},
});
/**
* Remove a reaction on a poll. No-op if the user hasn't reacted
* with that emoji.
*/
export const removeReaction = mutation({
args: {
pollId: v.id("polls"),
userId: v.id("userProfiles"),
emoji: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("pollReactions")
.withIndex("by_poll_user_emoji", (q) =>
q
.eq("pollId", args.pollId)
.eq("userId", args.userId)
.eq("emoji", args.emoji),
)
.unique();
if (existing) {
await ctx.db.delete(existing._id);
}
return null;
},
});

View File

@@ -77,6 +77,12 @@ export const update = mutation({
handler: async (ctx, args) => { handler: async (ctx, args) => {
const role = await ctx.db.get(args.id); const role = await ctx.db.get(args.id);
if (!role) throw new Error("Role not found"); if (!role) throw new Error("Role not found");
// Owner is frozen — we can't let a client rename/recolour it
// or strip its permissions, since that would defeat the
// assign/unassign guards in one shot.
if (role.name === "Owner") {
throw new Error("The Owner role can't be edited.");
}
const { id, ...fields } = args; const { id, ...fields } = args;
const updates: Record<string, unknown> = {}; const updates: Record<string, unknown> = {};
@@ -92,6 +98,33 @@ export const update = mutation({
}, },
}); });
/**
* Batch-reorder roles by passing a list of `{id, position}` pairs.
* Used by the Roles & Permissions settings surface after a drag-
* drop drop. Owner and @everyone are refused so their positions
* stay pinned at the natural extremes of the list.
*/
export const reorder = mutation({
args: {
updates: v.array(
v.object({
id: v.id("roles"),
position: v.number(),
}),
),
},
returns: v.null(),
handler: async (ctx, args) => {
for (const u of args.updates) {
const role = await ctx.db.get(u.id);
if (!role) continue;
if (role.name === "Owner" || role.name === "@everyone") continue;
await ctx.db.patch(u.id, { position: u.position });
}
return null;
},
});
// Delete role // Delete role
export const remove = mutation({ export const remove = mutation({
args: { id: v.id("roles") }, args: { id: v.id("roles") },
@@ -99,6 +132,12 @@ export const remove = mutation({
handler: async (ctx, args) => { handler: async (ctx, args) => {
const role = await ctx.db.get(args.id); const role = await ctx.db.get(args.id);
if (!role) throw new Error("Role not found"); if (!role) throw new Error("Role not found");
if (role.name === "Owner") {
throw new Error("The Owner role can't be deleted.");
}
if (role.name === "@everyone") {
throw new Error("The @everyone role can't be deleted.");
}
const assignments = await ctx.db const assignments = await ctx.db
.query("userRoles") .query("userRoles")
@@ -139,6 +178,16 @@ export const assign = mutation({
}, },
returns: v.object({ success: v.boolean() }), returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Owner is immutable — it's granted once during first-user
// bootstrap (convex/auth.ts) and the app never reassigns it. The
// UI already hides it from the ManageRoles checkbox list, but we
// also reject it server-side so a crafted client can't sneak it
// onto another user.
const role = await ctx.db.get(args.roleId);
if (role?.name === "Owner") {
throw new Error("The Owner role can't be assigned.");
}
const existing = await ctx.db const existing = await ctx.db
.query("userRoles") .query("userRoles")
.withIndex("by_user_and_role", (q) => .withIndex("by_user_and_role", (q) =>
@@ -165,6 +214,13 @@ export const unassign = mutation({
}, },
returns: v.object({ success: v.boolean() }), returns: v.object({ success: v.boolean() }),
handler: async (ctx, args) => { handler: async (ctx, args) => {
// Owner is immutable — see `assign` above. Removing it would
// leave the server without a permission-bearing admin.
const role = await ctx.db.get(args.roleId);
if (role?.name === "Owner") {
throw new Error("The Owner role can't be removed.");
}
const existing = await ctx.db const existing = await ctx.db
.query("userRoles") .query("userRoles")
.withIndex("by_user_and_role", (q) => .withIndex("by_user_and_role", (q) =>

113
convex/savedMedia.ts Normal file
View File

@@ -0,0 +1,113 @@
import { mutation, query } from "./_generated/server";
import { v } from "convex/values";
const savedMediaValidator = v.object({
_id: v.id("savedMedia"),
_creationTime: v.number(),
userId: v.id("userProfiles"),
url: v.string(),
kind: v.string(),
filename: v.string(),
mimeType: v.optional(v.string()),
width: v.optional(v.number()),
height: v.optional(v.number()),
size: v.optional(v.number()),
encryptionKey: v.string(),
encryptionIv: v.string(),
savedAt: v.number(),
});
/**
* Save (favorite) an attachment to the user's media library. Idempotent
* per (userId, url) — re-saving the same media just updates the
* existing row's filename / metadata in case it changed.
*/
export const save = mutation({
args: {
userId: v.id("userProfiles"),
url: v.string(),
kind: v.string(),
filename: v.string(),
mimeType: v.optional(v.string()),
width: v.optional(v.number()),
height: v.optional(v.number()),
size: v.optional(v.number()),
encryptionKey: v.string(),
encryptionIv: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("savedMedia")
.withIndex("by_user_and_url", (q) =>
q.eq("userId", args.userId).eq("url", args.url),
)
.unique();
if (existing) {
await ctx.db.patch(existing._id, {
kind: args.kind,
filename: args.filename,
mimeType: args.mimeType,
width: args.width,
height: args.height,
size: args.size,
encryptionKey: args.encryptionKey,
encryptionIv: args.encryptionIv,
});
return null;
}
await ctx.db.insert("savedMedia", {
userId: args.userId,
url: args.url,
kind: args.kind,
filename: args.filename,
mimeType: args.mimeType,
width: args.width,
height: args.height,
size: args.size,
encryptionKey: args.encryptionKey,
encryptionIv: args.encryptionIv,
savedAt: Date.now(),
});
return null;
},
});
/**
* Remove a saved-media entry by (userId, url). No-op if not present.
*/
export const remove = mutation({
args: {
userId: v.id("userProfiles"),
url: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const existing = await ctx.db
.query("savedMedia")
.withIndex("by_user_and_url", (q) =>
q.eq("userId", args.userId).eq("url", args.url),
)
.unique();
if (existing) await ctx.db.delete(existing._id);
return null;
},
});
/**
* List the user's saved media in reverse-chron order (newest first).
*/
export const list = query({
args: {
userId: v.id("userProfiles"),
},
returns: v.array(savedMediaValidator),
handler: async (ctx, args) => {
const items = await ctx.db
.query("savedMedia")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.order("desc")
.collect();
return items;
},
});

View File

@@ -17,6 +17,7 @@ export default defineSchema({
aboutMe: v.optional(v.string()), aboutMe: v.optional(v.string()),
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()),
}).index("by_username", ["username"]), }).index("by_username", ["username"]),
categories: defineTable({ categories: defineTable({
@@ -142,8 +143,71 @@ export default defineSchema({
name: v.string(), name: v.string(),
storageId: v.id("_storage"), storageId: v.id("_storage"),
uploadedBy: v.id("userProfiles"), uploadedBy: v.id("userProfiles"),
// `true` for animated (GIF / APNG) uploads so the settings UI
// can split Static vs Animated in separate sections. Optional
// so existing rows without the flag still validate — they
// surface as static in the UI by default.
animated: v.optional(v.boolean()),
createdAt: v.number(), createdAt: v.number(),
}).index("by_name", ["name"]) }).index("by_name", ["name"])
.index("by_uploader", ["uploadedBy"]), .index("by_uploader", ["uploadedBy"]),
polls: defineTable({
channelId: v.id("channels"),
createdBy: v.id("userProfiles"),
question: v.string(),
options: v.array(
v.object({
id: v.string(),
text: v.string(),
}),
),
allowMultiple: v.boolean(),
disclosed: v.boolean(),
closed: v.boolean(),
closesAt: v.optional(v.number()),
createdAt: v.number(),
})
.index("by_channel", ["channelId"])
.index("by_creator", ["createdBy"]),
pollVotes: defineTable({
pollId: v.id("polls"),
userId: v.id("userProfiles"),
optionIds: v.array(v.string()),
votedAt: v.number(),
})
.index("by_poll", ["pollId"])
.index("by_poll_and_user", ["pollId", "userId"]),
pollReactions: defineTable({
pollId: v.id("polls"),
userId: v.id("userProfiles"),
emoji: v.string(),
})
.index("by_poll", ["pollId"])
.index("by_poll_user_emoji", ["pollId", "userId", "emoji"])
.index("by_user", ["userId"]),
savedMedia: defineTable({
userId: v.id("userProfiles"),
// Convex storage URL — also the dedupe key for a single user.
url: v.string(),
kind: v.string(), // 'image' | 'video' | 'audio'
filename: v.string(),
mimeType: v.optional(v.string()),
width: v.optional(v.number()),
height: v.optional(v.number()),
size: v.optional(v.number()),
// Re-post path: keep the per-file AES key + iv so the same
// attachment metadata can be embedded in a future message
// without re-uploading. The file stays encrypted in storage —
// saving just bookmarks the metadata.
encryptionKey: v.string(),
encryptionIv: v.string(),
savedAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_user_and_url", ["userId", "url"]),
}); });

View File

@@ -2,9 +2,24 @@
import { action } from "./_generated/server"; import { action } from "./_generated/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { AccessToken } from "livekit-server-sdk"; import { AccessToken, RoomServiceClient } from "livekit-server-sdk";
// Generate LiveKit token for voice channel /**
* Generate a LiveKit join token for a voice channel.
*
* LiveKit servers run with `room.auto_create: false` reject joins for
* rooms that don't already exist — the client gets a 404 "requested
* room does not exist" back from the /rtc/v1/validate endpoint. To
* make this deployment-agnostic, we pre-create the room via the
* LiveKit Server SDK before minting the token. When auto-create is
* enabled the `createRoom` call is idempotent (409 Conflict is
* swallowed silently), so the same code path works on both
* configurations.
*
* Requires `LIVEKIT_URL` (or the frontend's `VITE_LIVEKIT_URL` as a
* fallback) in the Convex environment so the RoomServiceClient
* can talk to the LiveKit API.
*/
export const getToken = action({ export const getToken = action({
args: { args: {
channelId: v.string(), channelId: v.string(),
@@ -15,6 +30,46 @@ export const getToken = action({
handler: async (_ctx, args) => { handler: async (_ctx, args) => {
const apiKey = process.env.LIVEKIT_API_KEY || "devkey"; const apiKey = process.env.LIVEKIT_API_KEY || "devkey";
const apiSecret = process.env.LIVEKIT_API_SECRET || "secret"; const apiSecret = process.env.LIVEKIT_API_SECRET || "secret";
const livekitUrl =
process.env.LIVEKIT_URL || process.env.VITE_LIVEKIT_URL || "";
// Ensure the room exists. The LiveKit API accepts `http(s)` URLs
// for the management endpoint, but the frontend connect URL is a
// `wss://` — swap the scheme when needed.
if (livekitUrl) {
const httpUrl = livekitUrl
.replace(/^wss:\/\//i, "https://")
.replace(/^ws:\/\//i, "http://");
try {
const roomService = new RoomServiceClient(httpUrl, apiKey, apiSecret);
await roomService.createRoom({
name: args.channelId,
// Empty rooms auto-destroy after 5 minutes with no participants,
// matching LiveKit's own default so stale rooms from a crashed
// client don't pile up forever.
emptyTimeout: 5 * 60,
// 50 participants is plenty for a voice channel in this
// single-server deployment and keeps any runaway join loop
// from hitting the global limit.
maxParticipants: 50,
});
} catch (err: any) {
// 409 / "already exists" is expected when a room has already
// been created by an earlier join — swallow it and continue.
const message = String(err?.message ?? err ?? "");
const status = err?.status ?? err?.statusCode;
const alreadyExists =
status === 409 ||
/already exists/i.test(message) ||
/AlreadyExists/i.test(message);
if (!alreadyExists) {
// Non-fatal: log and fall through to token generation. If the
// real issue was misconfiguration the client will surface the
// 404 it already does.
console.warn("LiveKit createRoom failed:", message);
}
}
}
const at = new AccessToken(apiKey, apiSecret, { const at = new AccessToken(apiKey, apiSecret, {
identity: args.userId, identity: args.userId,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,160 +0,0 @@
.loadingWrapper__5a143 {
align-items: center;
border-radius: 2px;
display: flex;
flex-direction: row;
height: 16px;
justify-content: center;
margin: 2px 0;
padding: 8px 4px
}
.list_c47777 {
max-height: 500px
}
.actionContainer_bc4513 {
background-color: var(--background-mod-normal);
border-radius: 80px;
flex-direction: row;
padding-inline:6px 8px;padding-bottom: 4px;
padding-top: 4px
}
.actionContainer_bc4513,.actionIconContainer_bc4513 {
align-items: center;
display: flex;
justify-content: center
}
.actionIconContainer_bc4513 {
height: 16px;
margin-inline-end:4px;width: 16px
}
.actionIcon_bc4513 {
color: var(--text-muted)
}
.actionTextContainer_bc4513 {
flex: 1
}
.actionTextHeader_bc4513 {
word-wrap: normal;
text-transform: lowercase
}
.actionTextHelper_bc4513 {
margin-inline-start:4px;text-transform: lowercase
}
.emoji_ab6c65 {
-o-object-fit: contain;
object-fit: contain
}
.pro__30cbe {
text-transform: uppercase
}
.tip__30cbe {
line-height: 16px;
opacity: 1
}
.block__30cbe .pro__30cbe,.block__30cbe .tip__30cbe,.tip__30cbe {
font-size: 14px
}
.inline__30cbe .pro__30cbe,.inline__30cbe .tip__30cbe {
display: inline;
font-size: 12px
}
.inline__30cbe .pro__30cbe {
margin-inline-end:3px}
.enable-forced-colors .tip__30cbe {
opacity: 1
}
.spacing_fd14e0 {
margin-bottom: 20px
}
.spacingTop_fd14e0 {
margin-top: 20px
}
.message_fd14e0 {
background-color: var(--background-base-low);
border-radius: 3px;
box-shadow: var(--legacy-elevation-border),var(--legacy-elevation-high);
overflow: hidden;
padding-bottom: 10px;
padding-top: 10px;
pointer-events: none;
position: relative
}
.closeButton_fd14e0 {
justify-content: flex-end
}
.wrapper_f563df {
display: grid;
gap: 4px;
grid-template-columns: repeat(4,1fr);
grid-template-rows: 1fr;
justify-items: center;
padding: 8px
}
.button_f563df,.wrapper_f563df {
align-items: center
}
.button_f563df {
background-color: var(--background-mod-subtle);
border-radius: 8px;
cursor: pointer;
display: flex;
flex: 0 0 auto;
height: 44px;
justify-content: center;
padding: 0;
width: 44px
}
.button_f563df:hover {
background-color: var(--background-mod-strong)
}
.button_f563df:active {
background-color: var(--background-mod-muted)
}
.button_f563df:hover {
background-color: var(--background-mod-normal)
}
.keyboard-mode .button_f563df.focused_f563df {
background-color: var(--background-mod-normal);
box-shadow: 0 0 0 2px var(--blue-345)
}
.icon_f563df {
display: block;
height: 20px;
-o-object-fit: contain;
object-fit: contain;
width: 20px
}
.flagIcon__45b6e {
height: 12px;
width: 16px
}
/*# sourceMappingURL=59c6d21704b78874.css.map*/

View File

@@ -1,13 +0,0 @@
.highlight-mana-components [data-mana-component] {
box-shadow: 0 0 6px 2px var(--pink-51),0 0 8px 4px var(--opacity-blurple-80)
}
.highlight-mana-components [data-mana-component=text-area],.highlight-mana-components [data-mana-component=text-input] {
box-shadow: 0 0 6px 2px var(--pink-51),0 0 8px 4px var(--opacity-blurple-80)
}
.highlight-mana-buttons [data-mana-component=button] {
box-shadow: 0 0 6px 2px var(--pink-51),0 0 8px 4px var(--opacity-blurple-80)
}
/*# sourceMappingURL=6f7713d5b10d7cb3.css.map*/

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,212 +0,0 @@
.DraftEditor-editorContainer,.DraftEditor-root,.public-DraftEditor-content {
height: inherit;
text-align: initial
}
.public-DraftEditor-content[contenteditable=true] {
-webkit-user-modify: read-write-plaintext-only
}
.DraftEditor-root {
position: relative
}
.DraftEditor-editorContainer {
background-color: hsla(0,0%,100%,0);
border-left: .1px solid transparent;
position: relative;
z-index: 1
}
.public-DraftEditor-block {
position: relative
}
.DraftEditor-alignLeft .public-DraftStyleDefault-block {
text-align: left
}
.DraftEditor-alignLeft .public-DraftEditorPlaceholder-root {
left: 0;
text-align: left
}
.DraftEditor-alignCenter .public-DraftStyleDefault-block {
text-align: center
}
.DraftEditor-alignCenter .public-DraftEditorPlaceholder-root {
margin: 0 auto;
text-align: center;
width: 100%
}
.DraftEditor-alignRight .public-DraftStyleDefault-block {
text-align: right
}
.DraftEditor-alignRight .public-DraftEditorPlaceholder-root {
right: 0;
text-align: right
}
.public-DraftEditorPlaceholder-root {
color: #9197a3;
position: absolute;
z-index: 1
}
.public-DraftEditorPlaceholder-hasFocus {
color: #bdc1c9
}
.DraftEditorPlaceholder-hidden {
display: none
}
.public-DraftStyleDefault-block {
position: relative;
white-space: pre-wrap
}
.public-DraftStyleDefault-ltr {
direction: ltr;
text-align: left
}
.public-DraftStyleDefault-rtl {
direction: rtl;
text-align: right
}
.public-DraftStyleDefault-listLTR {
direction: ltr
}
.public-DraftStyleDefault-listRTL {
direction: rtl
}
.public-DraftStyleDefault-ol,.public-DraftStyleDefault-ul {
margin: 16px 0;
padding: 0
}
.public-DraftStyleDefault-depth0.public-DraftStyleDefault-listLTR {
margin-left: 1.5em
}
.public-DraftStyleDefault-depth0.public-DraftStyleDefault-listRTL {
margin-right: 1.5em
}
.public-DraftStyleDefault-depth1.public-DraftStyleDefault-listLTR {
margin-left: 3em
}
.public-DraftStyleDefault-depth1.public-DraftStyleDefault-listRTL {
margin-right: 3em
}
.public-DraftStyleDefault-depth2.public-DraftStyleDefault-listLTR {
margin-left: 4.5em
}
.public-DraftStyleDefault-depth2.public-DraftStyleDefault-listRTL {
margin-right: 4.5em
}
.public-DraftStyleDefault-depth3.public-DraftStyleDefault-listLTR {
margin-left: 6em
}
.public-DraftStyleDefault-depth3.public-DraftStyleDefault-listRTL {
margin-right: 6em
}
.public-DraftStyleDefault-depth4.public-DraftStyleDefault-listLTR {
margin-left: 7.5em
}
.public-DraftStyleDefault-depth4.public-DraftStyleDefault-listRTL {
margin-right: 7.5em
}
.public-DraftStyleDefault-unorderedListItem {
list-style-type: square;
position: relative
}
.public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth0 {
list-style-type: disc
}
.public-DraftStyleDefault-unorderedListItem.public-DraftStyleDefault-depth1 {
list-style-type: circle
}
.public-DraftStyleDefault-orderedListItem {
list-style-type: none;
position: relative
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listLTR:before {
left: -36px;
position: absolute;
text-align: right;
width: 30px
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-listRTL:before {
position: absolute;
right: -36px;
text-align: left;
width: 30px
}
.public-DraftStyleDefault-orderedListItem:before {
content: counter(ol0) ". ";
counter-increment: ol0
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth1:before {
content: counter(ol1,lower-alpha) ". ";
counter-increment: ol1
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth2:before {
content: counter(ol2,lower-roman) ". ";
counter-increment: ol2
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth3:before {
content: counter(ol3) ". ";
counter-increment: ol3
}
.public-DraftStyleDefault-orderedListItem.public-DraftStyleDefault-depth4:before {
content: counter(ol4,lower-alpha) ". ";
counter-increment: ol4
}
.public-DraftStyleDefault-depth0.public-DraftStyleDefault-reset {
counter-reset: ol0
}
.public-DraftStyleDefault-depth1.public-DraftStyleDefault-reset {
counter-reset: ol1
}
.public-DraftStyleDefault-depth2.public-DraftStyleDefault-reset {
counter-reset: ol2
}
.public-DraftStyleDefault-depth3.public-DraftStyleDefault-reset {
counter-reset: ol3
}
.public-DraftStyleDefault-depth4.public-DraftStyleDefault-reset {
counter-reset: ol4
}
/*# sourceMappingURL=9296efc0128dcaf8.css.map*/

File diff suppressed because it is too large Load Diff

View File

@@ -1,65 +0,0 @@
.activeWrapper__452c3,.wrapper__452c3 {
height: 100%;
inset-inline-start: 0;
position: absolute;
top: 0;
width: 100%;
z-index: 1002
}
.videoWrapper__452c3,.wrapper__452c3 {
pointer-events: none
}
.videoWrapper__452c3 {
height: 100%;
inset-inline-start: 50%;
position: absolute;
top: 50%;
transform: translate(-50%,-50%);
width: auto;
z-index: 200
}
@media (min-aspect-ratio: 45/32) {
.videoWrapper__452c3 {
height:auto;
width: 100%
}
}
.videoWrapperForHelper__452c3 {
inset-inline-start: 0;
top: 0;
z-index: 200
}
.gadientHighlight__452c3,.videoWrapperForHelper__452c3 {
height: 100%;
pointer-events: none;
position: absolute;
width: 100%
}
.gadientHighlight__452c3 {
background-image: linear-gradient(90deg,var(--premium-tier-2-purple-for-gradients) 0,var(--premium-tier-2-purple-for-gradients-2) 50%,var(--premium-tier-2-pink-for-gradients) 100%)
}
.swipeWrapper__452c3 {
pointer-events: none;
width: 100%
}
.swipe__452c3,.swipeWrapper__452c3 {
height: 100%;
position: absolute
}
.swipe__452c3 {
inset-inline-end: 0;
opacity: .1;
top: 0;
width: auto
}
/*# sourceMappingURL=a06f142ee55db4f5.css.map*/

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

176
package-lock.json generated
View File

@@ -17,7 +17,7 @@
}, },
"apps/android": { "apps/android": {
"name": "@discord-clone/android", "name": "@discord-clone/android",
"version": "1.0.36", "version": "1.0.40",
"dependencies": { "dependencies": {
"@capacitor/android": "^6.0.0", "@capacitor/android": "^6.0.0",
"@capacitor/app": "^6.0.0", "@capacitor/app": "^6.0.0",
@@ -29,7 +29,7 @@
}, },
"apps/electron": { "apps/electron": {
"name": "@discord-clone/electron", "name": "@discord-clone/electron",
"version": "1.0.36", "version": "1.0.40",
"dependencies": { "dependencies": {
"@discord-clone/shared": "*", "@discord-clone/shared": "*",
"electron-log": "^5.4.3", "electron-log": "^5.4.3",
@@ -46,7 +46,7 @@
}, },
"apps/web": { "apps/web": {
"name": "@discord-clone/web", "name": "@discord-clone/web",
"version": "1.0.36", "version": "1.0.40",
"dependencies": { "dependencies": {
"@discord-clone/platform-web": "*", "@discord-clone/platform-web": "*",
"@discord-clone/shared": "*" "@discord-clone/shared": "*"
@@ -1771,6 +1771,10 @@
"resolved": "apps/android", "resolved": "apps/android",
"link": true "link": true
}, },
"node_modules/@discord-clone/constants": {
"resolved": "packages/constants",
"link": true
},
"node_modules/@discord-clone/electron": { "node_modules/@discord-clone/electron": {
"resolved": "apps/electron", "resolved": "apps/electron",
"link": true "link": true
@@ -1783,6 +1787,10 @@
"resolved": "packages/shared", "resolved": "packages/shared",
"link": true "link": true
}, },
"node_modules/@discord-clone/ui": {
"resolved": "packages/ui",
"link": true
},
"node_modules/@discord-clone/web": { "node_modules/@discord-clone/web": {
"resolved": "apps/web", "resolved": "apps/web",
"link": true "link": true
@@ -2559,12 +2567,12 @@
} }
}, },
"node_modules/@floating-ui/core": { "node_modules/@floating-ui/core": {
"version": "1.7.4", "version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==", "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@floating-ui/utils": "^0.2.10" "@floating-ui/utils": "^0.2.11"
} }
}, },
"node_modules/@floating-ui/dom": { "node_modules/@floating-ui/dom": {
@@ -2577,10 +2585,48 @@
"@floating-ui/utils": "^0.2.10" "@floating-ui/utils": "^0.2.10"
} }
}, },
"node_modules/@floating-ui/react": {
"version": "0.26.28",
"resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
"integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
"license": "MIT",
"dependencies": {
"@floating-ui/react-dom": "^2.1.2",
"@floating-ui/utils": "^0.2.8",
"tabbable": "^6.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz",
"integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==",
"license": "MIT",
"dependencies": {
"@floating-ui/dom": "^1.7.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@floating-ui/react-dom/node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": { "node_modules/@floating-ui/utils": {
"version": "0.2.10", "version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@gar/promisify": { "node_modules/@gar/promisify": {
@@ -3069,6 +3115,19 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/@phosphor-icons/react": {
"version": "2.1.10",
"resolved": "https://registry.npmjs.org/@phosphor-icons/react/-/react-2.1.10.tgz",
"integrity": "sha512-vt8Tvq8GLjheAZZYa+YG/pW7HDbov8El/MANW8pOAz4eGxrwhnbfrQZq0Cp4q8zBEu8NIhHdnr+r8thnfRSNYA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"react": ">= 16.8",
"react-dom": ">= 16.8"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3737,11 +3796,20 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.2.2" "csstype": "^3.2.2"
} }
}, },
"node_modules/@types/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
}
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -5590,8 +5658,7 @@
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/data-view-buffer": { "node_modules/data-view-buffer": {
"version": "1.0.2", "version": "1.0.2",
@@ -6777,6 +6844,33 @@
"node": ">=0.4.x" "node": ">=0.4.x"
} }
}, },
"node_modules/framer-motion": {
"version": "11.18.2",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.18.2.tgz",
"integrity": "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w==",
"license": "MIT",
"dependencies": {
"motion-dom": "^11.18.1",
"motion-utils": "^11.18.1",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fs-constants": { "node_modules/fs-constants": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
@@ -9790,6 +9884,21 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/motion-dom": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.18.1.tgz",
"integrity": "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw==",
"license": "MIT",
"dependencies": {
"motion-utils": "^11.18.1"
}
},
"node_modules/motion-utils": {
"version": "11.18.1",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.18.1.tgz",
"integrity": "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -12021,6 +12130,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tar": { "node_modules/tar": {
"version": "6.2.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
@@ -13661,6 +13776,13 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"packages/constants": {
"name": "@discord-clone/constants",
"version": "1.0.0",
"devDependencies": {
"typescript": "^5.7.0"
}
},
"packages/platform-web": { "packages/platform-web": {
"name": "@discord-clone/platform-web", "name": "@discord-clone/platform-web",
"version": "1.0.0", "version": "1.0.0",
@@ -13670,16 +13792,22 @@
}, },
"packages/shared": { "packages/shared": {
"name": "@discord-clone/shared", "name": "@discord-clone/shared",
"version": "1.0.36", "version": "1.0.40",
"dependencies": { "dependencies": {
"@convex-dev/presence": "^0.3.0", "@convex-dev/presence": "^0.3.0",
"@discord-clone/constants": "*",
"@discord-clone/ui": "*",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.26.0",
"@livekit/components-react": "^2.9.17", "@livekit/components-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"@phosphor-icons/react": "^2.1.7",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
"clsx": "^2.1.1",
"convex": "^1.31.2", "convex": "^1.31.2",
"framer-motion": "^11.0.0",
"livekit-client": "^2.16.1", "livekit-client": "^2.16.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -13690,6 +13818,26 @@
"react-virtuoso": "^4.18.1", "react-virtuoso": "^4.18.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sql.js": "^1.12.0" "sql.js": "^1.12.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0"
}
},
"packages/ui": {
"name": "@discord-clone/ui",
"version": "1.0.0",
"dependencies": {
"@floating-ui/react": "^0.26.0",
"@phosphor-icons/react": "^2.1.7",
"clsx": "^2.1.1",
"framer-motion": "^11.0.0",
"react": "^19.2.0",
"react-dom": "^19.2.0"
},
"devDependencies": {
"typescript": "^5.7.0"
} }
} }
} }

View File

@@ -0,0 +1,10 @@
{
"name": "@discord-clone/constants",
"private": true,
"version": "1.0.0",
"type": "module",
"main": "src/index.ts",
"devDependencies": {
"typescript": "^5.7.0"
}
}

View File

@@ -0,0 +1,9 @@
export const ChannelTypes = {
TEXT: 'text',
VOICE: 'voice',
CATEGORY: 'category',
DM: 'dm',
GROUP_DM: 'group_dm',
} as const;
export type ChannelType = (typeof ChannelTypes)[keyof typeof ChannelTypes];

View File

@@ -0,0 +1,37 @@
export const PermissionFlags = {
MANAGE_SERVER: 1 << 0,
MANAGE_CHANNELS: 1 << 1,
MANAGE_ROLES: 1 << 2,
MANAGE_MESSAGES: 1 << 3,
KICK_MEMBERS: 1 << 4,
BAN_MEMBERS: 1 << 5,
SEND_MESSAGES: 1 << 6,
SEND_FILES: 1 << 7,
ADD_REACTIONS: 1 << 8,
CONNECT_VOICE: 1 << 9,
SPEAK: 1 << 10,
STREAM: 1 << 11,
MUTE_MEMBERS: 1 << 12,
DEAFEN_MEMBERS: 1 << 13,
MOVE_MEMBERS: 1 << 14,
MENTION_EVERYONE: 1 << 15,
VIEW_CHANNEL: 1 << 16,
CREATE_INVITE: 1 << 17,
CHANGE_NICKNAME: 1 << 18,
MANAGE_NICKNAMES: 1 << 19,
MANAGE_EMOJIS: 1 << 20,
ADMINISTRATOR: 1 << 31,
} as const;
export type PermissionFlag = (typeof PermissionFlags)[keyof typeof PermissionFlags];
export function hasPermission(permissions: number, flag: number): boolean {
if (permissions & PermissionFlags.ADMINISTRATOR) return true;
return (permissions & flag) === flag;
}
export function combinePermissions(...flags: number[]): number {
return flags.reduce((acc, flag) => acc | flag, 0);
}
export const MAX_ROLES_PER_SERVER = 25;

View File

@@ -0,0 +1,19 @@
export const Routes = {
HOME: '/',
LOGIN: '/login',
REGISTER: '/register',
SETUP_ENCRYPTION: '/setup-encryption',
ME: '/channels/@me',
NOTIFICATIONS: '/notifications',
YOU: '/you',
dmChannel: (channelId: string) => `/channels/@me/${channelId}`,
serverChannel: (serverId: string, channelId?: string) =>
channelId ? `/channels/${serverId}/${channelId}` : `/channels/${serverId}`,
channelMessage: (serverId: string, channelId: string, messageId: string) =>
`/channels/${serverId}/${channelId}/${messageId}`,
isDMRoute: (path: string) => path.startsWith('/channels/@me'),
isServerRoute: (path: string) => path.startsWith('/channels/') && !path.startsWith('/channels/@'),
} as const;

View File

@@ -0,0 +1,17 @@
export const StatusTypes = {
ONLINE: 'online',
IDLE: 'idle',
DND: 'dnd',
OFFLINE: 'offline',
INVISIBLE: 'invisible',
} as const;
export type StatusType = (typeof StatusTypes)[keyof typeof StatusTypes];
export enum PresenceStatus {
Online = 'online',
Idle = 'idle',
DoNotDisturb = 'dnd',
Offline = 'offline',
Invisible = 'invisible',
}

View File

@@ -0,0 +1,9 @@
export { ChannelTypes } from './ChannelTypes';
export {
PermissionFlags,
hasPermission,
combinePermissions,
MAX_ROLES_PER_SERVER,
} from './PermissionFlags';
export { StatusTypes, PresenceStatus } from './StatusTypes';
export { Routes } from './Routes';

View File

@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}

View File

@@ -1,18 +1,24 @@
{ {
"name": "@discord-clone/shared", "name": "@discord-clone/shared",
"private": true, "private": true,
"version": "1.0.40", "version": "1.0.50",
"type": "module", "type": "module",
"main": "src/App.jsx", "main": "src/App.tsx",
"dependencies": { "dependencies": {
"@convex-dev/presence": "^0.3.0", "@convex-dev/presence": "^0.3.0",
"@discord-clone/ui": "*",
"@discord-clone/constants": "*",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@floating-ui/react": "^0.26.0",
"@livekit/components-react": "^2.9.17", "@livekit/components-react": "^2.9.17",
"@livekit/components-styles": "^1.2.0", "@livekit/components-styles": "^1.2.0",
"@phosphor-icons/react": "^2.1.7",
"@use-gesture/react": "^10.3.1", "@use-gesture/react": "^10.3.1",
"clsx": "^2.1.1",
"convex": "^1.31.2", "convex": "^1.31.2",
"framer-motion": "^11.0.0",
"livekit-client": "^2.16.1", "livekit-client": "^2.16.1",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -23,5 +29,10 @@
"react-virtuoso": "^4.18.1", "react-virtuoso": "^4.18.1",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sql.js": "^1.12.0" "sql.js": "^1.12.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"typescript": "^5.7.0"
} }
} }

View File

@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -1,119 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import Login from './pages/Login';
import Register from './pages/Register';
import Recovery from './pages/Recovery';
import Chat from './pages/Chat';
import { usePlatform } from './platform';
import { useSearch } from './contexts/SearchContext';
import { useSystemBars } from './hooks/useSystemBars';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
function AuthGuard({ children }) {
const [authState, setAuthState] = useState('loading'); // 'loading' | 'authenticated' | 'unauthenticated'
const location = useLocation();
const navigate = useNavigate();
const { session, settings } = usePlatform();
const searchCtx = useSearch();
useSystemBars(null);
useEffect(() => {
let cancelled = false;
async function restoreSession() {
// Already have keys in sessionStorage — current session is active
if (sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey')) {
searchCtx?.initialize();
if (!cancelled) setAuthState('authenticated');
return;
}
// Try restoring from safeStorage
if (session) {
try {
const savedSession = await session.load();
if (savedSession && savedSession.savedAt && (Date.now() - savedSession.savedAt) < THIRTY_DAYS_MS) {
// Restore to localStorage + sessionStorage
localStorage.setItem('userId', savedSession.userId);
localStorage.setItem('username', savedSession.username);
if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey);
sessionStorage.setItem('signingKey', savedSession.signingKey);
sessionStorage.setItem('privateKey', savedSession.privateKey);
if (savedSession.masterKey) sessionStorage.setItem('masterKey', savedSession.masterKey);
if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey);
searchCtx?.initialize();
// Restore user preferences from file-based backup into localStorage
if (settings) {
try {
const savedPrefs = await settings.get(`userPrefs_${savedSession.userId}`);
if (savedPrefs && typeof savedPrefs === 'object') {
localStorage.setItem(`userPrefs_${savedSession.userId}`, JSON.stringify(savedPrefs));
}
} catch {}
}
if (!cancelled) setAuthState('authenticated');
return;
}
// Expired — clear stale session
if (savedSession && savedSession.savedAt) {
await session.clear();
}
} catch (err) {
console.error('Session restore failed:', err);
}
}
if (!cancelled) setAuthState('unauthenticated');
}
restoreSession();
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (authState === 'loading') return;
const isAuthPage = location.pathname === '/' || location.pathname === '/register' || location.pathname === '/recovery';
const hasSession = sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey');
if (hasSession && isAuthPage) {
navigate('/chat', { replace: true });
} else if (!hasSession && !isAuthPage) {
navigate('/', { replace: true });
}
}, [authState, location.pathname]);
if (authState === 'loading') {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
backgroundColor: 'var(--bg-primary, #313338)',
color: 'var(--text-normal, #dbdee1)',
fontSize: '16px',
}}>
Loading...
</div>
);
}
return children;
}
function App() {
return (
<AuthGuard>
<Routes>
<Route path="/" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/recovery" element={<Recovery />} />
<Route path="/chat" element={<Chat />} />
</Routes>
</AuthGuard>
);
}
export default App;

181
packages/shared/src/App.tsx Normal file
View File

@@ -0,0 +1,181 @@
import { useEffect, useState, type ReactNode } from 'react';
import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { IconContext } from '@phosphor-icons/react';
import { LoginPage } from './components/auth/LoginPage';
import { RegisterPage } from './components/auth/RegisterPage';
import { InviteAcceptPage } from './components/auth/InviteAcceptPage';
import { AppLayout } from './components/layout/AppLayout';
import { MobileYouPage } from './components/layout/MobileYouPage';
import { TitleBar } from './components/layout/TitleBar';
import { ChannelView } from './components/channel/ChannelView';
import Recovery from './pages/Recovery';
import { usePlatform } from './platform';
import { useSearch } from './contexts/SearchContext';
import { useSystemBars } from './hooks/useSystemBars';
import './global.css';
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
type AuthState = 'loading' | 'authenticated' | 'unauthenticated';
function AuthGuard({ children }: { children: ReactNode }) {
const [authState, setAuthState] = useState<AuthState>('loading');
const location = useLocation();
const navigate = useNavigate();
const { session, settings } = usePlatform();
const searchCtx = useSearch();
useSystemBars(null);
useEffect(() => {
let cancelled = false;
async function restoreSession() {
if (sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey')) {
searchCtx?.initialize();
if (!cancelled) setAuthState('authenticated');
return;
}
if (session) {
try {
const savedSession = await session.load();
if (savedSession && savedSession.savedAt && Date.now() - savedSession.savedAt < THIRTY_DAYS_MS) {
localStorage.setItem('userId', savedSession.userId);
localStorage.setItem('username', savedSession.username);
if (savedSession.publicKey) localStorage.setItem('publicKey', savedSession.publicKey);
sessionStorage.setItem('signingKey', savedSession.signingKey);
sessionStorage.setItem('privateKey', savedSession.privateKey);
if (savedSession.masterKey) sessionStorage.setItem('masterKey', savedSession.masterKey);
if (savedSession.searchDbKey) sessionStorage.setItem('searchDbKey', savedSession.searchDbKey);
searchCtx?.initialize();
if (settings) {
try {
const savedPrefs = await settings.get(`userPrefs_${savedSession.userId}`);
if (savedPrefs && typeof savedPrefs === 'object') {
localStorage.setItem(`userPrefs_${savedSession.userId}`, JSON.stringify(savedPrefs));
}
} catch {}
}
if (!cancelled) setAuthState('authenticated');
return;
}
if (savedSession?.savedAt) {
await session.clear();
}
} catch (err) {
console.error('Session restore failed:', err);
}
}
if (!cancelled) setAuthState('unauthenticated');
}
restoreSession();
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (authState === 'loading') return;
const isAuthPage =
location.pathname === '/login' ||
location.pathname === '/register' ||
location.pathname === '/recovery' ||
location.pathname.startsWith('/invite/');
const hasSession = !!(sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey'));
// Invite accept runs regardless of session — if the viewer is
// already logged in, the InviteAcceptPage itself will forward
// them home after stashing the keys. Don't redirect them away.
const isInvitePage = location.pathname.startsWith('/invite/');
if (hasSession && isAuthPage && !isInvitePage) {
navigate('/channels/@me', { replace: true });
} else if (!hasSession && !isAuthPage) {
navigate('/login', { replace: true });
}
}, [authState, location.pathname]);
if (authState === 'loading') {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
backgroundColor: 'var(--background-primary)',
color: 'var(--text-primary)',
fontSize: 16,
}}
>
Loading...
</div>
);
}
return <>{children}</>;
}
function DMHomePage() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
color: 'var(--text-secondary)',
}}
>
<div style={{ textAlign: 'center' }}>
<h2 style={{ color: 'var(--text-primary)', marginBottom: 8 }}>Welcome to Brycord</h2>
<p>Select a conversation or start a new one</p>
</div>
</div>
);
}
function ServerPage() {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flex: 1,
color: 'var(--text-secondary)',
}}
>
<p>Select a channel to start chatting</p>
</div>
);
}
function App() {
return (
<IconContext.Provider value={{ color: 'currentColor', weight: 'fill' }}>
<TitleBar />
<AuthGuard>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/invite/:code" element={<InviteAcceptPage />} />
<Route path="/recovery" element={<Recovery />} />
<Route path="/channels/*" element={<AppLayout />}>
<Route path="@me" element={<DMHomePage />} />
<Route path="@me/:channelId" element={<ChannelView />} />
<Route path="you" element={<MobileYouPage />} />
<Route path=":serverId" element={<ServerPage />} />
<Route path=":serverId/:channelId" element={<ChannelView />} />
</Route>
<Route path="*" element={<Navigate to="/channels/@me" replace />} />
</Routes>
</AuthGuard>
</IconContext.Provider>
);
}
export default App;

View File

@@ -0,0 +1,13 @@
/**
* MatrixActions shim — the original wired Matrix SDK event listeners.
* In this project, reactive state comes from Convex useQuery, so these
* are no-ops kept only to satisfy imports.
*/
export const initializeMatrix = async (): Promise<void> => {};
export const teardownMatrix = async (): Promise<void> => {};
export default {
initializeMatrix,
teardownMatrix,
};

View File

@@ -0,0 +1,29 @@
/**
* MessageActionCreators shim — stub message action flows.
* Real sends go through Convex; these no-ops keep the UI compiling
* until each call site is rewired.
*/
import type { Attachment } from '../matrix-client';
export interface SendMessageArgs {
channelId: string;
body: string;
formattedBody?: string;
replyToId?: string | null;
attachments?: Attachment[];
mentions?: string[];
}
export const MessageActionCreators = {
sendMessage: async (_args: SendMessageArgs): Promise<string> => '',
editMessage: async (_channelId: string, _messageId: string, _body: string) => {},
deleteMessage: async (_channelId: string, _messageId: string) => {},
addReaction: async (_channelId: string, _messageId: string, _key: string) => {},
removeReaction: async (_channelId: string, _messageId: string, _key: string) => {},
markRead: async (_channelId: string, _messageId: string) => {},
flushPendingAttachments: async (_channelId: string) => {},
createPoll: async (_args: unknown) => {},
};
export default MessageActionCreators;

View File

@@ -0,0 +1,2 @@
export * from './MessageActionCreators';
export * from './MatrixActions';

View File

@@ -0,0 +1,119 @@
[
{
"emoji": "angry",
"shortcuts": [">:(", ">:-(", ">=(", ">=-("]
},
{
"emoji": "blush",
"shortcuts": [":\")", ":-\")", "=\")", "=-\")"]
},
{
"emoji": "broken_heart",
"shortcuts": ["</3", "<\\3"]
},
{
"emoji": "confused",
"shortcuts": [":-\\", ":-/", "=-\\", "=-/"]
},
{
"emoji": "cry",
"shortcuts": [":'(", ":'-(", ":,(", ":,-(", "='(", "='-(", "=,(", "=,-("]
},
{
"emoji": "frowning",
"shortcuts": [":(", ":-(", "=(", "=-("]
},
{
"emoji": "heart",
"shortcuts": ["<3", "♡"]
},
{
"emoji": "imp",
"shortcuts": ["]:(", "]:-(", "]=(", "]=-("]
},
{
"emoji": "innocent",
"shortcuts": ["o:)", "O:)", "o:-)", "O:-)", "0:)", "0:-)", "o=)", "O=)", "o=-)", "O=-)", "0=)", "0=-)"]
},
{
"emoji": "joy",
"shortcuts": [
":')",
":'-)",
":,)",
":,-)",
":'D",
":'-D",
":,D",
":,-D",
"=')",
"='-)",
"=,)",
"=,-)",
"='D",
"='-D",
"=,D",
"=,-D"
]
},
{
"emoji": "kissing",
"shortcuts": [":*", ":-*", "=*", "=-*"]
},
{
"emoji": "laughing",
"shortcuts": ["x-)", "X-)"]
},
{
"emoji": "neutral_face",
"shortcuts": [":|", ":-|", "=|", "=-|"]
},
{
"emoji": "open_mouth",
"shortcuts": [":o", ":-o", ":O", ":-O", "=o", "=-o", "=O", "=-O"]
},
{
"emoji": "rage",
"shortcuts": [":@", ":-@", "=@", "=-@"]
},
{
"emoji": "smile",
"shortcuts": [":D", ":-D", "=D", "=-D"]
},
{
"emoji": "smiley",
"shortcuts": [":)", ":-)", "=)", "=-)"]
},
{
"emoji": "smiling_imp",
"shortcuts": ["]:)", "]:-)", "]=)", "]=-)"]
},
{
"emoji": "sob",
"shortcuts": [":,'(", ":,'-(", ";(", ";-(", "=,'(", "=,'-("]
},
{
"emoji": "stuck_out_tongue",
"shortcuts": [":P", ":-P", "=P", "=-P"]
},
{
"emoji": "sunglasses",
"shortcuts": ["8-)", "B-)"]
},
{
"emoji": "sweat",
"shortcuts": [",:(", ",:-(", ",=(", ",=-("]
},
{
"emoji": "sweat_smile",
"shortcuts": [",:)", ",:-)", ",=)", ",=-)"]
},
{
"emoji": "unamused",
"shortcuts": [":s", ":-S", ":z", ":-Z", ":$", ":-$", "=s", "=-S", "=z", "=-Z", "=$", "=-$"]
},
{
"emoji": "wink",
"shortcuts": [";)", ";-)"]
}
]

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,11 @@
/**
* useMxcUrl shim — the new UI's Matrix media URL resolver.
* In Convex we already have direct URLs from storage, so this passes through.
*/
export function useMxcUrl(src: string | null | undefined): string | undefined {
if (!src) return undefined;
return src;
}
export default useMxcUrl;

View File

@@ -0,0 +1,20 @@
/**
* dmOtherUser shim — given a DM channel, return the other participant.
* Real implementation reads from Convex dm query. For now, return null.
*/
import type { Channel, Member } from '../../matrix-client';
export function dmOtherUser(_channel: Channel | null): Member | null {
return null;
}
export function getDmOtherUser(_channel: Channel | null): Member | null {
return null;
}
export function getDmOtherUserId(_channel: Channel | null): string | null {
return null;
}
export default dmOtherUser;

View File

@@ -0,0 +1,41 @@
/**
* joinSound shim — voice channel join-sound manager + preferences.
*/
export function playJoinSound(_url?: string | null): void {}
export function playLeaveSound(): void {}
export function stopAllSounds(): void {}
export function getJoinSoundEnabled(): boolean {
if (typeof localStorage === 'undefined') return true;
const v = localStorage.getItem('joinSoundEnabled');
return v === null ? true : v === 'true';
}
export function setJoinSoundEnabled(enabled: boolean): void {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('joinSoundEnabled', enabled ? 'true' : 'false');
}
}
export function getJoinSoundVolume(): number {
if (typeof localStorage === 'undefined') return 100;
const v = parseInt(localStorage.getItem('joinSoundVolume') || '100', 10);
return Number.isFinite(v) ? v : 100;
}
export function setJoinSoundVolume(volume: number): void {
if (typeof localStorage !== 'undefined') {
localStorage.setItem('joinSoundVolume', String(volume));
}
}
export default {
playJoinSound,
playLeaveSound,
stopAllSounds,
getJoinSoundEnabled,
setJoinSoundEnabled,
getJoinSoundVolume,
setJoinSoundVolume,
};

View File

@@ -0,0 +1,22 @@
/**
* twemojiBroken shim — tracks which emoji codepoints Twemoji fails on.
*/
import { useState } from 'react';
const broken: Set<string> = new Set();
export function isTwemojiBroken(codepoint: string): boolean {
return broken.has(codepoint);
}
export function markTwemojiBroken(codepoint: string): void {
broken.add(codepoint);
}
export function useBrokenTwemojiVersion(): number {
const [version] = useState(0);
return version;
}
export default { isTwemojiBroken, markTwemojiBroken, useBrokenTwemojiVersion };

View File

@@ -0,0 +1,399 @@
/**
* Matrix client shim.
*
* The new UI was built against a Matrix client SDK (`@brycord/matrix-client`).
* This project uses Convex instead. This file provides stub types and
* manager classes so the UI compiles. Every component that reads from these
* stubs must eventually be rewired to use Convex queries/mutations — but
* having them here lets the big-bang UI replacement compile in one step.
*
* Each manager is a singleton exposing `getInstance()` + no-op methods that
* return sensible empty values (empty arrays, null, false). UI still renders.
*/
// ─── Shared primitive types ───────────────────────────────────────────────
export type ChannelType = 'text' | 'voice' | 'category' | 'dm' | 'group_dm';
export interface Channel {
id: string;
roomId: string;
name: string;
type: ChannelType;
topic?: string;
parentId?: string | null;
position?: number;
nsfw?: boolean;
slowmode?: number;
icon?: string | null;
isDM?: boolean;
avatarUrl?: string | null;
lastMessageTs?: number;
memberCount?: number;
userLimit?: number;
bitrate?: number;
rateLimitPerUser?: number;
}
export interface Server {
id: string;
name: string;
icon?: string | null;
avatarUrl?: string | null;
memberCount?: number;
onlineCount?: number;
ownerId?: string;
description?: string;
roomIds?: string[];
}
export interface Member {
userId: string;
id: string;
displayName: string;
name?: string;
avatarUrl?: string | null;
powerLevel?: number;
roles?: string[];
presence?: PresenceStatus;
status?: string;
accentColor?: string;
bio?: string;
joinedAt?: number;
}
export interface Attachment {
id?: string;
url: string;
name: string;
filename?: string;
size: number;
mimeType: string;
width?: number;
height?: number;
duration?: number;
waveform?: number[];
thumbnailUrl?: string | null;
spoiler?: boolean;
description?: string;
}
export interface Message {
id: string;
eventId: string;
channelId: string;
senderId: string;
authorId: string;
authorName?: string;
authorAvatarUrl?: string | null;
content: string;
body?: string;
formattedBody?: string;
timestamp: number;
editedTimestamp?: number | null;
attachments?: Attachment[];
replyToId?: string | null;
reactions?: MessageReaction[];
mentions?: string[];
mentionRoles?: string[];
pinned?: boolean;
type?: string;
isRedacted?: boolean;
isEncrypted?: boolean;
isPending?: boolean;
error?: string | null;
}
export interface MessageReaction {
key: string;
count: number;
userIds: string[];
me: boolean;
}
export interface CustomEmoji {
id: string;
name: string;
shortcode: string;
url: string;
animated?: boolean;
packId?: string;
}
export interface EmojiPack {
id: string;
name: string;
emojis: CustomEmoji[];
}
export type SavedMediaKind = 'image' | 'video' | 'audio' | 'gif' | 'sticker';
export interface SavedMediaItem {
id: string;
kind: SavedMediaKind;
url: string;
mimeType?: string;
name?: string;
width?: number;
height?: number;
addedAt: number;
}
export interface Role {
id: string;
name: string;
color: string;
position: number;
permissions: number;
hoist?: boolean;
mentionable?: boolean;
powerLevel?: number;
}
export interface PowerLevelAbilities {
canKick: boolean;
canBan: boolean;
canInvite: boolean;
canRedact: boolean;
canSetState: boolean;
canManageRoles: boolean;
canManageChannels: boolean;
}
export type PresenceStatus = 'online' | 'idle' | 'dnd' | 'offline' | 'invisible' | 'unavailable';
export interface PollAnswer {
id: string;
text: string;
}
export interface Poll {
id: string;
question: string;
answers: PollAnswer[];
multiple: boolean;
endsAt?: number;
votes?: Record<string, string[]>;
}
export interface DeviceTrustInfo {
deviceId: string;
userId: string;
trusted: boolean;
verified: boolean;
ed25519Key?: string;
}
export type VoiceConnectionState =
| 'disconnected'
| 'connecting'
| 'connected'
| 'reconnecting'
| 'disconnecting'
| 'failed'
| 'rejoinable';
export type VoiceErrorReason = 'token' | 'network' | 'permission' | 'unknown';
export interface VoiceError {
reason: VoiceErrorReason;
message: string;
}
export interface VoiceParticipantSnapshot {
userId: string;
identity: string;
displayName: string;
avatarUrl?: string | null;
isMuted: boolean;
isDeafened: boolean;
isSpeaking: boolean;
isScreenSharing: boolean;
isCameraOn: boolean;
isLocal: boolean;
connectionQuality?: 'excellent' | 'good' | 'poor' | 'lost' | 'unknown';
volume?: number;
}
// ─── Manager singletons (stubs) ───────────────────────────────────────────
function singleton<T extends object>(factory: () => T): { getInstance(): T } {
let inst: T | null = null;
return {
getInstance() {
if (!inst) inst = factory();
return inst;
},
};
}
export const MatrixClientManager = singleton(() => ({
getClient: () => null as any,
getUserId: () => (typeof localStorage !== 'undefined' ? localStorage.getItem('userId') || '' : ''),
getDisplayName: () => (typeof localStorage !== 'undefined' ? localStorage.getItem('username') || '' : ''),
getDeviceId: () => '',
getHomeserverUrl: () => '',
isReady: () => true,
start: async () => {},
stop: async () => {},
logout: async () => {},
}));
export const RoomManager = singleton(() => ({
getRoom: (_id: string): Channel | null => null,
listRooms: (): Channel[] => [],
createChannel: async (_args: unknown): Promise<string> => '',
renameChannel: async (_id: string, _name: string) => {},
deleteChannel: async (_id: string) => {},
setTopic: async (_id: string, _topic: string) => {},
getPowerLevel: (_id: string) => 0,
getStateDefault: (_id: string) => 0,
canManageChannel: (_id: string) => true,
}));
export const MemberManager = singleton(() => ({
getMember: (_roomId: string, _userId: string): Member | null => null,
listMembers: (_roomId: string): Member[] => [],
getMemberSnapshot: async (_roomId: string, _userId: string): Promise<Member | null> => null,
setNickname: async (_roomId: string, _userId: string, _nickname: string) => {},
kick: async (_roomId: string, _userId: string, _reason?: string) => {},
ban: async (_roomId: string, _userId: string, _reason?: string) => {},
}));
export const MessageManager = singleton(() => ({
listMessages: (_channelId: string): Message[] => [],
sendMessage: async (_channelId: string, _body: string): Promise<string> => '',
editMessage: async (_channelId: string, _id: string, _body: string) => {},
deleteMessage: async (_channelId: string, _id: string) => {},
addReaction: async (_channelId: string, _id: string, _key: string) => {},
removeReaction: async (_channelId: string, _id: string, _key: string) => {},
}));
export const MediaManager = singleton(() => ({
resolveMxcToObjectUrl: async (_mxc: string): Promise<string | null> => null,
resolveMxcThumbnailToObjectUrl: async (
_mxc: string,
_w: number,
_h: number,
_mode?: string,
): Promise<string | null> => null,
uploadFile: async (_file: File): Promise<string> => '',
getFileUrl: (_url: string): string => _url,
}));
export const CryptoManager = singleton(() => ({
isReady: () => true,
getDeviceTrustInfo: (_userId: string, _deviceId: string): DeviceTrustInfo | null => null,
verifyDevice: async (_userId: string, _deviceId: string) => {},
exportRoomKeys: async (): Promise<string> => '',
importRoomKeys: async (_json: string) => {},
}));
export const PinsManager = singleton(() => ({
getPinned: (_channelId: string): Message[] => [],
pin: async (_channelId: string, _messageId: string) => {},
unpin: async (_channelId: string, _messageId: string) => {},
}));
export const SavedMediaManager = singleton(() => ({
list: (_kind?: SavedMediaKind): SavedMediaItem[] => [],
save: async (_item: SavedMediaItem) => {},
remove: async (_id: string) => {},
has: (_url: string): boolean => false,
}));
export const FriendManager = singleton(() => ({
listFriends: (): Member[] => [],
listPending: (): Member[] => [],
listIgnored: (): Member[] => [],
addFriend: async (_userId: string) => {},
acceptFriend: async (_userId: string) => {},
ignoreFriend: async (_userId: string) => {},
removeFriend: async (_userId: string) => {},
unignoreFriend: async (_userId: string) => {},
}));
export const PersonalNotesManager = singleton(() => ({
getRoomId: (): string | null => null,
ensureRoom: async (): Promise<string> => '',
}));
export const SpaceManager = singleton(() => ({
listSpaces: (): Server[] => [],
getSpace: (_id: string): Server | null => null,
createSpace: async (_name: string): Promise<string> => '',
leaveSpace: async (_id: string) => {},
}));
export const PresenceManager = singleton(() => ({
getPresence: (_userId: string): PresenceStatus => 'offline',
setPresence: async (_status: PresenceStatus) => {},
}));
export const RoleManager = singleton(() => ({
listRoles: (_serverId: string): Role[] => [],
getRole: (_serverId: string, _roleId: string): Role | null => null,
createRole: async (_serverId: string, _name: string): Promise<string> => '',
updateRole: async (_serverId: string, _roleId: string, _patch: Partial<Role>) => {},
deleteRole: async (_serverId: string, _roleId: string) => {},
assignRole: async (_serverId: string, _userId: string, _roleId: string) => {},
unassignRole: async (_serverId: string, _userId: string, _roleId: string) => {},
getMemberRoles: (_serverId: string, _userId: string): string[] => [],
getAbilitiesForMember: (_serverId: string, _userId: string): PowerLevelAbilities => ({
canKick: false,
canBan: false,
canInvite: false,
canRedact: false,
canSetState: false,
canManageRoles: false,
canManageChannels: false,
}),
}));
export const VoiceManager = singleton(() => ({
connect: async (_channelId: string) => {},
disconnect: async () => {},
isConnected: (): boolean => false,
getConnectionState: (): VoiceConnectionState => 'disconnected',
getParticipants: (_channelId: string): VoiceParticipantSnapshot[] => [],
setMuted: async (_muted: boolean) => {},
setDeafened: async (_deafened: boolean) => {},
setCameraOn: async (_on: boolean) => {},
setScreenShare: async (_on: boolean) => {},
}));
export const EmojiPackManager = singleton(() => ({
listPacks: (): EmojiPack[] => [],
getPack: (_id: string): EmojiPack | null => null,
}));
export const VoiceModerationManager = singleton(() => ({
serverMute: async (_userId: string) => {},
serverUnmute: async (_userId: string) => {},
moveMember: async (_userId: string, _channelId: string) => {},
disconnectMember: async (_userId: string) => {},
}));
// ─── Constants / helpers ──────────────────────────────────────────────────
export const MAX_EMOJIS_PER_PACK = 100;
/** Classify a MIME type into a SavedMediaKind. */
export function classifyMediaKind(mimeType: string): SavedMediaKind {
if (mimeType.startsWith('image/gif')) return 'gif';
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
return 'image';
}
/** Parse a LiveKit participant identity in the format `@user:server:device`. */
export function parseMatrixUserFromIdentity(identity: string): { userId: string; deviceId: string } | null {
if (!identity) return null;
const lastColon = identity.lastIndexOf(':');
if (lastColon === -1) return null;
return {
userId: identity.slice(0, lastColon),
deviceId: identity.slice(lastColon + 1),
};
}

View File

@@ -0,0 +1,19 @@
/**
* mobx-react-lite shim — replaces MobX observer with an identity HOC.
* Our stores are plain objects (not reactive), so observer() becomes a no-op.
* Components still re-render on React state changes from Convex hooks.
*/
import type { ComponentType, FC } from 'react';
export function observer<T extends ComponentType<any>>(component: T): T {
return component;
}
export function useLocalObservable<T>(factory: () => T): T {
return factory();
}
export const Observer: FC<{ children: () => JSX.Element | null }> = ({ children }) => children() as any;
export default { observer, useLocalObservable, Observer };

View File

@@ -0,0 +1,46 @@
/**
* mobx shim — makeAutoObservable etc. become no-ops.
*/
export function makeAutoObservable<T>(obj: T): T {
return obj;
}
export function makeObservable<T>(obj: T): T {
return obj;
}
export function observable<T>(v: T): T {
return v;
}
export function action<T extends (...args: any[]) => any>(fn: T): T {
return fn;
}
export function computed<T>(fn: () => T): { get(): T } {
return { get: fn };
}
export function runInAction<T>(fn: () => T): T {
return fn();
}
export function reaction<T>(_expression: () => T, _effect: (v: T) => void): () => void {
return () => {};
}
export function autorun(_fn: () => void): () => void {
return () => {};
}
export default {
makeAutoObservable,
makeObservable,
observable,
action,
computed,
runInAction,
reaction,
autorun,
};

View File

@@ -0,0 +1,3 @@
import { AuthenticationStore } from ".";
export default AuthenticationStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { ChannelStore } from ".";
export default ChannelStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { CryptoStore } from ".";
export default CryptoStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { EmojiPackStore } from ".";
export default EmojiPackStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { FriendStore } from ".";
export default FriendStore;
export * from ".";

View File

@@ -0,0 +1,28 @@
import { KeybindStore } from '.';
export type KeybindAction = string;
export type KeybindCategory = string;
export function formatCombo(combo: string): string {
return combo
.split('+')
.map((t) => t.trim())
.filter(Boolean)
.map((t) => (t.length === 1 ? t.toUpperCase() : t[0].toUpperCase() + t.slice(1)))
.join('+');
}
export function eventToCombo(e: KeyboardEvent): string {
const parts: string[] = [];
if (e.ctrlKey) parts.push('ctrl');
if (e.shiftKey) parts.push('shift');
if (e.altKey) parts.push('alt');
if (e.metaKey) parts.push('meta');
if (e.key && !['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) {
parts.push(e.key.toLowerCase());
}
return parts.join('+');
}
export default KeybindStore;
export { KeybindStore };

View File

@@ -0,0 +1,3 @@
import { MatrixConnectionStore } from ".";
export default MatrixConnectionStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { MessageStore } from ".";
export default MessageStore;
export * from ".";

View File

@@ -0,0 +1,5 @@
import { PendingAttachmentStore, type PendingAttachment } from '.';
export type { PendingAttachment };
export default PendingAttachmentStore;
export { PendingAttachmentStore };

View File

@@ -0,0 +1,3 @@
import { PersonalNotesStore } from ".";
export default PersonalNotesStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { PinStore } from ".";
export default PinStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { ReadStateStore } from ".";
export default ReadStateStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { RoleStore } from ".";
export default RoleStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { SavedMediaStore } from ".";
export default SavedMediaStore;
export * from ".";

View File

@@ -0,0 +1,11 @@
import { SearchStore } from '.';
import type { Message } from '../matrix-client';
export interface SearchResult {
message: Message;
channelId: string;
matches?: string[];
}
export default SearchStore;
export { SearchStore };

View File

@@ -0,0 +1,3 @@
import { SelectionStore } from ".";
export default SelectionStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { ServerStore } from ".";
export default ServerStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { ThemeStore } from ".";
export default ThemeStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { TypingStore } from ".";
export default TypingStore;
export * from ".";

View File

@@ -0,0 +1,3 @@
import { UserStore } from ".";
export default UserStore;
export * from ".";

View File

@@ -0,0 +1,6 @@
import { VoiceSettingsStore } from '.';
export type InputMode = 'voice_activity' | 'push_to_talk';
export default VoiceSettingsStore;
export { VoiceSettingsStore };

View File

@@ -0,0 +1,3 @@
import { VoiceStore } from ".";
export default VoiceStore;
export * from ".";

View File

@@ -0,0 +1,292 @@
/**
* Stores shim index.
*
* The new UI uses MobX stores. This project uses Convex + React Contexts.
* Each store here is a plain object with getter methods returning sane
* defaults. Components rewired to Convex will stop reading these stubs.
*/
import type {
Channel,
Server,
Member,
Message,
Role,
PresenceStatus,
VoiceParticipantSnapshot,
CustomEmoji,
EmojiPack,
SavedMediaItem,
SavedMediaKind,
} from '../matrix-client';
// ─── Helper: reactive-compatible getter wrappers ──────────────────────────
// The original stores used MobX `makeAutoObservable`. Components call these
// getters inside `observer()` wrappers. Our shims just return plain values,
// and we remove `observer` via the mobx-react-lite shim.
const ok = <T>(v: T): T => v;
// ─── AuthenticationStore ─────────────────────────────────────────────────
// isAuthenticated is a getter so the latest sessionStorage state is read
// every access. Keeps the new AppLayout redirect logic in sync with the
// existing session-restore flow in App.tsx (which sets sessionStorage).
export const AuthenticationStore = {
get isAuthenticated(): boolean {
if (typeof sessionStorage === 'undefined') return false;
return !!(sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey'));
},
get userId(): string {
return typeof localStorage !== 'undefined' ? localStorage.getItem('userId') || '' : '';
},
get username(): string {
return typeof localStorage !== 'undefined' ? localStorage.getItem('username') || '' : '';
},
isLoading: false,
login: async (_username: string, _password: string) => {},
register: async (_username: string, _password: string) => {},
logout: async () => {},
getUserId: () => (typeof localStorage !== 'undefined' ? localStorage.getItem('userId') || '' : ''),
getUsername: () => (typeof localStorage !== 'undefined' ? localStorage.getItem('username') || '' : ''),
async restoreSession(): Promise<boolean> {
if (typeof sessionStorage === 'undefined') return false;
return !!(sessionStorage.getItem('privateKey') && sessionStorage.getItem('signingKey'));
},
};
// ─── ChannelStore ────────────────────────────────────────────────────────
export const ChannelStore = {
getChannel: (_id: string): Channel | null => null,
getChannels: (_serverId: string): Channel[] => [],
listChannels: (_serverId: string): Channel[] => [],
getCategories: (_serverId: string): Channel[] => [],
getChannelsInCategory: (_serverId: string, _categoryId: string | null): Channel[] => [],
setChannel: (_c: Channel) => {},
removeChannel: (_id: string) => {},
};
// ─── ServerStore ─────────────────────────────────────────────────────────
export const ServerStore = {
getServers: (): Server[] => [],
getServer: (_id: string): Server | null => null,
setServer: (_s: Server) => {},
removeServer: (_id: string) => {},
};
// ─── MessageStore ────────────────────────────────────────────────────────
export const MessageStore = {
getMessages: (_channelId: string): Message[] => [],
getMessage: (_channelId: string, _id: string): Message | null => null,
addMessage: (_m: Message) => {},
updateMessage: (_channelId: string, _id: string, _patch: Partial<Message>) => {},
removeMessage: (_channelId: string, _id: string) => {},
getLastReadId: (_channelId: string): string | null => null,
};
// ─── UserStore ───────────────────────────────────────────────────────────
export const UserStore = {
getUser: (_id: string): Member | null => null,
getMe: (): Member | null => null,
getDisplayName: (_id: string): string => '',
getAvatarUrl: (_id: string): string | null => null,
getPresence: (_id: string): PresenceStatus => 'offline',
getAccentColor: (_id: string): string | null => null,
setStatus: async (_status: PresenceStatus) => {},
updateProfile: async (_patch: unknown) => {},
};
// ─── SelectionStore ──────────────────────────────────────────────────────
export const SelectionStore = {
selectedServerId: null as string | null,
selectedChannelId: null as string | null,
isMobileViewport: typeof window !== 'undefined' ? window.innerWidth <= 768 : false,
selectServer: (_id: string | null) => {},
selectChannel: (_id: string | null) => {},
syncFromPath: (_path: string) => {},
};
// ─── VoiceStore ──────────────────────────────────────────────────────────
export const VoiceStore = {
connectedChannelId: null as string | null,
isMuted: false,
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
connectionState: 'disconnected' as const,
participants: [] as VoiceParticipantSnapshot[],
getParticipants: (_channelId: string): VoiceParticipantSnapshot[] => [],
toggleMute: () => {},
toggleDeafen: () => {},
toggleCamera: () => {},
toggleScreenShare: () => {},
disconnect: async () => {},
};
// ─── VoiceSettingsStore ──────────────────────────────────────────────────
export const VoiceSettingsStore = {
inputDeviceId: 'default',
outputDeviceId: 'default',
inputVolume: 100,
outputVolume: 100,
noiseSuppression: true,
echoCancellation: true,
autoGainControl: true,
setInputDevice: (_id: string) => {},
setOutputDevice: (_id: string) => {},
};
// ─── RoleStore ───────────────────────────────────────────────────────────
export const RoleStore = {
listRoles: (_serverId: string): Role[] => [],
getRole: (_serverId: string, _id: string): Role | null => null,
getMemberRoles: (_serverId: string, _userId: string): Role[] => [],
getHighestHoistedRole: (_serverId: string, _userId: string): Role | null => null,
};
// ─── ReadStateStore ──────────────────────────────────────────────────────
export const ReadStateStore = {
isUnread: (_channelId: string): boolean => false,
getUnreadCount: (_channelId: string): number => 0,
getMentionCount: (_channelId: string): number => 0,
getLastReadId: (_channelId: string): string | null => null,
markRead: async (_channelId: string) => {},
};
// ─── TypingStore ─────────────────────────────────────────────────────────
export const TypingStore = {
getTypingUsers: (_channelId: string): Member[] => [],
startTyping: async (_channelId: string) => {},
};
// ─── FriendStore ─────────────────────────────────────────────────────────
export const FriendStore = {
getFriendChannels: (): Channel[] => [],
getPendingChannels: (): Channel[] => [],
getFriends: (): Member[] => [],
getPending: (): Member[] => [],
getIgnored: (): Member[] => [],
isFriend: (_id: string): boolean => false,
isPending: (_id: string): boolean => false,
isIgnored: (_id: string): boolean => false,
};
// ─── PinStore ────────────────────────────────────────────────────────────
export const PinStore = {
getPinned: (_channelId: string): Message[] => [],
isPinned: (_channelId: string, _messageId: string): boolean => false,
};
// ─── SavedMediaStore ─────────────────────────────────────────────────────
export const SavedMediaStore = {
list: (_kind?: SavedMediaKind): SavedMediaItem[] => [],
isSaved: (_url: string): boolean => false,
add: async (_item: SavedMediaItem) => {},
remove: async (_id: string) => {},
};
// ─── EmojiPackStore ──────────────────────────────────────────────────────
export const EmojiPackStore = {
listPacks: (): EmojiPack[] => [],
getPack: (_id: string): EmojiPack | null => null,
getEmoji: (_id: string): CustomEmoji | null => null,
findByShortcode: (_shortcode: string): CustomEmoji | null => null,
};
// ─── PendingAttachmentStore ──────────────────────────────────────────────
export interface PendingAttachment {
id: string;
channelId: string;
file: File;
name: string;
size: number;
mimeType: string;
previewUrl?: string;
progress?: number;
error?: string | null;
spoiler?: boolean;
description?: string;
width?: number;
height?: number;
}
export const PendingAttachmentStore = {
getAttachments: (_channelId: string): PendingAttachment[] => [],
addAttachment: (_a: PendingAttachment) => {},
removeAttachment: (_channelId: string, _id: string) => {},
updateAttachment: (_channelId: string, _id: string, _patch: Partial<PendingAttachment>) => {},
clearAttachments: (_channelId: string) => {},
};
// ─── PersonalNotesStore ──────────────────────────────────────────────────
export const PersonalNotesStore = {
getRoomId: (): string | null => null,
isPersonalNotesRoom: (_id: string): boolean => false,
};
// ─── KeybindStore ────────────────────────────────────────────────────────
export const KeybindStore = {
getCombo: (_action: string): string => '',
getBinding: (_action: string): string => '',
setCombo: (_action: string, _combo: string) => {},
listActions: (): string[] => [],
};
// ─── SearchStore ─────────────────────────────────────────────────────────
export const SearchStore = {
isOpen: false,
query: '',
results: [] as Message[],
setQuery: (_q: string) => {},
search: async (_q: string) => {},
close: () => {},
};
// ─── CryptoStore ─────────────────────────────────────────────────────────
export const CryptoStore = {
isReady: true,
isSetup: true,
needsSetup: false,
};
// ─── MatrixConnectionStore ───────────────────────────────────────────────
export const MatrixConnectionStore = {
isReady: true,
isConnecting: false,
isConnected: true,
error: null as string | null,
};
// ─── ThemeStore ──────────────────────────────────────────────────────────
export const ThemeStore = {
theme: 'dark' as 'light' | 'dark',
setTheme: (_t: 'light' | 'dark') => {},
};
// Default exports so `import Foo from '@app/stores/Foo'` also works
export default {
AuthenticationStore,
ChannelStore,
ServerStore,
MessageStore,
UserStore,
SelectionStore,
VoiceStore,
VoiceSettingsStore,
RoleStore,
ReadStateStore,
TypingStore,
FriendStore,
PinStore,
SavedMediaStore,
EmojiPackStore,
PendingAttachmentStore,
PersonalNotesStore,
KeybindStore,
SearchStore,
CryptoStore,
MatrixConnectionStore,
ThemeStore,
};
// Workaround for `ok` not being used (keeps TS from erroring on strict mode)
export { ok };

View File

@@ -1,62 +0,0 @@
import React from 'react';
const USER_COLORS = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
function getUserColor(name) {
let hash = 0;
for (let i = 0; i < (name || '').length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return USER_COLORS[Math.abs(hash) % USER_COLORS.length];
}
const Avatar = ({ username, avatarUrl, size = 40, className = '', style = {}, onClick }) => {
const sizeStr = `${size}px`;
const fontSize = `${Math.max(size * 0.45, 10)}px`;
if (avatarUrl) {
return (
<img
className={className}
src={avatarUrl}
alt={username || '?'}
onClick={onClick}
style={{
width: sizeStr,
height: sizeStr,
borderRadius: '50%',
objectFit: 'cover',
cursor: onClick ? 'pointer' : 'default',
...style,
}}
/>
);
}
return (
<div
className={className}
onClick={onClick}
style={{
width: sizeStr,
height: sizeStr,
borderRadius: '50%',
backgroundColor: getUserColor(username || 'U'),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontWeight: 600,
fontSize,
userSelect: 'none',
flexShrink: 0,
cursor: onClick ? 'pointer' : 'default',
...style,
}}
>
{(username || '?').substring(0, 1).toUpperCase()}
</div>
);
};
export default Avatar;

View File

@@ -1,134 +0,0 @@
import React, { useState, useCallback, useEffect } from 'react';
import Cropper from 'react-easy-crop';
function getCroppedImg(imageSrc, pixelCrop) {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.drawImage(
image,
pixelCrop.x, pixelCrop.y, pixelCrop.width, pixelCrop.height,
0, 0, 256, 256
);
canvas.toBlob((blob) => {
if (!blob) return reject(new Error('Canvas toBlob failed'));
resolve(blob);
}, 'image/png');
};
image.onerror = reject;
image.src = imageSrc;
});
}
const AvatarCropModal = ({ imageUrl, onApply, onCancel, cropShape = 'round' }) => {
const [crop, setCrop] = useState({ x: 0, y: 0 });
const [zoom, setZoom] = useState(1);
const [croppedAreaPixels, setCroppedAreaPixels] = useState(null);
const onCropComplete = useCallback((_croppedArea, croppedPixels) => {
setCroppedAreaPixels(croppedPixels);
}, []);
const handleApply = useCallback(async () => {
if (!croppedAreaPixels) return;
const blob = await getCroppedImg(imageUrl, croppedAreaPixels);
onApply(blob);
}, [imageUrl, croppedAreaPixels, onApply]);
useEffect(() => {
const handleKey = (e) => {
if (e.key === 'Escape') {
e.stopPropagation();
onCancel();
}
};
window.addEventListener('keydown', handleKey, true);
return () => window.removeEventListener('keydown', handleKey, true);
}, [onCancel]);
return (
<div className="avatar-crop-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget) onCancel(); }}>
<div className="avatar-crop-dialog">
{/* Header */}
<div className="avatar-crop-header">
<h2 style={{ margin: 0, fontSize: '20px', fontWeight: 700, color: 'var(--header-primary)' }}>
Edit Image
</h2>
<button
onClick={onCancel}
style={{
background: 'none', border: 'none', color: 'var(--header-secondary)',
fontSize: '24px', cursor: 'pointer', padding: '4px', lineHeight: 1,
}}
>
</button>
</div>
{/* Crop area */}
<div className="avatar-crop-area">
<Cropper
image={imageUrl}
crop={crop}
zoom={zoom}
aspect={1}
cropShape={cropShape}
showGrid={false}
onCropChange={setCrop}
onZoomChange={setZoom}
onCropComplete={onCropComplete}
/>
</div>
{/* Zoom slider */}
<div className="avatar-crop-slider-row">
<svg width="16" height="16" viewBox="0 0 24 24" fill="var(--header-secondary)">
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
</svg>
<input
type="range"
min={1}
max={3}
step={0.01}
value={zoom}
onChange={(e) => setZoom(Number(e.target.value))}
className="avatar-crop-slider"
/>
<svg width="20" height="20" viewBox="0 0 24 24" fill="var(--header-secondary)">
<path d="M15 3H9v2h6V3zm-4 18h2v-2h-2v2zm8-15.97l-1.41-1.41-1.76 1.76 1.41 1.41L19 5.03zM4.76 4.38L3.34 5.8 5.1 7.56 6.52 6.14 4.76 4.38zM21 11h-2v2h2v-2zM5 11H3v2h2v-2zm7-4a5 5 0 100 10 5 5 0 000-10z"/>
</svg>
</div>
{/* Actions */}
<div className="avatar-crop-actions">
<button
onClick={onCancel}
style={{
background: 'none', border: 'none', color: 'var(--header-primary)',
cursor: 'pointer', fontSize: '14px', fontWeight: 500, padding: '8px 16px',
}}
>
Cancel
</button>
<button
onClick={handleApply}
style={{
backgroundColor: 'var(--brand-experiment)', color: 'white', border: 'none',
borderRadius: '4px', padding: '8px 24px', cursor: 'pointer',
fontSize: '14px', fontWeight: 500,
}}
>
Apply
</button>
</div>
</div>
</div>
);
};
export default AvatarCropModal;

View File

@@ -1,222 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { useMutation } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const ChangeNicknameModal = ({ targetUserId, targetUsername, currentNickname, actorUserId, onClose }) => {
const [nickname, setNickname] = useState(currentNickname || '');
const inputRef = useRef(null);
const setNicknameMutation = useMutation(api.auth.setNickname);
const isSelf = targetUserId === actorUserId;
useEffect(() => {
inputRef.current?.focus();
inputRef.current?.select();
}, []);
const handleSave = async () => {
try {
await setNicknameMutation({
actorUserId,
targetUserId,
displayName: nickname,
});
onClose();
} catch (err) {
console.error('Failed to set nickname:', err);
}
};
const handleReset = async () => {
try {
await setNicknameMutation({
actorUserId,
targetUserId,
displayName: '',
});
onClose();
} catch (err) {
console.error('Failed to reset nickname:', err);
}
};
const handleKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handleSave();
} else if (e.key === 'Escape') {
onClose();
}
};
return ReactDOM.createPortal(
<div
onClick={onClose}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.85)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 10001,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
width: '440px',
maxWidth: '90vw',
backgroundColor: 'var(--bg-primary)',
borderRadius: '8px',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.4)',
overflow: 'hidden',
}}
>
{/* Header */}
<div style={{ padding: '16px 16px 0 16px', position: 'relative' }}>
<h2 style={{ color: 'var(--header-primary)', margin: '0 0 16px 0', fontSize: '20px', fontWeight: 600 }}>
Change Nickname
</h2>
<button
onClick={onClose}
style={{
position: 'absolute',
top: '12px',
right: '12px',
background: 'none',
border: 'none',
color: 'var(--interactive-normal)',
cursor: 'pointer',
padding: '4px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '50%',
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
</svg>
</button>
</div>
{/* Content */}
<div style={{ padding: '0 16px 16px 16px' }}>
{/* Notice */}
{!isSelf && (
<div style={{
backgroundColor: 'var(--bg-tertiary)',
borderLeft: '4px solid var(--text-warning, #faa61a)',
borderRadius: '4px',
padding: '12px',
marginBottom: '16px',
fontSize: '14px',
color: 'var(--text-normal)',
lineHeight: '1.4',
}}>
Nicknames are visible to everyone on this server. Do not change them unless you are enforcing a naming system or clearing a bad nickname.
</div>
)}
{/* Label */}
<label style={{
color: 'var(--header-secondary)',
fontSize: '12px',
fontWeight: 700,
textTransform: 'uppercase',
marginBottom: '8px',
display: 'block',
}}>
Nickname
</label>
{/* Input */}
<input
ref={inputRef}
type="text"
value={nickname}
onChange={(e) => setNickname(e.target.value.slice(0, 32))}
onKeyDown={handleKeyDown}
placeholder={targetUsername}
maxLength={32}
style={{
width: '100%',
padding: '10px',
backgroundColor: 'var(--bg-tertiary)',
border: '1px solid var(--border-subtle)',
borderRadius: '4px',
color: 'var(--text-normal)',
fontSize: '14px',
outline: 'none',
boxSizing: 'border-box',
}}
/>
{/* Reset link */}
<div
onClick={handleReset}
style={{
color: 'var(--text-link, #00a8fc)',
fontSize: '14px',
cursor: 'pointer',
marginTop: '8px',
marginBottom: '4px',
userSelect: 'none',
}}
>
Reset Nickname
</div>
</div>
{/* Footer */}
<div style={{
display: 'flex',
gap: '12px',
padding: '16px',
backgroundColor: 'var(--bg-secondary)',
borderTop: '1px solid var(--border-subtle)',
}}>
<button
onClick={onClose}
style={{
flex: 1,
padding: '10px 0',
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-normal)',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
}}
>
Cancel
</button>
<button
onClick={handleSave}
style={{
flex: 1,
padding: '10px 0',
backgroundColor: 'var(--brand-experiment)',
color: '#fff',
border: 'none',
borderRadius: '3px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 500,
}}
>
Save
</button>
</div>
</div>
</div>,
document.body
);
};
export default ChangeNicknameModal;

View File

@@ -1,213 +0,0 @@
import React, { useState, useEffect } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const ChannelSettingsModal = ({ channel, onClose, onRename, onDelete }) => {
const [name, setName] = useState(channel.name);
const [activeTab, setActiveTab] = useState('Overview');
const convex = useConvex();
useEffect(() => {
const handleKey = (e) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [onClose]);
const handleSave = async () => {
try {
await convex.mutation(api.channels.rename, { id: channel._id, name });
onRename(channel._id, name);
onClose();
} catch (err) {
console.error(err);
alert('Failed to update channel: ' + err.message);
}
};
const handleDelete = async () => {
if (!confirm('Are you sure you want to delete this channel? This cannot be undone.')) return;
try {
await convex.mutation(api.channels.remove, { id: channel._id });
onDelete(channel._id);
onClose();
} catch (err) {
console.error(err);
alert('Failed to delete channel: ' + err.message);
}
};
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'var(--bg-primary)',
zIndex: 1000,
display: 'flex',
color: 'var(--text-normal)'
}}>
{/* Sidebar */}
<div style={{
width: '218px',
backgroundColor: 'var(--bg-secondary)',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
padding: '60px 6px 60px 20px'
}}>
<div style={{ width: '100%', padding: '0 10px' }}>
<div style={{
fontSize: '12px',
fontWeight: '700',
color: 'var(--text-muted)',
marginBottom: '6px',
textTransform: 'uppercase'
}}>
{channel.name} Text Channels
</div>
<div
onClick={() => setActiveTab('Overview')}
style={{
padding: '6px 10px',
borderRadius: '4px',
backgroundColor: activeTab === 'Overview' ? 'var(--background-modifier-selected)' : 'transparent',
color: activeTab === 'Overview' ? 'var(--header-primary)' : 'var(--header-secondary)',
cursor: 'pointer',
marginBottom: '2px',
fontSize: '15px'
}}
>
Overview
</div>
<div style={{ height: '1px', backgroundColor: 'var(--border-subtle)', margin: '8px 0' }} />
<div
onClick={() => setActiveTab('Delete')}
style={{
padding: '6px 10px',
borderRadius: '4px',
color: '#ed4245',
cursor: 'pointer',
fontSize: '15px',
display: 'flex',
justifyContent: 'space-between'
}}
>
Delete Channel
<span>🗑</span>
</div>
</div>
</div>
{/* Content */}
<div style={{ flex: 1, display: 'flex', justifyContent: 'flex-start', overflowY: 'auto' }}>
<div style={{ flex: 1, maxWidth: '740px', padding: '60px 40px 80px', position: 'relative' }}>
<h2 style={{ color: 'var(--header-primary)', margin: 0, marginBottom: '20px' }}>
{activeTab === 'Delete' ? 'Delete Channel' : 'Overview'}
</h2>
{activeTab === 'Overview' && (
<>
<div style={{ marginBottom: '20px' }}>
<label style={{
display: 'block',
color: 'var(--header-secondary)',
fontSize: '12px',
fontWeight: '700',
textTransform: 'uppercase',
marginBottom: '8px'
}}>
Channel Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
style={{
width: '100%',
backgroundColor: 'var(--bg-tertiary)',
border: 'none',
borderRadius: '4px',
padding: '10px',
color: 'var(--text-normal)',
fontSize: '16px',
outline: 'none'
}}
/>
</div>
<div style={{ display: 'flex', gap: '20px' }}>
<button
onClick={handleSave}
style={{
backgroundColor: '#3ba55c',
color: 'var(--header-primary)',
border: 'none',
borderRadius: '4px',
padding: '10px 24px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
Save Changes
</button>
</div>
</>
)}
{activeTab === 'Delete' && (
<div style={{ backgroundColor: 'var(--bg-tertiary)', padding: '16px', borderRadius: '8px', border: '1px solid #ed4245' }}>
<h3 style={{ color: 'var(--header-primary)', marginTop: 0 }}>Are you sure?</h3>
<p style={{ color: 'var(--header-secondary)' }}>
Deleting <b>#{channel.name}</b> cannot be undone. All messages and keys will be lost forever.
</p>
<button
onClick={handleDelete}
style={{
backgroundColor: '#ed4245',
color: 'var(--header-primary)',
border: 'none',
borderRadius: '4px',
padding: '10px 24px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
Delete Channel
</button>
</div>
)}
</div>
<div style={{ flex: '0 0 36px', paddingTop: '60px', marginLeft: '8px' }}>
<button
onClick={onClose}
style={{
width: '36px', height: '36px', borderRadius: '50%',
border: '2px solid var(--header-secondary)', background: 'transparent',
color: 'var(--header-secondary)', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: '18px',
}}
>
</button>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--header-secondary)', textAlign: 'center', marginTop: '4px' }}>
ESC
</div>
</div>
<div style={{ flex: '0.5' }} />
</div>
</div>
);
};
export default ChannelSettingsModal;

File diff suppressed because it is too large Load Diff

View File

@@ -1,175 +0,0 @@
import React, { useMemo } from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import { useOnlineUsers } from '../contexts/PresenceContext';
import Tooltip from './Tooltip';
const ChatHeader = ({
channelName,
channelType,
channelTopic,
channelId,
onToggleMembers,
showMembers,
onTogglePinned,
serverName,
isMobile,
onMobileBack,
onStartCall,
isDMCallActive,
onOpenMembersScreen,
// Search props
searchQuery,
onSearchQueryChange,
onSearchSubmit,
onSearchFocus,
onSearchBlur,
searchInputRef,
searchActive,
}) => {
const isDM = channelType === 'dm';
const searchPlaceholder = isDM ? 'Search' : `Search ${serverName || 'Server'}`;
// Query members on mobile text channels only for online count
const shouldQueryMembers = isMobile && !isDM && channelId;
const members = useQuery(
api.members.getChannelMembers,
shouldQueryMembers ? { channelId } : "skip"
) || [];
const { resolveStatus } = useOnlineUsers();
const onlineCount = useMemo(() => {
if (!shouldQueryMembers) return 0;
return members.filter(m => resolveStatus(m.status, m.id) !== 'offline').length;
}, [members, resolveStatus, shouldQueryMembers]);
const handleSearchKeyDown = (e) => {
if (e.key === 'Enter') {
e.preventDefault();
onSearchSubmit?.();
}
if (e.key === 'Escape') {
e.preventDefault();
onSearchBlur?.();
e.target.blur();
}
};
return (
<div className="chat-header">
<div className="chat-header-left">
{isMobile && onMobileBack && (
<button className="mobile-back-btn" onClick={onMobileBack}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
</button>
)}
{isMobile && !isDM ? (
<button className="mobile-channel-header-tap" onClick={onOpenMembersScreen}>
<div className="mobile-channel-header-top">
<span className="chat-header-icon">#</span>
<span className="chat-header-name">{channelName}</span>
<svg className="mobile-channel-chevron" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9.29 6.71a1 1 0 0 0 0 1.41L13.17 12l-3.88 3.88a1 1 0 1 0 1.42 1.41l4.59-4.59a1 1 0 0 0 0-1.41L10.71 6.7a1 1 0 0 0-1.42 0Z"/>
</svg>
</div>
<div className="mobile-channel-header-bottom">
<span className="mobile-online-dot" />
<span className="mobile-online-count">{onlineCount ?? 0} Online</span>
</div>
</button>
) : (
<>
<span className="chat-header-icon">{isDM ? '@' : '#'}</span>
<span className="chat-header-name">{channelName}</span>
{channelTopic && !isDM && !isMobile && (
<>
<div className="chat-header-divider" />
<span className="chat-header-topic" title={channelTopic}>{channelTopic}</span>
</>
)}
{isDM && <span className="chat-header-status-text"></span>}
</>
)}
</div>
<div className="chat-header-right">
{!isDM && !isMobile && (
<Tooltip text="Threads" position="bottom">
<button className="chat-header-btn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M5.43309 21C5.35842 21 5.30189 20.9325 5.31494 20.859L5.99991 17H2.14274C2.06819 17 2.01168 16.9327 2.02453 16.8593L2.33253 15.0993C2.34258 15.0419 2.39244 15 2.45074 15H6.34991L7.14991 10.5H3.29274C3.21819 10.5 3.16168 10.4327 3.17453 10.3593L3.48253 8.59926C3.49258 8.54185 3.54244 8.5 3.60074 8.5H7.49991L8.25674 4.49395C8.26688 4.43665 8.31672 4.395 8.37491 4.395H10.1919C10.2666 4.395 10.3231 4.4625 10.3101 4.536L9.59991 8.5H14.0999L14.8568 4.49395C14.8669 4.43665 14.9167 4.395 14.9749 4.395H16.7919C16.8666 4.395 16.9231 4.4625 16.9101 4.536L16.1999 8.5H20.0571C20.1316 8.5 20.1881 8.56734 20.1753 8.64074L19.8673 10.4007C19.8572 10.4581 19.8074 10.5 19.7491 10.5H15.8499L15.0499 15H18.9071C18.9816 15 19.0381 15.0673 19.0253 15.1407L18.7173 16.9007C18.7072 16.9581 18.6574 17 18.5991 17H14.6999L13.9431 21.006C13.9329 21.0634 13.8831 21.105 13.8249 21.105H12.0079C11.9332 21.105 11.8767 21.0375 11.8897 20.964L12.5999 17H8.09991L7.34309 21.006C7.33295 21.0634 7.28311 21.105 7.22491 21.105H5.43309V21ZM8.44991 15H12.9499L13.7499 10.5H9.24991L8.44991 15Z" />
</svg>
</button>
</Tooltip>
)}
{isDM && !isMobile && (
<Tooltip text={isDMCallActive ? "Join Call" : "Start Call"} position="bottom">
<button
className={`chat-header-btn ${isDMCallActive ? 'active' : ''}`}
onClick={onStartCall}
style={isDMCallActive ? { color: '#3ba55c' } : undefined}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.62 10.79a15.053 15.053 0 006.59 6.59l2.2-2.2a1.003 1.003 0 011.01-.24c1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1C10.07 21 3 13.93 3 4c0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.1.31.03.66-.25 1.02l-2.2 2.2z"/>
</svg>
</button>
</Tooltip>
)}
<Tooltip text="Pinned Messages" position="bottom">
<button className="chat-header-btn" onClick={onTogglePinned}>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path fill="currentColor" d="M19.38 11.38a3 3 0 0 0 4.24 0l.03-.03a.5.5 0 0 0 0-.7L13.35.35a.5.5 0 0 0-.7 0l-.03.03a3 3 0 0 0 0 4.24L13 5l-2.92 2.92-3.65-.34a2 2 0 0 0-1.6.58l-.62.63a1 1 0 0 0 0 1.42l9.58 9.58a1 1 0 0 0 1.42 0l.63-.63a2 2 0 0 0 .58-1.6l-.34-3.64L19 11zM9.07 17.07a.5.5 0 0 1-.08.77l-5.15 3.43a.5.5 0 0 1-.63-.06l-.42-.42a.5.5 0 0 1-.06-.63L6.16 15a.5.5 0 0 1 .77-.08l2.14 2.14Z"/>
</svg>
</button>
</Tooltip>
{!isDM && !isMobile && (
<Tooltip text={showMembers ? "Hide Members" : "Show Members"} position="bottom">
<button
className={`chat-header-btn ${showMembers ? 'active' : ''}`}
onClick={onToggleMembers}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
<path fill="currentColor" d="M14.5 8a3 3 0 1 0-2.7-4.3c-.2.4.06.86.44 1.12a5 5 0 0 1 2.14 3.08c.01.06.06.1.12.1ZM18.44 17.27c.15.43.54.73 1 .73h1.06c.83 0 1.5-.67 1.5-1.5a7.5 7.5 0 0 0-6.5-7.43c-.55-.08-.99.38-1.1.92-.06.3-.15.6-.26.87-.23.58-.05 1.3.47 1.63a9.53 9.53 0 0 1 3.83 4.78ZM12.5 9a3 3 0 1 1-6 0 3 3 0 0 1 6 0ZM2 20.5a7.5 7.5 0 0 1 15 0c0 .83-.67 1.5-1.5 1.5a.2.2 0 0 1-.2-.16c-.2-.96-.56-1.87-.88-2.54-.1-.23-.42-.15-.42.1v2.1a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-2.1c0-.25-.31-.33-.42-.1-.32.67-.67 1.58-.88 2.54a.2.2 0 0 1-.2.16A1.5 1.5 0 0 1 2 20.5Z"/>
</svg>
</button>
</Tooltip>
)}
{!isMobile && (
<Tooltip text="Notification Settings" position="bottom">
<button className="chat-header-btn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M18 9V14C18 15.657 19.344 17 21 17V18H3V17C4.656 17 6 15.657 6 14V9C6 5.686 8.686 3 12 3C15.314 3 18 5.686 18 9ZM11.9999 22C10.5239 22 9.24993 20.955 8.99993 19.5H14.9999C14.7499 20.955 13.4759 22 11.9999 22Z" />
</svg>
</button>
</Tooltip>
)}
{!isMobile && (
<div className="chat-header-search-wrapper" ref={searchInputRef}>
<svg className="chat-header-search-icon" width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.71 20.29L18 16.61A9 9 0 1016.61 18l3.68 3.68a1 1 0 001.42 0 1 1 0 000-1.39zM11 18a7 7 0 110-14 7 7 0 010 14z"/>
</svg>
<input
type="text"
placeholder={searchPlaceholder}
className={`chat-header-search ${searchActive ? 'focused' : ''}`}
value={searchQuery || ''}
onChange={(e) => onSearchQueryChange?.(e.target.value)}
onFocus={onSearchFocus}
onKeyDown={handleSearchKeyDown}
/>
{searchQuery && (
<button
className="chat-header-search-clear"
onClick={() => onSearchQueryChange?.('')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z"/>
</svg>
</button>
)}
</div>
)}
</div>
</div>
);
};
export default ChatHeader;

View File

@@ -1,31 +0,0 @@
import React from 'react';
const ColoredIcon = React.memo(({ src, color, size = '20px', style = {} }) => {
if (!color) {
return (
<div style={{ width: size, height: size, display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, ...style }}>
<img src={src} alt="" style={{ width: size, height: size, objectFit: 'contain' }} />
</div>
);
}
return (
<div style={{
width: size, height: size, flexShrink: 0,
overflow: 'hidden',
...style,
}}>
<img
src={src}
alt=""
style={{
width: size, height: size,
objectFit: 'contain',
filter: `drop-shadow(${size} 0 0 ${color})`,
transform: `translateX(-${size})`,
}}
/>
</div>
);
});
export default ColoredIcon;

View File

@@ -1,245 +0,0 @@
import React, { useState, useEffect, useRef } from 'react';
import { useConvex } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Tooltip from './Tooltip';
import Avatar from './Avatar';
import ColoredIcon from './ColoredIcon';
import { useOnlineUsers } from '../contexts/PresenceContext';
import friendsIcon from '../assets/icons/friends.svg';
const STATUS_COLORS = {
online: '#3ba55c',
idle: '#faa61a',
dnd: '#ed4245',
invisible: '#747f8d',
offline: '#747f8d',
};
const STATUS_LABELS = {
online: 'Online',
idle: 'Idle',
dnd: 'Do Not Disturb',
invisible: 'Offline',
offline: 'Offline',
};
const getUserColor = (username) => {
if (!username) return '#5865F2';
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
const DMList = ({ dmChannels, activeDMChannel, onSelectDM, onOpenDM, voiceStates }) => {
const [showUserPicker, setShowUserPicker] = useState(false);
const [allUsers, setAllUsers] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [searchFocused, setSearchFocused] = useState(false);
const searchRef = useRef(null);
const searchInputRef = useRef(null);
const { resolveStatus } = useOnlineUsers();
const convex = useConvex();
const handleOpenUserPicker = async () => {
setShowUserPicker(true);
setSearchQuery('');
try {
const data = await convex.query(api.auth.getPublicKeys, {});
const myId = localStorage.getItem('userId');
setAllUsers(data.filter(u => u.id !== myId));
} catch (err) {
console.error(err);
}
};
const handleSearchFocus = async () => {
setSearchFocused(true);
if (allUsers.length === 0) {
try {
const data = await convex.query(api.auth.getPublicKeys, {});
const myId = localStorage.getItem('userId');
setAllUsers(data.filter(u => u.id !== myId));
} catch (err) {
console.error(err);
}
}
};
useEffect(() => {
if (showUserPicker && searchRef.current) {
searchRef.current.focus();
}
}, [showUserPicker]);
const filteredUsers = allUsers.filter(u =>
u.username?.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleCloseDM = (e, dm) => {
e.stopPropagation();
// If closing the active DM, switch back to friends
if (activeDMChannel?.channel_id === dm.channel_id) {
onSelectDM('friends');
}
};
return (
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
{/* Search Input */}
<div className="dm-search-wrapper">
<input
ref={searchInputRef}
type="text"
className="dm-search-input"
placeholder="Find or start a conversation"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={handleSearchFocus}
onBlur={() => {
setTimeout(() => setSearchFocused(false), 200);
}}
/>
{searchFocused && searchQuery && filteredUsers.length > 0 && (
<div className="dm-search-dropdown">
{filteredUsers.slice(0, 8).map(user => (
<div
key={user.id}
className="dm-search-result"
onMouseDown={(e) => {
e.preventDefault();
setSearchQuery('');
setSearchFocused(false);
onOpenDM(user.id, user.username);
}}
>
<Avatar username={user.username} size={24} style={{ marginRight: '8px' }} />
<span>{user.username}</span>
</div>
))}
</div>
)}
</div>
{/* User Picker Modal */}
{showUserPicker && (
<div style={{
position: 'absolute', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.7)', zIndex: 100,
display: 'flex', alignItems: 'center', justifyContent: 'center'
}}
onClick={() => setShowUserPicker(false)}
>
<div
style={{
backgroundColor: 'var(--bg-primary)', borderRadius: '8px', padding: '16px',
width: '400px', maxHeight: '500px', display: 'flex', flexDirection: 'column'
}}
onClick={e => e.stopPropagation()}
>
<h3 style={{ color: 'var(--header-primary)', margin: '0 0 4px 0', fontSize: '16px' }}>Select a User</h3>
<p style={{ color: 'var(--header-secondary)', fontSize: '12px', margin: '0 0 12px 0' }}>Start a new direct message conversation.</p>
<input
ref={searchRef}
type="text"
placeholder="Type a username..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
width: '100%', backgroundColor: 'var(--bg-tertiary)', border: '1px solid var(--border-subtle)',
borderRadius: '4px', color: 'var(--text-normal)', padding: '8px 12px', fontSize: '14px',
outline: 'none', marginBottom: '8px', boxSizing: 'border-box'
}}
/>
<div style={{ flex: 1, overflowY: 'auto', maxHeight: '300px' }}>
{filteredUsers.map(user => (
<div
key={user.id}
className="dm-picker-user"
onClick={() => { setShowUserPicker(false); onOpenDM(user.id, user.username); }}
>
<Avatar username={user.username} size={32} style={{ marginRight: '12px' }} />
<span style={{ fontWeight: '500' }}>{user.username}</span>
</div>
))}
{filteredUsers.length === 0 && (
<div style={{ color: 'var(--text-muted)', textAlign: 'center', padding: '16px', fontSize: '13px' }}>
No users found.
</div>
)}
</div>
</div>
</div>
)}
{/* Friends Button */}
<div
className={`dm-friends-btn ${!activeDMChannel ? 'active' : ''}`}
onClick={() => onSelectDM('friends')}
>
<div style={{ marginRight: '12px' }}>
<ColoredIcon src={friendsIcon} color="var(--interactive-normal)" size="24px" />
</div>
<span style={{ fontWeight: 500 }}>Friends</span>
</div>
{/* DM List Header */}
<div style={{ fontFamily: 'gg sans', display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '12px 16px 8px', color: '#96989d', fontSize: '11px', fontWeight: 'bold', borderTop: 'solid 1px var(--app-frame-border)'}}>
<span>Direct Messages</span>
</div>
{/* DM Channel List */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 8px 8px' }}>
{(dmChannels || []).map(dm => {
const isActive = activeDMChannel?.channel_id === dm.channel_id;
const effectiveStatus = resolveStatus(dm.other_user_status, dm.other_user_id);
return (
<div
key={dm.channel_id}
className={`dm-item ${isActive ? 'dm-item-active' : ''}`}
onClick={() => onSelectDM({ channel_id: dm.channel_id, other_username: dm.other_username })}
>
<div style={{ display: 'flex', alignItems: 'center', flex: 1, minWidth: 0 }}>
<div style={{ position: 'relative', marginRight: '12px', flexShrink: 0 }}>
<Avatar username={dm.other_username} avatarUrl={dm.other_user_avatar_url} size={32} />
<div style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline,
border: '2px solid var(--bg-secondary)'
}} />
</div>
<div style={{ overflow: 'hidden', flex: 1 }}>
<div style={{ color: isActive ? 'var(--header-primary)' : 'var(--text-normal)', whiteSpace: 'nowrap', textOverflow: 'ellipsis', overflow: 'hidden', fontWeight: '500' }}>
{dm.other_username}
</div>
{voiceStates && voiceStates[dm.channel_id]?.length > 0 && (
<div style={{ color: '#3ba55c', fontSize: '11px', fontWeight: '500' }}>
In Call
</div>
)}
</div>
</div>
<div className="dm-close-btn" onClick={(e) => handleCloseDM(e, dm)}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z"/>
</svg>
</div>
</div>
);
})}
{(!dmChannels || dmChannels.length === 0) && (
<div style={{ color: 'var(--text-muted)', fontSize: '13px', textAlign: 'center', padding: '16px 8px' }}>
No DMs yet. Click + to start a conversation.
</div>
)}
</div>
</div>
);
};
export default DMList;

View File

@@ -1,271 +0,0 @@
import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useVoice } from '../contexts/VoiceContext';
import { VideoRenderer, useParticipantTrack } from '../utils/streamUtils.jsx';
import { getUserPref, setUserPref } from '../utils/userPreferences';
import { usePlatform } from '../platform';
const MIN_WIDTH = 240;
const MIN_HEIGHT = 135;
const MAX_WIDTH_RATIO = 0.75;
const MAX_HEIGHT_RATIO = 0.75;
const DEFAULT_WIDTH = 320;
const DEFAULT_HEIGHT = 180;
const ASPECT_RATIO = 16 / 9;
const LIVE_BADGE_STYLE = {
backgroundColor: '#ed4245', borderRadius: '4px', padding: '2px 6px',
color: 'white', fontSize: '10px', fontWeight: 'bold',
textTransform: 'uppercase', letterSpacing: '0.5px',
};
const FloatingStreamPiP = ({ onGoBackToStream }) => {
const { room, watchingStreamOf, setWatchingStreamOf, voiceStates, activeChannelId } = useVoice();
const { settings } = usePlatform();
const pipUserId = localStorage.getItem('userId');
const [position, setPosition] = useState(() => getUserPref(pipUserId, 'pipPosition', { x: -1, y: -1 }));
const [size, setSize] = useState(() => getUserPref(pipUserId, 'pipSize', { width: DEFAULT_WIDTH, height: DEFAULT_HEIGHT }));
const [hovering, setHovering] = useState(false);
const isDragging = useRef(false);
const isResizing = useRef(false);
const dragOffset = useRef({ x: 0, y: 0 });
const resizeStart = useRef({ x: 0, y: 0, width: 0, height: 0 });
const containerRef = useRef(null);
// Initialize position to bottom-right on mount (only if no saved position)
useEffect(() => {
if (position.x === -1 && position.y === -1) {
setPosition({
x: window.innerWidth - size.width - 24,
y: window.innerHeight - size.height - 24,
});
}
}, []);
// Find the watched participant from the room
const participant = (() => {
if (!room || !watchingStreamOf) return null;
if (room.localParticipant.identity === watchingStreamOf) return room.localParticipant;
return room.remoteParticipants.get(watchingStreamOf) || null;
})();
const screenTrack = useParticipantTrack(participant, 'screenshare');
// Resolve streamer username from voiceStates
const streamerUsername = (() => {
if (!watchingStreamOf) return '';
for (const users of Object.values(voiceStates)) {
const u = users.find(u => u.userId === watchingStreamOf);
if (u) return u.username;
}
return watchingStreamOf;
})();
// Drag handlers
const handleDragStart = useCallback((e) => {
if (isResizing.current) return;
e.preventDefault();
isDragging.current = true;
dragOffset.current = {
x: e.clientX - position.x,
y: e.clientY - position.y,
};
}, [position]);
useEffect(() => {
const handleMouseMove = (e) => {
if (isDragging.current) {
let newX = e.clientX - dragOffset.current.x;
let newY = e.clientY - dragOffset.current.y;
// Constrain to window bounds
newX = Math.max(0, Math.min(newX, window.innerWidth - size.width));
newY = Math.max(0, Math.min(newY, window.innerHeight - size.height));
setPosition({ x: newX, y: newY });
}
if (isResizing.current) {
const dx = e.clientX - resizeStart.current.x;
const dy = e.clientY - resizeStart.current.y;
// Use the larger delta to maintain aspect ratio
const maxW = window.innerWidth * MAX_WIDTH_RATIO;
const maxH = window.innerHeight * MAX_HEIGHT_RATIO;
let newWidth = resizeStart.current.width + dx;
newWidth = Math.max(MIN_WIDTH, Math.min(maxW, newWidth));
let newHeight = newWidth / ASPECT_RATIO;
if (newHeight > maxH) {
newHeight = maxH;
newWidth = newHeight * ASPECT_RATIO;
}
if (newHeight < MIN_HEIGHT) {
newHeight = MIN_HEIGHT;
newWidth = newHeight * ASPECT_RATIO;
}
setSize({ width: Math.round(newWidth), height: Math.round(newHeight) });
}
};
const handleMouseUp = () => {
isDragging.current = false;
isResizing.current = false;
};
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', handleMouseUp);
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseUp);
};
}, [size]);
// Resize handler
const handleResizeStart = useCallback((e) => {
e.preventDefault();
e.stopPropagation();
isResizing.current = true;
resizeStart.current = {
x: e.clientX,
y: e.clientY,
width: size.width,
height: size.height,
};
}, [size]);
// Debounced persist of PiP position and size
useEffect(() => {
if (position.x === -1 && position.y === -1) return;
const timer = setTimeout(() => {
setUserPref(pipUserId, 'pipPosition', position, settings);
setUserPref(pipUserId, 'pipSize', size, settings);
}, 300);
return () => clearTimeout(timer);
}, [position, size, pipUserId]);
const handleStopWatching = useCallback(() => {
setWatchingStreamOf(null);
}, [setWatchingStreamOf]);
if (!watchingStreamOf || !participant) return null;
return (
<div
ref={containerRef}
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
style={{
position: 'fixed',
left: position.x,
top: position.y,
width: size.width,
height: size.height,
zIndex: 1000,
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
backgroundColor: 'black',
cursor: isDragging.current ? 'grabbing' : 'default',
userSelect: 'none',
}}
onMouseDown={handleDragStart}
>
{/* Video content */}
{screenTrack ? (
<VideoRenderer track={screenTrack} style={{ objectFit: 'contain', pointerEvents: 'none' }} />
) : (
<div style={{
width: '100%', height: '100%',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: '#72767d', fontSize: '13px',
}}>
Loading stream...
</div>
)}
{/* Hover overlay */}
{hovering && (
<div style={{
position: 'absolute', inset: 0,
backgroundColor: 'rgba(0,0,0,0.55)',
display: 'flex', flexDirection: 'column',
justifyContent: 'space-between',
padding: '8px',
transition: 'opacity 0.15s',
}}>
{/* Top row: streamer name + back button */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ color: 'white', fontSize: '12px', fontWeight: '600' }}>
{streamerUsername}
</span>
<span style={LIVE_BADGE_STYLE}>LIVE</span>
</div>
<button
onClick={(e) => { e.stopPropagation(); onGoBackToStream(); }}
title="Back to Stream"
style={{
width: '28px', height: '28px', borderRadius: '4px',
backgroundColor: 'rgba(255,255,255,0.15)', border: 'none',
color: 'white', fontSize: '16px', cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.25)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(255,255,255,0.15)'}
>
{/* Back arrow icon */}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M6.5 12.5L2 8L6.5 3.5M2.5 8H14" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</button>
</div>
{/* Bottom row: stop watching */}
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button
onClick={(e) => { e.stopPropagation(); handleStopWatching(); }}
style={{
backgroundColor: 'rgba(0,0,0,0.6)', color: 'white', border: 'none',
padding: '6px 14px', borderRadius: '4px', fontWeight: '600',
fontSize: '12px', cursor: 'pointer',
transition: 'background-color 0.15s',
}}
onMouseEnter={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.8)'}
onMouseLeave={e => e.currentTarget.style.backgroundColor = 'rgba(0,0,0,0.6)'}
>
Stop Watching
</button>
</div>
</div>
)}
{/* Resize handle (bottom-right corner) */}
<div
onMouseDown={handleResizeStart}
style={{
position: 'absolute',
bottom: 0,
right: 0,
width: '16px',
height: '16px',
cursor: 'nwse-resize',
zIndex: 2,
}}
>
{/* Diagonal grip lines */}
<svg width="16" height="16" viewBox="0 0 16 16" style={{ opacity: hovering ? 0.6 : 0 , transition: 'opacity 0.15s' }}>
<line x1="14" y1="4" x2="4" y2="14" stroke="white" strokeWidth="1.5"/>
<line x1="14" y1="8" x2="8" y2="14" stroke="white" strokeWidth="1.5"/>
<line x1="14" y1="12" x2="12" y2="14" stroke="white" strokeWidth="1.5"/>
</svg>
</div>
</div>
);
};
export default FloatingStreamPiP;

View File

@@ -1,149 +0,0 @@
import React, { useState } from 'react';
import { useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
import Avatar from './Avatar';
import ColoredIcon from './ColoredIcon';
import { useOnlineUsers } from '../contexts/PresenceContext';
import friendsIcon from '../assets/icons/friends.svg';
const FriendsView = ({ onOpenDM }) => {
const [activeTab, setActiveTab] = useState('Online');
const [addFriendSearch, setAddFriendSearch] = useState('');
const myId = localStorage.getItem('userId');
const { resolveStatus } = useOnlineUsers();
const allUsers = useQuery(api.auth.getPublicKeys) || [];
const users = allUsers.filter(u => u.id !== myId);
const getUserColor = (username) => {
if (!username) return '#747f8d';
const colors = ['#5865F2', '#EBA7CD', '#57F287', '#FEE75C', '#EB459E', '#ED4245'];
let hash = 0;
for (let i = 0; i < username.length; i++) {
hash = username.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
const STATUS_COLORS = {
online: '#3ba55c',
idle: '#faa61a',
dnd: '#ed4245',
invisible: '#747f8d',
offline: '#747f8d',
};
const filteredUsers = activeTab === 'Online'
? users.filter(u => resolveStatus(u.status, u.id) !== 'offline')
: activeTab === 'Add Friend'
? users.filter(u => u.username?.toLowerCase().includes(addFriendSearch.toLowerCase()))
: users;
return (
<div style={{ display: 'flex', flexDirection: 'column', flex: 1, backgroundColor: 'var(--bg-primary)', height: '100vh' }}>
{/* Top Bar */}
<div style={{
height: '48px',
borderBottom: '1px solid var(--border-subtle)',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
color: '#fff',
fontWeight: 'bold',
flexShrink: 0
}}>
<div style={{ display: 'flex', alignItems: 'center', marginRight: '16px', paddingRight: '16px', borderRight: '1px solid var(--border-subtle)' }}>
<div style={{ marginRight: '12px' }}>
<ColoredIcon src={friendsIcon} color="var(--interactive-normal)" size="24px" />
</div>
Friends
</div>
<div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
{['Online', 'All'].map(tab => (
<div
key={tab}
onClick={() => setActiveTab(tab)}
className="friends-tab"
style={{
cursor: 'pointer',
color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
backgroundColor: activeTab === tab ? 'rgba(255,255,255,0.06)' : 'transparent',
padding: '2px 8px',
borderRadius: '4px'
}}
>
{tab}
</div>
))}
</div>
</div>
{/* List Header */}
<div style={{ padding: '16px 20px 8px' }}>
<div style={{
fontSize: '12px',
fontWeight: 'bold',
color: 'var(--header-secondary)',
textTransform: 'uppercase'
}}>
{activeTab === 'Add Friend' ? 'USERS' : activeTab} {filteredUsers.length}
</div>
</div>
{/* Friends List */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 20px' }}>
{filteredUsers.map(user => {
const effectiveStatus = resolveStatus(user.status, user.id);
return (
<div
key={user.id}
className="friend-item"
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ position: 'relative', marginRight: '12px' }}>
<Avatar username={user.username} avatarUrl={user.avatarUrl} size={32} />
<div style={{
position: 'absolute', bottom: -2, right: -2,
width: 10, height: 10, borderRadius: '50%',
backgroundColor: STATUS_COLORS[effectiveStatus] || STATUS_COLORS.offline,
border: '2px solid var(--bg-primary)'
}} />
</div>
<div>
<div style={{ color: 'var(--header-primary)', fontWeight: '600' }}>
{user.username ?? 'Unknown'}
</div>
<div style={{ color: 'var(--header-secondary)', fontSize: '12px' }}>
{effectiveStatus === 'dnd' ? 'Do Not Disturb' : effectiveStatus.charAt(0).toUpperCase() + effectiveStatus.slice(1)}
</div>
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<div
className="friend-action-btn"
onClick={() => onOpenDM && onOpenDM(user.id, user.username)}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M4.79805 3C3.80445 3 2.99805 3.8055 2.99805 4.8V15.6C2.99805 16.5936 3.80445 17.4 4.79805 17.4H8.39805L11.998 21L15.598 17.4H19.198C20.1925 17.4 20.998 16.5936 20.998 15.6V4.8C20.998 3.8055 20.1925 3 19.198 3H4.79805Z" />
</svg>
</div>
<div className="friend-action-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 16C13.1046 16 14 15.1046 14 14C14 12.8954 13.1046 12 12 12C10.8954 12 10 12.8954 10 14C10 15.1046 10.8954 16 12 16Z" />
<path d="M12 10C13.1046 10 14 9.10457 14 8C14 6.89543 13.1046 6 12 6C10.8954 6 10 6.89543 10 8C10 9.10457 10.8954 10 12 10Z" />
<path d="M12 22C13.1046 22 14 21.1046 14 20C14 18.8954 13.1046 18 12 18C10.8954 18 10 18.8954 10 20C10 21.1046 10.8954 22 12 22Z" />
</svg>
</div>
</div>
</div>
);
})}
</div>
</div>
);
};
export default FriendsView;

View File

@@ -1,342 +0,0 @@
import CategorizedEmojis, { AllEmojis } from '../assets/emojis';
import React, { useState, useEffect, useRef } from 'react';
import { useConvex, useQuery } from 'convex/react';
import { api } from '../../../../convex/_generated/api';
const EmojiItem = ({ emoji, onSelect }) => (
<div
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect && onSelect({ type: 'emoji', ...emoji })}
title={`:${emoji.name}:`}
style={{ cursor: 'pointer', padding: '4px', borderRadius: '4px' }}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<img src={emoji.src} alt={emoji.name} style={{ width: '32px', height: '32px' }} loading="lazy" />
</div>
);
const emojiGridStyle = { display: 'grid', gridTemplateColumns: 'repeat(8, 1fr)', gap: '4px' };
const GifContent = ({ search, results, categories, onSelect, onCategoryClick }) => {
if (search || results.length > 0) {
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{results.map(gif => (
<img
key={gif.id}
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
alt={gif.title}
style={{ width: '100%', borderRadius: '4px', cursor: 'pointer' }}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onSelect(gif.media_formats?.gif?.url || gif.src)}
/>
))}
{results.length === 0 && <div style={{ color: 'var(--header-secondary)', gridColumn: 'span 2', textAlign: 'center' }}>No GIFs found</div>}
</div>
);
}
return (
<div>
<div style={{
backgroundImage: 'linear-gradient(to right, #4a90e2, #9013fe)',
borderRadius: '4px',
padding: '20px',
marginBottom: '12px',
color: '#fff',
fontWeight: 'bold',
fontSize: '16px',
textAlign: 'center',
cursor: 'pointer'
}}>
Favorites
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{categories.map(cat => (
<div
key={cat.name}
onClick={() => onCategoryClick(cat.name)}
style={{
position: 'relative',
height: '100px',
borderRadius: '4px',
overflow: 'hidden',
cursor: 'pointer',
backgroundColor: 'var(--bg-tertiary)'
}}
>
<video
src={cat.src}
autoPlay
loop
muted
style={{ width: '100%', height: '100%', objectFit: 'cover', opacity: 0.6 }}
/>
<div style={{
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(0,0,0,0.3)',
color: '#fff',
fontWeight: 'bold',
textTransform: 'capitalize'
}}>
{cat.name}
</div>
</div>
))}
</div>
</div>
);
};
const EmojiContent = ({ search, onSelect, collapsedCategories, toggleCategory, customEmojis = [] }) => {
if (search) {
const q = search.toLowerCase().replace(/:/g, '');
const customFiltered = customEmojis.filter(e => e.name.toLowerCase().includes(q));
const builtinFiltered = AllEmojis.filter(e => e.name.toLowerCase().includes(q));
const filtered = [...customFiltered, ...builtinFiltered].slice(0, 100);
return (
<div className="emoji-grid" style={{ height: '100%' }}>
<div style={emojiGridStyle}>
{filtered.map((emoji, idx) => (
<EmojiItem key={idx} emoji={emoji} onSelect={onSelect} />
))}
</div>
</div>
);
}
const CategoryHeader = ({ name, collapsed }) => (
<div
onClick={() => toggleCategory(name)}
style={{
display: 'flex',
alignItems: 'center',
cursor: 'pointer',
marginBottom: '8px',
padding: '4px',
borderRadius: '4px'
}}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = 'var(--background-modifier-hover)'}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="var(--header-secondary)"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
style={{
marginRight: '8px',
transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)',
transition: 'transform 0.2s'
}}
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<h3 style={{
color: 'var(--header-secondary)',
fontSize: '12px',
textTransform: 'uppercase',
fontWeight: 700,
margin: 0
}}>
{name}
</h3>
</div>
);
return (
<div className="emoji-grid" style={{ height: '100%' }}>
{customEmojis.length > 0 && (
<div style={{ marginBottom: '8px' }}>
<CategoryHeader name="Custom" collapsed={collapsedCategories['Custom']} />
{!collapsedCategories['Custom'] && (
<div style={emojiGridStyle}>
{customEmojis.map((emoji) => (
<EmojiItem key={emoji._id || emoji.name} emoji={emoji} onSelect={onSelect} />
))}
</div>
)}
</div>
)}
{Object.entries(CategorizedEmojis).map(([category, emojis]) => (
<div key={category} style={{ marginBottom: '8px' }}>
<CategoryHeader name={category} collapsed={collapsedCategories[category]} />
{!collapsedCategories[category] && (
<div style={emojiGridStyle}>
{emojis.map((emoji, idx) => (
<EmojiItem key={idx} emoji={emoji} onSelect={onSelect} />
))}
</div>
)}
</div>
))}
</div>
);
};
const initialCollapsed = Object.fromEntries(
Object.keys(CategorizedEmojis).map(cat => [cat, true])
);
const GifPicker = ({ onSelect, onClose, initialTab, currentTab, onTabChange }) => {
const [search, setSearch] = useState('');
const [categories, setCategories] = useState([]);
const [results, setResults] = useState([]);
const [loading, setLoading] = useState(false);
const [internalActiveTab, setInternalActiveTab] = useState(initialTab || 'GIFs');
const [collapsedCategories, setCollapsedCategories] = useState(initialCollapsed);
const inputRef = useRef(null);
const convex = useConvex();
const customEmojis = useQuery(api.customEmojis.list) || [];
const activeTab = currentTab !== undefined ? currentTab : internalActiveTab;
const setActiveTab = (tab) => {
if (onTabChange) onTabChange(tab);
if (currentTab === undefined) setInternalActiveTab(tab);
};
useEffect(() => {
convex.action(api.gifs.categories, {})
.then(data => {
if (data.categories) setCategories(data.categories);
})
.catch(err => console.error('Failed to load categories', err));
if (inputRef.current) inputRef.current.focus();
}, []);
useEffect(() => {
const fetchResults = async () => {
if (!search || activeTab !== 'GIFs') {
setResults([]);
return;
}
setLoading(true);
try {
const data = await convex.action(api.gifs.search, { q: search });
setResults(data.results || []);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
const timeout = setTimeout(fetchResults, 500);
return () => clearTimeout(timeout);
}, [search, activeTab]);
const toggleCategory = (categoryName) => {
setCollapsedCategories(prev => ({
...prev,
[categoryName]: !prev[categoryName]
}));
};
return (
<div
className="gif-picker"
style={{
position: 'absolute',
bottom: '50px',
right: '0',
width: '400px',
height: '450px',
backgroundColor: 'var(--embed-background)',
borderRadius: '8px',
boxShadow: '0 8px 16px rgba(0,0,0,0.24)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
zIndex: 1000
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header / Tabs */}
<div style={{ padding: '16px 16px 8px 16px', display: 'flex', gap: '16px', borderBottom: '1px solid var(--bg-tertiary)' }}>
{['GIFs', 'Stickers', 'Emoji'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
style={{
background: 'none',
border: 'none',
color: activeTab === tab ? 'var(--header-primary)' : 'var(--header-secondary)',
fontWeight: 500,
cursor: 'pointer',
fontSize: '14px',
paddingBottom: '4px',
borderBottom: activeTab === tab ? '2px solid var(--brand-experiment)' : '2px solid transparent'
}}
>
{tab}
</button>
))}
</div>
{/* Search Bar */}
<div style={{ padding: '8px 16px' }}>
<div style={{
backgroundColor: 'var(--bg-tertiary)',
borderRadius: '4px',
display: 'flex',
alignItems: 'center',
padding: '0 8px'
}}>
<input
ref={inputRef}
type="text"
placeholder={activeTab === 'Emoji' ? "Find the perfect emoji" : "Search Tenor"}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
}
}}
style={{
flex: 1,
backgroundColor: 'transparent',
border: 'none',
color: 'var(--text-normal)',
padding: '8px',
fontSize: '14px',
outline: 'none'
}}
/>
<div style={{ padding: '4px' }}>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--header-secondary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="11" cy="11" r="8"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
</div>
</div>
</div>
{/* Content Area */}
<div style={{ flex: 1, overflowY: 'auto', padding: '0 16px 16px 16px' }}>
{loading ? (
<div style={{ color: 'var(--header-secondary)', textAlign: 'center', padding: '20px' }}>Loading...</div>
) : activeTab === 'GIFs' ? (
<GifContent search={search} results={results} categories={categories} onSelect={onSelect} onCategoryClick={setSearch} />
) : (
<EmojiContent search={search} onSelect={onSelect} collapsedCategories={collapsedCategories} toggleCategory={toggleCategory} customEmojis={customEmojis} />
)}
</div>
</div>
);
};
export default GifPicker;

View File

@@ -1,28 +0,0 @@
import React from 'react';
import Avatar from './Avatar';
const IncomingCallUI = ({ callerUsername, callerAvatarUrl, onJoin, onReject }) => {
return (
<div className="incoming-call-ui">
<div className="incoming-call-avatar-ring">
<Avatar username={callerUsername} avatarUrl={callerAvatarUrl} size={80} />
</div>
<div className="incoming-call-username">{callerUsername}</div>
<div className="incoming-call-subtitle">Incoming call...</div>
<div className="incoming-call-buttons">
<button className="incoming-call-btn join" onClick={onJoin} title="Join Call">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.62 10.79a15.05 15.05 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.01-.24c1.12.37 2.33.57 3.58.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.46.57 3.58a1 1 0 0 1-.25 1.01l-2.2 2.2z"/>
</svg>
</button>
<button className="incoming-call-btn reject" onClick={onReject} title="Reject">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28a1 1 0 0 1-.71-.3L.29 13.08a1 1 0 0 1 0-1.41C3.57 8.55 7.53 7 12 7s8.43 1.55 11.71 4.67a1 1 0 0 1 0 1.41l-2.48 2.48a1 1 0 0 1-.7.29c-.27 0-.52-.11-.7-.28a11.27 11.27 0 0 0-2.67-1.85.99.99 0 0 1-.56-.9v-3.1A15.9 15.9 0 0 0 12 9z"/>
</svg>
</button>
</div>
</div>
);
};
export default IncomingCallUI;

Some files were not shown because too many files have changed in this diff Show More