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..1ba4bd3 100644 --- a/app/components/Chat.tsx +++ b/app/components/Chat.tsx @@ -1,16 +1,36 @@ "use client"; -import { useEffect, useRef, useState } from "react"; +import { useCallback, 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"; +import { IconDefinition } from "@fortawesome/fontawesome-svg-core"; import { faFile, faPaperPlane, faPlus, + faSearch, + faGhost, + faCrown, + faStar, + faFire, + faBolt, + faSeedling, + faMedal, + faMinus, + 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 { useChatPresence } from "./chat/hooks/useChatPresence"; import Image from "next/image"; import { toast } from "react-toastify"; @@ -18,6 +38,7 @@ export interface Conversation { id: string; users: { id: string; email: string }[]; type: string; + created_at?: string; } export interface Message { @@ -32,6 +53,7 @@ export interface Message { public_url: string; }[]; created_at: string; + optimistic?: boolean; } export interface ChatUser { @@ -51,6 +73,59 @@ export interface ConversationParticipantRow { conversation: ConversationParticipant[]; } +interface ParticipantPresence { + last_seen_at: string | null; + last_read_at: string | null; +} + +interface ConversationUserRow { + user_id: string; + email: string | null; + last_seen_at: string | null; +} + +interface ConversationRowWithParticipants { + id: string; + created_at: string; + type: string; + users: ConversationUserRow[]; +} + +interface ConversationParticipantWithConversationRow { + conversation_id: string; + last_seen_at: string | null; + last_read_at: string | null; + conversation: + | ConversationRowWithParticipants + | ConversationRowWithParticipants[] + | 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 getAttachmentFingerprint = (attachments: Message["attachments"] = []) => + attachments + .map((attachment) => { + const filename = attachment?.filename ?? ""; + const mimetype = attachment?.mimetype ?? ""; + const filesize = String(attachment?.filesize ?? 0); + const publicUrl = attachment?.public_url ?? ""; + return `${filename}|${mimetype}|${filesize}|${publicUrl}`; + }) + .join("::"); + type Attachment = File; const supabase = createClient(); @@ -62,74 +137,265 @@ export default function Chat({ user }: { user: User }) { const [input, setInput] = useState(""); const [showModal, setShowModal] = useState(false); const [search, setSearch] = useState(""); + const [messageSearch, setMessageSearch] = useState(""); const [allUsers, setAllUsers] = useState([]); const [badgesByUserId, setBadgesByUserId] = useState< - Record + Record + >({}); + const [unreadCountByConversationId, setUnreadCountByConversationId] = useState< + Record + >({}); + const [, setParticipantMetaByConversationId] = useState< + Record >({}); const badgeCacheRef = useRef< - Record + Record >({}); + const conversationIdsRef = useRef>(new Set()); + const activeConversationIdRef = useRef(null); const channelRef = useRef(null); const textareaRef = 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 [showRightSidebar, setShowRightSidebar] = useState(false); + const [showAllMedia, setShowAllMedia] = useState(false); + const [mediaViewer, setMediaViewer] = useState( + null, + ); + const lastReadSyncAtRef = useRef>({}); + const [typingByConversationId, setTypingByConversationId] = useState< + Record + >({}); + const typingStopTimeoutRef = useRef>({}); + const typingExpiryTimeoutRef = useRef>({}); + const localTypingByConversationRef = 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 clearAllTypingTimers = useCallback(() => { + Object.values(typingStopTimeoutRef.current).forEach((timeoutId) => { + window.clearTimeout(timeoutId); + }); + Object.values(typingExpiryTimeoutRef.current).forEach((timeoutId) => { + window.clearTimeout(timeoutId); + }); + typingStopTimeoutRef.current = {}; + typingExpiryTimeoutRef.current = {}; + }, []); + + useEffect(() => { + return clearAllTypingTimers; + }, [clearAllTypingTimers]); + + useEffect(() => { + conversationIdsRef.current = new Set(conversations.map((conv) => conv.id)); + }, [conversations]); + + useEffect(() => { + activeConversationIdRef.current = conversationId; + }, [conversationId]); + const bucketName = process.env.NEXT_PUBLIC_SUPABASE_BUCKET_NAME || ""; + const ensureGlobalConversationMembership = useCallback(async () => { + if (!user.id) return; + + const timestamp = new Date().toISOString(); + + const { error: conversationError } = await supabase + .from("conversations") + .upsert( + { + id: GLOBAL_CONVERSATION_ID, + type: "global", + }, + { + onConflict: "id", + }, + ); + + if (conversationError) { + console.error("Failed to ensure global conversation:", conversationError); + } + + const { error: participantError } = await supabase + .from("conversation_participants") + .upsert( + { + conversation_id: GLOBAL_CONVERSATION_ID, + user_id: user.id, + email: user.email ?? "", + last_seen_at: timestamp, + last_read_at: timestamp, + }, + { + onConflict: "conversation_id,user_id", + }, + ); + + if (participantError) { + console.error("Failed to ensure global conversation membership:", participantError); + } + }, [user.email, user.id]); + + const setRemoteTypingState = useCallback( + (targetConversationId: string, state: TypingState | null) => { + const activeTimeoutId = typingExpiryTimeoutRef.current[targetConversationId]; + if (activeTimeoutId) { + window.clearTimeout(activeTimeoutId); + delete typingExpiryTimeoutRef.current[targetConversationId]; + } + + if (!state) { + setTypingByConversationId((prev) => { + if (!prev[targetConversationId]) return prev; + const next = { ...prev }; + delete next[targetConversationId]; + return next; + }); + return; + } + + setTypingByConversationId((prev) => ({ + ...prev, + [targetConversationId]: state, + })); + + typingExpiryTimeoutRef.current[targetConversationId] = window.setTimeout(() => { + setTypingByConversationId((prev) => { + if (!prev[targetConversationId]) return prev; + const next = { ...prev }; + delete next[targetConversationId]; + return next; + }); + }, TYPING_REMOTE_EXPIRE_MS); + }, + [], + ); + + const emitTypingState = useCallback( + (targetConversationId: string, isTyping: boolean) => { + if (!targetConversationId) return; + const channel = channelRef.current; + if (!channel) return; + + void channel.send({ + type: "broadcast", + event: "typing", + payload: { + conversation_id: targetConversationId, + user_id: user.id, + email: user.email ?? "", + is_typing: isTyping, + }, + }); + }, + [user.email, user.id], + ); + + const stopTyping = useCallback( + (targetConversationId: string) => { + if (!targetConversationId) return; + + const activeTimeoutId = typingStopTimeoutRef.current[targetConversationId]; + if (activeTimeoutId) { + window.clearTimeout(activeTimeoutId); + delete typingStopTimeoutRef.current[targetConversationId]; + } + + if (!localTypingByConversationRef.current[targetConversationId]) return; + + localTypingByConversationRef.current[targetConversationId] = false; + emitTypingState(targetConversationId, false); + }, + [emitTypingState], + ); + + const markTypingFromInput = useCallback( + (targetConversationId: string, nextValue: string) => { + if (!targetConversationId) return; + + const hasText = nextValue.trim().length > 0; + if (!hasText) { + stopTyping(targetConversationId); + return; + } + + if (!localTypingByConversationRef.current[targetConversationId]) { + localTypingByConversationRef.current[targetConversationId] = true; + emitTypingState(targetConversationId, true); + } + + const activeTimeoutId = typingStopTimeoutRef.current[targetConversationId]; + if (activeTimeoutId) { + window.clearTimeout(activeTimeoutId); + } + + typingStopTimeoutRef.current[targetConversationId] = window.setTimeout(() => { + stopTyping(targetConversationId); + }, TYPING_INACTIVE_TIMEOUT_MS); + }, + [emitTypingState, stopTyping], + ); + + const totalUnreadCount = useMemo( + () => Object.values(unreadCountByConversationId).reduce((sum, count) => sum + count, 0), + [unreadCountByConversationId], + ); + const globalConversations = conversations.filter((c) => c.type === "global"); - const privateConversations = 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); + } + if (dmSortOrder === "oldest") { + return (a.created_at ? new Date(a.created_at).getTime() : 0) - (b.created_at ? new Date(b.created_at).getTime() : 0); + } + + 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); + } + return 0; + }); 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", - }; + if (hours >= 160) return { label: "MISSION IMPOSSIBLE", className: "badge-impossible", icon: faGhost }; + if (hours >= 130) return { label: "GOD LEVEL", className: "badge-god", icon: faCrown }; + if (hours >= 100) return { label: "STARLIGHT", className: "badge-starlight", icon: faStar }; + if (hours >= 50) return { label: "ELITE", className: "badge-elite", icon: faFire }; + if (hours >= 20) return { label: "PRO", className: "badge-pro", icon: faBolt }; + if (hours >= 5) return { label: "NOVICE", className: "badge-novice", icon: faMedal }; + if (hours >= 1) return { label: "NEWBIE", className: "badge-newbie", icon: faSeedling }; + return { label: "NONE", className: "badge-none", icon: faMinus }; }; useEffect(() => { @@ -147,7 +413,7 @@ export default function Chat({ user }: { user: User }) { const ids = Array.from(participantIds).filter(Boolean); if (ids.length === 0) return; - const cached: Record = {}; + const cached: Record = {}; const missingIds: string[] = []; ids.forEach((id) => { const hit = badgeCacheRef.current[id]; @@ -168,12 +434,12 @@ export default function Chat({ user }: { user: User }) { if (!data) return; - const next: Record = {}; + 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 }; + next[row.user_id] = { label: badge.label, className: badge.className, icon: badge.icon }; } badgeCacheRef.current = { ...badgeCacheRef.current, ...next }; @@ -207,13 +473,19 @@ export default function Chat({ user }: { user: User }) { useEffect(() => { const fetchConversations = async () => { + await ensureGlobalConversationMembership(); + const { data } = await supabase .from("conversation_participants") .select( ` + conversation_id, + last_read_at, + last_seen_at, conversation: conversations( id, - users: conversation_participants!inner(user_id, email), + created_at, + users: conversation_participants!inner(user_id, email, last_seen_at), type ) `, @@ -221,96 +493,319 @@ export default function Chat({ user }: { user: User }) { .eq("user_id", user.id); if (data) { - const convs: Conversation[] = (data ?? []).map((row) => { + const participantRows = + (data as ConversationParticipantWithConversationRow[]) ?? []; + const convs: Conversation[] = []; + const nextParticipantMeta: Record = {}; + const nextLastSeenByUserId: Record = {}; + const readMap: Record = {}; + + participantRows.forEach((row) => { const convo = Array.isArray(row.conversation) ? row.conversation[0] : row.conversation; - return { + if (!convo) return; + + convs.push({ id: convo.id, - users: convo.users.map((u: { user_id: string; email: string }) => ({ + created_at: convo.created_at, + users: convo.users.map((u: ConversationUserRow) => ({ id: u.user_id, - email: u.email, + email: u.email ?? "", })), type: convo.type, + }); + + nextParticipantMeta[row.conversation_id] = { + last_seen_at: row.last_seen_at ?? null, + last_read_at: row.last_read_at ?? null, }; + readMap[row.conversation_id] = row.last_read_at ?? null; + + convo.users.forEach((participant) => { + if (!participant.user_id || participant.user_id === user.id) return; + + const previous = nextLastSeenByUserId[participant.user_id]; + const incoming = participant.last_seen_at ?? null; + + if (!previous) { + nextLastSeenByUserId[participant.user_id] = incoming; + return; + } + + if (!incoming) return; + + if (new Date(incoming).getTime() > new Date(previous).getTime()) { + nextLastSeenByUserId[participant.user_id] = incoming; + } + }); }); const sortedConvs = convs.sort((a, b) => a.type === "global" ? -1 : b.type === "global" ? 1 : 0, ); setConversations(sortedConvs); + setParticipantMetaByConversationId(nextParticipantMeta); + setLastSeenByUserId(nextLastSeenByUserId); + void fetchUnreadCountsForConversations( + sortedConvs.map((conv) => conv.id), + readMap, + "replace", + ); } }; - fetchConversations(); - }, [user.id]); + + void fetchConversations(); + }, [ + ensureGlobalConversationMembership, + fetchUnreadCountsForConversations, + setLastSeenByUserId, + user.id, + ]); useEffect(() => { if (!user.id) return; const channel = supabase - .channel(`conversations-user-${user.id}`) + .channel(`conversation-membership-${user.id}`) .on( "postgres_changes", { event: "INSERT", schema: "public", table: "conversation_participants", + filter: `user_id=eq.${user.id}`, }, - (payload) => { - const row = payload.new; - if (row.user_id === user.id) return; + async (payload) => { + const row = payload.new as { + conversation_id: string; + last_seen_at: string | null; + last_read_at: string | null; + }; - supabase + const { data } = await supabase .from("conversations") .select( ` id, - users:conversation_participants!inner(user_id), - type + created_at, + type, + users:conversation_participants!inner(user_id, email, last_seen_at) `, ) .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, - }, - ]); + .single(); + + if (!data) return; + + const convo = data as ConversationRowWithParticipants; + + const nextConversation: Conversation = { + id: convo.id, + created_at: convo.created_at, + users: convo.users.map((participant) => ({ + id: participant.user_id, + email: participant.email ?? "", + })), + type: convo.type, + }; + + setConversations((prev) => { + if (prev.some((existing) => existing.id === nextConversation.id)) { + return prev; + } + + return [...prev, nextConversation].sort((a, b) => + a.type === "global" ? -1 : b.type === "global" ? 1 : 0, + ); + }); + + setParticipantMetaByConversationId((prev) => ({ + ...prev, + [row.conversation_id]: { + last_seen_at: row.last_seen_at ?? null, + last_read_at: row.last_read_at ?? null, + }, + })); + + convo.users.forEach((participant) => { + if (participant.user_id === user.id) return; + + setLastSeenByUserId((prev) => { + const previous = prev[participant.user_id]; + const incoming = participant.last_seen_at ?? null; + + if (!previous) { + return { ...prev, [participant.user_id]: incoming }; + } + + if (!incoming) { + return prev; + } + + if (new Date(incoming).getTime() > new Date(previous).getTime()) { + return { ...prev, [participant.user_id]: incoming }; + } + + return prev; }); + }); + + void fetchUnreadCountsForConversations( + [row.conversation_id], + { [row.conversation_id]: row.last_read_at ?? null }, + "merge", + ); }, ) .subscribe(); + return () => { channel.unsubscribe(); }; - }, [user.id, conversations]); + }, [fetchUnreadCountsForConversations, setLastSeenByUserId, user.id]); useEffect(() => { - if (!conversationId) return; + if (!user.id) return; + + const channel = supabase + .channel(`conversation-participant-updates-${user.id}`) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "public", + table: "conversation_participants", + }, + (payload) => { + const row = payload.new as { + conversation_id: string; + user_id: string; + last_seen_at: string | null; + last_read_at: string | null; + }; + + if (!conversationIdsRef.current.has(row.conversation_id)) return; + + if (row.user_id === user.id) { + setParticipantMetaByConversationId((prev) => ({ + ...prev, + [row.conversation_id]: { + last_seen_at: row.last_seen_at ?? null, + last_read_at: row.last_read_at ?? null, + }, + })); + + return; + } + + setLastSeenByUserId((prev) => { + const previous = prev[row.user_id]; + const incoming = row.last_seen_at ?? null; + + if (!previous) { + return { ...prev, [row.user_id]: incoming }; + } + + if (!incoming) { + return prev; + } + + if (new Date(incoming).getTime() > new Date(previous).getTime()) { + return { ...prev, [row.user_id]: incoming }; + } + + return prev; + }); + }, + ) + .subscribe(); + + return () => { + channel.unsubscribe(); + }; + }, [setLastSeenByUserId, user.id]); + + useEffect(() => { + if (!user.id) return; + + const channel = supabase + .channel(`message-unread-${user.id}`) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "public", + table: "messages", + }, + (payload) => { + const message = payload.new as Message; + + if (!conversationIdsRef.current.has(message.conversation_id)) return; + if (message.sender_id === user.id) return; + + if (activeConversationIdRef.current === message.conversation_id) { + void markConversationAsRead(message.conversation_id); + return; + } + + setUnreadCountByConversationId((prev) => ({ + ...prev, + [message.conversation_id]: (prev[message.conversation_id] ?? 0) + 1, + })); + }, + ) + .subscribe(); + + return () => { + channel.unsubscribe(); + }; + }, [markConversationAsRead, user.id]); + + useEffect(() => { + if (!conversationId) { + setMessages([]); + return; + } if (channelRef.current) { channelRef.current.unsubscribe(); } + void markConversationAsRead(conversationId); + const channel = supabase .channel(`conversation-${conversationId}`) + .on( + "broadcast", + { + event: "typing", + }, + ({ payload }) => { + const typingPayload = payload as { + conversation_id?: string; + user_id?: string; + email?: string | null; + is_typing?: boolean; + }; + + if (typingPayload.conversation_id !== conversationId) return; + if (!typingPayload.user_id || typingPayload.user_id === user.id) return; + + if (typingPayload.is_typing) { + setRemoteTypingState(conversationId, { + user_id: typingPayload.user_id, + label: + typingPayload.email?.split("@")[0] || + "Someone", + }); + return; + } + + setRemoteTypingState(conversationId, null); + }, + ) .on( "postgres_changes", { @@ -320,17 +815,51 @@ export default function Chat({ user }: { user: User }) { 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, - }, - ]); + const incomingMessage: Message = { + 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, + }; + + setMessages((prev) => { + if (prev.some((message) => message.id === incomingMessage.id)) { + return prev; + } + + const incomingFingerprint = getAttachmentFingerprint( + incomingMessage.attachments, + ); + + const optimisticMessageIndex = prev.findIndex((message) => { + if (!message.id.startsWith("temp-")) return false; + if (message.sender_id !== incomingMessage.sender_id) return false; + if (message.conversation_id !== incomingMessage.conversation_id) { + return false; + } + if (message.text !== incomingMessage.text) return false; + + return ( + getAttachmentFingerprint(message.attachments) === + incomingFingerprint + ); + }); + + if (optimisticMessageIndex === -1) { + return [...prev, incomingMessage]; + } + + const next = [...prev]; + next[optimisticMessageIndex] = incomingMessage; + return next; + }); + + if (payload.new.sender_id !== user.id) { + void markConversationAsRead(conversationId); + } + setTimeout(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, 100); @@ -348,18 +877,21 @@ export default function Chat({ user }: { user: User }) { .order("created_at", { ascending: true }); if (data) { setMessages(data as Message[]); + void markConversationAsRead(conversationId); setTimeout(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, 100); } }; - fetchMessages(); + void fetchMessages(); return () => { + stopTyping(conversationId); + setRemoteTypingState(conversationId, null); channel.unsubscribe(); }; - }, [conversationId]); + }, [conversationId, markConversationAsRead, setRemoteTypingState, stopTyping, user.id]); useEffect(() => { if (!showModal) return; @@ -404,7 +936,7 @@ export default function Chat({ user }: { user: User }) { setIsDraggingOver(true); }; - const onDragLeave = (e: React.DragEvent) => { + const onDragLeave = () => { setIsDraggingOver(false); }; @@ -436,31 +968,60 @@ export default function Chat({ user }: { user: User }) { if (!badWords.length) return input; const filter = new RegExp(`\\b(${badWords.join("|")})\\b`, "gi"); - return input.replace(filter, "*-?;[]"); + return input.replace(filter, "System says: touch grass first 🌱"); }; 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), - ); +/** + * Dear DevPulse Team, + * + * I think I broke something but but but let’s pretend it’s a “feature update.” + * + * This update improves chat behavior, unread counters, typing indicators, online status accuracy, + * profile-to-DM shortcuts, and overall message flow stability. + * Also gave the UI a small glow-up. + * + * Honest release note: there are many bugs. + * Some are known, some are unknown, and some are pretending to be features. + * + * Good luck, and may production be slightly stable. + * + * - Pat + */ + + const existing = conversations.find((conv) => { + if (conv.type === "global") return false; + + const participantIds = new Set(conv.users.map((u) => u.id)); + return ( + participantIds.size === 2 && + participantIds.has(user.id) && + participantIds.has(otherUser.user_id) + ); + }); if (existing) { setConversationId(existing.id); setShowModal(false); + creatingRef.current = false; return; } const { data: convData } = await supabase .from("conversations") - .insert({}) + .insert({ type: "private" }) .select("*") .single(); - if (!convData) return; + if (!convData) { + creatingRef.current = false; + return; + } const convId = convData.id; + const timestamp = new Date().toISOString(); await supabase.from("conversation_participants").upsert( [ @@ -468,15 +1029,20 @@ export default function Chat({ user }: { user: User }) { conversation_id: convId, user_id: user.id, email: user.email, + last_seen_at: timestamp, + last_read_at: timestamp, }, { conversation_id: convId, user_id: otherUser.user_id, email: otherUser.email, + last_seen_at: PRESENCE_UNSEEN_AT_ISO, + last_read_at: PRESENCE_UNSEEN_AT_ISO, }, ], { onConflict: "conversation_id,user_id", + ignoreDuplicates: true, }, ); @@ -485,6 +1051,7 @@ export default function Chat({ user }: { user: User }) { ...prev, { id: convId, + created_at: convData.created_at, users: [ { id: user.id, email: user.email ?? "" }, { id: otherUser.user_id, email: otherUser.email ?? "" }, @@ -492,14 +1059,64 @@ export default function Chat({ user }: { user: User }) { type: "private", }, ]); + setUnreadCountByConversationId((prev) => ({ ...prev, [convId]: 0 })); + setParticipantMetaByConversationId((prev) => ({ + ...prev, + [convId]: { + last_seen_at: timestamp, + last_read_at: timestamp, + }, + })); setShowModal(false); creatingRef.current = false; }; + const openPrivateChatFromGlobalProfile = ( + targetUserId: string, + targetEmail: string, + ) => { + if (!targetUserId || targetUserId === user.id) return; + if (!targetEmail) { + toast.info("Cannot start a private chat without user email."); + return; + } + + void createConversation({ user_id: targetUserId, email: targetEmail }); + }; + + const handleDeleteConversation = async () => { + if (!conversationId) return; + try { + const { error } = await supabase.from("conversations").delete().eq("id", conversationId); + if (error) throw error; + setConversations((prev) => prev.filter((c) => c.id !== conversationId)); + setUnreadCountByConversationId((prev) => { + const next = { ...prev }; + delete next[conversationId]; + return next; + }); + setParticipantMetaByConversationId((prev) => { + const next = { ...prev }; + delete next[conversationId]; + return next; + }); + setConversationId(null); + setShowRightSidebar(false); + toast.success("Conversation deleted"); + } catch (err) { + console.error(err); + toast.error("Failed to delete conversation"); + } + }; + const sendMessage = async () => { if ((!input.trim() && attachments.length === 0) || !conversationId) return; + const targetConversationId = conversationId; + const originalText = input; + const outgoingText = sanitizeInput(input.slice(0, 1000)); + try { const uploadedAttachments = await Promise.all( attachments.map(async (file) => { @@ -536,191 +1153,519 @@ export default function Chat({ user }: { user: User }) { }), ); - const validAttachments = uploadedAttachments.filter(Boolean); + const validAttachments = uploadedAttachments.filter( + (attachment): attachment is Message["attachments"][number] => + attachment !== null, + ); - await supabase.from("messages").insert({ - conversation_id: conversationId, - sender_id: user.id, - text: sanitizeInput(input.slice(0, 1000)), - attachments: validAttachments, - }); + if (!outgoingText.trim() && validAttachments.length === 0) { + setAttachments([]); + return; + } + + const optimisticMessageId = `temp-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}`; + + setMessages((prev) => [ + ...prev, + { + id: optimisticMessageId, + conversation_id: targetConversationId, + sender_id: user.id, + text: outgoingText, + attachments: validAttachments, + created_at: new Date().toISOString(), + optimistic: true, + }, + ]); + + setInput(""); + setAttachments([]); + stopTyping(targetConversationId); setTimeout(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); }, 100); - setInput(""); - setAttachments([]); + const { error: insertError } = await supabase.from("messages").insert({ + conversation_id: targetConversationId, + sender_id: user.id, + text: outgoingText, + attachments: validAttachments, + }); + + if (insertError) { + setMessages((prev) => + prev.filter((message) => message.id !== optimisticMessageId), + ); + setInput(originalText); + setAttachments(attachments); + throw insertError; + } + + void markConversationAsRead(targetConversationId); } catch (err) { console.error("Send message error:", err); + toast.error("Failed to send message. Please try again."); } }; + 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}

+
+
+
+ +
+
-