From 096d6ab16c9dc0f2b151254b0f06541e2d520a74 Mon Sep 17 00:00:00 2001 From: Nixyan Date: Sun, 28 Dec 2025 04:46:41 -0300 Subject: [PATCH] Enhance user interaction with friend management and participant details - Added `getParticipantDetails` query to fetch details of multiple participants in a direct message channel. - Introduced `dexie-react-hooks` for improved state management with Dexie. - Refactored user validation logic to support optional user authentication. - Created new UI components for friend actions and friend list display. - Implemented a layout structure for the application, including a sidebar and main content area. - Updated socket management to handle connection states more effectively. - Removed deprecated `page.tsx` file and organized routing structure for better maintainability. --- bun.lock | 3 + convex/_generated/api.d.ts | 6 + convex/auth.ts | 11 + convex/betterAuth/_generated/component.ts | 7 + convex/betterAuth/user/index.ts | 68 +++- package.json | 1 + src/app/(app)/channels/me/[id]/page.tsx | 4 + .../servers/[serverId]/[channelId]/page.tsx | 4 + src/app/(app)/layout.tsx | 5 + src/app/(app)/page.tsx | 4 + src/app/page.tsx | 378 ------------------ src/components/app-container.tsx | 133 ++++++ .../ui/friends/friend-actions-menu.tsx | 96 +++++ .../ui/friends/friend-list-item.tsx | 115 ++++++ src/components/ui/friends/friends-page.tsx | 124 ++++++ src/components/ui/friends/index.ts | 7 + src/components/ui/layout/channel-list.tsx | 179 +++++++++ src/components/ui/layout/index.ts | 9 + .../ui/layout/main-content-layout.tsx | 136 +++++++ src/components/ui/layout/page-header.tsx | 148 +++++++ src/components/ui/layout/settings-page.tsx | 18 + src/hooks/use-socket.ts | 30 +- src/lib/constants/phrases.ts | 92 +++++ src/lib/db/index.ts | 53 +-- src/lib/sockets/index.ts | 35 +- src/types/globals.d.ts | 3 +- src/types/sidebar.d.ts | 2 +- 27 files changed, 1248 insertions(+), 423 deletions(-) create mode 100644 src/app/(app)/channels/me/[id]/page.tsx create mode 100644 src/app/(app)/channels/servers/[serverId]/[channelId]/page.tsx create mode 100644 src/app/(app)/layout.tsx create mode 100644 src/app/(app)/page.tsx delete mode 100644 src/app/page.tsx create mode 100644 src/components/app-container.tsx create mode 100644 src/components/ui/friends/friend-actions-menu.tsx create mode 100644 src/components/ui/friends/friend-list-item.tsx create mode 100644 src/components/ui/friends/friends-page.tsx create mode 100644 src/components/ui/friends/index.ts create mode 100644 src/components/ui/layout/channel-list.tsx create mode 100644 src/components/ui/layout/index.ts create mode 100644 src/components/ui/layout/main-content-layout.tsx create mode 100644 src/components/ui/layout/page-header.tsx create mode 100644 src/components/ui/layout/settings-page.tsx create mode 100644 src/lib/constants/phrases.ts diff --git a/bun.lock b/bun.lock index 58de404..bc53d48 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "cross-env": "^10.1.0", "date-fns": "^4.1.0", "dexie": "^4.2.1", + "dexie-react-hooks": "^4.2.0", "framer-motion": "^12.23.26", "libsodium-wrappers": "^0.7.15", "lucide-react": "^0.561.0", @@ -460,6 +461,8 @@ "dexie": ["dexie@4.2.1", "", {}, "sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg=="], + "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=="], + "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=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index b10c6a3..11ebd0f 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -2035,6 +2035,12 @@ export declare const components: { >; getFriendRequests: FunctionReference<"query", "internal", any, any>; getFriends: FunctionReference<"query", "internal", any, any>; + getParticipantDetails: FunctionReference< + "query", + "internal", + { participantIds: Array }, + any + >; getUserStatus: FunctionReference<"query", "internal", any, any>; sendFriendRequest: FunctionReference< "mutation", diff --git a/convex/auth.ts b/convex/auth.ts index 9054754..69f82e8 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -183,4 +183,15 @@ export const getUserStatus = query({ handler: async (ctx) => { return ctx.runQuery(components.betterAuth.user.index.getUserStatus) }, +}); + +export const getParticipantDetails = query({ + args: { + participantIds: v.array(v.string()), + }, + handler: async (ctx, args) => { + return ctx.runQuery(components.betterAuth.user.index.getParticipantDetails, { + participantIds: args.participantIds, + }); + }, }); \ No newline at end of file diff --git a/convex/betterAuth/_generated/component.ts b/convex/betterAuth/_generated/component.ts index ebb09c3..bb6e713 100644 --- a/convex/betterAuth/_generated/component.ts +++ b/convex/betterAuth/_generated/component.ts @@ -2024,6 +2024,13 @@ export type ComponentApi = Name >; getFriends: FunctionReference<"query", "internal", any, any, Name>; + getParticipantDetails: FunctionReference< + "query", + "internal", + { participantIds: Array }, + any, + Name + >; getUserStatus: FunctionReference<"query", "internal", any, any, Name>; sendFriendRequest: FunctionReference< "mutation", diff --git a/convex/betterAuth/user/index.ts b/convex/betterAuth/user/index.ts index 51d417f..3703928 100644 --- a/convex/betterAuth/user/index.ts +++ b/convex/betterAuth/user/index.ts @@ -2,21 +2,27 @@ import { v } from "convex/values"; import { Id } from "../_generated/dataModel"; import { mutation, MutationCtx, query, QueryCtx } from "../_generated/server"; -async function userValidation(ctx: MutationCtx | QueryCtx) { +// 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: any }>; + +// Implementation +async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required?: boolean }) { + const required = options?.required ?? true; + const user = await ctx.auth.getUserIdentity(); if (!user) { - throw new Error("User not found"); + 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) { - throw new Error("User not found"); + if (required) throw new Error("User not found"); + return null; } - return { - userId, - user, - } + return { userId, user }; } export const updateUserStatus = mutation({ @@ -54,7 +60,12 @@ export const updateUserStatus = mutation({ export const getUserStatus = query({ handler: async (ctx) => { - const { userId } = await userValidation(ctx); + const validation = await userValidation(ctx, { required: false }); + if (!validation) { + return null; // User not authenticated + } + + const { userId } = validation; const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first(); return userStatus; } @@ -310,11 +321,50 @@ export const getFriends = query({ status: friendStatus ? { status: friendStatus.status, isUserSet: friendStatus.isUserSet, - } : null, + } : { + status: "offline" as const, + isUserSet: false, + }, }; }) ); return friends.filter(Boolean); } +}) + +export const getParticipantDetails = query({ + args: { + participantIds: v.array(v.string()), + }, + handler: async (ctx, args) => { + const { participantIds } = args; + const { userId } = await userValidation(ctx); + if (!userId) throw new Error("User not found"); + + if (participantIds.length === 0) return []; + const normalizedParticipantIds = participantIds.map((id) => ctx.db.normalizeId("user", id)); + if (normalizedParticipantIds.length === 0) return []; + + // Filter out all null values + const filteredParticipantIds = normalizedParticipantIds.filter((id) => id !== null); + if (filteredParticipantIds.length === 0) return []; + + const participantDetails = await Promise.all(filteredParticipantIds.map(async (id) => { + const participant = await ctx.db.get("user", id) + const participantStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", id)).first(); + if (!participant) return null; + + return { + id: participant._id, + name: participant.name, + username: participant.username, + displayUsername: participant.displayUsername, + image: participant.image, + status: participantStatus?.status || "offline", + } + })); + + return participantDetails.filter(Boolean); + } }) \ No newline at end of file diff --git a/package.json b/package.json index 55eec08..a7e3392 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "cross-env": "^10.1.0", "date-fns": "^4.1.0", "dexie": "^4.2.1", + "dexie-react-hooks": "^4.2.0", "framer-motion": "^12.23.26", "libsodium-wrappers": "^0.7.15", "lucide-react": "^0.561.0", diff --git a/src/app/(app)/channels/me/[id]/page.tsx b/src/app/(app)/channels/me/[id]/page.tsx new file mode 100644 index 0000000..279cc3e --- /dev/null +++ b/src/app/(app)/channels/me/[id]/page.tsx @@ -0,0 +1,4 @@ +export default function DmPage() { + return null; +} + diff --git a/src/app/(app)/channels/servers/[serverId]/[channelId]/page.tsx b/src/app/(app)/channels/servers/[serverId]/[channelId]/page.tsx new file mode 100644 index 0000000..166b182 --- /dev/null +++ b/src/app/(app)/channels/servers/[serverId]/[channelId]/page.tsx @@ -0,0 +1,4 @@ +export default function ServerChannelPage() { + return null; +} + diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..a8cfd8f --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,5 @@ +import AppContainer from "@/components/app-container"; + +export default function AppLayout() { + return ; +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx new file mode 100644 index 0000000..382326e --- /dev/null +++ b/src/app/(app)/page.tsx @@ -0,0 +1,4 @@ +export default function HomePage() { + return null; +} + diff --git a/src/app/page.tsx b/src/app/page.tsx deleted file mode 100644 index 717bcf4..0000000 --- a/src/app/page.tsx +++ /dev/null @@ -1,378 +0,0 @@ -"use client" -import AppSidebar from "@/components/home"; -import FriendRequestModal from "@/components/home/modals/friendRequest"; -import OlmSetupDialog from "@/components/olm/olm-setup-dialog"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Spinner } from "@/components/ui/spinner"; -import UserFloatingCard from "@/components/ui/user/floating-card"; -import { useOlmSetup } from "@/hooks/use-olm-setup"; -import { useSocket } from "@/hooks/use-socket"; -import { authClient } from "@/lib/auth/client"; -import { useMutation, useQuery } from "convex/react"; -import { PlusIcon, SearchIcon, SettingsIcon, UsersIcon } from "lucide-react"; -import { redirect } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; -import { api } from "../../convex/_generated/api"; -const mockPhrases = [ - "No bitches? Womp womp", - "You're all alone", - "No friends? Damn", - "Oh look, a spiderweb!", - "You must be bored, go make some friends", - "DMs drier than the Sahara", - "Echo echo... anyone there?", - "Your inbox called, it's collecting dust", - "Even the bots won't slide in", - "Social life on life support", - "Crickets in the chat", - "Zero notifications? Skill issue", - "This is the quietest room on the internet", - "Go outside, the graphics are better", - "Loneliness speedrun any%", - "Your DMs look like a ghost town", - "Population: You", - "Unread messages: 0 (forever)", - "Bro really out here talking to himself", - "The void stares back", - "Touch grass detected: false", - "Friends list looking minimalist", - "Inbox so empty it has an echo", - "No one loves you... yet", - "Slide into someone's DMs instead of staring at none", -]; - -const comfortingPhrases = [ - "Quiet inbox today—just a little peace and quiet", - "Empty DMs mean more time for you", - "Even when it's silent here, you're never truly alone", - "Sometimes the best company is your own thoughts", - "Take a deep breath—this calm won't last forever", - "Your worth isn't measured by notifications", - "The right people will show up exactly when they're meant to", - "God is with you in the silence, just like always", - "'Be still, and know that I am God' – Psalm 46:10", - "An empty inbox is just a blank page waiting for new stories", - "Enjoy the quiet while it lasts—life gets loud again soon", - "You're building strength in these quiet moments", - "Real connections can't be rushed; they're coming", - "In the stillness, you can hear your own heart clearest", - "'I am with you always' – Matthew 28:20", - "No rush—good things take time", - "This is your moment to recharge without distractions", - "Loneliness is temporary; connection is inevitable", - "God's presence fills every empty space", - "Silence isn't empty—it's full of possibility", - "You're exactly where you need to be right now", - "The best conversations often start after a little quiet", - "Peaceful DMs = a peaceful mind", - "Don't worry, someone is thinking of you right now", - "You're not alone, we're all here for you", - "Trust the process, even if it's slow and painful", - "Someone out there is thinking of messaging you... any second now", - "You're loved more than you know, messages or not", - "Silence is a rare gift in such a noisy world", - "No notifications means no demands on your energy today", - "God is working behind the scenes on your behalf", - "Your value exists completely outside of this app", - "Take this moment to simply be, rather than do", - "The right message will arrive at the perfect time", - "You are safe, loved, and held in this silence", - "Let the quiet wash over you like a gentle wave", - "He knows the desires of your heart—have faith", - "A quiet screen is just an invitation to look up", - "True connection starts with being comfortable within yourself", - "Your soul needs this rest more than a quick reply", - "Someone, somewhere, is grateful that you exist today", - "Prayers travel much further than any direct message can", - "You are preserving your peace for something better", - "God's timing is rarely early, but never late", - "Use this time to love yourself a little harder", - "The world is loud, but your space is peaceful", - "You don't need a buzz in your pocket to matter", - "Rest easy, the right people are finding their way" -]; - -export default function Home() { - const { data, error, isPending, refetch } = authClient.useSession(); - - const [page, setPage] = useState<"friends" | "support">("friends"); - const [currentChannel, setCurrentChannel] = useState(null); - const [openDmChannels, setOpenDmChannels] = useState([]); - const [availableServers, setAvailableServers] = useState([]); - - // Friends page state - const [friendsPage, setFriendsPage] = useState<"all" | "available">("all"); - const [friendsSearch, setFriendsSearch] = useState(""); - const [friendModal, setFriendModal] = useState(false); - - const hasServerOlm = useQuery( - api.auth.retrieveServerOlmAccount, - data?.user?.id ? { userId: data.user.id } : "skip" - ); - - // Get user status from separate table - const userStatus = useQuery(api.auth.getUserStatus); - - // Get friends list (reactive) - const friends = useQuery(api.auth.getFriends); - - // Type for friends - type Friend = NonNullable[number]; - - // Mutation for sending keys to server - const sendKeysToServer = useMutation(api.auth.sendKeysToServer); - - - const updateUserMetadata = useMutation(api.auth.updateUserMetadata); - useEffect(() => { - if (!data) return; - - const metadata = data.user.metadata - if (!metadata) { - console.debug( - "[Home] > User metadata set", - data.user.metadata - ) - updateUserMetadata({ metadata: { phrasePreference: "comforting" } }); - return - } - }, [data, updateUserMetadata]); - - // Custom hooks for socket and OLM management - const { socketStatus, socketInfo, disconnect, connect } = useSocket({ - user: { - id: data?.user?.id, - status: userStatus ? { - status: userStatus.status, - isUserSet: userStatus.isUserSet, - } : { - status: "offline" as const, - isUserSet: false, - }, - }, - refetchUser: refetch - }); - const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmSetup({ - userId: data?.user?.id, - hasServerOlm, - sendKeysToServer - }); - - if (isPending) { - return
- -
- } - - if (error || !data) { - return redirect(`/auth${error ? `?error=${error.cause}` : "?error=no-data"}`); - } - - const getRandomPhrase = useCallback(() => { - const phrases = { - comforting: comfortingPhrases, - mocking: mockPhrases, - both: [...comfortingPhrases, ...mockPhrases] - } - - const preference = data.user.metadata?.phrasePreference as keyof typeof phrases; - - if (!preference) return comfortingPhrases[Math.floor(Math.random() * comfortingPhrases.length)]; - - return phrases[preference][Math.floor(Math.random() * phrases[preference].length)]; - }, [data.user.metadata?.phrasePreference]); - - return ( - <> - - -
- {/* Header - fixed height and sticky */} -
- {/* SCS or DM Selector */} -
- { - // If the current channel is none or a DM, we show a search bar - !currentChannel || currentChannel.type === SiPher.ChannelType.DM ? ( - - ) : ( - {currentChannel.name} - ) - } -
- {/* Page title/options */} - { - page === "friends" ? ( -
-
- - Friends -
- -
- - - -
-
- ) : null - } -
- {/* Content Area - Channel List + Main Content */} -
- {/* Channel List */} -
- {/* Channel List Header - sticky top */} -
-
- - -
-
-
- {/* Channel List */} -
- {/* Channel List Item */} -
- { - currentChannel && currentChannel.type === SiPher.ChannelType.DM || !currentChannel ? ( -
-
- Direct Messages - -
- { - openDmChannels.length > 0 ? ( - openDmChannels.map((channel) => ( - - )) - ) : ( -
- - {getRandomPhrase()} - -
- ) - } -
- ) : ( -
- No channels -
- ) - } -
-
-
- - {/* Main Content */} -
- { - page === "friends" ? ( -
-
- setFriendsSearch(e.target.value)} - className="w-full min-h-10 sticky top-0" - /> - { - friendsPage === "all" ? ( -
- All Friends • {friends ? friends.length : 0} - { - friends && friends.length > 0 ? ( - friends.map((friend: Friend) => { - if (!friend) return null; - - return ( -
- {friend.displayUsername || friend.username || friend.name} -
- ) - }) - ) : ( - - {getRandomPhrase()} - - ) - } -
- ) : ( -
- Available Friends • {friends ? friends.filter((f: Friend) => f && f.status?.status !== "offline").length : 0} - { - friends && friends.length > 0 ? ( - friends - .filter((f: Friend) => f && f.status?.status !== "offline") - .map((friend: Friend) => { - if (!friend) return null; - - return ( -
- {friend.displayUsername || friend.username || friend.name} -
- ) - }) - ) : ( - - {getRandomPhrase()} - - ) - } -
- ) - } -
-
- ) : page === "support" ? ( -
-
- Servers -
-
- ) : null - } -
-
-
- - - - {/* OLM Account Setup/Sync Modal */} - - - ) -} \ No newline at end of file diff --git a/src/components/app-container.tsx b/src/components/app-container.tsx new file mode 100644 index 0000000..da1eddd --- /dev/null +++ b/src/components/app-container.tsx @@ -0,0 +1,133 @@ +"use client" + +import AppSidebar from "@/components/home"; +import OlmSetupDialog from "@/components/olm/olm-setup-dialog"; +import { MainContentLayout } from "@/components/ui/layout"; +import { Spinner } from "@/components/ui/spinner"; +import UserFloatingCard from "@/components/ui/user/floating-card"; +import { useOlmSetup } from "@/hooks/use-olm-setup"; +import { useSocket } from "@/hooks/use-socket"; +import { authClient } from "@/lib/auth/client"; +import { getRandomPhrase, type PhrasePreference } from "@/lib/constants/phrases"; +import { useMutation, useQuery } from "convex/react"; +import { redirect, useParams, usePathname } from "next/navigation"; +import { useCallback, useEffect, useMemo } from "react"; +import { api } from "../../convex/_generated/api"; + +export default function AppContainer() { + 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 + }; + } + 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 }; + }, [pathname, params]); + const { data, error, isPending, refetch } = authClient.useSession(); + + const hasServerOlm = useQuery( + api.auth.retrieveServerOlmAccount, + data?.user?.id ? { userId: data.user.id } : "skip" + ); + + const userStatus = useQuery(api.auth.getUserStatus); + const { socketStatus, socketInfo, disconnect, connect } = useSocket({ + user: { + id: data?.user?.id, + status: userStatus ? { + status: userStatus.status, + isUserSet: userStatus.isUserSet, + } : { + status: "offline" as const, + isUserSet: false, + }, + }, + refetchUser: refetch + }); + + const sendKeysToServer = useMutation(api.auth.sendKeysToServer); + const updateUserMetadata = useMutation(api.auth.updateUserMetadata); + + useEffect(() => { + if (!data) return; + const metadata = data.user.metadata + if (!metadata) { + console.debug("[AppContainer] > User metadata set", data.user.metadata) + updateUserMetadata({ metadata: { phrasePreference: "comforting" } }); + } + }, [data, updateUserMetadata]); + + const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmSetup({ + userId: data?.user?.id, + hasServerOlm, + sendKeysToServer + }); + + const getPhrase = useCallback(() => { + const preference = data?.user?.metadata?.phrasePreference as PhrasePreference | undefined; + return getRandomPhrase(preference); + }, [data?.user?.metadata?.phrasePreference]); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (error || !data) { + return redirect(`/auth${error ? `?error=${error.cause}` : "?error=no-data"}`); + } + + if (["connecting", "error", "disconnected"].includes(socketStatus)) { + return ( +
+ +
+ ); + } + + return ( + <> + + + + + + + + ); +} + diff --git a/src/components/ui/friends/friend-actions-menu.tsx b/src/components/ui/friends/friend-actions-menu.tsx new file mode 100644 index 0000000..200fa89 --- /dev/null +++ b/src/components/ui/friends/friend-actions-menu.tsx @@ -0,0 +1,96 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Separator } from "@/components/ui/separator" +import { + MoreVerticalIcon, + PhoneIcon, + ShieldBanIcon, + UserMinusIcon, + UsersIcon, + VideoIcon, +} from "lucide-react" + +export interface FriendActionsMenuProps { + friendId: string + onStartCall?: (friendId: string) => void + onVideoCall?: (friendId: string) => void + onViewProfile?: (friendId: string) => void + onRemoveFriend?: (friendId: string) => void + onBlock?: (friendId: string) => void +} + +export function FriendActionsMenu({ + friendId, + onStartCall, + onVideoCall, + onViewProfile, + onRemoveFriend, + onBlock, +}: FriendActionsMenuProps) { + return ( + + + + + +
+ + + + + + +
+
+
+ ) +} + diff --git a/src/components/ui/friends/friend-list-item.tsx b/src/components/ui/friends/friend-list-item.tsx new file mode 100644 index 0000000..4b64618 --- /dev/null +++ b/src/components/ui/friends/friend-list-item.tsx @@ -0,0 +1,115 @@ +"use client" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { getOrCreateDmChannel } from "@/lib/db" +import { MessageCircleIcon } from "lucide-react" +import { useRouter } from "next/navigation" +import { FriendActionsMenu } from "./friend-actions-menu" + +export interface FriendData { + _id: string + id: string + name?: string | null + username?: string | null + displayUsername?: string | null + image?: string | null + friendshipCreatedAt?: number + status?: { + status: "online" | "busy" | "offline" | "away" + isUserSet: boolean + } +} + +export interface FriendListItemProps { + friend: FriendData + onMessage?: (friendId: string) => void + onStartCall?: (friendId: string) => void + onVideoCall?: (friendId: string) => void + onViewProfile?: (friendId: string) => void + onRemoveFriend?: (friendId: string) => void + onBlock?: (friendId: string) => void + userId: string +} + +export function FriendListItem({ + friend, + onMessage, + onStartCall, + onVideoCall, + onViewProfile, + onRemoveFriend, + onBlock, + userId, +}: FriendListItemProps) { + const router = useRouter() + const displayName = friend.displayUsername || friend.username || friend.name + const status = friend.status?.status || "offline" + const statusColor = { + online: "bg-green-500", + idle: "bg-yellow-500", + dnd: "bg-red-500", + offline: "bg-gray-500" + }[status as "online" | "idle" | "dnd" | "offline"] + + return ( +
{ + // Call the db to create or get the dm channel + getOrCreateDmChannel(userId, friend).then((channel) => { + if (channel) { + router.push(`/channels/me/${channel.id}`) + } + }) + }} + > + {/* Left side: Avatar + Info */} +
+
+ + + + {displayName?.charAt(0).toUpperCase()} + + +
+
+
+ + {displayName} + + + {status} + +
+
+ + {/* Right side: Actions Menu */} +
+ + + +
+
+ ) +} + diff --git a/src/components/ui/friends/friends-page.tsx b/src/components/ui/friends/friends-page.tsx new file mode 100644 index 0000000..06f6cbb --- /dev/null +++ b/src/components/ui/friends/friends-page.tsx @@ -0,0 +1,124 @@ +"use client" + +import { Input } from "@/components/ui/input" +import { useQuery } from "convex/react" +import * as React from "react" +import { api } from "../../../../convex/_generated/api" +import { FriendListItem, type FriendData } from "./friend-list-item" + +export interface FriendsPageProps { + friendsPage: "all" | "available" + socketStatus: string + emptyMessage?: string + userId: string +} + +export function FriendsPage({ + friendsPage, + socketStatus, + userId, + emptyMessage = "No friends found", +}: FriendsPageProps) { + const [friendsSearch, setFriendsSearch] = React.useState("") + + // Fetch friends directly in this component + const friends = useQuery( + api.auth.getFriends, + socketStatus === "connected" ? {} : "skip" + ) + + const filteredFriends = React.useMemo(() => { + if (!friends) return [] + + let filtered = friends.filter(Boolean) as FriendData[] + + // Filter by availability + if (friendsPage === "available") { + filtered = filtered.filter((f: FriendData) => f.status?.status !== "offline") + } + + // Filter by search + if (friendsSearch) { + const search = friendsSearch.toLowerCase() + filtered = filtered.filter((f: FriendData) => { + const displayName = f.displayUsername || f.username || f.name || "" + return displayName.toLowerCase().includes(search) + }) + } + + return filtered + }, [friends, friendsPage, friendsSearch]) + + const handleMessage = React.useCallback((friendId: string) => { + // TODO: Open DM with friend + console.log("Open DM with", friendId) + }, []) + + const handleStartCall = React.useCallback((friendId: string) => { + console.log("Start Call with", friendId) + }, []) + + const handleVideoCall = React.useCallback((friendId: string) => { + console.log("Start Video Call with", friendId) + }, []) + + const handleViewProfile = React.useCallback((friendId: string) => { + console.log("View Profile", friendId) + }, []) + + const handleRemoveFriend = React.useCallback((friendId: string) => { + console.log("Remove Friend", friendId) + }, []) + + const handleBlock = React.useCallback((friendId: string) => { + console.log("Block User", friendId) + }, []) + + return ( +
+ {/* Search Input - Sticky at top */} +
+ setFriendsSearch(e.target.value)} + className="w-full" + /> +
+ + {/* Scrollable Friends List */} +
+
+ + {friendsPage === "all" + ? `All Friends • ${filteredFriends.length} of ${friends?.length || 0}` + : `Available Friends • ${filteredFriends.length} of ${friends?.filter((f: FriendData) => f && f.status?.status !== "offline").length || 0}` + } + + {filteredFriends.length > 0 ? ( + filteredFriends.map((friend: FriendData) => ( + + )) + ) : ( +
+ + {friendsSearch ? `No friends found matching "${friendsSearch}"` : emptyMessage} + +
+ )} +
+
+
+ ) +} + diff --git a/src/components/ui/friends/index.ts b/src/components/ui/friends/index.ts new file mode 100644 index 0000000..1df8abe --- /dev/null +++ b/src/components/ui/friends/index.ts @@ -0,0 +1,7 @@ +export { FriendListItem } from "./friend-list-item" +export type { FriendListItemProps, FriendData } from "./friend-list-item" +export { FriendActionsMenu } from "./friend-actions-menu" +export type { FriendActionsMenuProps } from "./friend-actions-menu" +export { FriendsPage } from "./friends-page" +export type { FriendsPageProps } from "./friends-page" + diff --git a/src/components/ui/layout/channel-list.tsx b/src/components/ui/layout/channel-list.tsx new file mode 100644 index 0000000..a986620 --- /dev/null +++ b/src/components/ui/layout/channel-list.tsx @@ -0,0 +1,179 @@ +"use client" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Button } from "@/components/ui/button" +import { db } from "@/lib/db" +import { cn } from "@/lib/utils" +import { formatDistanceToNow } from "date-fns" +import { PlusIcon, SettingsIcon, UsersIcon, XIcon } from "lucide-react" +import { useRouter } from "next/navigation" + +export interface ChannelListProps { + currentChannel: SiPher.Channel | null + openDmChannels: SiPher.Channel[] + page: "friends" | "support" | "dm" | "server" + onPageChange: (page: "friends" | "support" | "dm" | "server") => void + emptyMessage?: string + dmChannel?: { + id: string + participantDetails: { + id: string + name: string + username: string + displayUsername: string + image: string + status: "online" | "busy" | "offline" | "away" + }[] + } +} + +export function ChannelList({ + currentChannel, + openDmChannels, + page, + onPageChange, + emptyMessage = "No messages yet", + dmChannel, +}: ChannelListProps) { + const router = useRouter() + + return ( +
+ {/* Channel List Header */} +
+
+ + +
+
+ +
+ + {/* Channel List */} +
+ {page === "friends" || !currentChannel ? ( +
+
+ + Direct Messages + + +
+ {openDmChannels.length > 0 ? ( + openDmChannels.map((channel) => { + const isActive = dmChannel?.id === channel.id + const lastMessage = channel.times?.lastMessage + const lastMessageTime = channel.times?.lastMessageAt + if (!channel.isOpen) return null; + + return ( +
router.push(`/channels/me/${channel.id}`)} + > + {/* Avatar */} +
+ + + + {channel.name?.charAt(0).toUpperCase()} + + + +
+ + {/* Channel Info */} +
+
+ + {channel.name} + + {lastMessageTime && ( + + {formatDistanceToNow(lastMessageTime, { addSuffix: false })} + + )} +
+ {lastMessage && ( + + {lastMessage.content} + + )} +
+ + {/* Close button */} + +
+ ) + }) + ) : ( +
+ + {emptyMessage} + +
+ )} +
+ ) : ( +
+ No channels +
+ )} +
+
+ ) +} + diff --git a/src/components/ui/layout/index.ts b/src/components/ui/layout/index.ts new file mode 100644 index 0000000..1b282dd --- /dev/null +++ b/src/components/ui/layout/index.ts @@ -0,0 +1,9 @@ +export { ChannelList } from "./channel-list" +export type { ChannelListProps } from "./channel-list" +export { MainContentLayout } from "./main-content-layout" +export type { MainContentLayoutProps } from "./main-content-layout" +export { PageHeader } from "./page-header" +export type { PageHeaderProps } from "./page-header" +export { SettingsPage } from "./settings-page" +export type { SettingsPageProps } from "./settings-page" + diff --git a/src/components/ui/layout/main-content-layout.tsx b/src/components/ui/layout/main-content-layout.tsx new file mode 100644 index 0000000..ee7d688 --- /dev/null +++ b/src/components/ui/layout/main-content-layout.tsx @@ -0,0 +1,136 @@ +"use client" + +import FriendRequestModal from "@/components/home/modals/friendRequest" +import { db } from "@/lib/db" +import { useQuery } from "convex/react" +import { useLiveQuery } from "dexie-react-hooks" +import * as React from "react" +import { useEffect, useMemo } from "react" +import { api } from "../../../../convex/_generated/api" +import { FriendsPage } from "../friends/friends-page" +import { ChannelList } from "./channel-list" +import { PageHeader } from "./page-header" +import { SettingsPage } from "./settings-page" + +export interface MainContentLayoutProps { + socketStatus: string + emptyChannelMessage?: string + emptyFriendsMessage?: string + userId: string + dmChannelId?: string + serverId?: string + serverChannelId?: string +} + +export function MainContentLayout({ + socketStatus, + emptyChannelMessage, + emptyFriendsMessage, + userId, + dmChannelId, + serverId, + serverChannelId, +}: MainContentLayoutProps) { + const [page, setPage] = React.useState<"friends" | "support" | "dm" | "server">( + dmChannelId ? "dm" : serverChannelId ? "server" : "friends" + ) + const [friendsPage, setFriendsPage] = React.useState<"all" | "available">("all") + const [friendModal, setFriendModal] = React.useState(false) + const [currentChannel] = React.useState(null) + + // Use useLiveQuery to reactively fetch channels - automatically updates when DB changes + const openDmChannels = useLiveQuery( + () => db.channels.where("participants").equals(userId).toArray(), + [userId] + ) ?? [] + + const getParticipantDetails = useQuery(api.auth.getParticipantDetails, dmChannelId ? { + participantIds: openDmChannels + .find((channel) => channel.id === dmChannelId) + ?.participants + .filter((participant) => participant !== userId) ?? [] + } : "skip") + + // Combine channel from local DB with participant details from Convex + const dmChannel = useMemo(() => { + if (!dmChannelId) return undefined + + const channel = openDmChannels.find((ch) => ch.id === dmChannelId) + if (!channel || !getParticipantDetails) return undefined + + return { + id: channel.id, + participantDetails: getParticipantDetails ?? [] + } + }, [openDmChannels, dmChannelId, getParticipantDetails]) + + // Sync page state with route props for seamless navigation + useEffect(() => { + if (dmChannelId) { + setPage("dm"); + } else if (serverChannelId) { + setPage("server"); + } else { + setPage("friends"); + } + }, [dmChannelId, serverChannelId]); + + return ( + <> +
+ {/* Header */} + setFriendModal(true)} + dmChannel={dmChannel} + serverId={serverId} + serverChannelId={serverChannelId} + /> + + {/* Content Area - Channel List + Main Content */} +
+ + + + {/* Main Content */} +
+ {page === "dm" ? ( +
+

DM chat with {dmChannelId}

+
+ ) : page === "server" ? ( +
+

Server channel {serverChannelId}

+
+ ) : page === "friends" ? ( + + ) : ( + + )} +
+
+
+ + + + ) +} + diff --git a/src/components/ui/layout/page-header.tsx b/src/components/ui/layout/page-header.tsx new file mode 100644 index 0000000..c2a605c --- /dev/null +++ b/src/components/ui/layout/page-header.tsx @@ -0,0 +1,148 @@ +"use client" + +import { Button } from "@/components/ui/button" +import { cn } from "@/lib/utils" +import { PhoneIcon, SearchIcon, UserIcon, UsersIcon, VideoIcon } from "lucide-react" +import { Avatar, AvatarFallback, AvatarImage } from "../avatar" + +export interface PageHeaderProps { + currentChannel: SiPher.Channel | null + page: "friends" | "support" | "dm" | "server" + friendsPage?: "all" | "available" + onFriendsPageChange?: (page: "all" | "available") => void + onAddFriend?: () => void + dmChannel?: { + id: string + participantDetails: { + id: string + name: string + username: string + displayUsername: string + image: string + status: "online" | "busy" | "offline" | "away" + }[] + } + serverId?: string + serverChannelId?: string +} + +const statusColors: Record<"online" | "busy" | "offline" | "away", string> = { + online: "bg-emerald-500", + busy: "bg-red-500", + away: "bg-yellow-500", + offline: "bg-muted-foreground" +}; + +export function PageHeader({ + currentChannel, + page, + friendsPage, + onFriendsPageChange, + onAddFriend, + dmChannel, + serverId, + serverChannelId, +}: PageHeaderProps) { + return ( +
+ {/* SCS or DM Selector */} +
+ {!currentChannel || currentChannel.type === "DM" ? ( + + ) : ( + {currentChannel.name} + )} +
+ + {/* Page title/options */} + {dmChannel ? ( +
+
+ + + + {dmChannel.participantDetails[0].name?.charAt(0).toUpperCase()} + + + +
+ {dmChannel.participantDetails[0].name} +
+ + + +
+
+ ) : serverChannelId ? ( +
+ #{serverChannelId} +
+ ) : page === "friends" ? ( +
+
+ + Friends +
+ +
+ + + +
+
+ ) : null} +
+ ) +} + diff --git a/src/components/ui/layout/settings-page.tsx b/src/components/ui/layout/settings-page.tsx new file mode 100644 index 0000000..2a0b578 --- /dev/null +++ b/src/components/ui/layout/settings-page.tsx @@ -0,0 +1,18 @@ +"use client" + +import * as React from "react" + +export interface SettingsPageProps { + // Add settings-specific props as needed +} + +export function SettingsPage({}: SettingsPageProps) { + return ( +
+
+ Servers +
+
+ ) +} + diff --git a/src/hooks/use-socket.ts b/src/hooks/use-socket.ts index c119882..c05663e 100644 --- a/src/hooks/use-socket.ts +++ b/src/hooks/use-socket.ts @@ -40,7 +40,7 @@ export function useSocket({ user, refetchUser }: UseSocketProps) { clearInterval(pingIntervalRef.current); pingIntervalRef.current = null; } - setSocketStatus("disconnected"); + setSocketStatus("manually_disconnected"); } }, []); @@ -54,7 +54,9 @@ export function useSocket({ user, refetchUser }: UseSocketProps) { useEffect(() => { if (!user.id) return; - const socket: Socket = io({ withCredentials: false }); + const socket: Socket = io({ + withCredentials: true, reconnectionAttempts: 3, reconnectionDelay: 1000, reconnectionDelayMax: 5000 + }); socketRef.current = socket; // Measure ping latency using acknowledgment callback @@ -145,7 +147,6 @@ export function useSocket({ user, refetchUser }: UseSocketProps) { socket.on("disconnect", (reason) => { console.log("🔌 Disconnected from socket:", reason); - setUserDefaultStatus("offline", user.status); setSocketStatus("disconnected"); setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, @@ -159,9 +160,26 @@ export function useSocket({ user, refetchUser }: UseSocketProps) { } }); - // Handle pong response for ping measurement - socket.on("pong", () => { - // Handled in measurePing callback + socket.on("reconnect_attempt", (attempt) => { + console.log("🔌 Reconnect attempt:", attempt); + setSocketStatus("connecting"); + setSocketInfo((prev: SiPher.SocketInfo) => ({ + ...prev, + ping: null, + connectedAt: null, + error: null + })); + }); + + socket.on("reconnect_error", (error) => { + console.error("❌ Reconnect error:", error); + setSocketStatus("error"); + setSocketInfo((prev: SiPher.SocketInfo) => ({ + ...prev, + ping: null, + connectedAt: null, + error: error.message + })); }); return () => { diff --git a/src/lib/constants/phrases.ts b/src/lib/constants/phrases.ts new file mode 100644 index 0000000..654726a --- /dev/null +++ b/src/lib/constants/phrases.ts @@ -0,0 +1,92 @@ +export const mockPhrases = [ + "No bitches? Womp womp", + "You're all alone", + "No friends? Damn", + "Oh look, a spiderweb!", + "You must be bored, go make some friends", + "DMs drier than the Sahara", + "Echo echo... anyone there?", + "Your inbox called, it's collecting dust", + "Even the bots won't slide in", + "Social life on life support", + "Crickets in the chat", + "Zero notifications? Skill issue", + "This is the quietest room on the internet", + "Go outside, the graphics are better", + "Loneliness speedrun any%", + "Your DMs look like a ghost town", + "Population: You", + "Unread messages: 0 (forever)", + "Bro really out here talking to himself", + "The void stares back", + "Touch grass detected: false", + "Friends list looking minimalist", + "Inbox so empty it has an echo", + "No one loves you... yet", + "Slide into someone's DMs instead of staring at none", +] + +export const comfortingPhrases = [ + "Quiet inbox today—just a little peace and quiet", + "Empty DMs mean more time for you", + "Even when it's silent here, you're never truly alone", + "Sometimes the best company is your own thoughts", + "Take a deep breath—this calm won't last forever", + "Your worth isn't measured by notifications", + "The right people will show up exactly when they're meant to", + "God is with you in the silence, just like always", + "'Be still, and know that I am God' – Psalm 46:10", + "An empty inbox is just a blank page waiting for new stories", + "Enjoy the quiet while it lasts—life gets loud again soon", + "You're building strength in these quiet moments", + "Real connections can't be rushed; they're coming", + "In the stillness, you can hear your own heart clearest", + "'I am with you always' – Matthew 28:20", + "No rush—good things take time", + "This is your moment to recharge without distractions", + "Loneliness is temporary; connection is inevitable", + "God's presence fills every empty space", + "Silence isn't empty—it's full of possibility", + "You're exactly where you need to be right now", + "The best conversations often start after a little quiet", + "Peaceful DMs = a peaceful mind", + "Don't worry, someone is thinking of you right now", + "You're not alone, we're all here for you", + "Trust the process, even if it's slow and painful", + "Someone out there is thinking of messaging you... any second now", + "You're loved more than you know, messages or not", + "Silence is a rare gift in such a noisy world", + "No notifications means no demands on your energy today", + "God is working behind the scenes on your behalf", + "Your value exists completely outside of this app", + "Take this moment to simply be, rather than do", + "The right message will arrive at the perfect time", + "You are safe, loved, and held in this silence", + "Let the quiet wash over you like a gentle wave", + "He knows the desires of your heart—have faith", + "A quiet screen is just an invitation to look up", + "True connection starts with being comfortable within yourself", + "Your soul needs this rest more than a quick reply", + "Someone, somewhere, is grateful that you exist today", + "Prayers travel much further than any direct message can", + "You are preserving your peace for something better", + "God's timing is rarely early, but never late", + "Use this time to love yourself a little harder", + "The world is loud, but your space is peaceful", + "You don't need a buzz in your pocket to matter", + "Rest easy, the right people are finding their way" +] + +export type PhrasePreference = "comforting" | "mocking" | "both" + +export function getRandomPhrase(preference?: PhrasePreference): string { + const phrases = { + comforting: comfortingPhrases, + mocking: mockPhrases, + both: [...comfortingPhrases, ...mockPhrases] + } + + const selectedPhrases = preference ? phrases[preference] : comfortingPhrases + return selectedPhrases[Math.floor(Math.random() * selectedPhrases.length)] +} + diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 953c0e8..fb5983f 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -1,4 +1,5 @@ import Dexie, { type EntityTable } from "dexie"; +import { getDmRoomId } from "../sockets/events/dm"; // ============================================ // Types @@ -21,17 +22,6 @@ export interface OlmSession { updatedAt: number; } -/** DM channel */ -export interface Channel { - id: string; // Deterministic room ID (dm:hash) - participants: string[]; // User IDs in this channel - type: "dm" | "group"; - name?: string; // For groups - createdAt: number; - updatedAt: number; - lastMessageAt?: number; -} - /** Message stored locally */ export interface Message { id: string; // Unique message ID @@ -55,7 +45,7 @@ export interface UnreadCount { class SipherDB extends Dexie { olmAccounts!: EntityTable; olmSessions!: EntityTable; - channels!: EntityTable; + channels!: EntityTable; messages!: EntityTable; unreadCounts!: EntityTable; @@ -81,21 +71,33 @@ export const db = new SipherDB(); /** Get or create a DM channel with another user */ export async function getOrCreateDmChannel( myUserId: string, - otherUserId: string -): Promise { + otherUser: any +): Promise { // Generate deterministic channel ID - const sorted = [myUserId, otherUserId].sort().join(":"); - const channelId = `dm:${await hashString(sorted)}`; + const channelId = getDmRoomId(myUserId, otherUser.id); const existing = await db.channels.get(channelId); - if (existing) return existing; + if (existing) { + // Change the isOpen status to true + await db.channels.where("id").equals(channelId).modify((channel) => { + channel.isOpen = true; + }); + return existing; + } - const channel: Channel = { + const channel: SiPher.Channel = { id: channelId, - participants: [myUserId, otherUserId].sort(), - type: "dm", - createdAt: Date.now(), - updatedAt: Date.now(), + name: otherUser.name, + participants: [myUserId, otherUser.id].sort(), + type: "DM" as typeof SiPher.ChannelType.DM, + times: { + createdAt: Date.now(), + updatedAt: Date.now(), + lastMessageAt: undefined, + lastMessage: undefined, + }, + metadata: undefined, + isOpen: true, }; await db.channels.add(channel); @@ -123,9 +125,10 @@ export async function addMessage(message: Omit): Promise await db.messages.add({ ...message, id }); // Update channel's lastMessageAt - await db.channels.update(message.channelId, { - lastMessageAt: message.timestamp, - updatedAt: Date.now(), + await db.channels.where("id").equals(message.channelId).modify((channel) => { + channel.times.lastMessage = message; + channel.times.lastMessageAt = message.timestamp; + channel.times.updatedAt = Date.now(); }); return id; diff --git a/src/lib/sockets/index.ts b/src/lib/sockets/index.ts index 7a83e65..e3970d6 100644 --- a/src/lib/sockets/index.ts +++ b/src/lib/sockets/index.ts @@ -3,12 +3,14 @@ */ import { Session, User } from "better-auth"; +import { ConvexHttpClient } from "convex/browser"; import { existsSync, readdirSync } from "fs"; import type { Server as HTTPServer } from "http"; import path from "path"; import { Socket, Server as SocketIOServer } from "socket.io"; import { pathToFileURL } from "url"; import z from "zod"; +import { api } from "../../../convex/_generated/api"; interface SocketManagerOptions { /** Enable authentication via Better Auth (default: false) */ @@ -28,6 +30,7 @@ export default class SocketManager { private socketIo: SocketIOServer | null = null; private events: Map = new Map(); private options: SocketManagerOptions; + private convex: ConvexHttpClient; constructor(nextServer: HTTPServer, options: SocketManagerOptions = {}) { if (!nextServer) { @@ -41,6 +44,12 @@ export default class SocketManager { ...options }; + // Initialize Convex client for server-side mutations + if (!process.env.NEXT_PUBLIC_CONVEX_URL) { + throw new Error("NEXT_PUBLIC_CONVEX_URL is required for SocketManager"); + } + this.convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL); + if (!this.socketIo) { this.socketIo = new SocketIOServer(nextServer, { // Configure Socket.IO's built-in heartbeat mechanism @@ -194,7 +203,7 @@ export default class SocketManager { // Register all events with Socket.IO socketIo.on("connection", (socket) => { - const user = (socket as any).user; + const user = socket.user; console.log(`[SocketManager] Client connected: ${socket.id}${user ? ` (${user.email})` : ""}`); // Register all event handlers by name @@ -211,8 +220,28 @@ export default class SocketManager { } // Handle disconnect within the connection context - socket.on("disconnect", (reason) => { - console.log(`[SocketManager] Client disconnected: ${socket.id} (${reason})`); + 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) { + console.warn(`[SocketManager] No session found for user ${socket.id}, skipping status update`); + return; + } + + // Set auth token for this mutation + this.convex.setAuth(session); + + await this.convex.mutation(api.auth.updateUserStatus, { + status: "offline", + isUserSet: false, + }); + console.log(`[SocketManager] Set user ${socket.id} status to offline`); + } catch (error) { + console.error(`[SocketManager] Failed to set user status to offline:`, error); + } }); }) } diff --git a/src/types/globals.d.ts b/src/types/globals.d.ts index eb04361..9031301 100644 --- a/src/types/globals.d.ts +++ b/src/types/globals.d.ts @@ -27,13 +27,14 @@ declare global { id: string, name: string, type: typeof ChannelType.DM | typeof ChannelType.GROUP | typeof ChannelType.REGIONAL | typeof ChannelType.GLOBAL | typeof ChannelType.SERVER | typeof ChannelType.SYSTEM - participants: SipherUser[] + participants: string[] times: { createdAt: number, updatedAt: number, lastMessageAt?: number, lastMessage?: Message, }, + isOpen: boolean, metadata?: { description?: string, subtitle?: string, diff --git a/src/types/sidebar.d.ts b/src/types/sidebar.d.ts index 916abc1..5801adf 100644 --- a/src/types/sidebar.d.ts +++ b/src/types/sidebar.d.ts @@ -17,7 +17,7 @@ declare global { } type OlmStatus = "checking" | "synced" | "mismatched" | "not_setup" | "creating"; - type SocketStatus = "connecting" | "connected" | "error" | "disconnected"; + type SocketStatus = "connecting" | "connected" | "error" | "disconnected" | "manually_disconnected"; interface SocketInfo { ping: number | null;