Skip to content

Commit d2840c0

Browse files
committed
feat(chat): modernize chat flow, no cap
1 parent 0ace420 commit d2840c0

4 files changed

Lines changed: 516 additions & 158 deletions

File tree

app/components/Chat.tsx

Lines changed: 159 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ export default function Chat({ user }: { user: User }) {
6363
const [showModal, setShowModal] = useState(false);
6464
const [search, setSearch] = useState("");
6565
const [allUsers, setAllUsers] = useState<ChatUser[]>([]);
66+
const [badgesByUserId, setBadgesByUserId] = useState<
67+
Record<string, { label: string; className: string }>
68+
>({});
69+
const badgeCacheRef = useRef<Record<string, { label: string; className: string }>>({});
6670
const channelRef = useRef<RealtimeChannel>(null);
6771
const textareaRef = useRef<HTMLTextAreaElement>(null);
6872
const bottomRef = useRef<HTMLDivElement | null>(null);
@@ -72,6 +76,110 @@ export default function Chat({ user }: { user: User }) {
7276
const [attachments, setAttachments] = useState<Attachment[]>([]);
7377
const bucketName = process.env.NEXT_PUBLIC_SUPABASE_BUCKET_NAME || "";
7478

79+
const globalConversations = conversations.filter((c) => c.type === "global");
80+
const privateConversations = conversations.filter((c) => c.type !== "global");
81+
82+
const getBadgeInfoFromHours = (hours: number) => {
83+
if (hours >= 160)
84+
return {
85+
label: "MISSION IMPOSSIBLE",
86+
className:
87+
"bg-gradient-to-r from-fuchsia-500/20 via-pink-500/40 to-fuchsia-500/20 border-fuchsia-500/60 text-fuchsia-200",
88+
};
89+
if (hours >= 130)
90+
return {
91+
label: "GOD LEVEL",
92+
className:
93+
"bg-gradient-to-r from-fuchsia-500/20 via-pink-400/40 to-fuchsia-500/20 border-fuchsia-400/60 text-fuchsia-200",
94+
};
95+
if (hours >= 100)
96+
return {
97+
label: "STARLIGHT",
98+
className:
99+
"bg-gradient-to-r from-sky-500/15 via-cyan-400/35 to-sky-500/15 border-sky-400/50 text-cyan-200",
100+
};
101+
if (hours >= 50)
102+
return {
103+
label: "ELITE",
104+
className:
105+
"bg-gradient-to-r from-rose-500/15 via-red-400/35 to-rose-500/15 border-red-400/50 text-rose-200",
106+
};
107+
if (hours >= 20)
108+
return {
109+
label: "PRO",
110+
className:
111+
"bg-gradient-to-r from-indigo-500/15 via-violet-500/35 to-indigo-500/15 border-indigo-400/50 text-indigo-200",
112+
};
113+
if (hours >= 5)
114+
return {
115+
label: "NOVICE",
116+
className:
117+
"bg-gradient-to-r from-emerald-500/15 via-green-400/35 to-emerald-500/15 border-emerald-400/45 text-emerald-200",
118+
};
119+
if (hours >= 1)
120+
return {
121+
label: "NEWBIE",
122+
className:
123+
"bg-gradient-to-r from-lime-500/15 via-yellow-400/35 to-lime-500/15 border-lime-400/45 text-lime-200",
124+
};
125+
126+
return {
127+
label: "NONE",
128+
className: "bg-white/[0.03] border-white/10 text-gray-300",
129+
};
130+
};
131+
132+
useEffect(() => {
133+
const fetchBadgesForParticipants = async () => {
134+
if (!conversations.length) return;
135+
136+
const participantIds = new Set<string>();
137+
conversations.forEach((c) => {
138+
c.users.forEach((u) => {
139+
if (u.id) participantIds.add(u.id);
140+
});
141+
});
142+
participantIds.add(user.id);
143+
144+
const ids = Array.from(participantIds).filter(Boolean);
145+
if (ids.length === 0) return;
146+
147+
const cached: Record<string, { label: string; className: string }> = {};
148+
const missingIds: string[] = [];
149+
ids.forEach((id) => {
150+
const hit = badgeCacheRef.current[id];
151+
if (hit) cached[id] = hit;
152+
else missingIds.push(id);
153+
});
154+
155+
if (Object.keys(cached).length > 0) {
156+
setBadgesByUserId((prev) => ({ ...prev, ...cached }));
157+
}
158+
159+
if (missingIds.length === 0) return;
160+
161+
const { data } = await supabase
162+
.from("top_user_stats")
163+
.select("user_id, email, total_seconds")
164+
.in("user_id", missingIds);
165+
166+
if (!data) return;
167+
168+
const next: Record<string, { label: string; className: string }> = {};
169+
for (const row of data) {
170+
if (!row.user_id || row.total_seconds === null) continue;
171+
const hours = Math.round((row.total_seconds || 0) / 3600);
172+
const badge = getBadgeInfoFromHours(hours);
173+
next[row.user_id] = { label: badge.label, className: badge.className };
174+
}
175+
176+
badgeCacheRef.current = { ...badgeCacheRef.current, ...next };
177+
setBadgesByUserId((prev) => ({ ...prev, ...next }));
178+
};
179+
180+
fetchBadgesForParticipants();
181+
}, [conversations, user.id]);
182+
75183
useEffect(() => {
76184
fetch(
77185
"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 }) {
86194
useEffect(() => {
87195
if (textareaRef.current) {
88196
const el = textareaRef.current;
89-
el.style.height = "auto";
90-
el.style.height = `${Math.min(el.scrollHeight, 1.5 * 6 * 16)}px`;
91-
// 1.5rem line-height * 6 lines * 16px per rem
197+
const minHeight = 20;
198+
const maxHeight = minHeight * 6;
199+
el.style.height = `${minHeight}px`;
200+
el.style.height = `${Math.min(el.scrollHeight, maxHeight)}px`;
201+
el.style.overflowY = el.scrollHeight > maxHeight ? "auto" : "hidden";
92202
}
93203
}, [input]);
94204

@@ -123,7 +233,6 @@ export default function Chat({ user }: { user: User }) {
123233
};
124234
});
125235

126-
// making sure global conversation is always first
127236
const sortedConvs = convs.sort((a, b) =>
128237
a.type === "global" ? -1 : b.type === "global" ? 1 : 0,
129238
);
@@ -162,14 +271,12 @@ export default function Chat({ user }: { user: User }) {
162271
.then(({ data }) => {
163272
if (!data || data.length === 0) return;
164273
const convo = data[0];
165-
// Double check the user is part of the conversation (should always be true)
166274
if (
167275
!convo.users.some(
168276
(u: { user_id: string }) => u.user_id === user.id,
169277
)
170278
)
171279
return;
172-
// Check if we already have this conversation in state
173280
if (conversations.some((c) => c.id === convo.id)) return;
174281

175282
setConversations((prev) => [
@@ -409,25 +516,42 @@ export default function Chat({ user }: { user: User }) {
409516

410517
return (
411518
<div className="flex flex-col h-screen">
412-
<div className="px-3 pt-3 flex border-neutral-700">
413-
<button
414-
onClick={() => setShowModal(true)}
415-
className="flex flex-col items-center min-w-15"
416-
>
417-
<div className="w-10 h-10 rounded-full bg-indigo-500 flex items-center justify-center">
418-
<FontAwesomeIcon icon={faPlus} className="text-white" />
419-
</div>
420-
<span className="text-xs mt-1">New</span>
421-
</button>
422-
423-
<div className="flex-1 flex gap-4 overflow-x-auto">
519+
{/* ── Conversation bar: labels above icon rows so text + avatars align cleanly ── */}
520+
<div className="flex items-stretch gap-3 px-3 py-2 border-b border-white/[0.06] bg-[#0a0a1a]/80 backdrop-blur-md flex-shrink-0">
521+
{/* Global: pinned icons, label sits under the icon */}
522+
<div className="flex-shrink-0 min-w-0">
424523
<Conversations
425-
conversations={conversations}
524+
conversations={globalConversations}
426525
user={user}
427526
conversationId={conversationId}
428527
setConversationId={setConversationId}
528+
showLabel={true}
429529
/>
430530
</div>
531+
532+
<div className="w-px self-stretch bg-white/[0.08] flex-shrink-0 my-1" />
533+
534+
{/* DMs: scrollable list, labels sit under each icon */}
535+
<div className="flex-1 min-w-0 overflow-x-auto">
536+
<div className="flex gap-2 items-start">
537+
<Conversations
538+
conversations={privateConversations}
539+
user={user}
540+
conversationId={conversationId}
541+
setConversationId={setConversationId}
542+
showLabel={true}
543+
/>
544+
</div>
545+
</div>
546+
547+
{/* + aligned with avatar row (bottom), not with section title */}
548+
<button
549+
onClick={() => setShowModal(true)}
550+
className="flex-shrink-0 self-center w-8 h-8 rounded-full bg-indigo-500/15 border border-indigo-500/30 flex items-center justify-center hover:bg-indigo-500/25 transition"
551+
title="New conversation"
552+
>
553+
<FontAwesomeIcon icon={faPlus} className="text-indigo-300 w-3 h-3" />
554+
</button>
431555
</div>
432556

433557
{conversationId ? (
@@ -437,34 +561,35 @@ export default function Chat({ user }: { user: User }) {
437561
user={user}
438562
conversations={conversations}
439563
bottomRef={bottomRef}
564+
badgesByUserId={badgesByUserId}
440565
/>
441566

442-
<div className="p-4 border-t border-neutral-700">
567+
<div className="sticky bottom-0 z-20 p-3 border-t border-neutral-700 bg-[#0a0a1a]/70 backdrop-blur-md">
443568
{attachments.length > 0 && (
444569
<div className="mb-2 flex flex-wrap gap-2">
445570
{attachments.map((file, index) => (
446571
<div
447572
key={index}
448-
className="bg-neutral-700 text-sm px-3 py-1 rounded flex items-center gap-2"
573+
className="bg-neutral-900/40 border border-white/10 text-xs px-2 py-1 rounded-full flex items-center gap-2"
449574
>
450575
{file.type.startsWith("image/") ? (
451576
<Image
452577
src={URL.createObjectURL(file)}
453578
alt={file.name}
454579
width={20}
455580
height={20}
456-
className="w-6 h-6 rounded object-cover"
581+
className="w-5 h-5 rounded object-cover border border-white/10"
457582
/>
458583
) : (
459584
<FontAwesomeIcon
460585
icon={faFile}
461586
className="w-4 h-4 text-gray-400"
462587
/>
463588
)}
464-
<span className="truncate max-w-[120px]">{file.name}</span>
589+
<span className="truncate max-w-[110px]">{file.name}</span>
465590
<button
466591
onClick={() => removeAttachment(index)}
467-
className="text-red-400 hover:text-red-300"
592+
className="text-red-400 hover:text-red-300 rounded px-1"
468593
>
469594
470595
</button>
@@ -473,7 +598,7 @@ export default function Chat({ user }: { user: User }) {
473598
</div>
474599
)}
475600

476-
<div className="bg-neutral-800 rounded flex">
601+
<div className="rounded-2xl border border-white/10 bg-neutral-900/35/80 backdrop-blur-sm flex items-center gap-1 px-2 py-0.5 pr-0.5 shadow-[0_6px_18px_rgba(0,0,0,0.22)] overflow-hidden">
477602
<input
478603
type="file"
479604
ref={fileInputRef}
@@ -492,27 +617,30 @@ export default function Chat({ user }: { user: User }) {
492617
sendMessage();
493618
}
494619
}}
495-
className="flex-1 px-4 py-2 outline-none resize-none overflow-auto bg-neutral-800"
620+
className="flex-1 outline-none resize-none overflow-y-auto bg-transparent text-gray-100 placeholder:text-gray-500 px-1 py-0.5 leading-5 max-h-[150px]"
496621
placeholder="Message..."
497622
rows={1}
498623
style={{
499-
lineHeight: "1.5rem",
500-
maxHeight: "calc(1.5rem * 6)",
624+
lineHeight: "1.25rem",
625+
maxHeight: "calc(1.25rem * 6)",
501626
}}
502627
/>
503628

504629
<button onClick={() => fileInputRef.current?.click()}>
505630
<FontAwesomeIcon
506631
icon={faFile}
507-
className="text-gray-500 hover:text-gray-300 transition mx-2"
632+
className="text-gray-500 hover:text-gray-200 transition p-1.5 rounded-xl hover:bg-white/5"
508633
/>
509634
</button>
510635

511636
<button
512637
onClick={sendMessage}
513-
className="bg-indigo-500 px-4 rounded max-h-12"
638+
className="h-9 w-10 rounded-r-xl rounded-l-lg bg-gradient-to-br from-indigo-500 to-violet-500 border border-indigo-300/25 flex items-center justify-center hover:brightness-110 transition shadow-[0_6px_14px_rgba(99,102,241,0.3)]"
514639
>
515-
<FontAwesomeIcon icon={faPaperPlane} className="text-white" />
640+
<FontAwesomeIcon
641+
icon={faPaperPlane}
642+
className="text-white text-[13px] drop-shadow-[0_2px_6px_rgba(0,0,0,0.35)]"
643+
/>
516644
</button>
517645
</div>
518646
</div>

app/components/chat/Conversations.tsx

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,60 @@ export default function Conversations({
66
user,
77
conversationId,
88
setConversationId,
9+
showLabel = true,
910
}: {
1011
conversations: Conversation[];
1112
user: User;
1213
conversationId: string | null;
1314
setConversationId: (id: string) => void;
15+
showLabel?: boolean;
1416
}) {
1517
return (
1618
<>
1719
{conversations.map((conv, idx) => {
1820
const otherUser = conv.users.find((u) => u.id !== user.id);
19-
const isActive = conv.id === conversationId; // check active
21+
const isActive = conv.id === conversationId;
22+
const isGlobal = conv.type === "global";
23+
const label = isGlobal
24+
? "Global"
25+
: (() => {
26+
const name = otherUser?.email?.split("@")[0] || "";
27+
return name.length > 10 ? name.slice(0, 8) + "…" : name;
28+
})();
2029

2130
return (
22-
<div
31+
<button
2332
key={idx}
33+
type="button"
2434
onClick={() => setConversationId(conv.id)}
25-
className="flex flex-col items-center min-w-15 cursor-pointer"
35+
title={label}
36+
className={`flex flex-col items-center gap-0.5 cursor-pointer select-none transition-opacity ${
37+
isActive ? "opacity-100" : "opacity-60 hover:opacity-90"
38+
}`}
2639
>
2740
<div
28-
className={`
29-
flex justify-center items-center w-10 h-10 rounded-full border border-white/10
30-
${isActive ? "bg-indigo-500 text-white" : "bg-white/5 text-gray-300"}
31-
`}
41+
className={`flex justify-center items-center w-8 h-8 rounded-full text-[13px] font-semibold transition-all border ${
42+
isActive
43+
? "bg-indigo-500 text-white border-indigo-400/60 shadow-sm shadow-indigo-500/20"
44+
: isGlobal
45+
? "bg-indigo-500/15 text-indigo-200 border-indigo-500/40"
46+
: "bg-neutral-700/60 text-gray-300 border-white/10 hover:bg-neutral-700"
47+
}`}
3248
>
33-
{conv.type == "global" ? "G" : otherUser?.email[0]?.toUpperCase()}
49+
{isGlobal ? "G" : otherUser?.email?.[0]?.toUpperCase() ?? "?"}
3450
</div>
3551
<span
36-
className={`text-xs mt-1 ${isActive ? "text-white" : "text-gray-300"}`}
52+
className={`text-[10px] leading-tight max-w-[44px] truncate min-h-[14px] ${
53+
isActive
54+
? "text-white"
55+
: isGlobal
56+
? "text-indigo-300"
57+
: "text-gray-400"
58+
} ${showLabel ? "opacity-100" : "opacity-0"}`}
3759
>
38-
{conv.type == "global"
39-
? "Global"
40-
: (() => {
41-
const name = otherUser?.email?.split("@")[0] || "";
42-
return name.length > 10 ? name.slice(0, 8) + "..." : name;
43-
})()}
60+
{label}
4461
</span>
45-
</div>
62+
</button>
4663
);
4764
})}
4865
</>

0 commit comments

Comments
 (0)