feat: enhance user status management and introduce nests functionality

- Updated user status handling to include optional user-set status, improving user experience during reconnections.
- Added new queries and mutations for managing nests, including fetching non-offline user IDs and forcing users offline.
- Introduced new database schema for nests, roles, and channels, enhancing the application's organizational structure.
- Updated dependencies in package.json and bun.lock for improved stability and compatibility.
- Refactored related components and API to support the new nests functionality.
This commit is contained in:
Nixyan 2026-02-20 10:01:07 -03:00
parent 55e78db2cb
commit e7dd6c961d
39 changed files with 3087 additions and 455 deletions

217
bun.lock
View file

@ -5,60 +5,61 @@
"": { "": {
"name": "sipher", "name": "sipher",
"dependencies": { "dependencies": {
"@convex-dev/better-auth": "latest", "@convex-dev/better-auth": "^0.10.10",
"@marsidev/react-turnstile": "latest", "@marsidev/react-turnstile": "^1.4.2",
"@matrix-org/olm": "latest", "@matrix-org/olm": "^3.2.15",
"@nanostores/react": "latest", "@nanostores/react": "^1.0.0",
"@phosphor-icons/react": "latest", "@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-avatar": "latest", "@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "latest", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-context-menu": "latest", "@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "latest", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-hover-card": "latest", "@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "latest", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "latest", "@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-popover": "latest", "@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "latest", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "latest", "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-separator": "latest", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "latest", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "latest", "@radix-ui/react-tooltip": "^1.2.8",
"@types/bun": "latest", "@types/bun": "^1.3.9",
"@types/libsodium-wrappers": "latest", "@types/libsodium-wrappers": "^0.7.14",
"better-auth": "latest", "better-auth": "1.4.12",
"class-variance-authority": "latest", "class-variance-authority": "^0.7.1",
"clsx": "latest", "clsx": "^2.1.1",
"cmdk": "latest", "cmdk": "^1.1.1",
"convex": "latest", "convex": "^1.31.7",
"cross-env": "latest", "cross-env": "^10.1.0",
"date-fns": "latest", "date-fns": "^4.1.0",
"dexie": "latest", "dexie": "^4.3.0",
"dexie-react-hooks": "latest", "dexie-react-hooks": "^4.2.0",
"framer-motion": "latest", "dotenv": "^17.3.1",
"lucide-react": "latest", "framer-motion": "^12.34.0",
"moment": "latest", "lucide-react": "^0.562.0",
"nanostores": "latest", "moment": "^2.30.1",
"next": "latest", "nanostores": "^1.1.0",
"next-themes": "latest", "next": "16.1.1",
"react": "latest", "next-themes": "^0.4.6",
"react-day-picker": "latest", "react": "19.2.3",
"react-dom": "latest", "react-day-picker": "^9.13.1",
"socket.io": "latest", "react-dom": "19.2.3",
"socket.io-client": "latest", "socket.io": "^4.8.3",
"sonner": "latest", "socket.io-client": "^4.8.3",
"tailwind-merge": "latest", "sonner": "^2.0.7",
"zod": "latest", "tailwind-merge": "^3.4.0",
"zod": "^4.3.6",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "latest", "@tailwindcss/postcss": "^4.1.18",
"@types/node": "latest", "@types/node": "^25.2.2",
"@types/react": "latest", "@types/react": "^19.2.13",
"@types/react-dom": "latest", "@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "latest", "babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "latest", "tailwindcss": "^4.1.18",
"tsx": "latest", "tsx": "^4.21.0",
"tw-animate-css": "latest", "tw-animate-css": "^1.4.0",
"typescript": "latest", "typescript": "^5.9.3",
}, },
}, },
}, },
@ -213,7 +214,7 @@
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
"@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.1", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-1jE0IjvB8z+q1NFRs3149gXzXwIzXQWqQjn9fmAr13BiE3RYLWck5Me6flHYE90shW5L12Jkm6R1peS1OnA9oQ=="], "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.2", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-xs1qOuyeMOz6t9BXXCXWiukC0/0+48vR08B7uwNdG05wCMnbcNgxiFmdFKDOFbM76qFYFRYlGeRfhfq1U/iZmA=="],
"@matrix-org/olm": ["@matrix-org/olm@3.2.15", "", {}, "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q=="], "@matrix-org/olm": ["@matrix-org/olm@3.2.15", "", {}, "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q=="],
@ -359,45 +360,45 @@
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/node": ["@tailwindcss/node@4.2.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.0" } }, "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.0", "@tailwindcss/oxide-darwin-arm64": "4.2.0", "@tailwindcss/oxide-darwin-x64": "4.2.0", "@tailwindcss/oxide-freebsd-x64": "4.2.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.0", "@tailwindcss/oxide-linux-arm64-musl": "4.2.0", "@tailwindcss/oxide-linux-x64-gnu": "4.2.0", "@tailwindcss/oxide-linux-x64-musl": "4.2.0", "@tailwindcss/oxide-wasm32-wasi": "4.2.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.0", "@tailwindcss/oxide-win32-x64-msvc": "4.2.0" } }, "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ=="],
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw=="],
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA=="],
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg=="],
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw=="],
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg=="],
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA=="],
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A=="],
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA=="],
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw=="],
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.0", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw=="],
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA=="],
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ=="],
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.0", "@tailwindcss/oxide": "4.2.0", "postcss": "^8.5.6", "tailwindcss": "4.2.0" } }, "sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="],
"@types/libsodium-wrappers": ["@types/libsodium-wrappers@0.7.14", "", {}, "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ=="], "@types/libsodium-wrappers": ["@types/libsodium-wrappers@0.7.14", "", {}, "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ=="],
"@types/node": ["@types/node@25.0.8", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg=="], "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
"@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
@ -417,7 +418,7 @@
"better-call": ["better-call@1.1.7", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-6gaJe1bBIEgVebQu/7q9saahVzvBsGaByEnE8aDVncZEDiJO7sdNB28ot9I6iXSbR25egGmmZ6aIURXyQHRraQ=="], "better-call": ["better-call@1.1.7", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-6gaJe1bBIEgVebQu/7q9saahVzvBsGaByEnE8aDVncZEDiJO7sdNB28ot9I6iXSbR25egGmmZ6aIURXyQHRraQ=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001757", "", {}, "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ=="], "caniuse-lite": ["caniuse-lite@1.0.30001757", "", {}, "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ=="],
@ -431,7 +432,7 @@
"common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="],
"convex": ["convex@1.31.4", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-iDm283Gb/CFRb30cvhH6Z9qlYof6dhtin415FarKUKB3K7gumO0rn8snY0CTvUrThV3UnCtttbuL/1oY7LscyA=="], "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="],
"convex-helpers": ["convex-helpers@0.1.106", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-hWRe3yDaAVHMe4CUYw1YoQLiPZ1KIx6Kbf0w6UcRDx1BXpJgMCl3GVIMiSeYiA0PkbwjnIwGWIvoUVKloG5Tyw=="], "convex-helpers": ["convex-helpers@0.1.106", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-hWRe3yDaAVHMe4CUYw1YoQLiPZ1KIx6Kbf0w6UcRDx1BXpJgMCl3GVIMiSeYiA0PkbwjnIwGWIvoUVKloG5Tyw=="],
@ -457,21 +458,23 @@
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dexie": ["dexie@4.2.1", "", {}, "sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg=="], "dexie": ["dexie@4.3.0", "", {}, "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug=="],
"dexie-react-hooks": ["dexie-react-hooks@4.2.0", "", { "peerDependencies": { "@types/react": ">=16", "dexie": ">=4.2.0-alpha.1 <5.0.0", "react": ">=16" } }, "sha512-u7KqTX9JpBQK8+tEyA9X0yMGXlSCsbm5AU64N6gjvGk/IutYDpLBInMYEAEC83s3qhIvryFS+W+sqLZUBEvePQ=="], "dexie-react-hooks": ["dexie-react-hooks@4.2.0", "", { "peerDependencies": { "@types/react": ">=16", "dexie": ">=4.2.0-alpha.1 <5.0.0", "react": ">=16" } }, "sha512-u7KqTX9JpBQK8+tEyA9X0yMGXlSCsbm5AU64N6gjvGk/IutYDpLBInMYEAEC83s3qhIvryFS+W+sqLZUBEvePQ=="],
"dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="],
"engine.io": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="], "engine.io": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="],
"engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="], "engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"framer-motion": ["framer-motion@12.26.2", "", { "dependencies": { "motion-dom": "^12.26.2", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA=="], "framer-motion": ["framer-motion@12.34.2", "", { "dependencies": { "motion-dom": "^12.34.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-CcnYTzbRybm1/OE8QLXfXI8gR1cx5T4dF3D2kn5IyqsGNeLAKl2iFHb2BzFyXBGqESntDt6rPYl4Jhrb7tdB8g=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@ -489,29 +492,29 @@
"kysely": ["kysely@0.28.8", "", {}, "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA=="], "kysely": ["kysely@0.28.8", "", {}, "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="],
@ -523,9 +526,9 @@
"moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="],
"motion-dom": ["motion-dom@12.26.2", "", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw=="], "motion-dom": ["motion-dom@12.34.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-n7gknp7gHcW7DUcmet0JVPLVHmE3j9uWwDp5VbE3IkCNnW5qdu0mOhjNYzXMkrQjrgr+h6Db3EDM2QBhW2qNxQ=="],
"motion-utils": ["motion-utils@12.24.10", "", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="], "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@ -555,7 +558,7 @@
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="],
"react-day-picker": ["react-day-picker@9.13.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ=="], "react-day-picker": ["react-day-picker@9.13.2", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="],
@ -599,9 +602,9 @@
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tailwindcss": ["tailwindcss@4.2.0", "", {}, "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
@ -617,7 +620,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@ -629,16 +632,20 @@
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
"zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"@better-auth/core/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@better-auth/passkey/@better-auth/core": ["@better-auth/core@1.4.9", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.7", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-JT2q4NDkQzN22KclUEoZ7qU6tl9HUTfK1ctg2oWlT87SEagkwJcnrUwS9VznL+u9ziOIfY27P0f7/jSnmvLcoQ=="], "@better-auth/passkey/@better-auth/core": ["@better-auth/core@1.4.9", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.7", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-JT2q4NDkQzN22KclUEoZ7qU6tl9HUTfK1ctg2oWlT87SEagkwJcnrUwS9VznL+u9ziOIfY27P0f7/jSnmvLcoQ=="],
"@better-auth/passkey/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], "@better-auth/passkey/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
"@convex-dev/better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], "@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="],
@ -707,13 +714,13 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="], "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@ -721,18 +728,26 @@
"@types/cors/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "@types/cors/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], "convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
"engine.io/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "engine.io/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"engine.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "engine.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"engine.io/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
"engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"engine.io-client/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
"next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
"socket.io-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "socket.io-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"socket.io-adapter/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
"socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
@ -761,6 +776,8 @@
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@types/cors/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], "convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="],
"convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], "convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="],
@ -812,5 +829,7 @@
"convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], "convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="],
"convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], "convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="],
"engine.io/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
} }
} }

View file

