Enhance authentication and messaging features with OLM integration

- Added support for consuming one-time keys (OTK) in the authentication flow.
- Implemented new mutation `consumeOTK` to handle OTK consumption and update user accounts.
- Updated participant details to include OLM account information.
- Refactored socket management to improve direct messaging functionality.
- Introduced new UI components for password handling and user interactions.
- Updated dependencies in package.json and bun.lock for compatibility and feature enhancements.
This commit is contained in:
Nixyan 2026-01-07 14:47:07 -03:00
parent d9368301ae
commit 07f9984f03
30 changed files with 1732 additions and 329 deletions

7
LICENSE Normal file
View file

@ -0,0 +1,7 @@
Copyright 2026 Marcello Brito
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

4
README.md Normal file
View file

@ -0,0 +1,4 @@
please don't use this
I made this to test things
I am not to be trusted under any circunstances

133
bun.lock
View file

@ -1,66 +1,67 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "sipher", "name": "sipher",
"dependencies": { "dependencies": {
"@better-fetch/fetch": "^1.1.21", "@better-fetch/fetch": "latest",
"@convex-dev/better-auth": "^0.10.9", "@convex-dev/better-auth": "latest",
"@marsidev/react-turnstile": "^1.4.0", "@marsidev/react-turnstile": "latest",
"@matrix-org/olm": "^3.2.15", "@matrix-org/olm": "latest",
"@nanostores/react": "^1.0.0", "@nanostores/react": "latest",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "latest",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "latest",
"@radix-ui/react-checkbox": "^1.3.3", "@radix-ui/react-checkbox": "latest",
"@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-context-menu": "latest",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "latest",
"@radix-ui/react-hover-card": "^1.1.15", "@radix-ui/react-hover-card": "latest",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "latest",
"@radix-ui/react-menubar": "^1.1.16", "@radix-ui/react-menubar": "latest",
"@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-popover": "latest",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "latest",
"@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-scroll-area": "latest",
"@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-separator": "latest",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "latest",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "latest",
"@types/libsodium-wrappers": "^0.7.14", "@types/libsodium-wrappers": "latest",
"better-auth": "1.4.9", "better-auth": "latest",
"class-variance-authority": "^0.7.1", "class-variance-authority": "latest",
"clsx": "^2.1.1", "clsx": "latest",
"cmdk": "^1.1.1", "cmdk": "latest",
"convex": "^1.31.2", "convex": "latest",
"cross-env": "^10.1.0", "cross-env": "latest",
"date-fns": "^4.1.0", "date-fns": "latest",
"dexie": "^4.2.1", "dexie": "latest",
"dexie-react-hooks": "^4.2.0", "dexie-react-hooks": "latest",
"framer-motion": "^12.23.26", "framer-motion": "latest",
"libsodium-wrappers": "^0.7.15", "libsodium-wrappers": "latest",
"lucide-react": "^0.562.0", "lucide-react": "latest",
"nanostores": "^1.1.0", "nanostores": "latest",
"next": "16.1.1", "next": "latest",
"next-themes": "^0.4.6", "next-themes": "latest",
"react": "19.2.3", "react": "latest",
"react-day-picker": "^9.13.0", "react-day-picker": "latest",
"react-dom": "19.2.3", "react-dom": "latest",
"socket.io": "^4.8.3", "socket.io": "latest",
"socket.io-client": "^4.8.3", "socket.io-client": "latest",
"sonner": "^2.0.7", "sonner": "latest",
"tailwind-merge": "^3.4.0", "tailwind-merge": "latest",
"ws": "^8.18.3", "ws": "latest",
"zod": "^4.2.1", "zod": "latest",
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "latest",
"@types/bun": "^1.3.5", "@types/bun": "latest",
"@types/node": "^25.0.3", "@types/node": "latest",
"@types/react": "^19.2.7", "@types/react": "latest",
"@types/react-dom": "^19.2.3", "@types/react-dom": "latest",
"@types/ws": "^8.18.1", "@types/ws": "latest",
"babel-plugin-react-compiler": "1.0.0", "babel-plugin-react-compiler": "latest",
"tailwindcss": "^4.1.18", "tailwindcss": "latest",
"tsx": "^4.21.0", "tsx": "latest",
"tw-animate-css": "^1.4.0", "tw-animate-css": "latest",
"typescript": "^5.9.3", "typescript": "latest",
}, },
}, },
}, },
@ -73,11 +74,11 @@
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@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/core": ["@better-auth/core@1.4.10", "", { "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-AThrfb6CpG80wqkanfrbN2/fGOYzhGladHFf3JhaWt/3/Vtf4h084T6PJLrDE7M/vCCGYvDI1DkvP3P1OB2HAg=="],
"@better-auth/passkey": ["@better-auth/passkey@1.4.9", "", { "dependencies": { "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/core": "1.4.9", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-auth": "1.4.9", "better-call": "1.1.7", "nanostores": "^1.0.1" } }, "sha512-fPsV0LYbmPytxrTaltM2RXbJnmSttX9UWr4wkZtJYgCBGeFqN8+8ZzBTZXOymWDJTVQ0kVZrD7c7/HyxXEG1zA=="], "@better-auth/passkey": ["@better-auth/passkey@1.4.9", "", { "dependencies": { "@simplewebauthn/browser": "^13.1.2", "@simplewebauthn/server": "^13.1.2", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/core": "1.4.9", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-auth": "1.4.9", "better-call": "1.1.7", "nanostores": "^1.0.1" } }, "sha512-fPsV0LYbmPytxrTaltM2RXbJnmSttX9UWr4wkZtJYgCBGeFqN8+8ZzBTZXOymWDJTVQ0kVZrD7c7/HyxXEG1zA=="],
"@better-auth/telemetry": ["@better-auth/telemetry@1.4.9", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.9" } }, "sha512-Tthy1/Gmx+pYlbvRQPBTKfVei8+pJwvH1NZp+5SbhwA6K2EXIaoonx/K6N/AXYs2aKUpyR4/gzqDesDjL7zd6A=="], "@better-auth/telemetry": ["@better-auth/telemetry@1.4.10", "", { "dependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21" }, "peerDependencies": { "@better-auth/core": "1.4.10" } }, "sha512-Dq4XJX6EKsUu0h3jpRagX739p/VMOTcnJYWRrLtDYkqtZFg+sFiFsSWVcfapZoWpRSUGYX9iKwl6nDHn6Ju2oQ=="],
"@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="], "@better-auth/utils": ["@better-auth/utils@0.3.0", "", {}, "sha512-W+Adw6ZA6mgvnSnhOki270rwJ42t4XzSK6YWGF//BbVXL6SwCLWfyzBc1lN2m/4RM28KubdBKQ4X5VMoLRNPQw=="],
@ -215,7 +216,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.0", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-3aR7mh4lATeayWt6GjWuYyLjM0GL148z7/ZQl0rLKGpDYIrWgoU2PYsdAdA9fzH+JysW3Q2OaPfHvv66cwcAZg=="], "@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=="],
"@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=="],
@ -417,7 +418,7 @@
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="],
"better-auth": ["better-auth@1.4.9", "", { "dependencies": { "@better-auth/core": "1.4.9", "@better-auth/telemetry": "1.4.9", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.7", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.12" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-usSdjuyTzZwIvM8fjF8YGhPncxV3MAg3dHUO9uPUnf0yklXUSYISiH1+imk6/Z+UBqsscyyPRnbIyjyK97p7YA=="], "better-auth": ["better-auth@1.4.10", "", { "dependencies": { "@better-auth/core": "1.4.10", "@better-auth/telemetry": "1.4.10", "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.0.0", "@noble/hashes": "^2.0.0", "better-call": "1.1.7", "defu": "^6.1.4", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1", "zod": "^4.1.12" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": ">=0.41.0", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-0kqwEBJLe8eyFzbUspRG/htOriCf9uMLlnpe34dlIJGdmDfPuQISd4shShvUrvIVhPxsY1dSTXdXPLpqISYOYg=="],
"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=="],
@ -475,7 +476,7 @@
"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.23.26", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "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-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA=="], "framer-motion": ["framer-motion@12.23.27", "", { "dependencies": { "motion-dom": "^12.23.23", "motion-utils": "^12.23.6", "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-EAcX8FS8jzZ4tSKpj+1GhwbVY+r1gfamPFwXZAsioPqu/ffRwU2otkKg6GEDCR41FVJv3RoBN7Aqep6drL9Itg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@ -493,9 +494,9 @@
"kysely": ["kysely@0.28.8", "", {}, "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA=="], "kysely": ["kysely@0.28.8", "", {}, "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA=="],
"libsodium": ["libsodium@0.7.15", "", {}, "sha512-sZwRknt/tUpE2AwzHq3jEyUU5uvIZHtSssktXq7owd++3CSgn8RGrv6UZJJBpP7+iBghBqe7Z06/2M31rI2NKw=="], "libsodium": ["libsodium@0.7.16", "", {}, "sha512-3HrzSPuzm6Yt9aTYCDxYEG8x8/6C0+ag655Y7rhhWZM9PT4NpdnbqlzXhGZlDnkgR6MeSTnOt/VIyHLs9aSf+Q=="],
"libsodium-wrappers": ["libsodium-wrappers@0.7.15", "", { "dependencies": { "libsodium": "^0.7.15" } }, "sha512-E4anqJQwcfiC6+Yrl01C1m8p99wEhLmJSs0VQqST66SbQXXBoaJY0pF4BNjRYa/sOQAxx6lXAaAFIlx+15tXJQ=="], "libsodium-wrappers": ["libsodium-wrappers@0.7.16", "", { "dependencies": { "libsodium": "^0.7.16" } }, "sha512-Gtr/WBx4dKjvRL1pvfwZqu7gO6AfrQ0u9vFL+kXihtHf6NfkROR8pjYWn98MFDI3jN19Ii1ZUfPR9afGiPyfHg=="],
"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.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=="],
@ -639,7 +640,13 @@
"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.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], "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/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
"@convex-dev/better-auth/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="],
"@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=="],

View file

