From 01c5baa017bf296048c70773259160c42361a9d0 Mon Sep 17 00:00:00 2001 From: Jay Patrick Cano <0x3ef8@gmail.com> Date: Thu, 2 Apr 2026 04:30:10 +0800 Subject: [PATCH] feat(ui): unify dashboard animations and FontAwesome icons --- app/(auth)/login/page.tsx | 12 +- app/(auth)/signup/page.tsx | 12 +- app/(public)/join/[code]/JoinButton.tsx | 24 +- app/(public)/join/page.tsx | 66 +- app/(public)/leaderboard/page.tsx | 16 +- app/(user)/dashboard/settings/page.tsx | 54 +- app/api/wakatime/sync/route.ts | 159 +-- app/components/BrowserCheck.tsx | 2 +- app/components/Chat.tsx | 1242 +++++++++-------- app/components/DevIcon.tsx | 4 - app/components/Flex.tsx | 207 ++- app/components/LeaderboardTable.tsx | 148 +- app/components/ProfileDropdown.tsx | 16 +- app/components/auth/LoginForm.tsx | 39 +- app/components/auth/SignupForm.tsx | 39 +- app/components/chat/Conversations.tsx | 117 +- app/components/chat/MediaViewerModal.tsx | 146 ++ app/components/chat/Messages.tsx | 205 +-- app/components/chat/Player.tsx | 53 +- .../chat/hooks/useActiveConversationStream.ts | 191 +++ .../chat/hooks/useChatAttachmentInput.ts | 70 + app/components/chat/hooks/useChatBadWords.ts | 7 + app/components/chat/hooks/useChatBadges.ts | 83 ++ .../chat/hooks/useChatConversationActions.ts | 211 +++ .../hooks/useChatConversationsRealtime.ts | 419 ++++++ .../chat/hooks/useChatInputBehavior.ts | 77 + .../chat/hooks/useChatMessageComposer.ts | 167 +++ app/components/chat/hooks/useChatPresence.ts | 214 +++ app/components/chat/hooks/useChatTyping.ts | 159 +++ .../chat/hooks/useChatUserPicker.ts | 58 + app/components/dashboard/Navbar.tsx | 209 ++- app/components/dashboard/Settings/Profile.tsx | 167 ++- .../dashboard/Settings/ResetPassword.tsx | 26 +- .../dashboard/Settings/WakaTimeKey.tsx | 165 +++ app/components/dashboard/Stats.tsx | 236 +++- app/components/dashboard/WithKey.tsx | 32 +- app/components/dashboard/WithoutKey.tsx | 164 ++- .../dashboard/widgets/Categories.tsx | 77 +- .../widgets/CodingConsistencyHeatmap.tsx | 267 ++++ .../dashboard/widgets/Dependencies.tsx | 12 +- app/components/dashboard/widgets/Editors.tsx | 104 +- .../widgets/LanguageDestribution.tsx | 161 ++- app/components/dashboard/widgets/Machines.tsx | 19 +- .../dashboard/widgets/OperatingSystem.tsx | 107 +- app/components/dashboard/widgets/Projects.tsx | 12 +- .../landing-page/ContributeCard.tsx | 86 +- app/components/landing-page/Contributors.tsx | 100 +- app/components/landing-page/LosserMembers.tsx | 147 +- .../landing-page/RecentLeaderboard.tsx | 180 ++- app/components/landing-page/TopLeaderbord.tsx | 175 ++- app/components/landing-page/VibeCoders.tsx | 123 +- app/components/layout/CTA.tsx | 64 +- app/components/leaderboard/Header.tsx | 22 +- app/globals.css | 103 ++ app/hooks/useBadWords.ts | 77 + app/layout.tsx | 3 +- app/lib/wakatime/repository.ts | 53 + app/lib/wakatime/sync.ts | 267 ++++ app/page.tsx | 64 +- app/sentry-example-page/page.tsx | 16 +- app/supabase-types.ts | 57 + app/utils/badge.ts | 86 ++ app/utils/media.ts | 21 + app/utils/moderation.ts | 43 + app/utils/slug.ts | 11 + app/utils/wakatime.ts | 63 + package-lock.json | 131 +- package.json | 3 +- .../20260320234731_add_enforcement_checks.sql | 1 - ...0327100000_cascade_conversation_delete.sql | 10 + ...329120000_add_user_dashboard_snapshots.sql | 42 + ...00_add_chat_presence_and_read_tracking.sql | 14 + ...03000_enable_chat_realtime_publication.sql | 33 + ...60330104500_fix_global_chat_membership.sql | 45 + 74 files changed, 6049 insertions(+), 1966 deletions(-) delete mode 100644 app/components/DevIcon.tsx create mode 100644 app/components/chat/MediaViewerModal.tsx create mode 100644 app/components/chat/hooks/useActiveConversationStream.ts create mode 100644 app/components/chat/hooks/useChatAttachmentInput.ts create mode 100644 app/components/chat/hooks/useChatBadWords.ts create mode 100644 app/components/chat/hooks/useChatBadges.ts create mode 100644 app/components/chat/hooks/useChatConversationActions.ts create mode 100644 app/components/chat/hooks/useChatConversationsRealtime.ts create mode 100644 app/components/chat/hooks/useChatInputBehavior.ts create mode 100644 app/components/chat/hooks/useChatMessageComposer.ts create mode 100644 app/components/chat/hooks/useChatPresence.ts create mode 100644 app/components/chat/hooks/useChatTyping.ts create mode 100644 app/components/chat/hooks/useChatUserPicker.ts create mode 100644 app/components/dashboard/Settings/WakaTimeKey.tsx create mode 100644 app/components/dashboard/widgets/CodingConsistencyHeatmap.tsx create mode 100644 app/hooks/useBadWords.ts create mode 100644 app/lib/wakatime/repository.ts create mode 100644 app/lib/wakatime/sync.ts create mode 100644 app/utils/badge.ts create mode 100644 app/utils/media.ts create mode 100644 app/utils/moderation.ts create mode 100644 app/utils/slug.ts create mode 100644 app/utils/wakatime.ts create mode 100644 supabase/migrations/20260327100000_cascade_conversation_delete.sql create mode 100644 supabase/migrations/20260329120000_add_user_dashboard_snapshots.sql create mode 100644 supabase/migrations/20260329133000_add_chat_presence_and_read_tracking.sql create mode 100644 supabase/migrations/20260330103000_enable_chat_realtime_publication.sql create mode 100644 supabase/migrations/20260330104500_fix_global_chat_membership.sql diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx index affe353..05cd1c5 100644 --- a/app/(auth)/login/page.tsx +++ b/app/(auth)/login/page.tsx @@ -2,6 +2,8 @@ import Link from "next/link"; import Image from "next/image"; import LoginForm from "@/app/components/auth/LoginForm"; import { Metadata } from "next"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; export const metadata: Metadata = { title: "Login - DevPulse", @@ -63,7 +65,15 @@ export default async function Login(props: { : undefined; return ( -
+
+ + + Back + + {/* Left Side - Visual / Branding */}
{/* Background elements */} diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index 438601a..33c39d6 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -2,6 +2,8 @@ import Link from "next/link"; import Image from "next/image"; import SignupForm from "@/app/components/auth/SignupForm"; import { Metadata } from "next"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faChevronLeft } from "@fortawesome/free-solid-svg-icons"; export const metadata: Metadata = { title: "Sign Up - DevPulse", @@ -50,7 +52,15 @@ export default async function Signup(props: { : undefined; return ( -
+
+ + + Back + + {/* Left Side - Visual / Branding */}
{/* Background elements */} diff --git a/app/(public)/join/[code]/JoinButton.tsx b/app/(public)/join/[code]/JoinButton.tsx index 886328c..9882448 100644 --- a/app/(public)/join/[code]/JoinButton.tsx +++ b/app/(public)/join/[code]/JoinButton.tsx @@ -5,6 +5,13 @@ import { createClient } from "../../../lib/supabase/client"; import { useRouter } from "next/navigation"; import { toast } from "react-toastify"; import Link from "next/link"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faArrowRightToBracket, + faCheck, + faSpinner, + faUserPlus, +} from "@fortawesome/free-solid-svg-icons"; export default function JoinButton({ code, @@ -26,9 +33,7 @@ export default function JoinButton({ href={`/leaderboard/${leaderboardSlug}`} className="btn-primary inline-flex items-center justify-center gap-2 w-full py-4 text-sm font-bold rounded-xl shadow-lg shadow-indigo-500/20" > - - - + View View Leaderboard @@ -42,9 +47,7 @@ export default function JoinButton({ href={`/login?redirect=${encodeURIComponent(`/join?id=${code}`)}`} className="btn-primary inline-flex items-center justify-center gap-2 w-full py-4 text-sm font-bold rounded-xl shadow-lg shadow-indigo-500/20" > - - - + Log In to Join

@@ -112,17 +115,12 @@ export default function JoinButton({ > {joining ? ( <> - - - - + Joining... ) : ( <> - - - + Accept Invite & Join )} diff --git a/app/(public)/join/page.tsx b/app/(public)/join/page.tsx index 8bd997d..25e8498 100644 --- a/app/(public)/join/page.tsx +++ b/app/(public)/join/page.tsx @@ -4,6 +4,13 @@ import JoinButton from "./[code]/JoinButton"; import Footer from "@/app/components/layout/Footer"; import Image from "next/image"; import Link from "next/link"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { + faCircleInfo, + faCircleXmark, + faRankingStar, + faUsers, +} from "@fortawesome/free-solid-svg-icons"; type Props = { searchParams: Promise<{ id?: string }>; @@ -82,19 +89,10 @@ export default async function JoinPage({ searchParams }: Props) {

- - - + />

Join a Leaderboard @@ -118,19 +116,10 @@ export default async function JoinPage({ searchParams }: Props) {
- - - + />

Invite Not Found @@ -195,37 +184,16 @@ export default async function JoinPage({ searchParams }: Props) {
- - - + {memberCount} {memberCount === 1 ? "member" : "members"}
- - - + /> Leaderboard
diff --git a/app/(public)/leaderboard/page.tsx b/app/(public)/leaderboard/page.tsx index 17d4194..0bb0f99 100644 --- a/app/(public)/leaderboard/page.tsx +++ b/app/(public)/leaderboard/page.tsx @@ -5,6 +5,8 @@ import CTA from "@/app/components/layout/CTA"; import Image from "next/image"; import { Metadata } from "next"; import { getUserWithProfile } from "@/app/lib/supabase/help/user"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; export const metadata: Metadata = { title: "Leaderboards - DevPulse", @@ -109,19 +111,7 @@ export default async function Leaderboards() {

View{" "} - - - + ), diff --git a/app/(user)/dashboard/settings/page.tsx b/app/(user)/dashboard/settings/page.tsx index e8c6262..fbf2fb7 100644 --- a/app/(user)/dashboard/settings/page.tsx +++ b/app/(user)/dashboard/settings/page.tsx @@ -1,6 +1,7 @@ import { Metadata } from "next"; import UserProfile from "@/app/components/dashboard/Settings/Profile"; import ResetPassword from "@/app/components/dashboard/Settings/ResetPassword"; +import WakaTimeKey from "@/app/components/dashboard/Settings/WakaTimeKey"; import { getUserWithProfile } from "@/app/lib/supabase/help/user"; import { redirect } from "next/navigation"; @@ -8,24 +9,53 @@ export const metadata: Metadata = { title: "Settings - DevPulse", }; -export default async function LeaderboardsPage() { - const { user } = await getUserWithProfile(); +export default async function SettingsPage() { + const { user, profile } = await getUserWithProfile(); if (!user) return redirect("/login?from=/dashboard/settings"); + const hasWakaKey = Boolean(profile?.wakatime_api_key); + const maskedWakaKey = profile?.wakatime_api_key + ? `${profile.wakatime_api_key.slice(0, 8)}...${profile.wakatime_api_key.slice(-4)}` + : null; + return ( -
-
-

Settings

-

- Manage your account settings and including your WakaTime API key. -

+
+
+
+
+

+ Account Settings +

+

+ Manage profile details, WakaTime connection, and account security. +

+
+ +
+ + {hasWakaKey ? "WakaTime Connected" : "WakaTime Not Connected"} + +
+
{user && ( - <> - - - +
+
+ + +
+ +
+ +
+
)}
); diff --git a/app/api/wakatime/sync/route.ts b/app/api/wakatime/sync/route.ts index 680c492..587cd97 100644 --- a/app/api/wakatime/sync/route.ts +++ b/app/api/wakatime/sync/route.ts @@ -1,166 +1,43 @@ import { NextResponse } from "next/server"; import { createClient } from "../../../lib/supabase/server"; import { getUserWithProfile } from "@/app/lib/supabase/help/user"; +import { + syncWakatimeData, + validateWakatimeApiKey, +} from "@/app/lib/wakatime/sync"; export async function GET(request: Request) { const supabase = await createClient(); const { user, profile } = await getUserWithProfile(); const { searchParams } = new URL(request.url); const apiKey = searchParams.get("apiKey") || ""; - let profile$: { wakatime_api_key: string }; - if (apiKey && (!apiKey.trim() || !/^waka_[0-9a-f-]{36}$/i.test(apiKey))) { + const validationError = validateWakatimeApiKey(apiKey); + if (validationError) { return NextResponse.json( - { error: "Please enter a valid WakaTime API key." }, + { error: validationError }, { status: 400 }, ); } - profile$ = { wakatime_api_key: apiKey }; - if (!user) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } - if (!apiKey) { - if (!profile?.wakatime_api_key) { - return NextResponse.json({ error: "No API key found" }, { status: 400 }); - } - - profile$ = { wakatime_api_key: profile.wakatime_api_key }; - - // Check last fetch - const { data: existing } = await supabase - .from("user_stats") - .select( - ` - *, - projects:user_projects ( - projects - ) - `, - ) - .eq("user_id", user.id) - .single(); - - const now = new Date(); - const sixHours = 6 * 60 * 60 * 1000; - - if (existing?.last_fetched_at) { - const lastFetch = new Date(existing.last_fetched_at).getTime(); - if (now.getTime() - lastFetch < sixHours) { - return NextResponse.json({ success: true, data: existing }); - } - } - } - - // Fetch from WakaTime API endpoints - const endDate = new Date(); - const startDate = new Date(); - startDate.setDate(endDate.getDate() - 6); - const endStr = endDate.toISOString().split("T")[0]; - const startStr = startDate.toISOString().split("T")[0]; - - const authHeader = `Basic ${Buffer.from(profile$.wakatime_api_key).toString("base64")}`; - - const [statsResponse, summariesResponse] = await Promise.all([ - fetch("https://wakatime.com/api/v1/users/current/stats/last_7_days", { - headers: { Authorization: authHeader }, - }), - fetch( - `https://wakatime.com/api/v1/users/current/summaries?start=${startStr}&end=${endStr}`, - { - headers: { Authorization: authHeader }, - }, - ), - ]); - - const statsData = await statsResponse.json(); - const summariesData = await summariesResponse.json(); - - if (!statsResponse.ok || !summariesResponse.ok) { - return NextResponse.json( - { error: "Failed to fetch data from WakaTime" }, - { status: 500 }, - ); - } - - const wakaStats = statsData.data; - const wakaSummaries = summariesData.data; - - // Process daily summaries - const daily_stats = wakaSummaries.map( - (day: { - range: { date: string }; - grand_total: { total_seconds: number }; - }) => ({ - date: day.range.date, - total_seconds: day.grand_total.total_seconds, - }), - ); - - if (apiKey) { - const { error } = await supabase - .from("profiles") - .update({ wakatime_api_key: apiKey }) - .eq("id", user.id); - - if (error) { - if (error.code === "23505") { - return NextResponse.json( - { error: "This WakaTime API key is already in use." }, - { status: 400 }, - ); - } + const result = await syncWakatimeData({ + supabase, + userId: user.id, + incomingApiKey: apiKey, + storedApiKey: profile?.wakatime_api_key, + }); - return NextResponse.json( - { error: "Failed to update API key" }, - { status: 500 }, - ); - } + if (!result.success && result.status !== 200) { + return NextResponse.json({ error: result.error }, { status: result.status }); } - const [ - { data: statsResult, error: statsError }, - { data: projectsResult, error: projectsError }, - ] = await Promise.all([ - supabase - .from("user_stats") - .upsert({ - user_id: user.id, - total_seconds: Math.floor(wakaStats.total_seconds), - daily_average: Math.floor(wakaStats.daily_average || 0), - languages: wakaStats.languages, - operating_systems: wakaStats.operating_systems, - editors: wakaStats.editors, - machines: wakaStats.machines, - categories: wakaStats.categories, - dependencies: wakaStats.dependencies || [], - best_day: wakaStats.best_day || {}, - daily_stats: daily_stats, - last_fetched_at: new Date().toISOString(), - }) - .select() - .single(), - supabase - .from("user_projects") - .upsert({ - user_id: user.id, - projects: wakaStats.projects, - last_fetched_at: new Date().toISOString(), - }) - .select() - .single(), - ]); - - const mergedResult = { - ...statsResult, - projects: projectsResult?.projects || [], - }; - return NextResponse.json({ - success: !!statsResult && !statsError && !projectsError, - data: mergedResult, - error: statsError || projectsError, + success: result.success, + data: result.data, + error: result.error, }); } diff --git a/app/components/BrowserCheck.tsx b/app/components/BrowserCheck.tsx index 644ce58..a056bfe 100644 --- a/app/components/BrowserCheck.tsx +++ b/app/components/BrowserCheck.tsx @@ -24,4 +24,4 @@ export default function BrowserCheck() { }, []); return null; -} +} \ No newline at end of file diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx index a760d8a..e39f4da 100644 --- a/app/components/Chat.tsx +++ b/app/components/Chat.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { RealtimeChannel, User } from "@supabase/supabase-js"; import { createClient } from "../lib/supabase/client"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; @@ -8,9 +8,30 @@ import { faFile, faPaperPlane, faPlus, + faSearch, + faInfoCircle, + faBellSlash, + faChevronDown, + faChevronUp, + faXmark, + faChevronLeft, + faTrash, + faPlay } from "@fortawesome/free-solid-svg-icons"; import Conversations from "./chat/Conversations"; import Messages from "./chat/Messages"; +import MediaViewerModal, { type MediaViewerPayload } from "./chat/MediaViewerModal"; +import { useActiveConversationStream } from "./chat/hooks/useActiveConversationStream"; +import { useChatAttachmentInput } from "./chat/hooks/useChatAttachmentInput"; +import { useChatBadWords } from "./chat/hooks/useChatBadWords"; +import { useChatBadges } from "./chat/hooks/useChatBadges"; +import { useChatConversationActions } from "./chat/hooks/useChatConversationActions"; +import { useChatConversationsRealtime } from "./chat/hooks/useChatConversationsRealtime"; +import { useChatInputBehavior } from "./chat/hooks/useChatInputBehavior"; +import { useChatMessageComposer } from "./chat/hooks/useChatMessageComposer"; +import { useChatPresence } from "./chat/hooks/useChatPresence"; +import { useChatTyping } from "./chat/hooks/useChatTyping"; +import { useChatUserPicker } from "./chat/hooks/useChatUserPicker"; import Image from "next/image"; import { toast } from "react-toastify"; @@ -18,6 +39,7 @@ export interface Conversation { id: string; users: { id: string; email: string }[]; type: string; + created_at?: string; } export interface Message { @@ -32,6 +54,7 @@ export interface Message { public_url: string; }[]; created_at: string; + optimistic?: boolean; } export interface ChatUser { @@ -51,7 +74,24 @@ export interface ConversationParticipantRow { conversation: ConversationParticipant[]; } -type Attachment = File; +interface ParticipantPresence { + last_seen_at: string | null; + last_read_at: string | null; +} + +export interface TypingState { + user_id: string; + label: string; +} + +const GLOBAL_CONVERSATION_ID = "00000000-0000-0000-0000-000000000001"; +const ONLINE_TIMEOUT_MS = 2 * 60 * 1000; +const MAX_PRESENCE_FUTURE_SKEW_MS = 30_000; +const PRESENCE_HEARTBEAT_MS = 45_000; +const READ_RECEIPT_THROTTLE_MS = 1_500; +const TYPING_INACTIVE_TIMEOUT_MS = 1_800; +const TYPING_REMOTE_EXPIRE_MS = 2_500; +const PRESENCE_UNSEEN_AT_ISO = "1970-01-01T00:00:00.000Z"; const supabase = createClient(); @@ -61,689 +101,661 @@ export default function Chat({ user }: { user: User }) { const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [showModal, setShowModal] = useState(false); - const [search, setSearch] = useState(""); - const [allUsers, setAllUsers] = useState([]); - const [badgesByUserId, setBadgesByUserId] = useState< - Record + const [messageSearch, setMessageSearch] = useState(""); + const [unreadCountByConversationId, setUnreadCountByConversationId] = useState< + Record >({}); - const badgeCacheRef = useRef< - Record + const [, setParticipantMetaByConversationId] = useState< + Record >({}); - const channelRef = useRef(null); - const textareaRef = useRef(null); + const conversationIdsRef = useRef>(new Set()); + const activeConversationIdRef = useRef(null); + const channelRef = useRef(null); const bottomRef = useRef(null); - const [badWords, setBadWords] = useState([]); + const [dmSortOrder, setDmSortOrder] = useState<"newest" | "oldest" | "az" | "za">("newest"); + const [isDmSortOpen, setIsDmSortOpen] = useState(false); const creatingRef = useRef(false); const fileInputRef = useRef(null); - const [attachments, setAttachments] = useState([]); - const [isDraggingOver, setIsDraggingOver] = useState(false); - const bucketName = process.env.NEXT_PUBLIC_SUPABASE_BUCKET_NAME || ""; - - const globalConversations = conversations.filter((c) => c.type === "global"); - const privateConversations = conversations.filter((c) => c.type !== "global"); - - const getBadgeInfoFromHours = (hours: number) => { - if (hours >= 160) - return { - label: "MISSION IMPOSSIBLE", - className: - "bg-gradient-to-r from-fuchsia-500/20 via-pink-500/40 to-fuchsia-500/20 border-fuchsia-500/60 text-fuchsia-200", - }; - if (hours >= 130) - return { - label: "GOD LEVEL", - className: - "bg-gradient-to-r from-fuchsia-500/20 via-pink-400/40 to-fuchsia-500/20 border-fuchsia-400/60 text-fuchsia-200", - }; - if (hours >= 100) - return { - label: "STARLIGHT", - className: - "bg-gradient-to-r from-sky-500/15 via-cyan-400/35 to-sky-500/15 border-sky-400/50 text-cyan-200", - }; - if (hours >= 50) - return { - label: "ELITE", - className: - "bg-gradient-to-r from-rose-500/15 via-red-400/35 to-rose-500/15 border-red-400/50 text-rose-200", - }; - if (hours >= 20) - return { - label: "PRO", - className: - "bg-gradient-to-r from-indigo-500/15 via-violet-500/35 to-indigo-500/15 border-indigo-400/50 text-indigo-200", - }; - if (hours >= 5) - return { - label: "NOVICE", - className: - "bg-gradient-to-r from-emerald-500/15 via-green-400/35 to-emerald-500/15 border-emerald-400/45 text-emerald-200", - }; - if (hours >= 1) - return { - label: "NEWBIE", - className: - "bg-gradient-to-r from-lime-500/15 via-yellow-400/35 to-lime-500/15 border-lime-400/45 text-lime-200", - }; - - return { - label: "NONE", - className: "bg-white/[0.03] border-white/10 text-gray-300", - }; - }; + const [showRightSidebar, setShowRightSidebar] = useState(false); + const [showAllMedia, setShowAllMedia] = useState(false); + const [mediaViewer, setMediaViewer] = useState( + null, + ); + const lastReadSyncAtRef = useRef>({}); + + const { + setLastSeenByUserId, + onlineByUserId, + fetchUnreadCountsForConversations, + markConversationAsRead, + } = useChatPresence({ + supabase, + userId: user.id, + onlineTimeoutMs: ONLINE_TIMEOUT_MS, + maxPresenceFutureSkewMs: MAX_PRESENCE_FUTURE_SKEW_MS, + presenceHeartbeatMs: PRESENCE_HEARTBEAT_MS, + readReceiptThrottleMs: READ_RECEIPT_THROTTLE_MS, + setParticipantMetaByConversationId, + setUnreadCountByConversationId, + lastReadSyncAtRef, + }); + + const { + typingByConversationId, + setRemoteTypingState, + stopTyping, + markTypingFromInput, + } = useChatTyping({ + channelRef, + userId: user.id, + userEmail: user.email ?? "", + typingInactiveTimeoutMs: TYPING_INACTIVE_TIMEOUT_MS, + typingRemoteExpireMs: TYPING_REMOTE_EXPIRE_MS, + }); + + const { + attachments, + setAttachments, + isDraggingOver, + handleFileChange, + handleDrop, + handleDragOver, + onDragLeave, + handlePaste, + removeAttachment, + } = useChatAttachmentInput(); + + const { search, setSearch, allUsers, filteredUsers } = useChatUserPicker({ + supabase, + userId: user.id, + showModal, + }); + + const { badgesByUserId } = useChatBadges({ + supabase, + userId: user.id, + conversations, + }); + + const { badWords } = useChatBadWords(); useEffect(() => { - const fetchBadgesForParticipants = async () => { - if (!conversations.length) return; - - const participantIds = new Set(); - conversations.forEach((c) => { - c.users.forEach((u) => { - if (u.id) participantIds.add(u.id); - }); - }); - participantIds.add(user.id); - - const ids = Array.from(participantIds).filter(Boolean); - if (ids.length === 0) return; - - const cached: Record = {}; - const missingIds: string[] = []; - ids.forEach((id) => { - const hit = badgeCacheRef.current[id]; - if (hit) cached[id] = hit; - else missingIds.push(id); - }); - - if (Object.keys(cached).length > 0) { - setBadgesByUserId((prev) => ({ ...prev, ...cached })); - } - - if (missingIds.length === 0) return; - - const { data } = await supabase - .from("top_user_stats") - .select("user_id, email, total_seconds") - .in("user_id", missingIds); - - if (!data) return; - - const next: Record = {}; - for (const row of data) { - if (!row.user_id || row.total_seconds === null) continue; - const hours = Math.round((row.total_seconds || 0) / 3600); - const badge = getBadgeInfoFromHours(hours); - next[row.user_id] = { label: badge.label, className: badge.className }; - } - - badgeCacheRef.current = { ...badgeCacheRef.current, ...next }; - setBadgesByUserId((prev) => ({ ...prev, ...next })); - }; - - fetchBadgesForParticipants(); - }, [conversations, user.id]); + conversationIdsRef.current = new Set(conversations.map((conv) => conv.id)); + }, [conversations]); useEffect(() => { - fetch( - "https://raw.githubusercontent.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words/refs/heads/master/en", - ) - .then((res) => res.text()) - .then((text) => { - const wordsArray = text.split("\n").filter(Boolean); - setBadWords(wordsArray); - }); - }, []); + activeConversationIdRef.current = conversationId; + }, [conversationId]); - useEffect(() => { - if (textareaRef.current) { - const el = textareaRef.current; - const minHeight = 20; - const maxHeight = minHeight * 6; - el.style.height = `${minHeight}px`; - el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`; - el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden"; - } - }, [input]); + useChatConversationsRealtime({ + supabase, + userId: user.id, + userEmail: user.email ?? "", + globalConversationId: GLOBAL_CONVERSATION_ID, + conversationIdsRef, + activeConversationIdRef, + setConversations, + setParticipantMetaByConversationId, + setLastSeenByUserId, + setUnreadCountByConversationId, + fetchUnreadCountsForConversations, + markConversationAsRead, + }); + + useActiveConversationStream({ + supabase, + conversationId, + userId: user.id, + channelRef, + bottomRef, + setMessages, + markConversationAsRead, + setRemoteTypingState, + stopTyping, + }); + + const { + createConversation, + openPrivateChatFromGlobalProfile, + handleDeleteConversation, + } = useChatConversationActions({ + supabase, + userId: user.id, + userEmail: user.email, + conversationId, + conversations, + creatingRef, + unseenPresenceIso: PRESENCE_UNSEEN_AT_ISO, + setConversationId, + setShowModal, + setShowRightSidebar, + setConversations, + setUnreadCountByConversationId, + setParticipantMetaByConversationId, + }); - useEffect(() => { - const fetchConversations = async () => { - const { data } = await supabase - .from("conversation_participants") - .select( - ` - conversation: conversations( - id, - users: conversation_participants!inner(user_id, email), - type - ) - `, - ) - .eq("user_id", user.id); - - if (data) { - const convs: Conversation[] = (data ?? []).map((row) => { - const convo = Array.isArray(row.conversation) - ? row.conversation[0] - : row.conversation; - - return { - id: convo.id, - users: convo.users.map((u: { user_id: string; email: string }) => ({ - id: u.user_id, - email: u.email, - })), - type: convo.type, - }; - }); - - const sortedConvs = convs.sort((a, b) => - a.type === "global" ? -1 : b.type === "global" ? 1 : 0, - ); - setConversations(sortedConvs); - } - }; - fetchConversations(); - }, [user.id]); + const bucketName = process.env.NEXT_PUBLIC_SUPABASE_BUCKET_NAME || ""; - useEffect(() => { - if (!user.id) return; - - const channel = supabase - .channel(`conversations-user-${user.id}`) - .on( - "postgres_changes", - { - event: "INSERT", - schema: "public", - table: "conversation_participants", - }, - (payload) => { - const row = payload.new; - if (row.user_id === user.id) return; - - supabase - .from("conversations") - .select( - ` - id, - users:conversation_participants!inner(user_id), - type - `, - ) - .eq("id", row.conversation_id) - .then(({ data }) => { - if (!data || data.length === 0) return; - const convo = data[0]; - if ( - !convo.users.some( - (u: { user_id: string }) => u.user_id === user.id, - ) - ) - return; - if (conversations.some((c) => c.id === convo.id)) return; - - setConversations((prev) => [ - ...prev, - { - id: convo.id, - users: convo.users.map((u) => ({ - id: u.user_id, - email: row.email, - })), - type: convo.type, - }, - ]); - }); - }, - ) - .subscribe(); - return () => { - channel.unsubscribe(); - }; - }, [user.id, conversations]); + const { sendMessage } = useChatMessageComposer({ + supabase, + userId: user.id, + conversationId, + input, + attachments, + badWords, + bucketName, + bottomRef, + setInput, + setAttachments, + setMessages, + stopTyping, + markConversationAsRead, + }); + + const { textareaRef, handleInputChange, handleInputKeyDown } = + useChatInputBehavior({ + input, + conversationId, + attachmentsCount: attachments.length, + setInput, + markTypingFromInput, + sendMessage, + maxChars: 1000, + }); + + const totalUnreadCount = useMemo( + () => Object.values(unreadCountByConversationId).reduce((sum, count) => sum + count, 0), + [unreadCountByConversationId], + ); - useEffect(() => { - if (!conversationId) return; - - if (channelRef.current) { - channelRef.current.unsubscribe(); - } - - const channel = supabase - .channel(`conversation-${conversationId}`) - .on( - "postgres_changes", - { - event: "INSERT", - schema: "public", - table: "messages", - filter: `conversation_id=eq.${conversationId}`, - }, - (payload) => { - setMessages((prev) => [ - ...prev, - { - id: payload.new.id, - conversation_id: payload.new.conversation_id, - sender_id: payload.new.sender_id, - text: payload.new.text, - attachments: payload.new.attachments, - created_at: payload.new.created_at, - }, - ]); - setTimeout(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); - }, - ) - .subscribe(); - - channelRef.current = channel; - - const fetchMessages = async () => { - const { data } = await supabase - .from("messages") - .select("*") - .eq("conversation_id", conversationId) - .order("created_at", { ascending: true }); - if (data) { - setMessages(data as Message[]); - setTimeout(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); + const globalConversations = conversations.filter((c) => c.type === "global"); + const privateConversations = conversations + .filter((c) => c.type !== "global") + .sort((a, b) => { + if (dmSortOrder === "newest") { + return (b.created_at ? new Date(b.created_at).getTime() : 0) - (a.created_at ? new Date(a.created_at).getTime() : 0); } - }; - - fetchMessages(); - - return () => { - channel.unsubscribe(); - }; - }, [conversationId]); - - useEffect(() => { - if (!showModal) return; - - const fetchUsers = async () => { - const { data } = await supabase - .from("top_user_stats") - .select("user_id, email") - .neq("user_id", user.id); - if (data) { - const users: ChatUser[] = data.filter( - (u): u is { user_id: string; email: string } => - u.user_id !== null && u.email !== null, - ); - - setAllUsers(users); + if (dmSortOrder === "oldest") { + return (a.created_at ? new Date(a.created_at).getTime() : 0) - (b.created_at ? new Date(b.created_at).getTime() : 0); } - }; - - fetchUsers(); - }, [showModal, user.id]); - - const handleFileChange = (e: React.ChangeEvent) => { - const files = Array.from(e.target.files || []); - if (!files.length) return; - - setAttachments((prev) => [...prev, ...files]); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDraggingOver(false); - - const files = Array.from(e.dataTransfer.files || []); - if (!files.length) return; - - setAttachments((prev) => [...prev, ...files]); - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDraggingOver(true); - }; - - const onDragLeave = (e: React.DragEvent) => { - setIsDraggingOver(false); - }; - - const handlePaste = (e: React.ClipboardEvent) => { - const items = e.clipboardData.items; - if (!items) return; - - const files: File[] = []; - - for (let i = 0; i < items.length; i++) { - const item = items[i]; - - if (item.kind === "file") { - const file = item.getAsFile(); - if (file) files.push(file); + + const aName = a.users.find((u) => u.id !== user.id)?.email?.split("@")[0] || ""; + const bName = b.users.find((u) => u.id !== user.id)?.email?.split("@")[0] || ""; + + if (dmSortOrder === "az") { + return aName.localeCompare(bName); + } + if (dmSortOrder === "za") { + return bName.localeCompare(aName); } - } - - if (files.length) { - setAttachments((prev) => [...prev, ...files]); - } - }; - - const removeAttachment = (index: number) => { - setAttachments((prev) => prev.filter((_, i) => i !== index)); - }; - - const sanitizeInput = (input: string) => { - if (!badWords.length) return input; - - const filter = new RegExp(`\\b(${badWords.join("|")})\\b`, "gi"); - return input.replace(filter, "*-?;[]"); - }; - - const createConversation = async (otherUser: ChatUser) => { - if (creatingRef.current) return; - creatingRef.current = true; - - const existing = conversations.find((conv) => - conv.users.some((u) => u.id === otherUser.user_id), - ); - if (existing) { - setConversationId(existing.id); - setShowModal(false); - return; - } - - const { data: convData } = await supabase - .from("conversations") - .insert({}) - .select("*") - .single(); - - if (!convData) return; - - const convId = convData.id; - - await supabase.from("conversation_participants").upsert( - [ - { - conversation_id: convId, - user_id: user.id, - email: user.email, - }, - { - conversation_id: convId, - user_id: otherUser.user_id, - email: otherUser.email, - }, - ], - { - onConflict: "conversation_id,user_id", - }, - ); - - setConversationId(convId); - setConversations((prev) => [ - ...prev, - { - id: convId, - users: [ - { id: user.id, email: user.email ?? "" }, - { id: otherUser.user_id, email: otherUser.email ?? "" }, - ], - type: "private", - }, - ]); - - setShowModal(false); - creatingRef.current = false; - }; - - const sendMessage = async () => { - if ((!input.trim() && attachments.length === 0) || !conversationId) return; - - try { - const uploadedAttachments = await Promise.all( - attachments.map(async (file) => { - if (!bucketName || bucketName.length === 0) { - toast.error("Storage bucket is not configured."); - return null; - } - if (file.size > 10 * 1024 * 1024) { - toast.error(`${file.name} is too large. Max size is 10MB.`); - return null; - } - - const filePath = `messages/${conversationId}/${Date.now()}-${file.name}`; - - const { error: uploadError } = await supabase.storage - .from(bucketName) - .upload(filePath, file); - - if (uploadError) { - console.error("Upload error:", uploadError); - return null; - } - - const { data } = supabase.storage - .from(bucketName) - .getPublicUrl(filePath); - - return { - filename: file.name, - mimetype: file.type, - filesize: file.size, - public_url: data.publicUrl, - }; - }), - ); - - const validAttachments = uploadedAttachments.filter(Boolean); - - await supabase.from("messages").insert({ - conversation_id: conversationId, - sender_id: user.id, - text: sanitizeInput(input.slice(0, 1000)), - attachments: validAttachments, - }); - - setTimeout(() => { - bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, 100); - - setInput(""); - setAttachments([]); - } catch (err) { - console.error("Send message error:", err); - } - }; + return 0; + }); + + const activeConversation = conversations.find((c) => c.id === conversationId); + const activeOtherUser = activeConversation?.users.find((u) => u.id !== user.id); + const isGlobalActive = activeConversation?.type === "global"; + const activeOtherUserOnline = + !!activeOtherUser?.id && !!onlineByUserId[activeOtherUser.id]; + const activeTypingState = conversationId + ? typingByConversationId[conversationId] + : undefined; + + const activeLabel = isGlobalActive + ? "Global Chat" + : activeOtherUser?.email?.split("@")[0] || "Unknown"; + + const activeSublabel = isGlobalActive + ? "Public Channel" + : activeOtherUserOnline + ? "Online" + : "Offline"; + + const activeSublabelClass = activeOtherUserOnline || isGlobalActive + ? "text-emerald-400" + : "text-gray-500"; + + const typingIndicatorText = activeTypingState + ? isGlobalActive + ? `${activeTypingState.label} is typing...` + : "Typing..." + : ""; + + const activeInitials = isGlobalActive + ? "G" + : activeOtherUser?.email?.[0]?.toUpperCase() ?? "?"; + + const allMediaAttachments = useMemo(() => { + return messages + .flatMap((m) => m.attachments || []) + .filter( + (a) => + a?.mimetype?.startsWith("image/") || a?.mimetype?.startsWith("video/"), + ) + .reverse(); + }, [messages]); return ( -
-
-
- + <> + +
+ + {/* Left Sidebar */} +
+
+
+
+

Message category

+ {totalUnreadCount > 0 && ( + + {totalUnreadCount > 99 ? "99+" : totalUnreadCount} + + )} +
+ +
+
+ + setMessageSearch(e.target.value)} + placeholder="Search Message..." + className="w-full bg-[rgba(10,10,30,0.6)] border border-transparent rounded-xl py-2 pl-9 pr-4 text-sm text-gray-200 placeholder:text-gray-500 outline-none focus:border-indigo-500/50 transition-colors shadow-inner" + /> +
-
- -
-
+
+
+

ROOMS

-
- - -
- {conversationId ? ( - <> - - -
- {attachments.length > 0 && ( -
- {attachments.map((file, index) => ( -
- {file.type.startsWith("image/") ? ( - {file.name} - ) : ( - - )} - {file.name} +
+
+

DIRECT MESSAGE

+
+ setIsDmSortOpen(!isDmSortOpen)} + className="text-[10px] text-gray-500 bg-[rgba(10,10,30,0.6)] px-2 py-0.5 rounded cursor-pointer hover:bg-white/5 flex items-center gap-1 select-none" + > + {dmSortOrder === "newest" && "Newest"} + {dmSortOrder === "oldest" && "Oldest"} + {dmSortOrder === "az" && "A-Z"} + {dmSortOrder === "za" && "Z-A"} + + + + {isDmSortOpen && ( +
+ + +
- ))} + )}
- )} +
+ +
+
+
-
- + {/* Middle Chat Area */} +
+ {conversationId ? ( + <> + {/* Header */} +
+
+ +
+
+ {activeInitials} +
+ {!isGlobalActive && activeOtherUserOnline && ( +
+ )} +
+
+

{activeLabel}

+

{activeSublabel}

+
+
+
+ +
+
-