Skip to content

Commit 014b588

Browse files
committed
refactor: 채팅 안정화 및 영속성 자동 스크롤
1 parent f9e45ba commit 014b588

4 files changed

Lines changed: 500 additions & 149 deletions

File tree

src/app/components/ChannelPanel.tsx

Lines changed: 143 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,18 @@ interface Thread {
1818
}
1919

2020
interface 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+
2733
const 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

Comments
 (0)