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:
parent
df41cf4657
commit
32168722a2
12 changed files with 633 additions and 127 deletions
46
convex/_generated/api.d.ts
vendored
46
convex/_generated/api.d.ts
vendored
|
|
@ -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
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
28
convex/betterAuth/user/index.ts
Normal file
28
convex/betterAuth/user/index.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
382
src/app/page.tsx
382
src/app/page.tsx
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
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");
|
||||
if (status.status === "offline" && !status.isUserSet) {
|
||||
updateUserStatus({ status: "online", isUserSet: false });
|
||||
}
|
||||
}
|
||||
}, [data?.user?.id, updateUserStatus, data?.user?.status]);
|
||||
|
||||
// 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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
59
src/hooks/use-olm-setup.ts
Normal file
59
src/hooks/use-olm-setup.ts
Normal 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
91
src/hooks/use-socket.ts
Normal 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 };
|
||||
}
|
||||
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue