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:
parent
d9368301ae
commit
07f9984f03
30 changed files with 1732 additions and 329 deletions
7
LICENSE
Normal file
7
LICENSE
Normal 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
4
README.md
Normal 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
133
bun.lock
|
|
@ -1,66 +1,67 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 0,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "sipher",
|
||||
"dependencies": {
|
||||
"@better-fetch/fetch": "^1.1.21",
|
||||
"@convex-dev/better-auth": "^0.10.9",
|
||||
"@marsidev/react-turnstile": "^1.4.0",
|
||||
"@matrix-org/olm": "^3.2.15",
|
||||
"@nanostores/react": "^1.0.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"better-auth": "1.4.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"convex": "^1.31.2",
|
||||
"cross-env": "^10.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"lucide-react": "^0.562.0",
|
||||
"nanostores": "^1.1.0",
|
||||
"next": "16.1.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "19.2.3",
|
||||
"react-day-picker": "^9.13.0",
|
||||
"react-dom": "19.2.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.2.1",
|
||||
"@better-fetch/fetch": "latest",
|
||||
"@convex-dev/better-auth": "latest",
|
||||
"@marsidev/react-turnstile": "latest",
|
||||
"@matrix-org/olm": "latest",
|
||||
"@nanostores/react": "latest",
|
||||
"@phosphor-icons/react": "latest",
|
||||
"@radix-ui/react-avatar": "latest",
|
||||
"@radix-ui/react-checkbox": "latest",
|
||||
"@radix-ui/react-context-menu": "latest",
|
||||
"@radix-ui/react-dialog": "latest",
|
||||
"@radix-ui/react-hover-card": "latest",
|
||||
"@radix-ui/react-label": "latest",
|
||||
"@radix-ui/react-menubar": "latest",
|
||||
"@radix-ui/react-popover": "latest",
|
||||
"@radix-ui/react-progress": "latest",
|
||||
"@radix-ui/react-scroll-area": "latest",
|
||||
"@radix-ui/react-separator": "latest",
|
||||
"@radix-ui/react-slot": "latest",
|
||||
"@radix-ui/react-tooltip": "latest",
|
||||
"@types/libsodium-wrappers": "latest",
|
||||
"better-auth": "latest",
|
||||
"class-variance-authority": "latest",
|
||||
"clsx": "latest",
|
||||
"cmdk": "latest",
|
||||
"convex": "latest",
|
||||
"cross-env": "latest",
|
||||
"date-fns": "latest",
|
||||
"dexie": "latest",
|
||||
"dexie-react-hooks": "latest",
|
||||
"framer-motion": "latest",
|
||||
"libsodium-wrappers": "latest",
|
||||
"lucide-react": "latest",
|
||||
"nanostores": "latest",
|
||||
"next": "latest",
|
||||
"next-themes": "latest",
|
||||
"react": "latest",
|
||||
"react-day-picker": "latest",
|
||||
"react-dom": "latest",
|
||||
"socket.io": "latest",
|
||||
"socket.io-client": "latest",
|
||||
"sonner": "latest",
|
||||
"tailwind-merge": "latest",
|
||||
"ws": "latest",
|
||||
"zod": "latest",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@types/bun": "^1.3.5",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ws": "^8.18.1",
|
||||
"babel-plugin-react-compiler": "1.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"@tailwindcss/postcss": "latest",
|
||||
"@types/bun": "latest",
|
||||
"@types/node": "latest",
|
||||
"@types/react": "latest",
|
||||
"@types/react-dom": "latest",
|
||||
"@types/ws": "latest",
|
||||
"babel-plugin-react-compiler": "latest",
|
||||
"tailwindcss": "latest",
|
||||
"tsx": "latest",
|
||||
"tw-animate-css": "latest",
|
||||
"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=="],
|
||||
|
||||
"@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/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=="],
|
||||
|
||||
|
|
@ -215,7 +216,7 @@
|
|||
|
||||
"@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=="],
|
||||
|
||||
|
|
@ -417,7 +418,7 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -493,9 +494,9 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
|
|
@ -639,7 +640,13 @@
|
|||
|
||||
"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=="],
|
||||
|
||||
|
|
|
|||
8
convex/_generated/api.d.ts
vendored
8
convex/_generated/api.d.ts
vendored
|
|
@ -951,6 +951,7 @@ export declare const components: {
|
|||
"query",
|
||||
"internal",
|
||||
{
|
||||
join?: any;
|
||||
limit?: number;
|
||||
model:
|
||||
| "user"
|
||||
|
|
@ -1004,6 +1005,7 @@ export declare const components: {
|
|||
"query",
|
||||
"internal",
|
||||
{
|
||||
join?: any;
|
||||
model:
|
||||
| "user"
|
||||
| "userStatus"
|
||||
|
|
@ -2006,6 +2008,12 @@ export declare const components: {
|
|||
};
|
||||
olm: {
|
||||
index: {
|
||||
consumeOTK: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ keyId: string; userId: string },
|
||||
any
|
||||
>;
|
||||
retrieveServerOlmAccount: FunctionReference<
|
||||
"query",
|
||||
"internal",
|
||||
|
|
|
|||
|
|
@ -194,4 +194,17 @@ export const getParticipantDetails = query({
|
|||
participantIds: args.participantIds,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -927,6 +927,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
"query",
|
||||
"internal",
|
||||
{
|
||||
join?: any;
|
||||
limit?: number;
|
||||
model:
|
||||
| "user"
|
||||
|
|
@ -981,6 +982,7 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
"query",
|
||||
"internal",
|
||||
{
|
||||
join?: any;
|
||||
model:
|
||||
| "user"
|
||||
| "userStatus"
|
||||
|
|
@ -1986,6 +1988,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
};
|
||||
olm: {
|
||||
index: {
|
||||
consumeOTK: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ keyId: string; userId: string },
|
||||
any,
|
||||
Name
|
||||
>;
|
||||
retrieveServerOlmAccount: FunctionReference<
|
||||
"query",
|
||||
"internal",
|
||||
|
|
|
|||
|
|
@ -45,4 +45,29 @@ export const retrieveServerOlmAccount = query({
|
|||
|
||||
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
|
||||
}
|
||||
},
|
||||
})
|
||||
|
|
@ -353,6 +353,7 @@ export const getParticipantDetails = query({
|
|||
const participantDetails = await Promise.all(filteredParticipantIds.map(async (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 participantOlmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", id)).first();
|
||||
if (!participant) return null;
|
||||
|
||||
return {
|
||||
|
|
@ -362,9 +363,10 @@ export const getParticipantDetails = query({
|
|||
displayUsername: participant.displayUsername,
|
||||
image: participant.image,
|
||||
status: participantStatus?.status || "offline",
|
||||
olmAccount: participantOlmAccount,
|
||||
}
|
||||
}));
|
||||
|
||||
return participantDetails.filter(Boolean);
|
||||
return participantDetails
|
||||
}
|
||||
})
|
||||
10
package.json
10
package.json
|
|
@ -12,7 +12,7 @@
|
|||
"dependencies": {
|
||||
"@better-fetch/fetch": "^1.1.21",
|
||||
"@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",
|
||||
"@nanostores/react": "^1.0.0",
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
|
|
@ -30,7 +30,7 @@
|
|||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@types/libsodium-wrappers": "^0.7.14",
|
||||
"better-auth": "1.4.9",
|
||||
"better-auth": "1.4.10",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
|
@ -39,8 +39,8 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"framer-motion": "^12.23.27",
|
||||
"libsodium-wrappers": "^0.7.16",
|
||||
"lucide-react": "^0.562.0",
|
||||
"nanostores": "^1.1.0",
|
||||
"next": "16.1.1",
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^4.2.1"
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { db } from "@/lib/db";
|
||||
|
||||
// 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 ((window as any).Olm) return (window as any).Olm;
|
||||
|
||||
|
|
@ -64,6 +64,19 @@ export default async function makeKeysOnSignUp(
|
|||
|
||||
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({
|
||||
odId,
|
||||
pickledAccount,
|
||||
|
|
|
|||
|
|
@ -5,16 +5,17 @@ import OlmSetupDialog from "@/components/olm/olm-setup-dialog";
|
|||
import { MainContentLayout } from "@/components/ui/layout";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import UserFloatingCard from "@/components/ui/user/floating-card";
|
||||
import { useOlmSetup } from "@/hooks/use-olm-setup";
|
||||
import { useSocket } from "@/hooks/use-socket";
|
||||
import { OlmProvider, useOlmContext } from "@/contexts/olm-context";
|
||||
import { SocketProvider, useSocketContext } from "@/contexts/socket-context";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { getRandomPhrase, type PhrasePreference } from "@/lib/constants/phrases";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { redirect, useParams, usePathname } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
import OlmPasswordDialog from "./olm/olm-password-dialog";
|
||||
|
||||
export default function AppContainer() {
|
||||
function AppContainerContent() {
|
||||
const pathname = usePathname();
|
||||
const params = useParams();
|
||||
|
||||
|
|
@ -36,29 +37,15 @@ export default function AppContainer() {
|
|||
}
|
||||
return { type: 'home' as const };
|
||||
}, [pathname, params]);
|
||||
const { data, error, isPending, refetch } = authClient.useSession();
|
||||
|
||||
const hasServerOlm = useQuery(
|
||||
api.auth.retrieveServerOlmAccount,
|
||||
data?.user?.id ? { userId: data.user.id } : "skip"
|
||||
);
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
const userStatus = useQuery(api.auth.getUserStatus);
|
||||
const { socketStatus, socketInfo, disconnect, connect } = useSocket({
|
||||
user: {
|
||||
id: data?.user?.id,
|
||||
status: userStatus ? {
|
||||
status: userStatus.status,
|
||||
isUserSet: userStatus.isUserSet,
|
||||
} : {
|
||||
status: "offline" as const,
|
||||
isUserSet: false,
|
||||
},
|
||||
},
|
||||
refetchUser: refetch
|
||||
});
|
||||
// Use socket context instead of hook
|
||||
const { socketStatus, socketInfo, disconnect, connect } = useSocketContext();
|
||||
|
||||
// Use OLM context
|
||||
const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmContext();
|
||||
|
||||
const sendKeysToServer = useMutation(api.auth.sendKeysToServer);
|
||||
const updateUserMetadata = useMutation(api.auth.updateUserMetadata);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -70,29 +57,11 @@ export default function AppContainer() {
|
|||
}
|
||||
}, [data, updateUserMetadata]);
|
||||
|
||||
const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmSetup({
|
||||
userId: data?.user?.id,
|
||||
hasServerOlm,
|
||||
sendKeysToServer
|
||||
});
|
||||
|
||||
const getPhrase = useCallback(() => {
|
||||
const preference = data?.user?.metadata?.phrasePreference as PhrasePreference | undefined;
|
||||
return getRandomPhrase(preference);
|
||||
}, [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)) {
|
||||
return (
|
||||
<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 (
|
||||
<>
|
||||
<UserFloatingCard user={data.user} />
|
||||
|
|
@ -121,6 +94,7 @@ export default function AppContainer() {
|
|||
/>
|
||||
</AppSidebar>
|
||||
|
||||
<OlmPasswordDialog userId={data.user.id} />
|
||||
<OlmSetupDialog
|
||||
open={showOlmModal}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
|
|||
</SidebarContent>
|
||||
</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">
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<SidebarTrigger className="size-9" />
|
||||
|
|
@ -124,8 +124,8 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
|
|||
</div>
|
||||
<div className="w-9 md:hidden" /> {/* Spacer for centering on mobile */}
|
||||
</header>
|
||||
<SidebarInset className="mr-0 mb-0 border-none flex-1 rounded-l-lg">
|
||||
<div className="w-full h-full bg-background border-border border rounded-l-lg rounded-bl-none overflow-auto">
|
||||
<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-hidden min-h-0">
|
||||
{children}
|
||||
</div>
|
||||
</SidebarInset>
|
||||
|
|
|
|||
100
src/components/olm/olm-password-dialog.tsx
Normal file
100
src/components/olm/olm-password-dialog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
355
src/components/ui/dm/DmChannelContent.tsx
Normal file
355
src/components/ui/dm/DmChannelContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
"use client"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { getOrCreateDmChannel } from "@/lib/db"
|
||||
import { MessageCircleIcon } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import UserCard from "../user/user-card"
|
||||
import { FriendActionsMenu } from "./friend-actions-menu"
|
||||
|
||||
export interface FriendData {
|
||||
|
|
@ -45,12 +45,6 @@ export function FriendListItem({
|
|||
const router = useRouter()
|
||||
const displayName = friend.displayUsername || friend.username || friend.name
|
||||
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 (
|
||||
<div
|
||||
|
|
@ -66,18 +60,12 @@ export function FriendListItem({
|
|||
>
|
||||
{/* Left side: Avatar + Info */}
|
||||
<div className="flex flex-row items-center gap-3 flex-1 min-w-0">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={friend.image || undefined} />
|
||||
<AvatarFallback className="text-sm font-medium">
|
||||
{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>
|
||||
<UserCard
|
||||
userName={displayName ?? ""}
|
||||
image={friend.image ?? undefined}
|
||||
status={status}
|
||||
/>
|
||||
|
||||
<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">
|
||||
{displayName}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
"use client"
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { db } from "@/lib/db"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { clearUnread, db } from "@/lib/db"
|
||||
import { formatDistanceToNow } from "date-fns"
|
||||
import { useLiveQuery } from "dexie-react-hooks"
|
||||
import { PlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import UserCard from "../user/user-card"
|
||||
|
||||
export interface ChannelListProps {
|
||||
currentChannel: SiPher.Channel | null
|
||||
|
|
@ -37,6 +37,11 @@ export function ChannelList({
|
|||
}: ChannelListProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const unreadCount = useLiveQuery(
|
||||
() => db.unreadCounts.toArray(),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col shrink-0 max-w-72 min-w-72 border-r border-border/40">
|
||||
{/* Channel List Header */}
|
||||
|
|
@ -87,6 +92,7 @@ export function ChannelList({
|
|||
const isActive = dmChannel?.id === channel.id
|
||||
const lastMessage = channel.times?.lastMessage
|
||||
const lastMessageTime = channel.times?.lastMessageAt
|
||||
const channelUnreadCount = unreadCount?.find((unread) => unread.channelId === channel.id)?.count ?? 0
|
||||
if (!channel.isOpen) return null;
|
||||
|
||||
return (
|
||||
|
|
@ -96,22 +102,23 @@ export function ChannelList({
|
|||
? "bg-accent/60"
|
||||
: "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">
|
||||
<Avatar className="size-8 ring-2 ring-border">
|
||||
<AvatarImage src={channel.metadata?.icon ?? undefined} alt={channel.name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary-foreground font-semibold">
|
||||
{channel.name?.charAt(0).toUpperCase()}
|
||||
</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"
|
||||
)}
|
||||
<UserCard
|
||||
userName={channel.name}
|
||||
image={channel.metadata?.icon ?? undefined}
|
||||
status={"none"}
|
||||
/>
|
||||
{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>
|
||||
|
||||
{/* Channel Info */}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import { useLiveQuery } from "dexie-react-hooks"
|
|||
import * as React from "react"
|
||||
import { useEffect, useMemo } from "react"
|
||||
import { api } from "../../../../convex/_generated/api"
|
||||
import DMChannelContent from "../dm/DmChannelContent"
|
||||
import { FriendsPage } from "../friends/friends-page"
|
||||
import { Spinner } from "../spinner"
|
||||
import { ChannelList } from "./channel-list"
|
||||
import { PageHeader } from "./page-header"
|
||||
import { SettingsPage } from "./settings-page"
|
||||
|
|
@ -44,12 +46,13 @@ export function MainContentLayout({
|
|||
[userId]
|
||||
) ?? []
|
||||
|
||||
const getParticipantDetails = useQuery(api.auth.getParticipantDetails, dmChannelId ? {
|
||||
participantIds: openDmChannels
|
||||
.find((channel) => channel.id === dmChannelId)
|
||||
?.participants
|
||||
.filter((participant) => participant !== userId) ?? []
|
||||
} : "skip")
|
||||
const participantIds = openDmChannels
|
||||
.find((channel) => channel.id === dmChannelId)
|
||||
?.participants ?? []
|
||||
|
||||
const getParticipantDetails: SiPher.ParticipantDetail[] | undefined = useQuery(api.auth.getParticipantDetails,
|
||||
{ participantIds }
|
||||
)
|
||||
|
||||
// Combine channel from local DB with participant details from Convex
|
||||
const dmChannel = useMemo(() => {
|
||||
|
|
@ -60,7 +63,14 @@ export function MainContentLayout({
|
|||
|
||||
return {
|
||||
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])
|
||||
|
||||
|
|
@ -104,12 +114,21 @@ export function MainContentLayout({
|
|||
|
||||
{/* Main Content */}
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{page === "dm" ? (
|
||||
<div className="flex flex-col h-full p-4">
|
||||
<p className="text-sm text-muted-foreground">DM chat with {dmChannelId}</p>
|
||||
</div>
|
||||
) : page === "server" ? (
|
||||
<div className="flex flex-col h-full p-4">
|
||||
{page === "dm" && dmChannelId ? (
|
||||
getParticipantDetails ? (
|
||||
<div className="flex flex-1 min-h-0">
|
||||
<DMChannelContent userId={userId} channelId={dmChannelId!} participantDetails={getParticipantDetails} />
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
</div>
|
||||
) : page === "friends" ? (
|
||||
|
|
@ -132,5 +151,4 @@ export function MainContentLayout({
|
|||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { PhoneIcon, SearchIcon, UserIcon, UsersIcon, VideoIcon } from "lucide-react"
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../avatar"
|
||||
import UserCard from "../user/user-card"
|
||||
|
||||
export interface PageHeaderProps {
|
||||
currentChannel: SiPher.Channel | null
|
||||
|
|
@ -26,13 +25,6 @@ export interface PageHeaderProps {
|
|||
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({
|
||||
currentChannel,
|
||||
page,
|
||||
|
|
@ -63,20 +55,12 @@ export function PageHeader({
|
|||
{/* Page title/options */}
|
||||
{dmChannel ? (
|
||||
<div className="flex flex-row justify-start items-center gap-2 w-full px-4">
|
||||
<div className="relative shrink-0">
|
||||
<Avatar className="size-4 ring-2 ring-border">
|
||||
<AvatarImage src={dmChannel.participantDetails[0].image ?? undefined} alt={dmChannel.participantDetails[0].name} />
|
||||
<AvatarFallback className="bg-primary/20 text-primary-foreground font-semibold">
|
||||
{dmChannel.participantDetails[0].name?.charAt(0).toUpperCase()}
|
||||
</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>
|
||||
<UserCard
|
||||
userName={dmChannel.participantDetails[0].name}
|
||||
image={dmChannel.participantDetails[0].image}
|
||||
status={dmChannel.participantDetails[0].status}
|
||||
size="small"
|
||||
/>
|
||||
<span className="text-sm font-medium">{dmChannel.participantDetails[0].name}</span>
|
||||
<div className="flex flex-row gap-2 ml-auto">
|
||||
<Button
|
||||
|
|
|
|||
39
src/components/ui/user/user-card.tsx
Normal file
39
src/components/ui/user/user-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
381
src/contexts/olm-context.tsx
Normal file
381
src/contexts/olm-context.tsx
Normal 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;
|
||||
}
|
||||
|
||||
415
src/contexts/socket-context.tsx
Normal file
415
src/contexts/socket-context.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
|
||||
import { useMutation } from "convex/react";
|
||||
|
|
@ -16,6 +32,7 @@ interface UseSocketProps {
|
|||
refetchUser: () => void;
|
||||
}
|
||||
|
||||
/** @deprecated Use useSocketContext from '@/contexts/socket-context' instead */
|
||||
export function useSocket({ user, refetchUser }: UseSocketProps) {
|
||||
const updateUserStatus = useMutation(api.auth.updateUserStatus);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
|
|
@ -51,6 +68,15 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
|
|||
}
|
||||
}, [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(() => {
|
||||
if (!user.id) return;
|
||||
|
||||
|
|
@ -191,6 +217,6 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
|
|||
};
|
||||
}, [user.id, updateUserStatus]);
|
||||
|
||||
return { socketStatus, socketInfo, disconnect, connect };
|
||||
return { socketStatus, socketInfo, disconnect, connect, sendMessage };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,16 +22,6 @@ export interface OlmSession {
|
|||
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 */
|
||||
export interface UnreadCount {
|
||||
channelId: string;
|
||||
|
|
@ -46,7 +36,7 @@ class SipherDB extends Dexie {
|
|||
olmAccounts!: EntityTable<OlmAccount, "odId">;
|
||||
olmSessions!: EntityTable<OlmSession, "odId">;
|
||||
channels!: EntityTable<SiPher.Channel, "id">;
|
||||
messages!: EntityTable<Message, "id">;
|
||||
messages!: EntityTable<SiPher.Messages.ClientEncrypted.EncryptedMessage, "id">;
|
||||
unreadCounts!: EntityTable<UnreadCount, "channelId">;
|
||||
|
||||
constructor() {
|
||||
|
|
@ -71,7 +61,7 @@ export const db = new SipherDB();
|
|||
/** Get or create a DM channel with another user */
|
||||
export async function getOrCreateDmChannel(
|
||||
myUserId: string,
|
||||
otherUser: any
|
||||
otherUser: SiPher.ParticipantDetail
|
||||
): Promise<SiPher.Channel> {
|
||||
// Generate deterministic channel ID
|
||||
const channelId = getDmRoomId(myUserId, otherUser.id);
|
||||
|
|
@ -109,7 +99,7 @@ export async function getChannelMessages(
|
|||
channelId: string,
|
||||
limit = 50,
|
||||
before?: number
|
||||
): Promise<Message[]> {
|
||||
): Promise<SiPher.Messages.ClientEncrypted.EncryptedMessage[]> {
|
||||
let query = db.messages.where("channelId").equals(channelId);
|
||||
|
||||
if (before) {
|
||||
|
|
@ -120,7 +110,12 @@ export async function getChannelMessages(
|
|||
}
|
||||
|
||||
/** 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();
|
||||
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();
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
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 */
|
||||
export async function incrementUnread(channelId: string): Promise<void> {
|
||||
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 */
|
||||
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 */
|
||||
|
|
@ -154,12 +190,3 @@ export async function getTotalUnread(): Promise<number> {
|
|||
const all = await db.unreadCounts.toArray();
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import makeKeysOnSignUp from "@/app/auth/scripts/makeKeys";
|
||||
import makeKeysOnSignUp, { loadOlm } from "@/app/auth/scripts/makeKeys";
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
// ============================================
|
||||
|
|
@ -16,6 +16,39 @@ export type SendKeysToServerFn = (args: {
|
|||
// 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
|
||||
* @param userId - The user's ID
|
||||
|
|
@ -110,4 +143,3 @@ export async function handleOlmAccountCreation(
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
|
|||
import { ConvexReactClient } from "convex/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({
|
||||
children,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
@ -25,7 +25,7 @@ interface DmMessage {
|
|||
}
|
||||
|
||||
const dmEvent: SiPher.EventsType = {
|
||||
name: "dm",
|
||||
name: "dm:send",
|
||||
description: "Send a direct message to another user using the client-side encryption",
|
||||
category: "user",
|
||||
type: "message",
|
||||
|
|
@ -48,27 +48,14 @@ const dmEvent: SiPher.EventsType = {
|
|||
// Join sender to the DM room
|
||||
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)
|
||||
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)
|
||||
// This ensures they receive the message even if not in the DM room yet
|
||||
io.to(to).emit("dm:new", {
|
||||
...message,
|
||||
// Include sender info so recipient can identify the conversation
|
||||
participants: [sender.id, to],
|
||||
content: JSON.parse(content),
|
||||
participants: [sender.id, to].sort(),
|
||||
});
|
||||
|
||||
console.log(`[DM] ${sender.id} → ${to} in room ${roomId}`);
|
||||
|
|
|
|||
25
src/types/globals.d.ts
vendored
25
src/types/globals.d.ts
vendored
|
|
@ -1,5 +1,6 @@
|
|||
import { Session, User } from "better-auth";
|
||||
import { Socket, Server as SocketIOServer } from "socket.io";
|
||||
import { Id } from "../../../convex/_generated/dataModel";
|
||||
|
||||
declare global {
|
||||
namespace SiPher {
|
||||
|
|
@ -113,9 +114,31 @@ declare global {
|
|||
id: string,
|
||||
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
|
||||
|
|
|
|||
6
src/types/messages/encrypted.d.ts
vendored
6
src/types/messages/encrypted.d.ts
vendored
|
|
@ -1,8 +1,12 @@
|
|||
declare global {
|
||||
declare namespace SiPher.Messages.ClientEncrypted {
|
||||
type EncryptedMessage = {
|
||||
id: string,
|
||||
channelId: string,
|
||||
fromUserId: string,
|
||||
timestamp: number,
|
||||
status: "sent" | "delivered" | "read",
|
||||
content: string,
|
||||
iv?: string
|
||||
}
|
||||
type MessageEvent = {
|
||||
message: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue