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.
This commit is contained in:
Nixyan 2025-12-28 04:46:41 -03:00
parent 45301ac52b
commit 096d6ab16c
27 changed files with 1248 additions and 423 deletions

View file

@ -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=="],

View file

@ -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<string> },
any
>;
getUserStatus: FunctionReference<"query", "internal", any, any>;
sendFriendRequest: FunctionReference<
"mutation",

View file

@ -184,3 +184,14 @@ export const getUserStatus = query({
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,
});
},
});

View file

@ -2024,6 +2024,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
Name
>;
getFriends: FunctionReference<"query", "internal", any, any, Name>;
getParticipantDetails: FunctionReference<
"query",
"internal",
{ participantIds: Array<string> },
any,
Name
>;
getUserStatus: FunctionReference<"query", "internal", any, any, Name>;
sendFriendRequest: FunctionReference<
"mutation",

View file

@ -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,7 +321,10 @@ export const getFriends = query({
status: friendStatus ? {
status: friendStatus.status,
isUserSet: friendStatus.isUserSet,
} : null,
} : {
status: "offline" as const,
isUserSet: false,
},
};
})
);
@ -318,3 +332,39 @@ export const getFriends = query({
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);
}
})

View file

@ -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",

View file

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

View file

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

5
src/app/(app)/layout.tsx Normal file
View file

@ -0,0 +1,5 @@
import AppContainer from "@/components/app-container";
export default function AppLayout() {
return <AppContainer />;
}

4
src/app/(app)/page.tsx Normal file
View file

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

View file

