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",
|
||||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"lucide-react": "^0.561.0",
|
||||
|
|
@ -460,6 +461,8 @@
|
|||
|
||||
"dexie": ["dexie@4.2.1", "", {}, "sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg=="],
|
||||
|
||||
"dexie-react-hooks": ["dexie-react-hooks@4.2.0", "", { "peerDependencies": { "@types/react": ">=16", "dexie": ">=4.2.0-alpha.1 <5.0.0", "react": ">=16" } }, "sha512-u7KqTX9JpBQK8+tEyA9X0yMGXlSCsbm5AU64N6gjvGk/IutYDpLBInMYEAEC83s3qhIvryFS+W+sqLZUBEvePQ=="],
|
||||
|
||||
"engine.io": ["engine.io@6.6.4", "", { "dependencies": { "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", "accepts": "~1.3.4", "base64id": "2.0.0", "cookie": "~0.7.2", "cors": "~2.8.5", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1" } }, "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g=="],
|
||||
|
||||
"engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="],
|
||||
|
|
|
|||
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>;
|
||||
getFriends: FunctionReference<"query", "internal", any, any>;
|
||||
getParticipantDetails: FunctionReference<
|
||||
"query",
|
||||
"internal",
|
||||
{ participantIds: Array<string> },
|
||||
any
|
||||
>;
|
||||
getUserStatus: FunctionReference<"query", "internal", any, any>;
|
||||
sendFriendRequest: FunctionReference<
|
||||
"mutation",
|
||||
|
|
|
|||
|
|
@ -184,3 +184,14 @@ export const getUserStatus = query({
|
|||
return ctx.runQuery(components.betterAuth.user.index.getUserStatus)
|
||||
},
|
||||
});
|
||||
|
||||
export const getParticipantDetails = query({
|
||||
args: {
|
||||
participantIds: v.array(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
return ctx.runQuery(components.betterAuth.user.index.getParticipantDetails, {
|
||||
participantIds: args.participantIds,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
|
@ -2024,6 +2024,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
Name
|
||||
>;
|
||||
getFriends: FunctionReference<"query", "internal", any, any, Name>;
|
||||
getParticipantDetails: FunctionReference<
|
||||
"query",
|
||||
"internal",
|
||||
{ participantIds: Array<string> },
|
||||
any,
|
||||
Name
|
||||
>;
|
||||
getUserStatus: FunctionReference<"query", "internal", any, any, Name>;
|
||||
sendFriendRequest: FunctionReference<
|
||||
"mutation",
|
||||
|
|
|
|||
|
|
@ -2,21 +2,27 @@ import { v } from "convex/values";
|
|||
import { Id } from "../_generated/dataModel";
|
||||
import { mutation, MutationCtx, query, QueryCtx } from "../_generated/server";
|
||||
|
||||
async function userValidation(ctx: MutationCtx | QueryCtx) {
|
||||
// Overload signatures
|
||||
async function userValidation(ctx: MutationCtx | QueryCtx, options: { required: false }): Promise<{ userId: Id<"user">; user: any } | null>;
|
||||
async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required?: true }): Promise<{ userId: Id<"user">; user: any }>;
|
||||
|
||||
// Implementation
|
||||
async function userValidation(ctx: MutationCtx | QueryCtx, options?: { required?: boolean }) {
|
||||
const required = options?.required ?? true;
|
||||
|
||||
const user = await ctx.auth.getUserIdentity();
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
if (required) throw new Error("User not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">;
|
||||
if (!userId) {
|
||||
throw new Error("User not found");
|
||||
if (required) throw new Error("User not found");
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
user,
|
||||
}
|
||||
return { userId, user };
|
||||
}
|
||||
|
||||
export const updateUserStatus = mutation({
|
||||
|
|
@ -54,7 +60,12 @@ export const updateUserStatus = mutation({
|
|||
|
||||
export const getUserStatus = query({
|
||||
handler: async (ctx) => {
|
||||
const { userId } = await userValidation(ctx);
|
||||
const validation = await userValidation(ctx, { required: false });
|
||||
if (!validation) {
|
||||
return null; // User not authenticated
|
||||
}
|
||||
|
||||
const { userId } = validation;
|
||||
const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first();
|
||||
return userStatus;
|
||||
}
|
||||
|
|
@ -310,7 +321,10 @@ export const getFriends = query({
|
|||
status: friendStatus ? {
|
||||
status: friendStatus.status,
|
||||
isUserSet: friendStatus.isUserSet,
|
||||
} : null,
|
||||
} : {
|
||||
status: "offline" as const,
|
||||
isUserSet: false,
|
||||
},
|
||||
};
|
||||
})
|
||||
);
|
||||
|
|
@ -318,3 +332,39 @@ export const getFriends = query({
|
|||
return friends.filter(Boolean);
|
||||
}
|
||||
})
|
||||
|
||||
export const getParticipantDetails = query({
|
||||
args: {
|
||||
participantIds: v.array(v.string()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { participantIds } = args;
|
||||
const { userId } = await userValidation(ctx);
|
||||
if (!userId) throw new Error("User not found");
|
||||
|
||||
if (participantIds.length === 0) return [];
|
||||
const normalizedParticipantIds = participantIds.map((id) => ctx.db.normalizeId("user", id));
|
||||
if (normalizedParticipantIds.length === 0) return [];
|
||||
|
||||
// Filter out all null values
|
||||
const filteredParticipantIds = normalizedParticipantIds.filter((id) => id !== null);
|
||||
if (filteredParticipantIds.length === 0) return [];
|
||||
|
||||
const participantDetails = await Promise.all(filteredParticipantIds.map(async (id) => {
|
||||
const participant = await ctx.db.get("user", id)
|
||||
const participantStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", id)).first();
|
||||
if (!participant) return null;
|
||||
|
||||
return {
|
||||
id: participant._id,
|
||||
name: participant.name,
|
||||
username: participant.username,
|
||||
displayUsername: participant.displayUsername,
|
||||
image: participant.image,
|
||||
status: participantStatus?.status || "offline",
|
||||
}
|
||||
}));
|
||||
|
||||
return participantDetails.filter(Boolean);
|
||||
}
|
||||
})
|
||||
|
|
@ -38,6 +38,7 @@
|
|||
"cross-env": "^10.1.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"dexie": "^4.2.1",
|
||||
"dexie-react-hooks": "^4.2.0",
|
||||
"framer-motion": "^12.23.26",
|
||||
"libsodium-wrappers": "^0.7.15",
|
||||
"lucide-react": "^0.561.0",
|
||||
|
|
|
|||
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);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
setSocketStatus("disconnected");
|
||||
setSocketStatus("manually_disconnected");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -54,7 +54,9 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
|
|||
useEffect(() => {
|
||||
if (!user.id) return;
|
||||
|
||||
const socket: Socket = io({ withCredentials: false });
|
||||
const socket: Socket = io({
|
||||
withCredentials: true, reconnectionAttempts: 3, reconnectionDelay: 1000, reconnectionDelayMax: 5000
|
||||
});
|
||||
socketRef.current = socket;
|
||||
|
||||
// Measure ping latency using acknowledgment callback
|
||||
|
|
@ -145,7 +147,6 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
|
|||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.log("🔌 Disconnected from socket:", reason);
|
||||
setUserDefaultStatus("offline", user.status);
|
||||
setSocketStatus("disconnected");
|
||||
setSocketInfo((prev: SiPher.SocketInfo) => ({
|
||||
...prev,
|
||||
|
|
@ -159,9 +160,26 @@ export function useSocket({ user, refetchUser }: UseSocketProps) {
|
|||
}
|
||||
});
|
||||
|
||||
// Handle pong response for ping measurement
|
||||
socket.on("pong", () => {
|
||||
// Handled in measurePing callback
|
||||
socket.on("reconnect_attempt", (attempt) => {
|
||||
console.log("🔌 Reconnect attempt:", attempt);
|
||||
setSocketStatus("connecting");
|
||||
setSocketInfo((prev: SiPher.SocketInfo) => ({
|
||||
...prev,
|
||||
ping: null,
|
||||
connectedAt: null,
|
||||
error: null
|
||||
}));
|
||||
});
|
||||
|
||||
socket.on("reconnect_error", (error) => {
|
||||
console.error("❌ Reconnect error:", error);
|
||||
setSocketStatus("error");
|
||||
setSocketInfo((prev: SiPher.SocketInfo) => ({
|
||||
...prev,
|
||||
ping: null,
|
||||
connectedAt: null,
|
||||
error: error.message
|
||||
}));
|
||||
});
|
||||
|
||||
return () => {
|
||||
|
|
|
|||
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 { getDmRoomId } from "../sockets/events/dm";
|
||||
|
||||
// ============================================
|
||||
// Types
|
||||
|
|
@ -21,17 +22,6 @@ export interface OlmSession {
|
|||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** DM channel */
|
||||
export interface Channel {
|
||||
id: string; // Deterministic room ID (dm:hash)
|
||||
participants: string[]; // User IDs in this channel
|
||||
type: "dm" | "group";
|
||||
name?: string; // For groups
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
lastMessageAt?: number;
|
||||
}
|
||||
|
||||
/** Message stored locally */
|
||||
export interface Message {
|
||||
id: string; // Unique message ID
|
||||
|
|
@ -55,7 +45,7 @@ export interface UnreadCount {
|
|||
class SipherDB extends Dexie {
|
||||
olmAccounts!: EntityTable<OlmAccount, "odId">;
|
||||
olmSessions!: EntityTable<OlmSession, "odId">;
|
||||
channels!: EntityTable<Channel, "id">;
|
||||
channels!: EntityTable<SiPher.Channel, "id">;
|
||||
messages!: EntityTable<Message, "id">;
|
||||
unreadCounts!: EntityTable<UnreadCount, "channelId">;
|
||||
|
||||
|
|
@ -81,21 +71,33 @@ export const db = new SipherDB();
|
|||
/** Get or create a DM channel with another user */
|
||||
export async function getOrCreateDmChannel(
|
||||
myUserId: string,
|
||||
otherUserId: string
|
||||
): Promise<Channel> {
|
||||
otherUser: any
|
||||
): Promise<SiPher.Channel> {
|
||||
// Generate deterministic channel ID
|
||||
const sorted = [myUserId, otherUserId].sort().join(":");
|
||||
const channelId = `dm:${await hashString(sorted)}`;
|
||||
const channelId = getDmRoomId(myUserId, otherUser.id);
|
||||
|
||||
const existing = await db.channels.get(channelId);
|
||||
if (existing) return existing;
|
||||
if (existing) {
|
||||
// Change the isOpen status to true
|
||||
await db.channels.where("id").equals(channelId).modify((channel) => {
|
||||
channel.isOpen = true;
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
|
||||
const channel: Channel = {
|
||||
const channel: SiPher.Channel = {
|
||||
id: channelId,
|
||||
participants: [myUserId, otherUserId].sort(),
|
||||
type: "dm",
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
name: otherUser.name,
|
||||
participants: [myUserId, otherUser.id].sort(),
|
||||
type: "DM" as typeof SiPher.ChannelType.DM,
|
||||
times: {
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
lastMessageAt: undefined,
|
||||
lastMessage: undefined,
|
||||
},
|
||||
metadata: undefined,
|
||||
isOpen: true,
|
||||
};
|
||||
|
||||
await db.channels.add(channel);
|
||||
|
|
@ -123,9 +125,10 @@ export async function addMessage(message: Omit<Message, "id">): Promise<string>
|
|||
await db.messages.add({ ...message, id });
|
||||
|
||||
// Update channel's lastMessageAt
|
||||
await db.channels.update(message.channelId, {
|
||||
lastMessageAt: message.timestamp,
|
||||
updatedAt: Date.now(),
|
||||
await db.channels.where("id").equals(message.channelId).modify((channel) => {
|
||||
channel.times.lastMessage = message;
|
||||
channel.times.lastMessageAt = message.timestamp;
|
||||
channel.times.updatedAt = Date.now();
|
||||
});
|
||||
|
||||
return id;
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@
|
|||
*/
|
||||
|
||||
import { Session, User } from "better-auth";
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
import { existsSync, readdirSync } from "fs";
|
||||
import type { Server as HTTPServer } from "http";
|
||||
import path from "path";
|
||||
import { Socket, Server as SocketIOServer } from "socket.io";
|
||||
import { pathToFileURL } from "url";
|
||||
import z from "zod";
|
||||
import { api } from "../../../convex/_generated/api";
|
||||
|
||||
interface SocketManagerOptions {
|
||||
/** Enable authentication via Better Auth (default: false) */
|
||||
|
|
@ -28,6 +30,7 @@ export default class SocketManager {
|
|||
private socketIo: SocketIOServer | null = null;
|
||||
private events: Map<string, SiPher.EventsType[]> = new Map();
|
||||
private options: SocketManagerOptions;
|
||||
private convex: ConvexHttpClient;
|
||||
|
||||
constructor(nextServer: HTTPServer, options: SocketManagerOptions = {}) {
|
||||
if (!nextServer) {
|
||||
|
|
@ -41,6 +44,12 @@ export default class SocketManager {
|
|||
...options
|
||||
};
|
||||
|
||||
// Initialize Convex client for server-side mutations
|
||||
if (!process.env.NEXT_PUBLIC_CONVEX_URL) {
|
||||
throw new Error("NEXT_PUBLIC_CONVEX_URL is required for SocketManager");
|
||||
}
|
||||
this.convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL);
|
||||
|
||||
if (!this.socketIo) {
|
||||
this.socketIo = new SocketIOServer(nextServer, {
|
||||
// Configure Socket.IO's built-in heartbeat mechanism
|
||||
|
|
@ -194,7 +203,7 @@ export default class SocketManager {
|
|||
|
||||
// Register all events with Socket.IO
|
||||
socketIo.on("connection", (socket) => {
|
||||
const user = (socket as any).user;
|
||||
const user = socket.user;
|
||||
console.log(`[SocketManager] Client connected: ${socket.id}${user ? ` (${user.email})` : ""}`);
|
||||
|
||||
// Register all event handlers by name
|
||||
|
|
@ -211,8 +220,28 @@ export default class SocketManager {
|
|||
}
|
||||
|
||||
// Handle disconnect within the connection context
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.log(`[SocketManager] Client disconnected: ${socket.id} (${reason})`);
|
||||
socket.on("disconnect", async (reason) => {
|
||||
try {
|
||||
const cookies = socket.handshake.headers.cookie;
|
||||
if (!cookies || !cookies.includes("better-auth.convex_jwt")) return;
|
||||
const session = cookies.split("better-auth.convex_jwt=")[1].split(";")[0];
|
||||
|
||||
if (!session) {
|
||||
console.warn(`[SocketManager] No session found for user ${socket.id}, skipping status update`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set auth token for this mutation
|
||||
this.convex.setAuth(session);
|
||||
|
||||
await this.convex.mutation(api.auth.updateUserStatus, {
|
||||
status: "offline",
|
||||
isUserSet: false,
|
||||
});
|
||||
console.log(`[SocketManager] Set user ${socket.id} status to offline`);
|
||||
} catch (error) {
|
||||
console.error(`[SocketManager] Failed to set user status to offline:`, error);
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
|
|
|||
3
src/types/globals.d.ts
vendored
3
src/types/globals.d.ts
vendored
|
|
@ -27,13 +27,14 @@ declare global {
|
|||
id: string,
|
||||
name: string,
|
||||
type: typeof ChannelType.DM | typeof ChannelType.GROUP | typeof ChannelType.REGIONAL | typeof ChannelType.GLOBAL | typeof ChannelType.SERVER | typeof ChannelType.SYSTEM
|
||||
participants: SipherUser[]
|
||||
participants: string[]
|
||||
times: {
|
||||
createdAt: number,
|
||||
updatedAt: number,
|
||||
lastMessageAt?: number,
|
||||
lastMessage?: Message,
|
||||
},
|
||||
isOpen: boolean,
|
||||
metadata?: {
|
||||
description?: string,
|
||||
subtitle?: string,
|
||||
|
|
|
|||
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 SocketStatus = "connecting" | "connected" | "error" | "disconnected";
|
||||
type SocketStatus = "connecting" | "connected" | "error" | "disconnected" | "manually_disconnected";
|
||||
|
||||
interface SocketInfo {
|
||||
ping: number | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue