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 (
{[...name][0] ?? '?'}
)
}
/** 같은 사람이 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 (
setChannel('mj')}
className={`rounded-full border px-3 py-1 text-xs ${
channel === 'mj'
? 'border-blue-600 bg-blue-50 font-semibold text-blue-700'
: 'border-gray-200 text-gray-500'
} disabled:cursor-not-allowed disabled:opacity-40`}
>
문정원
setChannel('law')}
className={`rounded-full border px-3 py-1 text-xs ${
channel === 'law'
? 'border-blue-600 bg-blue-50 font-semibold text-blue-700'
: 'border-gray-200 text-gray-500'
} disabled:cursor-not-allowed disabled:opacity-40`}
>
법률검토
{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}
setAttachments((prev) => prev.filter((_, j) => j !== i))}
className="text-gray-400 hover:text-gray-600"
>
×
))}
)}
>
)}
)
}