From e7dd6c961d11d1078ed1892aaa991ee120991eb8 Mon Sep 17 00:00:00 2001 From: Nixyan Date: Fri, 20 Feb 2026 10:01:07 -0300 Subject: [PATCH] feat: enhance user status management and introduce nests functionality - Updated user status handling to include optional user-set status, improving user experience during reconnections. - Added new queries and mutations for managing nests, including fetching non-offline user IDs and forcing users offline. - Introduced new database schema for nests, roles, and channels, enhancing the application's organizational structure. - Updated dependencies in package.json and bun.lock for improved stability and compatibility. - Refactored related components and API to support the new nests functionality. --- bun.lock | 217 ++--- convex/_generated/api.d.ts | 719 ++++++++++++++++- convex/_generated/dataModel.d.ts | 2 +- convex/auth.ts | 43 +- convex/betterAuth/_generated/api.ts | 4 + convex/betterAuth/_generated/component.ts | 739 +++++++++++++++++- convex/betterAuth/_generated/dataModel.ts | 2 +- convex/betterAuth/_generated/server.ts | 5 - convex/betterAuth/nests/locals.ts | 60 ++ convex/betterAuth/olm/index.ts | 86 +- convex/betterAuth/schema.ts | 11 +- convex/betterAuth/schemas/nests.ts | 19 +- convex/betterAuth/schemas/user.ts | 11 +- convex/betterAuth/user/index.ts | 88 ++- package.json | 25 +- src/app/(app)/channels/me/friends/page.tsx | 3 + src/app/(app)/channels/nests/global/page.tsx | 3 + .../servers/[serverId]/[channelId]/page.tsx | 4 - src/app/(app)/discover/page.tsx | 3 + src/app/layout.tsx | 2 + src/components/app-container.tsx | 71 +- src/components/home/index.tsx | 23 +- .../notifications/NotificationSettings.tsx | 99 +++ src/components/ui/dm/DmChannelContent.tsx | 51 +- src/components/ui/layout/channel-list.tsx | 285 +++++-- .../ui/layout/main-content-layout.tsx | 128 +-- src/components/ui/layout/page-header.tsx | 2 +- src/components/ui/scroll-area.tsx | 86 +- src/contexts/olm-context.tsx | 160 +++- src/contexts/socket-context.tsx | 98 ++- src/lib/db/index.ts | 125 ++- src/lib/notifications/index.ts | 135 ++++ src/lib/olm/index.ts | 23 +- src/lib/olm/keySync.ts | 112 +++ src/lib/sockets/events/dm.ts | 2 +- src/lib/sockets/index.ts | 59 +- src/server.ts | 10 +- src/types/globals.d.ts | 3 + src/types/sidebar.d.ts | 24 + 39 files changed, 3087 insertions(+), 455 deletions(-) create mode 100644 convex/betterAuth/nests/locals.ts create mode 100644 src/app/(app)/channels/me/friends/page.tsx create mode 100644 src/app/(app)/channels/nests/global/page.tsx delete mode 100644 src/app/(app)/channels/servers/[serverId]/[channelId]/page.tsx create mode 100644 src/app/(app)/discover/page.tsx create mode 100644 src/components/notifications/NotificationSettings.tsx create mode 100644 src/lib/notifications/index.ts create mode 100644 src/lib/olm/keySync.ts diff --git a/bun.lock b/bun.lock index 9bf3c54..14f44d0 100644 --- a/bun.lock +++ b/bun.lock @@ -5,60 +5,61 @@ "": { "name": "sipher", "dependencies": { - "@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/bun": "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", - "lucide-react": "latest", - "moment": "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", - "zod": "latest", + "@convex-dev/better-auth": "^0.10.10", + "@marsidev/react-turnstile": "^1.4.2", + "@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/bun": "^1.3.9", + "@types/libsodium-wrappers": "^0.7.14", + "better-auth": "1.4.12", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "convex": "^1.31.7", + "cross-env": "^10.1.0", + "date-fns": "^4.1.0", + "dexie": "^4.3.0", + "dexie-react-hooks": "^4.2.0", + "dotenv": "^17.3.1", + "framer-motion": "^12.34.0", + "lucide-react": "^0.562.0", + "moment": "^2.30.1", + "nanostores": "^1.1.0", + "next": "16.1.1", + "next-themes": "^0.4.6", + "react": "19.2.3", + "react-day-picker": "^9.13.1", + "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", + "zod": "^4.3.6", }, "devDependencies": { - "@tailwindcss/postcss": "latest", - "@types/node": "latest", - "@types/react": "latest", - "@types/react-dom": "latest", - "babel-plugin-react-compiler": "latest", - "tailwindcss": "latest", - "tsx": "latest", - "tw-animate-css": "latest", - "typescript": "latest", + "@tailwindcss/postcss": "^4.1.18", + "@types/node": "^25.2.2", + "@types/react": "^19.2.13", + "@types/react-dom": "^19.2.3", + "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", }, }, }, @@ -213,7 +214,7 @@ "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], - "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.1", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-1jE0IjvB8z+q1NFRs3149gXzXwIzXQWqQjn9fmAr13BiE3RYLWck5Me6flHYE90shW5L12Jkm6R1peS1OnA9oQ=="], + "@marsidev/react-turnstile": ["@marsidev/react-turnstile@1.4.2", "", { "peerDependencies": { "react": "^17.0.2 || ^18.0.0 || ^19.0", "react-dom": "^17.0.2 || ^18.0.0 || ^19.0" } }, "sha512-xs1qOuyeMOz6t9BXXCXWiukC0/0+48vR08B7uwNdG05wCMnbcNgxiFmdFKDOFbM76qFYFRYlGeRfhfq1U/iZmA=="], "@matrix-org/olm": ["@matrix-org/olm@3.2.15", "", {}, "sha512-S7lOrndAK9/8qOtaTq/WhttJC/o4GAzdfK0MUPpo8ApzsJEC0QjtwrkC3KBXdFP1cD1MXi/mlKR7aaoVMKgs6Q=="], @@ -359,45 +360,45 @@ "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], - "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], + "@tailwindcss/node": ["@tailwindcss/node@4.2.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.0" } }, "sha512-Yv+fn/o2OmL5fh/Ir62VXItdShnUxfpkMA4Y7jdeC8O81WPB8Kf6TT6GSHvnqgSwDzlB5iT7kDpeXxLsUS0T6Q=="], - "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.0", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.0", "@tailwindcss/oxide-darwin-arm64": "4.2.0", "@tailwindcss/oxide-darwin-x64": "4.2.0", "@tailwindcss/oxide-freebsd-x64": "4.2.0", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.0", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.0", "@tailwindcss/oxide-linux-arm64-musl": "4.2.0", "@tailwindcss/oxide-linux-x64-gnu": "4.2.0", "@tailwindcss/oxide-linux-x64-musl": "4.2.0", "@tailwindcss/oxide-wasm32-wasi": "4.2.0", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.0", "@tailwindcss/oxide-win32-x64-msvc": "4.2.0" } }, "sha512-AZqQzADaj742oqn2xjl5JbIOzZB/DGCYF/7bpvhA8KvjUj9HJkag6bBuwZvH1ps6dfgxNHyuJVlzSr2VpMgdTQ=="], - "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.0", "", { "os": "android", "cpu": "arm64" }, "sha512-F0QkHAVaW/JNBWl4CEKWdZ9PMb0khw5DCELAOnu+RtjAfx5Zgw+gqCHFvqg3AirU1IAd181fwOtJQ5I8Yx5wtw=="], - "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-I0QylkXsBsJMZ4nkUNSR04p6+UptjcwhcVo3Zu828ikiEqHjVmQL9RuQ6uT/cVIiKpvtVA25msu/eRV97JeNSA=="], - "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-6TmQIn4p09PBrmnkvbYQ0wbZhLtbaksCDx7Y7R3FYYx0yxNA7xg5KP7dowmQ3d2JVdabIHvs3Hx4K3d5uCf8xg=="], - "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qBudxDvAa2QwGlq9y7VIzhTvp2mLJ6nD/G8/tI70DCDoneaUeLWBJaPcbfzqRIWraj+o969aDQKvKW9dvkUizw=="], - "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7XKkitpy5NIjFZNUQPeUyNJNJn1CJeV7rmMR+exHfTuOsg8rxIO9eNV5TSEnqRcaOK77zQpsyUkBWmPy8FgdSg=="], - "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mff5a5Q3WoQR01pGU1gr29hHM1N93xYrKkGXfPw/aRtK4bOc331Ho4Tgfsm5WDGvpevqMpdlkCojT3qlCQbCpA=="], - "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-XKcSStleEVnbH6W/9DHzZv1YhjE4eSS6zOu2eRtYAIh7aV4o3vIBs+t/B15xlqoxt6ef/0uiqJVB6hkHjWD/0A=="], - "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-/hlXCBqn9K6fi7eAM0RsobHwJYa5V/xzWspVTzxnX+Ft9v6n+30Pz8+RxCn7sQL/vRHHLS30iQPrHQunu6/vJA=="], - "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-lKUaygq4G7sWkhQbfdRRBkaq4LY39IriqBQ+Gk6l5nKq6Ay2M2ZZb1tlIyRNgZKS8cbErTwuYSor0IIULC0SHw=="], - "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.0", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-xuDjhAsFdUuFP5W9Ze4k/o4AskUtI8bcAGU4puTYprr89QaYFmhYOPfP+d1pH+k9ets6RoE23BXZM1X1jJqoyw=="], - "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-2UU/15y1sWDEDNJXxEIrfWKC2Yb4YgIW5Xz2fKFqGzFWfoMHWFlfa1EJlGO2Xzjkq/tvSarh9ZTjvbxqWvLLXA=="], - "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.0", "", { "os": "win32", "cpu": "x64" }, "sha512-CrFadmFoc+z76EV6LPG1jx6XceDsaCG3lFhyLNo/bV9ByPrE+FnBPckXQVP4XRkN76h3Fjt/a+5Er/oA/nCBvQ=="], - "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.0", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.0", "@tailwindcss/oxide": "4.2.0", "postcss": "^8.5.6", "tailwindcss": "4.2.0" } }, "sha512-u6YBacGpOm/ixPfKqfgrJEjMfrYmPD7gEFRoygS/hnQaRtV0VCBdpkx5Ouw9pnaLRwwlgGCuJw8xLpaR0hOrQg=="], - "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/cors": ["@types/cors@2.8.19", "", { "dependencies": { "@types/node": "*" } }, "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg=="], "@types/libsodium-wrappers": ["@types/libsodium-wrappers@0.7.14", "", {}, "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ=="], - "@types/node": ["@types/node@25.0.8", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg=="], + "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="], - "@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], + "@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], @@ -417,7 +418,7 @@ "better-call": ["better-call@1.1.7", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-6gaJe1bBIEgVebQu/7q9saahVzvBsGaByEnE8aDVncZEDiJO7sdNB28ot9I6iXSbR25egGmmZ6aIURXyQHRraQ=="], - "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "caniuse-lite": ["caniuse-lite@1.0.30001757", "", {}, "sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ=="], @@ -431,7 +432,7 @@ "common-tags": ["common-tags@1.8.2", "", {}, "sha512-gk/Z852D2Wtb//0I+kRFNKKE9dIIVirjoqPoA1wJU+XePVXZfGeBpk45+A1rKO4Q43prqWBNY/MiIeRLbPWUaA=="], - "convex": ["convex@1.31.4", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-iDm283Gb/CFRb30cvhH6Z9qlYof6dhtin415FarKUKB3K7gumO0rn8snY0CTvUrThV3UnCtttbuL/1oY7LscyA=="], + "convex": ["convex@1.32.0", "", { "dependencies": { "esbuild": "0.27.0", "prettier": "^3.0.0", "ws": "8.18.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-5FlajdLpW75pdLS+/CgGH5H6yeRuA+ru50AKJEYbJpmyILUS+7fdTvsdTaQ7ZFXMv0gE8mX4S+S3AtJ94k0mfw=="], "convex-helpers": ["convex-helpers@0.1.106", "", { "peerDependencies": { "@standard-schema/spec": "^1.0.0", "convex": "^1.25.4", "hono": "^4.0.5", "react": "^17.0.2 || ^18.0.0 || ^19.0.0", "typescript": "^5.5", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@standard-schema/spec", "hono", "react", "typescript", "zod"], "bin": { "convex-helpers": "bin.cjs" } }, "sha512-hWRe3yDaAVHMe4CUYw1YoQLiPZ1KIx6Kbf0w6UcRDx1BXpJgMCl3GVIMiSeYiA0PkbwjnIwGWIvoUVKloG5Tyw=="], @@ -457,21 +458,23 @@ "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], - "dexie": ["dexie@4.2.1", "", {}, "sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg=="], + "dexie": ["dexie@4.3.0", "", {}, "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug=="], "dexie-react-hooks": ["dexie-react-hooks@4.2.0", "", { "peerDependencies": { "@types/react": ">=16", "dexie": ">=4.2.0-alpha.1 <5.0.0", "react": ">=16" } }, "sha512-u7KqTX9JpBQK8+tEyA9X0yMGXlSCsbm5AU64N6gjvGk/IutYDpLBInMYEAEC83s3qhIvryFS+W+sqLZUBEvePQ=="], + "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + "engine.io": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="], "engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="], "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], - "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], + "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], - "framer-motion": ["framer-motion@12.26.2", "", { "dependencies": { "motion-dom": "^12.26.2", "motion-utils": "^12.24.10", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-lflOQEdjquUi9sCg5Y1LrsZDlsjrHw7m0T9Yedvnk7Bnhqfkc89/Uha10J3CFhkL+TCZVCRw9eUGyM/lyYhXQA=="], + "framer-motion": ["framer-motion@12.34.2", "", { "dependencies": { "motion-dom": "^12.34.2", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-CcnYTzbRybm1/OE8QLXfXI8gR1cx5T4dF3D2kn5IyqsGNeLAKl2iFHb2BzFyXBGqESntDt6rPYl4Jhrb7tdB8g=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], @@ -489,29 +492,29 @@ "kysely": ["kysely@0.28.8", "", {}, "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA=="], - "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + "lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="], "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], @@ -523,9 +526,9 @@ "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], - "motion-dom": ["motion-dom@12.26.2", "", { "dependencies": { "motion-utils": "^12.24.10" } }, "sha512-KLMT1BroY8oKNeliA3JMNJ+nbCIsTKg6hJpDb4jtRAJ7nCKnnpg/LTq/NGqG90Limitz3kdAnAVXecdFVGlWTw=="], + "motion-dom": ["motion-dom@12.34.2", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-n7gknp7gHcW7DUcmet0JVPLVHmE3j9uWwDp5VbE3IkCNnW5qdu0mOhjNYzXMkrQjrgr+h6Db3EDM2QBhW2qNxQ=="], - "motion-utils": ["motion-utils@12.24.10", "", {}, "sha512-x5TFgkCIP4pPsRLpKoI86jv/q8t8FQOiM/0E8QKBzfMozWHfkKap2gA1hOki+B5g3IsBNpxbUnfOum1+dgvYww=="], + "motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -555,7 +558,7 @@ "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], - "react-day-picker": ["react-day-picker@9.13.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ=="], + "react-day-picker": ["react-day-picker@9.13.2", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-IMPiXfXVIAuR5Yk58DDPBC8QKClrhdXV+Tr/alBrwrHUw0qDDYB1m5zPNuTnnPIr/gmJ4ChMxmtqPdxm8+R4Eg=="], "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], @@ -599,9 +602,9 @@ "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], - "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + "tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="], - "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + "tailwindcss": ["tailwindcss@4.2.0", "", {}, "sha512-yYzTZ4++b7fNYxFfpnberEEKu43w44aqDMNM9MHMmcKuCH7lL8jJ4yJ7LGHv7rSwiqM0nkiobF9I6cLlpS2P7Q=="], "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], @@ -617,7 +620,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], @@ -629,16 +632,20 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], - "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@better-auth/core/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "@better-auth/passkey/@better-auth/core": ["@better-auth/core@1.4.9", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "zod": "^4.1.12" }, "peerDependencies": { "@better-auth/utils": "0.3.0", "@better-fetch/fetch": "1.1.21", "better-call": "1.1.7", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" } }, "sha512-JT2q4NDkQzN22KclUEoZ7qU6tl9HUTfK1ctg2oWlT87SEagkwJcnrUwS9VznL+u9ziOIfY27P0f7/jSnmvLcoQ=="], "@better-auth/passkey/zod": ["zod@4.2.1", "", {}, "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw=="], + "@convex-dev/better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], "@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], @@ -707,13 +714,13 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -721,18 +728,26 @@ "@types/cors/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "better-auth/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "convex/esbuild": ["esbuild@0.27.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="], "engine.io/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], "engine.io/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "engine.io/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "engine.io-client/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], "socket.io-adapter/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "socket.io-adapter/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + "socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -761,6 +776,8 @@ "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@types/cors/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "convex/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.0", "", { "os": "aix", "cpu": "ppc64" }, "sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A=="], "convex/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.0", "", { "os": "android", "cpu": "arm" }, "sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ=="], @@ -812,5 +829,7 @@ "convex/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ=="], "convex/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.0", "", { "os": "win32", "cpu": "x64" }, "sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg=="], + + "engine.io/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], } } diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index c4792ab..6d6c960 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -67,6 +67,7 @@ export declare const components: { phrasePreference: "comforting" | "mocking" | "both"; }; name: string; + nests?: Array; updatedAt: number; userId?: null | string; username?: null | string; @@ -75,10 +76,14 @@ export declare const components: { } | { data: { - isUserSet: boolean; status: "online" | "busy" | "offline" | "away"; updatedAt: number; userId: string; + userSetStatus?: { + isSet: boolean; + status: "online" | "busy" | "offline" | "away"; + updatedAt: number; + }; }; model: "userStatus"; } @@ -100,6 +105,63 @@ export declare const components: { data: { createdAt: number; friendId: string; userId: string }; model: "friends"; } + | { + data: { + channels: Array; + colors?: { accent: string; primary: string }; + createdAt: number; + description?: string; + emojis: Array<{ + createdAt: number; + id: string; + name: string; + }>; + images?: { banner: string; icon: string }; + managerId: string; + members: Array; + name: string; + onDiscover?: boolean; + region?: string; + roles: Array; + type: "global" | "regional" | "private"; + updatedAt: number; + }; + model: "nests"; + } + | { + data: { + color?: string; + createdAt: number; + flags: Array; + hoist?: boolean; + icon?: string; + members: Array; + mentionable?: boolean; + name: string; + nestId: string; + permissions: Array; + position?: number; + updatedAt: number; + }; + model: "roles"; + } + | { + data: { + createdAt: number; + name: string; + nestId: string; + overwrites: Array<{ + allow: Array | null; + deny: Array | null; + id: string | string; + }>; + permissions: Array; + position: number; + type: "text" | "category" | "announcement"; + updatedAt: number; + }; + model: "channels"; + } | { data: { attachments?: Array; @@ -182,8 +244,11 @@ export declare const components: { } | { data: { + createdAt?: number; identityKey: { curve25519: string; ed25519: string }; + keyVersion?: number; oneTimeKeys: Array<{ keyId: string; publicKey: string }>; + updatedAt?: number; userId: string; }; model: "olmAccount"; @@ -213,6 +278,7 @@ export declare const components: { | "username" | "displayUsername" | "metadata" + | "nests" | "_id"; operator?: | "lt" @@ -242,7 +308,7 @@ export declare const components: { field: | "userId" | "status" - | "isUserSet" + | "userSetStatus" | "updatedAt" | "_id"; operator?: @@ -328,6 +394,121 @@ export declare const components: { | null; }>; } + | { + model: "nests"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "description" + | "images" + | "colors" + | "createdAt" + | "updatedAt" + | "managerId" + | "members" + | "channels" + | "roles" + | "region" + | "emojis" + | "onDiscover" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "roles"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "nestId" + | "name" + | "color" + | "hoist" + | "mentionable" + | "icon" + | "position" + | "permissions" + | "flags" + | "createdAt" + | "updatedAt" + | "members" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "channels"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "nestId" + | "position" + | "permissions" + | "overwrites" + | "createdAt" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } | { model: "messages"; where?: Array<{ @@ -540,7 +721,14 @@ export declare const components: { model: "olmAccount"; where?: Array<{ connector?: "AND" | "OR"; - field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; + field: + | "userId" + | "identityKey" + | "oneTimeKeys" + | "createdAt" + | "updatedAt" + | "keyVersion" + | "_id"; operator?: | "lt" | "lte" @@ -594,6 +782,7 @@ export declare const components: { | "username" | "displayUsername" | "metadata" + | "nests" | "_id"; operator?: | "lt" @@ -623,7 +812,7 @@ export declare const components: { field: | "userId" | "status" - | "isUserSet" + | "userSetStatus" | "updatedAt" | "_id"; operator?: @@ -709,6 +898,121 @@ export declare const components: { | null; }>; } + | { + model: "nests"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "description" + | "images" + | "colors" + | "createdAt" + | "updatedAt" + | "managerId" + | "members" + | "channels" + | "roles" + | "region" + | "emojis" + | "onDiscover" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "roles"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "nestId" + | "name" + | "color" + | "hoist" + | "mentionable" + | "icon" + | "position" + | "permissions" + | "flags" + | "createdAt" + | "updatedAt" + | "members" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "channels"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "nestId" + | "position" + | "permissions" + | "overwrites" + | "createdAt" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } | { model: "messages"; where?: Array<{ @@ -921,7 +1225,14 @@ export declare const components: { model: "olmAccount"; where?: Array<{ connector?: "AND" | "OR"; - field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; + field: + | "userId" + | "identityKey" + | "oneTimeKeys" + | "createdAt" + | "updatedAt" + | "keyVersion" + | "_id"; operator?: | "lt" | "lte" @@ -958,6 +1269,9 @@ export declare const components: { | "userStatus" | "friendRequests" | "friends" + | "nests" + | "roles" + | "channels" | "messages" | "attachments" | "session" @@ -1011,6 +1325,9 @@ export declare const components: { | "userStatus" | "friendRequests" | "friends" + | "nests" + | "roles" + | "channels" | "messages" | "attachments" | "session" @@ -1062,6 +1379,7 @@ export declare const components: { phrasePreference: "comforting" | "mocking" | "both"; }; name?: string; + nests?: Array; updatedAt?: number; userId?: null | string; username?: null | string; @@ -1079,6 +1397,7 @@ export declare const components: { | "username" | "displayUsername" | "metadata" + | "nests" | "_id"; operator?: | "lt" @@ -1104,17 +1423,21 @@ export declare const components: { | { model: "userStatus"; update: { - isUserSet?: boolean; status?: "online" | "busy" | "offline" | "away"; updatedAt?: number; userId?: string; + userSetStatus?: { + isSet: boolean; + status: "online" | "busy" | "offline" | "away"; + updatedAt: number; + }; }; where?: Array<{ connector?: "AND" | "OR"; field: | "userId" | "status" - | "isUserSet" + | "userSetStatus" | "updatedAt" | "_id"; operator?: @@ -1216,6 +1539,169 @@ export declare const components: { | null; }>; } + | { + model: "nests"; + update: { + channels?: Array; + colors?: { accent: string; primary: string }; + createdAt?: number; + description?: string; + emojis?: Array<{ + createdAt: number; + id: string; + name: string; + }>; + images?: { banner: string; icon: string }; + managerId?: string; + members?: Array; + name?: string; + onDiscover?: boolean; + region?: string; + roles?: Array; + type?: "global" | "regional" | "private"; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "description" + | "images" + | "colors" + | "createdAt" + | "updatedAt" + | "managerId" + | "members" + | "channels" + | "roles" + | "region" + | "emojis" + | "onDiscover" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "roles"; + update: { + color?: string; + createdAt?: number; + flags?: Array; + hoist?: boolean; + icon?: string; + members?: Array; + mentionable?: boolean; + name?: string; + nestId?: string; + permissions?: Array; + position?: number; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "nestId" + | "name" + | "color" + | "hoist" + | "mentionable" + | "icon" + | "position" + | "permissions" + | "flags" + | "createdAt" + | "updatedAt" + | "members" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "channels"; + update: { + createdAt?: number; + name?: string; + nestId?: string; + overwrites?: Array<{ + allow: Array | null; + deny: Array | null; + id: string | string; + }>; + permissions?: Array; + position?: number; + type?: "text" | "category" | "announcement"; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "nestId" + | "position" + | "permissions" + | "overwrites" + | "createdAt" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } | { model: "messages"; update: { @@ -1489,13 +1975,23 @@ export declare const components: { | { model: "olmAccount"; update: { + createdAt?: number; identityKey?: { curve25519: string; ed25519: string }; + keyVersion?: number; oneTimeKeys?: Array<{ keyId: string; publicKey: string }>; + updatedAt?: number; userId?: string; }; where?: Array<{ connector?: "AND" | "OR"; - field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; + field: + | "userId" + | "identityKey" + | "oneTimeKeys" + | "createdAt" + | "updatedAt" + | "keyVersion" + | "_id"; operator?: | "lt" | "lte" @@ -1546,6 +2042,7 @@ export declare const components: { phrasePreference: "comforting" | "mocking" | "both"; }; name?: string; + nests?: Array; updatedAt?: number; userId?: null | string; username?: null | string; @@ -1563,6 +2060,7 @@ export declare const components: { | "username" | "displayUsername" | "metadata" + | "nests" | "_id"; operator?: | "lt" @@ -1588,17 +2086,21 @@ export declare const components: { | { model: "userStatus"; update: { - isUserSet?: boolean; status?: "online" | "busy" | "offline" | "away"; updatedAt?: number; userId?: string; + userSetStatus?: { + isSet: boolean; + status: "online" | "busy" | "offline" | "away"; + updatedAt: number; + }; }; where?: Array<{ connector?: "AND" | "OR"; field: | "userId" | "status" - | "isUserSet" + | "userSetStatus" | "updatedAt" | "_id"; operator?: @@ -1700,6 +2202,169 @@ export declare const components: { | null; }>; } + | { + model: "nests"; + update: { + channels?: Array; + colors?: { accent: string; primary: string }; + createdAt?: number; + description?: string; + emojis?: Array<{ + createdAt: number; + id: string; + name: string; + }>; + images?: { banner: string; icon: string }; + managerId?: string; + members?: Array; + name?: string; + onDiscover?: boolean; + region?: string; + roles?: Array; + type?: "global" | "regional" | "private"; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "description" + | "images" + | "colors" + | "createdAt" + | "updatedAt" + | "managerId" + | "members" + | "channels" + | "roles" + | "region" + | "emojis" + | "onDiscover" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "roles"; + update: { + color?: string; + createdAt?: number; + flags?: Array; + hoist?: boolean; + icon?: string; + members?: Array; + mentionable?: boolean; + name?: string; + nestId?: string; + permissions?: Array; + position?: number; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "nestId" + | "name" + | "color" + | "hoist" + | "mentionable" + | "icon" + | "position" + | "permissions" + | "flags" + | "createdAt" + | "updatedAt" + | "members" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "channels"; + update: { + createdAt?: number; + name?: string; + nestId?: string; + overwrites?: Array<{ + allow: Array | null; + deny: Array | null; + id: string | string; + }>; + permissions?: Array; + position?: number; + type?: "text" | "category" | "announcement"; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "nestId" + | "position" + | "permissions" + | "overwrites" + | "createdAt" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } | { model: "messages"; update: { @@ -1973,13 +2638,23 @@ export declare const components: { | { model: "olmAccount"; update: { + createdAt?: number; identityKey?: { curve25519: string; ed25519: string }; + keyVersion?: number; oneTimeKeys?: Array<{ keyId: string; publicKey: string }>; + updatedAt?: number; userId?: string; }; where?: Array<{ connector?: "AND" | "OR"; - field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; + field: + | "userId" + | "identityKey" + | "oneTimeKeys" + | "createdAt" + | "updatedAt" + | "keyVersion" + | "_id"; operator?: | "lt" | "lte" @@ -2006,6 +2681,12 @@ export declare const components: { any >; }; + nests: { + locals: { + getRecommendedNests: FunctionReference<"query", "internal", any, any>; + getUserNests: FunctionReference<"query", "internal", any, any>; + }; + }; olm: { index: { consumeOTK: FunctionReference< @@ -2014,6 +2695,13 @@ export declare const components: { { keyId: string; userId: string }, any >; + getKeyVersion: FunctionReference< + "query", + "internal", + { userId: string }, + any + >; + migrateOlmAccounts: FunctionReference<"mutation", "internal", any, any>; retrieveServerOlmAccount: FunctionReference< "query", "internal", @@ -2041,8 +2729,15 @@ export declare const components: { { answer: "accept" | "decline" | "ignore"; requestId: string }, any >; + forceUserOffline: FunctionReference< + "mutation", + "internal", + { userId: string }, + any + >; getFriendRequests: FunctionReference<"query", "internal", any, any>; getFriends: FunctionReference<"query", "internal", any, any>; + getNonOfflineUserIds: FunctionReference<"query", "internal", {}, any>; getParticipantDetails: FunctionReference< "query", "internal", @@ -2066,7 +2761,7 @@ export declare const components: { "mutation", "internal", { - isUserSet: boolean; + isUserSet?: boolean; status: "online" | "busy" | "offline" | "away"; }, any diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts index fb12533..a850cc1 100644 --- a/convex/_generated/dataModel.d.ts +++ b/convex/_generated/dataModel.d.ts @@ -38,7 +38,7 @@ export type Doc = any; * Convex documents are uniquely identified by their `Id`, which is accessible * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * - * Documents can be loaded using `db.get(id)` in query and mutation functions. + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. diff --git a/convex/auth.ts b/convex/auth.ts index be36fe4..8db1a68 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -122,7 +122,7 @@ export const retrieveServerOlmAccount = query({ export const updateUserStatus = mutation({ args: { status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), - isUserSet: v.boolean(), + isUserSet: v.optional(v.boolean()), }, handler: async (ctx, args) => { return ctx.runMutation(components.betterAuth.user.index.updateUserStatus, { @@ -132,6 +132,24 @@ export const updateUserStatus = mutation({ }, }); +export const getNonOfflineUserIds = query({ + args: {}, + handler: async (ctx) => { + return ctx.runQuery(components.betterAuth.user.index.getNonOfflineUserIds, {}); + }, +}); + +export const forceUserOffline = mutation({ + args: { + userId: v.string(), + }, + handler: async (ctx, args) => { + return ctx.runMutation(components.betterAuth.user.index.forceUserOffline, { + userId: args.userId, + }); + }, +}); + export const updateUserMetadata = mutation({ args: { metadata: v.object({ @@ -212,4 +230,27 @@ export const consumeOTK = mutation({ keyId: args.keyId, }); }, +}); + +export const getKeyVersion = query({ + args: { + userId: v.string(), + }, + handler: async (ctx, args) => { + return ctx.runQuery(components.betterAuth.olm.index.getKeyVersion, { + userId: args.userId, + }); + }, +}); + +export const migrateOlmAccounts = mutation({ + handler: async (ctx) => { + return ctx.runMutation(components.betterAuth.olm.index.migrateOlmAccounts, {}); + }, +}); + +export const getUserNests = query({ + handler: async (ctx) => { + return ctx.runQuery(components.betterAuth.nests.locals.getUserNests); + }, }); \ No newline at end of file diff --git a/convex/betterAuth/_generated/api.ts b/convex/betterAuth/_generated/api.ts index 036a9b7..fcb8051 100644 --- a/convex/betterAuth/_generated/api.ts +++ b/convex/betterAuth/_generated/api.ts @@ -10,7 +10,9 @@ import type * as adapter from "../adapter.js"; import type * as auth from "../auth.js"; +import type * as nests_locals from "../nests/locals.js"; import type * as olm_index from "../olm/index.js"; +import type * as schemas_nests from "../schemas/nests.js"; import type * as schemas_user from "../schemas/user.js"; import type * as user_index from "../user/index.js"; @@ -24,7 +26,9 @@ import { anyApi, componentsGeneric } from "convex/server"; const fullApi: ApiFromModules<{ adapter: typeof adapter; auth: typeof auth; + "nests/locals": typeof nests_locals; "olm/index": typeof olm_index; + "schemas/nests": typeof schemas_nests; "schemas/user": typeof schemas_user; "user/index": typeof user_index; }> = anyApi as any; diff --git a/convex/betterAuth/_generated/component.ts b/convex/betterAuth/_generated/component.ts index c5c49be..dba0421 100644 --- a/convex/betterAuth/_generated/component.ts +++ b/convex/betterAuth/_generated/component.ts @@ -40,6 +40,7 @@ export type ComponentApi = phrasePreference: "comforting" | "mocking" | "both"; }; name: string; + nests?: Array; updatedAt: number; userId?: null | string; username?: null | string; @@ -48,10 +49,14 @@ export type ComponentApi = } | { data: { - isUserSet: boolean; status: "online" | "busy" | "offline" | "away"; updatedAt: number; userId: string; + userSetStatus?: { + isSet: boolean; + status: "online" | "busy" | "offline" | "away"; + updatedAt: number; + }; }; model: "userStatus"; } @@ -73,6 +78,63 @@ export type ComponentApi = data: { createdAt: number; friendId: string; userId: string }; model: "friends"; } + | { + data: { + channels: Array; + colors?: { accent: string; primary: string }; + createdAt: number; + description?: string; + emojis: Array<{ + createdAt: number; + id: string; + name: string; + }>; + images?: { banner: string; icon: string }; + managerId: string; + members: Array; + name: string; + onDiscover?: boolean; + region?: string; + roles: Array; + type: "global" | "regional" | "private"; + updatedAt: number; + }; + model: "nests"; + } + | { + data: { + color?: string; + createdAt: number; + flags: Array; + hoist?: boolean; + icon?: string; + members: Array; + mentionable?: boolean; + name: string; + nestId: string; + permissions: Array; + position?: number; + updatedAt: number; + }; + model: "roles"; + } + | { + data: { + createdAt: number; + name: string; + nestId: string; + overwrites: Array<{ + allow: Array | null; + deny: Array | null; + id: string | string; + }>; + permissions: Array; + position: number; + type: "text" | "category" | "announcement"; + updatedAt: number; + }; + model: "channels"; + } | { data: { attachments?: Array; @@ -155,8 +217,11 @@ export type ComponentApi = } | { data: { + createdAt?: number; identityKey: { curve25519: string; ed25519: string }; + keyVersion?: number; oneTimeKeys: Array<{ keyId: string; publicKey: string }>; + updatedAt?: number; userId: string; }; model: "olmAccount"; @@ -187,6 +252,7 @@ export type ComponentApi = | "username" | "displayUsername" | "metadata" + | "nests" | "_id"; operator?: | "lt" @@ -216,7 +282,7 @@ export type ComponentApi = field: | "userId" | "status" - | "isUserSet" + | "userSetStatus" | "updatedAt" | "_id"; operator?: @@ -302,6 +368,121 @@ export type ComponentApi = | null; }>; } + | { + model: "nests"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "description" + | "images" + | "colors" + | "createdAt" + | "updatedAt" + | "managerId" + | "members" + | "channels" + | "roles" + | "region" + | "emojis" + | "onDiscover" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "roles"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "nestId" + | "name" + | "color" + | "hoist" + | "mentionable" + | "icon" + | "position" + | "permissions" + | "flags" + | "createdAt" + | "updatedAt" + | "members" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "channels"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "nestId" + | "position" + | "permissions" + | "overwrites" + | "createdAt" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } | { model: "messages"; where?: Array<{ @@ -514,7 +695,14 @@ export type ComponentApi = model: "olmAccount"; where?: Array<{ connector?: "AND" | "OR"; - field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; + field: + | "userId" + | "identityKey" + | "oneTimeKeys" + | "createdAt" + | "updatedAt" + | "keyVersion" + | "_id"; operator?: | "lt" | "lte" @@ -569,6 +757,7 @@ export type ComponentApi = | "username" | "displayUsername" | "metadata" + | "nests" | "_id"; operator?: | "lt" @@ -598,7 +787,7 @@ export type ComponentApi = field: | "userId" | "status" - | "isUserSet" + | "userSetStatus" | "updatedAt" | "_id"; operator?: @@ -684,6 +873,121 @@ export type ComponentApi = | null; }>; } + | { + model: "nests"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "description" + | "images" + | "colors" + | "createdAt" + | "updatedAt" + | "managerId" + | "members" + | "channels" + | "roles" + | "region" + | "emojis" + | "onDiscover" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "roles"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "nestId" + | "name" + | "color" + | "hoist" + | "mentionable" + | "icon" + | "position" + | "permissions" + | "flags" + | "createdAt" + | "updatedAt" + | "members" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "channels"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "nestId" + | "position" + | "permissions" + | "overwrites" + | "createdAt" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } | { model: "messages"; where?: Array<{ @@ -896,7 +1200,14 @@ export type ComponentApi = model: "olmAccount"; where?: Array<{ connector?: "AND" | "OR"; - field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; + field: + | "userId" + | "identityKey" + | "oneTimeKeys" + | "createdAt" + | "updatedAt" + | "keyVersion" + | "_id"; operator?: | "lt" | "lte" @@ -934,6 +1245,9 @@ export type ComponentApi = | "userStatus" | "friendRequests" | "friends" + | "nests" + | "roles" + | "channels" | "messages" | "attachments" | "session" @@ -988,6 +1302,9 @@ export type ComponentApi = | "userStatus" | "friendRequests" | "friends" + | "nests" + | "roles" + | "channels" | "messages" | "attachments" | "session" @@ -1040,6 +1357,7 @@ export type ComponentApi = phrasePreference: "comforting" | "mocking" | "both"; }; name?: string; + nests?: Array; updatedAt?: number; userId?: null | string; username?: null | string; @@ -1057,6 +1375,7 @@ export type ComponentApi = | "username" | "displayUsername" | "metadata" + | "nests" | "_id"; operator?: | "lt" @@ -1082,17 +1401,21 @@ export type ComponentApi = | { model: "userStatus"; update: { - isUserSet?: boolean; status?: "online" | "busy" | "offline" | "away"; updatedAt?: number; userId?: string; + userSetStatus?: { + isSet: boolean; + status: "online" | "busy" | "offline" | "away"; + updatedAt: number; + }; }; where?: Array<{ connector?: "AND" | "OR"; field: | "userId" | "status" - | "isUserSet" + | "userSetStatus" | "updatedAt" | "_id"; operator?: @@ -1194,6 +1517,169 @@ export type ComponentApi = | null; }>; } + | { + model: "nests"; + update: { + channels?: Array; + colors?: { accent: string; primary: string }; + createdAt?: number; + description?: string; + emojis?: Array<{ + createdAt: number; + id: string; + name: string; + }>; + images?: { banner: string; icon: string }; + managerId?: string; + members?: Array; + name?: string; + onDiscover?: boolean; + region?: string; + roles?: Array; + type?: "global" | "regional" | "private"; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "description" + | "images" + | "colors" + | "createdAt" + | "updatedAt" + | "managerId" + | "members" + | "channels" + | "roles" + | "region" + | "emojis" + | "onDiscover" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "roles"; + update: { + color?: string; + createdAt?: number; + flags?: Array; + hoist?: boolean; + icon?: string; + members?: Array; + mentionable?: boolean; + name?: string; + nestId?: string; + permissions?: Array; + position?: number; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "nestId" + | "name" + | "color" + | "hoist" + | "mentionable" + | "icon" + | "position" + | "permissions" + | "flags" + | "createdAt" + | "updatedAt" + | "members" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "channels"; + update: { + createdAt?: number; + name?: string; + nestId?: string; + overwrites?: Array<{ + allow: Array | null; + deny: Array | null; + id: string | string; + }>; + permissions?: Array; + position?: number; + type?: "text" | "category" | "announcement"; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "nestId" + | "position" + | "permissions" + | "overwrites" + | "createdAt" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } | { model: "messages"; update: { @@ -1467,13 +1953,23 @@ export type ComponentApi = | { model: "olmAccount"; update: { + createdAt?: number; identityKey?: { curve25519: string; ed25519: string }; + keyVersion?: number; oneTimeKeys?: Array<{ keyId: string; publicKey: string }>; + updatedAt?: number; userId?: string; }; where?: Array<{ connector?: "AND" | "OR"; - field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; + field: + | "userId" + | "identityKey" + | "oneTimeKeys" + | "createdAt" + | "updatedAt" + | "keyVersion" + | "_id"; operator?: | "lt" | "lte" @@ -1525,6 +2021,7 @@ export type ComponentApi = phrasePreference: "comforting" | "mocking" | "both"; }; name?: string; + nests?: Array; updatedAt?: number; userId?: null | string; username?: null | string; @@ -1542,6 +2039,7 @@ export type ComponentApi = | "username" | "displayUsername" | "metadata" + | "nests" | "_id"; operator?: | "lt" @@ -1567,17 +2065,21 @@ export type ComponentApi = | { model: "userStatus"; update: { - isUserSet?: boolean; status?: "online" | "busy" | "offline" | "away"; updatedAt?: number; userId?: string; + userSetStatus?: { + isSet: boolean; + status: "online" | "busy" | "offline" | "away"; + updatedAt: number; + }; }; where?: Array<{ connector?: "AND" | "OR"; field: | "userId" | "status" - | "isUserSet" + | "userSetStatus" | "updatedAt" | "_id"; operator?: @@ -1679,6 +2181,169 @@ export type ComponentApi = | null; }>; } + | { + model: "nests"; + update: { + channels?: Array; + colors?: { accent: string; primary: string }; + createdAt?: number; + description?: string; + emojis?: Array<{ + createdAt: number; + id: string; + name: string; + }>; + images?: { banner: string; icon: string }; + managerId?: string; + members?: Array; + name?: string; + onDiscover?: boolean; + region?: string; + roles?: Array; + type?: "global" | "regional" | "private"; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "description" + | "images" + | "colors" + | "createdAt" + | "updatedAt" + | "managerId" + | "members" + | "channels" + | "roles" + | "region" + | "emojis" + | "onDiscover" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "roles"; + update: { + color?: string; + createdAt?: number; + flags?: Array; + hoist?: boolean; + icon?: string; + members?: Array; + mentionable?: boolean; + name?: string; + nestId?: string; + permissions?: Array; + position?: number; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "nestId" + | "name" + | "color" + | "hoist" + | "mentionable" + | "icon" + | "position" + | "permissions" + | "flags" + | "createdAt" + | "updatedAt" + | "members" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "channels"; + update: { + createdAt?: number; + name?: string; + nestId?: string; + overwrites?: Array<{ + allow: Array | null; + deny: Array | null; + id: string | string; + }>; + permissions?: Array; + position?: number; + type?: "text" | "category" | "announcement"; + updatedAt?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "type" + | "name" + | "nestId" + | "position" + | "permissions" + | "overwrites" + | "createdAt" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } | { model: "messages"; update: { @@ -1952,13 +2617,23 @@ export type ComponentApi = | { model: "olmAccount"; update: { + createdAt?: number; identityKey?: { curve25519: string; ed25519: string }; + keyVersion?: number; oneTimeKeys?: Array<{ keyId: string; publicKey: string }>; + updatedAt?: number; userId?: string; }; where?: Array<{ connector?: "AND" | "OR"; - field: "userId" | "identityKey" | "oneTimeKeys" | "_id"; + field: + | "userId" + | "identityKey" + | "oneTimeKeys" + | "createdAt" + | "updatedAt" + | "keyVersion" + | "_id"; operator?: | "lt" | "lte" @@ -1986,6 +2661,18 @@ export type ComponentApi = Name >; }; + nests: { + locals: { + getRecommendedNests: FunctionReference< + "query", + "internal", + any, + any, + Name + >; + getUserNests: FunctionReference<"query", "internal", any, any, Name>; + }; + }; olm: { index: { consumeOTK: FunctionReference< @@ -1995,6 +2682,20 @@ export type ComponentApi = any, Name >; + getKeyVersion: FunctionReference< + "query", + "internal", + { userId: string }, + any, + Name + >; + migrateOlmAccounts: FunctionReference< + "mutation", + "internal", + any, + any, + Name + >; retrieveServerOlmAccount: FunctionReference< "query", "internal", @@ -2025,6 +2726,13 @@ export type ComponentApi = any, Name >; + forceUserOffline: FunctionReference< + "mutation", + "internal", + { userId: string }, + any, + Name + >; getFriendRequests: FunctionReference< "query", "internal", @@ -2033,6 +2741,13 @@ export type ComponentApi = Name >; getFriends: FunctionReference<"query", "internal", any, any, Name>; + getNonOfflineUserIds: FunctionReference< + "query", + "internal", + {}, + any, + Name + >; getParticipantDetails: FunctionReference< "query", "internal", @@ -2059,7 +2774,7 @@ export type ComponentApi = "mutation", "internal", { - isUserSet: boolean; + isUserSet?: boolean; status: "online" | "busy" | "offline" | "away"; }, any, diff --git a/convex/betterAuth/_generated/dataModel.ts b/convex/betterAuth/_generated/dataModel.ts index 8541f31..f97fd19 100644 --- a/convex/betterAuth/_generated/dataModel.ts +++ b/convex/betterAuth/_generated/dataModel.ts @@ -38,7 +38,7 @@ export type Doc = DocumentByName< * Convex documents are uniquely identified by their `Id`, which is accessible * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). * - * Documents can be loaded using `db.get(id)` in query and mutation functions. + * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. diff --git a/convex/betterAuth/_generated/server.ts b/convex/betterAuth/_generated/server.ts index 24994e4..739b02f 100644 --- a/convex/betterAuth/_generated/server.ts +++ b/convex/betterAuth/_generated/server.ts @@ -107,11 +107,6 @@ export const internalAction: ActionBuilder = */ export const httpAction: HttpActionBuilder = httpActionGeneric; -type GenericCtx = - | GenericActionCtx - | GenericMutationCtx - | GenericQueryCtx; - /** * A set of services for use within Convex query functions. * diff --git a/convex/betterAuth/nests/locals.ts b/convex/betterAuth/nests/locals.ts new file mode 100644 index 0000000..49765af --- /dev/null +++ b/convex/betterAuth/nests/locals.ts @@ -0,0 +1,60 @@ +import { UserIdentity } from "convex/server"; +import { Doc, Id } from "../_generated/dataModel"; +import { MutationCtx, query, QueryCtx } from "../_generated/server"; + +// Overload signatures +async function userValidation(ctx: MutationCtx | QueryCtx, options: { required: false }): Promise<{ userId: Id<"user">; user: any } | null>; +async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required?: true }): Promise<{ userId: Id<"user">; user: UserIdentity }>; + +// Implementation +async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required?: boolean }) { + const required = options?.required ?? true; + + const user = await ctx.auth.getUserIdentity(); + if (!user) { + if (required) throw new Error("User not found"); + return null; + } + + const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">; + if (!userId) { + if (required) throw new Error("User not found"); + return null; + } + + return { userId, user }; +} + +export const getUserNests = query({ + handler: async (ctx) => { + const { userId } = await userValidation(ctx, { required: true }); + if (!userId) throw new Error("User not found"); + + const getUser = await ctx.db.get<"user">(userId); + if (!getUser) throw new Error("User not found"); + else if (!getUser.nests || getUser.nests.length === 0) return []; + + // Get the nests the user is a member of + const nests: Doc<"nests">[] = []; + for (const nestId of getUser.nests) { + const nest = await ctx.db.get<"nests">(nestId); + if (!nest) continue; + nests.push(nest); + } + + return nests; + } +}); + +export const getRecommendedNests = query({ + handler: async (ctx) => { + const { userId } = await userValidation(ctx, { required: true }); + if (!userId) throw new Error("User not found"); + + const getUser = await ctx.db.get<"user">(userId); + if (!getUser) throw new Error("User not found"); + + const nests = await ctx.db.query<"nests">("nests").withIndex("onDiscover", q => q.eq("onDiscover", true)).collect(); + return nests; + } +}); \ No newline at end of file diff --git a/convex/betterAuth/olm/index.ts b/convex/betterAuth/olm/index.ts index de555d7..442eb7d 100644 --- a/convex/betterAuth/olm/index.ts +++ b/convex/betterAuth/olm/index.ts @@ -1,5 +1,4 @@ import { v } from "convex/values"; -import { Id } from "../../_generated/dataModel"; import { mutation, query } from "../_generated/server"; export const sendKeysToServer = mutation({ @@ -16,22 +15,40 @@ export const sendKeysToServer = mutation({ forceInsert: v.boolean(), // if true, insert even if user already has an olm account }, handler: async (ctx, args) => { + const now = Date.now(); // check if user already has an olm account const olmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", args.userId)).first(); if (olmAccount && !args.forceInsert) { throw new Error("User already has an olm account"); + } else if (olmAccount && args.forceInsert) { + // Keys are being rotated - increment version and update timestamp + await ctx.db.patch(olmAccount._id, { + identityKey: args.identityKey, + oneTimeKeys: args.oneTimeKeys, + updatedAt: now, + keyVersion: (olmAccount.keyVersion || 0) + 1, + }); + + // Notify all users who have sessions with this user that their sessions are now invalid + // This will be handled client-side by checking key versions + console.log(`[OLM] Keys rotated for user ${args.userId}, new version: ${(olmAccount.keyVersion || 0) + 1}`); + + return { ...olmAccount, keyVersion: (olmAccount.keyVersion || 0) + 1 }; } - const insert = await ctx.db.insert<"olmAccount">("olmAccount", { + // Create new account with initial key version + const newOlmAccount = await ctx.db.insert<"olmAccount">("olmAccount", { userId: args.userId, identityKey: args.identityKey, - oneTimeKeys: args.oneTimeKeys, + oneTimeKeys: args.oneTimeKeys || [], + createdAt: now, + updatedAt: now, + keyVersion: 1, }); - console.log("insert", insert); - return insert; + return newOlmAccount; }, }); @@ -40,10 +57,16 @@ export const retrieveServerOlmAccount = query({ userId: v.string(), }, handler: async (ctx, args) => { - const olmAccount = await ctx.db.get<"olmAccount">(args.userId as Id<"olmAccount">); - if (olmAccount) return olmAccount; + const olmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", args.userId)).first(); + if (!olmAccount) return null; - return null; + // Ensure backward compatibility with old records that don't have keyVersion + return { + ...olmAccount, + keyVersion: olmAccount.keyVersion ?? 1, + createdAt: olmAccount.createdAt ?? olmAccount._creationTime, + updatedAt: olmAccount.updatedAt ?? olmAccount._creationTime, + }; }, }); @@ -72,4 +95,49 @@ export const consumeOTK = mutation({ keysLeft: oneTimeKeys.length } }, -}) \ No newline at end of file +}); + +export const getKeyVersion = query({ + args: { + userId: v.string(), + }, + handler: async (ctx, args) => { + const olmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", args.userId)).first(); + if (!olmAccount) return null; + + return { + keyVersion: olmAccount.keyVersion ?? 1, + updatedAt: olmAccount.updatedAt ?? olmAccount._creationTime, + identityKey: olmAccount.identityKey, + }; + }, +}); + +/** + * Migration mutation to add keyVersion, createdAt, updatedAt to existing olmAccount records + * Run this once to migrate old records + */ +export const migrateOlmAccounts = mutation({ + handler: async (ctx) => { + const accounts = await ctx.db.query("olmAccount").collect(); + let updated = 0; + + for (const account of accounts) { + // Only update if keyVersion is missing + if (account.keyVersion === undefined) { + await ctx.db.patch(account._id, { + keyVersion: 1, // Initial version for existing accounts + createdAt: account.createdAt ?? account._creationTime, + updatedAt: account.updatedAt ?? account._creationTime, + }); + updated++; + } + } + + return { + message: `Migrated ${updated} olmAccount records`, + total: accounts.length, + updated + }; + }, +}); \ No newline at end of file diff --git a/convex/betterAuth/schema.ts b/convex/betterAuth/schema.ts index 2047f57..e3a7ad8 100644 --- a/convex/betterAuth/schema.ts +++ b/convex/betterAuth/schema.ts @@ -4,6 +4,7 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +import { nests } from "./schemas/nests"; import { user } from "./schemas/user"; const Attachment = v.object({ @@ -39,7 +40,12 @@ const Message = v.object({ export const tables = { ...user, - messages: defineTable(Message), + ...nests, + messages: defineTable(Message) + .index("channelId", ["channelId"]) + .index("channelId_createdTimestamp", ["channelId", "createdTimestamp"]) + .index("authorId", ["authorId"]) + .index("guildId", ["guildId"]), attachments: defineTable(Attachment), session: defineTable({ expiresAt: v.number(), @@ -96,6 +102,9 @@ export const tables = { keyId: v.string(), publicKey: v.string(), })), + createdAt: v.optional(v.number()), + updatedAt: v.optional(v.number()), + keyVersion: v.optional(v.number()), // Increments when keys are rotated }) .index("userId", ["userId"]) .index("userId_keys", ["userId", "oneTimeKeys"]) diff --git a/convex/betterAuth/schemas/nests.ts b/convex/betterAuth/schemas/nests.ts index c2221f9..7081ba8 100644 --- a/convex/betterAuth/schemas/nests.ts +++ b/convex/betterAuth/schemas/nests.ts @@ -6,10 +6,12 @@ export const nests = { type: v.union(v.literal("global"), v.literal("regional"), v.literal("private")), name: v.string(), description: v.optional(v.string()), - images: v.object({ - banner: v.id("storage"), - icon: v.id("storage"), - }), + images: v.optional( + v.object({ + banner: v.id("storage"), + icon: v.id("storage"), + }) + ), colors: v.optional( v.object({ primary: v.string(), @@ -28,11 +30,13 @@ export const nests = { name: v.string(), createdAt: v.number(), })), + onDiscover: v.optional(v.boolean()), }) .index("managerId", ["managerId"]) .index("type", ["type"]) .index("type_region", ["type", "region"]) - .index("createdAt", ["createdAt"]), + .index("createdAt", ["createdAt"]) + .index("onDiscover", ["onDiscover"]), roles: defineTable({ nestId: v.id("nests"), name: v.string(), @@ -45,9 +49,12 @@ export const nests = { flags: v.array(v.int64()), // Flags as bitfield createdAt: v.number(), updatedAt: v.number(), + members: v.array(v.id("user")), }) .index("nestId", ["nestId"]) - .index("nestId_position", ["nestId", "position"]), + .index("nestId_position", ["nestId", "position"]) + .index("nestId_members", ["nestId", "members"]) + .index("members", ["members"]), channels: defineTable({ type: v.union(v.literal("text"), v.literal("category"), v.literal("announcement")), name: v.string(), diff --git a/convex/betterAuth/schemas/user.ts b/convex/betterAuth/schemas/user.ts index d4aa349..1ae71dd 100644 --- a/convex/betterAuth/schemas/user.ts +++ b/convex/betterAuth/schemas/user.ts @@ -15,15 +15,23 @@ export const user = { metadata: v.optional(v.object({ phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")), })), + nests: v.optional(v.array(v.id("nests"))), }) .index("email_name", ["email", "name"]) + .index("nests", ["nests"]) .index("byName", ["name"]) .index("userId", ["userId"]) .index("username", ["username"]), userStatus: defineTable({ userId: v.id("user"), status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), - isUserSet: v.boolean(), + userSetStatus: v.optional( + v.object({ + status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), + updatedAt: v.number(), + isSet: v.boolean(), + }) + ), updatedAt: v.number(), }) .index("userId", ["userId"]) @@ -42,6 +50,7 @@ export const user = { .index("userId_method", ["userId", "method"]) .index("userId", ["userId"]) .index("requestId", ["requestId"]) + .index("userId_requestTo", ["userId", "requestTo"]) .index("requestTo", ["requestTo"]) .index("expiresAt", ["expiresAt"]), friends: defineTable({ diff --git a/convex/betterAuth/user/index.ts b/convex/betterAuth/user/index.ts index 599f035..30b2931 100644 --- a/convex/betterAuth/user/index.ts +++ b/convex/betterAuth/user/index.ts @@ -28,25 +28,38 @@ async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required? export const updateUserStatus = mutation({ args: { status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), - isUserSet: v.boolean(), + isUserSet: v.optional(v.boolean()), }, handler: async (ctx, args) => { try { const { userId } = await userValidation(ctx); + const isUserSet = args.isUserSet ?? false; - // Check if user status is already set const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first(); if (userStatus) { + let resolvedStatus = args.status; + + // Restore user-set status when reconnecting + if (args.status === "online" && !isUserSet && userStatus.userSetStatus?.isSet) { + resolvedStatus = userStatus.userSetStatus.status; + } + await ctx.db.patch(userStatus._id, { - status: args.status, - isUserSet: args.isUserSet, + status: resolvedStatus, + userSetStatus: isUserSet + ? { status: args.status, updatedAt: Date.now(), isSet: true } + : userStatus.userSetStatus, updatedAt: Date.now(), }); } else { await ctx.db.insert("userStatus", { userId: userId, status: args.status, - isUserSet: false, + userSetStatus: { + status: args.status, + updatedAt: Date.now(), + isSet: isUserSet, + }, updatedAt: Date.now(), }); } @@ -71,6 +84,59 @@ export const getUserStatus = query({ } }); +export const getNonOfflineUserIds = query({ + args: {}, + handler: async (ctx) => { + const results: { userId: string; status: string; isUserSet: boolean }[] = []; + + for (const status of ["online", "busy", "away"] as const) { + const records = await ctx.db + .query("userStatus") + .withIndex("status", (q) => q.eq("status", status)) + .collect(); + + for (const record of records) { + results.push({ + userId: record.userId, + status: record.status, + isUserSet: record.userSetStatus?.isSet ?? false, + }); + } + } + + return results; + }, +}); + +export const forceUserOffline = mutation({ + args: { + userId: v.string(), + }, + handler: async (ctx, args) => { + const normalizedId = ctx.db.normalizeId("user", args.userId); + if (!normalizedId) return; + + const userStatus = await ctx.db + .query("userStatus") + .withIndex("userId", (q) => q.eq("userId", normalizedId)) + .first(); + + if (!userStatus || userStatus.status === "offline") return; + + await ctx.db.patch(userStatus._id, { + status: "offline", + userSetStatus: userStatus.userSetStatus ? { + status: userStatus.userSetStatus.status, + updatedAt: Date.now(), + isSet: userStatus.userSetStatus.isSet, + } : undefined, + updatedAt: Date.now(), + }); + + console.log(`[forceUserOffline] Set user ${args.userId} offline (was: ${userStatus.status})`); + }, +}); + export const updateUserMetadata = mutation({ args: { metadata: v.object({ @@ -320,7 +386,7 @@ export const getFriends = query({ friendshipCreatedAt: friendship.createdAt, status: friendStatus ? { status: friendStatus.status, - isUserSet: friendStatus.isUserSet, + isUserSet: friendStatus.userSetStatus?.isSet ?? false, } : { status: "offline" as const, isUserSet: false, @@ -356,6 +422,14 @@ export const getParticipantDetails = query({ const participantOlmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", id)).first(); if (!participant) return null; + // Ensure backward compatibility with old olmAccount records + const olmAccountWithDefaults = participantOlmAccount ? { + ...participantOlmAccount, + keyVersion: participantOlmAccount.keyVersion ?? 1, + createdAt: participantOlmAccount.createdAt ?? participantOlmAccount._creationTime, + updatedAt: participantOlmAccount.updatedAt ?? participantOlmAccount._creationTime, + } : null; + return { id: participant._id, name: participant.name, @@ -363,7 +437,7 @@ export const getParticipantDetails = query({ displayUsername: participant.displayUsername, image: participant.image, status: participantStatus?.status || "offline", - olmAccount: participantOlmAccount, + olmAccount: olmAccountWithDefaults, } })); diff --git a/package.json b/package.json index f3805c1..2734133 100644 --- a/package.json +++ b/package.json @@ -5,12 +5,14 @@ "scripts": { "postinstall": "bun src/lib/scripts/copy-olm.ts", "dev": "cross-env NODE_ENV=development PORT=3000 tsx src/server.ts", - "build": "bun src/lib/scripts/copy-olm.ts && convex deploy --cmd \"bun run build\"", - "start": "cross-env NODE_ENV=production PORT=8081 tsx src/server.ts" + "build": "bun src/lib/scripts/copy-olm.ts && convex deploy --cmd \"next build\"", + "build:local": "bun src/lib/scripts/copy-olm.ts && next build", + "start": "cross-env NODE_ENV=production PORT=8081 tsx src/server.ts", + "start:dev": "cross-env NODE_ENV=development PORT=3000 tsx src/server.ts" }, "dependencies": { "@convex-dev/better-auth": "^0.10.10", - "@marsidev/react-turnstile": "^1.4.1", + "@marsidev/react-turnstile": "^1.4.2", "@matrix-org/olm": "^3.2.15", "@nanostores/react": "^1.0.0", "@phosphor-icons/react": "^2.1.10", @@ -27,36 +29,37 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", - "@types/bun": "^1.3.6", + "@types/bun": "^1.3.9", "@types/libsodium-wrappers": "^0.7.14", "better-auth": "1.4.12", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "convex": "^1.31.4", + "convex": "^1.31.7", "cross-env": "^10.1.0", "date-fns": "^4.1.0", - "dexie": "^4.2.1", + "dexie": "^4.3.0", "dexie-react-hooks": "^4.2.0", - "framer-motion": "^12.26.2", + "dotenv": "^17.3.1", + "framer-motion": "^12.34.0", "lucide-react": "^0.562.0", "moment": "^2.30.1", "nanostores": "^1.1.0", "next": "16.1.1", "next-themes": "^0.4.6", "react": "19.2.3", - "react-day-picker": "^9.13.0", + "react-day-picker": "^9.13.1", "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", - "zod": "^4.3.5" + "zod": "^4.3.6" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.18", - "@types/node": "^25.0.8", - "@types/react": "^19.2.8", + "@types/node": "^25.2.2", + "@types/react": "^19.2.13", "@types/react-dom": "^19.2.3", "babel-plugin-react-compiler": "1.0.0", "tailwindcss": "^4.1.18", diff --git a/src/app/(app)/channels/me/friends/page.tsx b/src/app/(app)/channels/me/friends/page.tsx new file mode 100644 index 0000000..4ea2c68 --- /dev/null +++ b/src/app/(app)/channels/me/friends/page.tsx @@ -0,0 +1,3 @@ +export default function FriendsPage() { + return null; +} \ No newline at end of file diff --git a/src/app/(app)/channels/nests/global/page.tsx b/src/app/(app)/channels/nests/global/page.tsx new file mode 100644 index 0000000..64dcd07 --- /dev/null +++ b/src/app/(app)/channels/nests/global/page.tsx @@ -0,0 +1,3 @@ +export default function GlobalNestsPage() { + return null; +} \ No newline at end of file diff --git a/src/app/(app)/channels/servers/[serverId]/[channelId]/page.tsx b/src/app/(app)/channels/servers/[serverId]/[channelId]/page.tsx deleted file mode 100644 index 166b182..0000000 --- a/src/app/(app)/channels/servers/[serverId]/[channelId]/page.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export default function ServerChannelPage() { - return null; -} - diff --git a/src/app/(app)/discover/page.tsx b/src/app/(app)/discover/page.tsx new file mode 100644 index 0000000..23033f5 --- /dev/null +++ b/src/app/(app)/discover/page.tsx @@ -0,0 +1,3 @@ +export default function DiscoverPage() { + return null; +} \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 18c02d1..54c990b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { AutoRequestNotifications } from "@/components/notifications/NotificationSettings"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { getToken } from "@/lib/auth/auth-server"; @@ -49,6 +50,7 @@ export default async function RootLayout({ disableTransitionOnChange > {children} + diff --git a/src/components/app-container.tsx b/src/components/app-container.tsx index 23046b8..6ad4014 100644 --- a/src/components/app-container.tsx +++ b/src/components/app-container.tsx @@ -15,27 +15,59 @@ import { useCallback, useEffect, useMemo } from "react"; import { api } from "../../convex/_generated/api"; import OlmPasswordDialog from "./olm/olm-password-dialog"; +type RouteParams = Record; + +type RouteMatcher = { + path?: string; + pattern?: RegExp; + type: SiPher.PageTypes; + extract?: (match: RegExpMatchArray, params: RouteParams) => Partial; +}; + +const routes: RouteMatcher[] = [ + { path: '/channels/me/friends', type: 'friends' }, + { path: '/discover', type: 'discover' }, + { path: '/support', type: 'support' }, + { path: '/channels/nests/global', type: 'global-nests' }, + { + pattern: /^\/channels\/me\/(.+)$/, + type: 'dm', + extract: (_, params) => ({ + dmChannelId: params.id ? decodeURIComponent(params.id as string) : undefined + }) + }, + { + pattern: /^\/channels\/servers\/(.+)$/, + type: 'server', + extract: (_, params) => ({ + serverId: params.serverId ? decodeURIComponent(params.serverId as string) : undefined, + serverChannelId: params.channelId ? decodeURIComponent(params.channelId as string) : undefined + }) + }, +]; + function AppContainerContent() { const pathname = usePathname(); const params = useParams(); - // Detect route type and extract params from URL - const routeInfo = useMemo(() => { - if (pathname.startsWith('/channels/me/')) { - return { - type: 'dm' as const, - // Decode URL-encoded params (dm%3A... becomes dm:...) - dmChannelId: params.id ? decodeURIComponent(params.id as string) : undefined - }; + const routeInfo: SiPher.RouteInfo = useMemo(() => { + for (const route of routes) { + if (route.path && pathname === route.path) { + return { type: route.type }; + } + + if (route.pattern) { + const match = pathname.match(route.pattern); + if (match) { + return { + type: route.type, + ...route.extract?.(match, params) + }; + } + } } - if (pathname.startsWith('/channels/servers/')) { - return { - type: 'server' as const, - serverId: params.serverId ? decodeURIComponent(params.serverId as string) : undefined, - serverChannelId: params.channelId ? decodeURIComponent(params.channelId as string) : undefined - }; - } - return { type: 'home' as const }; + + return { type: 'friends' }; }, [pathname, params]); const { data } = authClient.useSession(); @@ -47,6 +79,7 @@ function AppContainerContent() { const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmContext(); const updateUserMetadata = useMutation(api.auth.updateUserMetadata); + const userNests = useQuery(api.auth.getUserNests); useEffect(() => { if (!data) return; @@ -82,15 +115,15 @@ function AppContainerContent() { socketInfo={socketInfo} disconnectSocket={disconnect} connectSocket={connect} + routeInfo={routeInfo} > diff --git a/src/components/home/index.tsx b/src/components/home/index.tsx index a59b1dd..b6a0bc5 100644 --- a/src/components/home/index.tsx +++ b/src/components/home/index.tsx @@ -10,8 +10,9 @@ import { SidebarMenuItem, SidebarProvider } from "@/components/ui/sidebar"; -import { CompassIcon, HouseIcon } from "@phosphor-icons/react"; +import { CompassIcon, GlobeIcon, HouseIcon } from "@phosphor-icons/react"; import { Plus } from "lucide-react"; +import { useRouter } from "next/navigation"; import { useState } from "react"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Separator } from "../ui/separator"; @@ -19,10 +20,17 @@ import ConnectionStatusIndicator from "./csi"; import SidebarIcon from "./sicons"; const SidebarItems: SiPher.SidebarItem[] = [ + { + id: "global-nests", + icon: , + label: "Global Nest", + href: "/channels/nests/global" + }, { id: "discover", icon: , - label: "Discover" + label: "Discover", + href: "/discover" } ]; @@ -31,8 +39,9 @@ const SidebarItems: SiPher.SidebarItem[] = [ * It also is the controller for everything on the app, including going to other pages, showing conversations and other. * @param children - The children to be rendered in the sidebar inset */ -export default function AppSidebar({ children, socketStatus, socketInfo, currentChannel, disconnectSocket, connectSocket }: SiPher.AppSidebarProps) { +export default function AppSidebar({ children, socketStatus, socketInfo, currentChannel, disconnectSocket, connectSocket, routeInfo }: SiPher.AppSidebarProps) { const [activeItem, setActiveItem] = useState("home"); + const router = useRouter(); return ( {SidebarItems.map((item) => ( - + { + if (item.href) { + router.push(item.href); + } + }}> setActiveItem(item.id)} > diff --git a/src/components/notifications/NotificationSettings.tsx b/src/components/notifications/NotificationSettings.tsx new file mode 100644 index 0000000..eefa0ad --- /dev/null +++ b/src/components/notifications/NotificationSettings.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { requestNotificationPermission } from "@/lib/notifications"; +import { Bell, BellOff } from "lucide-react"; +import { useEffect, useState } from "react"; +import { Button } from "../ui/button"; + +export function NotificationSettings({ userStatus }: { userStatus: "online" | "busy" | "offline" | "away" }) { + const [permission, setPermission] = useState("default"); + const [isLoading, setIsLoading] = useState(false); + + useEffect(() => { + if ("Notification" in window) { + setPermission(Notification.permission); + } + }, []); + + const handleRequestPermission = async () => { + setIsLoading(true); + try { + const newPermission = await requestNotificationPermission(); + setPermission(newPermission); + + if (newPermission === "granted") { + if (userStatus === "busy") return; + // Show a test notification + new Notification("Notifications enabled!", { + body: "You'll now receive message notifications", + icon: "/logo.png", + }); + } + } catch (error) { + console.error("Failed to request notification permission:", error); + } finally { + setIsLoading(false); + } + }; + + if (!("Notification" in window)) { + return null; // Browser doesn't support notifications + } + + if (permission === "granted") { + return ( +
+ + Notifications enabled +
+ ); + } + + if (permission === "denied") { + return ( +
+ + Notifications blocked. Enable in browser settings. +
+ ); + } + + return ( + + ); +} + +/** + * Auto-request notification permission component + * Place in your app layout to automatically request permission on load + */ +export function AutoRequestNotifications() { + useEffect(() => { + if ("Notification" in window && Notification.permission === "default") { + // Auto-request permission after a short delay (to not interrupt page load) + const timer = setTimeout(async () => { + try { + const permission = await requestNotificationPermission(); + if (permission === "granted") { + console.log("[Notifications] Permission granted automatically"); + } + } catch (error) { + console.debug("[Notifications] Auto-request failed or was dismissed:", error); + } + }, 2000); // Wait 2 seconds after page load + + return () => clearTimeout(timer); + } + }, []); + + return null; // This component doesn't render anything +} diff --git a/src/components/ui/dm/DmChannelContent.tsx b/src/components/ui/dm/DmChannelContent.tsx index d179dea..8dae4b5 100644 --- a/src/components/ui/dm/DmChannelContent.tsx +++ b/src/components/ui/dm/DmChannelContent.tsx @@ -1,6 +1,7 @@ import { useOlmContext } from "@/contexts/olm-context"; import { useSocketContext } from "@/contexts/socket-context"; import { clearUnread, db, sendMessage } from "@/lib/db"; +import { setActiveChannel } from "@/lib/notifications"; import { useLiveQuery } from "dexie-react-hooks"; import { KeyRound, SendIcon } from "lucide-react"; import moment from "moment"; @@ -111,10 +112,21 @@ export default function DMChannelContent( } }, [allMessages.length]); - // Clear unread count when entering the channel + // Set active channel and clear unread count when viewing this channel useEffect(() => { + // Mark this channel as active (prevents notifications) + setActiveChannel(channelId); + + // Clear any existing unread count clearUnread(channelId); - console.debug("[DMChannelContent] Cleared unread count for channel", channelId); + + console.debug("[DMChannelContent] Set active channel and cleared unread:", channelId); + + // Cleanup: unset active channel when leaving + return () => { + setActiveChannel(null); + console.debug("[DMChannelContent] Cleared active channel"); + }; }, [channelId]); // Guard: Check if otherUser exists @@ -131,18 +143,31 @@ export default function DMChannelContent( // Get or create session when OLM is ready and we have the other user's account useEffect(() => { const loadSession = async () => { + console.log("[DMChannelContent] loadSession effect triggered", { + isReady, + hasOlmAccount: !!olmAccount, + hasOtherUser: !!otherUser, + hasOtherUserOlmAccount: !!otherUser?.olmAccount, + otherUserId: otherUser?.id + }); + if (!isReady || !olmAccount || !otherUser || !otherUser.olmAccount) { + console.log("[DMChannelContent] Not ready to load session, skipping"); return; } setSessionError(null); + console.log("[DMChannelContent] Calling getSession for", otherUser.id); try { const session = await getSession(otherUser.id, otherUser.olmAccount); + console.log("[DMChannelContent] getSession returned:", !!session); if (session) { setOlmSession(session); + console.log("[DMChannelContent] Session set successfully"); } else { + console.error("[DMChannelContent] getSession returned null"); setSessionError("Failed to create encryption session"); } } catch (err) { @@ -152,7 +177,7 @@ export default function DMChannelContent( }; loadSession(); - }, [isReady, olmAccount, otherUser, password,]) + }, [isReady, olmAccount, otherUser, password, getSession]) // Check if OLM is ready if (!isReady || !olmAccount) { @@ -263,6 +288,7 @@ export default function DMChannelContent( const displayName = isSelf ? selfDetail?.displayUsername ?? selfDetail?.username ?? selfDetail?.name ?? "You" : (sender?.displayUsername ?? sender?.username ?? sender?.name ?? "Unknown"); const timestamp = moment(msg.timestamp); const timeLabel = timestamp.isSame(moment(), "day") ? timestamp.format("h:mm A") : timestamp.format("MMM D, YYYY h:mm A"); + const shortTimeLabel = timestamp.format("h:mm A") // Check if this message is from the same user as the previous one within 5 minutes const prevMsg = index > 0 ? messages[index - 1] : null; @@ -303,11 +329,9 @@ export default function DMChannelContent( ) : ( // Compact message without avatar (grouped)
-
- - { - timeLabel - } +
+ + {shortTimeLabel}
@@ -339,6 +363,13 @@ export default function DMChannelContent( onKeyDown={async (e) => { if (e.key === 'Enter' && !e.shiftKey && messageInput.trim() && password) { e.preventDefault(); + console.log("[DMChannelContent] Attempting to send message", { + hasOlmSession: !!olmSession, + hasPassword: !!password, + recipientId: otherUser.id, + recipientKeyVersion: otherUser.olmAccount?.keyVersion + }); + try { const messageId = await sendMessage({ channelId, @@ -351,8 +382,12 @@ export default function DMChannelContent( userId, recipientId: otherUser.id, password, + recipientKeyVersion: otherUser.olmAccount?.keyVersion, + recipientIdentityKey: otherUser.olmAccount?.identityKey, }); + console.log("[DMChannelContent] Message sent successfully, ID:", messageId); + if (messageId) { setMessageInput(""); } diff --git a/src/components/ui/layout/channel-list.tsx b/src/components/ui/layout/channel-list.tsx index 7b4aed3..e1645e6 100644 --- a/src/components/ui/layout/channel-list.tsx +++ b/src/components/ui/layout/channel-list.tsx @@ -1,19 +1,56 @@ "use client" import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" import { clearUnread, db } from "@/lib/db" import { QuestionMarkIcon } from "@phosphor-icons/react" import { formatDistanceToNow } from "date-fns" import { useLiveQuery } from "dexie-react-hooks" -import { MessageSquarePlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react" +import { Globe2Icon, GlobeIcon, HomeIcon, MessageSquarePlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react" import { useRouter } from "next/navigation" +import { useMemo } from "react" import UserCard from "../user/user-card" +// Mock channels for testing scroll behavior - set to true to enable +const ENABLE_MOCK_CHANNELS = true + +function createMockChannel(id: string, name: string, message: string, hoursAgo: number): SiPher.Channel { + return { + id: `mock-${id}`, + name, + type: "DM" as SiPher.Channel["type"], + participants: ["current-user", `user-${id}`], + isOpen: true, + metadata: {}, + times: { + createdAt: Date.now() - 1000 * 60 * 60 * 24 * 30, + updatedAt: Date.now() - 1000 * 60 * 60 * hoursAgo, + lastMessage: { content: message } as unknown as SiPher.Channel["times"]["lastMessage"], + lastMessageAt: Date.now() - 1000 * 60 * 60 * hoursAgo + } + } +} + +// const mockChannels: SiPher.Channel[] = ENABLE_MOCK_CHANNELS ? [ +// createMockChannel("1", "Alice Johnson", "Hey, are you coming to the meeting?", 0.08), +// createMockChannel("2", "Bob Smith", "The project looks great!", 0.5), +// createMockChannel("3", "Charlie Brown", "Can you review my PR?", 1), +// createMockChannel("4", "Diana Prince", "Thanks for the help!", 2), +// createMockChannel("5", "Edward Norton", "Let's catch up soon", 5), +// createMockChannel("6", "Fiona Green", "Did you see the news?", 12), +// createMockChannel("7", "George Wilson", "Meeting at 3pm", 24), +// createMockChannel("8", "Hannah Baker", "Sounds good to me!", 48), +// createMockChannel("9", "Ivan Petrov", "I'll send over the files", 72), +// createMockChannel("10", "Julia Roberts", "Great work on that!", 96), +// createMockChannel("11", "Kevin Hart", "LOL that's hilarious", 120), +// createMockChannel("12", "Laura Palmer", "See you tomorrow", 144), +// ] : [] + export interface ChannelListProps { currentChannel: SiPher.Channel | null openDmChannels: SiPher.Channel[] - page: "friends" | "support" | "dm" | "server" - onPageChange: (page: "friends" | "support" | "dm" | "server") => void + page: SiPher.PageTypes + onPageChange: (page: SiPher.PageTypes) => void emptyMessage?: string dmChannel?: { id: string @@ -48,13 +85,22 @@ export function ChannelList({ [] ) + // Combine real channels with mock channels for testing, sorted by most recent activity + const allDmChannels = useMemo(() => { + return [...openDmChannels].sort((a, b) => { + const aTime = a.times?.lastMessageAt ?? a.times?.updatedAt ?? 0 + const bTime = b.times?.lastMessageAt ?? b.times?.updatedAt ?? 0 + return bTime - aTime // Descending order (most recent first) + }) + }, [openDmChannels]) + const handleNavigation = (path: string) => { router.push(path) onChannelSelect?.() } return ( -
+
{/* Channel List Header - Navigation Items (Desktop only) */} {!isMobile && ( <> @@ -67,7 +113,7 @@ export function ChannelList({ }`} onClick={() => { onPageChange("friends") - handleNavigation("/") + handleNavigation("/channels/me/friends") }} >
- {page === "friends" || !currentChannel ? ( -
+
+
+
+ + Global Nests + +
+ + {/* Nest Type Selector */} +
+ {[ + { id: 'global', icon: GlobeIcon, label: 'Global', description: 'Worldwide nests' }, + { id: 'continental', icon: Globe2Icon, label: 'Continent', description: 'Nests by continent' }, + { id: 'country', icon: HomeIcon, label: 'Country', description: 'Your country nests' }, + ].map((nest) => { + const isActive = nest.id === 'global' // TODO: Replace with actual state + const Icon = nest.icon + + return ( + + ) + })} +
+
+ + {(page === "friends" || !currentChannel) && ( +
{/* DM Header */} -
+
Direct Messages @@ -155,88 +262,92 @@ export function ChannelList({
- {openDmChannels.length > 0 ? ( -
- {openDmChannels.map((channel) => { - 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; + {allDmChannels.length > 0 && ( + +
+ {allDmChannels.map((channel) => { + 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 ( -
{ - clearUnread(channel.id) - console.log("Cleared unread count for channel", channel.id) - handleNavigation(`/channels/me/${channel.id}`) - }} - > -
- - {channelUnreadCount > 0 && ( - - {channelUnreadCount > 99 ? '99+' : channelUnreadCount} - - )} -
- - {/* Channel Info */} -
-
- - {channel.name} - - {lastMessageTime && ( - - {formatDistanceToNow(lastMessageTime, { addSuffix: false })} + return ( +
{ + clearUnread(channel.id) + console.log("Cleared unread count for channel", channel.id) + handleNavigation(`/channels/me/${channel.id}`) + }} + > +
+ + {channelUnreadCount > 0 && ( + + {channelUnreadCount > 99 ? '99+' : channelUnreadCount} )}
- {lastMessage && ( - - {lastMessage.content} - - )} + + {/* Channel Info */} +
+
+ + {channel.name} + + {lastMessageTime && ( + + {formatDistanceToNow(lastMessageTime, { addSuffix: false })} + + )} +
+ {lastMessage && ( + + {lastMessage.content} + + )} +
+ + {/* Close button - always visible on mobile, hover-visible on desktop */} +
+ ) + })} +
+ + )} - {/* Close button - always visible on mobile, hover-visible on desktop */} - -
- ) - })} -
- ) : ( + {allDmChannels.length === 0 && (
@@ -247,7 +358,9 @@ export function ChannelList({
)}
- ) : ( + )} + + {page !== "friends" && currentChannel && (
No channels
diff --git a/src/components/ui/layout/main-content-layout.tsx b/src/components/ui/layout/main-content-layout.tsx index 5a0eb92..3ebaed6 100644 --- a/src/components/ui/layout/main-content-layout.tsx +++ b/src/components/ui/layout/main-content-layout.tsx @@ -14,6 +14,7 @@ import { Plus } from "lucide-react" import * as React from "react" import { useEffect, useMemo } from "react" import { api } from "../../../../convex/_generated/api" +import { Doc } from "../../../../convex/betterAuth/_generated/dataModel" import DMChannelContent from "../dm/DmChannelContent" import { FriendsPage } from "../friends/friends-page" import { Spinner } from "../spinner" @@ -26,9 +27,13 @@ export interface MainContentLayoutProps { emptyChannelMessage?: string emptyFriendsMessage?: string userId: string - dmChannelId?: string - serverId?: string - serverChannelId?: string + routeInfo: { + type: SiPher.PageTypes + dmChannelId?: string + serverId?: string + serverChannelId?: string + } + userNests: Doc<"nests">[] | undefined } export function MainContentLayout({ @@ -36,13 +41,11 @@ export function MainContentLayout({ emptyChannelMessage, emptyFriendsMessage, userId, - dmChannelId, - serverId, - serverChannelId, + routeInfo, + userNests, }: MainContentLayoutProps) { - const [page, setPage] = React.useState<"friends" | "support" | "dm" | "server">( - dmChannelId ? "dm" : serverChannelId ? "server" : "friends" - ) + const { type, dmChannelId, serverId, serverChannelId } = routeInfo + const [page, setPage] = React.useState(type) const [friendsPage, setFriendsPage] = React.useState<"all" | "available">("all") const [friendModal, setFriendModal] = React.useState(false) const [currentChannel] = React.useState(null) @@ -59,8 +62,9 @@ export function MainContentLayout({ .find((channel) => channel.id === dmChannelId) ?.participants ?? [] - const getParticipantDetails: SiPher.ParticipantDetail[] | undefined = useQuery(api.auth.getParticipantDetails, - { participantIds } + const getParticipantDetails: SiPher.ParticipantDetail[] | undefined = useQuery( + api.auth.getParticipantDetails, + participantIds.length > 0 && dmChannelId ? { participantIds } : "skip" ) // Combine channel from local DB with participant details from Convex @@ -86,17 +90,11 @@ export function MainContentLayout({ // Sync page state with route props for seamless navigation useEffect(() => { - if (dmChannelId) { - setPage("dm"); - } else if (serverChannelId) { - setPage("server"); - } else { - setPage("friends"); - } - }, [dmChannelId, serverChannelId]); + setPage(type); + }, [type]); // Close mobile channel list when navigating to a channel - const handlePageChange = React.useCallback((newPage: "friends" | "support" | "dm" | "server") => { + const handlePageChange = React.useCallback((newPage: SiPher.PageTypes) => { setPage(newPage); if (isMobile) { setMobileChannelListOpen(false); @@ -126,7 +124,6 @@ export function MainContentLayout({ return ( <>
- {/* Header */} - {/* Content Area - Channel List + Main Content */}
- {/* Desktop: Always visible channel list */} -
+
{channelListContent}
- {/* Mobile: Sheet-based channel list - Discord-style two-panel layout */} {isMobile && ( @@ -156,42 +150,33 @@ export function MainContentLayout({ Navigate between channels and DMs
- {/* Left Rail - Server/Home Icons (Discord-style) */}
- {/* Home/DMs Button */} - {/* Divider */}
- {/* Discover */} - + {/* Future: Server icons will go here */} {/* Placeholder for servers */} - {/* Add Server Button */} - +
- {/* Right Panel - Channel List */}
- {/* Panel Header */}
Direct Messages
- {/* Channel List Content */}
{channelListContent}
@@ -203,33 +188,47 @@ export function MainContentLayout({ {/* Main Content */}
- {page === "dm" && dmChannelId ? ( - getParticipantDetails ? ( -
- + {page === "dm" && dmChannelId && getParticipantDetails && ( +
+ +
+ )} + + {page === "dm" && dmChannelId && !getParticipantDetails && ( +
+
+ +

Loading...

- ) : ( -
-
- -

Loading...

-
-
- ) - ) : page === "server" && serverChannelId ? ( +
+ )} + + {page === "server" && serverChannelId && (

Server channel {serverChannelId}

- ) : page === "friends" ? ( + )} + + {page === "friends" && ( - ) : ( - )} + + {page === "nests" && ( +
+

Nests

+
+ )} + + {page === "discover" && } + + {page === "global-nests" && } + + {page === "support" && }
@@ -242,20 +241,39 @@ export function MainContentLayout({ ) } -// Discord-style mobile server icon component +function GlobalNestsPage() { + return ( +
+
+ +

Loading...

+
+
+ ) +} + +function DiscoverPage({ userNests }: { userNests: Doc<"nests">[] }) { + return ( +
+
+ +

Loading...

+
+
+ ) +} + function MobileServerIcon({ children, isActive, isHome, isAddButton, - label, onClick }: { children: React.ReactNode isActive?: boolean isHome?: boolean isAddButton?: boolean - label?: string onClick?: () => void }) { return ( diff --git a/src/components/ui/layout/page-header.tsx b/src/components/ui/layout/page-header.tsx index 26ebca1..7c4bd98 100644 --- a/src/components/ui/layout/page-header.tsx +++ b/src/components/ui/layout/page-header.tsx @@ -6,7 +6,7 @@ import UserCard from "../user/user-card" export interface PageHeaderProps { currentChannel: SiPher.Channel | null - page: "friends" | "support" | "dm" | "server" + page: SiPher.PageTypes friendsPage?: "all" | "available" onFriendsPageChange?: (page: "all" | "available") => void onAddFriend?: () => void diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx index 8e4fa13..69fd851 100644 --- a/src/components/ui/scroll-area.tsx +++ b/src/components/ui/scroll-area.tsx @@ -1,58 +1,58 @@ "use client" -import * as React from "react" import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" +import * as React from "react" import { cn } from "@/lib/utils" function ScrollArea({ - className, - children, - ...props + className, + children, + ...props }: React.ComponentProps) { - return ( - - - {children} - - - - - ) + return ( + + + {children} + + + + + ) } function ScrollBar({ - className, - orientation = "vertical", - ...props + className, + orientation = "vertical", + ...props }: React.ComponentProps) { - return ( - - - - ) + return ( + + + + ) } export { ScrollArea, ScrollBar } diff --git a/src/contexts/olm-context.tsx b/src/contexts/olm-context.tsx index de96c89..dd792b4 100644 --- a/src/contexts/olm-context.tsx +++ b/src/contexts/olm-context.tsx @@ -2,8 +2,8 @@ import { loadOlm } from "@/app/auth/scripts/makeKeys"; import { decryptPassword, encryptPassword, getOrCreatePasswordEncryptionKey } from "@/lib/crypto"; -import { db } from "@/lib/db"; -import { checkOlmStatus, getOlmAccount, handleOlmAccountCreation, SendKeysToServerFn } from "@/lib/olm"; +import { db, invalidateSession, validateSessionKeys } from "@/lib/db"; +import { checkOlmStatus, clearOlmAccountCache, getOlmAccount, handleOlmAccountCreation, SendKeysToServerFn } from "@/lib/olm"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; // ============================================ @@ -20,10 +20,25 @@ interface OlmContextValue { getSession: (recipientId: string, recipientOlmAccount: { identityKey: { curve25519: string; ed25519: string }; oneTimeKeys: Array<{ keyId: string; publicKey: string }>; + keyVersion?: number; }) => Promise; - createInboundSession: (senderId: string, preKeyMessage: string) => Promise; + createInboundSession: ( + senderId: string, + preKeyMessage: string, + senderKeyVersion?: number, + senderIdentityKey?: { curve25519: string; ed25519: string } + ) => Promise; sessions: Map; + // Key synchronization + validateRecipientKeys: ( + recipientId: string, + recipientOlmAccount: { + identityKey: { curve25519: string; ed25519: string }; + keyVersion?: number; + } + ) => Promise; + // Password & setup password: string | null; passwordError: string | null; @@ -72,6 +87,8 @@ export function OlmProvider({ const passwordSetManuallyRef = useRef(false); // Track if we're currently loading the OLM account (prevent duplicate loads) const isLoadingAccountRef = useRef(false); + // Trigger to force reload of OLM account + const [reloadTrigger, setReloadTrigger] = useState(0); const [, forceUpdate] = useState({}); // Initialize encryption key on mount @@ -96,7 +113,9 @@ export function OlmProvider({ const saveSessionToDb = useCallback(async ( recipientId: string, session: Olm.Session, - sessionPassword: string + sessionPassword: string, + recipientKeyVersion?: number, + recipientIdentityKey?: { curve25519: string; ed25519: string } ) => { if (!userId) return; @@ -106,8 +125,10 @@ export function OlmProvider({ pickledSession: session.pickle(sessionPassword), createdAt: Date.now(), updatedAt: Date.now(), + recipientKeyVersion, + recipientIdentityKey, }); - console.debug("[OlmContext]: Session saved to DB"); + console.debug("[OlmContext]: Session saved to DB with key version:", recipientKeyVersion); }, [userId]); // Helper: Unpickle session from database @@ -217,8 +238,10 @@ export function OlmProvider({ const loadAccount = async () => { isLoadingAccountRef.current = true; try { - console.debug("[OlmContext]: Loading OLM account..."); - const account = await getOlmAccount(userId, password); + const forceReload = reloadTrigger > 0; + console.log("[OlmContext]: Loading OLM account... (trigger:", reloadTrigger, "forceReload:", forceReload, ")"); + + const account = await getOlmAccount(userId, password, forceReload); if (!account) { console.warn("[OlmContext]: No OLM account found"); isLoadingAccountRef.current = false; @@ -227,7 +250,7 @@ export function OlmProvider({ setOlmAccount(account); setPasswordError(null); - console.debug("[OlmContext]: OLM account loaded successfully"); + console.log("[OlmContext]: OLM account loaded successfully"); } catch (err) { console.error("[OlmContext]: Failed to load OLM account:", err); // Password is wrong - clear it and set error @@ -239,7 +262,7 @@ export function OlmProvider({ }; loadAccount(); - }, [userId, password, clearPassword]); + }, [userId, password, reloadTrigger, clearPassword]); // Clear password error const clearPasswordError = useCallback(() => { @@ -279,17 +302,24 @@ export function OlmProvider({ if (!userId || !accountPassword.trim()) return; setOlmStatus("creating"); + const isRotation = olmStatus === "mismatched"; + const success = await handleOlmAccountCreation( userId, accountPassword, sendKeysToServer, - olmStatus === "mismatched" + isRotation ); if (success) { setOlmStatus("synced"); setShowOlmModal(false); setPassword(accountPassword); + + // Clear cache and force reload OLM account from IndexedDB after creation/rotation + console.log("[OlmContext]: Keys", isRotation ? "rotated" : "created", "- clearing cache and reloading account"); + clearOlmAccountCache(userId); + setReloadTrigger(prev => prev + 1); } else { setOlmStatus("not_setup"); } @@ -301,15 +331,43 @@ export function OlmProvider({ recipientOlmAccount: { identityKey: { curve25519: string; ed25519: string }; oneTimeKeys: Array<{ keyId: string; publicKey: string }>; + keyVersion?: number; // Optional key version for validation } ): Promise => { + console.log(`[OlmContext]: getSession called for ${recipientId}`, { + hasIdentityKey: !!recipientOlmAccount.identityKey, + oneTimeKeysCount: recipientOlmAccount.oneTimeKeys.length, + keyVersion: recipientOlmAccount.keyVersion + }); + if (!validateSessionRequirements()) { + console.error("[OlmContext]: Session requirements validation failed"); return null; } - // Check if we already have this session in memory - if (sessionsRef.current.has(recipientId)) { - console.debug(`[OlmContext]: Using cached session for ${recipientId}`); + // CRITICAL: Validate recipient's keys before using cached session + const keyVersion = recipientOlmAccount.keyVersion || 0; + console.log(`[OlmContext]: Validating keys for ${recipientId}, version: ${keyVersion}`); + + const isValid = await validateSessionKeys( + recipientId, + keyVersion, + recipientOlmAccount.identityKey + ); + + console.log(`[OlmContext]: Key validation result for ${recipientId}: ${isValid}`); + + if (!isValid) { + console.warn(`[OlmContext]: Recipient keys changed, invalidating session for ${recipientId}`); + // Remove cached session + sessionsRef.current.delete(recipientId); + // Remove from database + await invalidateSession(userId!, recipientId); + } + + // Check if we already have this session in memory (after validation) + if (sessionsRef.current.has(recipientId) && isValid) { + console.log(`[OlmContext]: Using cached session for ${recipientId}`); return sessionsRef.current.get(recipientId)!; } @@ -325,13 +383,13 @@ export function OlmProvider({ try { console.debug(`[OlmContext]: Loading/creating session for user ${recipientId}`); - // Check if session exists in DB + // Check if session exists in DB (after validation cleared invalid ones) const existingSession = await db.olmSessions .where("[odId+recipientId]") .equals([userId!, recipientId]) .first(); - if (existingSession) { + if (existingSession && isValid) { console.debug("[OlmContext]: Found existing session in DB, unpickling..."); const session = await unpickleSessionFromDb(recipientId, existingSession.pickledSession, password!); @@ -344,40 +402,57 @@ export function OlmProvider({ } // Create new outbound session - console.debug("[OlmContext]: Creating new outbound session..."); + console.log("[OlmContext]: Creating new outbound session..."); if (recipientOlmAccount.oneTimeKeys.length === 0) { + console.error("[OlmContext]: No one-time keys available for recipient"); throw new Error("No one-time keys available for recipient"); } const otk = recipientOlmAccount.oneTimeKeys[0]; + console.log(`[OlmContext]: Using OTK ${otk.keyId} for session creation`); + const Olm: typeof import("@matrix-org/olm") = await loadOlm(); const newSession: Olm.Session = new Olm.Session(); + console.log(`[OlmContext]: Creating outbound session with:`, { + recipientCurve: recipientOlmAccount.identityKey.curve25519.substring(0, 20) + '...', + otkPublicKey: otk.publicKey.substring(0, 20) + '...' + }); + newSession.create_outbound( olmAccount!, recipientOlmAccount.identityKey.curve25519, otk.publicKey ); - console.debug(`[OlmContext]: Created session: ${newSession.session_id()}`); + console.log(`[OlmContext]: Created session: ${newSession.session_id()}`); - // Save to DB - await saveSessionToDb(recipientId, newSession, password!); + // Save to DB with key version and identity key + console.log(`[OlmContext]: Saving session to DB with keyVersion: ${keyVersion}`); + await saveSessionToDb( + recipientId, + newSession, + password!, + keyVersion, + recipientOlmAccount.identityKey + ); // Consume the OTK from server try { + console.log(`[OlmContext]: Consuming OTK ${otk.keyId} from server`); await consumeOTK({ userId: recipientId, keyId: otk.keyId, }); - console.debug(`[OlmContext]: Consumed OTK: ${otk.keyId}`); + console.log(`[OlmContext]: Successfully consumed OTK: ${otk.keyId}`); } catch (err) { console.error("[OlmContext]: Failed to consume OTK:", err); } // Cache it cacheSession(recipientId, newSession); + console.log(`[OlmContext]: Session cached and ready for ${recipientId}`); return newSession; } catch (err) { @@ -398,7 +473,9 @@ export function OlmProvider({ // Create an INBOUND session from a received pre-key message const createInboundSession = useCallback(async ( senderId: string, - preKeyMessage: string + preKeyMessage: string, + senderKeyVersion?: number, + senderIdentityKey?: { curve25519: string; ed25519: string } ): Promise => { console.debug("[OlmContext]: Args passed to createInboundSession", { senderId, preKeyMessage }); @@ -426,8 +503,14 @@ export function OlmProvider({ console.debug(`[OlmContext]: Created inbound session: ${newSession.session_id()}`); - // Save to DB - await saveSessionToDb(senderId, newSession, password!); + // Save to DB with sender's key metadata + await saveSessionToDb( + senderId, + newSession, + password!, + senderKeyVersion, + senderIdentityKey + ); // Cache it cacheSession(senderId, newSession); @@ -439,6 +522,36 @@ export function OlmProvider({ } }, [validateSessionRequirements, olmAccount, password, saveSessionToDb, cacheSession]); + // Validate recipient keys and invalidate session if keys have changed + const validateRecipientKeys = useCallback(async ( + recipientId: string, + recipientOlmAccount: { + identityKey: { curve25519: string; ed25519: string }; + keyVersion?: number; + } + ): Promise => { + if (!userId) return false; + + const keyVersion = recipientOlmAccount.keyVersion || 0; + const isValid = await validateSessionKeys( + recipientId, + keyVersion, + recipientOlmAccount.identityKey + ); + + if (!isValid) { + console.warn(`[OlmContext]: Keys changed for ${recipientId}, invalidating session`); + // Remove cached session + sessionsRef.current.delete(recipientId); + // Remove from database + await invalidateSession(userId, recipientId); + // Force re-render to update UI + forceUpdate({}); + } + + return isValid; + }, [userId]); + const isReady = useMemo(() => { return olmAccount !== null && olmStatus === "synced"; }, [olmAccount, olmStatus]); @@ -450,6 +563,7 @@ export function OlmProvider({ getSession, createInboundSession, sessions: sessionsRef.current, + validateRecipientKeys, password, passwordError, showOlmModal, diff --git a/src/contexts/socket-context.tsx b/src/contexts/socket-context.tsx index 45b1068..1de3e4d 100644 --- a/src/contexts/socket-context.tsx +++ b/src/contexts/socket-context.tsx @@ -1,6 +1,7 @@ "use client" -import { db, getOrCreateDmChannel, incrementUnread, storeMessage } from "@/lib/db"; +import { db, getOrCreateDmChannel, storeMessage } from "@/lib/db"; +import { isChannelActive, showMessageNotification } from "@/lib/notifications"; import { convex } from "@/lib/providers/Convex"; import { useMutation } from "convex/react"; import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; @@ -96,7 +97,8 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr messageType: 0 | 1, encryptedBody: string, currentUserId: string, - fromUserId: string + fromUserId: string, + senderDetails?: { name: string; image?: string } ) => { // Decrypt the message const decryptedBody = session.decrypt(messageType, encryptedBody); @@ -113,9 +115,30 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr 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); + const channelId = validatedMessage.data.channelId; + const isActive = isChannelActive(channelId); + + // Store message, skip unread increment if channel is active + await storeMessage( + validatedMessage.data as SiPher.Messages.ClientEncrypted.EncryptedMessage & { to: string }, + { skipUnreadIncrement: isActive } + ); + + // Show browser notification if channel is not active + if (!isActive && senderDetails) { + showMessageNotification({ + senderName: senderDetails.name, + senderImage: senderDetails.image, + messagePreview: validatedMessage.data.content, + channelId: channelId, + userStatus: userStatusRef.current.status, // Pass current user's status + onClick: () => { + // Could navigate to the channel here if needed + window.location.href = `/channels/me/${channelId}`; + }, + }); + } + console.debug("[Socket]: Message stored successfully"); }, [saveSessionState]); @@ -169,7 +192,7 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr return; } - // Fetch participant details + // Fetch participant details including OLM account with key version try { const participantDetails = await convex.query(api.auth.getParticipantDetails, { participantIds: [fromUserId] @@ -183,11 +206,23 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr const { type, body } = data.content; + // Prepare sender details for notifications + const senderDetails = { + name: fromUser.displayUsername || fromUser.username || fromUser.name, + image: fromUser.image || undefined, + }; + switch (type) { case 0: { console.debug("[Socket]: Received inbound message from pre-key message"); - const session = await createInboundSession(fromUserId, body as string); + // Create inbound session with sender's key metadata + const session = await createInboundSession( + fromUserId, + body as string, + fromUser.olmAccount?.keyVersion, + fromUser.olmAccount?.identityKey + ); if (!session) { console.error("[Socket]: Failed to create inbound session"); return; @@ -197,7 +232,7 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr await getOrCreateDmChannel(currentUserId, fromUser); // Decrypt, validate, and store using helper - await decryptAndStoreMessage(session, type, body as string, currentUserId, fromUserId); + await decryptAndStoreMessage(session, type, body as string, currentUserId, fromUserId, senderDetails); break; } case 1: { @@ -211,7 +246,7 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr } // Decrypt, validate, and store using helper - await decryptAndStoreMessage(session, type, body as string, currentUserId, fromUserId); + await decryptAndStoreMessage(session, type, body as string, currentUserId, fromUserId, senderDetails); break; } } @@ -222,25 +257,41 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr // Process queued messages when OLM becomes ready useEffect(() => { - if (!olmAccount || !olmIsReady || messageQueueRef.current.length === 0) return; + if (!olmAccount || !olmIsReady) { + if (messageQueueRef.current.length > 0) { + console.log(`[Socket - processQueue]: Waiting for OLM... ${messageQueueRef.current.length} messages queued`); + } + return; + } - console.log(`[Socket - processQueue]: OLM is now ready, processing ${messageQueueRef.current.length} queued messages`); + if (messageQueueRef.current.length === 0) return; + + console.log(`[Socket - processQueue]: ========================================`); + console.log(`[Socket - processQueue]: OLM is now ready!`); + console.log(`[Socket - processQueue]: Processing ${messageQueueRef.current.length} queued messages`); + console.log(`[Socket - processQueue]: ========================================`); const processQueue = async () => { const queue = [...messageQueueRef.current]; messageQueueRef.current = []; // Clear queue - for (const data of queue) { - console.log("[Socket - processQueue]: Processing queued message:", data); - await processIncomingDM(data); + for (let i = 0; i < queue.length; i++) { + console.log(`[Socket - processQueue]: Processing queued message ${i + 1}/${queue.length}`); + await processIncomingDM(queue[i]); } + console.log(`[Socket - processQueue]: All queued messages processed!`); }; processQueue(); }, [olmAccount, olmIsReady, processIncomingDM]); useEffect(() => { - if (!user.id) return; + if (!user.id) { + console.warn("[Socket]: No user ID, not connecting socket"); + return; + } + + console.log("[Socket]: Initializing socket connection for user:", user.id); const socket: Socket = io({ withCredentials: true, @@ -301,7 +352,13 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr } socket.on("connect", () => { - console.log("[Socket - connect]: Connected to socket - Authentication successful!"); + console.log("[Socket - connect]: ========================================"); + console.log("[Socket - connect]: Connected to socket server!"); + console.log("[Socket - connect]: Socket ID:", socket.id); + console.log("[Socket - connect]: User ID:", user.id); + console.log("[Socket - connect]: Transport:", socket.io.engine?.transport?.name); + console.log("[Socket - connect]: ========================================"); + setSocketStatus("connected"); updateSocketInfo({ connectedAt: Date.now(), @@ -370,7 +427,11 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr }); socket.on("dm:new", async (data: { content: { type: 0 | 1; body: unknown }, participants: string[] }) => { - console.log("[Socket - dm:new]: New DM received:", data); + console.log("[Socket - dm:new]: New DM received"); + console.log("[Socket - dm:new]: Message type:", data.content.type); + console.log("[Socket - dm:new]: Participants:", data.participants); + console.log("[Socket - dm:new]: OLM account ready:", !!olmAccount); + console.log("[Socket - dm:new]: User ID:", user.id); // Check if OLM account is loaded if (!olmAccount) { @@ -380,8 +441,9 @@ export function SocketProvider({ children, user, refetchUser }: SocketProviderPr } // Process immediately if OLM is ready - console.debug("[Socket]: Processing incoming DM immediately:", data); + console.log("[Socket]: Processing incoming DM immediately"); await processIncomingDM(data); + console.log("[Socket]: Finished processing incoming DM"); }); return () => { diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 82e32e1..2b40b73 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -20,6 +20,8 @@ export interface OlmSession { pickledSession: string; // Serialized Olm.Session createdAt: number; updatedAt: number; + recipientKeyVersion?: number; // Track recipient's key version + recipientIdentityKey?: { curve25519: string; ed25519: string }; // Track recipient's identity key } /** Unread count per channel */ @@ -121,15 +123,92 @@ export async function getChannelMessages( return query.reverse().sortBy("timestamp").then((msgs) => msgs.slice(0, limit)); } +/** Validate session keys match recipient's current keys */ +export async function validateSessionKeys( + recipientId: string, + currentKeyVersion: number, + currentIdentityKey: { curve25519: string; ed25519: string } +): Promise { + console.debug(`[DB] Validating session keys for ${recipientId}`, { + currentKeyVersion, + currentIdentityKey + }); + + const sessions = await db.olmSessions + .where("recipientId") + .equals(recipientId) + .toArray(); + + console.debug(`[DB] Found ${sessions.length} existing sessions for ${recipientId}`); + + if (sessions.length === 0) { + console.debug(`[DB] No existing session - validation passes`); + return true; // No session yet, validation passes + } + + const session = sessions[0]; + console.debug(`[DB] Existing session metadata:`, { + recipientKeyVersion: session.recipientKeyVersion, + recipientIdentityKey: session.recipientIdentityKey + }); + + // Check if key version has changed + if (session.recipientKeyVersion !== undefined && session.recipientKeyVersion !== currentKeyVersion) { + console.warn(`[DB] Key version mismatch for ${recipientId}: local=${session.recipientKeyVersion}, server=${currentKeyVersion}`); + return false; + } + + // Check if identity key has changed + if (session.recipientIdentityKey) { + if (session.recipientIdentityKey.curve25519 !== currentIdentityKey.curve25519 || + session.recipientIdentityKey.ed25519 !== currentIdentityKey.ed25519) { + console.warn(`[DB] Identity key mismatch for ${recipientId}`); + console.warn(`[DB] Local curve25519: ${session.recipientIdentityKey.curve25519}`); + console.warn(`[DB] Server curve25519: ${currentIdentityKey.curve25519}`); + console.warn(`[DB] Local ed25519: ${session.recipientIdentityKey.ed25519}`); + console.warn(`[DB] Server ed25519: ${currentIdentityKey.ed25519}`); + return false; + } + } + + console.debug(`[DB] Key validation passed for ${recipientId}`); + return true; +} + +/** Invalidate and remove session for a recipient */ +export async function invalidateSession(userId: string, recipientId: string): Promise { + await db.olmSessions + .where("[odId+recipientId]") + .equals([userId, recipientId]) + .delete(); + console.log(`[DB] Invalidated session for ${recipientId}`); +} + /** Add a message to local storage */ export async function sendMessage( message: Omit & { to: string }, olmSession: Olm.Session, sendMessage: (message: { type: 0 | 1; body: string }, to: string) => void, - saveSession?: { userId: string; recipientId: string; password: string } + saveSession?: { + userId: string; + recipientId: string; + password: string; + recipientKeyVersion?: number; + recipientIdentityKey?: { curve25519: string; ed25519: string }; + } ): Promise { + console.log("[DB] sendMessage called", { + channelId: message.channelId, + to: message.to, + hasSession: !!olmSession, + hasSaveSession: !!saveSession + }); + const id = crypto.randomUUID(); + console.log("[DB] Generated message ID:", id); + await db.messages.add({ ...message, id }); + console.log("[DB] Message added to local DB"); // Update channel's lastMessageAt await db.channels.where("id").equals(message.channelId).modify((channel) => { @@ -137,8 +216,10 @@ export async function sendMessage( channel.times.lastMessageAt = message.timestamp; channel.times.updatedAt = Date.now(); }); + console.log("[DB] Channel updated with last message"); // Encrypt the message + console.log("[DB] Encrypting message..."); const encrypted = olmSession.encrypt( JSON.stringify({ id, @@ -148,29 +229,49 @@ export async function sendMessage( status: message.status, content: message.content, } satisfies SiPher.Messages.ClientEncrypted.EncryptedMessage) - ) + ); + console.log("[DB] Message encrypted, type:", encrypted.type); // CRITICAL: Save the updated session after encrypt (ratchet has advanced) if (saveSession) { + console.log("[DB] Saving session state...", { + recipientKeyVersion: saveSession.recipientKeyVersion, + hasIdentityKey: !!saveSession.recipientIdentityKey + }); + + const updateData: Partial = { + pickledSession: olmSession.pickle(saveSession.password), + updatedAt: Date.now(), + }; + + // Update key version and identity key if provided + if (saveSession.recipientKeyVersion !== undefined) { + updateData.recipientKeyVersion = saveSession.recipientKeyVersion; + } + if (saveSession.recipientIdentityKey) { + updateData.recipientIdentityKey = saveSession.recipientIdentityKey; + } + await db.olmSessions .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"); + .modify(updateData); + console.log("[DB] Session state saved after encrypt with keyVersion:", saveSession.recipientKeyVersion); } // Send the message using the socket + console.log("[DB] Sending message via socket to:", message.to); sendMessage(encrypted, message.to); + console.log("[DB] Message sent via socket"); return id; } export async function storeMessage( - message: SiPher.Messages.ClientEncrypted.EncryptedMessage - & { to: string } + message: SiPher.Messages.ClientEncrypted.EncryptedMessage & { to: string }, + options?: { + skipUnreadIncrement?: boolean; // Skip incrementing if user is viewing the channel + } ): Promise { await db.messages.add(message); await db.channels.where("id").equals(message.channelId).modify((channel) => { @@ -178,7 +279,11 @@ export async function storeMessage( channel.times.lastMessageAt = message.timestamp; channel.times.updatedAt = Date.now(); }); - await incrementUnread(message.channelId); + + // Only increment unread if not explicitly skipped + if (!options?.skipUnreadIncrement) { + await incrementUnread(message.channelId); + } } /** Increment unread count for a channel */ diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts new file mode 100644 index 0000000..5716bdf --- /dev/null +++ b/src/lib/notifications/index.ts @@ -0,0 +1,135 @@ +/** + * Browser Notification System + * Handles both unread counts and native browser notifications + */ + +// Track which channel the user is currently viewing +let activeChannelId: string | null = null; + +/** + * Set the currently active channel + * Messages in this channel won't trigger notifications or increment unread + */ +export function setActiveChannel(channelId: string | null) { + activeChannelId = channelId; + console.debug("[Notifications] Active channel set to:", channelId); +} + +/** + * Get the currently active channel + */ +export function getActiveChannel(): string | null { + return activeChannelId; +} + +/** + * Check if user is currently viewing a specific channel + */ +export function isChannelActive(channelId: string): boolean { + return activeChannelId === channelId; +} + +/** + * Request browser notification permission + * Should be called on user interaction (button click, etc.) + */ +export async function requestNotificationPermission(): Promise { + if (!("Notification" in window)) { + console.warn("[Notifications] Browser doesn't support notifications"); + return "denied"; + } + + if (Notification.permission === "granted") { + return "granted"; + } + + if (Notification.permission !== "denied") { + const permission = await Notification.requestPermission(); + console.log("[Notifications] Permission:", permission); + return permission; + } + + return Notification.permission; +} + +/** + * Check if notifications are enabled + */ +export function areNotificationsEnabled(): boolean { + return "Notification" in window && Notification.permission === "granted"; +} + +/** + * Show a browser notification for a new message + */ +export function showMessageNotification(options: { + senderName: string; + senderImage?: string; + messagePreview: string; + channelId: string; + userStatus?: "online" | "busy" | "offline" | "away"; // Current user's status + onClick?: () => void; +}) { + // Don't show notification if user status is "busy" + if (options.userStatus === "busy") { + console.debug("[Notifications] Skipping notification - user is busy"); + return; + } + + // Don't show notification if user is viewing this channel + if (isChannelActive(options.channelId)) { + console.debug("[Notifications] Skipping notification - channel is active"); + return; + } + + // Don't show if notifications not enabled + if (!areNotificationsEnabled()) { + console.debug("[Notifications] Skipping notification - not enabled"); + return; + } + + // Don't show if page is focused (user is actively using the app) + if (document.hasFocus()) { + console.debug("[Notifications] Skipping notification - page has focus"); + return; + } + + try { + const notification = new Notification(`${options.senderName}`, { + body: options.messagePreview, + icon: options.senderImage || "/default-avatar.png", + badge: "/logo.png", + tag: options.channelId, // Prevents duplicate notifications for same channel + requireInteraction: false, + silent: false, + }); + + notification.onclick = () => { + window.focus(); + notification.close(); + options.onClick?.(); + }; + + // Auto-close after 5 seconds + setTimeout(() => notification.close(), 5000); + + console.log("[Notifications] Browser notification shown"); + } catch (error) { + console.error("[Notifications] Failed to show notification:", error); + } +} + +/** + * Play a notification sound (optional) + */ +export function playNotificationSound() { + try { + const audio = new Audio("/notification.mp3"); + audio.volume = 0.5; + audio.play().catch((err) => { + console.debug("[Notifications] Failed to play sound:", err); + }); + } catch (error) { + console.debug("[Notifications] Audio not available"); + } +} diff --git a/src/lib/olm/index.ts b/src/lib/olm/index.ts index 79b57b1..5ef77b1 100644 --- a/src/lib/olm/index.ts +++ b/src/lib/olm/index.ts @@ -20,17 +20,22 @@ export type SendKeysToServerFn = (args: { * Unpickle and retrieve the OLM account for a user * @param userId - The user's ID * @param password - The password used to pickle the account + * @param forceReload - If true, skips cache and reloads from IndexedDB * @returns Promise resolving to the unpickled Olm.Account, or null if not found */ export async function getOlmAccount( userId: string, - password: string + password: string, + forceReload: boolean = false ): Promise { - // Check cache first - if ((window as any).olmAccountCache?.[userId]) { + // Check cache first (unless forcing reload) + if (!forceReload && (window as any).olmAccountCache?.[userId]) { + console.debug("[OLM] Using cached account for", userId); return (window as any).olmAccountCache[userId]; } + console.debug("[OLM] Loading account from IndexedDB for", userId, "forceReload:", forceReload); + // Get pickled account from DB const pickledData = await db.olmAccounts.get(userId); if (!pickledData) return null; @@ -46,9 +51,21 @@ export async function getOlmAccount( } (window as any).olmAccountCache[userId] = account; + console.debug("[OLM] Account loaded and cached for", userId); return account; } +/** + * Clear cached OLM account for a user + * Call this after key rotation to force reload + */ +export function clearOlmAccountCache(userId: string): void { + if ((window as any).olmAccountCache?.[userId]) { + delete (window as any).olmAccountCache[userId]; + console.debug("[OLM] Cleared account cache for", userId); + } +} + /** * Check if user has an OLM account stored locally in IndexedDB * @param userId - The user's ID diff --git a/src/lib/olm/keySync.ts b/src/lib/olm/keySync.ts new file mode 100644 index 0000000..175e4f1 --- /dev/null +++ b/src/lib/olm/keySync.ts @@ -0,0 +1,112 @@ +/** + * OLM Key Synchronization Utilities + * + * This module provides utilities for managing OLM key synchronization + * to handle key rotation scenarios where users change their encryption keys. + */ + +import { convex } from "@/lib/providers/Convex"; +import { api } from "../../../convex/_generated/api"; +import { invalidateSession, validateSessionKeys } from "../db"; + +/** + * Check if a recipient's keys have changed and invalidate session if needed + * @param userId - Current user's ID + * @param recipientId - Recipient's user ID + * @returns True if keys are valid, false if they changed + */ +export async function checkRecipientKeyStatus( + userId: string, + recipientId: string +): Promise<{ + isValid: boolean; + keyVersion?: number; + updatedAt?: number; + identityKey?: { curve25519: string; ed25519: string }; +}> { + try { + // Fetch current key version from server + const keyInfo = await convex.query(api.auth.getKeyVersion, { + userId: recipientId + }); + + if (!keyInfo) { + return { isValid: false }; + } + + // Validate against locally stored key metadata + const isValid = await validateSessionKeys( + recipientId, + keyInfo.keyVersion, + keyInfo.identityKey + ); + + if (!isValid) { + console.warn(`[KeySync] Keys changed for ${recipientId}, invalidating session`); + await invalidateSession(userId, recipientId); + } + + return { + isValid, + keyVersion: keyInfo.keyVersion, + updatedAt: keyInfo.updatedAt, + identityKey: keyInfo.identityKey, + }; + } catch (error) { + console.error("[KeySync] Failed to check recipient key status:", error); + return { isValid: false }; + } +} + +/** + * Batch check multiple recipients' key statuses + * @param userId - Current user's ID + * @param recipientIds - Array of recipient user IDs + * @returns Map of recipientId to validation result + */ +export async function batchCheckRecipientKeys( + userId: string, + recipientIds: string[] +): Promise> { + const results = new Map(); + + // Check all recipients in parallel + await Promise.all( + recipientIds.map(async (recipientId) => { + const result = await checkRecipientKeyStatus(userId, recipientId); + results.set(recipientId, result); + }) + ); + + return results; +} + +/** + * Create a periodic key sync checker + * @param userId - Current user's ID + * @param recipientIds - Array of recipient user IDs to monitor + * @param intervalMs - Check interval in milliseconds (default: 5 minutes) + * @param onKeyChange - Callback when keys change + * @returns Cleanup function to stop the interval + */ +export function createPeriodicKeySync( + userId: string, + recipientIds: string[], + intervalMs: number = 5 * 60 * 1000, // 5 minutes default + onKeyChange?: (recipientId: string) => void +): () => void { + const intervalId = setInterval(async () => { + console.debug("[KeySync] Running periodic key check..."); + const results = await batchCheckRecipientKeys(userId, recipientIds); + + results.forEach((result, recipientId) => { + if (!result.isValid) { + console.warn(`[KeySync] Keys changed for ${recipientId}`); + onKeyChange?.(recipientId); + } + }); + }, intervalMs); + + // Return cleanup function + return () => clearInterval(intervalId); +} diff --git a/src/lib/sockets/events/dm.ts b/src/lib/sockets/events/dm.ts index 4d2a4bb..20a768d 100644 --- a/src/lib/sockets/events/dm.ts +++ b/src/lib/sockets/events/dm.ts @@ -74,7 +74,7 @@ const dmEvent: SiPher.EventsType = { io.to(to).emit("dm:new", dmData); - console.log(`[DM] ${sender.id} → ${to} in room ${roomId}`); + console.log(`[DM] ${sender.id} → ${to} in room ${roomId}: ${message.body}`); }, }; diff --git a/src/lib/sockets/index.ts b/src/lib/sockets/index.ts index e3970d6..9de6097 100644 --- a/src/lib/sockets/index.ts +++ b/src/lib/sockets/index.ts @@ -25,12 +25,15 @@ interface SocketManagerOptions { authMethod?: "session" | "ott"; } +const RECONCILE_INTERVAL_MS = 2 * 60 * 1000; // 2 minutes + export default class SocketManager { private socketIo: SocketIOServer | null = null; private events: Map = new Map(); private options: SocketManagerOptions; private convex: ConvexHttpClient; + private reconcileTimer: ReturnType | null = null; constructor(nextServer: HTTPServer, options: SocketManagerOptions = {}) { if (!nextServer) { @@ -148,6 +151,48 @@ export default class SocketManager { return this.socketIo?.sockets.sockets.get(userId); } + private extractJwt(socket: Socket): string | null { + const cookies = socket.handshake.headers.cookie; + if (!cookies || !cookies.includes("better-auth.convex_jwt")) return null; + const token = cookies.split("better-auth.convex_jwt=")[1]?.split(";")[0]; + return token || null; + } + + /** + * Periodically queries Convex for all users with a non-offline status, + * checks if they have a live socket connection, and forces offline + * any that don't. + */ + private startStatusReconciliation(): void { + if (this.reconcileTimer) return; + + this.reconcileTimer = setInterval(async () => { + try { + const nonOfflineUsers = await this.convex.query(api.auth.getNonOfflineUserIds, {}); + if (!nonOfflineUsers || nonOfflineUsers.length === 0) return; + + const connectedSocketIds = this.socketIo?.sockets.sockets; + let reconciled = 0; + + for (const entry of nonOfflineUsers) { + const hasSocket = connectedSocketIds?.has(entry.userId) ?? false; + if (hasSocket) continue; + + await this.convex.mutation(api.auth.forceUserOffline, { userId: entry.userId }); + reconciled++; + } + + if (reconciled > 0) { + console.log(`[SocketManager] Reconciled ${reconciled} ghost user(s) to offline`); + } + } catch (error) { + console.error("[SocketManager] Status reconciliation error:", error); + } + }, RECONCILE_INTERVAL_MS); + + console.log(`[SocketManager] Status reconciliation started (every ${RECONCILE_INTERVAL_MS / 1000}s)`); + } + public async initializeEventHandler(): Promise { // Get events from the events folder const socketIo = this.getSocketIo(); @@ -222,21 +267,15 @@ export default class SocketManager { // Handle disconnect within the connection context socket.on("disconnect", async (reason) => { try { - const cookies = socket.handshake.headers.cookie; - if (!cookies || !cookies.includes("better-auth.convex_jwt")) return; - const session = cookies.split("better-auth.convex_jwt=")[1].split(";")[0]; - - if (!session) { + const token = this.extractJwt(socket); + if (!token) { console.warn(`[SocketManager] No session found for user ${socket.id}, skipping status update`); return; } - // Set auth token for this mutation - this.convex.setAuth(session); - + this.convex.setAuth(token); await this.convex.mutation(api.auth.updateUserStatus, { status: "offline", - isUserSet: false, }); console.log(`[SocketManager] Set user ${socket.id} status to offline`); } catch (error) { @@ -244,5 +283,7 @@ export default class SocketManager { } }); }) + + this.startStatusReconciliation(); } } \ No newline at end of file diff --git a/src/server.ts b/src/server.ts index fa02f26..6b249b0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,7 +1,9 @@ -import { createServer } from 'http' -import next from 'next' -import { parse } from 'url' -import SocketManager from "./lib/sockets" +import { config } from "dotenv"; +import { createServer } from 'http'; +import next from 'next'; +import { parse } from 'url'; +import SocketManager from "./lib/sockets"; +config({ path: '.env.local' }); const port = parseInt(process.env.PORT || '3000', 10) const dev = process.env.NODE_ENV !== 'production' diff --git a/src/types/globals.d.ts b/src/types/globals.d.ts index 2f54808..4462a24 100644 --- a/src/types/globals.d.ts +++ b/src/types/globals.d.ts @@ -134,6 +134,9 @@ declare global { keyId: string publicKey: string }> + createdAt: number + updatedAt: number + keyVersion: number } | null } } diff --git a/src/types/sidebar.d.ts b/src/types/sidebar.d.ts index 5801adf..49d2e49 100644 --- a/src/types/sidebar.d.ts +++ b/src/types/sidebar.d.ts @@ -1,3 +1,4 @@ + declare global { namespace SiPher { @@ -8,12 +9,35 @@ declare global { currentChannel?: SiPher.Channel; disconnectSocket: () => void; connectSocket: () => void; + routeInfo: RouteInfo; + } + + type PageTypes = "friends" | "support" | "dm" | "server" | "nests" | "discover" | "global-nests"; + + type RouteInfo = { + type: PageTypes; + } | { + type: PageTypes.dm; + dmChannelId: string; + } | { + type: PageTypes.server; + serverId: string; + serverChannelId: string; + } | { + type: PageTypes.nests; + serverId: string; + serverChannelId: string; + } | { + type: PageTypes.discover; + } | { + type: PageTypes.support; } interface SidebarItem { id: string; icon: React.ReactNode; label: string; + href?: string; } type OlmStatus = "checking" | "synced" | "mismatched" | "not_setup" | "creating";