@ -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<SiPher.Channel | null>(null);
const [openDmChannels, setOpenDmChannels] = useState<SiPher.Channel[] | []>([]);
const [availableServers, setAvailableServers] = useState<SiPher.Server[] | []>([]);
// Friends page state
const [friendsPage, setFriendsPage] = useState<"all" | "available">("all");
const [friendsSearch, setFriendsSearch] = useState<string>("");
const [friendModal, setFriendModal] = useState<boolean>(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<typeof friends>[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 <div className="flex items-center justify-center h-screen w-full bg-background">
<Spinner className="size-10 animate-spin" />
</div>
}
if (error || !data) {
return redirect(`/auth${error ? `?error=${error.cause}` : "?error=no-data"}`);
}
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 (
<>
<UserFloatingCard user={data.user} />
<AppSidebar socketStatus={socketStatus} socketInfo={socketInfo} disconnectSocket={disconnect} connectSocket={connect}>
<div className="flex flex-col h-full">
{/* Header - fixed height and sticky */}
<div className="flex items-center min-h-10 max-h-10 border-b border-border/40 sticky top-0 z-10 bg-background">
{/* SCS or DM Selector */}
<div className="flex justify-center items-center gap-2 max-w-72 min-w-72 border-r h-10 border-border/40">
{
// If the current channel is none or a DM, we show a search bar
!currentChannel || currentChannel.type === SiPher.ChannelType.DM ? (
<Button
variant="outline"
className="w-[calc(100%-2rem)] h-3/4 rounded-lg hover:cursor-pointer"
>
<SearchIcon className="size-4" />
<span className="text-sm font-medium">Search for a Server or DM</span>
</Button>
) : (
<span className="text-sm font-medium">{currentChannel.name}</span>
)
}
</div>
{/* Page title/options */}
{
page === "friends" ? (
<div className="flex flex-row justify-start items-center gap-2 w-full">
<div className="flex flex-row gap-2 justify-start p-2">
<UsersIcon className="size-4" />
<span className="text-sm font-medium">Friends</span>
</div>
<span className="text-sm font-medium"></span>
<div className="flex flex-row gap-2 h-full">
<Button variant="ghost" disabled={friendsPage === "available"} className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "available" ? "bg-primary text-primary-foreground" : ""}`} onClick={() => setFriendsPage("available")}>
Available
</Button>
<Button variant="ghost" disabled={friendsPage === "all"} className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "all" ? "bg-primary text-primary-foreground" : ""}`} onClick={() => setFriendsPage("all")}>
All Known
</Button>
<Button variant="ghost" className="h-full bg-primary text-primary-foreground hover:cursor-pointer justify-start p-2" onClick={() => setFriendModal(true)}>
Add Friend
</Button>
</div>
</div>
) : null
}
</div>
{/* Content Area - Channel List + Main Content */}
<div className="flex flex-1 overflow-hidden">
{/* Channel List */}
<div className="flex flex-col shrink-0 max-w-72 min-w-72 border-r border-border/40">
{/* Channel List Header - sticky top */}
<div className="flex justify-center items-center min-h-10 max-h-50 bg-background">
<div className="flex flex-col justify-start items-start p-1 gap-2 w-full">
<Button variant="ghost" className="w-full h-full hover:cursor-pointer justify-start" onClick={() => setPage("friends")}>
<UsersIcon className="size-4" />
<span className="text-sm font-medium">Friends</span>
</Button>
<Button variant="ghost" className="w-full h-full hover:cursor-pointer justify-start" onClick={() => setPage("support")}>
<SettingsIcon className="size-4" />
<span className="text-sm font-medium">Settings</span>
</Button>
</div>
</div>
<div className="w-[calc(100%-0.8rem)] h-px bg-border/40 mx-2" />
{/* Channel List */}
<div className="flex flex-col flex-1 overflow-y-auto">
{/* Channel List Item */}
<div className="flex items-center min-h-10 max-h-10">
{
currentChannel && currentChannel.type === SiPher.ChannelType.DM || !currentChannel ? (
<div className="flex flex-col items-center min-h-10 max-h-10 w-full">
<div className="flex items-center w-full justify-between p-2 select-none">
<span className="text-xs font-semibold text-muted-foreground">Direct Messages</span>
<Button variant="ghost" size="icon-sm" className="hover:cursor-pointer hover:bg-transparent!">
<PlusIcon className="size-4" />
</Button>
</div>
{
openDmChannels.length > 0 ? (
openDmChannels.map((channel) => (
<Button variant="ghost" size="icon-sm" className="hover:cursor-pointer hover:bg-transparent!">
<span className="text-sm font-medium">{channel.name}</span>
</Button>
))
) : (
<div className="flex items-center min-h-10 max-h-10">
<span className="text-xs font-medium text-muted-foreground text-center text-wrap">
{getRandomPhrase()}
</span>
</div>
)
}
</div>
) : (
<div className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">No channels</span>
</div>
)
}
</div>
</div>
</div>
{/* Main Content */}
<div className="flex flex-col flex-1 overflow-hidden">
{
page === "friends" ? (
<div className="flex flex-col flex-1 overflow-y-auto p-4">
<div className="flex flex-col items-center min-h-10 max-h-10">
<Input
placeholder="Search for a friend..."
value={friendsSearch}
onChange={(e) => setFriendsSearch(e.target.value)}
className="w-full min-h-10 sticky top-0"
/>
{
friendsPage === "all" ? (
<div className="flex flex-col items-start w-full p-2 gap-2 pt-4">
<span className="text-sm text-start font-medium">All Friends {friends ? friends.length : 0}</span>
{
friends && friends.length > 0 ? (
friends.map((friend: Friend) => {
if (!friend) return null;
return (
<div key={friend._id} className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">{friend.displayUsername || friend.username || friend.name}</span>
</div>
)
})
) : (
<span className="text-sm font-medium text-muted-foreground">
{getRandomPhrase()}
</span>
)
}
</div>
) : (
<div className="flex flex-col items-start w-full p-2 gap-2 pt-4">
<span className="text-sm text-start font-medium">Available Friends {friends ? friends.filter((f: Friend) => f && f.status?.status !== "offline").length : 0}</span>
{
friends && friends.length > 0 ? (
friends
.filter((f: Friend) => f && f.status?.status !== "offline")
.map((friend: Friend) => {
if (!friend) return null;
return (
<div key={friend._id} className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">{friend.displayUsername || friend.username || friend.name}</span>
</div>
)
})
) : (
<span className="text-sm font-medium text-muted-foreground">
{getRandomPhrase()}
</span>
)
}
</div>
)
}
</div>
</div>
) : page === "support" ? (
<div className="flex flex-col flex-1 overflow-y-auto p-4">
<div className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">Servers</span>
</div>
</div>
) : null
}
</div>
</div>
</div>
</AppSidebar>
<FriendRequestModal
open={friendModal}
onOpenChange={setFriendModal}
/>
{/* OLM Account Setup/Sync Modal */}
<OlmSetupDialog
open={showOlmModal}
onOpenChange={setShowOlmModal}
olmStatus={olmStatus}
onCreateAccount={handleCreateAccount}
/>
</>
)
}