@ -67,6 +67,7 @@ export declare const components: {
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name: string; name: string;
nests?: Array<string>;
updatedAt: number; updatedAt: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
@ -75,10 +76,14 @@ export declare const components: {
} }
| { | {
data: { data: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away"; status: "online" | "busy" | "offline" | "away";
updatedAt: number; updatedAt: number;
userId: string; userId: string;
userSetStatus?: {
isSet: boolean;
status: "online" | "busy" | "offline" | "away";
updatedAt: number;
};
}; };
model: "userStatus"; model: "userStatus";
} }
@ -100,6 +105,63 @@ export declare const components: {
data: { createdAt: number; friendId: string; userId: string }; data: { createdAt: number; friendId: string; userId: string };
model: "friends"; model: "friends";
} }
| {
data: {
channels: Array<string>;
colors?: { accent: string; primary: string };
createdAt: number;
description?: string;
emojis: Array<{
createdAt: number;
id: string;
name: string;
}>;
images?: { banner: string; icon: string };
managerId: string;
members: Array<string>;
name: string;
onDiscover?: boolean;
region?: string;
roles: Array<string>;
type: "global" | "regional" | "private";
updatedAt: number;
};
model: "nests";
}
| {
data: {
color?: string;
createdAt: number;
flags: Array<bigint>;
hoist?: boolean;
icon?: string;
members: Array<string>;
mentionable?: boolean;
name: string;
nestId: string;
permissions: Array<bigint>;
position?: number;
updatedAt: number;
};
model: "roles";
}
| {
data: {
createdAt: number;
name: string;
nestId: string;
overwrites: Array<{
allow: Array<bigint> | null;
deny: Array<bigint> | null;
id: string | string;
}>;
permissions: Array<bigint>;
position: number;
type: "text" | "category" | "announcement";
updatedAt: number;
};
model: "channels";
}
| { | {
data: { data: {
attachments?: Array<string>; attachments?: Array<string>;
@ -182,8 +244,11 @@ export declare const components: {
} }
| { | {
data: { data: {
createdAt?: number;
identityKey: { curve25519: string; ed25519: string }; identityKey: { curve25519: string; ed25519: string };
keyVersion?: number;
oneTimeKeys: Array<{ keyId: string; publicKey: string }>; oneTimeKeys: Array<{ keyId: string; publicKey: string }>;
updatedAt?: number;
userId: string; userId: string;
}; };
model: "olmAccount"; model: "olmAccount";
@ -213,6 +278,7 @@ export declare const components: {
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "nests"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -242,7 +308,7 @@ export declare const components: {
field: field:
| "userId" | "userId"
| "status" | "status"
| "isUserSet" | "userSetStatus"
| "updatedAt" | "updatedAt"
| "_id"; | "_id";
operator?: operator?:
@ -328,6 +394,121 @@ export declare const components: {
| null; | null;
}>; }>;
} }
| {
model: "nests";
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "description"
| "images"
| "colors"
| "createdAt"
| "updatedAt"
| "managerId"
| "members"
| "channels"
| "roles"
| "region"
| "emojis"
| "onDiscover"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "roles";
where?: Array<{
connector?: "AND" | "OR";
field:
| "nestId"
| "name"
| "color"
| "hoist"
| "mentionable"
| "icon"
| "position"
| "permissions"
| "flags"
| "createdAt"
| "updatedAt"
| "members"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "channels";
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "nestId"
| "position"
| "permissions"
| "overwrites"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| { | {
model: "messages"; model: "messages";
where?: Array<{ where?: Array<{
@ -540,7 +721,14 @@ export declare const components: {
model: "olmAccount"; model: "olmAccount";
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; field:
| "userId"
| "identityKey"
| "oneTimeKeys"
| "createdAt"
| "updatedAt"
| "keyVersion"
| "_id";
operator?: operator?:
| "lt" | "lt"
| "lte" | "lte"
@ -594,6 +782,7 @@ export declare const components: {
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "nests"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -623,7 +812,7 @@ export declare const components: {
field: field:
| "userId" | "userId"
| "status" | "status"
| "isUserSet" | "userSetStatus"
| "updatedAt" | "updatedAt"
| "_id"; | "_id";
operator?: operator?:
@ -709,6 +898,121 @@ export declare const components: {
| null; | null;
}>; }>;
} }
| {
model: "nests";
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "description"
| "images"
| "colors"
| "createdAt"
| "updatedAt"
| "managerId"
| "members"
| "channels"
| "roles"
| "region"
| "emojis"
| "onDiscover"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "roles";
where?: Array<{
connector?: "AND" | "OR";
field:
| "nestId"
| "name"
| "color"
| "hoist"
| "mentionable"
| "icon"
| "position"
| "permissions"
| "flags"
| "createdAt"
| "updatedAt"
| "members"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "channels";
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "nestId"
| "position"
| "permissions"
| "overwrites"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| { | {
model: "messages"; model: "messages";
where?: Array<{ where?: Array<{
@ -921,7 +1225,14 @@ export declare const components: {
model: "olmAccount"; model: "olmAccount";
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; field:
| "userId"
| "identityKey"
| "oneTimeKeys"
| "createdAt"
| "updatedAt"
| "keyVersion"
| "_id";
operator?: operator?:
| "lt" | "lt"
| "lte" | "lte"
@ -958,6 +1269,9 @@ export declare const components: {
| "userStatus" | "userStatus"
| "friendRequests" | "friendRequests"
| "friends" | "friends"
| "nests"
| "roles"
| "channels"
| "messages" | "messages"
| "attachments" | "attachments"
| "session" | "session"
@ -1011,6 +1325,9 @@ export declare const components: {
| "userStatus" | "userStatus"
| "friendRequests" | "friendRequests"
| "friends" | "friends"
| "nests"
| "roles"
| "channels"
| "messages" | "messages"
| "attachments" | "attachments"
| "session" | "session"
@ -1062,6 +1379,7 @@ export declare const components: {
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name?: string; name?: string;
nests?: Array<string>;
updatedAt?: number; updatedAt?: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
@ -1079,6 +1397,7 @@ export declare const components: {
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "nests"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -1104,17 +1423,21 @@ export declare const components: {
| { | {
model: "userStatus"; model: "userStatus";
update: { update: {
isUserSet?: boolean;
status?: "online" | "busy" | "offline" | "away"; status?: "online" | "busy" | "offline" | "away";
updatedAt?: number; updatedAt?: number;
userId?: string; userId?: string;
userSetStatus?: {
isSet: boolean;
status: "online" | "busy" | "offline" | "away";
updatedAt: number;
};
}; };
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: field:
| "userId" | "userId"
| "status" | "status"
| "isUserSet" | "userSetStatus"
| "updatedAt" | "updatedAt"
| "_id"; | "_id";
operator?: operator?:
@ -1216,6 +1539,169 @@ export declare const components: {
| null; | null;
}>; }>;
} }
| {
model: "nests";
update: {
channels?: Array<string>;
colors?: { accent: string; primary: string };
createdAt?: number;
description?: string;
emojis?: Array<{
createdAt: number;
id: string;
name: string;
}>;
images?: { banner: string; icon: string };
managerId?: string;
members?: Array<string>;
name?: string;
onDiscover?: boolean;
region?: string;
roles?: Array<string>;
type?: "global" | "regional" | "private";
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "description"
| "images"
| "colors"
| "createdAt"
| "updatedAt"
| "managerId"
| "members"
| "channels"
| "roles"
| "region"
| "emojis"
| "onDiscover"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "roles";
update: {
color?: string;
createdAt?: number;
flags?: Array<bigint>;
hoist?: boolean;
icon?: string;
members?: Array<string>;
mentionable?: boolean;
name?: string;
nestId?: string;
permissions?: Array<bigint>;
position?: number;
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "nestId"
| "name"
| "color"
| "hoist"
| "mentionable"
| "icon"
| "position"
| "permissions"
| "flags"
| "createdAt"
| "updatedAt"
| "members"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "channels";
update: {
createdAt?: number;
name?: string;
nestId?: string;
overwrites?: Array<{
allow: Array<bigint> | null;
deny: Array<bigint> | null;
id: string | string;
}>;
permissions?: Array<bigint>;
position?: number;
type?: "text" | "category" | "announcement";
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "nestId"
| "position"
| "permissions"
| "overwrites"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| { | {
model: "messages"; model: "messages";
update: { update: {
@ -1489,13 +1975,23 @@ export declare const components: {
| { | {
model: "olmAccount"; model: "olmAccount";
update: { update: {
createdAt?: number;
identityKey?: { curve25519: string; ed25519: string }; identityKey?: { curve25519: string; ed25519: string };
keyVersion?: number;
oneTimeKeys?: Array<{ keyId: string; publicKey: string }>; oneTimeKeys?: Array<{ keyId: string; publicKey: string }>;
updatedAt?: number;
userId?: string; userId?: string;
}; };
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; field:
| "userId"
| "identityKey"
| "oneTimeKeys"
| "createdAt"
| "updatedAt"
| "keyVersion"
| "_id";
operator?: operator?:
| "lt" | "lt"
| "lte" | "lte"
@ -1546,6 +2042,7 @@ export declare const components: {
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name?: string; name?: string;
nests?: Array<string>;
updatedAt?: number; updatedAt?: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
@ -1563,6 +2060,7 @@ export declare const components: {
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "nests"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -1588,17 +2086,21 @@ export declare const components: {
| { | {
model: "userStatus"; model: "userStatus";
update: { update: {
isUserSet?: boolean;
status?: "online" | "busy" | "offline" | "away"; status?: "online" | "busy" | "offline" | "away";
updatedAt?: number; updatedAt?: number;
userId?: string; userId?: string;
userSetStatus?: {
isSet: boolean;
status: "online" | "busy" | "offline" | "away";
updatedAt: number;
};
}; };
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: field:
| "userId" | "userId"
| "status" | "status"
| "isUserSet" | "userSetStatus"
| "updatedAt" | "updatedAt"
| "_id"; | "_id";
operator?: operator?:
@ -1700,6 +2202,169 @@ export declare const components: {
| null; | null;
}>; }>;
} }
| {
model: "nests";
update: {
channels?: Array<string>;
colors?: { accent: string; primary: string };
createdAt?: number;
description?: string;
emojis?: Array<{
createdAt: number;
id: string;
name: string;
}>;
images?: { banner: string; icon: string };
managerId?: string;
members?: Array<string>;
name?: string;
onDiscover?: boolean;
region?: string;
roles?: Array<string>;
type?: "global" | "regional" | "private";
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "description"
| "images"
| "colors"
| "createdAt"
| "updatedAt"
| "managerId"
| "members"
| "channels"
| "roles"
| "region"
| "emojis"
| "onDiscover"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "roles";
update: {
color?: string;
createdAt?: number;
flags?: Array<bigint>;
hoist?: boolean;
icon?: string;
members?: Array<string>;
mentionable?: boolean;
name?: string;
nestId?: string;
permissions?: Array<bigint>;
position?: number;
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "nestId"
| "name"
| "color"
| "hoist"
| "mentionable"
| "icon"
| "position"
| "permissions"
| "flags"
| "createdAt"
| "updatedAt"
| "members"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "channels";
update: {
createdAt?: number;
name?: string;
nestId?: string;
overwrites?: Array<{
allow: Array<bigint> | null;
deny: Array<bigint> | null;
id: string | string;
}>;
permissions?: Array<bigint>;
position?: number;
type?: "text" | "category" | "announcement";
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "nestId"
| "position"
| "permissions"
| "overwrites"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| { | {
model: "messages"; model: "messages";
update: { update: {
@ -1973,13 +2638,23 @@ export declare const components: {
| { | {
model: "olmAccount"; model: "olmAccount";
update: { update: {
createdAt?: number;
identityKey?: { curve25519: string; ed25519: string }; identityKey?: { curve25519: string; ed25519: string };
keyVersion?: number;
oneTimeKeys?: Array<{ keyId: string; publicKey: string }>; oneTimeKeys?: Array<{ keyId: string; publicKey: string }>;
updatedAt?: number;
userId?: string; userId?: string;
}; };
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; field:
| "userId"
| "identityKey"
| "oneTimeKeys"
| "createdAt"
| "updatedAt"
| "keyVersion"
| "_id";
operator?: operator?:
| "lt" | "lt"
| "lte" | "lte"
@ -2006,6 +2681,12 @@ export declare const components: {
any any
>; >;
}; };
nests: {
locals: {
getRecommendedNests: FunctionReference<"query", "internal", any, any>;
getUserNests: FunctionReference<"query", "internal", any, any>;
};
};
olm: { olm: {
index: { index: {
consumeOTK: FunctionReference< consumeOTK: FunctionReference<
@ -2014,6 +2695,13 @@ export declare const components: {
{ keyId: string; userId: string }, { keyId: string; userId: string },
any any
>; >;
getKeyVersion: FunctionReference<
"query",
"internal",
{ userId: string },
any
>;
migrateOlmAccounts: FunctionReference<"mutation", "internal", any, any>;
retrieveServerOlmAccount: FunctionReference< retrieveServerOlmAccount: FunctionReference<
"query", "query",
"internal", "internal",
@ -2041,8 +2729,15 @@ export declare const components: {
{ answer: "accept" | "decline" | "ignore"; requestId: string }, { answer: "accept" | "decline" | "ignore"; requestId: string },
any any
>; >;
forceUserOffline: FunctionReference<
"mutation",
"internal",
{ userId: string },
any
>;
getFriendRequests: FunctionReference<"query", "internal", any, any>; getFriendRequests: FunctionReference<"query", "internal", any, any>;
getFriends: FunctionReference<"query", "internal", any, any>; getFriends: FunctionReference<"query", "internal", any, any>;
getNonOfflineUserIds: FunctionReference<"query", "internal", {}, any>;
getParticipantDetails: FunctionReference< getParticipantDetails: FunctionReference<
"query", "query",
"internal", "internal",
@ -2066,7 +2761,7 @@ export declare const components: {
"mutation", "mutation",
"internal", "internal",
{ {
isUserSet: boolean; isUserSet?: boolean;
status: "online" | "busy" | "offline" | "away"; status: "online" | "busy" | "offline" | "away";
}, },
any any

View file

@ -38,7 +38,7 @@ export type Doc = any;
* Convex documents are uniquely identified by their `Id`, which is accessible * Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
* *
* Documents can be loaded using `db.get(id)` in query and mutation functions. * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
* *
* IDs are just strings at runtime, but this type can be used to distinguish them from other * IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking. * strings when type checking.

View file

@ -122,7 +122,7 @@ export const retrieveServerOlmAccount = query({
export const updateUserStatus = mutation({ export const updateUserStatus = mutation({
args: { args: {
status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
isUserSet: v.boolean(), isUserSet: v.optional(v.boolean()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
return ctx.runMutation(components.betterAuth.user.index.updateUserStatus, { return ctx.runMutation(components.betterAuth.user.index.updateUserStatus, {
@ -132,6 +132,24 @@ export const updateUserStatus = mutation({
}, },
}); });
export const getNonOfflineUserIds = query({
args: {},
handler: async (ctx) => {
return ctx.runQuery(components.betterAuth.user.index.getNonOfflineUserIds, {});
},
});
export const forceUserOffline = mutation({
args: {
userId: v.string(),
},
handler: async (ctx, args) => {
return ctx.runMutation(components.betterAuth.user.index.forceUserOffline, {
userId: args.userId,
});
},
});
export const updateUserMetadata = mutation({ export const updateUserMetadata = mutation({
args: { args: {
metadata: v.object({ metadata: v.object({
@ -213,3 +231,26 @@ export const consumeOTK = mutation({
}); });
}, },
}); });
export const getKeyVersion = query({
args: {
userId: v.string(),
},
handler: async (ctx, args) => {
return ctx.runQuery(components.betterAuth.olm.index.getKeyVersion, {
userId: args.userId,
});
},
});
export const migrateOlmAccounts = mutation({
handler: async (ctx) => {
return ctx.runMutation(components.betterAuth.olm.index.migrateOlmAccounts, {});
},
});
export const getUserNests = query({
handler: async (ctx) => {
return ctx.runQuery(components.betterAuth.nests.locals.getUserNests);
},
});

View file

@ -10,7 +10,9 @@
import type * as adapter from "../adapter.js"; import type * as adapter from "../adapter.js";
import type * as auth from "../auth.js"; import type * as auth from "../auth.js";
import type * as nests_locals from "../nests/locals.js";
import type * as olm_index from "../olm/index.js"; import type * as olm_index from "../olm/index.js";
import type * as schemas_nests from "../schemas/nests.js";
import type * as schemas_user from "../schemas/user.js"; import type * as schemas_user from "../schemas/user.js";
import type * as user_index from "../user/index.js"; import type * as user_index from "../user/index.js";
@ -24,7 +26,9 @@ import { anyApi, componentsGeneric } from "convex/server";
const fullApi: ApiFromModules<{ const fullApi: ApiFromModules<{
adapter: typeof adapter; adapter: typeof adapter;
auth: typeof auth; auth: typeof auth;
"nests/locals": typeof nests_locals;
"olm/index": typeof olm_index; "olm/index": typeof olm_index;
"schemas/nests": typeof schemas_nests;
"schemas/user": typeof schemas_user; "schemas/user": typeof schemas_user;
"user/index": typeof user_index; "user/index": typeof user_index;
}> = anyApi as any; }> = anyApi as any;

View file

@ -40,6 +40,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name: string; name: string;
nests?: Array<string>;
updatedAt: number; updatedAt: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
@ -48,10 +49,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
} }
| { | {
data: { data: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away"; status: "online" | "busy" | "offline" | "away";
updatedAt: number; updatedAt: number;
userId: string; userId: string;
userSetStatus?: {
isSet: boolean;
status: "online" | "busy" | "offline" | "away";
updatedAt: number;
};
}; };
model: "userStatus"; model: "userStatus";
} }
@ -73,6 +78,63 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
data: { createdAt: number; friendId: string; userId: string }; data: { createdAt: number; friendId: string; userId: string };
model: "friends"; model: "friends";
} }
| {
data: {
channels: Array<string>;
colors?: { accent: string; primary: string };
createdAt: number;
description?: string;
emojis: Array<{
createdAt: number;
id: string;
name: string;
}>;
images?: { banner: string; icon: string };
managerId: string;
members: Array<string>;
name: string;
onDiscover?: boolean;
region?: string;
roles: Array<string>;
type: "global" | "regional" | "private";
updatedAt: number;
};
model: "nests";
}
| {
data: {
color?: string;
createdAt: number;
flags: Array<bigint>;
hoist?: boolean;
icon?: string;
members: Array<string>;
mentionable?: boolean;
name: string;
nestId: string;
permissions: Array<bigint>;
position?: number;
updatedAt: number;
};
model: "roles";
}
| {
data: {
createdAt: number;
name: string;
nestId: string;
overwrites: Array<{
allow: Array<bigint> | null;
deny: Array<bigint> | null;
id: string | string;
}>;
permissions: Array<bigint>;
position: number;
type: "text" | "category" | "announcement";
updatedAt: number;
};
model: "channels";
}
| { | {
data: { data: {
attachments?: Array<string>; attachments?: Array<string>;
@ -155,8 +217,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
} }
| { | {
data: { data: {
createdAt?: number;
identityKey: { curve25519: string; ed25519: string }; identityKey: { curve25519: string; ed25519: string };
keyVersion?: number;
oneTimeKeys: Array<{ keyId: string; publicKey: string }>; oneTimeKeys: Array<{ keyId: string; publicKey: string }>;
updatedAt?: number;
userId: string; userId: string;
}; };
model: "olmAccount"; model: "olmAccount";
@ -187,6 +252,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "nests"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -216,7 +282,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
field: field:
| "userId" | "userId"
| "status" | "status"
| "isUserSet" | "userSetStatus"
| "updatedAt" | "updatedAt"
| "_id"; | "_id";
operator?: operator?:
@ -302,6 +368,121 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| null; | null;
}>; }>;
} }
| {
model: "nests";
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "description"
| "images"
| "colors"
| "createdAt"
| "updatedAt"
| "managerId"
| "members"
| "channels"
| "roles"
| "region"
| "emojis"
| "onDiscover"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "roles";
where?: Array<{
connector?: "AND" | "OR";
field:
| "nestId"
| "name"
| "color"
| "hoist"
| "mentionable"
| "icon"
| "position"
| "permissions"
| "flags"
| "createdAt"
| "updatedAt"
| "members"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "channels";
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "nestId"
| "position"
| "permissions"
| "overwrites"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| { | {
model: "messages"; model: "messages";
where?: Array<{ where?: Array<{
@ -514,7 +695,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
model: "olmAccount"; model: "olmAccount";
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; field:
| "userId"
| "identityKey"
| "oneTimeKeys"
| "createdAt"
| "updatedAt"
| "keyVersion"
| "_id";
operator?: operator?:
| "lt" | "lt"
| "lte" | "lte"
@ -569,6 +757,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "nests"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -598,7 +787,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
field: field:
| "userId" | "userId"
| "status" | "status"
| "isUserSet" | "userSetStatus"
| "updatedAt" | "updatedAt"
| "_id"; | "_id";
operator?: operator?:
@ -684,6 +873,121 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| null; | null;
}>; }>;
} }
| {
model: "nests";
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "description"
| "images"
| "colors"
| "createdAt"
| "updatedAt"
| "managerId"
| "members"
| "channels"
| "roles"
| "region"
| "emojis"
| "onDiscover"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "roles";
where?: Array<{
connector?: "AND" | "OR";
field:
| "nestId"
| "name"
| "color"
| "hoist"
| "mentionable"
| "icon"
| "position"
| "permissions"
| "flags"
| "createdAt"
| "updatedAt"
| "members"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "channels";
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "nestId"
| "position"
| "permissions"
| "overwrites"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| { | {
model: "messages"; model: "messages";
where?: Array<{ where?: Array<{
@ -896,7 +1200,14 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
model: "olmAccount"; model: "olmAccount";
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; field:
| "userId"
| "identityKey"
| "oneTimeKeys"
| "createdAt"
| "updatedAt"
| "keyVersion"
| "_id";
operator?: operator?:
| "lt" | "lt"
| "lte" | "lte"
@ -934,6 +1245,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "userStatus" | "userStatus"
| "friendRequests" | "friendRequests"
| "friends" | "friends"
| "nests"
| "roles"
| "channels"
| "messages" | "messages"
| "attachments" | "attachments"
| "session" | "session"
@ -988,6 +1302,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "userStatus" | "userStatus"
| "friendRequests" | "friendRequests"
| "friends" | "friends"
| "nests"
| "roles"
| "channels"
| "messages" | "messages"
| "attachments" | "attachments"
| "session" | "session"
@ -1040,6 +1357,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name?: string; name?: string;
nests?: Array<string>;
updatedAt?: number; updatedAt?: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
@ -1057,6 +1375,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "nests"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -1082,17 +1401,21 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| { | {
model: "userStatus"; model: "userStatus";
update: { update: {
isUserSet?: boolean;
status?: "online" | "busy" | "offline" | "away"; status?: "online" | "busy" | "offline" | "away";
updatedAt?: number; updatedAt?: number;
userId?: string; userId?: string;
userSetStatus?: {
isSet: boolean;
status: "online" | "busy" | "offline" | "away";
updatedAt: number;
};
}; };
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: field:
| "userId" | "userId"
| "status" | "status"
| "isUserSet" | "userSetStatus"
| "updatedAt" | "updatedAt"
| "_id"; | "_id";
operator?: operator?:
@ -1194,6 +1517,169 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| null; | null;
}>; }>;
} }
| {
model: "nests";
update: {
channels?: Array<string>;
colors?: { accent: string; primary: string };
createdAt?: number;
description?: string;
emojis?: Array<{
createdAt: number;
id: string;
name: string;
}>;
images?: { banner: string; icon: string };
managerId?: string;
members?: Array<string>;
name?: string;
onDiscover?: boolean;
region?: string;
roles?: Array<string>;
type?: "global" | "regional" | "private";
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "description"
| "images"
| "colors"
| "createdAt"
| "updatedAt"
| "managerId"
| "members"
| "channels"
| "roles"
| "region"
| "emojis"
| "onDiscover"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "roles";
update: {
color?: string;
createdAt?: number;
flags?: Array<bigint>;
hoist?: boolean;
icon?: string;
members?: Array<string>;
mentionable?: boolean;
name?: string;
nestId?: string;
permissions?: Array<bigint>;
position?: number;
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "nestId"
| "name"
| "color"
| "hoist"
| "mentionable"
| "icon"
| "position"
| "permissions"
| "flags"
| "createdAt"
| "updatedAt"
| "members"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "channels";
update: {
createdAt?: number;
name?: string;
nestId?: string;
overwrites?: Array<{
allow: Array<bigint> | null;
deny: Array<bigint> | null;
id: string | string;
}>;
permissions?: Array<bigint>;
position?: number;
type?: "text" | "category" | "announcement";
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "nestId"
| "position"
| "permissions"
| "overwrites"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| { | {
model: "messages"; model: "messages";
update: { update: {
@ -1467,13 +1953,23 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| { | {
model: "olmAccount"; model: "olmAccount";
update: { update: {
createdAt?: number;
identityKey?: { curve25519: string; ed25519: string }; identityKey?: { curve25519: string; ed25519: string };
keyVersion?: number;
oneTimeKeys?: Array<{ keyId: string; publicKey: string }>; oneTimeKeys?: Array<{ keyId: string; publicKey: string }>;
updatedAt?: number;
userId?: string; userId?: string;
}; };
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; field:
| "userId"
| "identityKey"
| "oneTimeKeys"
| "createdAt"
| "updatedAt"
| "keyVersion"
| "_id";
operator?: operator?:
| "lt" | "lt"
| "lte" | "lte"
@ -1525,6 +2021,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name?: string; name?: string;
nests?: Array<string>;
updatedAt?: number; updatedAt?: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
@ -1542,6 +2039,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "nests"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -1567,17 +2065,21 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| { | {
model: "userStatus"; model: "userStatus";
update: { update: {
isUserSet?: boolean;
status?: "online" | "busy" | "offline" | "away"; status?: "online" | "busy" | "offline" | "away";
updatedAt?: number; updatedAt?: number;
userId?: string; userId?: string;
userSetStatus?: {
isSet: boolean;
status: "online" | "busy" | "offline" | "away";
updatedAt: number;
};
}; };
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: field:
| "userId" | "userId"
| "status" | "status"
| "isUserSet" | "userSetStatus"
| "updatedAt" | "updatedAt"
| "_id"; | "_id";
operator?: operator?:
@ -1679,6 +2181,169 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| null; | null;
}>; }>;
} }
| {
model: "nests";
update: {
channels?: Array<string>;
colors?: { accent: string; primary: string };
createdAt?: number;
description?: string;
emojis?: Array<{
createdAt: number;
id: string;
name: string;
}>;
images?: { banner: string; icon: string };
managerId?: string;
members?: Array<string>;
name?: string;
onDiscover?: boolean;
region?: string;
roles?: Array<string>;
type?: "global" | "regional" | "private";
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "description"
| "images"
| "colors"
| "createdAt"
| "updatedAt"
| "managerId"
| "members"
| "channels"
| "roles"
| "region"
| "emojis"
| "onDiscover"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "roles";
update: {
color?: string;
createdAt?: number;
flags?: Array<bigint>;
hoist?: boolean;
icon?: string;
members?: Array<string>;
mentionable?: boolean;
name?: string;
nestId?: string;
permissions?: Array<bigint>;
position?: number;
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "nestId"
| "name"
| "color"
| "hoist"
| "mentionable"
| "icon"
| "position"
| "permissions"
| "flags"
| "createdAt"
| "updatedAt"
| "members"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "channels";
update: {
createdAt?: number;
name?: string;
nestId?: string;
overwrites?: Array<{
allow: Array<bigint> | null;
deny: Array<bigint> | null;
id: string | string;
}>;
permissions?: Array<bigint>;
position?: number;
type?: "text" | "category" | "announcement";
updatedAt?: number;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "type"
| "name"
| "nestId"
| "position"
| "permissions"
| "overwrites"
| "createdAt"
| "updatedAt"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| { | {
model: "messages"; model: "messages";
update: { update: {
@ -1952,13 +2617,23 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| { | {
model: "olmAccount"; model: "olmAccount";
update: { update: {
createdAt?: number;
identityKey?: { curve25519: string; ed25519: string }; identityKey?: { curve25519: string; ed25519: string };
keyVersion?: number;
oneTimeKeys?: Array<{ keyId: string; publicKey: string }>; oneTimeKeys?: Array<{ keyId: string; publicKey: string }>;
updatedAt?: number;
userId?: string; userId?: string;
}; };
where?: Array<{ where?: Array<{
connector?: "AND" | "OR"; connector?: "AND" | "OR";
field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; field:
| "userId"
| "identityKey"
| "oneTimeKeys"
| "createdAt"
| "updatedAt"
| "keyVersion"
| "_id";
operator?: operator?:
| "lt" | "lt"
| "lte" | "lte"
@ -1986,6 +2661,18 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
Name Name
>; >;
}; };
nests: {
locals: {
getRecommendedNests: FunctionReference<
"query",
"internal",
any,
any,
Name
>;
getUserNests: FunctionReference<"query", "internal", any, any, Name>;
};
};
olm: { olm: {
index: { index: {
consumeOTK: FunctionReference< consumeOTK: FunctionReference<
@ -1995,6 +2682,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
any, any,
Name Name
>; >;
getKeyVersion: FunctionReference<
"query",
"internal",
{ userId: string },
any,
Name
>;
migrateOlmAccounts: FunctionReference<
"mutation",
"internal",
any,
any,
Name
>;
retrieveServerOlmAccount: FunctionReference< retrieveServerOlmAccount: FunctionReference<
"query", "query",
"internal", "internal",
@ -2025,6 +2726,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
any, any,
Name Name
>; >;
forceUserOffline: FunctionReference<
"mutation",
"internal",
{ userId: string },
any,
Name
>;
getFriendRequests: FunctionReference< getFriendRequests: FunctionReference<
"query", "query",
"internal", "internal",
@ -2033,6 +2741,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
Name Name
>; >;
getFriends: FunctionReference<"query", "internal", any, any, Name>; getFriends: FunctionReference<"query", "internal", any, any, Name>;
getNonOfflineUserIds: FunctionReference<
"query",
"internal",
{},
any,
Name
>;
getParticipantDetails: FunctionReference< getParticipantDetails: FunctionReference<
"query", "query",
"internal", "internal",
@ -2059,7 +2774,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
"mutation", "mutation",
"internal", "internal",
{ {
isUserSet: boolean; isUserSet?: boolean;
status: "online" | "busy" | "offline" | "away"; status: "online" | "busy" | "offline" | "away";
}, },
any, any,

View file

@ -38,7 +38,7 @@ export type Doc<TableName extends TableNames> = DocumentByName<
* Convex documents are uniquely identified by their `Id`, which is accessible * Convex documents are uniquely identified by their `Id`, which is accessible
* on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
* *
* Documents can be loaded using `db.get(id)` in query and mutation functions. * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions.
* *
* IDs are just strings at runtime, but this type can be used to distinguish them from other * IDs are just strings at runtime, but this type can be used to distinguish them from other
* strings when type checking. * strings when type checking.

View file

@ -107,11 +107,6 @@ export const internalAction: ActionBuilder<DataModel, "internal"> =
*/ */
export const httpAction: HttpActionBuilder = httpActionGeneric; export const httpAction: HttpActionBuilder = httpActionGeneric;
type GenericCtx =
| GenericActionCtx<DataModel>
| GenericMutationCtx<DataModel>
| GenericQueryCtx<DataModel>;
/** /**
* A set of services for use within Convex query functions. * A set of services for use within Convex query functions.
* *

View file

@ -0,0 +1,60 @@
import { UserIdentity } from "convex/server";
import { Doc, Id } from "../_generated/dataModel";
import { MutationCtx, query, QueryCtx } from "../_generated/server";
// Overload signatures
async function userValidation(ctx: MutationCtx | QueryCtx, options: { required: false }): Promise<{ userId: Id<"user">; user: any } | null>;
async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required?: true }): Promise<{ userId: Id<"user">; user: UserIdentity }>;
// Implementation
async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required?: boolean }) {
const required = options?.required ?? true;
const user = await ctx.auth.getUserIdentity();
if (!user) {
if (required) throw new Error("User not found");
return null;
}
const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">;
if (!userId) {
if (required) throw new Error("User not found");
return null;
}
return { userId, user };
}
export const getUserNests = query({
handler: async (ctx) => {
const { userId } = await userValidation(ctx, { required: true });
if (!userId) throw new Error("User not found");
const getUser = await ctx.db.get<"user">(userId);
if (!getUser) throw new Error("User not found");
else if (!getUser.nests || getUser.nests.length === 0) return [];
// Get the nests the user is a member of
const nests: Doc<"nests">[] = [];
for (const nestId of getUser.nests) {
const nest = await ctx.db.get<"nests">(nestId);
if (!nest) continue;
nests.push(nest);
}
return nests;
}
});
export const getRecommendedNests = query({
handler: async (ctx) => {
const { userId } = await userValidation(ctx, { required: true });
if (!userId) throw new Error("User not found");
const getUser = await ctx.db.get<"user">(userId);
if (!getUser) throw new Error("User not found");
const nests = await ctx.db.query<"nests">("nests").withIndex("onDiscover", q => q.eq("onDiscover", true)).collect();
return nests;
}
});

View file

@ -1,5 +1,4 @@
import { v } from "convex/values"; import { v } from "convex/values";
import { Id } from "../../_generated/dataModel";
import { mutation, query } from "../_generated/server"; import { mutation, query } from "../_generated/server";
export const sendKeysToServer = mutation({ export const sendKeysToServer = mutation({
@ -16,22 +15,40 @@ export const sendKeysToServer = mutation({
forceInsert: v.boolean(), // if true, insert even if user already has an olm account forceInsert: v.boolean(), // if true, insert even if user already has an olm account
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const now = Date.now();
// check if user already has an olm account // check if user already has an olm account
const olmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", args.userId)).first(); const olmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", args.userId)).first();
if (olmAccount && !args.forceInsert) { if (olmAccount && !args.forceInsert) {
throw new Error("User already has an olm account"); throw new Error("User already has an olm account");
} } else if (olmAccount && args.forceInsert) {
// Keys are being rotated - increment version and update timestamp
const insert = await ctx.db.insert<"olmAccount">("olmAccount", { await ctx.db.patch(olmAccount._id, {
userId: args.userId,
identityKey: args.identityKey, identityKey: args.identityKey,
oneTimeKeys: args.oneTimeKeys, oneTimeKeys: args.oneTimeKeys,
updatedAt: now,
keyVersion: (olmAccount.keyVersion || 0) + 1,
}); });
console.log("insert", insert); // Notify all users who have sessions with this user that their sessions are now invalid
return insert; // This will be handled client-side by checking key versions
console.log(`[OLM] Keys rotated for user ${args.userId}, new version: ${(olmAccount.keyVersion || 0) + 1}`);
return { ...olmAccount, keyVersion: (olmAccount.keyVersion || 0) + 1 };
}
// Create new account with initial key version
const newOlmAccount = await ctx.db.insert<"olmAccount">("olmAccount", {
userId: args.userId,
identityKey: args.identityKey,
oneTimeKeys: args.oneTimeKeys || [],
createdAt: now,
updatedAt: now,
keyVersion: 1,
});
return newOlmAccount;
}, },
}); });
@ -40,10 +57,16 @@ export const retrieveServerOlmAccount = query({
userId: v.string(), userId: v.string(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const olmAccount = await ctx.db.get<"olmAccount">(args.userId as Id<"olmAccount">); const olmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", args.userId)).first();
if (olmAccount) return olmAccount; if (!olmAccount) return null;
return null; // Ensure backward compatibility with old records that don't have keyVersion
return {
...olmAccount,
keyVersion: olmAccount.keyVersion ?? 1,
createdAt: olmAccount.createdAt ?? olmAccount._creationTime,
updatedAt: olmAccount.updatedAt ?? olmAccount._creationTime,
};
}, },
}); });
@ -72,4 +95,49 @@ export const consumeOTK = mutation({
keysLeft: oneTimeKeys.length keysLeft: oneTimeKeys.length
} }
}, },
}) });
export const getKeyVersion = query({
args: {
userId: v.string(),
},
handler: async (ctx, args) => {
const olmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", args.userId)).first();
if (!olmAccount) return null;
return {
keyVersion: olmAccount.keyVersion ?? 1,
updatedAt: olmAccount.updatedAt ?? olmAccount._creationTime,
identityKey: olmAccount.identityKey,
};
},
});
/**
* Migration mutation to add keyVersion, createdAt, updatedAt to existing olmAccount records
* Run this once to migrate old records
*/
export const migrateOlmAccounts = mutation({
handler: async (ctx) => {
const accounts = await ctx.db.query("olmAccount").collect();
let updated = 0;
for (const account of accounts) {
// Only update if keyVersion is missing
if (account.keyVersion === undefined) {
await ctx.db.patch(account._id, {
keyVersion: 1, // Initial version for existing accounts
createdAt: account.createdAt ?? account._creationTime,
updatedAt: account.updatedAt ?? account._creationTime,
});
updated++;
}
}
return {
message: `Migrated ${updated} olmAccount records`,
total: accounts.length,
updated
};
},
});

View file

@ -4,6 +4,7 @@
import { defineSchema, defineTable } from "convex/server"; import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values"; import { v } from "convex/values";
import { nests } from "./schemas/nests";
import { user } from "./schemas/user"; import { user } from "./schemas/user";
const Attachment = v.object({ const Attachment = v.object({
@ -39,7 +40,12 @@ const Message = v.object({
export const tables = { export const tables = {
...user, ...user,
messages: defineTable(Message), ...nests,
messages: defineTable(Message)
.index("channelId", ["channelId"])
.index("channelId_createdTimestamp", ["channelId", "createdTimestamp"])
.index("authorId", ["authorId"])
.index("guildId", ["guildId"]),
attachments: defineTable(Attachment), attachments: defineTable(Attachment),
session: defineTable({ session: defineTable({
expiresAt: v.number(), expiresAt: v.number(),
@ -96,6 +102,9 @@ export const tables = {
keyId: v.string(), keyId: v.string(),
publicKey: v.string(), publicKey: v.string(),
})), })),
createdAt: v.optional(v.number()),
updatedAt: v.optional(v.number()),
keyVersion: v.optional(v.number()), // Increments when keys are rotated
}) })
.index("userId", ["userId"]) .index("userId", ["userId"])
.index("userId_keys", ["userId", "oneTimeKeys"]) .index("userId_keys", ["userId", "oneTimeKeys"])

View file

@ -6,10 +6,12 @@ export const nests = {
type: v.union(v.literal("global"), v.literal("regional"), v.literal("private")), type: v.union(v.literal("global"), v.literal("regional"), v.literal("private")),
name: v.string(), name: v.string(),
description: v.optional(v.string()), description: v.optional(v.string()),
images: v.object({ images: v.optional(
v.object({
banner: v.id("storage"), banner: v.id("storage"),
icon: v.id("storage"), icon: v.id("storage"),
}), })
),
colors: v.optional( colors: v.optional(
v.object({ v.object({
primary: v.string(), primary: v.string(),
@ -28,11 +30,13 @@ export const nests = {
name: v.string(), name: v.string(),
createdAt: v.number(), createdAt: v.number(),
})), })),
onDiscover: v.optional(v.boolean()),
}) })
.index("managerId", ["managerId"]) .index("managerId", ["managerId"])
.index("type", ["type"]) .index("type", ["type"])
.index("type_region", ["type", "region"]) .index("type_region", ["type", "region"])
.index("createdAt", ["createdAt"]), .index("createdAt", ["createdAt"])
.index("onDiscover", ["onDiscover"]),
roles: defineTable({ roles: defineTable({
nestId: v.id("nests"), nestId: v.id("nests"),
name: v.string(), name: v.string(),
@ -45,9 +49,12 @@ export const nests = {
flags: v.array(v.int64()), // Flags as bitfield flags: v.array(v.int64()), // Flags as bitfield
createdAt: v.number(), createdAt: v.number(),
updatedAt: v.number(), updatedAt: v.number(),
members: v.array(v.id("user")),
}) })
.index("nestId", ["nestId"]) .index("nestId", ["nestId"])
.index("nestId_position", ["nestId", "position"]), .index("nestId_position", ["nestId", "position"])
.index("nestId_members", ["nestId", "members"])
.index("members", ["members"]),
channels: defineTable({ channels: defineTable({
type: v.union(v.literal("text"), v.literal("category"), v.literal("announcement")), type: v.union(v.literal("text"), v.literal("category"), v.literal("announcement")),
name: v.string(), name: v.string(),

View file

@ -15,15 +15,23 @@ export const user = {
metadata: v.optional(v.object({ metadata: v.optional(v.object({
phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")), phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")),
})), })),
nests: v.optional(v.array(v.id("nests"))),
}) })
.index("email_name", ["email", "name"]) .index("email_name", ["email", "name"])
.index("nests", ["nests"])
.index("byName", ["name"]) .index("byName", ["name"])
.index("userId", ["userId"]) .index("userId", ["userId"])
.index("username", ["username"]), .index("username", ["username"]),
userStatus: defineTable({ userStatus: defineTable({
userId: v.id("user"), userId: v.id("user"),
status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
isUserSet: v.boolean(), userSetStatus: v.optional(
v.object({
status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
updatedAt: v.number(),
isSet: v.boolean(),
})
),
updatedAt: v.number(), updatedAt: v.number(),
}) })
.index("userId", ["userId"]) .index("userId", ["userId"])
@ -42,6 +50,7 @@ export const user = {
.index("userId_method", ["userId", "method"]) .index("userId_method", ["userId", "method"])
.index("userId", ["userId"]) .index("userId", ["userId"])
.index("requestId", ["requestId"]) .index("requestId", ["requestId"])
.index("userId_requestTo", ["userId", "requestTo"])
.index("requestTo", ["requestTo"]) .index("requestTo", ["requestTo"])
.index("expiresAt", ["expiresAt"]), .index("expiresAt", ["expiresAt"]),
friends: defineTable({ friends: defineTable({

View file

@ -28,25 +28,38 @@ async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required?
export const updateUserStatus = mutation({ export const updateUserStatus = mutation({
args: { args: {
status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
isUserSet: v.boolean(), isUserSet: v.optional(v.boolean()),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
try { try {
const { userId } = await userValidation(ctx); const { userId } = await userValidation(ctx);
const isUserSet = args.isUserSet ?? false;
// Check if user status is already set
const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first(); const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first();
if (userStatus) { if (userStatus) {
let resolvedStatus = args.status;
// Restore user-set status when reconnecting
if (args.status === "online" && !isUserSet && userStatus.userSetStatus?.isSet) {
resolvedStatus = userStatus.userSetStatus.status;
}
await ctx.db.patch(userStatus._id, { await ctx.db.patch(userStatus._id, {
status: args.status, status: resolvedStatus,
isUserSet: args.isUserSet, userSetStatus: isUserSet
? { status: args.status, updatedAt: Date.now(), isSet: true }
: userStatus.userSetStatus,
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
} else { } else {
await ctx.db.insert("userStatus", { await ctx.db.insert("userStatus", {
userId: userId, userId: userId,
status: args.status, status: args.status,
isUserSet: false, userSetStatus: {
status: args.status,
updatedAt: Date.now(),
isSet: isUserSet,
},
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
} }
@ -71,6 +84,59 @@ export const getUserStatus = query({
} }
}); });
export const getNonOfflineUserIds = query({
args: {},
handler: async (ctx) => {
const results: { userId: string; status: string; isUserSet: boolean }[] = [];
for (const status of ["online", "busy", "away"] as const) {
const records = await ctx.db
.query("userStatus")
.withIndex("status", (q) => q.eq("status", status))
.collect();
for (const record of records) {
results.push({
userId: record.userId,
status: record.status,
isUserSet: record.userSetStatus?.isSet ?? false,
});
}
}
return results;
},
});
export const forceUserOffline = mutation({
args: {
userId: v.string(),
},
handler: async (ctx, args) => {
const normalizedId = ctx.db.normalizeId("user", args.userId);
if (!normalizedId) return;
const userStatus = await ctx.db
.query("userStatus")
.withIndex("userId", (q) => q.eq("userId", normalizedId))
.first();
if (!userStatus || userStatus.status === "offline") return;
await ctx.db.patch(userStatus._id, {
status: "offline",
userSetStatus: userStatus.userSetStatus ? {
status: userStatus.userSetStatus.status,
updatedAt: Date.now(),
isSet: userStatus.userSetStatus.isSet,
} : undefined,
updatedAt: Date.now(),
});
console.log(`[forceUserOffline] Set user ${args.userId} offline (was: ${userStatus.status})`);
},
});
export const updateUserMetadata = mutation({ export const updateUserMetadata = mutation({
args: { args: {
metadata: v.object({ metadata: v.object({
@ -320,7 +386,7 @@ export const getFriends = query({
friendshipCreatedAt: friendship.createdAt, friendshipCreatedAt: friendship.createdAt,
status: friendStatus ? { status: friendStatus ? {
status: friendStatus.status, status: friendStatus.status,
isUserSet: friendStatus.isUserSet, isUserSet: friendStatus.userSetStatus?.isSet ?? false,
} : { } : {
status: "offline" as const, status: "offline" as const,
isUserSet: false, isUserSet: false,
@ -356,6 +422,14 @@ export const getParticipantDetails = query({
const participantOlmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", id)).first(); const participantOlmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", id)).first();
if (!participant) return null; if (!participant) return null;
// Ensure backward compatibility with old olmAccount records
const olmAccountWithDefaults = participantOlmAccount ? {
...participantOlmAccount,
keyVersion: participantOlmAccount.keyVersion ?? 1,
createdAt: participantOlmAccount.createdAt ?? participantOlmAccount._creationTime,
updatedAt: participantOlmAccount.updatedAt ?? participantOlmAccount._creationTime,
} : null;
return { return {
id: participant._id, id: participant._id,
name: participant.name, name: participant.name,
@ -363,7 +437,7 @@ export const getParticipantDetails = query({
displayUsername: participant.displayUsername, displayUsername: participant.displayUsername,
image: participant.image, image: participant.image,
status: participantStatus?.status || "offline", status: participantStatus?.status || "offline",
olmAccount: participantOlmAccount, olmAccount: olmAccountWithDefaults,
} }
})); }));

View file

@ -5,12 +5,14 @@
"scripts": { "scripts": {
"postinstall": "bun src/lib/scripts/copy-olm.ts", "postinstall": "bun src/lib/scripts/copy-olm.ts",
"dev": "cross-env NODE_ENV=development PORT=3000 tsx src/server.ts", "dev": "cross-env NODE_ENV=development PORT=3000 tsx src/server.ts",
"build": "bun src/lib/scripts/copy-olm.ts && convex deploy --cmd \"bun run build\"", "build": "bun src/lib/scripts/copy-olm.ts && convex deploy --cmd \"next build\"",
"start": "cross-env NODE_ENV=production PORT=8081 tsx src/server.ts" "build:local": "bun src/lib/scripts/copy-olm.ts && next build",
"start": "cross-env NODE_ENV=production PORT=8081 tsx src/server.ts",
"start:dev": "cross-env NODE_ENV=development PORT=3000 tsx src/server.ts"
}, },
"dependencies": { "dependencies": {
"@convex-dev/better-auth": "^0.10.10", "@convex-dev/better-auth": "^0.10.10",
"@marsidev/react-turnstile": "^1.4.1", "@marsidev/react-turnstile": "^1.4.2",
"@matrix-org/olm": "^3.2.15", "@matrix-org/olm": "^3.2.15",
"@nanostores/react": "^1.0.0", "@nanostores/react": "^1.0.0",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
@ -27,36 +29,37 @@
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@types/bun": "^1.3.6", "@types/bun": "^1.3.9",
"@types/libsodium-wrappers": "^0.7.14", "@types/libsodium-wrappers": "^0.7.14",
"better-auth": "1.4.12", "better-auth": "1.4.12",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"convex": "^1.31.4", "convex": "^1.31.7",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dexie": "^4.2.1", "dexie": "^4.3.0",
"dexie-react-hooks": "^4.2.0", "dexie-react-hooks": "^4.2.0",
"framer-motion": "^12.26.2", "dotenv": "^17.3.1",
"framer-motion": "^12.34.0",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"nanostores": "^1.1.0", "nanostores": "^1.1.0",
"next": "16.1.1", "next": "16.1.1",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.3", "react": "19.2.3",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.1",
"react-dom": "19.2.3", "react-dom": "19.2.3",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"zod": "^4.3.5" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.0.8", "@types/node": "^25.2.2",
"@types/react": "^19.2.8", "@types/react": "^19.2.13",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "1.0.0",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",

View file

@ -0,0 +1,3 @@
export default function FriendsPage() {
return null;
}

View file

@ -0,0 +1,3 @@
export default function GlobalNestsPage() {
return null;
}

View file

@ -1,4 +0,0 @@
export default function ServerChannelPage() {
return null;
}

View file

@ -0,0 +1,3 @@
export default function DiscoverPage() {
return null;
}

View file

@ -1,3 +1,4 @@
import { AutoRequestNotifications } from "@/components/notifications/NotificationSettings";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { getToken } from "@/lib/auth/auth-server"; import { getToken } from "@/lib/auth/auth-server";
@ -49,6 +50,7 @@ export default async function RootLayout({
disableTransitionOnChange disableTransitionOnChange
> >
{children} {children}
<AutoRequestNotifications />
</ThemeProvider> </ThemeProvider>
<Toaster richColors /> <Toaster richColors />
</ConvexClientProvider> </ConvexClientProvider>

View file

@ -15,27 +15,59 @@ import { useCallback, useEffect, useMemo } from "react";
import { api } from "../../convex/_generated/api"; import { api } from "../../convex/_generated/api";
import OlmPasswordDialog from "./olm/olm-password-dialog"; import OlmPasswordDialog from "./olm/olm-password-dialog";
type RouteParams = Record<string, string | string[] | undefined>;
type RouteMatcher = {
path?: string;
pattern?: RegExp;
type: SiPher.PageTypes;
extract?: (match: RegExpMatchArray, params: RouteParams) => Partial<SiPher.RouteInfo>;
};
const routes: RouteMatcher[] = [
{ path: '/channels/me/friends', type: 'friends' },
{ path: '/discover', type: 'discover' },
{ path: '/support', type: 'support' },
{ path: '/channels/nests/global', type: 'global-nests' },
{
pattern: /^\/channels\/me\/(.+)$/,
type: 'dm',
extract: (_, params) => ({
dmChannelId: params.id ? decodeURIComponent(params.id as string) : undefined
})
},
{
pattern: /^\/channels\/servers\/(.+)$/,
type: 'server',
extract: (_, params) => ({
serverId: params.serverId ? decodeURIComponent(params.serverId as string) : undefined,
serverChannelId: params.channelId ? decodeURIComponent(params.channelId as string) : undefined
})
},
];
function AppContainerContent() { function AppContainerContent() {
const pathname = usePathname(); const pathname = usePathname();
const params = useParams(); const params = useParams();
// Detect route type and extract params from URL const routeInfo: SiPher.RouteInfo = useMemo(() => {
const routeInfo = useMemo(() => { for (const route of routes) {
if (pathname.startsWith('/channels/me/')) { if (route.path && pathname === route.path) {
return { type: route.type };
}
if (route.pattern) {
const match = pathname.match(route.pattern);
if (match) {
return { return {
type: 'dm' as const, type: route.type,
// Decode URL-encoded params (dm%3A... becomes dm:...) ...route.extract?.(match, params)
dmChannelId: params.id ? decodeURIComponent(params.id as string) : undefined
}; };
} }
if (pathname.startsWith('/channels/servers/')) {
return {
type: 'server' as const,
serverId: params.serverId ? decodeURIComponent(params.serverId as string) : undefined,
serverChannelId: params.channelId ? decodeURIComponent(params.channelId as string) : undefined
};
} }
return { type: 'home' as const }; }
return { type: 'friends' };
}, [pathname, params]); }, [pathname, params]);
const { data } = authClient.useSession(); const { data } = authClient.useSession();
@ -47,6 +79,7 @@ function AppContainerContent() {
const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmContext(); const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmContext();
const updateUserMetadata = useMutation(api.auth.updateUserMetadata); const updateUserMetadata = useMutation(api.auth.updateUserMetadata);
const userNests = useQuery(api.auth.getUserNests);
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
@ -82,15 +115,15 @@ function AppContainerContent() {
socketInfo={socketInfo} socketInfo={socketInfo}
disconnectSocket={disconnect} disconnectSocket={disconnect}
connectSocket={connect} connectSocket={connect}
routeInfo={routeInfo}
> >
<MainContentLayout <MainContentLayout
socketStatus={socketStatus} socketStatus={socketStatus}
emptyChannelMessage={getPhrase()} emptyChannelMessage={getPhrase()}
emptyFriendsMessage={getPhrase()} emptyFriendsMessage={getPhrase()}
userId={data.user.id} userId={data.user.id}
dmChannelId={routeInfo.type === 'dm' ? routeInfo.dmChannelId : undefined} routeInfo={routeInfo}
serverId={routeInfo.type === 'server' ? routeInfo.serverId : undefined} userNests={userNests}
serverChannelId={routeInfo.type === 'server' ? routeInfo.serverChannelId : undefined}
/> />
</AppSidebar> </AppSidebar>

View file

@ -10,8 +10,9 @@ import {
SidebarMenuItem, SidebarMenuItem,
SidebarProvider SidebarProvider
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { CompassIcon, HouseIcon } from "@phosphor-icons/react"; import { CompassIcon, GlobeIcon, HouseIcon } from "@phosphor-icons/react";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react"; import { useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { Separator } from "../ui/separator"; import { Separator } from "../ui/separator";
@ -19,10 +20,17 @@ import ConnectionStatusIndicator from "./csi";
import SidebarIcon from "./sicons"; import SidebarIcon from "./sicons";
const SidebarItems: SiPher.SidebarItem[] = [ const SidebarItems: SiPher.SidebarItem[] = [
{
id: "global-nests",
icon: <GlobeIcon className="size-5" weight="fill" />,
label: "Global Nest",
href: "/channels/nests/global"
},
{ {
id: "discover", id: "discover",
icon: <CompassIcon className="size-5" weight="fill" />, icon: <CompassIcon className="size-5" weight="fill" />,
label: "Discover" label: "Discover",
href: "/discover"
} }
]; ];
@ -31,8 +39,9 @@ const SidebarItems: SiPher.SidebarItem[] = [
* It also is the controller for everything on the app, including going to other pages, showing conversations and other. * It also is the controller for everything on the app, including going to other pages, showing conversations and other.
* @param children - The children to be rendered in the sidebar inset * @param children - The children to be rendered in the sidebar inset
*/ */
export default function AppSidebar({ children, socketStatus, socketInfo, currentChannel, disconnectSocket, connectSocket }: SiPher.AppSidebarProps) { export default function AppSidebar({ children, socketStatus, socketInfo, currentChannel, disconnectSocket, connectSocket, routeInfo }: SiPher.AppSidebarProps) {
const [activeItem, setActiveItem] = useState<string>("home"); const [activeItem, setActiveItem] = useState<string>("home");
const router = useRouter();
return ( return (
<SidebarProvider <SidebarProvider
@ -61,9 +70,13 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
<SidebarContent className="pt-2 px-0 overflow-hidden"> <SidebarContent className="pt-2 px-0 overflow-hidden">
<SidebarMenu className="gap-2"> <SidebarMenu className="gap-2">
{SidebarItems.map((item) => ( {SidebarItems.map((item) => (
<SidebarMenuItem key={item.id}> <SidebarMenuItem key={item.id} onClick={() => {
if (item.href) {
router.push(item.href);
}
}}>
<SidebarIcon <SidebarIcon
isActive={activeItem === item.id} isActive={activeItem === item.id && routeInfo.type === item.id}
label={item.label} label={item.label}
onClick={() => setActiveItem(item.id)} onClick={() => setActiveItem(item.id)}
> >

View file

@ -0,0 +1,99 @@
"use client";
import { requestNotificationPermission } from "@/lib/notifications";
import { Bell, BellOff } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "../ui/button";
export function NotificationSettings({ userStatus }: { userStatus: "online" | "busy" | "offline" | "away" }) {
const [permission, setPermission] = useState<NotificationPermission>("default");
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if ("Notification" in window) {
setPermission(Notification.permission);
}
}, []);
const handleRequestPermission = async () => {
setIsLoading(true);
try {
const newPermission = await requestNotificationPermission();
setPermission(newPermission);
if (newPermission === "granted") {
if (userStatus === "busy") return;
// Show a test notification
new Notification("Notifications enabled!", {
body: "You'll now receive message notifications",
icon: "/logo.png",
});
}
} catch (error) {
console.error("Failed to request notification permission:", error);
} finally {
setIsLoading(false);
}
};
if (!("Notification" in window)) {
return null; // Browser doesn't support notifications
}
if (permission === "granted") {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Bell className="h-4 w-4 text-green-500" />
<span>Notifications enabled</span>
</div>
);
}
if (permission === "denied") {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<BellOff className="h-4 w-4 text-red-500" />
<span>Notifications blocked. Enable in browser settings.</span>
</div>
);
}
return (
<Button
variant="outline"
size="sm"
onClick={handleRequestPermission}
disabled={isLoading}
className="gap-2"
>
<Bell className="h-4 w-4" />
{isLoading ? "Requesting..." : "Enable Notifications"}
</Button>
);
}
/**
* Auto-request notification permission component
* Place in your app layout to automatically request permission on load
*/
export function AutoRequestNotifications() {
useEffect(() => {
if ("Notification" in window && Notification.permission === "default") {
// Auto-request permission after a short delay (to not interrupt page load)
const timer = setTimeout(async () => {
try {
const permission = await requestNotificationPermission();
if (permission === "granted") {
console.log("[Notifications] Permission granted automatically");
}
} catch (error) {
console.debug("[Notifications] Auto-request failed or was dismissed:", error);
}
}, 2000); // Wait 2 seconds after page load
return () => clearTimeout(timer);
}
}, []);
return null; // This component doesn't render anything
}

View file

@ -1,6 +1,7 @@
import { useOlmContext } from "@/contexts/olm-context"; import { useOlmContext } from "@/contexts/olm-context";
import { useSocketContext } from "@/contexts/socket-context"; import { useSocketContext } from "@/contexts/socket-context";
import { clearUnread, db, sendMessage } from "@/lib/db"; import { clearUnread, db, sendMessage } from "@/lib/db";
import { setActiveChannel } from "@/lib/notifications";
import { useLiveQuery } from "dexie-react-hooks"; import { useLiveQuery } from "dexie-react-hooks";
import { KeyRound, SendIcon } from "lucide-react"; import { KeyRound, SendIcon } from "lucide-react";
import moment from "moment"; import moment from "moment";
@ -111,10 +112,21 @@ export default function DMChannelContent(
} }
}, [allMessages.length]); }, [allMessages.length]);
// Clear unread count when entering the channel // Set active channel and clear unread count when viewing this channel
useEffect(() => { useEffect(() => {
// Mark this channel as active (prevents notifications)
setActiveChannel(channelId);
// Clear any existing unread count
clearUnread(channelId); clearUnread(channelId);
console.debug("[DMChannelContent] Cleared unread count for channel", channelId);
console.debug("[DMChannelContent] Set active channel and cleared unread:", channelId);
// Cleanup: unset active channel when leaving
return () => {
setActiveChannel(null);
console.debug("[DMChannelContent] Cleared active channel");
};
}, [channelId]); }, [channelId]);
// Guard: Check if otherUser exists // Guard: Check if otherUser exists
@ -131,18 +143,31 @@ export default function DMChannelContent(
// Get or create session when OLM is ready and we have the other user's account // Get or create session when OLM is ready and we have the other user's account
useEffect(() => { useEffect(() => {
const loadSession = async () => { const loadSession = async () => {
console.log("[DMChannelContent] loadSession effect triggered", {
isReady,
hasOlmAccount: !!olmAccount,
hasOtherUser: !!otherUser,
hasOtherUserOlmAccount: !!otherUser?.olmAccount,
otherUserId: otherUser?.id
});
if (!isReady || !olmAccount || !otherUser || !otherUser.olmAccount) { if (!isReady || !olmAccount || !otherUser || !otherUser.olmAccount) {
console.log("[DMChannelContent] Not ready to load session, skipping");
return; return;
} }
setSessionError(null); setSessionError(null);
console.log("[DMChannelContent] Calling getSession for", otherUser.id);
try { try {
const session = await getSession(otherUser.id, otherUser.olmAccount); const session = await getSession(otherUser.id, otherUser.olmAccount);
console.log("[DMChannelContent] getSession returned:", !!session);
if (session) { if (session) {
setOlmSession(session); setOlmSession(session);
console.log("[DMChannelContent] Session set successfully");
} else { } else {
console.error("[DMChannelContent] getSession returned null");
setSessionError("Failed to create encryption session"); setSessionError("Failed to create encryption session");
} }
} catch (err) { } catch (err) {
@ -152,7 +177,7 @@ export default function DMChannelContent(
}; };
loadSession(); loadSession();
}, [isReady, olmAccount, otherUser, password,]) }, [isReady, olmAccount, otherUser, password, getSession])
// Check if OLM is ready // Check if OLM is ready
if (!isReady || !olmAccount) { if (!isReady || !olmAccount) {
@ -263,6 +288,7 @@ export default function DMChannelContent(
const displayName = isSelf ? selfDetail?.displayUsername ?? selfDetail?.username ?? selfDetail?.name ?? "You" : (sender?.displayUsername ?? sender?.username ?? sender?.name ?? "Unknown"); const displayName = isSelf ? selfDetail?.displayUsername ?? selfDetail?.username ?? selfDetail?.name ?? "You" : (sender?.displayUsername ?? sender?.username ?? sender?.name ?? "Unknown");
const timestamp = moment(msg.timestamp); const timestamp = moment(msg.timestamp);
const timeLabel = timestamp.isSame(moment(), "day") ? timestamp.format("h:mm A") : timestamp.format("MMM D, YYYY h:mm A"); const timeLabel = timestamp.isSame(moment(), "day") ? timestamp.format("h:mm A") : timestamp.format("MMM D, YYYY h:mm A");
const shortTimeLabel = timestamp.format("h:mm A")
// Check if this message is from the same user as the previous one within 5 minutes // Check if this message is from the same user as the previous one within 5 minutes
const prevMsg = index > 0 ? messages[index - 1] : null; const prevMsg = index > 0 ? messages[index - 1] : null;
@ -303,11 +329,9 @@ export default function DMChannelContent(
) : ( ) : (
// Compact message without avatar (grouped) // Compact message without avatar (grouped)
<div className="flex gap-2 md:gap-4 leading-5.5"> <div className="flex gap-2 md:gap-4 leading-5.5">
<div className="w-8 md:w-10 shrink-0 flex items-start justify-end pt-0.5"> <div className="w-6 md:w-10 shrink-0 flex items-start justify-end pt-0.5">
<span className="text-[10px] text-transparent group-hover:text-muted-foreground transition-colors duration-100 font-medium"> <span className="text-[9px] text-transparent group-hover:text-muted-foreground transition-colors duration-100 font-light">
{ {shortTimeLabel}
timeLabel
}
</span> </span>
</div> </div>
<div className="flex-1 min-w-0 text-sm md:text-[15px] leading-5.5 text-foreground wrap-break-word"> <div className="flex-1 min-w-0 text-sm md:text-[15px] leading-5.5 text-foreground wrap-break-word">
@ -339,6 +363,13 @@ export default function DMChannelContent(
onKeyDown={async (e) => { onKeyDown={async (e) => {
if (e.key === 'Enter' && !e.shiftKey && messageInput.trim() && password) { if (e.key === 'Enter' && !e.shiftKey && messageInput.trim() && password) {
e.preventDefault(); e.preventDefault();
console.log("[DMChannelContent] Attempting to send message", {
hasOlmSession: !!olmSession,
hasPassword: !!password,
recipientId: otherUser.id,
recipientKeyVersion: otherUser.olmAccount?.keyVersion
});
try { try {
const messageId = await sendMessage({ const messageId = await sendMessage({
channelId, channelId,
@ -351,8 +382,12 @@ export default function DMChannelContent(
userId, userId,
recipientId: otherUser.id, recipientId: otherUser.id,
password, password,
recipientKeyVersion: otherUser.olmAccount?.keyVersion,
recipientIdentityKey: otherUser.olmAccount?.identityKey,
}); });
console.log("[DMChannelContent] Message sent successfully, ID:", messageId);
if (messageId) { if (messageId) {
setMessageInput(""); setMessageInput("");
} }

View file

@ -1,19 +1,56 @@
"use client" "use client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { clearUnread, db } from "@/lib/db" import { clearUnread, db } from "@/lib/db"
import { QuestionMarkIcon } from "@phosphor-icons/react" import { QuestionMarkIcon } from "@phosphor-icons/react"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { useLiveQuery } from "dexie-react-hooks" import { useLiveQuery } from "dexie-react-hooks"
import { MessageSquarePlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react" import { Globe2Icon, GlobeIcon, HomeIcon, MessageSquarePlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { useMemo } from "react"
import UserCard from "../user/user-card" import UserCard from "../user/user-card"
// Mock channels for testing scroll behavior - set to true to enable
const ENABLE_MOCK_CHANNELS = true
function createMockChannel(id: string, name: string, message: string, hoursAgo: number): SiPher.Channel {
return {
id: `mock-${id}`,
name,
type: "DM" as SiPher.Channel["type"],
participants: ["current-user", `user-${id}`],
isOpen: true,
metadata: {},
times: {
createdAt: Date.now() - 1000 * 60 * 60 * 24 * 30,
updatedAt: Date.now() - 1000 * 60 * 60 * hoursAgo,
lastMessage: { content: message } as unknown as SiPher.Channel["times"]["lastMessage"],
lastMessageAt: Date.now() - 1000 * 60 * 60 * hoursAgo
}
}
}
// const mockChannels: SiPher.Channel[] = ENABLE_MOCK_CHANNELS ? [
// createMockChannel("1", "Alice Johnson", "Hey, are you coming to the meeting?", 0.08),
// createMockChannel("2", "Bob Smith", "The project looks great!", 0.5),
// createMockChannel("3", "Charlie Brown", "Can you review my PR?", 1),
// createMockChannel("4", "Diana Prince", "Thanks for the help!", 2),
// createMockChannel("5", "Edward Norton", "Let's catch up soon", 5),
// createMockChannel("6", "Fiona Green", "Did you see the news?", 12),
// createMockChannel("7", "George Wilson", "Meeting at 3pm", 24),
// createMockChannel("8", "Hannah Baker", "Sounds good to me!", 48),
// createMockChannel("9", "Ivan Petrov", "I'll send over the files", 72),
// createMockChannel("10", "Julia Roberts", "Great work on that!", 96),
// createMockChannel("11", "Kevin Hart", "LOL that's hilarious", 120),
// createMockChannel("12", "Laura Palmer", "See you tomorrow", 144),
// ] : []
export interface ChannelListProps { export interface ChannelListProps {
currentChannel: SiPher.Channel | null currentChannel: SiPher.Channel | null
openDmChannels: SiPher.Channel[] openDmChannels: SiPher.Channel[]
page: "friends" | "support" | "dm" | "server" page: SiPher.PageTypes
onPageChange: (page: "friends" | "support" | "dm" | "server") => void onPageChange: (page: SiPher.PageTypes) => void
emptyMessage?: string emptyMessage?: string
dmChannel?: { dmChannel?: {
id: string id: string
@ -48,13 +85,22 @@ export function ChannelList({
[] []
) )
// Combine real channels with mock channels for testing, sorted by most recent activity
const allDmChannels = useMemo(() => {
return [...openDmChannels].sort((a, b) => {
const aTime = a.times?.lastMessageAt ?? a.times?.updatedAt ?? 0
const bTime = b.times?.lastMessageAt ?? b.times?.updatedAt ?? 0
return bTime - aTime // Descending order (most recent first)
})
}, [openDmChannels])
const handleNavigation = (path: string) => { const handleNavigation = (path: string) => {
router.push(path) router.push(path)
onChannelSelect?.() onChannelSelect?.()
} }
return ( return (
<div className={`flex flex-col shrink-0 border-border/40 ${isMobile ? 'w-full h-full bg-transparent' : 'max-w-72 min-w-72 border-r bg-linear-to-b from-background to-muted/20'}`}> <div className={`flex flex-col shrink-0 border-border/40 ${isMobile ? 'w-full h-full bg-transparent' : 'max-w-72 min-w-72 h-full border-r bg-linear-to-b from-background to-muted/20'}`}>
{/* Channel List Header - Navigation Items (Desktop only) */} {/* Channel List Header - Navigation Items (Desktop only) */}
{!isMobile && ( {!isMobile && (
<> <>
@ -67,7 +113,7 @@ export function ChannelList({
}`} }`}
onClick={() => { onClick={() => {
onPageChange("friends") onPageChange("friends")
handleNavigation("/") handleNavigation("/channels/me/friends")
}} }}
> >
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${page === "friends" <div className={`flex items-center justify-center w-8 h-8 rounded-lg ${page === "friends"
@ -137,11 +183,72 @@ export function ChannelList({
)} )}
{/* Channel List */} {/* Channel List */}
<div className={`flex flex-col flex-1 overflow-y-auto ${isMobile ? 'px-2' : 'px-2'}`}> <div className={`flex flex-col flex-1 min-h-0 ${isMobile ? 'px-2' : 'px-2'}`}>
{page === "friends" || !currentChannel ? ( <div className="flex flex-col w-full gap-2">
<div className="flex flex-col w-full">
{/* DM Header */}
<div className="flex items-center justify-between px-1 py-2 select-none"> <div className="flex items-center justify-between px-1 py-2 select-none">
<span className={`font-bold uppercase tracking-wider text-muted-foreground/70 ${isMobile ? 'text-[10px]' : 'text-[11px]'}`}>
Global Nests
</span>
</div>
{/* Nest Type Selector */}
<div className={`flex ${isMobile ? 'flex-col gap-1' : 'flex-row gap-0.5'} p-1 bg-muted/40 rounded-lg`}>
{[
{ id: 'global', icon: GlobeIcon, label: 'Global', description: 'Worldwide nests' },
{ id: 'continental', icon: Globe2Icon, label: 'Continent', description: 'Nests by continent' },
{ id: 'country', icon: HomeIcon, label: 'Country', description: 'Your country nests' },
].map((nest) => {
const isActive = nest.id === 'global' // TODO: Replace with actual state
const Icon = nest.icon
return (
<Button
key={nest.id}
variant="ghost"
size="sm"
className={`
${isMobile
? 'w-full justify-start gap-3 h-11 px-3'
: 'flex-1 gap-1.5 h-7 px-2'
}
rounded-md transition-all duration-150
${isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground hover:bg-transparent'
}
`}
title={nest.description}
onClick={() => {
// TODO: Handle nest type selection
onChannelSelect?.()
}}
>
{isMobile ? (
<>
<div className={`
flex items-center justify-center w-7 h-7 rounded-md transition-colors
${isActive ? 'bg-primary/15' : 'bg-muted/50'}
`}>
<Icon className="size-4" />
</div>
<span className="text-sm font-medium">{nest.label}</span>
</>
) : (
<>
<Icon className="size-3.5" />
<span className="text-[11px] font-medium">{nest.label}</span>
</>
)}
</Button>
)
})}
</div>
</div>
{(page === "friends" || !currentChannel) && (
<div className="flex flex-col w-full flex-1 min-h-0 overflow-hidden mb-16">
{/* DM Header */}
<div className="flex items-center justify-between px-1 py-2 select-none shrink-0">
<span className={`font-bold uppercase tracking-wider text-muted-foreground/70 ${isMobile ? 'text-[10px]' : 'text-[11px]'}`}> <span className={`font-bold uppercase tracking-wider text-muted-foreground/70 ${isMobile ? 'text-[10px]' : 'text-[11px]'}`}>
Direct Messages Direct Messages
</span> </span>
@ -155,9 +262,10 @@ export function ChannelList({
</Button> </Button>
</div> </div>
{openDmChannels.length > 0 ? ( {allDmChannels.length > 0 && (
<div className="flex flex-col gap-0.5"> <ScrollArea className="flex-1 -mx-2 h-full">
{openDmChannels.map((channel) => { <div className="flex flex-col gap-0.5 px-2 pb-2">
{allDmChannels.map((channel) => {
const isActive = dmChannel?.id === channel.id const isActive = dmChannel?.id === channel.id
const lastMessage = channel.times?.lastMessage const lastMessage = channel.times?.lastMessage
const lastMessageTime = channel.times?.lastMessageAt const lastMessageTime = channel.times?.lastMessageAt
@ -236,7 +344,10 @@ export function ChannelList({
) )
})} })}
</div> </div>
) : ( </ScrollArea>
)}
{allDmChannels.length === 0 && (
<div className="flex flex-col items-center justify-center py-8 px-4 text-center"> <div className="flex flex-col items-center justify-center py-8 px-4 text-center">
<div className="flex items-center justify-center w-12 h-12 rounded-full bg-muted/50 mb-3"> <div className="flex items-center justify-center w-12 h-12 rounded-full bg-muted/50 mb-3">
<QuestionMarkIcon size={20} className="text-muted-foreground/50" /> <QuestionMarkIcon size={20} className="text-muted-foreground/50" />
@ -247,7 +358,9 @@ export function ChannelList({
</div> </div>
)} )}
</div> </div>
) : ( )}
{page !== "friends" && currentChannel && (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<span className="text-sm font-medium text-muted-foreground">No channels</span> <span className="text-sm font-medium text-muted-foreground">No channels</span>
</div> </div>

View file

@ -14,6 +14,7 @@ import { Plus } from "lucide-react"
import * as React from "react" import * as React from "react"
import { useEffect, useMemo } from "react" import { useEffect, useMemo } from "react"
import { api } from "../../../../convex/_generated/api" import { api } from "../../../../convex/_generated/api"
import { Doc } from "../../../../convex/betterAuth/_generated/dataModel"
import DMChannelContent from "../dm/DmChannelContent" import DMChannelContent from "../dm/DmChannelContent"
import { FriendsPage } from "../friends/friends-page" import { FriendsPage } from "../friends/friends-page"
import { Spinner } from "../spinner" import { Spinner } from "../spinner"
@ -26,9 +27,13 @@ export interface MainContentLayoutProps {
emptyChannelMessage?: string emptyChannelMessage?: string
emptyFriendsMessage?: string emptyFriendsMessage?: string
userId: string userId: string
routeInfo: {
type: SiPher.PageTypes
dmChannelId?: string dmChannelId?: string
serverId?: string serverId?: string
serverChannelId?: string serverChannelId?: string
}
userNests: Doc<"nests">[] | undefined
} }
export function MainContentLayout({ export function MainContentLayout({
@ -36,13 +41,11 @@ export function MainContentLayout({
emptyChannelMessage, emptyChannelMessage,
emptyFriendsMessage, emptyFriendsMessage,
userId, userId,
dmChannelId, routeInfo,
serverId, userNests,
serverChannelId,
}: MainContentLayoutProps) { }: MainContentLayoutProps) {
const [page, setPage] = React.useState<"friends" | "support" | "dm" | "server">( const { type, dmChannelId, serverId, serverChannelId } = routeInfo
dmChannelId ? "dm" : serverChannelId ? "server" : "friends" const [page, setPage] = React.useState<SiPher.PageTypes>(type)
)
const [friendsPage, setFriendsPage] = React.useState<"all" | "available">("all") const [friendsPage, setFriendsPage] = React.useState<"all" | "available">("all")
const [friendModal, setFriendModal] = React.useState(false) const [friendModal, setFriendModal] = React.useState(false)
const [currentChannel] = React.useState<SiPher.Channel | null>(null) const [currentChannel] = React.useState<SiPher.Channel | null>(null)
@ -59,8 +62,9 @@ export function MainContentLayout({
.find((channel) => channel.id === dmChannelId) .find((channel) => channel.id === dmChannelId)
?.participants ?? [] ?.participants ?? []
const getParticipantDetails: SiPher.ParticipantDetail[] | undefined = useQuery(api.auth.getParticipantDetails, const getParticipantDetails: SiPher.ParticipantDetail[] | undefined = useQuery(
{ participantIds } api.auth.getParticipantDetails,
participantIds.length > 0 && dmChannelId ? { participantIds } : "skip"
) )
// Combine channel from local DB with participant details from Convex // Combine channel from local DB with participant details from Convex
@ -86,17 +90,11 @@ export function MainContentLayout({
// Sync page state with route props for seamless navigation // Sync page state with route props for seamless navigation
useEffect(() => { useEffect(() => {
if (dmChannelId) { setPage(type);
setPage("dm"); }, [type]);
} else if (serverChannelId) {
setPage("server");
} else {
setPage("friends");
}
}, [dmChannelId, serverChannelId]);
// Close mobile channel list when navigating to a channel // Close mobile channel list when navigating to a channel
const handlePageChange = React.useCallback((newPage: "friends" | "support" | "dm" | "server") => { const handlePageChange = React.useCallback((newPage: SiPher.PageTypes) => {
setPage(newPage); setPage(newPage);
if (isMobile) { if (isMobile) {
setMobileChannelListOpen(false); setMobileChannelListOpen(false);
@ -126,7 +124,6 @@ export function MainContentLayout({
return ( return (
<> <>
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */}
<PageHeader <PageHeader
currentChannel={currentChannel} currentChannel={currentChannel}
page={page} page={page}
@ -140,14 +137,11 @@ export function MainContentLayout({
isMobile={isMobile} isMobile={isMobile}
/> />
{/* Content Area - Channel List + Main Content */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
{/* Desktop: Always visible channel list */} <div className="hidden md:flex h-full">
<div className="hidden md:flex">
{channelListContent} {channelListContent}
</div> </div>
{/* Mobile: Sheet-based channel list - Discord-style two-panel layout */}
{isMobile && ( {isMobile && (
<Sheet open={mobileChannelListOpen} onOpenChange={setMobileChannelListOpen}> <Sheet open={mobileChannelListOpen} onOpenChange={setMobileChannelListOpen}>
<SheetContent side="left" className="w-[calc(100%-3rem)] max-w-[340px] p-0 [&>button]:hidden"> <SheetContent side="left" className="w-[calc(100%-3rem)] max-w-[340px] p-0 [&>button]:hidden">
@ -156,42 +150,33 @@ export function MainContentLayout({
<SheetDescription>Navigate between channels and DMs</SheetDescription> <SheetDescription>Navigate between channels and DMs</SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex h-full"> <div className="flex h-full">
{/* Left Rail - Server/Home Icons (Discord-style) */}
<div className="flex flex-col items-center w-[72px] shrink-0 bg-muted/50 py-3 gap-2"> <div className="flex flex-col items-center w-[72px] shrink-0 bg-muted/50 py-3 gap-2">
{/* Home/DMs Button */}
<MobileServerIcon <MobileServerIcon
isActive={true} isActive={true}
isHome isHome
label="Direct Messages"
> >
<LogoIcon className="size-6" /> <LogoIcon className="size-6" />
</MobileServerIcon> </MobileServerIcon>
{/* Divider */}
<div className="w-8 h-0.5 rounded-full bg-border/60 my-1" /> <div className="w-8 h-0.5 rounded-full bg-border/60 my-1" />
{/* Discover */} <MobileServerIcon>
<MobileServerIcon label="Discover">
<CompassIcon className="size-5" weight="fill" /> <CompassIcon className="size-5" weight="fill" />
</MobileServerIcon> </MobileServerIcon>
{/* Future: Server icons will go here */} {/* Future: Server icons will go here */}
{/* Placeholder for servers */} {/* Placeholder for servers */}
{/* Add Server Button */} <MobileServerIcon isAddButton>
<MobileServerIcon label="Add a Server" isAddButton>
<Plus className="size-5" /> <Plus className="size-5" />
</MobileServerIcon> </MobileServerIcon>
</div> </div>
{/* Right Panel - Channel List */}
<div className="flex-1 flex flex-col bg-background min-w-0 border-l border-border/30"> <div className="flex-1 flex flex-col bg-background min-w-0 border-l border-border/30">
{/* Panel Header */}
<div className="flex items-center px-4 h-12 shrink-0 border-b border-border/30"> <div className="flex items-center px-4 h-12 shrink-0 border-b border-border/30">
<span className="text-sm font-semibold text-foreground">Direct Messages</span> <span className="text-sm font-semibold text-foreground">Direct Messages</span>
</div> </div>
{/* Channel List Content */}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{channelListContent} {channelListContent}
</div> </div>
@ -203,33 +188,47 @@ export function MainContentLayout({
{/* Main Content */} {/* Main Content */}
<div className="flex flex-col flex-1 overflow-hidden"> <div className="flex flex-col flex-1 overflow-hidden">
{page === "dm" && dmChannelId ? ( {page === "dm" && dmChannelId && getParticipantDetails && (
getParticipantDetails ? (
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<DMChannelContent userId={userId} channelId={dmChannelId!} participantDetails={getParticipantDetails} /> <DMChannelContent userId={userId} channelId={dmChannelId} participantDetails={getParticipantDetails} />
</div> </div>
) : ( )}
{page === "dm" && dmChannelId && !getParticipantDetails && (
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<div className="flex items-center justify-center flex-1"> <div className="flex items-center justify-center flex-1">
<Spinner className="size-4 animate-spin" /> <Spinner className="size-4 animate-spin" />
<p className="text-sm text-muted-foreground">Loading...</p> <p className="text-sm text-muted-foreground">Loading...</p>
</div> </div>
</div> </div>
) )}
) : page === "server" && serverChannelId ? (
{page === "server" && serverChannelId && (
<div className="p-4"> <div className="p-4">
<p className="text-sm text-muted-foreground">Server channel {serverChannelId}</p> <p className="text-sm text-muted-foreground">Server channel {serverChannelId}</p>
</div> </div>
) : page === "friends" ? ( )}
{page === "friends" && (
<FriendsPage <FriendsPage
userId={userId} userId={userId}
friendsPage={friendsPage} friendsPage={friendsPage}
socketStatus={socketStatus} socketStatus={socketStatus}
emptyMessage={emptyFriendsMessage} emptyMessage={emptyFriendsMessage}
/> />
) : (
<SettingsPage />
)} )}
{page === "nests" && (
<div className="p-4">
<p className="text-sm text-muted-foreground">Nests</p>
</div>
)}
{page === "discover" && <DiscoverPage userNests={userNests ?? []} />}
{page === "global-nests" && <GlobalNestsPage />}
{page === "support" && <SettingsPage />}
</div> </div>
</div> </div>
</div> </div>
@ -242,20 +241,39 @@ export function MainContentLayout({
) )
} }
// Discord-style mobile server icon component function GlobalNestsPage() {
return (
<div className="flex flex-col flex-1 overflow-hidden">
<div className="flex items-center justify-center flex-1">
<Spinner className="size-4 animate-spin" />
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
)
}
function DiscoverPage({ userNests }: { userNests: Doc<"nests">[] }) {
return (
<div className="flex flex-col flex-1 overflow-hidden">
<div className="flex items-center justify-center flex-1">
<Spinner className="size-4 animate-spin" />
<p className="text-sm text-muted-foreground">Loading...</p>
</div>
</div>
)
}
function MobileServerIcon({ function MobileServerIcon({
children, children,
isActive, isActive,
isHome, isHome,
isAddButton, isAddButton,
label,
onClick onClick
}: { }: {
children: React.ReactNode children: React.ReactNode
isActive?: boolean isActive?: boolean
isHome?: boolean isHome?: boolean
isAddButton?: boolean isAddButton?: boolean
label?: string
onClick?: () => void onClick?: () => void
}) { }) {
return ( return (

View file

@ -6,7 +6,7 @@ import UserCard from "../user/user-card"
export interface PageHeaderProps { export interface PageHeaderProps {
currentChannel: SiPher.Channel | null currentChannel: SiPher.Channel | null
page: "friends" | "support" | "dm" | "server" page: SiPher.PageTypes
friendsPage?: "all" | "available" friendsPage?: "all" | "available"
onFriendsPageChange?: (page: "all" | "available") => void onFriendsPageChange?: (page: "all" | "available") => void
onAddFriend?: () => void onAddFriend?: () => void

View file

@ -1,7 +1,7 @@
"use client" "use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import * as React from "react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -13,12 +13,12 @@ function ScrollArea({
return ( return (
<ScrollAreaPrimitive.Root <ScrollAreaPrimitive.Root
data-slot="scroll-area" data-slot="scroll-area"
className={cn("relative", className)} className={cn("relative overflow-hidden", className)}
{...props} {...props}
> >
<ScrollAreaPrimitive.Viewport <ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport" data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1" className="size-full rounded-[inherit] [&>div]:block!"
> >
{children} {children}
</ScrollAreaPrimitive.Viewport> </ScrollAreaPrimitive.Viewport>

View file

@ -2,8 +2,8 @@
import { loadOlm } from "@/app/auth/scripts/makeKeys"; import { loadOlm } from "@/app/auth/scripts/makeKeys";
import { decryptPassword, encryptPassword, getOrCreatePasswordEncryptionKey } from "@/lib/crypto"; import { decryptPassword, encryptPassword, getOrCreatePasswordEncryptionKey } from "@/lib/crypto";
import { db } from "@/lib/db"; import { db, invalidateSession, validateSessionKeys } from "@/lib/db";
import { checkOlmStatus, getOlmAccount, handleOlmAccountCreation, SendKeysToServerFn } from "@/lib/olm"; import { checkOlmStatus, clearOlmAccountCache, getOlmAccount, handleOlmAccountCreation, SendKeysToServerFn } from "@/lib/olm";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
// ============================================ // ============================================
@ -20,10 +20,25 @@ interface OlmContextValue {
getSession: (recipientId: string, recipientOlmAccount: { getSession: (recipientId: string, recipientOlmAccount: {
identityKey: { curve25519: string; ed25519: string }; identityKey: { curve25519: string; ed25519: string };
oneTimeKeys: Array<{ keyId: string; publicKey: string }>; oneTimeKeys: Array<{ keyId: string; publicKey: string }>;
keyVersion?: number;
}) => Promise<Olm.Session | null>; }) => Promise<Olm.Session | null>;
createInboundSession: (senderId: string, preKeyMessage: string) => Promise<Olm.Session | null>; createInboundSession: (
senderId: string,
preKeyMessage: string,
senderKeyVersion?: number,
senderIdentityKey?: { curve25519: string; ed25519: string }
) => Promise<Olm.Session | null>;
sessions: Map<string, Olm.Session>; sessions: Map<string, Olm.Session>;
// Key synchronization
validateRecipientKeys: (
recipientId: string,
recipientOlmAccount: {
identityKey: { curve25519: string; ed25519: string };
keyVersion?: number;
}
) => Promise<boolean>;
// Password & setup // Password & setup
password: string | null; password: string | null;
passwordError: string | null; passwordError: string | null;
@ -72,6 +87,8 @@ export function OlmProvider({
const passwordSetManuallyRef = useRef(false); const passwordSetManuallyRef = useRef(false);
// Track if we're currently loading the OLM account (prevent duplicate loads) // Track if we're currently loading the OLM account (prevent duplicate loads)
const isLoadingAccountRef = useRef(false); const isLoadingAccountRef = useRef(false);
// Trigger to force reload of OLM account
const [reloadTrigger, setReloadTrigger] = useState(0);
const [, forceUpdate] = useState({}); const [, forceUpdate] = useState({});
// Initialize encryption key on mount // Initialize encryption key on mount
@ -96,7 +113,9 @@ export function OlmProvider({
const saveSessionToDb = useCallback(async ( const saveSessionToDb = useCallback(async (
recipientId: string, recipientId: string,
session: Olm.Session, session: Olm.Session,
sessionPassword: string sessionPassword: string,
recipientKeyVersion?: number,
recipientIdentityKey?: { curve25519: string; ed25519: string }
) => { ) => {
if (!userId) return; if (!userId) return;
@ -106,8 +125,10 @@ export function OlmProvider({
pickledSession: session.pickle(sessionPassword), pickledSession: session.pickle(sessionPassword),
createdAt: Date.now(), createdAt: Date.now(),
updatedAt: Date.now(), updatedAt: Date.now(),
recipientKeyVersion,
recipientIdentityKey,
}); });
console.debug("[OlmContext]: Session saved to DB"); console.debug("[OlmContext]: Session saved to DB with key version:", recipientKeyVersion);
}, [userId]); }, [userId]);
// Helper: Unpickle session from database // Helper: Unpickle session from database
@ -217,8 +238,10 @@ export function OlmProvider({
const loadAccount = async () => { const loadAccount = async () => {
isLoadingAccountRef.current = true; isLoadingAccountRef.current = true;
try { try {
console.debug("[OlmContext]: Loading OLM account..."); const forceReload = reloadTrigger > 0;
const account = await getOlmAccount(userId, password); console.log("[OlmContext]: Loading OLM account... (trigger:", reloadTrigger, "forceReload:", forceReload, ")");
const account = await getOlmAccount(userId, password, forceReload);
if (!account) { if (!account) {
console.warn("[OlmContext]: No OLM account found"); console.warn("[OlmContext]: No OLM account found");
isLoadingAccountRef.current = false; isLoadingAccountRef.current = false;
@ -227,7 +250,7 @@ export function OlmProvider({
setOlmAccount(account); setOlmAccount(account);
setPasswordError(null); setPasswordError(null);
console.debug("[OlmContext]: OLM account loaded successfully"); console.log("[OlmContext]: OLM account loaded successfully");
} catch (err) { } catch (err) {
console.error("[OlmContext]: Failed to load OLM account:", err); console.error("[OlmContext]: Failed to load OLM account:", err);
// Password is wrong - clear it and set error // Password is wrong - clear it and set error
@ -239,7 +262,7 @@ export function OlmProvider({
}; };
loadAccount(); loadAccount();
}, [userId, password, clearPassword]); }, [userId, password, reloadTrigger, clearPassword]);
// Clear password error // Clear password error
const clearPasswordError = useCallback(() => { const clearPasswordError = useCallback(() => {
@ -279,17 +302,24 @@ export function OlmProvider({
if (!userId || !accountPassword.trim()) return; if (!userId || !accountPassword.trim()) return;
setOlmStatus("creating"); setOlmStatus("creating");
const isRotation = olmStatus === "mismatched";
const success = await handleOlmAccountCreation( const success = await handleOlmAccountCreation(
userId, userId,
accountPassword, accountPassword,
sendKeysToServer, sendKeysToServer,
olmStatus === "mismatched" isRotation
); );
if (success) { if (success) {
setOlmStatus("synced"); setOlmStatus("synced");
setShowOlmModal(false); setShowOlmModal(false);
setPassword(accountPassword); setPassword(accountPassword);
// Clear cache and force reload OLM account from IndexedDB after creation/rotation
console.log("[OlmContext]: Keys", isRotation ? "rotated" : "created", "- clearing cache and reloading account");
clearOlmAccountCache(userId);
setReloadTrigger(prev => prev + 1);
} else { } else {
setOlmStatus("not_setup"); setOlmStatus("not_setup");
} }
@ -301,15 +331,43 @@ export function OlmProvider({
recipientOlmAccount: { recipientOlmAccount: {
identityKey: { curve25519: string; ed25519: string }; identityKey: { curve25519: string; ed25519: string };
oneTimeKeys: Array<{ keyId: string; publicKey: string }>; oneTimeKeys: Array<{ keyId: string; publicKey: string }>;
keyVersion?: number; // Optional key version for validation
} }
): Promise<Olm.Session | null> => { ): Promise<Olm.Session | null> => {
console.log(`[OlmContext]: getSession called for ${recipientId}`, {
hasIdentityKey: !!recipientOlmAccount.identityKey,
oneTimeKeysCount: recipientOlmAccount.oneTimeKeys.length,
keyVersion: recipientOlmAccount.keyVersion
});
if (!validateSessionRequirements()) { if (!validateSessionRequirements()) {
console.error("[OlmContext]: Session requirements validation failed");
return null; return null;
} }
// Check if we already have this session in memory // CRITICAL: Validate recipient's keys before using cached session
if (sessionsRef.current.has(recipientId)) { const keyVersion = recipientOlmAccount.keyVersion || 0;
console.debug(`[OlmContext]: Using cached session for ${recipientId}`); console.log(`[OlmContext]: Validating keys for ${recipientId}, version: ${keyVersion}`);
const isValid = await validateSessionKeys(
recipientId,
keyVersion,
recipientOlmAccount.identityKey
);
console.log(`[OlmContext]: Key validation result for ${recipientId}: ${isValid}`);
if (!isValid) {
console.warn(`[OlmContext]: Recipient keys changed, invalidating session for ${recipientId}`);
// Remove cached session
sessionsRef.current.delete(recipientId);
// Remove from database
await invalidateSession(userId!, recipientId);
}
// Check if we already have this session in memory (after validation)
if (sessionsRef.current.has(recipientId) && isValid) {
console.log(`[OlmContext]: Using cached session for ${recipientId}`);
return sessionsRef.current.get(recipientId)!; return sessionsRef.current.get(recipientId)!;
} }
@ -325,13 +383,13 @@ export function OlmProvider({
try { try {
console.debug(`[OlmContext]: Loading/creating session for user ${recipientId}`); console.debug(`[OlmContext]: Loading/creating session for user ${recipientId}`);
// Check if session exists in DB // Check if session exists in DB (after validation cleared invalid ones)
const existingSession = await db.olmSessions const existingSession = await db.olmSessions
.where("[odId+recipientId]") .where("[odId+recipientId]")
.equals([userId!, recipientId]) .equals([userId!, recipientId])
.first(); .first();
if (existingSession) { if (existingSession && isValid) {
console.debug("[OlmContext]: Found existing session in DB, unpickling..."); console.debug("[OlmContext]: Found existing session in DB, unpickling...");
const session = await unpickleSessionFromDb(recipientId, existingSession.pickledSession, password!); const session = await unpickleSessionFromDb(recipientId, existingSession.pickledSession, password!);
@ -344,40 +402,57 @@ export function OlmProvider({
} }
// Create new outbound session // Create new outbound session
console.debug("[OlmContext]: Creating new outbound session..."); console.log("[OlmContext]: Creating new outbound session...");
if (recipientOlmAccount.oneTimeKeys.length === 0) { if (recipientOlmAccount.oneTimeKeys.length === 0) {
console.error("[OlmContext]: No one-time keys available for recipient");
throw new Error("No one-time keys available for recipient"); throw new Error("No one-time keys available for recipient");
} }
const otk = recipientOlmAccount.oneTimeKeys[0]; const otk = recipientOlmAccount.oneTimeKeys[0];
console.log(`[OlmContext]: Using OTK ${otk.keyId} for session creation`);
const Olm: typeof import("@matrix-org/olm") = await loadOlm(); const Olm: typeof import("@matrix-org/olm") = await loadOlm();
const newSession: Olm.Session = new Olm.Session(); const newSession: Olm.Session = new Olm.Session();
console.log(`[OlmContext]: Creating outbound session with:`, {
recipientCurve: recipientOlmAccount.identityKey.curve25519.substring(0, 20) + '...',
otkPublicKey: otk.publicKey.substring(0, 20) + '...'
});
newSession.create_outbound( newSession.create_outbound(
olmAccount!, olmAccount!,
recipientOlmAccount.identityKey.curve25519, recipientOlmAccount.identityKey.curve25519,
otk.publicKey otk.publicKey
); );
console.debug(`[OlmContext]: Created session: ${newSession.session_id()}`); console.log(`[OlmContext]: Created session: ${newSession.session_id()}`);
// Save to DB // Save to DB with key version and identity key
await saveSessionToDb(recipientId, newSession, password!); console.log(`[OlmContext]: Saving session to DB with keyVersion: ${keyVersion}`);
await saveSessionToDb(
recipientId,
newSession,
password!,
keyVersion,
recipientOlmAccount.identityKey
);
// Consume the OTK from server // Consume the OTK from server
try { try {
console.log(`[OlmContext]: Consuming OTK ${otk.keyId} from server`);
await consumeOTK({ await consumeOTK({
userId: recipientId, userId: recipientId,
keyId: otk.keyId, keyId: otk.keyId,
}); });
console.debug(`[OlmContext]: Consumed OTK: ${otk.keyId}`); console.log(`[OlmContext]: Successfully consumed OTK: ${otk.keyId}`);
} catch (err) { } catch (err) {
console.error("[OlmContext]: Failed to consume OTK:", err); console.error("[OlmContext]: Failed to consume OTK:", err);
} }
// Cache it // Cache it
cacheSession(recipientId, newSession); cacheSession(recipientId, newSession);
console.log(`[OlmContext]: Session cached and ready for ${recipientId}`);
return newSession; return newSession;
} catch (err) { } catch (err) {
@ -398,7 +473,9 @@ export function OlmProvider({
// Create an INBOUND session from a received pre-key message // Create an INBOUND session from a received pre-key message
const createInboundSession = useCallback(async ( const createInboundSession = useCallback(async (
senderId: string, senderId: string,
preKeyMessage: string preKeyMessage: string,
senderKeyVersion?: number,
senderIdentityKey?: { curve25519: string; ed25519: string }
): Promise<Olm.Session | null> => { ): Promise<Olm.Session | null> => {
console.debug("[OlmContext]: Args passed to createInboundSession", { senderId, preKeyMessage }); console.debug("[OlmContext]: Args passed to createInboundSession", { senderId, preKeyMessage });
@ -426,8 +503,14 @@ export function OlmProvider({
console.debug(`[OlmContext]: Created inbound session: ${newSession.session_id()}`); console.debug(`[OlmContext]: Created inbound session: ${newSession.session_id()}`);
// Save to DB // Save to DB with sender's key metadata
await saveSessionToDb(senderId, newSession, password!); await saveSessionToDb(
senderId,
newSession,
password!,
senderKeyVersion,
senderIdentityKey
);
// Cache it // Cache it
cacheSession(senderId, newSession); cacheSession(senderId, newSession);
@ -439,6 +522,36 @@ export function OlmProvider({
} }
}, [validateSessionRequirements, olmAccount, password, saveSessionToDb, cacheSession]); }, [validateSessionRequirements, olmAccount, password, saveSessionToDb, cacheSession]);
// Validate recipient keys and invalidate session if keys have changed
const validateRecipientKeys = useCallback(async (
recipientId: string,
recipientOlmAccount: {
identityKey: { curve25519: string; ed25519: string };
keyVersion?: number;
}
): Promise<boolean> => {
if (!userId) return false;
const keyVersion = recipientOlmAccount.keyVersion || 0;
const isValid = await validateSessionKeys(
recipientId,
keyVersion,
recipientOlmAccount.identityKey
);
if (!isValid) {
console.warn(`[OlmContext]: Keys changed for ${recipientId}, invalidating session`);
// Remove cached session
sessionsRef.current.delete(recipientId);
// Remove from database
await invalidateSession(userId, recipientId);
// Force re-render to update UI
forceUpdate({});
}
return isValid;
}, [userId]);
const isReady = useMemo(() => { const isReady = useMemo(() => {
return olmAccount !== null && olmStatus === "synced"; return olmAccount !== null && olmStatus === "synced";
}, [olmAccount, olmStatus]); }, [olmAccount, olmStatus]);
@ -450,6 +563,7 @@ export function OlmProvider({
getSession, getSession,
createInboundSession, createInboundSession,
sessions: sessionsRef.current, sessions: sessionsRef.current,
validateRecipientKeys,
password, password,
passwordError, passwordError,
showOlmModal, showOlmModal,

View file

@ -1,6 +1,7 @@
"use client" "use client"
import { db, getOrCreateDmChannel, incrementUnread, storeMessage } from "@/lib/db"; import { db, getOrCreateDmChannel, storeMessage } from "@/lib/db";
import { isChannelActive, showMessageNotification } from "@/lib/notifications";
import { convex } from "@/lib/providers/Convex"; import { convex } from "@/lib/providers/Convex";
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
@ -96,7 +97,8 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr
messageType: 0 | 1, messageType: 0 | 1,
encryptedBody: string, encryptedBody: string,
currentUserId: string, currentUserId: string,
fromUserId: string fromUserId: string,
senderDetails?: { name: string; image?: string }
) => { ) => {
// Decrypt the message // Decrypt the message
const decryptedBody = session.decrypt(messageType, encryptedBody); const decryptedBody = session.decrypt(messageType, encryptedBody);
@ -113,9 +115,30 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr
throw new Error("Invalid message format"); throw new Error("Invalid message format");
} }
// Store message and increment unread count const channelId = validatedMessage.data.channelId;
await storeMessage(validatedMessage.data as SiPher.Messages.ClientEncrypted.EncryptedMessage & { to: string }); const isActive = isChannelActive(channelId);
await incrementUnread(validatedMessage.data.channelId);
// Store message, skip unread increment if channel is active
await storeMessage(
validatedMessage.data as SiPher.Messages.ClientEncrypted.EncryptedMessage & { to: string },
{ skipUnreadIncrement: isActive }
);
// Show browser notification if channel is not active
if (!isActive && senderDetails) {
showMessageNotification({
senderName: senderDetails.name,
senderImage: senderDetails.image,
messagePreview: validatedMessage.data.content,
channelId: channelId,
userStatus: userStatusRef.current.status, // Pass current user's status
onClick: () => {
// Could navigate to the channel here if needed
window.location.href = `/channels/me/${channelId}`;
},
});
}
console.debug("[Socket]: Message stored successfully"); console.debug("[Socket]: Message stored successfully");
}, [saveSessionState]); }, [saveSessionState]);
@ -169,7 +192,7 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr
return; return;
} }
// Fetch participant details // Fetch participant details including OLM account with key version
try { try {
const participantDetails = await convex.query(api.auth.getParticipantDetails, { const participantDetails = await convex.query(api.auth.getParticipantDetails, {
participantIds: [fromUserId] participantIds: [fromUserId]
@ -183,11 +206,23 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr
const { type, body } = data.content; const { type, body } = data.content;
// Prepare sender details for notifications
const senderDetails = {
name: fromUser.displayUsername || fromUser.username || fromUser.name,
image: fromUser.image || undefined,
};
switch (type) { switch (type) {
case 0: { case 0: {
console.debug("[Socket]: Received inbound message from pre-key message"); console.debug("[Socket]: Received inbound message from pre-key message");
const session = await createInboundSession(fromUserId, body as string); // Create inbound session with sender's key metadata
const session = await createInboundSession(
fromUserId,
body as string,
fromUser.olmAccount?.keyVersion,
fromUser.olmAccount?.identityKey
);
if (!session) { if (!session) {
console.error("[Socket]: Failed to create inbound session"); console.error("[Socket]: Failed to create inbound session");
return; return;
@ -197,7 +232,7 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr
await getOrCreateDmChannel(currentUserId, fromUser); await getOrCreateDmChannel(currentUserId, fromUser);
// Decrypt, validate, and store using helper // Decrypt, validate, and store using helper
await decryptAndStoreMessage(session, type, body as string, currentUserId, fromUserId); await decryptAndStoreMessage(session, type, body as string, currentUserId, fromUserId, senderDetails);
break; break;
} }
case 1: { case 1: {
@ -211,7 +246,7 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr
} }
// Decrypt, validate, and store using helper // Decrypt, validate, and store using helper
await decryptAndStoreMessage(session, type, body as string, currentUserId, fromUserId); await decryptAndStoreMessage(session, type, body as string, currentUserId, fromUserId, senderDetails);
break; break;
} }
} }
@ -222,25 +257,41 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr
// Process queued messages when OLM becomes ready // Process queued messages when OLM becomes ready
useEffect(() => { useEffect(() => {
if (!olmAccount || !olmIsReady || messageQueueRef.current.length === 0) return; if (!olmAccount || !olmIsReady) {
if (messageQueueRef.current.length > 0) {
console.log(`[Socket - processQueue]: Waiting for OLM... ${messageQueueRef.current.length} messages queued`);
}
return;
}
console.log(`[Socket - processQueue]: OLM is now ready, processing ${messageQueueRef.current.length} queued messages`); if (messageQueueRef.current.length === 0) return;
console.log(`[Socket - processQueue]: ========================================`);
console.log(`[Socket - processQueue]: OLM is now ready!`);
console.log(`[Socket - processQueue]: Processing ${messageQueueRef.current.length} queued messages`);
console.log(`[Socket - processQueue]: ========================================`);
const processQueue = async () => { const processQueue = async () => {
const queue = [...messageQueueRef.current]; const queue = [...messageQueueRef.current];
messageQueueRef.current = []; // Clear queue messageQueueRef.current = []; // Clear queue
for (const data of queue) { for (let i = 0; i < queue.length; i++) {
console.log("[Socket - processQueue]: Processing queued message:", data); console.log(`[Socket - processQueue]: Processing queued message ${i + 1}/${queue.length}`);
await processIncomingDM(data); await processIncomingDM(queue[i]);
} }
console.log(`[Socket - processQueue]: All queued messages processed!`);
}; };
processQueue(); processQueue();
}, [olmAccount, olmIsReady, processIncomingDM]); }, [olmAccount, olmIsReady, processIncomingDM]);
useEffect(() => { useEffect(() => {
if (!user.id) return; if (!user.id) {
console.warn("[Socket]: No user ID, not connecting socket");
return;
}
console.log("[Socket]: Initializing socket connection for user:", user.id);
const socket: Socket = io({ const socket: Socket = io({
withCredentials: true, withCredentials: true,
@ -301,7 +352,13 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr
} }
socket.on("connect", () => { socket.on("connect", () => {
console.log("[Socket - connect]: Connected to socket - Authentication successful!"); console.log("[Socket - connect]: ========================================");
console.log("[Socket - connect]: Connected to socket server!");
console.log("[Socket - connect]: Socket ID:", socket.id);
console.log("[Socket - connect]: User ID:", user.id);
console.log("[Socket - connect]: Transport:", socket.io.engine?.transport?.name);
console.log("[Socket - connect]: ========================================");
setSocketStatus("connected"); setSocketStatus("connected");
updateSocketInfo({ updateSocketInfo({
connectedAt: Date.now(), connectedAt: Date.now(),
@ -370,7 +427,11 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr
}); });
socket.on("dm:new", async (data: { content: { type: 0 | 1; body: unknown }, participants: string[] }) => { socket.on("dm:new", async (data: { content: { type: 0 | 1; body: unknown }, participants: string[] }) => {
console.log("[Socket - dm:new]: New DM received:", data); console.log("[Socket - dm:new]: New DM received");
console.log("[Socket - dm:new]: Message type:", data.content.type);
console.log("[Socket - dm:new]: Participants:", data.participants);
console.log("[Socket - dm:new]: OLM account ready:", !!olmAccount);
console.log("[Socket - dm:new]: User ID:", user.id);
// Check if OLM account is loaded // Check if OLM account is loaded
if (!olmAccount) { if (!olmAccount) {
@ -380,8 +441,9 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr
} }
// Process immediately if OLM is ready // Process immediately if OLM is ready
console.debug("[Socket]: Processing incoming DM immediately:", data); console.log("[Socket]: Processing incoming DM immediately");
await processIncomingDM(data); await processIncomingDM(data);
console.log("[Socket]: Finished processing incoming DM");
}); });
return () => { return () => {

View file

@ -20,6 +20,8 @@ export interface OlmSession {
pickledSession: string; // Serialized Olm.Session pickledSession: string; // Serialized Olm.Session
createdAt: number; createdAt: number;
updatedAt: number; updatedAt: number;
recipientKeyVersion?: number; // Track recipient's key version
recipientIdentityKey?: { curve25519: string; ed25519: string }; // Track recipient's identity key
} }
/** Unread count per channel */ /** Unread count per channel */
@ -121,15 +123,92 @@ export async function getChannelMessages(
return query.reverse().sortBy("timestamp").then((msgs) => msgs.slice(0, limit)); return query.reverse().sortBy("timestamp").then((msgs) => msgs.slice(0, limit));
} }
/** Validate session keys match recipient's current keys */
export async function validateSessionKeys(
recipientId: string,
currentKeyVersion: number,
currentIdentityKey: { curve25519: string; ed25519: string }
): Promise<boolean> {
console.debug(`[DB] Validating session keys for ${recipientId}`, {
currentKeyVersion,
currentIdentityKey
});
const sessions = await db.olmSessions
.where("recipientId")
.equals(recipientId)
.toArray();
console.debug(`[DB] Found ${sessions.length} existing sessions for ${recipientId}`);
if (sessions.length === 0) {
console.debug(`[DB] No existing session - validation passes`);
return true; // No session yet, validation passes
}
const session = sessions[0];
console.debug(`[DB] Existing session metadata:`, {
recipientKeyVersion: session.recipientKeyVersion,
recipientIdentityKey: session.recipientIdentityKey
});
// Check if key version has changed
if (session.recipientKeyVersion !== undefined && session.recipientKeyVersion !== currentKeyVersion) {
console.warn(`[DB] Key version mismatch for ${recipientId}: local=${session.recipientKeyVersion}, server=${currentKeyVersion}`);
return false;
}
// Check if identity key has changed
if (session.recipientIdentityKey) {
if (session.recipientIdentityKey.curve25519 !== currentIdentityKey.curve25519 ||
session.recipientIdentityKey.ed25519 !== currentIdentityKey.ed25519) {
console.warn(`[DB] Identity key mismatch for ${recipientId}`);
console.warn(`[DB] Local curve25519: ${session.recipientIdentityKey.curve25519}`);
console.warn(`[DB] Server curve25519: ${currentIdentityKey.curve25519}`);
console.warn(`[DB] Local ed25519: ${session.recipientIdentityKey.ed25519}`);
console.warn(`[DB] Server ed25519: ${currentIdentityKey.ed25519}`);
return false;
}
}
console.debug(`[DB] Key validation passed for ${recipientId}`);
return true;
}
/** Invalidate and remove session for a recipient */
export async function invalidateSession(userId: string, recipientId: string): Promise<void> {
await db.olmSessions
.where("[odId+recipientId]")
.equals([userId, recipientId])
.delete();
console.log(`[DB] Invalidated session for ${recipientId}`);
}
/** Add a message to local storage */ /** Add a message to local storage */
export async function sendMessage( export async function sendMessage(
message: Omit<SiPher.Messages.ClientEncrypted.EncryptedMessage, "id"> & { to: string }, message: Omit<SiPher.Messages.ClientEncrypted.EncryptedMessage, "id"> & { to: string },
olmSession: Olm.Session, olmSession: Olm.Session,
sendMessage: (message: { type: 0 | 1; body: string }, to: string) => void, sendMessage: (message: { type: 0 | 1; body: string }, to: string) => void,
saveSession?: { userId: string; recipientId: string; password: string } saveSession?: {
userId: string;
recipientId: string;
password: string;
recipientKeyVersion?: number;
recipientIdentityKey?: { curve25519: string; ed25519: string };
}
): Promise<string> { ): Promise<string> {
console.log("[DB] sendMessage called", {
channelId: message.channelId,
to: message.to,
hasSession: !!olmSession,
hasSaveSession: !!saveSession
});
const id = crypto.randomUUID(); const id = crypto.randomUUID();
console.log("[DB] Generated message ID:", id);
await db.messages.add({ ...message, id }); await db.messages.add({ ...message, id });
console.log("[DB] Message added to local DB");
// Update channel's lastMessageAt // Update channel's lastMessageAt
await db.channels.where("id").equals(message.channelId).modify((channel) => { await db.channels.where("id").equals(message.channelId).modify((channel) => {
@ -137,8 +216,10 @@ export async function sendMessage(
channel.times.lastMessageAt = message.timestamp; channel.times.lastMessageAt = message.timestamp;
channel.times.updatedAt = Date.now(); channel.times.updatedAt = Date.now();
}); });
console.log("[DB] Channel updated with last message");
// Encrypt the message // Encrypt the message
console.log("[DB] Encrypting message...");
const encrypted = olmSession.encrypt( const encrypted = olmSession.encrypt(
JSON.stringify({ JSON.stringify({
id, id,
@ -148,29 +229,49 @@ export async function sendMessage(
status: message.status, status: message.status,
content: message.content, content: message.content,
} satisfies SiPher.Messages.ClientEncrypted.EncryptedMessage) } satisfies SiPher.Messages.ClientEncrypted.EncryptedMessage)
) );
console.log("[DB] Message encrypted, type:", encrypted.type);
// CRITICAL: Save the updated session after encrypt (ratchet has advanced) // CRITICAL: Save the updated session after encrypt (ratchet has advanced)
if (saveSession) { if (saveSession) {
console.log("[DB] Saving session state...", {
recipientKeyVersion: saveSession.recipientKeyVersion,
hasIdentityKey: !!saveSession.recipientIdentityKey
});
const updateData: Partial<OlmSession> = {
pickledSession: olmSession.pickle(saveSession.password),
updatedAt: Date.now(),
};
// Update key version and identity key if provided
if (saveSession.recipientKeyVersion !== undefined) {
updateData.recipientKeyVersion = saveSession.recipientKeyVersion;
}
if (saveSession.recipientIdentityKey) {
updateData.recipientIdentityKey = saveSession.recipientIdentityKey;
}
await db.olmSessions await db.olmSessions
.where("[odId+recipientId]") .where("[odId+recipientId]")
.equals([saveSession.userId, saveSession.recipientId]) .equals([saveSession.userId, saveSession.recipientId])
.modify({ .modify(updateData);
pickledSession: olmSession.pickle(saveSession.password), console.log("[DB] Session state saved after encrypt with keyVersion:", saveSession.recipientKeyVersion);
updatedAt: Date.now(),
});
console.debug("[DB] Session state saved after encrypt");
} }
// Send the message using the socket // Send the message using the socket
console.log("[DB] Sending message via socket to:", message.to);
sendMessage(encrypted, message.to); sendMessage(encrypted, message.to);
console.log("[DB] Message sent via socket");
return id; return id;
} }
export async function storeMessage( export async function storeMessage(
message: SiPher.Messages.ClientEncrypted.EncryptedMessage message: SiPher.Messages.ClientEncrypted.EncryptedMessage & { to: string },
& { to: string } options?: {
skipUnreadIncrement?: boolean; // Skip incrementing if user is viewing the channel
}
): Promise<void> { ): Promise<void> {
await db.messages.add(message); await db.messages.add(message);
await db.channels.where("id").equals(message.channelId).modify((channel) => { await db.channels.where("id").equals(message.channelId).modify((channel) => {
@ -178,7 +279,11 @@ export async function storeMessage(
channel.times.lastMessageAt = message.timestamp; channel.times.lastMessageAt = message.timestamp;
channel.times.updatedAt = Date.now(); channel.times.updatedAt = Date.now();
}); });
// Only increment unread if not explicitly skipped
if (!options?.skipUnreadIncrement) {
await incrementUnread(message.channelId); await incrementUnread(message.channelId);
}
} }
/** Increment unread count for a channel */ /** Increment unread count for a channel */

View file

@ -0,0 +1,135 @@
/**
* Browser Notification System
* Handles both unread counts and native browser notifications
*/
// Track which channel the user is currently viewing
let activeChannelId: string | null = null;
/**
* Set the currently active channel
* Messages in this channel won't trigger notifications or increment unread
*/
export function setActiveChannel(channelId: string | null) {
activeChannelId = channelId;
console.debug("[Notifications] Active channel set to:", channelId);
}
/**
* Get the currently active channel
*/
export function getActiveChannel(): string | null {
return activeChannelId;
}
/**
* Check if user is currently viewing a specific channel
*/
export function isChannelActive(channelId: string): boolean {
return activeChannelId === channelId;
}
/**
* Request browser notification permission
* Should be called on user interaction (button click, etc.)
*/
export async function requestNotificationPermission(): Promise<NotificationPermission> {
if (!("Notification" in window)) {
console.warn("[Notifications] Browser doesn't support notifications");
return "denied";
}
if (Notification.permission === "granted") {
return "granted";
}
if (Notification.permission !== "denied") {
const permission = await Notification.requestPermission();
console.log("[Notifications] Permission:", permission);
return permission;
}
return Notification.permission;
}
/**
* Check if notifications are enabled
*/
export function areNotificationsEnabled(): boolean {
return "Notification" in window && Notification.permission === "granted";
}
/**
* Show a browser notification for a new message
*/
export function showMessageNotification(options: {
senderName: string;
senderImage?: string;
messagePreview: string;
channelId: string;
userStatus?: "online" | "busy" | "offline" | "away"; // Current user's status
onClick?: () => void;
}) {
// Don't show notification if user status is "busy"
if (options.userStatus === "busy") {
console.debug("[Notifications] Skipping notification - user is busy");
return;
}
// Don't show notification if user is viewing this channel
if (isChannelActive(options.channelId)) {
console.debug("[Notifications] Skipping notification - channel is active");
return;
}
// Don't show if notifications not enabled
if (!areNotificationsEnabled()) {
console.debug("[Notifications] Skipping notification - not enabled");
return;
}
// Don't show if page is focused (user is actively using the app)
if (document.hasFocus()) {
console.debug("[Notifications] Skipping notification - page has focus");
return;
}
try {
const notification = new Notification(`${options.senderName}`, {
body: options.messagePreview,
icon: options.senderImage || "/default-avatar.png",
badge: "/logo.png",
tag: options.channelId, // Prevents duplicate notifications for same channel
requireInteraction: false,
silent: false,
});
notification.onclick = () => {
window.focus();
notification.close();
options.onClick?.();
};
// Auto-close after 5 seconds
setTimeout(() => notification.close(), 5000);
console.log("[Notifications] Browser notification shown");
} catch (error) {
console.error("[Notifications] Failed to show notification:", error);
}
}
/**
* Play a notification sound (optional)
*/
export function playNotificationSound() {
try {
const audio = new Audio("/notification.mp3");
audio.volume = 0.5;
audio.play().catch((err) => {
console.debug("[Notifications] Failed to play sound:", err);
});
} catch (error) {
console.debug("[Notifications] Audio not available");
}
}

View file

@ -20,17 +20,22 @@ export type SendKeysToServerFn = (args: {
* Unpickle and retrieve the OLM account for a user * Unpickle and retrieve the OLM account for a user
* @param userId - The user's ID * @param userId - The user's ID
* @param password - The password used to pickle the account * @param password - The password used to pickle the account
* @param forceReload - If true, skips cache and reloads from IndexedDB
* @returns Promise resolving to the unpickled Olm.Account, or null if not found * @returns Promise resolving to the unpickled Olm.Account, or null if not found
*/ */
export async function getOlmAccount( export async function getOlmAccount(
userId: string, userId: string,
password: string password: string,
forceReload: boolean = false
): Promise<any | null> { ): Promise<any | null> {
// Check cache first // Check cache first (unless forcing reload)
if ((window as any).olmAccountCache?.[userId]) { if (!forceReload && (window as any).olmAccountCache?.[userId]) {
console.debug("[OLM] Using cached account for", userId);
return (window as any).olmAccountCache[userId]; return (window as any).olmAccountCache[userId];
} }
console.debug("[OLM] Loading account from IndexedDB for", userId, "forceReload:", forceReload);
// Get pickled account from DB // Get pickled account from DB
const pickledData = await db.olmAccounts.get(userId); const pickledData = await db.olmAccounts.get(userId);
if (!pickledData) return null; if (!pickledData) return null;
@ -46,9 +51,21 @@ export async function getOlmAccount(
} }
(window as any).olmAccountCache[userId] = account; (window as any).olmAccountCache[userId] = account;
console.debug("[OLM] Account loaded and cached for", userId);
return account; return account;
} }
/**
* Clear cached OLM account for a user
* Call this after key rotation to force reload
*/
export function clearOlmAccountCache(userId: string): void {
if ((window as any).olmAccountCache?.[userId]) {
delete (window as any).olmAccountCache[userId];
console.debug("[OLM] Cleared account cache for", userId);
}
}
/** /**
* Check if user has an OLM account stored locally in IndexedDB * Check if user has an OLM account stored locally in IndexedDB
* @param userId - The user's ID * @param userId - The user's ID

112
src/lib/olm/keySync.ts Normal file
View file

@ -0,0 +1,112 @@
/**
* OLM Key Synchronization Utilities
*
* This module provides utilities for managing OLM key synchronization
* to handle key rotation scenarios where users change their encryption keys.
*/
import { convex } from "@/lib/providers/Convex";
import { api } from "../../../convex/_generated/api";
import { invalidateSession, validateSessionKeys } from "../db";
/**
* Check if a recipient's keys have changed and invalidate session if needed
* @param userId - Current user's ID
* @param recipientId - Recipient's user ID
* @returns True if keys are valid, false if they changed
*/
export async function checkRecipientKeyStatus(
userId: string,
recipientId: string
): Promise<{
isValid: boolean;
keyVersion?: number;
updatedAt?: number;
identityKey?: { curve25519: string; ed25519: string };
}> {
try {
// Fetch current key version from server
const keyInfo = await convex.query(api.auth.getKeyVersion, {
userId: recipientId
});
if (!keyInfo) {
return { isValid: false };
}
// Validate against locally stored key metadata
const isValid = await validateSessionKeys(
recipientId,
keyInfo.keyVersion,
keyInfo.identityKey
);
if (!isValid) {
console.warn(`[KeySync] Keys changed for ${recipientId}, invalidating session`);
await invalidateSession(userId, recipientId);
}
return {
isValid,
keyVersion: keyInfo.keyVersion,
updatedAt: keyInfo.updatedAt,
identityKey: keyInfo.identityKey,
};
} catch (error) {
console.error("[KeySync] Failed to check recipient key status:", error);
return { isValid: false };
}
}
/**
* Batch check multiple recipients' key statuses
* @param userId - Current user's ID
* @param recipientIds - Array of recipient user IDs
* @returns Map of recipientId to validation result
*/
export async function batchCheckRecipientKeys(
userId: string,
recipientIds: string[]
): Promise<Map<string, { isValid: boolean; keyVersion?: number }>> {
const results = new Map();
// Check all recipients in parallel
await Promise.all(
recipientIds.map(async (recipientId) => {
const result = await checkRecipientKeyStatus(userId, recipientId);
results.set(recipientId, result);
})
);
return results;
}
/**
* Create a periodic key sync checker
* @param userId - Current user's ID
* @param recipientIds - Array of recipient user IDs to monitor
* @param intervalMs - Check interval in milliseconds (default: 5 minutes)
* @param onKeyChange - Callback when keys change
* @returns Cleanup function to stop the interval
*/
export function createPeriodicKeySync(
userId: string,
recipientIds: string[],
intervalMs: number = 5 * 60 * 1000, // 5 minutes default
onKeyChange?: (recipientId: string) => void
): () => void {
const intervalId = setInterval(async () => {
console.debug("[KeySync] Running periodic key check...");
const results = await batchCheckRecipientKeys(userId, recipientIds);
results.forEach((result, recipientId) => {
if (!result.isValid) {
console.warn(`[KeySync] Keys changed for ${recipientId}`);
onKeyChange?.(recipientId);
}
});
}, intervalMs);
// Return cleanup function
return () => clearInterval(intervalId);
}

View file

@ -74,7 +74,7 @@ const dmEvent: SiPher.EventsType = {
io.to(to).emit("dm:new", dmData); io.to(to).emit("dm:new", dmData);
console.log(`[DM] ${sender.id}${to} in room ${roomId}`); console.log(`[DM] ${sender.id}${to} in room ${roomId}: ${message.body}`);
}, },
}; };

View file

@ -25,12 +25,15 @@ interface SocketManagerOptions {
authMethod?: "session" | "ott"; authMethod?: "session" | "ott";
} }
const RECONCILE_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes
export default class SocketManager { export default class SocketManager {
private socketIo: SocketIOServer | null = null; private socketIo: SocketIOServer | null = null;
private events: Map<string, SiPher.EventsType[]> = new Map(); private events: Map<string, SiPher.EventsType[]> = new Map();
private options: SocketManagerOptions; private options: SocketManagerOptions;
private convex: ConvexHttpClient; private convex: ConvexHttpClient;
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
constructor(nextServer: HTTPServer, options: SocketManagerOptions = {}) { constructor(nextServer: HTTPServer, options: SocketManagerOptions = {}) {
if (!nextServer) { if (!nextServer) {
@ -148,6 +151,48 @@ export default class SocketManager {
return this.socketIo?.sockets.sockets.get(userId); return this.socketIo?.sockets.sockets.get(userId);
} }
private extractJwt(socket: Socket): string | null {
const cookies = socket.handshake.headers.cookie;
if (!cookies || !cookies.includes("better-auth.convex_jwt")) return null;
const token = cookies.split("better-auth.convex_jwt=")[1]?.split(";")[0];
return token || null;
}
/**
* Periodically queries Convex for all users with a non-offline status,
* checks if they have a live socket connection, and forces offline
* any that don't.
*/
private startStatusReconciliation(): void {
if (this.reconcileTimer) return;
this.reconcileTimer = setInterval(async () => {
try {
const nonOfflineUsers = await this.convex.query(api.auth.getNonOfflineUserIds, {});
if (!nonOfflineUsers || nonOfflineUsers.length === 0) return;
const connectedSocketIds = this.socketIo?.sockets.sockets;
let reconciled = 0;
for (const entry of nonOfflineUsers) {
const hasSocket = connectedSocketIds?.has(entry.userId) ?? false;
if (hasSocket) continue;
await this.convex.mutation(api.auth.forceUserOffline, { userId: entry.userId });
reconciled++;
}
if (reconciled > 0) {
console.log(`[SocketManager] Reconciled ${reconciled} ghost user(s) to offline`);
}
} catch (error) {
console.error("[SocketManager] Status reconciliation error:", error);
}
}, RECONCILE_INTERVAL_MS);
console.log(`[SocketManager] Status reconciliation started (every ${RECONCILE_INTERVAL_MS / 1000}s)`);
}
public async initializeEventHandler(): Promise<void> { public async initializeEventHandler(): Promise<void> {
// Get events from the events folder // Get events from the events folder
const socketIo = this.getSocketIo(); const socketIo = this.getSocketIo();
@ -222,21 +267,15 @@ export default class SocketManager {
// Handle disconnect within the connection context // Handle disconnect within the connection context
socket.on("disconnect", async (reason) => { socket.on("disconnect", async (reason) => {
try { try {
const cookies = socket.handshake.headers.cookie; const token = this.extractJwt(socket);
if (!cookies || !cookies.includes("better-auth.convex_jwt")) return; if (!token) {
const session = cookies.split("better-auth.convex_jwt=")[1].split(";")[0];
if (!session) {
console.warn(`[SocketManager] No session found for user ${socket.id}, skipping status update`); console.warn(`[SocketManager] No session found for user ${socket.id}, skipping status update`);
return; return;
} }
// Set auth token for this mutation this.convex.setAuth(token);
this.convex.setAuth(session);
await this.convex.mutation(api.auth.updateUserStatus, { await this.convex.mutation(api.auth.updateUserStatus, {
status: "offline", status: "offline",
isUserSet: false,
}); });
console.log(`[SocketManager] Set user ${socket.id} status to offline`); console.log(`[SocketManager] Set user ${socket.id} status to offline`);
} catch (error) { } catch (error) {
@ -244,5 +283,7 @@ export default class SocketManager {
} }
}); });
}) })
this.startStatusReconciliation();
} }
} }

View file

@ -1,7 +1,9 @@
import { createServer } from 'http' import { config } from "dotenv";
import next from 'next' import { createServer } from 'http';
import { parse } from 'url' import next from 'next';
import SocketManager from "./lib/sockets" import { parse } from 'url';
import SocketManager from "./lib/sockets";
config({ path: '.env.local' });
const port = parseInt(process.env.PORT || '3000', 10) const port = parseInt(process.env.PORT || '3000', 10)
const dev = process.env.NODE_ENV !== 'production' const dev = process.env.NODE_ENV !== 'production'

View file

@ -134,6 +134,9 @@ declare global {
keyId: string keyId: string
publicKey: string publicKey: string
}> }>
createdAt: number
updatedAt: number
keyVersion: number
} | null } | null
} }
} }

View file

@ -1,3 +1,4 @@
declare global { declare global {
namespace SiPher { namespace SiPher {
@ -8,12 +9,35 @@ declare global {
currentChannel?: SiPher.Channel; currentChannel?: SiPher.Channel;
disconnectSocket: () => void; disconnectSocket: () => void;
connectSocket: () => void; connectSocket: () => void;
routeInfo: RouteInfo;
}
type PageTypes = "friends" | "support" | "dm" | "server" | "nests" | "discover" | "global-nests";
type RouteInfo = {
type: PageTypes;
} | {
type: PageTypes.dm;
dmChannelId: string;
} | {
type: PageTypes.server;
serverId: string;
serverChannelId: string;
} | {
type: PageTypes.nests;
serverId: string;
serverChannelId: string;
} | {
type: PageTypes.discover;
} | {
type: PageTypes.support;
} }
interface SidebarItem { interface SidebarItem {
id: string; id: string;
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
href?: string;
} }
type OlmStatus = "checking" | "synced" | "mismatched" | "not_setup" | "creating"; type OlmStatus = "checking" | "synced" | "mismatched" | "not_setup" | "creating";