diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx index 53692e2..2137b7d 100644 --- a/app/components/Chat.tsx +++ b/app/components/Chat.tsx @@ -63,6 +63,10 @@ export default function Chat({ user }: { user: User }) { const [showModal, setShowModal] = useState(false); const [search, setSearch] = useState(""); const [allUsers, setAllUsers] = useState([]); + const [badgesByUserId, setBadgesByUserId] = useState< + Record + >({}); + const badgeCacheRef = useRef>({}); const channelRef = useRef(null); const textareaRef = useRef(null); const bottomRef = useRef(null); @@ -72,6 +76,110 @@ export default function Chat({ user }: { user: User }) { const [attachments, setAttachments] = useState([]); 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", + }; + }; + + 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]); + useEffect(() => { fetch( "https://raw.githubusercontent.com/LDNOOBW/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words/refs/heads/master/en", @@ -86,9 +194,11 @@ export default function Chat({ user }: { user: User }) { useEffect(() => { if (textareaRef.current) { const el = textareaRef.current; - el.style.height = "auto"; - el.style.height = `${Math.min(el.scrollHeight, 1.5 * 6 * 16)}px`; - // 1.5rem line-height * 6 lines * 16px per rem + 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]); @@ -123,7 +233,6 @@ export default function Chat({ user }: { user: User }) { }; }); - // making sure global conversation is always first const sortedConvs = convs.sort((a, b) => a.type === "global" ? -1 : b.type === "global" ? 1 : 0, ); @@ -162,14 +271,12 @@ export default function Chat({ user }: { user: User }) { .then(({ data }) => { if (!data || data.length === 0) return; const convo = data[0]; - // Double check the user is part of the conversation (should always be true) if ( !convo.users.some( (u: { user_id: string }) => u.user_id === user.id, ) ) return; - // Check if we already have this conversation in state if (conversations.some((c) => c.id === convo.id)) return; setConversations((prev) => [ @@ -409,25 +516,42 @@ export default function Chat({ user }: { user: User }) { return (
-
- - -
+ {/* ── Conversation bar: labels above icon rows so text + avatars align cleanly ── */} +
+ {/* Global: pinned icons, label sits under the icon */} +
+ +
+ + {/* DMs: scrollable list, labels sit under each icon */} +
+
+ +
+
+ + {/* + aligned with avatar row (bottom), not with section title */} +
{conversationId ? ( @@ -437,15 +561,16 @@ export default function Chat({ user }: { user: User }) { user={user} conversations={conversations} bottomRef={bottomRef} + badgesByUserId={badgesByUserId} /> -
+
{attachments.length > 0 && (
{attachments.map((file, index) => (
{file.type.startsWith("image/") ? ( {file.name} ) : ( )} - {file.name} + {file.name} @@ -473,7 +598,7 @@ export default function Chat({ user }: { user: User }) {
)} -
+
diff --git a/app/components/chat/Conversations.tsx b/app/components/chat/Conversations.tsx index 9d198fb..e2358da 100644 --- a/app/components/chat/Conversations.tsx +++ b/app/components/chat/Conversations.tsx @@ -6,43 +6,60 @@ export default function Conversations({ user, conversationId, setConversationId, + showLabel = true, }: { conversations: Conversation[]; user: User; conversationId: string | null; setConversationId: (id: string) => void; + showLabel?: boolean; }) { return ( <> {conversations.map((conv, idx) => { const otherUser = conv.users.find((u) => u.id !== user.id); - const isActive = conv.id === conversationId; // check active + const isActive = conv.id === conversationId; + const isGlobal = conv.type === "global"; + const label = isGlobal + ? "Global" + : (() => { + const name = otherUser?.email?.split("@")[0] || ""; + return name.length > 10 ? name.slice(0, 8) + "…" : name; + })(); return ( -
setConversationId(conv.id)} - className="flex flex-col items-center min-w-15 cursor-pointer" + title={label} + className={`flex flex-col items-center gap-0.5 cursor-pointer select-none transition-opacity ${ + isActive ? "opacity-100" : "opacity-60 hover:opacity-90" + }`} >
- {conv.type == "global" ? "G" : otherUser?.email[0]?.toUpperCase()} + {isGlobal ? "G" : otherUser?.email?.[0]?.toUpperCase() ?? "?"}
- {conv.type == "global" - ? "Global" - : (() => { - const name = otherUser?.email?.split("@")[0] || ""; - return name.length > 10 ? name.slice(0, 8) + "..." : name; - })()} + {label} -
+ ); })} diff --git a/app/components/chat/Messages.tsx b/app/components/chat/Messages.tsx index 0016b45..4561f10 100644 --- a/app/components/chat/Messages.tsx +++ b/app/components/chat/Messages.tsx @@ -11,143 +11,194 @@ import Image from "next/image"; import { faFile } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +const RANK_BORDER: Record = { + "MISSION IMPOSSIBLE": { + border: "border-fuchsia-400", + ring: "ring-fuchsia-400/25", + }, + "GOD LEVEL": { border: "border-fuchsia-400", ring: "ring-fuchsia-300/25" }, + STARLIGHT: { border: "border-sky-400", ring: "ring-sky-300/25" }, + ELITE: { border: "border-red-400", ring: "ring-red-300/25" }, + PRO: { border: "border-indigo-400", ring: "ring-indigo-300/25" }, + NOVICE: { border: "border-emerald-400", ring: "ring-emerald-300/25" }, + NEWBIE: { border: "border-lime-400", ring: "ring-lime-300/25" }, +}; +const DEFAULT_RANK_BORDER = { + border: "border-indigo-400", + ring: "ring-indigo-300/20", +}; + export default function Messages({ messages, user, conversations, bottomRef, + badgesByUserId, }: { messages: Message[]; user: User; conversations: Conversation[]; bottomRef: React.RefObject; + badgesByUserId?: Record; }) { const [showScrollBtn, setShowScrollBtn] = useState(false); useEffect(() => { const container = document.getElementById("chat-container"); - if (!container) return; - const handleScroll = () => { - const isNearBottom = - container.scrollHeight - container.scrollTop - container.clientHeight < - 100; - - setShowScrollBtn(!isNearBottom); + const nearBottom = + container.scrollHeight - container.scrollTop - container.clientHeight < 100; + setShowScrollBtn(!nearBottom); }; - container.addEventListener("scroll", handleScroll); return () => container.removeEventListener("scroll", handleScroll); }, []); return ( <> -
+
{showScrollBtn && ( )} {messages.length === 0 && ( -
- No messages yet. Start the conversation! +
+
💬
+

No messages yet. Say hello!

)} - {messages.map((msg, idx) => ( -
- {msg.sender_id !== user.id && ( -
- {conversations - .find((conv) => conv.id === msg.conversation_id) - ?.users.find((u) => u.id === msg.sender_id) - ?.email[0].toUpperCase()} -
- )} -
- - { - conversations - .find( - (conv) => - conv.id === msg.conversation_id && - conv.type === "global", - ) - ?.users.find((u) => u.id === msg.sender_id) - ?.email.split("@")[0] - } - + + {messages.map((msg, idx) => { + const isSelf = msg.sender_id === user.id; + const conversationRow = conversations.find( + (c) => c.id === msg.conversation_id, + ); + const senderRow = conversationRow?.users.find( + (u) => u.id === msg.sender_id, + ); + + const senderInitial = senderRow?.email?.[0]?.toUpperCase() ?? "?"; + const senderName = senderRow?.email?.split("@")?.[0] ?? ""; + + const badge = badgesByUserId?.[msg.sender_id]; + const badgeLabel = badge?.label ?? "NEWBIE"; + const rankBorder = RANK_BORDER[badgeLabel] ?? DEFAULT_RANK_BORDER; + + const badgePillClass = isSelf + ? `bg-indigo-500/10 text-indigo-200 ring-1 ${rankBorder.border} ${rankBorder.ring}` + : badge?.className ?? "bg-white/[0.03] border-white/10 text-gray-400"; + + // long msg = avatar up a lil, no cap. + const text = msg.text ?? ""; + const hasMedia = !!msg.attachments?.length; + const isLongMessage = hasMedia || text.length >= 120 || text.includes("\n"); + const avatarTranslateClass = isLongMessage + ? "-translate-y-[4.5px]" + : "-translate-y-[4px]"; + + return ( +
+ {!isSelf && ( +
+ + {senderInitial} + +
+ )} +
- {/*{msg.text}*/} - - {} - - )} - > - {String(children).replace(/\n$/, "")} - - - ); - }, - }} +
+ {isSelf && ( + + {timeAgo(msg.created_at)} + + )} + + {senderName} + + - {msg.text} - + {badgeLabel} + + {!isSelf && ( + + {timeAgo(msg.created_at)} + + )} +
+ + {msg.text && ( +
+
+ )} + > + {String(children).replace(/\n$/, "")} + + ); + }, + }} + > + {msg.text} + +
+
+ )} {msg.attachments && msg.attachments.length > 0 && ( -
- {msg.attachments.map((attachment, idx) => ( -
- {getAttachments(attachment)} -
+
+ {msg.attachments.map((att, i) => ( +
{getAttachments(att)}
))}
)} -
-
- {timeAgo(msg.created_at)}
-
- ))} + ); + })}
@@ -166,23 +217,21 @@ function getAttachments(attachment: { {attachment.filename} ); case "video": return ( -