@ -951,6 +951,7 @@ export declare const components: {
"query", "query",
"internal", "internal",
{ {
join?: any;
limit?: number; limit?: number;
model: model:
| "user" | "user"
@ -1004,6 +1005,7 @@ export declare const components: {
"query", "query",
"internal", "internal",
{ {
join?: any;
model: model:
| "user" | "user"
| "userStatus" | "userStatus"
@ -2006,6 +2008,12 @@ export declare const components: {
}; };
olm: { olm: {
index: { index: {
consumeOTK: FunctionReference<
"mutation",
"internal",
{ keyId: string; userId: string },
any
>;
retrieveServerOlmAccount: FunctionReference< retrieveServerOlmAccount: FunctionReference<
"query", "query",
"internal", "internal",

View file

@ -195,3 +195,16 @@ export const getParticipantDetails = query({
}); });
}, },
}); });
export const consumeOTK = mutation({
args: {
userId: v.string(),
keyId: v.string(),
},
handler: async (ctx, args) => {
return ctx.runMutation(components.betterAuth.olm.index.consumeOTK, {
userId: args.userId,
keyId: args.keyId,
});
},
});

View file

@ -927,6 +927,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
"query", "query",
"internal", "internal",
{ {
join?: any;
limit?: number; limit?: number;
model: model:
| "user" | "user"
@ -981,6 +982,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
"query", "query",
"internal", "internal",
{ {
join?: any;
model: model:
| "user" | "user"
| "userStatus" | "userStatus"
@ -1986,6 +1988,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
}; };
olm: { olm: {
index: { index: {
consumeOTK: FunctionReference<
"mutation",
"internal",
{ keyId: string; userId: string },
any,
Name
>;
retrieveServerOlmAccount: FunctionReference< retrieveServerOlmAccount: FunctionReference<
"query", "query",
"internal", "internal",

View file

@ -46,3 +46,28 @@ export const retrieveServerOlmAccount = query({
return null; return null;
}, },
}); });
export const consumeOTK = mutation({
args: {
userId: v.string(),
keyId: v.string(),
},
handler: async (ctx, args) => {
const olmAccount = await ctx.db.get<"olmAccount">(args.userId as Id<"olmAccount">);
if (!olmAccount) throw new Error("User has no OLM account");
const oneTimeKeys = olmAccount.oneTimeKeys;
const keyIndex = oneTimeKeys.findIndex((key) => key.keyId === args.keyId);
if (keyIndex === -1) throw new Error("The key to be consumed was not found");
oneTimeKeys.splice(keyIndex, 1);
await ctx.db.patch<"olmAccount">(args.userId as Id<"olmAccount">, {
oneTimeKeys,
});
return {
consumed: true,
keysLeft: oneTimeKeys.length
}
},
})

View file

@ -353,6 +353,7 @@ export const getParticipantDetails = query({
const participantDetails = await Promise.all(filteredParticipantIds.map(async (id) => { const participantDetails = await Promise.all(filteredParticipantIds.map(async (id) => {
const participant = await ctx.db.get("user", id) const participant = await ctx.db.get("user", id)
const participantStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", id)).first(); const participantStatus = await ctx.db.query("userStatus").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;
return { return {
@ -362,9 +363,10 @@ 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,
} }
})); }));
return participantDetails.filter(Boolean); return participantDetails
} }
}) })

View file

