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:
parent
45301ac52b
commit
096d6ab16c
27 changed files with 1248 additions and 423 deletions
3
bun.lock
3
bun.lock
|
|
@ -32,6 +32,7 @@
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dexie": "^4.2.1",
|
"dexie": "^4.2.1",
|
||||||
|
"dexie-react-hooks": "^4.2.0",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"libsodium-wrappers": "^0.7.15",
|
"libsodium-wrappers": "^0.7.15",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
|
|
@ -460,6 +461,8 @@
|
||||||
|
|
||||||
"dexie": ["dexie@4.2.1", "", {}, "sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg=="],
|
"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": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="],
|
||||||
|
|
||||||
"engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="],
|
"engine.io-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=="],
|
||||||
|
|
|
||||||
6
convex/_generated/api.d.ts
vendored
6
convex/_generated/api.d.ts
vendored
|
|
@ -2035,6 +2035,12 @@ export declare const components: {
|
||||||
>;
|
>;
|
||||||
getFriendRequests: FunctionReference<"query", "internal", any, any>;
|
getFriendRequests: FunctionReference<"query", "internal", any, any>;
|
||||||
getFriends: 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>;
|
getUserStatus: FunctionReference<"query", "internal", any, any>;
|
||||||
sendFriendRequest: FunctionReference<
|
sendFriendRequest: FunctionReference<
|
||||||
"mutation",
|
"mutation",
|
||||||
|
|
|
||||||
|
|
@ -184,3 +184,14 @@ export const getUserStatus = query({
|
||||||
return ctx.runQuery(components.betterAuth.user.index.getUserStatus)
|
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,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -2024,6 +2024,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
||||||
Name
|
Name
|
||||||
>;
|
>;
|
||||||
getFriends: FunctionReference<"query", "internal", any, any, Name>;
|
getFriends: FunctionReference<"query", "internal", any, any, Name>;
|
||||||
|
getParticipantDetails: FunctionReference<
|
||||||
|
"query",
|
||||||
|
"internal",
|
||||||
|
{ participantIds: Array<string> },
|
||||||
|
any,
|
||||||
|
Name
|
||||||
|
>;
|
||||||
getUserStatus: FunctionReference<"query", "internal", any, any, Name>;
|
getUserStatus: FunctionReference<"query", "internal", any, any, Name>;
|
||||||
sendFriendRequest: FunctionReference<
|
sendFriendRequest: FunctionReference<
|
||||||
"mutation",
|
"mutation",
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,27 @@ import { v } from "convex/values";
|
||||||
import { Id } from "../_generated/dataModel";
|
import { Id } from "../_generated/dataModel";
|
||||||
import { mutation, MutationCtx, query, QueryCtx } from "../_generated/server";
|
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();
|
const user = await ctx.auth.getUserIdentity();
|
||||||
if (!user) {
|
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">;
|
const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new Error("User not found");
|
if (required) throw new Error("User not found");
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return { userId, user };
|
||||||
userId,
|
|
||||||
user,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateUserStatus = mutation({
|
export const updateUserStatus = mutation({
|
||||||
|
|
@ -54,7 +60,12 @@ export const updateUserStatus = mutation({
|
||||||
|
|
||||||
export const getUserStatus = query({
|
export const getUserStatus = query({
|
||||||
handler: async (ctx) => {
|
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();
|
const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first();
|
||||||
return userStatus;
|
return userStatus;
|
||||||
}
|
}
|
||||||
|
|
@ -310,7 +321,10 @@ export const getFriends = query({
|
||||||
status: friendStatus ? {
|
status: friendStatus ? {
|
||||||
status: friendStatus.status,
|
status: friendStatus.status,
|
||||||
isUserSet: friendStatus.isUserSet,
|
isUserSet: friendStatus.isUserSet,
|
||||||
} : null,
|
} : {
|
||||||
|
status: "offline" as const,
|
||||||
|
isUserSet: false,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
@ -318,3 +332,39 @@ export const getFriends = query({
|
||||||
return friends.filter(Boolean);
|
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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
"cross-env": "^10.1.0",
|
"cross-env": "^10.1.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"dexie": "^4.2.1",
|
"dexie": "^4.2.1",
|
||||||
|
"dexie-react-hooks": "^4.2.0",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"libsodium-wrappers": "^0.7.15",
|
"libsodium-wrappers": "^0.7.15",
|
||||||
"lucide-react": "^0.561.0",
|
"lucide-react": "^0.561.0",
|
||||||
|
|
|
||||||
4
src/app/(app)/channels/me/[id]/page.tsx
Normal file
4
src/app/(app)/channels/me/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export default function DmPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export default function ServerChannelPage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
5
src/app/(app)/layout.tsx
Normal file
5
src/app/(app)/layout.tsx
Normal 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
4
src/app/(app)/page.tsx
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
export default function HomePage() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
378
src/app/page.tsx
378
src/app/page.tsx
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
133
src/components/app-container.tsx
Normal file
133
src/components/app-container.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
96
src/components/ui/friends/friend-actions-menu.tsx
Normal file
96
src/components/ui/friends/friend-actions-menu.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
115
src/components/ui/friends/friend-list-item.tsx
Normal file
115
src/components/ui/friends/friend-list-item.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
124
src/components/ui/friends/friends-page.tsx
Normal file
124
src/components/ui/friends/friends-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
7
src/components/ui/friends/index.ts
Normal file
7
src/components/ui/friends/index.ts
Normal 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"
|
||||||
|
|
||||||
179
src/components/ui/layout/channel-list.tsx
Normal file
179
src/components/ui/layout/channel-list.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
9
src/components/ui/layout/index.ts
Normal file
9
src/components/ui/layout/index.ts
Normal 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"
|
||||||
|
|
||||||
136
src/components/ui/layout/main-content-layout.tsx
Normal file
136
src/components/ui/layout/main-content-layout.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
148
src/components/ui/layout/page-header.tsx
Normal file
148
src/components/ui/layout/page-header.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
18
src/components/ui/layout/settings-page.tsx
Normal file
18
src/components/ui/layout/settings-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -40,7 +40,7 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
|
||||||
clearInterval(pingIntervalRef.current);
|
clearInterval(pingIntervalRef.current);
|
||||||
pingIntervalRef.current = null;
|
pingIntervalRef.current = null;
|
||||||
}
|
}
|
||||||
setSocketStatus("disconnected");
|
setSocketStatus("manually_disconnected");
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -54,7 +54,9 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user.id) return;
|
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;
|
socketRef.current = socket;
|
||||||
|
|
||||||
// Measure ping latency using acknowledgment callback
|
// Measure ping latency using acknowledgment callback
|
||||||
|
|
@ -145,7 +147,6 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
|
||||||
|
|
||||||
socket.on("disconnect", (reason) => {
|
socket.on("disconnect", (reason) => {
|
||||||
console.log("🔌 Disconnected from socket:", reason);
|
console.log("🔌 Disconnected from socket:", reason);
|
||||||
setUserDefaultStatus("offline", user.status);
|
|
||||||
setSocketStatus("disconnected");
|
setSocketStatus("disconnected");
|
||||||
setSocketInfo((prev: SiPher.SocketInfo) => ({
|
setSocketInfo((prev: SiPher.SocketInfo) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|
@ -159,9 +160,26 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle pong response for ping measurement
|
socket.on("reconnect_attempt", (attempt) => {
|
||||||
socket.on("pong", () => {
|
console.log("🔌 Reconnect attempt:", attempt);
|
||||||
// Handled in measurePing callback
|
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 () => {
|
return () => {
|
||||||
|
|
|
||||||
92
src/lib/constants/phrases.ts
Normal file
92
src/lib/constants/phrases.ts
Normal 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)]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import Dexie, { type EntityTable } from "dexie";
|
import Dexie, { type EntityTable } from "dexie";
|
||||||
|
import { getDmRoomId } from "../sockets/events/dm";
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -21,17 +22,6 @@ export interface OlmSession {
|
||||||
updatedAt: number;
|
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 */
|
/** Message stored locally */
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string; // Unique message ID
|
id: string; // Unique message ID
|
||||||
|
|
@ -55,7 +45,7 @@ export interface UnreadCount {
|
||||||
class SipherDB extends Dexie {
|
class SipherDB extends Dexie {
|
||||||
olmAccounts!: EntityTable<OlmAccount, "odId">;
|
olmAccounts!: EntityTable<OlmAccount, "odId">;
|
||||||
olmSessions!: EntityTable<OlmSession, "odId">;
|
olmSessions!: EntityTable<OlmSession, "odId">;
|
||||||
channels!: EntityTable<Channel, "id">;
|
channels!: EntityTable<SiPher.Channel, "id">;
|
||||||
messages!: EntityTable<Message, "id">;
|
messages!: EntityTable<Message, "id">;
|
||||||
unreadCounts!: EntityTable<UnreadCount, "channelId">;
|
unreadCounts!: EntityTable<UnreadCount, "channelId">;
|
||||||
|
|
||||||
|
|
@ -81,21 +71,33 @@ export const db = new SipherDB();
|
||||||
/** Get or create a DM channel with another user */
|
/** Get or create a DM channel with another user */
|
||||||
export async function getOrCreateDmChannel(
|
export async function getOrCreateDmChannel(
|
||||||
myUserId: string,
|
myUserId: string,
|
||||||
otherUserId: string
|
otherUser: any
|
||||||
): Promise<Channel> {
|
): Promise<SiPher.Channel> {
|
||||||
// Generate deterministic channel ID
|
// Generate deterministic channel ID
|
||||||
const sorted = [myUserId, otherUserId].sort().join(":");
|
const channelId = getDmRoomId(myUserId, otherUser.id);
|
||||||
const channelId = `dm:${await hashString(sorted)}`;
|
|
||||||
|
|
||||||
const existing = await db.channels.get(channelId);
|
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,
|
id: channelId,
|
||||||
participants: [myUserId, otherUserId].sort(),
|
name: otherUser.name,
|
||||||
type: "dm",
|
participants: [myUserId, otherUser.id].sort(),
|
||||||
|
type: "DM" as typeof SiPher.ChannelType.DM,
|
||||||
|
times: {
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
|
lastMessageAt: undefined,
|
||||||
|
lastMessage: undefined,
|
||||||
|
},
|
||||||
|
metadata: undefined,
|
||||||
|
isOpen: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.channels.add(channel);
|
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 });
|
await db.messages.add({ ...message, id });
|
||||||
|
|
||||||
// Update channel's lastMessageAt
|
// Update channel's lastMessageAt
|
||||||
await db.channels.update(message.channelId, {
|
await db.channels.where("id").equals(message.channelId).modify((channel) => {
|
||||||
lastMessageAt: message.timestamp,
|
channel.times.lastMessage = message;
|
||||||
updatedAt: Date.now(),
|
channel.times.lastMessageAt = message.timestamp;
|
||||||
|
channel.times.updatedAt = Date.now();
|
||||||
});
|
});
|
||||||
|
|
||||||
return id;
|
return id;
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Session, User } from "better-auth";
|
import { Session, User } from "better-auth";
|
||||||
|
import { ConvexHttpClient } from "convex/browser";
|
||||||
import { existsSync, readdirSync } from "fs";
|
import { existsSync, readdirSync } from "fs";
|
||||||
import type { Server as HTTPServer } from "http";
|
import type { Server as HTTPServer } from "http";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { Socket, Server as SocketIOServer } from "socket.io";
|
import { Socket, Server as SocketIOServer } from "socket.io";
|
||||||
import { pathToFileURL } from "url";
|
import { pathToFileURL } from "url";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import { api } from "../../../convex/_generated/api";
|
||||||
|
|
||||||
interface SocketManagerOptions {
|
interface SocketManagerOptions {
|
||||||
/** Enable authentication via Better Auth (default: false) */
|
/** Enable authentication via Better Auth (default: false) */
|
||||||
|
|
@ -28,6 +30,7 @@ export default class SocketManager {
|
||||||
private socketIo: SocketIOServer | null = null;
|
private socketIo: SocketIOServer | null = null;
|
||||||
private events: Map<string, SiPher.EventsType[]> = new Map();
|
private events: Map<string, SiPher.EventsType[]> = new Map();
|
||||||
private options: SocketManagerOptions;
|
private options: SocketManagerOptions;
|
||||||
|
private convex: ConvexHttpClient;
|
||||||
|
|
||||||
constructor(nextServer: HTTPServer, options: SocketManagerOptions = {}) {
|
constructor(nextServer: HTTPServer, options: SocketManagerOptions = {}) {
|
||||||
if (!nextServer) {
|
if (!nextServer) {
|
||||||
|
|
@ -41,6 +44,12 @@ export default class SocketManager {
|
||||||
...options
|
...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) {
|
if (!this.socketIo) {
|
||||||
this.socketIo = new SocketIOServer(nextServer, {
|
this.socketIo = new SocketIOServer(nextServer, {
|
||||||
// Configure Socket.IO's built-in heartbeat mechanism
|
// Configure Socket.IO's built-in heartbeat mechanism
|
||||||
|
|
@ -194,7 +203,7 @@ export default class SocketManager {
|
||||||
|
|
||||||
// Register all events with Socket.IO
|
// Register all events with Socket.IO
|
||||||
socketIo.on("connection", (socket) => {
|
socketIo.on("connection", (socket) => {
|
||||||
const user = (socket as any).user;
|
const user = socket.user;
|
||||||
console.log(`[SocketManager] Client connected: ${socket.id}${user ? ` (${user.email})` : ""}`);
|
console.log(`[SocketManager] Client connected: ${socket.id}${user ? ` (${user.email})` : ""}`);
|
||||||
|
|
||||||
// Register all event handlers by name
|
// Register all event handlers by name
|
||||||
|
|
@ -211,8 +220,28 @@ export default class SocketManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle disconnect within the connection context
|
// Handle disconnect within the connection context
|
||||||
socket.on("disconnect", (reason) => {
|
socket.on("disconnect", async (reason) => {
|
||||||
console.log(`[SocketManager] Client disconnected: ${socket.id} (${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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
3
src/types/globals.d.ts
vendored
3
src/types/globals.d.ts
vendored
|
|
@ -27,13 +27,14 @@ declare global {
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
type: typeof ChannelType.DM | typeof ChannelType.GROUP | typeof ChannelType.REGIONAL | typeof ChannelType.GLOBAL | typeof ChannelType.SERVER | typeof ChannelType.SYSTEM
|
type: typeof ChannelType.DM | typeof ChannelType.GROUP | typeof ChannelType.REGIONAL | typeof ChannelType.GLOBAL | typeof ChannelType.SERVER | typeof ChannelType.SYSTEM
|
||||||
participants: SipherUser[]
|
participants: string[]
|
||||||
times: {
|
times: {
|
||||||
createdAt: number,
|
createdAt: number,
|
||||||
updatedAt: number,
|
updatedAt: number,
|
||||||
lastMessageAt?: number,
|
lastMessageAt?: number,
|
||||||
lastMessage?: Message,
|
lastMessage?: Message,
|
||||||
},
|
},
|
||||||
|
isOpen: boolean,
|
||||||
metadata?: {
|
metadata?: {
|
||||||
description?: string,
|
description?: string,
|
||||||
subtitle?: string,
|
subtitle?: string,
|
||||||
|
|
|
||||||
2
src/types/sidebar.d.ts
vendored
2
src/types/sidebar.d.ts
vendored
|
|
@ -17,7 +17,7 @@ declare global {
|
||||||
}
|
}
|
||||||
|
|
||||||
type OlmStatus = "checking" | "synced" | "mismatched" | "not_setup" | "creating";
|
type OlmStatus = "checking" | "synced" | "mismatched" | "not_setup" | "creating";
|
||||||
type SocketStatus = "connecting" | "connected" | "error" | "disconnected";
|
type SocketStatus = "connecting" | "connected" | "error" | "disconnected" | "manually_disconnected";
|
||||||
|
|
||||||
interface SocketInfo {
|
interface SocketInfo {
|
||||||
ping: number | null;
|
ping: number | null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue