@@ -18,12 +18,18 @@ interface Thread {
1818}
1919
2020interface ChannelPanelProps {
21+ channelId ?: string ;
2122 repoId ?: string ;
2223 repoName ?: string ;
24+ reactions ?: Record < string , MessageReaction [ ] > ;
25+ replyCounts ?: Record < number , number > ;
2326 onOpenThread ?: ( message : any ) => void ;
2427 onOpenInvite ?: ( ) => void ;
28+ onToggleReaction ?: ( reactionKey : string , emoji : string ) => void ;
2529}
2630
31+ const CHANNEL_THREADS_KEY_PREFIX = "codedock-channel-threads-v1" ;
32+
2733const GENERAL_THREADS : Thread [ ] = [
2834 { id : 1 , user : '김재준' , avatar : '👨💼' , message : '이번 주 스프린트 계획 공유드립니다' , time : '10:23 AM' , replies : 3 , lastReply : '안현' } ,
2935 { id : 2 , user : '김진필' , avatar : '👨💻' , message : '새로운 API 엔드포인트 추가했습니다. /api/v2/users 확인해주세요' , time : '11:45 AM' , replies : 5 , lastReply : '김재준' }
@@ -54,18 +60,44 @@ const REPO_THREADS: Record<string, Thread[]> = {
5460 'dashboard-3' : DASHBOARD_THREADS ,
5561} ;
5662
57- export function ChannelPanel ( { repoId, repoName, onOpenThread, onOpenInvite } : ChannelPanelProps ) {
58- const initialThreads = repoId ? ( REPO_THREADS [ repoId ] ?? [ ] ) : GENERAL_THREADS ;
59- const [ threads , setThreads ] = useState < Thread [ ] > ( initialThreads ) ;
63+ function getDefaultThreads ( repoId ?: string ) {
64+ return repoId ? ( REPO_THREADS [ repoId ] ?? [ ] ) : GENERAL_THREADS ;
65+ }
66+
67+ function getSavedThreads ( storageKey : string , fallback : Thread [ ] ) {
68+ if ( typeof window === "undefined" || typeof window . localStorage === "undefined" ) {
69+ return fallback ;
70+ }
71+
72+ try {
73+ const storedValue = window . localStorage . getItem ( storageKey ) ;
74+ if ( ! storedValue ) return fallback ;
75+ const parsed = JSON . parse ( storedValue ) ;
76+ return Array . isArray ( parsed ) ? parsed : fallback ;
77+ } catch {
78+ return fallback ;
79+ }
80+ }
6081
61- // repoId가 바뀌면 스레드 목록 리셋
62- const prevRepoId = useRef ( repoId ) ;
63- if ( prevRepoId . current !== repoId ) {
64- prevRepoId . current = repoId ;
65- const next = repoId ? ( REPO_THREADS [ repoId ] ?? [ ] ) : GENERAL_THREADS ;
66- setThreads ( next ) ;
82+ function saveThreads ( storageKey : string , threads : Thread [ ] ) {
83+ if ( typeof window === "undefined" || typeof window . localStorage === "undefined" ) {
84+ return ;
6785 }
6886
87+ try {
88+ window . localStorage . setItem ( storageKey , JSON . stringify ( threads ) ) ;
89+ } catch {
90+ // Storage can be unavailable in embedded previews; the in-memory state still updates.
91+ }
92+ }
93+
94+ export function ChannelPanel ( { channelId, repoId, repoName, reactions, replyCounts = { } , onOpenThread, onOpenInvite, onToggleReaction } : ChannelPanelProps ) {
95+ const channelStorageId = channelId ?? repoId ?? "general" ;
96+ const channelStorageKey = `${ CHANNEL_THREADS_KEY_PREFIX } :${ channelStorageId } ` ;
97+ const [ threads , setThreads ] = useState < Thread [ ] > ( ( ) =>
98+ getSavedThreads ( channelStorageKey , getDefaultThreads ( repoId ) )
99+ ) ;
100+
69101 const channelLabel = repoName ?? '일반' ;
70102 const [ hoveredMessageId , setHoveredMessageId ] = useState < number | null > ( null ) ;
71103 const [ messageText , setMessageText ] = useState ( "" ) ;
@@ -77,7 +109,11 @@ export function ChannelPanel({ repoId, repoName, onOpenThread, onOpenInvite }: C
77109 const [ linkUrl , setLinkUrl ] = useState ( "" ) ;
78110 const [ linkTitle , setLinkTitle ] = useState ( "" ) ;
79111 const [ responderTyping , setResponderTyping ] = useState ( false ) ;
80- const [ threadReactions , setThreadReactions ] = useState < Record < number , MessageReaction [ ] > > ( { } ) ;
112+ const [ localThreadReactions , setLocalThreadReactions ] = useState < Record < string , MessageReaction [ ] > > ( { } ) ;
113+ const [ bookmarkedThreadIds , setBookmarkedThreadIds ] = useState < Record < number , boolean > > ( { } ) ;
114+ const [ openThreadMenuId , setOpenThreadMenuId ] = useState < number | null > ( null ) ;
115+ const scrollContainerRef = useRef < HTMLDivElement | null > ( null ) ;
116+ const skipThreadSaveRef = useRef ( false ) ;
81117 const responderTypingTimerRef = useRef < number | null > ( null ) ;
82118 const fileInputRef = useRef < HTMLInputElement | null > ( null ) ;
83119 const imageInputRef = useRef < HTMLInputElement | null > ( null ) ;
@@ -90,6 +126,30 @@ export function ChannelPanel({ repoId, repoName, onOpenThread, onOpenInvite }: C
90126 } ;
91127 } , [ ] ) ;
92128
129+ useEffect ( ( ) => {
130+ skipThreadSaveRef . current = true ;
131+ setThreads ( getSavedThreads ( channelStorageKey , getDefaultThreads ( repoId ) ) ) ;
132+ } , [ channelStorageKey , repoId ] ) ;
133+
134+ useEffect ( ( ) => {
135+ if ( skipThreadSaveRef . current ) {
136+ skipThreadSaveRef . current = false ;
137+ return ;
138+ }
139+
140+ saveThreads ( channelStorageKey , threads ) ;
141+ } , [ channelStorageKey , threads ] ) ;
142+
143+ useEffect ( ( ) => {
144+ const scrollContainer = scrollContainerRef . current ;
145+ if ( ! scrollContainer ) return ;
146+
147+ scrollContainer . scrollTo ( {
148+ top : scrollContainer . scrollHeight ,
149+ behavior : "smooth"
150+ } ) ;
151+ } , [ threads . length , responderTyping ] ) ;
152+
93153 const triggerResponderTyping = ( ) => {
94154 if ( responderTypingTimerRef . current ) {
95155 window . clearTimeout ( responderTypingTimerRef . current ) ;
@@ -155,13 +215,33 @@ export function ChannelPanel({ repoId, repoName, onOpenThread, onOpenInvite }: C
155215 setEmojiPickerOpen ( false ) ;
156216 } ;
157217
218+ const getThreadReactionKey = ( threadId : number ) => `channel:${ channelStorageId } :thread:${ threadId } ` ;
219+
158220 const handleReactionToggle = ( threadId : number , emoji : string ) => {
159- setThreadReactions ( ( prev ) => ( {
221+ const reactionKey = getThreadReactionKey ( threadId ) ;
222+
223+ if ( onToggleReaction ) {
224+ onToggleReaction ( reactionKey , emoji ) ;
225+ return ;
226+ }
227+
228+ setLocalThreadReactions ( ( prev ) => ( {
160229 ...prev ,
161- [ threadId ] : toggleMessageReaction ( prev [ threadId ] , emoji )
230+ [ reactionKey ] : toggleMessageReaction ( prev [ reactionKey ] , emoji )
162231 } ) ) ;
163232 } ;
164233
234+ const handleBookmarkToggle = ( threadId : number ) => {
235+ setBookmarkedThreadIds ( ( prev ) => ( {
236+ ...prev ,
237+ [ threadId ] : ! prev [ threadId ]
238+ } ) ) ;
239+ } ;
240+
241+ const handleShareThread = ( thread : Thread ) => {
242+ setMessageText ( ( prev ) => `${ prev } ${ prev ? "\n" : "" } > ${ thread . message } ` ) ;
243+ } ;
244+
165245 const handleSendMessage = ( ) => {
166246 const trimmedMessage = messageText . trim ( ) ;
167247 if ( ! canSendMessage ) return ;
@@ -198,6 +278,8 @@ export function ChannelPanel({ repoId, repoName, onOpenThread, onOpenInvite }: C
198278 }
199279 } ;
200280
281+ const reactionMap = reactions ?? localThreadReactions ;
282+
201283 return (
202284 < div className = "flex h-full min-h-0 flex-col overflow-hidden" >
203285 { /* Header */ }
@@ -234,9 +316,12 @@ export function ChannelPanel({ repoId, repoName, onOpenThread, onOpenInvite }: C
234316 </ div >
235317
236318 { /* Thread List */ }
237- < div className = "min-h-0 flex-1 overflow-y-auto px-6 py-6" >
319+ < div ref = { scrollContainerRef } className = "min-h-0 flex-1 overflow-y-auto px-6 py-6" >
238320 < div className = "grid gap-4" >
239- { threads . map ( ( thread ) => (
321+ { threads . map ( ( thread ) => {
322+ const displayedReplyCount = replyCounts [ thread . id ] ?? thread . replies ;
323+
324+ return (
240325 < div
241326 key = { thread . id }
242327 className = "rounded-xl overflow-hidden relative group"
@@ -286,7 +371,7 @@ export function ChannelPanel({ repoId, repoName, onOpenThread, onOpenInvite }: C
286371 ) ) }
287372 </ div >
288373 ) }
289- { thread . replies > 0 && (
374+ { displayedReplyCount > 0 && (
290375 < div className = "flex items-center gap-3" >
291376 < button
292377 onClick = { ( e ) => {
@@ -305,7 +390,7 @@ export function ChannelPanel({ repoId, repoName, onOpenThread, onOpenInvite }: C
305390 fontWeight : 900 ,
306391 color : 'var(--neon-cyan)'
307392 } } >
308- 답글 { thread . replies } 개
393+ 답글 { displayedReplyCount } 개
309394 </ span >
310395 </ button >
311396 { thread . lastReply && (
@@ -323,7 +408,7 @@ export function ChannelPanel({ repoId, repoName, onOpenThread, onOpenInvite }: C
323408 </ div >
324409 < div className = "pl-11" >
325410 < MessageReactions
326- reactions = { threadReactions [ thread . id ] }
411+ reactions = { reactionMap [ getThreadReactionKey ( thread . id ) ] }
327412 onToggle = { ( emoji ) => handleReactionToggle ( thread . id , emoji ) }
328413 />
329414 </ div >
@@ -346,25 +431,60 @@ export function ChannelPanel({ repoId, repoName, onOpenThread, onOpenInvite }: C
346431 >
347432 < MessageSquare size = { 14 } />
348433 </ button >
349- < button className = "w-7 h-7 rounded border-0 flex items-center justify-center transition-all hover:bg-[rgba(32,227,255,0.15)]" style = { {
350- background : 'transparent' , color : 'var(--muted)' , cursor : 'pointer'
434+ < button
435+ onClick = { ( e ) => {
436+ e . stopPropagation ( ) ;
437+ handleBookmarkToggle ( thread . id ) ;
438+ } }
439+ className = "w-7 h-7 rounded border-0 flex items-center justify-center transition-all hover:bg-[rgba(32,227,255,0.15)]" style = { {
440+ background : 'transparent' , color : bookmarkedThreadIds [ thread . id ] ? 'var(--neon-cyan)' : 'var(--muted)' , cursor : 'pointer'
351441 } } title = "북마크" >
352442 < Bookmark size = { 14 } />
353443 </ button >
354- < button className = "w-7 h-7 rounded border-0 flex items-center justify-center transition-all hover:bg-[rgba(32,227,255,0.15)]" style = { {
444+ < button
445+ onClick = { ( e ) => {
446+ e . stopPropagation ( ) ;
447+ handleShareThread ( thread ) ;
448+ } }
449+ className = "w-7 h-7 rounded border-0 flex items-center justify-center transition-all hover:bg-[rgba(32,227,255,0.15)]" style = { {
355450 background : 'transparent' , color : 'var(--muted)' , cursor : 'pointer'
356451 } } title = "공유" >
357452 < Share2 size = { 14 } />
358453 </ button >
359- < button className = "w-7 h-7 rounded border-0 flex items-center justify-center transition-all hover:bg-[rgba(32,227,255,0.15)]" style = { {
360- background : 'transparent' , color : 'var(--muted)' , cursor : 'pointer'
454+ < button
455+ onClick = { ( e ) => {
456+ e . stopPropagation ( ) ;
457+ setOpenThreadMenuId ( ( currentId ) => currentId === thread . id ? null : thread . id ) ;
458+ } }
459+ className = "w-7 h-7 rounded border-0 flex items-center justify-center transition-all hover:bg-[rgba(32,227,255,0.15)]" style = { {
460+ background : 'transparent' , color : openThreadMenuId === thread . id ? 'var(--neon-cyan)' : 'var(--muted)' , cursor : 'pointer'
361461 } } title = "더보기" >
362462 < MoreVertical size = { 14 } />
363463 </ button >
464+ { openThreadMenuId === thread . id && (
465+ < div className = "absolute right-2 top-10 z-20 grid gap-1 rounded-lg p-2" style = { {
466+ background : 'rgba(5, 11, 20, 0.96)' ,
467+ border : '1px solid rgba(32, 227, 255, 0.24)'
468+ } } >
469+ < button
470+ type = "button"
471+ onClick = { ( e ) => {
472+ e . stopPropagation ( ) ;
473+ setMessageText ( ( prev ) => `${ prev } ${ prev && ! prev . endsWith ( " " ) ? " " : "" } @${ thread . user } ` ) ;
474+ setOpenThreadMenuId ( null ) ;
475+ } }
476+ className = "rounded-md border-0 px-3 py-2 text-left tracking-tight"
477+ style = { { background : 'transparent' , color : 'var(--white)' , cursor : 'pointer' , fontSize : '12px' , fontWeight : 850 } }
478+ >
479+ Mention
480+ </ button >
481+ </ div >
482+ ) }
364483 </ div >
365484 ) }
366485 </ div >
367- ) ) }
486+ ) ;
487+ } ) }
368488 { typingLabel && (
369489 < TypingIndicator
370490 label = { typingLabel }
0 commit comments