@ -12,7 +12,7 @@
"dependencies": { "dependencies": {
"@better-fetch/fetch": "^1.1.21", "@better-fetch/fetch": "^1.1.21",
"@convex-dev/better-auth": "^0.10.9", "@convex-dev/better-auth": "^0.10.9",
"@marsidev/react-turnstile": "^1.4.0", "@marsidev/react-turnstile": "^1.4.1",
"@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",
@ -30,7 +30,7 @@
"@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/libsodium-wrappers": "^0.7.14", "@types/libsodium-wrappers": "^0.7.14",
"better-auth": "1.4.9", "better-auth": "1.4.10",
"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",
@ -39,8 +39,8 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dexie": "^4.2.1", "dexie": "^4.2.1",
"dexie-react-hooks": "^4.2.0", "dexie-react-hooks": "^4.2.0",
"framer-motion": "^12.23.26", "framer-motion": "^12.23.27",
"libsodium-wrappers": "^0.7.15", "libsodium-wrappers": "^0.7.16",
"lucide-react": "^0.562.0", "lucide-react": "^0.562.0",
"nanostores": "^1.1.0", "nanostores": "^1.1.0",
"next": "16.1.1", "next": "16.1.1",
@ -53,7 +53,7 @@
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"ws": "^8.18.3", "ws": "^8.18.3",
"zod": "^4.2.1" "zod": "^4.3.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4.1.18", "@tailwindcss/postcss": "^4.1.18",

View file

@ -1,7 +1,7 @@
import { db } from "@/lib/db"; import { db } from "@/lib/db";
// Load OLM via script tag to bypass bundler entirely // Load OLM via script tag to bypass bundler entirely
async function loadOlm() { export async function loadOlm() {
if (typeof window === "undefined") throw new Error("OLM requires browser"); if (typeof window === "undefined") throw new Error("OLM requires browser");
if ((window as any).Olm) return (window as any).Olm; if ((window as any).Olm) return (window as any).Olm;
@ -64,6 +64,19 @@ export default async function makeKeysOnSignUp(
const pickledAccount = account.pickle(localPassword); const pickledAccount = account.pickle(localPassword);
// Store password in sessionStorage for unpickling later
sessionStorage.setItem(`olm_password_${odId}`, localPassword);
// Cache the account in window
if (!(window as any).olmAccountCache) {
(window as any).olmAccountCache = {};
}
(window as any).olmAccountCache[odId] = account;
// Set the OLM session into the window object
(window as any).olmSession = new Olm.Session();
// Store the olm account on DB
await db.olmAccounts.put({ await db.olmAccounts.put({
odId, odId,
pickledAccount, pickledAccount,

View file

@ -5,16 +5,17 @@ import OlmSetupDialog from "@/components/olm/olm-setup-dialog";
import { MainContentLayout } from "@/components/ui/layout"; import { MainContentLayout } from "@/components/ui/layout";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import UserFloatingCard from "@/components/ui/user/floating-card"; import UserFloatingCard from "@/components/ui/user/floating-card";
import { useOlmSetup } from "@/hooks/use-olm-setup"; import { OlmProvider, useOlmContext } from "@/contexts/olm-context";
import { useSocket } from "@/hooks/use-socket"; import { SocketProvider, useSocketContext } from "@/contexts/socket-context";
import { authClient } from "@/lib/auth/client"; import { authClient } from "@/lib/auth/client";
import { getRandomPhrase, type PhrasePreference } from "@/lib/constants/phrases"; import { getRandomPhrase, type PhrasePreference } from "@/lib/constants/phrases";
import { useMutation, useQuery } from "convex/react"; import { useMutation, useQuery } from "convex/react";
import { redirect, useParams, usePathname } from "next/navigation"; import { redirect, useParams, usePathname } from "next/navigation";
import { useCallback, useEffect, useMemo } from "react"; 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";
export default function AppContainer() { function AppContainerContent() {
const pathname = usePathname(); const pathname = usePathname();
const params = useParams(); const params = useParams();
@ -36,29 +37,15 @@ export default function AppContainer() {
} }
return { type: 'home' as const }; return { type: 'home' as const };
}, [pathname, params]); }, [pathname, params]);
const { data, error, isPending, refetch } = authClient.useSession();
const hasServerOlm = useQuery( const { data } = authClient.useSession();
api.auth.retrieveServerOlmAccount,
data?.user?.id ? { userId: data.user.id } : "skip"
);
const userStatus = useQuery(api.auth.getUserStatus); // Use socket context instead of hook
const { socketStatus, socketInfo, disconnect, connect } = useSocket({ const { socketStatus, socketInfo, disconnect, connect } = useSocketContext();
user: {
id: data?.user?.id, // Use OLM context
status: userStatus ? { const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmContext();
status: userStatus.status,
isUserSet: userStatus.isUserSet,
} : {
status: "offline" as const,
isUserSet: false,
},
},
refetchUser: refetch
});
const sendKeysToServer = useMutation(api.auth.sendKeysToServer);
const updateUserMetadata = useMutation(api.auth.updateUserMetadata); const updateUserMetadata = useMutation(api.auth.updateUserMetadata);
useEffect(() => { useEffect(() => {
@ -70,29 +57,11 @@ export default function AppContainer() {
} }
}, [data, updateUserMetadata]); }, [data, updateUserMetadata]);
const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmSetup({
userId: data?.user?.id,
hasServerOlm,
sendKeysToServer
});
const getPhrase = useCallback(() => { const getPhrase = useCallback(() => {
const preference = data?.user?.metadata?.phrasePreference as PhrasePreference | undefined; const preference = data?.user?.metadata?.phrasePreference as PhrasePreference | undefined;
return getRandomPhrase(preference); return getRandomPhrase(preference);
}, [data?.user?.metadata?.phrasePreference]); }, [data?.user?.metadata?.phrasePreference]);
if (isPending) {
return (
<div className="flex items-center justify-center h-screen w-full bg-background">
<Spinner className="size-10 animate-spin" />
</div>
);
}
if (error || !data) {
return redirect(`/auth${error ? `?error=${error.cause}` : "?error=no-data"}`);
}
if (["connecting", "error", "disconnected"].includes(socketStatus)) { if (["connecting", "error", "disconnected"].includes(socketStatus)) {
return ( return (
<div className="flex items-center justify-center h-screen w-full bg-background"> <div className="flex items-center justify-center h-screen w-full bg-background">
@ -101,6 +70,10 @@ export default function AppContainer() {
); );
} }
if (!data?.user) {
return null;
}
return ( return (
<> <>
<UserFloatingCard user={data.user} /> <UserFloatingCard user={data.user} />
@ -121,6 +94,7 @@ export default function AppContainer() {
/> />
</AppSidebar> </AppSidebar>
<OlmPasswordDialog userId={data.user.id} />
<OlmSetupDialog <OlmSetupDialog
open={showOlmModal} open={showOlmModal}
onOpenChange={setShowOlmModal} onOpenChange={setShowOlmModal}
@ -131,3 +105,52 @@ export default function AppContainer() {
); );
} }
export default function AppContainer() {
const { data, error, isPending, refetch } = authClient.useSession();
const userStatus = useQuery(api.auth.getUserStatus);
const hasServerOlm = useQuery(
api.auth.retrieveServerOlmAccount,
data?.user?.id ? { userId: data.user.id } : "skip"
);
const sendKeysToServer = useMutation(api.auth.sendKeysToServer);
const consumeOTK = useMutation(api.auth.consumeOTK);
if (isPending) {
return (
<div className="flex items-center justify-center h-screen w-full bg-background">
<Spinner className="size-10 animate-spin" />
</div>
);
}
if (error || !data) {
return redirect(`/auth${error ? `?error=${error.cause}` : "?error=no-data"}`);
}
return (
<OlmProvider
userId={data?.user?.id}
hasServerOlm={hasServerOlm}
sendKeysToServer={sendKeysToServer}
consumeOTK={consumeOTK}
>
<SocketProvider
user={{
id: data?.user?.id,
status: userStatus ? {
status: userStatus.status,
isUserSet: userStatus.isUserSet,
} : {
status: "offline" as const,
isUserSet: false,
},
}}
refetchUser={refetch}
>
<AppContainerContent />
</SocketProvider>
</OlmProvider>
);
}

View file

@ -83,7 +83,7 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
</SidebarContent> </SidebarContent>
</Sidebar> </Sidebar>
<div className="flex flex-col flex-1 min-h-screen"> <div className="flex flex-col flex-1 h-svh min-h-0 overflow-hidden">
<header className="flex items-center justify-between md:justify-center gap-2 px-4 py-0.5 md:border-none border-b border-border backdrop-blur sticky top-0 z-10"> <header className="flex items-center justify-between md:justify-center gap-2 px-4 py-0.5 md:border-none border-b border-border backdrop-blur sticky top-0 z-10">
<div className="flex items-center gap-2 md:hidden"> <div className="flex items-center gap-2 md:hidden">
<SidebarTrigger className="size-9" /> <SidebarTrigger className="size-9" />
@ -124,8 +124,8 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
</div> </div>
<div className="w-9 md:hidden" /> {/* Spacer for centering on mobile */} <div className="w-9 md:hidden" /> {/* Spacer for centering on mobile */}
</header> </header>
<SidebarInset className="mr-0 mb-0 border-none flex-1 rounded-l-lg"> <SidebarInset className="mr-0 mb-0 border-none flex-1 min-h-0 overflow-hidden rounded-l-lg">
<div className="w-full h-full bg-background border-border border rounded-l-lg rounded-bl-none overflow-auto"> <div className="w-full h-full bg-background border-border border rounded-l-lg rounded-bl-none overflow-hidden min-h-0">
{children} {children}
</div> </div>
</SidebarInset> </SidebarInset>

View file

@ -0,0 +1,100 @@
import { useOlmContext } from "@/contexts/olm-context";
import { Info, KeyRound, ShieldCheck } from "lucide-react";
import { useEffect, useState } from "react";
import { Button } from "../ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../ui/dialog";
import { Input } from "../ui/input";
export default function OlmPasswordDialog({ userId }: { userId: string }) {
const [needsPassword, setNeedsPassword] = useState(false);
const [password, setPasswordInput] = useState("");
const { setPassword } = useOlmContext();
useEffect(() => {
// Get the password from the session storage
const password = sessionStorage.getItem(`olm_password_${userId}`);
console.log("🔒 Password from session storage:", password);
if (!password) {
setNeedsPassword(true);
return;
}
setPassword(password);
setNeedsPassword(false);
}, [userId]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (password.trim()) {
setPassword(password);
setNeedsPassword(false);
}
};
return (
<Dialog open={needsPassword}>
<DialogContent className="sm:max-w-[440px]" showCloseButton={false} onPointerDownOutside={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}>
<DialogHeader className="space-y-4">
<div className="flex flex-col items-center text-center space-y-3">
<div className="h-14 w-14 rounded-full bg-primary/10 flex items-center justify-center ring-8 ring-primary/5">
<KeyRound className="h-7 w-7 text-primary" />
</div>
<div className="space-y-2">
<DialogTitle className="text-2xl font-semibold tracking-tight">
Encryption Password Required
</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground max-w-sm">
Enter your encryption password to access this conversation. This may be different from your login password.
</DialogDescription>
</div>
</div>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6 pt-2">
<div className="space-y-3">
<Input
type="password"
placeholder="Enter your encryption password"
className="h-11 text-center"
autoFocus
value={password}
onChange={(e) => setPasswordInput(e.target.value)}
/>
<div className="space-y-2">
<div className="flex items-center gap-2 rounded-md bg-emerald-500/10 dark:bg-emerald-400/10 px-3 py-2.5 text-emerald-700 dark:text-emerald-300 border border-emerald-200/20 dark:border-emerald-500/20">
<ShieldCheck className="h-4 w-4 shrink-0" />
<p className="text-xs leading-relaxed">
Your password is stored locally and never sent to our servers.
</p>
</div>
<div className="flex items-center gap-2 rounded-md bg-blue-500/10 dark:bg-blue-400/10 px-3 py-2.5 text-blue-700 dark:text-blue-300 border border-blue-200/20 dark:border-blue-500/20">
<Info className="h-4 w-4 shrink-0" />
<p className="text-xs leading-relaxed">
You'll be asked to re-enter this password each time you start a new browser session.
</p>
</div>
</div>
</div>
<div className="flex gap-3">
<Button
type="button"
variant="outline"
onClick={() => setNeedsPassword(false)}
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!password.trim()}
>
Continue
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View file

@ -0,0 +1,355 @@
import { useOlmContext } from "@/contexts/olm-context";
import { useSocketContext } from "@/contexts/socket-context";
import { clearUnread, db, sendMessage } from "@/lib/db";
import { useLiveQuery } from "dexie-react-hooks";
import { KeyRound } from "lucide-react";
import React, { useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../dialog";
import { Input } from "../input";
interface DMChannelContentProps {
userId: string
channelId: string
participantDetails: SiPher.ParticipantDetail[]
}
export default function DMChannelContent(
{
userId,
channelId,
participantDetails,
}: DMChannelContentProps
) {
const otherUser = useMemo(() => participantDetails[0], [participantDetails]);
const [olmSession, setOlmSession] = useState<Olm.Session | null>(null);
const [sessionError, setSessionError] = useState<string | null>(null);
const [messageInput, setMessageInput] = useState("");
const [messageLimit, setMessageLimit] = useState(50);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const { sendMessage: sendMessageToServer } = useSocketContext();
const { olmAccount, password, isReady, getSession } = useOlmContext();
const messagesEndRef = React.useRef<HTMLDivElement>(null);
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const prevScrollHeightRef = React.useRef<number>(0);
// Get total message count
const totalMessageCount = useLiveQuery(
() => db.messages.where("channelId").equals(channelId).count(),
[channelId]
) ?? 0;
// Get messages from the local database with pagination
const allMessages = useLiveQuery(
() => db.messages.where("channelId").equals(channelId).sortBy("timestamp"),
[channelId]
) ?? [];
// Take only the most recent messages based on limit
const messages = useMemo(() => {
return allMessages.slice(-messageLimit);
}, [allMessages, messageLimit]);
const hasMoreMessages = messages.length < totalMessageCount;
// Reset message limit when channel changes
useEffect(() => {
setMessageLimit(50);
}, [channelId]);
// Handle scroll to load more messages
const handleScroll = React.useCallback(async (e: React.UIEvent<HTMLDivElement>) => {
const target = e.currentTarget;
const scrollTop = target.scrollTop;
// If scrolled near the top (within 100px) and there are more messages
if (scrollTop < 100 && hasMoreMessages && !isLoadingMore) {
setIsLoadingMore(true);
// Save current scroll height
prevScrollHeightRef.current = target.scrollHeight;
// Load 50 more messages
await new Promise(resolve => setTimeout(resolve, 100)); // Small delay to avoid rapid firing
setMessageLimit(prev => prev + 50);
setIsLoadingMore(false);
}
}, [hasMoreMessages, isLoadingMore]);
// Preserve scroll position after loading more messages
useEffect(() => {
if (prevScrollHeightRef.current > 0 && scrollContainerRef.current) {
const newScrollHeight = scrollContainerRef.current.scrollHeight;
const scrollDiff = newScrollHeight - prevScrollHeightRef.current;
scrollContainerRef.current.scrollTop += scrollDiff;
prevScrollHeightRef.current = 0;
}
}, [messages.length]);
// Scroll to bottom on initial load / channel change (instant)
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "auto" });
}, [channelId]);
// Auto-scroll to bottom when new messages arrive (smooth)
useEffect(() => {
if (messages.length > 0 && scrollContainerRef.current) {
const container = scrollContainerRef.current;
const isNearBottom = container.scrollHeight - container.scrollTop - container.clientHeight < 200;
// Only auto-scroll if user is near the bottom
if (isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}
}, [allMessages.length]);
// Clear unread count when entering the channel
useEffect(() => {
clearUnread(channelId);
console.debug("[DMChannelContent] Cleared unread count for channel", channelId);
}, [channelId]);
// Guard: Check if otherUser exists
if (!otherUser) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-muted-foreground">Loading participant information...</p>
</div>
</div>
);
}
// Get or create session when OLM is ready and we have the other user's account
useEffect(() => {
const loadSession = async () => {
if (!isReady || !olmAccount || !otherUser || !otherUser.olmAccount) {
return;
}
setSessionError(null);
try {
const session = await getSession(otherUser.id, {
identityKey: otherUser.olmAccount.identityKey,
oneTimeKeys: otherUser.olmAccount.oneTimeKeys,
});
if (session) {
setOlmSession(session);
} else {
setSessionError("Failed to create encryption session");
}
} catch (err) {
console.error("[DMChannelContent] Failed to get session:", err);
setSessionError(err instanceof Error ? err.message : "Unknown error");
}
};
loadSession();
}, [isReady, olmAccount, otherUser, password, getSession])
// Check if OLM is ready
if (!isReady || !olmAccount) {
return <div>Loading encryption keys...</div>
}
// Get the other user's id key and OT keys from the server to be prepared for messaging
if (!otherUser.olmAccount) {
return (
<Dialog open={true} onOpenChange={() => { }}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-accent/20">
<KeyRound className="h-8 w-8 text-accent-foreground" />
</div>
<DialogTitle className="text-2xl text-center">Encryption Setup Required</DialogTitle>
</DialogHeader>
<DialogDescription className="space-y-4 pt-2">
<div className="rounded-lg bg-card border border-border p-4">
<p className="text-sm text-card-foreground/90 leading-relaxed">
<span className="font-semibold text-card-foreground">{otherUser.name}</span> hasn't set up end-to-end encryption yet.
</p>
</div>
<div className="space-y-2 text-sm text-muted-foreground">
<p className="flex items-start gap-2">
<span className="text-accent-foreground/60 mt-0.5"></span>
<span>They need to log in and complete the encryption setup</span>
</p>
<p className="flex items-start gap-2">
<span className="text-accent-foreground/60 mt-0.5"></span>
<span>Once complete, you'll be able to send encrypted messages</span>
</p>
</div>
<p className="text-xs text-center text-muted-foreground/70 pt-2">
🔒 All messages are end-to-end encrypted for your privacy
</p>
</DialogDescription>
</DialogContent>
</Dialog>
)
}
// Show error if session creation failed
if (sessionError) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<p className="text-destructive">Failed to create encryption session</p>
<p className="text-sm text-muted-foreground">{sessionError}</p>
</div>
</div>
);
}
// Wait for session to be established
if (!olmSession) {
return (
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-2">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="text-sm text-muted-foreground">Establishing secure connection...</p>
</div>
</div>
);
}
return (
<div className="flex flex-col flex-1 min-h-0">
<div className="flex-1 min-h-0 overflow-hidden">
<div
ref={scrollContainerRef}
className="h-full overflow-y-auto"
onScroll={handleScroll}
>
<div className="pt-4">
{/* Load more indicator */}
{hasMoreMessages && (
<div className="flex justify-center py-4">
{isLoadingMore ? (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-4 h-4 border-2 border-muted-foreground/30 border-t-muted-foreground rounded-full animate-spin" />
<span className="text-xs">Loading older messages...</span>
</div>
) : (
<button
onClick={() => {
setIsLoadingMore(true);
prevScrollHeightRef.current = scrollContainerRef.current?.scrollHeight ?? 0;
setTimeout(() => {
setMessageLimit(prev => prev + 50);
setIsLoadingMore(false);
}, 100);
}}
className="text-xs text-muted-foreground hover:text-foreground transition-colors px-3 py-1 rounded-md hover:bg-muted/50"
>
Load more messages
</button>
)}
</div>
)}
{messages.map((msg, index) => {
const sender = participantDetails.find((p) => p.id === msg.fromUserId);
const selfDetail = participantDetails.find((p) => p.id === userId);
const isSelf = msg.fromUserId === userId;
const displayName = isSelf ? selfDetail?.displayUsername ?? selfDetail?.username ?? selfDetail?.name ?? "You" : (sender?.displayUsername ?? sender?.username ?? sender?.name ?? "Unknown");
const timeLabel = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
// 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 isGrouped = prevMsg &&
prevMsg.fromUserId === msg.fromUserId &&
msg.timestamp && prevMsg.timestamp &&
(msg.timestamp - prevMsg.timestamp) < 5 * 60 * 1000;
return (
<div
key={msg.id}
className="group relative px-4 py-0.5 hover:bg-muted/50 transition-colors duration-100"
>
{!isGrouped ? (
// Full message with avatar and header
<div className="flex gap-4 mt-[17px]">
<Avatar className="w-10 h-10 shrink-0 mt-0.5">
<AvatarImage src={sender?.image ?? undefined} alt={displayName} />
<AvatarFallback className="text-xs">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0 pt-0.5">
<div className="flex items-baseline gap-2 leading-snug">
<span className="font-semibold text-[15px] text-foreground hover:underline cursor-pointer">
{displayName}
</span>
<span className="text-[11px] text-muted-foreground font-medium">
{timeLabel}
</span>
</div>
<div className="text-[15px] leading-[1.375rem] text-foreground mt-0.5 wrap-break-word">
{msg.content}
</div>
</div>
</div>
) : (
// Compact message without avatar (grouped)
<div className="flex gap-4 leading-[1.375rem]">
<div className="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">
{new Date(msg.timestamp).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
<div className="flex-1 min-w-0 text-[15px] leading-[1.375rem] text-foreground wrap-break-word">
{msg.content}
</div>
</div>
)}
</div>
);
})}
{/* Invisible element for auto-scrolling */}
<div ref={messagesEndRef} />
</div>
</div>
</div>
{/* Message input */}
<div className="shrink-0 px-4 pb-6 pt-2">
<Input
className="h-11 rounded-lg bg-muted border-0 focus-visible:ring-0 focus-visible:ring-offset-0 px-4 text-[15px]"
placeholder={`Message @${otherUser.username ?? otherUser.name}`}
value={messageInput}
onChange={(e) => setMessageInput(e.target.value)}
onKeyDown={async (e) => {
if (e.key === 'Enter' && !e.shiftKey && messageInput.trim() && password) {
e.preventDefault();
try {
const messageId = await sendMessage({
channelId,
content: messageInput,
fromUserId: userId,
to: otherUser.id,
timestamp: Date.now(),
status: "sent",
}, olmSession, sendMessageToServer, {
userId,
recipientId: otherUser.id,
password,
});
if (messageId) {
setMessageInput("");
}
} catch (error) {
console.error("[DMChannelContent] Failed to send message:", error);
toast.error("Failed to send message: " + (error instanceof Error ? error.message : "Unknown error"));
}
}
}}
/>
</div>
</div>
);
}

View file

@ -1,10 +1,10 @@
"use client" "use client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { getOrCreateDmChannel } from "@/lib/db" import { getOrCreateDmChannel } from "@/lib/db"
import { MessageCircleIcon } from "lucide-react" import { MessageCircleIcon } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import UserCard from "../user/user-card"
import { FriendActionsMenu } from "./friend-actions-menu" import { FriendActionsMenu } from "./friend-actions-menu"
export interface FriendData { export interface FriendData {
@ -45,12 +45,6 @@ export function FriendListItem({
const router = useRouter() const router = useRouter()
const displayName = friend.displayUsername || friend.username || friend.name const displayName = friend.displayUsername || friend.username || friend.name
const status = friend.status?.status || "offline" const status = friend.status?.status || "offline"
const statusColor = {
online: "bg-green-500",
idle: "bg-yellow-500",
dnd: "bg-red-500",
offline: "bg-gray-500"
}[status as "online" | "idle" | "dnd" | "offline"]
return ( return (
<div <div
@ -66,18 +60,12 @@ export function FriendListItem({
> >
{/* Left side: Avatar + Info */} {/* Left side: Avatar + Info */}
<div className="flex flex-row items-center gap-3 flex-1 min-w-0"> <div className="flex flex-row items-center gap-3 flex-1 min-w-0">
<div className="relative shrink-0"> <UserCard
<Avatar className="size-10"> userName={displayName ?? ""}
<AvatarImage src={friend.image || undefined} /> image={friend.image ?? undefined}
<AvatarFallback className="text-sm font-medium"> status={status}
{displayName?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div
className={`absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full border-[2.5px] border-background ${statusColor}`}
title={status}
/> />
</div>
<div className="flex flex-col justify-center items-start overflow-hidden flex-1 min-w-0"> <div className="flex flex-col justify-center items-start overflow-hidden flex-1 min-w-0">
<span className="text-sm font-semibold truncate w-full text-foreground"> <span className="text-sm font-semibold truncate w-full text-foreground">
{displayName} {displayName}

View file

@ -1,12 +1,12 @@
"use client" "use client"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { db } from "@/lib/db" import { clearUnread, db } from "@/lib/db"
import { cn } from "@/lib/utils"
import { formatDistanceToNow } from "date-fns" import { formatDistanceToNow } from "date-fns"
import { useLiveQuery } from "dexie-react-hooks"
import { PlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react" import { PlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import UserCard from "../user/user-card"
export interface ChannelListProps { export interface ChannelListProps {
currentChannel: SiPher.Channel | null currentChannel: SiPher.Channel | null
@ -37,6 +37,11 @@ export function ChannelList({
}: ChannelListProps) { }: ChannelListProps) {
const router = useRouter() const router = useRouter()
const unreadCount = useLiveQuery(
() => db.unreadCounts.toArray(),
[]
)
return ( return (
<div className="flex flex-col shrink-0 max-w-72 min-w-72 border-r border-border/40"> <div className="flex flex-col shrink-0 max-w-72 min-w-72 border-r border-border/40">
{/* Channel List Header */} {/* Channel List Header */}
@ -87,6 +92,7 @@ export function ChannelList({
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
const channelUnreadCount = unreadCount?.find((unread) => unread.channelId === channel.id)?.count ?? 0
if (!channel.isOpen) return null; if (!channel.isOpen) return null;
return ( return (
@ -96,22 +102,23 @@ export function ChannelList({
? "bg-accent/60" ? "bg-accent/60"
: "hover:bg-accent/40" : "hover:bg-accent/40"
}`} }`}
onClick={() => router.push(`/channels/me/${channel.id}`)} onClick={() => {
clearUnread(channel.id)
console.log("Cleared unread count for channel", channel.id)
router.push(`/channels/me/${channel.id}`)
}}
> >
{/* Avatar */}
<div className="relative shrink-0"> <div className="relative shrink-0">
<Avatar className="size-8 ring-2 ring-border"> <UserCard
<AvatarImage src={channel.metadata?.icon ?? undefined} alt={channel.name} /> userName={channel.name}
<AvatarFallback className="bg-primary/20 text-primary-foreground font-semibold"> image={channel.metadata?.icon ?? undefined}
{channel.name?.charAt(0).toUpperCase()} status={"none"}
</AvatarFallback>
</Avatar>
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full border-[2.5px] border-secondary",
channel.metadata?.icon ? "bg-muted-foreground" : "bg-muted-foreground"
)}
/> />
{channelUnreadCount > 0 && (
<span className="absolute -top-1 -right-1 flex items-center justify-center min-w-[18px] h-[18px] px-1.5 rounded-full bg-red-500 text-[10px] font-bold text-white shadow-sm">
{channelUnreadCount > 99 ? '99+' : channelUnreadCount}
</span>
)}
</div> </div>
{/* Channel Info */} {/* Channel Info */}

View file

@ -7,7 +7,9 @@ import { useLiveQuery } from "dexie-react-hooks"
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 DMChannelContent from "../dm/DmChannelContent"
import { FriendsPage } from "../friends/friends-page" import { FriendsPage } from "../friends/friends-page"
import { Spinner } from "../spinner"
import { ChannelList } from "./channel-list" import { ChannelList } from "./channel-list"
import { PageHeader } from "./page-header" import { PageHeader } from "./page-header"
import { SettingsPage } from "./settings-page" import { SettingsPage } from "./settings-page"
@ -44,12 +46,13 @@ export function MainContentLayout({
[userId] [userId]
) ?? [] ) ?? []
const getParticipantDetails = useQuery(api.auth.getParticipantDetails, dmChannelId ? { const participantIds = openDmChannels
participantIds: openDmChannels
.find((channel) => channel.id === dmChannelId) .find((channel) => channel.id === dmChannelId)
?.participants ?.participants ?? []
.filter((participant) => participant !== userId) ?? []
} : "skip") const getParticipantDetails: SiPher.ParticipantDetail[] | undefined = useQuery(api.auth.getParticipantDetails,
{ participantIds }
)
// Combine channel from local DB with participant details from Convex // Combine channel from local DB with participant details from Convex
const dmChannel = useMemo(() => { const dmChannel = useMemo(() => {
@ -60,7 +63,14 @@ export function MainContentLayout({
return { return {
id: channel.id, id: channel.id,
participantDetails: getParticipantDetails ?? [] participantDetails: getParticipantDetails.map((participant) => ({
id: participant.id as string,
name: participant.name,
username: participant.username ?? "",
displayUsername: participant.displayUsername ?? "",
image: participant.image ?? "",
status: participant.status,
}))
} }
}, [openDmChannels, dmChannelId, getParticipantDetails]) }, [openDmChannels, dmChannelId, getParticipantDetails])
@ -104,12 +114,21 @@ 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" ? ( {page === "dm" && dmChannelId ? (
<div className="flex flex-col h-full p-4"> getParticipantDetails ? (
<p className="text-sm text-muted-foreground">DM chat with {dmChannelId}</p> <div className="flex flex-1 min-h-0">
<DMChannelContent userId={userId} channelId={dmChannelId!} participantDetails={getParticipantDetails} />
</div> </div>
) : page === "server" ? ( ) : (
<div className="flex flex-col h-full p-4"> <div className="flex flex-1 min-h-0">
<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>
)
) : page === "server" && serverChannelId ? (
<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" ? (
@ -133,4 +152,3 @@ export function MainContentLayout({
</> </>
) )
} }

View file

@ -1,9 +1,8 @@
"use client" "use client"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import { PhoneIcon, SearchIcon, UserIcon, UsersIcon, VideoIcon } from "lucide-react" import { PhoneIcon, SearchIcon, UserIcon, UsersIcon, VideoIcon } from "lucide-react"
import { Avatar, AvatarFallback, AvatarImage } from "../avatar" import UserCard from "../user/user-card"
export interface PageHeaderProps { export interface PageHeaderProps {
currentChannel: SiPher.Channel | null currentChannel: SiPher.Channel | null
@ -26,13 +25,6 @@ export interface PageHeaderProps {
serverChannelId?: string serverChannelId?: string
} }
const statusColors: Record<"online" | "busy" | "offline" | "away", string> = {
online: "bg-emerald-500",
busy: "bg-red-500",
away: "bg-yellow-500",
offline: "bg-muted-foreground"
};
export function PageHeader({ export function PageHeader({
currentChannel, currentChannel,
page, page,
@ -63,20 +55,12 @@ export function PageHeader({
{/* Page title/options */} {/* Page title/options */}
{dmChannel ? ( {dmChannel ? (
<div className="flex flex-row justify-start items-center gap-2 w-full px-4"> <div className="flex flex-row justify-start items-center gap-2 w-full px-4">
<div className="relative shrink-0"> <UserCard
<Avatar className="size-4 ring-2 ring-border"> userName={dmChannel.participantDetails[0].name}
<AvatarImage src={dmChannel.participantDetails[0].image ?? undefined} alt={dmChannel.participantDetails[0].name} /> image={dmChannel.participantDetails[0].image}
<AvatarFallback className="bg-primary/20 text-primary-foreground font-semibold"> status={dmChannel.participantDetails[0].status}
{dmChannel.participantDetails[0].name?.charAt(0).toUpperCase()} size="small"
</AvatarFallback>
</Avatar>
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-secondary",
dmChannel.participantDetails[0].status ? statusColors[dmChannel.participantDetails[0].status as "online" | "busy" | "offline" | "away"] : "bg-muted-foreground"
)}
/> />
</div>
<span className="text-sm font-medium">{dmChannel.participantDetails[0].name}</span> <span className="text-sm font-medium">{dmChannel.participantDetails[0].name}</span>
<div className="flex flex-row gap-2 ml-auto"> <div className="flex flex-row gap-2 ml-auto">
<Button <Button

View file

@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
export default function UserCard({ userName, image, status, size = "medium" }: { userName: string, image: string | undefined, status: "online" | "busy" | "offline" | "away" | "none", size?: "small" | "medium" | "large" }) {
const statusColors: Record<"online" | "busy" | "offline" | "away", string> = {
online: "bg-emerald-500",
busy: "bg-red-500",
away: "bg-yellow-500",
offline: "bg-muted-foreground",
};
const sizes: Record<"small" | "medium" | "large", string> = {
small: "size-4",
medium: "size-8",
large: "size-9",
};
return (
<div className="relative shrink-0">
<Avatar className={cn("ring-2 ring-border", sizes[size])}>
<AvatarImage src={image ?? undefined} alt={userName} />
<AvatarFallback className="bg-primary/20 text-primary-foreground font-semibold">
{userName?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
{
status !== "none" && (
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-secondary",
status ? statusColors[status] : "bg-muted-foreground"
)}
/>
)
}
</div>
)
}

View file

@ -0,0 +1,381 @@
"use client"
import { loadOlm } from "@/app/auth/scripts/makeKeys";
import { db } from "@/lib/db";
import { checkOlmStatus, getOlmAccount, handleOlmAccountCreation, SendKeysToServerFn } from "@/lib/olm";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
// ============================================
// Types
// ============================================
interface OlmContextValue {
// Account state
olmAccount: Olm.Account | null;
olmStatus: SiPher.OlmStatus;
isReady: boolean;
// Session management
getSession: (recipientId: string, recipientOlmAccount: {
identityKey: { curve25519: string; ed25519: string };
oneTimeKeys: Array<{ keyId: string; publicKey: string }>;
}) => Promise<Olm.Session | null>;
createInboundSession: (senderId: string, preKeyMessage: string) => Promise<Olm.Session | null>;
sessions: Map<string, Olm.Session>;
// Password & setup
password: string | null;
showOlmModal: boolean;
setShowOlmModal: (show: boolean) => void;
handleCreateAccount: (password: string) => Promise<void>;
setPassword: (password: string) => void;
}
const OlmContext = createContext<OlmContextValue | null>(null);
// ============================================
// Provider
// ============================================
interface OlmProviderProps {
children: React.ReactNode;
userId: string | undefined;
hasServerOlm: boolean | undefined;
sendKeysToServer: SendKeysToServerFn;
consumeOTK: (args: { userId: string; keyId: string }) => Promise<void>;
}
export function OlmProvider({
children,
userId,
hasServerOlm,
sendKeysToServer,
consumeOTK
}: OlmProviderProps) {
const [olmAccount, setOlmAccount] = useState<Olm.Account | null>(null);
const [olmStatus, setOlmStatus] = useState<SiPher.OlmStatus>("checking");
const [password, setPasswordState] = useState<string | null>(null);
const [showOlmModal, setShowOlmModal] = useState(false);
// Cache sessions in memory: recipientId -> Session
const sessionsRef = useRef<Map<string, Olm.Session>>(new Map());
const [, forceUpdate] = useState({});
// Helper: Cache session in memory
const cacheSession = useCallback((recipientId: string, session: Olm.Session) => {
sessionsRef.current.set(recipientId, session);
forceUpdate({});
}, []);
// Helper: Save session to database
const saveSessionToDb = useCallback(async (
recipientId: string,
session: Olm.Session,
sessionPassword: string
) => {
if (!userId) return;
await db.olmSessions.put({
odId: userId,
recipientId,
pickledSession: session.pickle(sessionPassword),
createdAt: Date.now(),
updatedAt: Date.now(),
});
console.debug("[OlmContext] ✓ Session saved to DB");
}, [userId]);
// Helper: Unpickle session from database
const unpickleSessionFromDb = useCallback(async (
recipientId: string,
pickledSession: string,
sessionPassword: string
): Promise<Olm.Session | null> => {
try {
const Olm = await loadOlm();
const session = new Olm.Session();
session.unpickle(sessionPassword, pickledSession);
console.debug("[OlmContext] ✓ Session unpickled from DB");
return session;
} catch (err) {
console.warn("[OlmContext] Failed to unpickle session:", err);
// Delete corrupted session
if (userId) {
await db.olmSessions
.where("[odId+recipientId]")
.equals([userId, recipientId])
.delete();
}
return null;
}
}, [userId]);
// Helper: Validate required fields for session operations
const validateSessionRequirements = useCallback((): boolean => {
const requirements = [
{ value: userId, name: 'userId' },
{ value: olmAccount, name: 'olmAccount' },
{ value: password, name: 'password' }
];
const missing = requirements.find(req => !req.value);
if (missing) {
console.error(`[OlmContext] Cannot perform session operation: missing ${missing.name}`);
return false;
}
return true;
}, [userId, olmAccount, password]);
// Helper: Get sessionStorage key for password
const getPasswordStorageKey = useCallback((uid: string) => {
return `olm_password_${uid}`;
}, []);
// Helper: Clear password from state and storage
const clearPassword = useCallback(() => {
if (!userId) return;
setPasswordState(null);
sessionStorage.removeItem(getPasswordStorageKey(userId));
}, [userId, getPasswordStorageKey]);
// Load password from sessionStorage on mount
useEffect(() => {
if (!userId) return;
const stored = sessionStorage.getItem(getPasswordStorageKey(userId));
if (stored) {
setPasswordState(stored);
}
}, [userId, getPasswordStorageKey]);
// Check OLM status when user data and server status are available
useEffect(() => {
if (!userId || hasServerOlm === undefined) return;
const checkStatus = async () => {
const status = await checkOlmStatus(userId, hasServerOlm);
setOlmStatus(status);
if (status === "not_setup" || status === "mismatched") {
setShowOlmModal(true);
}
};
checkStatus();
}, [userId, hasServerOlm]);
// Load and unpickle the OLM account when password is available
useEffect(() => {
if (!userId || !password) return;
const loadAccount = async () => {
try {
console.debug("[OlmContext] Loading OLM account...");
const account = await getOlmAccount(userId, password);
if (!account) {
console.warn("[OlmContext] No OLM account found");
return;
}
setOlmAccount(account);
console.debug("[OlmContext] ✓ OLM account loaded successfully");
} catch (err) {
console.error("[OlmContext] Failed to load OLM account:", err);
// Password might be wrong - clear it
clearPassword();
}
};
loadAccount();
}, [userId, password, clearPassword]);
// Set password and store in sessionStorage
const setPassword = useCallback((newPassword: string) => {
if (!userId) return;
sessionStorage.setItem(getPasswordStorageKey(userId), newPassword);
setPasswordState(newPassword);
}, [userId, getPasswordStorageKey]);
// Handle OLM account creation
const handleCreateAccount = useCallback(async (accountPassword: string): Promise<void> => {
if (!userId || !accountPassword.trim()) return;
setOlmStatus("creating");
const success = await handleOlmAccountCreation(
userId,
accountPassword,
sendKeysToServer,
olmStatus === "mismatched"
);
if (success) {
setOlmStatus("synced");
setShowOlmModal(false);
setPassword(accountPassword);
} else {
setOlmStatus("not_setup");
}
}, [userId, olmStatus, sendKeysToServer, setPassword]);
// Get or create an OUTBOUND session with another user (for sending messages)
const getSession = useCallback(async (
recipientId: string,
recipientOlmAccount: {
identityKey: { curve25519: string; ed25519: string };
oneTimeKeys: Array<{ keyId: string; publicKey: string }>;
}
): Promise<Olm.Session | null> => {
if (!validateSessionRequirements()) {
return null;
}
// Check if we already have this session in memory
if (sessionsRef.current.has(recipientId)) {
console.debug(`[OlmContext] Using cached session for ${recipientId}`);
return sessionsRef.current.get(recipientId)!;
}
try {
console.debug(`[OlmContext] Loading/creating session for user ${recipientId}`);
// Check if session exists in DB
const existingSession = await db.olmSessions
.where("[odId+recipientId]")
.equals([userId!, recipientId])
.first();
if (existingSession) {
console.debug("[OlmContext] Found existing session in DB, unpickling...");
const session = await unpickleSessionFromDb(recipientId, existingSession.pickledSession, password!);
if (session) {
cacheSession(recipientId, session);
console.debug("[OlmContext] ✓ Session loaded from DB");
return session;
}
// If unpickling failed, continue to create new session
}
// Create new outbound session
console.debug("[OlmContext] Creating new outbound session...");
if (recipientOlmAccount.oneTimeKeys.length === 0) {
throw new Error("No one-time keys available for recipient");
}
const otk = recipientOlmAccount.oneTimeKeys[0];
const Olm = await loadOlm();
const newSession = new Olm.Session();
newSession.create_outbound(
olmAccount!,
recipientOlmAccount.identityKey.curve25519,
otk.publicKey
);
console.debug(`[OlmContext] ✓ Created session: ${newSession.session_id()}`);
// Save to DB
await saveSessionToDb(recipientId, newSession, password!);
// Consume the OTK from server
try {
await consumeOTK({
userId: recipientId,
keyId: otk.keyId,
});
console.debug(`[OlmContext] ✓ Consumed OTK: ${otk.keyId}`);
} catch (err) {
console.error("[OlmContext] Failed to consume OTK:", err);
}
// Cache it
cacheSession(recipientId, newSession);
return newSession;
} catch (err) {
console.error("[OlmContext] Failed to get/create session:", err);
return null;
}
}, [userId, olmAccount, password, consumeOTK, validateSessionRequirements, unpickleSessionFromDb, cacheSession, saveSessionToDb]);
// Create an INBOUND session from a received pre-key message
const createInboundSession = useCallback(async (
senderId: string,
preKeyMessage: string
): Promise<Olm.Session | null> => {
if (!validateSessionRequirements()) {
return null;
}
// Check if we already have a session with this sender
if (sessionsRef.current.has(senderId)) {
console.debug(`[OlmContext] Session already exists for ${senderId}`);
return sessionsRef.current.get(senderId)!;
}
try {
console.debug(`[OlmContext] Creating inbound session from sender ${senderId}`);
const Olm = await loadOlm();
const newSession = new Olm.Session();
// Create inbound session from the pre-key message
newSession.create_inbound(olmAccount!, preKeyMessage);
// Remove the one-time key that was used
olmAccount!.remove_one_time_keys(newSession);
console.debug(`[OlmContext] ✓ Created inbound session: ${newSession.session_id()}`);
// Save to DB
await saveSessionToDb(senderId, newSession, password!);
// Cache it
cacheSession(senderId, newSession);
return newSession;
} catch (err) {
console.error("[OlmContext] Failed to create inbound session:", err);
return null;
}
}, [validateSessionRequirements, olmAccount, password, saveSessionToDb, cacheSession]);
const isReady = useMemo(() => {
return olmAccount !== null && olmStatus === "synced";
}, [olmAccount, olmStatus]);
const contextValue: OlmContextValue = {
olmAccount,
olmStatus,
isReady,
getSession,
createInboundSession,
sessions: sessionsRef.current,
password,
showOlmModal,
setShowOlmModal,
handleCreateAccount,
setPassword,
};
return (
<OlmContext.Provider value={contextValue}>
{children}
</OlmContext.Provider>
);
}
// ============================================
// Hook
// ============================================
export function useOlmContext() {
const context = useContext(OlmContext);
if (!context) {
throw new Error("useOlmContext must be used within an OlmProvider");
}
return context;
}

View file

@ -0,0 +1,415 @@
"use client"
import { db, getOrCreateDmChannel, incrementUnread, storeMessage } from "@/lib/db";
import { convex } from "@/lib/providers/Convex";
import { useMutation } from "convex/react";
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { io, Socket } from "socket.io-client";
import { z } from "zod";
import { api } from "../../convex/_generated/api";
import { useOlmContext } from "./olm-context";
interface SocketContextValue {
socketStatus: SiPher.SocketStatus;
socketInfo: SiPher.SocketInfo;
sendMessage: (message: { type: 0 | 1; body: string }, to: string) => void;
disconnect: () => void;
connect: () => void;
socket: Socket | null;
}
const SocketContext = createContext<SocketContextValue | null>(null);
interface SocketProviderProps {
children: React.ReactNode;
user: {
id?: string;
status: {
status: "online" | "busy" | "offline" | "away";
isUserSet: boolean;
}
}
refetchUser: () => void;
}
// Helper: Message validation schema
const MESSAGE_SCHEMA = z.object({
id: z.string(),
channelId: z.string(),
fromUserId: z.string(),
timestamp: z.number(),
status: z.enum(["sent", "delivered", "read"]),
content: z.any(),
to: z.string().optional(),
});
export function SocketProvider({ children, user, refetchUser }: SocketProviderProps) {
const updateUserStatus = useMutation(api.auth.updateUserStatus);
const socketRef = useRef<Socket | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const userStatusRef = useRef(user.status);
const { createInboundSession, sessions, olmAccount, isReady: olmIsReady, password } = useOlmContext();
// Queue for messages received before OLM is ready
const messageQueueRef = useRef<Array<{ content: { type: 0 | 1; body: unknown }, participants: string[] }>>([]);
// Update the ref when status changes, but don't trigger effect
useEffect(() => {
userStatusRef.current = user.status;
}, [user.status]);
const [socketStatus, setSocketStatus] = useState<SiPher.SocketStatus>("connecting");
const [socketInfo, setSocketInfo] = useState<SiPher.SocketInfo>({
ping: null,
transport: null,
connectedAt: null,
socketId: null,
serverUrl: null,
error: null
});
// Helper: Update socket info with partial values
const updateSocketInfo = useCallback((updates: Partial<SiPher.SocketInfo>) => {
setSocketInfo((prev) => ({ ...prev, ...updates }));
}, []);
// Helper: Save session state after decryption
const saveSessionState = useCallback(async (
session: any,
currentUserId: string,
fromUserId: string
) => {
if (!password) return;
await db.olmSessions
.where("[odId+recipientId]")
.equals([currentUserId, fromUserId])
.modify({
pickledSession: session.pickle(password),
updatedAt: Date.now(),
});
console.debug("[Socket] Session state saved after decrypt");
}, [password]);
// Helper: Decrypt, validate, and store message
const decryptAndStoreMessage = useCallback(async (
session: any,
messageType: 0 | 1,
encryptedBody: string,
currentUserId: string,
fromUserId: string
) => {
// Decrypt the message
const decryptedBody = session.decrypt(messageType, encryptedBody);
const message = JSON.parse(decryptedBody);
console.debug("[Socket] Decrypted message:", message);
// Save session state after decryption
await saveSessionState(session, currentUserId, fromUserId);
// Validate with ZOD
const validatedMessage = MESSAGE_SCHEMA.safeParse(message);
if (!validatedMessage.success) {
console.error("[Socket] Invalid message:", validatedMessage.error);
throw new Error("Invalid message format");
}
// Store message and increment unread count
await storeMessage(validatedMessage.data as SiPher.Messages.ClientEncrypted.EncryptedMessage & { to: string });
await incrementUnread(validatedMessage.data.channelId);
console.debug("[Socket] Message stored successfully");
}, [saveSessionState]);
// Manual disconnect function
const disconnect = useCallback(() => {
if (socketRef.current) {
console.log("🔌 Manually disconnecting socket...");
socketRef.current.disconnect();
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
setSocketStatus("manually_disconnected");
}
}, []);
const connect = useCallback(() => {
if (socketRef.current) {
socketRef.current.connect();
refetchUser();
}
}, [refetchUser]);
const sendMessage = useCallback((message: { type: 0 | 1; body: string }, to: string) => {
if (!socketRef.current) {
console.warn("⚠️ Cannot send message: Socket not connected");
return;
}
socketRef.current.emit("dm:send", {
to,
content: message,
});
}, []);
// Define message processor that can be called from both socket handler and queue processor
const processIncomingDM = useCallback(async (data: { content: { type: 0 | 1; body: unknown }, participants: string[] }) => {
// Get the current user id
const currentUserId = user.id;
if (!currentUserId) {
console.error("[Socket] No user ID available");
return;
}
// Extract sender from participants
const fromUserId = data.participants.find((participant) => participant !== currentUserId);
if (!fromUserId) {
console.error("[Socket] Could not determine sender from participants");
return;
}
// Fetch participant details
try {
const participantDetails = await convex.query(api.auth.getParticipantDetails, {
participantIds: [fromUserId]
});
const fromUser = participantDetails?.[0];
if (!fromUser) {
console.error("[Socket] Failed to get from user");
return;
}
const { type, body } = data.content;
switch (type) {
case 0: {
console.debug("[Socket] Received inbound message from pre-key message");
const session = await createInboundSession(fromUserId, body as string);
if (!session) {
console.error("[Socket] Failed to create inbound session");
return;
}
// Now we can create or open the DM channel
await getOrCreateDmChannel(currentUserId, fromUser);
// Decrypt, validate, and store using helper
await decryptAndStoreMessage(session, type, body as string, currentUserId, fromUserId);
break;
}
case 1: {
console.debug("[Socket] Received regular message");
// Get existing session from cache
const session = sessions.get(fromUserId);
if (!session) {
console.error("[Socket] No session found for sender. This shouldn't happen!");
return;
}
// Decrypt, validate, and store using helper
await decryptAndStoreMessage(session, type, body as string, currentUserId, fromUserId);
break;
}
}
} catch (error) {
console.error("[Socket] Error handling incoming DM:", error);
}
}, [user.id, createInboundSession, sessions, decryptAndStoreMessage]);
// Process queued messages when OLM becomes ready
useEffect(() => {
if (!olmAccount || !olmIsReady || messageQueueRef.current.length === 0) return;
console.log(`[Socket] OLM is now ready, processing ${messageQueueRef.current.length} queued messages`);
const processQueue = async () => {
const queue = [...messageQueueRef.current];
messageQueueRef.current = []; // Clear queue
for (const data of queue) {
console.log("[Socket] Processing queued message:", data);
await processIncomingDM(data);
}
};
processQueue();
}, [olmAccount, olmIsReady, processIncomingDM]);
useEffect(() => {
if (!user.id) return;
const socket: Socket = io({
withCredentials: true,
reconnectionAttempts: 3,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000
});
socketRef.current = socket;
// Measure ping latency using acknowledgment callback
const measurePing = () => {
const clientTimestamp = Date.now();
// Use acknowledgment callback for reliable latency measurement
socket.timeout(5000).emit("ping", (err: Error, serverTimestamp: number) => {
if (err) {
console.warn("[Socket] Ping timeout or error:", err);
updateSocketInfo({ ping: null });
return;
}
const now = Date.now();
const latency = now - clientTimestamp;
updateSocketInfo({ ping: latency });
});
};
// Helper: Update user status and refetch
const updateAndRefetchUserStatus = (
status: "online" | "busy" | "offline" | "away",
isUserSet: boolean
) => {
updateUserStatus({ status, isUserSet });
refetchUser();
};
function setUserDefaultStatus(
newStatus: "online" | "busy" | "offline" | "away",
oldStatus?: {
status: "online" | "busy" | "offline" | "away";
isUserSet: boolean;
}
) {
if (!oldStatus) {
console.log("🔌 User default status set to online");
updateAndRefetchUserStatus("online", false);
return;
}
if (newStatus === "offline") {
updateAndRefetchUserStatus(newStatus, oldStatus.isUserSet);
} else if (!oldStatus.isUserSet) {
console.log("🔌 User default status set to online");
updateAndRefetchUserStatus(newStatus, oldStatus.isUserSet);
} else {
updateAndRefetchUserStatus(oldStatus.status, oldStatus.isUserSet);
}
}
socket.on("connect", () => {
console.log("✅ Connected to socket - Authentication successful!");
setSocketStatus("connected");
updateSocketInfo({
connectedAt: Date.now(),
socketId: socket.id || null,
serverUrl: window.location.origin,
transport: socket.io.engine?.transport?.name || "unknown",
error: null
});
setUserDefaultStatus("online", userStatusRef.current);
// Start ping measurement every 5 seconds for latency display
measurePing();
pingIntervalRef.current = setInterval(measurePing, 5000);
});
// Update transport when it upgrades (polling -> websocket)
socket.io.engine?.on("upgrade", (transport) => {
updateSocketInfo({ transport: transport.name });
});
socket.on("connect_error", (err) => {
console.error("❌ Socket connection error:", err.message);
setUserDefaultStatus("offline", userStatusRef.current);
setSocketStatus("error");
updateSocketInfo({
error: err.message,
ping: null,
connectedAt: null,
socketId: null
});
});
socket.on("disconnect", (reason) => {
console.log("🔌 Disconnected from socket:", reason);
setSocketStatus("disconnected");
updateSocketInfo({
ping: null,
connectedAt: null,
error: reason
});
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
});
socket.on("reconnect_attempt", (attempt) => {
console.log("🔌 Reconnect attempt:", attempt);
setSocketStatus("connecting");
updateSocketInfo({
ping: null,
connectedAt: null,
error: null
});
});
socket.on("reconnect_error", (error) => {
console.error("❌ Reconnect error:", error);
setSocketStatus("error");
updateSocketInfo({
ping: null,
connectedAt: null,
error: error.message
});
});
socket.on("dm:new", async (data: { content: { type: 0 | 1; body: unknown }, participants: string[] }) => {
console.log("🔌 New DM received:", data);
// Check if OLM account is loaded
if (!olmAccount) {
console.warn("[Socket] OLM account not loaded yet, queueing message for later processing");
messageQueueRef.current.push(data);
return;
}
// Process immediately if OLM is ready
await processIncomingDM(data);
});
return () => {
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
socket.disconnect();
};
}, [user.id, updateUserStatus, refetchUser, processIncomingDM, olmAccount, updateSocketInfo]);
const contextValue: SocketContextValue = {
socketStatus,
socketInfo,
sendMessage,
disconnect,
connect,
socket: socketRef.current
};
return (
<SocketContext.Provider value={contextValue}>
{children}
</SocketContext.Provider>
);
}
export function useSocketContext() {
const context = useContext(SocketContext);
if (!context) {
throw new Error('useSocketContext must be used within a SocketProvider');
}
return context;
}

View file

@ -1,59 +0,0 @@
"use client"
import { checkOlmStatus, handleOlmAccountCreation, SendKeysToServerFn } from "@/lib/olm";
import { useEffect, useState } from "react";
interface UseOlmSetupOptions {
userId: string | undefined;
hasServerOlm: boolean | undefined;
sendKeysToServer: SendKeysToServerFn;
}
export function useOlmSetup({ userId, hasServerOlm, sendKeysToServer }: UseOlmSetupOptions) {
const [olmStatus, setOlmStatus] = useState<SiPher.OlmStatus>("checking");
const [showOlmModal, setShowOlmModal] = useState(false);
// Check OLM status when user data and server status are available
useEffect(() => {
if (!userId || hasServerOlm === undefined) return;
const checkStatus = async () => {
const status = await checkOlmStatus(userId, hasServerOlm);
setOlmStatus(status);
if (status === "not_setup" || status === "mismatched") {
setShowOlmModal(true);
}
};
checkStatus();
}, [userId, hasServerOlm]);
// Handle OLM account creation
const handleCreateAccount = async (password: string): Promise<void> => {
if (!userId || !password.trim()) return;
setOlmStatus("creating");
const success = await handleOlmAccountCreation(
userId,
password,
sendKeysToServer,
olmStatus === "mismatched"
);
if (success) {
setOlmStatus("synced");
setShowOlmModal(false);
} else {
setOlmStatus("not_setup");
}
};
return {
olmStatus,
showOlmModal,
setShowOlmModal,
handleCreateAccount
};
}

View file

@ -1,3 +1,19 @@
/**
* @deprecated This hook has been replaced with a context-based approach.
*
* Please use the SocketProvider and useSocketContext instead:
*
* @example
* ```tsx
* import { SocketProvider, useSocketContext } from '@/contexts/socket-context';
*
* // In your component:
* const { sendMessage, socketStatus, socketInfo, disconnect, connect } = useSocketContext();
* ```
*
* This file will be removed in a future version.
*/
"use client" "use client"
import { useMutation } from "convex/react"; import { useMutation } from "convex/react";
@ -16,6 +32,7 @@ interface UseSocketProps {
refetchUser: () => void; refetchUser: () => void;
} }
/** @deprecated Use useSocketContext from '@/contexts/socket-context' instead */
export function useSocket({ user, refetchUser }: UseSocketProps) { export function useSocket({ user, refetchUser }: UseSocketProps) {
const updateUserStatus = useMutation(api.auth.updateUserStatus); const updateUserStatus = useMutation(api.auth.updateUserStatus);
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
@ -51,6 +68,15 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
} }
}, [refetchUser]); }, [refetchUser]);
const sendMessage = useCallback((message: { type: 0 | 1; body: string }, to: string) => {
if (!socketRef.current) return;
socketRef.current.emit("dm:send", {
to,
content: JSON.stringify(message),
});
}, [socketRef]);
useEffect(() => { useEffect(() => {
if (!user.id) return; if (!user.id) return;
@ -191,6 +217,6 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
}; };
}, [user.id, updateUserStatus]); }, [user.id, updateUserStatus]);
return { socketStatus, socketInfo, disconnect, connect }; return { socketStatus, socketInfo, disconnect, connect, sendMessage };
} }

View file

@ -22,16 +22,6 @@ export interface OlmSession {
updatedAt: number; updatedAt: number;
} }
/** Message stored locally */
export interface Message {
id: string; // Unique message ID
channelId: string; // Channel this belongs to
fromUserId: string;
content: string; // Decrypted content
timestamp: number;
status: "sent" | "delivered" | "read";
}
/** Unread count per channel */ /** Unread count per channel */
export interface UnreadCount { export interface UnreadCount {
channelId: string; channelId: string;
@ -46,7 +36,7 @@ class SipherDB extends Dexie {
olmAccounts!: EntityTable<OlmAccount, "odId">; olmAccounts!: EntityTable<OlmAccount, "odId">;
olmSessions!: EntityTable<OlmSession, "odId">; olmSessions!: EntityTable<OlmSession, "odId">;
channels!: EntityTable<SiPher.Channel, "id">; channels!: EntityTable<SiPher.Channel, "id">;
messages!: EntityTable<Message, "id">; messages!: EntityTable<SiPher.Messages.ClientEncrypted.EncryptedMessage, "id">;
unreadCounts!: EntityTable<UnreadCount, "channelId">; unreadCounts!: EntityTable<UnreadCount, "channelId">;
constructor() { constructor() {
@ -71,7 +61,7 @@ export const db = new SipherDB();
/** Get or create a DM channel with another user */ /** Get or create a DM channel with another user */
export async function getOrCreateDmChannel( export async function getOrCreateDmChannel(
myUserId: string, myUserId: string,
otherUser: any otherUser: SiPher.ParticipantDetail
): Promise<SiPher.Channel> { ): Promise<SiPher.Channel> {
// Generate deterministic channel ID // Generate deterministic channel ID
const channelId = getDmRoomId(myUserId, otherUser.id); const channelId = getDmRoomId(myUserId, otherUser.id);
@ -109,7 +99,7 @@ export async function getChannelMessages(
channelId: string, channelId: string,
limit = 50, limit = 50,
before?: number before?: number
): Promise<Message[]> { ): Promise<SiPher.Messages.ClientEncrypted.EncryptedMessage[]> {
let query = db.messages.where("channelId").equals(channelId); let query = db.messages.where("channelId").equals(channelId);
if (before) { if (before) {
@ -120,7 +110,12 @@ export async function getChannelMessages(
} }
/** Add a message to local storage */ /** Add a message to local storage */
export async function addMessage(message: Omit<Message, "id">): Promise<string> { export async function sendMessage(
message: Omit<SiPher.Messages.ClientEncrypted.EncryptedMessage, "id"> & { to: string },
olmSession: Olm.Session,
sendMessage: (message: { type: 0 | 1; body: string }, to: string) => void,
saveSession?: { userId: string; recipientId: string; password: string }
): Promise<string> {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
await db.messages.add({ ...message, id }); await db.messages.add({ ...message, id });
@ -131,9 +126,49 @@ export async function addMessage(message: Omit<Message, "id">): Promise<string>
channel.times.updatedAt = Date.now(); channel.times.updatedAt = Date.now();
}); });
// Encrypt the message
const encrypted = olmSession.encrypt(
JSON.stringify({
id,
channelId: message.channelId,
fromUserId: message.fromUserId,
timestamp: message.timestamp,
status: message.status,
content: message.content,
} satisfies SiPher.Messages.ClientEncrypted.EncryptedMessage)
)
// CRITICAL: Save the updated session after encrypt (ratchet has advanced)
if (saveSession) {
await db.olmSessions
.where("[odId+recipientId]")
.equals([saveSession.userId, saveSession.recipientId])
.modify({
pickledSession: olmSession.pickle(saveSession.password),
updatedAt: Date.now(),
});
console.debug("[DB] Session state saved after encrypt");
}
// Send the message using the socket
sendMessage(encrypted, message.to);
return id; return id;
} }
export async function storeMessage(
message: SiPher.Messages.ClientEncrypted.EncryptedMessage
& { to: string }
): Promise<void> {
await db.messages.add(message);
await db.channels.where("id").equals(message.channelId).modify((channel) => {
channel.times.lastMessage = message;
channel.times.lastMessageAt = message.timestamp;
channel.times.updatedAt = Date.now();
});
await incrementUnread(message.channelId);
}
/** Increment unread count for a channel */ /** Increment unread count for a channel */
export async function incrementUnread(channelId: string): Promise<void> { export async function incrementUnread(channelId: string): Promise<void> {
const existing = await db.unreadCounts.get(channelId); const existing = await db.unreadCounts.get(channelId);
@ -146,7 +181,8 @@ export async function incrementUnread(channelId: string): Promise<void> {
/** Clear unread count for a channel */ /** Clear unread count for a channel */
export async function clearUnread(channelId: string): Promise<void> { export async function clearUnread(channelId: string): Promise<void> {
await db.unreadCounts.put({ channelId, count: 0 }); await db.unreadCounts.delete(channelId);
console.log(`[DB] Cleared unread count for channel ${channelId}`);
} }
/** Get total unread count across all channels */ /** Get total unread count across all channels */
@ -154,12 +190,3 @@ export async function getTotalUnread(): Promise<number> {
const all = await db.unreadCounts.toArray(); const all = await db.unreadCounts.toArray();
return all.reduce((sum, item) => sum + item.count, 0); return all.reduce((sum, item) => sum + item.count, 0);
} }
/** Hash a string (for deterministic IDs) */
async function hashString(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest("SHA-256", data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 16);
}

View file

@ -1,4 +1,4 @@
import makeKeysOnSignUp from "@/app/auth/scripts/makeKeys"; import makeKeysOnSignUp, { loadOlm } from "@/app/auth/scripts/makeKeys";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
// ============================================ // ============================================
@ -16,6 +16,39 @@ export type SendKeysToServerFn = (args: {
// Local OLM Account Management // Local OLM Account Management
// ============================================ // ============================================
/**
* Unpickle and retrieve the OLM account for a user
* @param userId - The user's ID
* @param password - The password used to pickle the account
* @returns Promise resolving to the unpickled Olm.Account, or null if not found
*/
export async function getOlmAccount(
userId: string,
password: string
): Promise<any | null> {
// Check cache first
if ((window as any).olmAccountCache?.[userId]) {
return (window as any).olmAccountCache[userId];
}
// Get pickled account from DB
const pickledData = await db.olmAccounts.get(userId);
if (!pickledData) return null;
// Load OLM and unpickle
const Olm = await loadOlm();
const account = new Olm.Account();
account.unpickle(password, pickledData.pickledAccount);
// Cache it
if (!(window as any).olmAccountCache) {
(window as any).olmAccountCache = {};
}
(window as any).olmAccountCache[userId] = account;
return account;
}
/** /**
* 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
@ -110,4 +143,3 @@ export async function handleOlmAccountCreation(
return false; return false;
} }
} }

View file

@ -5,7 +5,7 @@ import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { ConvexReactClient } from "convex/react"; import { ConvexReactClient } from "convex/react";
import { ReactNode } from "react"; import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); export const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ export function ConvexClientProvider({
children, children,

View file

@ -1,35 +0,0 @@
import type { Socket, Server as SocketIOServer } from "socket.io";
import { getDmRoomId } from "./dm";
interface JoinDmData {
withUser: string; // The other user's ID
}
const dmJoinEvent: SiPher.EventsType = {
name: "dm:join",
description: "Join a DM room with another user",
category: "user",
type: "connection",
handler: (socket: Socket, _io: SocketIOServer, data: JoinDmData) => {
const user = (socket as any).user;
if (!user?.id) {
socket.emit("error", { message: "Not authenticated" });
return;
}
const { withUser } = data;
if (!withUser) {
socket.emit("error", { message: "Missing 'withUser'" });
return;
}
const roomId = getDmRoomId(user.id, withUser);
socket.join(roomId);
socket.emit("dm:joined", { roomId, withUser });
console.log(`[DM] ${user.id} joined room ${roomId}`);
},
};
export default dmJoinEvent;

View file

@ -25,7 +25,7 @@ interface DmMessage {
} }
const dmEvent: SiPher.EventsType = { const dmEvent: SiPher.EventsType = {
name: "dm", name: "dm:send",
description: "Send a direct message to another user using the client-side encryption", description: "Send a direct message to another user using the client-side encryption",
category: "user", category: "user",
type: "message", type: "message",
@ -48,27 +48,14 @@ const dmEvent: SiPher.EventsType = {
// Join sender to the DM room // Join sender to the DM room
socket.join(roomId); socket.join(roomId);
const message = {
roomId,
from: {
id: sender.id,
name: sender.name,
email: sender.email,
},
to,
content, // <-- We can assume this was encrypted by the user
timestamp: Date.now(),
};
// Send to the DM room (for users already in the room) // Send to the DM room (for users already in the room)
io.to(roomId).emit("dm:message", message); io.to(roomId).emit("dm:message", JSON.parse(content));
// Also send directly to recipient's socket (socket.id = user.id) // Also send directly to recipient's socket (socket.id = user.id)
// This ensures they receive the message even if not in the DM room yet // This ensures they receive the message even if not in the DM room yet
io.to(to).emit("dm:new", { io.to(to).emit("dm:new", {
...message, content: JSON.parse(content),
// Include sender info so recipient can identify the conversation participants: [sender.id, to].sort(),
participants: [sender.id, to],
}); });
console.log(`[DM] ${sender.id}${to} in room ${roomId}`); console.log(`[DM] ${sender.id}${to} in room ${roomId}`);

View file

@ -1,5 +1,6 @@
import { Session, User } from "better-auth"; import { Session, User } from "better-auth";
import { Socket, Server as SocketIOServer } from "socket.io"; import { Socket, Server as SocketIOServer } from "socket.io";
import { Id } from "../../../convex/_generated/dataModel";
declare global { declare global {
namespace SiPher { namespace SiPher {
@ -113,9 +114,31 @@ declare global {
id: string, id: string,
system: System system: System
} }
type ParticipantDetail = {
id: Id<"user">
name: string
username: string | null | undefined
displayUsername: string | null | undefined
image: string | null | undefined
status: "online" | "busy" | "offline" | "away"
olmAccount: {
_id: Id<"olmAccount">
_creationTime: number
userId: Id<"user">
identityKey: {
curve25519: string
ed25519: string
}
oneTimeKeys: Array<{
keyId: string
publicKey: string
}>
} | null
}
} }
// Add custom socket.io types
} }
// Extend Socket.io types to include authenticated user data // Extend Socket.io types to include authenticated user data

View file

@ -1,8 +1,12 @@
declare global { declare global {
declare namespace SiPher.Messages.ClientEncrypted { declare namespace SiPher.Messages.ClientEncrypted {
type EncryptedMessage = { type EncryptedMessage = {
id: string,
channelId: string,
fromUserId: string,
timestamp: number,
status: "sent" | "delivered" | "read",
content: string, content: string,
iv?: string
} }
type MessageEvent = { type MessageEvent = {
message: { message: {