@@ -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 >
0 commit comments