View file

@ -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 (
<div className="flex items-center justify-center h-screen w-full bg-background">
<Spinner className="size-10 animate-spin" />
</div>
);
}
if (error || !data) {
return redirect(`/auth${error ? `?error=${error.cause}` : "?error=no-data"}`);
}
if (["connecting", "error", "disconnected"].includes(socketStatus)) {
return (
<div className="flex items-center justify-center h-screen w-full bg-background">
<Spinner className="size-10 animate-spin" />
</div>
);
}
return (
<>
<UserFloatingCard user={data.user} />
<AppSidebar
socketStatus={socketStatus}
socketInfo={socketInfo}
disconnectSocket={disconnect}
connectSocket={connect}
>
<MainContentLayout
socketStatus={socketStatus}
emptyChannelMessage={getPhrase()}
emptyFriendsMessage={getPhrase()}
userId={data.user.id}
dmChannelId={routeInfo.type === 'dm' ? routeInfo.dmChannelId : undefined}
serverId={routeInfo.type === 'server' ? routeInfo.serverId : undefined}
serverChannelId={routeInfo.type === 'server' ? routeInfo.serverChannelId : undefined}
/>
</AppSidebar>
<OlmSetupDialog
open={showOlmModal}
onOpenChange={setShowOlmModal}
olmStatus={olmStatus}
onCreateAccount={handleCreateAccount}
/>
</>
);
}

View file

@ -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 (
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="size-8 hover:bg-background/80"
title="More options"
>
<MoreVerticalIcon className="size-4" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-48 p-1" align="end">
<div className="flex flex-col">
<Button
variant="ghost"
className="justify-start h-9 px-2 font-normal hover:bg-accent"
onClick={() => onStartCall?.(friendId)}
>
<PhoneIcon className="size-4" />
<span className="text-sm">Start Call</span>
</Button>
<Button
variant="ghost"
className="justify-start h-9 px-2 font-normal hover:bg-accent"
onClick={() => onVideoCall?.(friendId)}
>
<VideoIcon className="size-4" />
<span className="text-sm">Start Video Call</span>
</Button>
<Button
variant="ghost"
className="justify-start h-9 px-2 font-normal hover:bg-accent"
onClick={() => onViewProfile?.(friendId)}
>
<UsersIcon className="size-4" />
<span className="text-sm">View Profile</span>
</Button>
<Separator className="my-1" />
<Button
variant="ghost"
className="justify-start h-9 px-2 font-normal hover:bg-accent text-orange-500 hover:text-orange-600"
onClick={() => onRemoveFriend?.(friendId)}
>
<UserMinusIcon className="size-4" />
<span className="text-sm">Remove Friend</span>
</Button>
<Button
variant="ghost"
className="justify-start h-9 px-2 font-normal hover:bg-accent text-red-500 hover:text-red-600"
onClick={() => onBlock?.(friendId)}
>
<ShieldBanIcon className="size-4" />
<span className="text-sm">Block</span>
</Button>
</div>
</PopoverContent>
</Popover>
)
}

