import { useEffect, useRef, useState } from 'react' import { clearNotice, fileDownloadUrl, getNotice, getPosts, sendMessage, type ChannelKindKey, type FileRef, type Org, type PostView, } from '../api/client' const POLL_INTERVAL_MS = 3000 const timeFormatter = new Intl.DateTimeFormat('ko-KR', { hour: '2-digit', minute: '2-digit' }) const dateTimeFormatter = new Intl.DateTimeFormat('ko-KR', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', }) function humanizeSize(bytes: number): string { if (bytes < 1024) { return `${bytes} B` } const kb = bytes / 1024 if (kb < 1024) { return `${kb.toFixed(1)} KB` } return `${(kb / 1024).toFixed(1)} MB` } function AttachmentChip({ file }: { file: FileRef }) { return ( {file.name} ({humanizeSize(file.size)}) ) } // 이름에서 결정되는 아바타 색 — 같은 사람은 항상 같은 색이 나온다 (Mattermost 이니셜 아바타 방식) const AVATAR_COLORS = [ 'bg-rose-500', 'bg-orange-500', 'bg-amber-500', 'bg-emerald-500', 'bg-teal-500', 'bg-sky-500', 'bg-indigo-500', 'bg-violet-500', 'bg-fuchsia-500', ] function avatarColor(name: string): string { let hash = 0 for (const ch of name) { hash = (hash + ch.codePointAt(0)!) % AVATAR_COLORS.length } return AVATAR_COLORS[hash] } function Avatar({ name }: { name: string }) { return ( ) } /** 같은 사람이 5분 안에 연달아 쓴 메시지는 아바타·이름 없이 이어 붙인다 (Mattermost와 동일) */ const GROUP_WINDOW_MS = 5 * 60 * 1000 function PostRow({ post, prev }: { post: PostView; prev: PostView | null }) { if (post.system) { return

{post.message}

} const continued = prev !== null && !prev.system && prev.user === post.user && post.createAt - prev.createAt < GROUP_WINDOW_MS return (
{continued ? : }
{!continued && (
{post.user} {timeFormatter.format(new Date(post.createAt))}
)} {post.message && (

{post.message}

)} {post.files.length > 0 && (
{post.files.map((f) => ( ))}
)}
) } /** 공지 배너: 현재 채널의 단일 활성 공지를 목록 위에 항상 고정해 보여준다. */ function NoticeBanner({ notice, busy, onClear, }: { notice: PostView busy: boolean onClear: () => void }) { return (
📢 공지 {notice.user} · {dateTimeFormatter.format(new Date(notice.createAt))}
{notice.message && (

{notice.message}

)} {notice.files.length > 0 && (
{notice.files.map((f) => ( ))}
)}
) } /** 기관관리 상세의 채팅 탭. 선택한 채널의 최근 게시글을 3초 간격으로 폴링해서 보여준다. */ export default function ChannelPosts({ org }: { org: Org }) { const bothMissing = !org.channelIdMj && !org.channelIdLaw const [channel, setChannel] = useState(org.channelIdMj ? 'mj' : 'law') const [posts, setPosts] = useState([]) const [notice, setNotice] = useState(null) const [error, setError] = useState(null) // 전송 성공 직후 3초 폴링을 기다리지 않고 즉시 다시 읽기 위한 트리거 const [refreshTick, setRefreshTick] = useState(0) const [draft, setDraft] = useState('') const [attachments, setAttachments] = useState([]) const [asNotice, setAsNotice] = useState(false) const [sending, setSending] = useState(false) const [sendError, setSendError] = useState(null) const [noticeWarning, setNoticeWarning] = useState(null) const containerRef = useRef(null) const fileInputRef = useRef(null) const prevCountRef = useRef(0) useEffect(() => { if (bothMissing) { return } let cancelled = false async function load() { try { const data = await getPosts(org.id, channel) if (!cancelled) { setPosts(data) setError(null) } } catch { if (!cancelled) { setError('연결이 원활하지 않습니다. 재시도 중…') } } // 공지 조회 실패는 목록을 깨뜨리지 않는다 - 마지막 값을 유지한다 try { const current = await getNotice(org.id, channel) if (!cancelled) { setNotice(current) } } catch { /* keep last notice */ } } void load() const timer = setInterval(() => void load(), POLL_INTERVAL_MS) return () => { cancelled = true clearInterval(timer) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [channel, org.id, refreshTick]) const canSend = !sending && (draft.trim().length > 0 || attachments.length > 0) async function submit() { if (!canSend) { return } setSending(true) setSendError(null) setNoticeWarning(null) try { const result = await sendMessage(org.id, channel, { message: draft, notice: asNotice, files: attachments, }) setDraft('') setAttachments([]) setAsNotice(false) setNoticeWarning(result.noticeWarning) setRefreshTick((t) => t + 1) } catch (e) { setSendError((e as Error).message) } finally { setSending(false) } } async function handleClearNotice() { if (!window.confirm('공지를 해제할까요? (원본 글은 채팅 기록에 남습니다)')) { return } setSending(true) try { await clearNotice(org.id, channel) setNotice(null) setRefreshTick((t) => t + 1) } catch (e) { setSendError((e as Error).message) } finally { setSending(false) } } useEffect(() => { if (posts.length > prevCountRef.current && containerRef.current) { containerRef.current.scrollTop = containerRef.current.scrollHeight } prevCountRef.current = posts.length }, [posts.length]) return (
{bothMissing ? (

채널이 아직 생성되지 않았습니다.

) : ( <> {error && (

{error}

)} {notice && ( void handleClearNotice()} /> )}
{posts.length === 0 ? (

아직 게시글이 없습니다. 아래 입력창이나 Mattermost에서 메시지를 보내면 여기 나타납니다.

) : ( posts.map((post, i) => ( 0 ? posts[i - 1] : null} /> )) )}
{/* 입력창 */}
{attachments.length > 0 && (
{attachments.map((file, i) => ( {file.name} ))}
)}