Add user status management and metadata fields to authentication

- Introduced user status management with the ability to update online, busy, offline, and away statuses.
- Added metadata fields for user preferences, including phrase preferences and friends list.
- Updated API and database schema to accommodate new user fields.
- Enhanced the authentication component to handle additional user data effectively.
- Implemented hooks for socket management and OLM setup to improve user experience.
This commit is contained in:
Nixyan 2025-12-19 17:04:24 -03:00
parent df41cf4657
commit 32168722a2
12 changed files with 633 additions and 127 deletions

View file

@ -62,8 +62,16 @@ export declare const components: {
displayUsername?: null | string;
email: string;
emailVerified: boolean;
friends?: Array<string>;
image?: null | string;
metadata?: {
phrasePreference: "comforting" | "mocking" | "both";
};
name: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt: number;
userId?: null | string;
username?: null | string;
@ -149,6 +157,9 @@ export declare const components: {
| "userId"
| "username"
| "displayUsername"
| "metadata"
| "status"
| "friends"
| "_id";
operator?:
| "lt"
@ -359,6 +370,9 @@ export declare const components: {
| "userId"
| "username"
| "displayUsername"
| "metadata"
| "status"
| "friends"
| "_id";
operator?:
| "lt"
@ -640,8 +654,16 @@ export declare const components: {
displayUsername?: null | string;
email?: string;
emailVerified?: boolean;
friends?: Array<string>;
image?: null | string;
metadata?: {
phrasePreference: "comforting" | "mocking" | "both";
};
name?: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt?: number;
userId?: null | string;
username?: null | string;
@ -658,6 +680,9 @@ export declare const components: {
| "userId"
| "username"
| "displayUsername"
| "metadata"
| "status"
| "friends"
| "_id";
operator?:
| "lt"
@ -901,8 +926,16 @@ export declare const components: {
displayUsername?: null | string;
email?: string;
emailVerified?: boolean;
friends?: Array<string>;
image?: null | string;
metadata?: {
phrasePreference: "comforting" | "mocking" | "both";
};
name?: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt?: number;
userId?: null | string;
username?: null | string;
@ -919,6 +952,9 @@ export declare const components: {
| "userId"
| "username"
| "displayUsername"
| "metadata"
| "status"
| "friends"
| "_id";
operator?:
| "lt"
@ -1164,5 +1200,15 @@ export declare const components: {
>;
};
};
user: {
index: {
updateUserStatus: FunctionReference<
"mutation",
"internal",
{ isUserSet: boolean; status: string },
any
>;
};
};
};
};

View file

@ -3,6 +3,7 @@ import { convex } from "@convex-dev/better-auth/plugins";
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { captcha, oneTimeToken, openAPI, username } from "better-auth/plugins";
import { v } from "convex/values";
import { z } from "zod";
import { components } from "./_generated/api";
import { DataModel } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server";
@ -22,6 +23,15 @@ export const authComponent = createClient<DataModel, typeof authSchema>(
}
);
const metadataSchema = z.object({
phrasePreference: z.enum(["comforting", "mocking", "both"]),
})
const statusSchema = z.object({
status: z.enum(["online", "busy", "offline", "away"]),
isUserSet: z.boolean(),
});
export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
return {
baseURL: siteUrl,
@ -31,6 +41,52 @@ export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
requireEmailVerification: false,
autoSignIn: true
},
user: {
additionalFields: {
metadata: {
type: "json",
defaultValue: () => {
const metadata = metadataSchema.parse({
phrasePreference: "comforting",
})
return metadata.phrasePreference;
},
required: false,
},
friends: {
type: "string[]",
defaultValue: [],
required: false,
index: true
},
status: {
type: "json",
defaultValue: () => {
return {
status: "offline",
isUserSet: false,
}
},
required: false,
index: true,
transform: {
input: (status) => {
return statusSchema.safeParse(status).success ? status : {
status: "offline",
isUserSet: false,
};
},
output: (status) => {
return statusSchema.safeParse(status).success ? status : {
status: "offline",
isUserSet: false,
};
}
}
}
},
},
plugins: [
convex({
authConfig,
@ -100,3 +156,16 @@ export const retrieveServerOlmAccount = query({
});
},
});
export const updateUserStatus = mutation({
args: {
status: v.string(),
isUserSet: v.boolean(),
},
handler: async (ctx, args) => {
return ctx.runMutation(components.betterAuth.user.index.updateUserStatus, {
status: args.status,
isUserSet: args.isUserSet,
});
},
});

View file

@ -11,6 +11,7 @@
import type * as adapter from "../adapter.js";
import type * as auth from "../auth.js";
import type * as olm_index from "../olm/index.js";
import type * as user_index from "../user/index.js";
import type {
ApiFromModules,
@ -23,6 +24,7 @@ const fullApi: ApiFromModules<{
adapter: typeof adapter;
auth: typeof auth;
"olm/index": typeof olm_index;
"user/index": typeof user_index;
}> = anyApi as any;
/**

View file

@ -35,8 +35,16 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
displayUsername?: null | string;
email: string;
emailVerified: boolean;
friends?: Array<string>;
image?: null | string;
metadata?: {
phrasePreference: "comforting" | "mocking" | "both";
};
name: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt: number;
userId?: null | string;
username?: null | string;
@ -123,6 +131,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "userId"
| "username"
| "displayUsername"
| "metadata"
| "status"
| "friends"
| "_id";
operator?:
| "lt"
@ -334,6 +345,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "userId"
| "username"
| "displayUsername"
| "metadata"
| "status"
| "friends"
| "_id";
operator?:
| "lt"
@ -618,8 +632,16 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
displayUsername?: null | string;
email?: string;
emailVerified?: boolean;
friends?: Array<string>;
image?: null | string;
metadata?: {
phrasePreference: "comforting" | "mocking" | "both";
};
name?: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt?: number;
userId?: null | string;
username?: null | string;
@ -636,6 +658,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "userId"
| "username"
| "displayUsername"
| "metadata"
| "status"
| "friends"
| "_id";
operator?:
| "lt"
@ -880,8 +905,16 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
displayUsername?: null | string;
email?: string;
emailVerified?: boolean;
friends?: Array<string>;
image?: null | string;
metadata?: {
phrasePreference: "comforting" | "mocking" | "both";
};
name?: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt?: number;
userId?: null | string;
username?: null | string;
@ -898,6 +931,9 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "userId"
| "username"
| "displayUsername"
| "metadata"
| "status"
| "friends"
| "_id";
operator?:
| "lt"
@ -1146,4 +1182,15 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
>;
};
};
user: {
index: {
updateUserStatus: FunctionReference<
"mutation",
"internal",
{ isUserSet: boolean; status: string },
any,
Name
>;
};
};
};

View file

@ -16,11 +16,21 @@ export const tables = {
userId: v.optional(v.union(v.null(), v.string())),
username: v.optional(v.union(v.null(), v.string())),
displayUsername: v.optional(v.union(v.null(), v.string())),
metadata: v.optional(v.object({
phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")),
})),
status: v.optional(v.object({
status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
isUserSet: v.boolean(),
})),
friends: v.optional(v.array(v.string())),
})
.index("email_name", ["email", "name"])
.index("name", ["name"])
.index("userId", ["userId"])
.index("username", ["username"]),
.index("username", ["username"])
.index("status", ["status"])
.index("friends", ["friends"]),
session: defineTable({
expiresAt: v.number(),
token: v.string(),

View file

@ -0,0 +1,28 @@
import { v } from "convex/values";
import { Id } from "../../_generated/dataModel";
import { mutation } from "../../_generated/server";
export const updateUserStatus = mutation({
args: {
status: v.string(),
isUserSet: v.boolean(),
},
handler: async (ctx, args) => {
const user = await ctx.auth.getUserIdentity();
if (!user) {
throw new Error("User not found");
}
const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">;
if (!userId) {
throw new Error("User not found");
}
return ctx.db.patch<"user">("user", userId, {
status: {
status: args.status,
isUserSet: args.isUserSet,
},
});
},
});

View file

@ -1,29 +1,109 @@
"use client"
import AppSidebar from "@/components/home";
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 { checkOlmStatus as checkOlmStatusUtil, handleOlmAccountCreation } from "@/lib/olm";
import { useMutation, useQuery } from "convex/react";
import { PlusIcon, SearchIcon, UsersIcon } from "lucide-react";
import { redirect } from "next/navigation";
import { useEffect, useState } from "react";
import { io, Socket } from "socket.io-client";
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 } = authClient.useSession();
const [socketStatus, setSocketStatus] = useState<SiPher.SocketStatus>("connecting");
const [socketInfo, setSocketInfo] = useState<SiPher.SocketInfo>({
ping: null,
transport: null,
connectedAt: null,
socketId: null,
serverUrl: null,
error: null
});
const [olmStatus, setOlmStatus] = useState<SiPher.OlmStatus>("checking");
const [showOlmModal, setShowOlmModal] = useState(false);
const [page, setPage] = useState<"friends" | "settings">("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 hasServerOlm = useQuery(
api.auth.retrieveServerOlmAccount,
@ -33,111 +113,25 @@ export default function Home() {
// Mutation for sending keys to server
const sendKeysToServer = useMutation(api.auth.sendKeysToServer);
const updateUserStatus = useMutation(api.auth.updateUserStatus);
useEffect(() => {
if (!data) return;
const socket: Socket = io({ withCredentials: false });
let pingInterval: NodeJS.Timeout | null = null;
const status = data.user.status
if (!status) return;
// Measure ping latency
const measurePing = () => {
const start = Date.now();
socket.volatile.emit("ping", () => {
const latency = Date.now() - start;
setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, ping: latency }));
});
};
socket.on("connect", () => {
console.log("✅ Connected to socket - Authentication successful!");
setSocketStatus("connected");
setSocketInfo((prev: SiPher.SocketInfo) => ({
...prev,
connectedAt: Date.now(),
socketId: socket.id || null,
serverUrl: window.location.origin,
transport: socket.io.engine?.transport?.name || "unknown",
error: null
}));
// Start ping measurement every 5 seconds
measurePing();
pingInterval = setInterval(measurePing, 5000);
});
// Update transport when it upgrades (polling -> websocket)
socket.io.engine?.on("upgrade", (transport) => {
setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, transport: transport.name }));
});
socket.on("connect_error", (err) => {
console.error("❌ Socket connection error:", err.message);
setSocketStatus("error");
setSocketInfo((prev: SiPher.SocketInfo) => ({
...prev,
error: err.message,
ping: null,
connectedAt: null,
socketId: null
}));
});
socket.on("disconnect", (reason) => {
console.log("🔌 Disconnected from socket:", reason);
setSocketStatus("disconnected");
setSocketInfo((prev: SiPher.SocketInfo) => ({
...prev,
ping: null,
connectedAt: null,
error: reason
}));
if (pingInterval) clearInterval(pingInterval);
});
// Handle pong response for ping measurement
socket.on("pong", () => {
// Handled in measurePing callback
});
return () => {
if (pingInterval) clearInterval(pingInterval);
socket.disconnect();
};
}, [data]);
useEffect(() => {
if (!data || hasServerOlm === undefined) return;
const checkStatus = async () => {
const status = await checkOlmStatusUtil(data.user.id, hasServerOlm);
setOlmStatus(status);
if (status === "not_setup" || status === "mismatched") {
setShowOlmModal(true);
if (status.status === "offline" && !status.isUserSet) {
updateUserStatus({ status: "online", isUserSet: false });
}
};
}, [data?.user?.id, updateUserStatus, data?.user?.status]);
checkStatus();
}, [data, hasServerOlm]);
async function handleCreateOlmAccount(password: string): Promise<void> {
if (!data || !password.trim()) return;
setOlmStatus("creating");
const success = await handleOlmAccountCreation(
data.user.id,
password,
sendKeysToServer,
olmStatus === "mismatched"
);
if (success) {
setOlmStatus("synced");
setShowOlmModal(false);
} else {
setOlmStatus("not_setup");
}
}
// Custom hooks for socket and OLM management
const { socketStatus, socketInfo } = useSocket(data?.user?.id);
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">
@ -149,11 +143,167 @@ export default function Home() {
return redirect(`/auth${error ? `?error=${error.cause}` : "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}>
<></>
<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 */}
<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 ">
Add Friend
</Button>
</div>
</div>
</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>
</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 items-center min-h-10 max-h-10">
<span className="text-sm font-medium">All Friends</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 {data.user.friends && data.user.friends.length > 0 ? data.user.friends.length : 0}</span>
{
data.user.friends && data.user.friends.length > 0 ? (
data.user.friends.map((friend) => (
<div className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">{friend}</span>
</div>
))
) : (
<span className="text-sm font-medium text-muted-foreground">
{getRandomPhrase()}
</span>
)
}
</div>
)
}
</div>
</div>
) : page === "settings" ? (
<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>
{/* OLM Account Setup/Sync Modal */}
@ -161,7 +311,7 @@ export default function Home() {
open={showOlmModal}
onOpenChange={setShowOlmModal}
olmStatus={olmStatus}
onCreateAccount={handleCreateOlmAccount}
onCreateAccount={handleCreateAccount}
/>
</>
)

View file

@ -95,6 +95,9 @@ export default function OlmSetupDialog({
onKeyDown={handleKeyDown}
/>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Import Keys from Backup
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>

View file

@ -6,36 +6,35 @@ import {
GearSix,
MicrophoneSlash
} from "@phosphor-icons/react";
import { User } from "better-auth";
import { useEffect, useRef, useState } from "react";
import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
import { Button } from "../button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "../hover-card";
import { Tooltip, TooltipContent, TooltipTrigger } from "../tooltip";
type UserStatus = "online" | "idle" | "dnd" | "offline";
type UserStatus = "online" | "busy" | "offline" | "away";
interface UserFloatingCardProps {
user: User;
user: any; // Too lazy to type the user type
status?: UserStatus;
activity?: string;
}
const statusColors: Record<UserStatus, string> = {
online: "bg-emerald-500",
idle: "bg-amber-500",
dnd: "bg-red-500",
busy: "bg-amber-500",
away: "bg-yellow-500",
offline: "bg-muted-foreground"
};
export default function UserFloatingCard({
user,
status = "online",
activity
}: UserFloatingCardProps) {
const [cardOpen, setCardOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null);
const status = user.status?.status;
const activity = user.status?.activity;
// Close when clicking outside the trigger/content
useEffect(() => {
@ -114,7 +113,7 @@ export default function UserFloatingCard({
<span
className={cn(
"absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full border-[3px] border-secondary",
statusColors[status]
status ? statusColors[status as UserStatus] : "bg-muted-foreground"
)}
/>
</div>

View file

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

91
src/hooks/use-socket.ts Normal file
View file

@ -0,0 +1,91 @@
"use client"
import { useEffect, useState } from "react";
import { io, Socket } from "socket.io-client";
export function useSocket(userId: string | undefined) {
const [socketStatus, setSocketStatus] = useState<SiPher.SocketStatus>("connecting");
const [socketInfo, setSocketInfo] = useState<SiPher.SocketInfo>({
ping: null,
transport: null,
connectedAt: null,
socketId: null,
serverUrl: null,
error: null
});
useEffect(() => {
if (!userId) return;
const socket: Socket = io({ withCredentials: false });
let pingInterval: NodeJS.Timeout | null = null;
// Measure ping latency
const measurePing = () => {
const start = Date.now();
socket.volatile.emit("ping", () => {
const latency = Date.now() - start;
setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, ping: latency }));
});
};
socket.on("connect", () => {
console.log("✅ Connected to socket - Authentication successful!");
setSocketStatus("connected");
setSocketInfo((prev: SiPher.SocketInfo) => ({
...prev,
connectedAt: Date.now(),
socketId: socket.id || null,
serverUrl: window.location.origin,
transport: socket.io.engine?.transport?.name || "unknown",
error: null
}));
// Start ping measurement every 5 seconds
measurePing();
pingInterval = setInterval(measurePing, 5000);
});
// Update transport when it upgrades (polling -> websocket)
socket.io.engine?.on("upgrade", (transport) => {
setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, transport: transport.name }));
});
socket.on("connect_error", (err) => {
console.error("❌ Socket connection error:", err.message);
setSocketStatus("error");
setSocketInfo((prev: SiPher.SocketInfo) => ({
...prev,
error: err.message,
ping: null,
connectedAt: null,
socketId: null
}));
});
socket.on("disconnect", (reason) => {
console.log("🔌 Disconnected from socket:", reason);
setSocketStatus("disconnected");
setSocketInfo((prev: SiPher.SocketInfo) => ({
...prev,
ping: null,
connectedAt: null,
error: reason
}));
if (pingInterval) clearInterval(pingInterval);
});
// Handle pong response for ping measurement
socket.on("pong", () => {
// Handled in measurePing callback
});
return () => {
if (pingInterval) clearInterval(pingInterval);
socket.disconnect();
};
}, [userId]);
return { socketStatus, socketInfo };
}

View file

@ -1,12 +1,14 @@
import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { oneTimeTokenClient, usernameClient } from "better-auth/client/plugins";
import { inferAdditionalFields, oneTimeTokenClient, usernameClient } from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import { auth } from "../../../convex/betterAuth/auth";
export const authClient = createAuthClient({
plugins: [
convexClient(),
usernameClient(),
oneTimeTokenClient()
oneTimeTokenClient(),
inferAdditionalFields<typeof auth>()
],
sessionOptions: {
refetchOnWindowFocus: false,