View file

@ -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 (
<div
className="flex flex-row items-center justify-between w-full p-3 rounded-md hover:bg-accent/50 transition-colors group border border-transparent hover:border-border/40 hover:cursor-pointer"
onClick={() => {
// 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 */}
<div className="flex flex-row items-center gap-3 flex-1 min-w-0">
<div className="relative shrink-0">
<Avatar className="size-10">
<AvatarImage src={friend.image || undefined} />
<AvatarFallback className="text-sm font-medium">
{displayName?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div
className={`absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full border-[2.5px] border-background ${statusColor}`}
title={status}
/>
</div>
<div className="flex flex-col justify-center items-start overflow-hidden flex-1 min-w-0">
<span className="text-sm font-semibold truncate w-full text-foreground">
{displayName}
</span>
<span className="text-xs text-muted-foreground capitalize truncate w-full">
{status}
</span>
</div>
</div>
{/* Right side: Actions Menu */}
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity shrink-0">
<Button
variant="ghost"
size="icon-sm"
className="size-8 hover:bg-background/80"
onClick={() => onMessage?.(friend._id)}
title="Message"
>
<MessageCircleIcon className="size-4" />
</Button>
<FriendActionsMenu
friendId={friend._id}
onStartCall={onStartCall}
onVideoCall={onVideoCall}
onViewProfile={onViewProfile}
onRemoveFriend={onRemoveFriend}
onBlock={onBlock}
/>
</div>
</div>
)
}

View file

@ -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 (
<div className="flex flex-col flex-1 overflow-hidden">
{/* Search Input - Sticky at top */}
<div className="flex flex-col p-4 pb-2 bg-background border-b border-border/40">
<Input
placeholder="Search for a friend..."
value={friendsSearch}
onChange={(e) => setFriendsSearch(e.target.value)}
className="w-full"
/>
</div>
{/* Scrollable Friends List */}
<div className="flex flex-col flex-1 overflow-y-auto p-4">
<div className="flex flex-col items-start w-full gap-2">
<span className="text-sm text-start font-medium">
{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}`
}
</span>
{filteredFriends.length > 0 ? (
filteredFriends.map((friend: FriendData) => (
<FriendListItem
userId={userId}
key={friend._id}
friend={friend}
onMessage={handleMessage}
onStartCall={handleStartCall}
onVideoCall={handleVideoCall}
onViewProfile={handleViewProfile}
onRemoveFriend={handleRemoveFriend}
onBlock={handleBlock}
/>
))
) : (
<div className="flex flex-col items-center justify-center w-full py-12">
<span className="text-sm font-medium text-muted-foreground">
{friendsSearch ? `No friends found matching "${friendsSearch}"` : emptyMessage}
</span>
</div>
)}
</div>
</div>
</div>
)
}

View file

@ -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"

View file

@ -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 (
<div className="flex flex-col shrink-0 max-w-72 min-w-72 border-r border-border/40">
{/* Channel List Header */}
<div className="flex justify-center items-center min-h-10 max-h-50 bg-background">
<div className="flex flex-col justify-start items-start p-1 gap-2 w-full">
<Button
variant="ghost"
className="w-full h-full hover:cursor-pointer justify-start"
onClick={() => {
onPageChange("friends")
router.push("/")
}}
>
<UsersIcon className="size-4" />
<span className="text-sm font-medium">Friends</span>
</Button>
<Button
variant="ghost"
className="w-full h-full hover:cursor-pointer justify-start"
onClick={() => onPageChange("support")}
>
<SettingsIcon className="size-4" />
<span className="text-sm font-medium">Settings</span>
</Button>
</div>
</div>
<div className="w-[calc(100%-0.8rem)] h-px bg-border/40 mx-2" />
{/* Channel List */}
<div className="flex flex-col flex-1 overflow-y-auto">
{page === "friends" || !currentChannel ? (
<div className="flex flex-col w-full">
<div className="flex items-center w-full justify-between p-2 select-none">
<span className="text-xs font-semibold text-muted-foreground">
Direct Messages
</span>
<Button
variant="ghost"
size="icon-sm"
className="hover:cursor-pointer hover:bg-transparent!"
>
<PlusIcon className="size-4" />
</Button>
</div>
{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 (
<div
key={channel.id}
className={`flex flex-row items-center gap-3 px-2 py-1.5 mx-2 mb-0.5 rounded-md transition-all cursor-pointer group ${isActive
? "bg-accent/60"
: "hover:bg-accent/40"
}`}
onClick={() => router.push(`/channels/me/${channel.id}`)}
>
{/* Avatar */}
<div className="relative shrink-0">
<Avatar className="size-8 ring-2 ring-border">
<AvatarImage src={channel.metadata?.icon ?? undefined} alt={channel.name} />
<AvatarFallback className="bg-primary/20 text-primary-foreground font-semibold">
{channel.name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full border-[2.5px] border-secondary",
channel.metadata?.icon ? "bg-muted-foreground" : "bg-muted-foreground"
)}
/>
</div>
{/* Channel Info */}
<div className="flex flex-col justify-center flex-1 min-w-0 overflow-hidden">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-semibold truncate text-foreground">
{channel.name}
</span>
{lastMessageTime && (
<span className="text-[10px] text-muted-foreground/70 shrink-0">
{formatDistanceToNow(lastMessageTime, { addSuffix: false })}
</span>
)}
</div>
{lastMessage && (
<span className="text-xs text-muted-foreground/80 truncate">
{lastMessage.content}
</span>
)}
</div>
{/* Close button */}
<Button
variant="ghost"
size="icon"
className="size-5 p-0 shrink-0 opacity-0 group-hover:opacity-100 hover:bg-background/80 transition-opacity"
onClick={(e) => {
e.stopPropagation()
const isCurrentlyViewing = isActive
db.channels.where("id").equals(channel.id).modify((channel) => {
channel.isOpen = false;
});
// Navigate away if we're closing the currently viewed channel
if (isCurrentlyViewing) {
console.log("Navigating away from channel")
router.push("/")
}
}}
title="Close DM"
>
<XIcon className="size-3.5" />
</Button>
</div>
)
})
) : (
<div className="flex items-center min-h-10 max-h-10 p-2">
<span className="text-xs font-medium text-muted-foreground text-center text-wrap">
{emptyMessage}
</span>
</div>
)}
</div>
) : (
<div className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">No channels</span>
</div>
)}
</div>
</div>
)
}

View file

@ -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"

View file

@ -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<SiPher.Channel | null>(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 (
<>
<div className="flex flex-col h-full">
{/* Header */}
<PageHeader
currentChannel={currentChannel}
page={page}
friendsPage={friendsPage}
onFriendsPageChange={setFriendsPage}
onAddFriend={() => setFriendModal(true)}
dmChannel={dmChannel}
serverId={serverId}
serverChannelId={serverChannelId}
/>
{/* Content Area - Channel List + Main Content */}
<div className="flex flex-1 overflow-hidden">
<ChannelList
currentChannel={currentChannel}
openDmChannels={openDmChannels}
page={page}
onPageChange={setPage}
emptyMessage={emptyChannelMessage}
dmChannel={dmChannel}
/>
{/* Main Content */}
<div className="flex flex-col flex-1 overflow-hidden">
{page === "dm" ? (
<div className="flex flex-col h-full p-4">
<p className="text-sm text-muted-foreground">DM chat with {dmChannelId}</p>
</div>
) : page === "server" ? (
<div className="flex flex-col h-full p-4">
<p className="text-sm text-muted-foreground">Server channel {serverChannelId}</p>
</div>
) : page === "friends" ? (
<FriendsPage
userId={userId}
friendsPage={friendsPage}
socketStatus={socketStatus}
emptyMessage={emptyFriendsMessage}
/>
) : (
<SettingsPage />
)}
</div>
</div>
</div>
<FriendRequestModal
open={friendModal}
onOpenChange={setFriendModal}
/>
</>
)
}

View file

@ -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 (
<div className="flex items-center min-h-10 max-h-10 border-b border-border/40 sticky top-0 z-10 bg-background">
{/* SCS or DM Selector */}
<div className="flex justify-center items-center gap-2 max-w-72 min-w-72 border-r h-10 border-border/40">
{!currentChannel || currentChannel.type === "DM" ? (
<Button
variant="outline"
className="w-[calc(100%-2rem)] h-3/4 rounded-lg hover:cursor-pointer"
>
<SearchIcon className="size-4" />
<span className="text-sm font-medium">Search for a Server or DM</span>
</Button>
) : (
<span className="text-sm font-medium">{currentChannel.name}</span>
)}
</div>
{/* Page title/options */}
{dmChannel ? (
<div className="flex flex-row justify-start items-center gap-2 w-full px-4">
<div className="relative shrink-0">
<Avatar className="size-4 ring-2 ring-border">
<AvatarImage src={dmChannel.participantDetails[0].image ?? undefined} alt={dmChannel.participantDetails[0].name} />
<AvatarFallback className="bg-primary/20 text-primary-foreground font-semibold">
{dmChannel.participantDetails[0].name?.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 size-2 rounded-full border-2 border-secondary",
dmChannel.participantDetails[0].status ? statusColors[dmChannel.participantDetails[0].status as "online" | "busy" | "offline" | "away"] : "bg-muted-foreground"
)}
/>
</div>
<span className="text-sm font-medium">{dmChannel.participantDetails[0].name}</span>
<div className="flex flex-row gap-2 ml-auto">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<PhoneIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<VideoIcon className="size-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
>
<UserIcon className="size-4" />
</Button>
</div>
</div>
) : serverChannelId ? (
<div className="flex flex-row justify-start items-center gap-2 w-full px-4">
<span className="text-sm font-medium">#{serverChannelId}</span>
</div>
) : page === "friends" ? (
<div className="flex flex-row justify-start items-center gap-2 w-full">
<div className="flex flex-row gap-2 justify-start p-2">
<UsersIcon className="size-4" />
<span className="text-sm font-medium">Friends</span>
</div>
<span className="text-sm font-medium"></span>
<div className="flex flex-row gap-2 h-full">
<Button
variant="ghost"
disabled={friendsPage === "available"}
className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "available" ? "bg-primary text-primary-foreground" : ""
}`}
onClick={() => onFriendsPageChange?.("available")}
>
Available
</Button>
<Button
variant="ghost"
disabled={friendsPage === "all"}
className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "all" ? "bg-primary text-primary-foreground" : ""
}`}
onClick={() => onFriendsPageChange?.("all")}
>
All Known
</Button>
<Button
variant="ghost"
className="h-full bg-primary text-primary-foreground hover:cursor-pointer justify-start p-2"
onClick={onAddFriend}
>
Add Friend
</Button>
</div>
</div>
) : null}
</div>
)
}

View file

@ -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 (
<div className="flex flex-col flex-1 overflow-y-auto p-4">
<div className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">Servers</span>
</div>
</div>
)
}

View file

@ -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 () => {

View file

@ -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)]
}

View file

@ -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<OlmAccount, "odId">;
olmSessions!: EntityTable<OlmSession, "odId">;
channels!: EntityTable<Channel, "id">;
channels!: EntityTable<SiPher.Channel, "id">;
messages!: EntityTable<Message, "id">;
unreadCounts!: EntityTable<UnreadCount, "channelId">;
@ -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<Channel> {
otherUser: any
): Promise<SiPher.Channel> {
// 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",
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<Message, "id">): Promise<string>
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;

View file

@ -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<string, SiPher.EventsType[]> = 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);
}
});
})
}

View file

@ -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,

View file

@ -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;