본문으로 건너뛰기

메시징

채널 · 스레드 · DM 의 대화 화면입니다. 사이드바 · 메시지 · 목록 · 스레드가 같은 ./chat 서브패스에 있습니다.

메시지 창은 카드 면 위에 놓입니다. 상태(보내는 중 · 실패 · 수정됨 · 삭제됨)는 본문을 흐리게 하지 않고 모양과 글자로 말합니다. 나를 부른 메시지는 행이 아니라 멘션 칩만 강조합니다(결정 #30). 남의 입력 중 · 접속 변화는 스크린리더에 알리지 않습니다(결정 #31). 서브패스는 radix-ui 와 @tanstack/react-virtual 을 선택 peer 로 요구합니다(결정 #34).

import
import { ChannelSidebar, ChannelItem, DmItem, ChannelFinder, CreateChannelDialog, MessageItem, MentionChip, MessageList, ThreadPanel, ThreadSummary } from "@uyoolabs/ui/chat";
쓰는 경우
사람끼리 이야기하는 채널 · 스레드 · DM. 첫 소비자는 레퍼런스 앱 채널 화면입니다.
쓰지 않는 경우
알림 · 활동 피드처럼 대화가 아닌 목록(ActivityFeed · NotificationCenter), AI 대화(uyoopack 의 스크롤러는 이번에 바꾸지 않습니다)에는 쓰지 않습니다. 메시징이 없는 제품의 주 내비는 W1 Sidebar 를 그대로 씁니다. 새 메시지 선 · 멘션 칩에 빨강을 쓰지 않습니다.

ChannelSidebar

W1 Sidebar · SidebarSection 위에 채널 종류 · 접속 점 · 쓰던 글 · 보내지 못함 · 항목 동작을 쌓습니다.

키보드

키동작
Tab항목 · 섹션 머리 · + · ⋯ 를 차례로(링크 목록이라 roving 이 없다)
Alt+↑·Alt+↓이전 · 다음 채널로(onSelect)
Alt+Shift+↑·Alt+Shift+↓이전 · 다음 안 읽은 채널로
⌘J·Ctrl+J채널 · 사람 찾기(onFind). IME 조합 중이면 무시
Shift+F10·메뉴 키항목의 ⋯ 메뉴. 오른쪽 클릭 · 길게 누르기도 같다
↑·↓·Enter·Esc찾기 안: 고르기 · 이동(조합 중 Enter 는 확정) · 닫고 찾기 단추로

MessageItem · MentionChip

손을 올리거나 포커스하면 오른쪽 위에 동작 막대가 뜹니다.

이도현

결제 실패 화면은 두 경우로 나눕니다. 에도 공유합니다.

김하린

오늘 오후에 올리겠습니다.

김하린

시안 두 장 올립니다.

보내지 못했습니다.

키보드

키동작
Enter스레드 열기(메시지에 포커스가 있을 때)
R답장
E수정(내 것)
⌫·Delete삭제 확인 대화상자(되돌릴 수 없어 빨강)
Shift+F10더 보기 메뉴
←·→동작 막대 안에서 단추 사이(APG Toolbar)

MessageList

바닥에서 64px 안이면 새 내용을 따라가고, 위로 올리면 놓아 줍니다(uyoopack message-scroller 의 규칙). 200개를 넘으면 가상 스크롤로 그립니다.

#디자인 채널의 시작입니다

이도현 님이 9월 3일에 만들었습니다.

이도현

결제 실패 화면은 두 경우로 나눕니다.

  1. 카드 한도 초과 2) 일시 오류

재시도 버튼은 키캡으로 바꿨습니다.

한서윤

문구 확인 부탁드립니다.

3~5일 안에 배포합니다.

이도현 님이 입력하고 있습니다

키보드

키동작
↑·↓이전 · 다음 메시지(article)
PageUp·PageDown화면 단위로 이전 · 다음 메시지
Home·End처음 · 끝. End 는 '최신으로' 와 같다
Esc안 읽음을 모두 읽음으로(onMarkRead)

스크롤 영역은 이름 붙은 region 입니다. APG Feed(role=feed)는 article 밖의 역할 · 포커스 요소를 허용하지 않아 날짜 메뉴 단추 · 구분선과 함께 둘 수 없습니다. 대신 Feed 의 키를 그대로 줍니다.

ThreadPanel · ThreadSummary

본문 오른쪽 패널(폭 400). 1024 미만에서는 드로어로 뜹니다.

키보드

키동작
Enter(요약 · 메시지에서) 패널을 열고 작성창으로 포커스
Esc작성창이 비었을 때 닫고 연 자리로. 쓰던 글이 있으면 닫지 않는다
Tab머리(⋯ · ×) → 원 메시지 → 답글 → 작성창 → 채널에도 보내기

props

소스의 타입과 JSDoc 에서 생성한 표입니다. 컴포넌트를 고치면 pnpm gen 으로 다시 만듭니다.

ChannelSidebar

prop타입기본값설명
headerReactNode—머리 줄(WorkspaceSwitcher)
footerReactNode—바닥 줄(StatusMenu 로 감싼 SidebarUser)
topReactNode—맨 위 고정 항목(스레드 · 멘션 · 초안). 보통 SidebarList
sections필수ChannelSection[]—섹션 순서는 앱이 정한다(별표 → 채널 → DM → 사용자 섹션)
onSectionToggle(id: string, open: boolean) => void—
currentIdstring—aria-current="page" 를 줄 항목
onSelect(id: string) => void—항목을 누르거나 Alt+↑↓ 로 옮길 때
onFind() => void—찾기 단추(⌘J). 주지 않으면 찾기 단추를 그리지 않는다
onMove(itemId: string, toSectionId: string, index: number) => void—항목을 다른 섹션으로 옮긴다. 주면 ⋯ 의 '섹션으로 옮기기' 가 켜진다(끌어 옮기기는 아직 없다, ADR 0008)
state"expanded" | "collapsed"—W1 Sidebar 와 같은 값. 접히면 섹션을 숨기고 멘션 합계를 '안 읽음' 칸 하나로 모은다(결정 #17)
lang"ko" | "en" | "ja"—
labelsPartial<ChannelSidebarLabels>—

이 밖에 <nav> props(children · onSelect 제외)를 그대로 받습니다.

ChannelItem

prop타입기본값설명
channel필수ChannelRef—kind 로 아이콘: public "#" · private 자물쇠(모양이 종류를 말한다)
hrefstring—
currentboolean—지금 화면. 없으면 ChannelSidebar 의 currentId 와 비교한다
unreadboolean—600 · 진한 글자
mentionsnumber—solid 칩(빨강 아님, 결정 #16). 99 넘으면 99+
mutedboolean—흐린 글자 + 종 끔. 멘션 칩은 남는다
draftboolean—끝 자리 연필(쓰던 글이 있다)
actionsReactNode—⋯ 메뉴 내용(DropdownMenuItem 들)
onClick() => void—
lang"ko" | "en" | "ja"—
classNamestring—

DmItem

prop타입기본값설명
people필수ChatPerson[]—1명이면 아바타 20 + 접속 점 8, 여럿이면 사람 수를 담은 사각
idstring—대화 id. 없으면 첫 사람 id
hrefstring—
currentboolean—
presence"online" | "away" | "dnd" | "offline"—1:1 일 때만. 점 둘레 틈은 --uyoo-cut(사이드바 well, 현재 항목 raised)
selfbooleanfalse나와의 DM. 이름 뒤에 (나)
unreadboolean—
mentionsnumber—DM 의 새 메시지는 멘션처럼 solid 칩
failedbooleanfalse보내지 못한 메시지가 있다. 끝 자리 경고 아이콘(bad)
mutedboolean—
actionsReactNode—
onClick() => void—
lang"ko" | "en" | "ja"—
classNamestring—

ChannelFinder

prop타입기본값설명
openboolean—
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void—
querystring—
defaultQuerystring""
onQueryChange(query: string) => void—
results필수ChannelFinderResult[]—
onSelect필수(id: string) => void—참여하지 않은 공개 채널은 미리 보기로 연다(참여 단추는 작성창 자리)
onCreate() => void—바닥 줄 '채널 만들기'
onBrowse() => void—바닥 줄 '채널 둘러보기'
loadingbooleanfalse
lang"ko" | "en" | "ja""ko"
labelsPartial<ChannelFinderLabels>—

CreateChannelDialog

prop타입기본값설명
openboolean—
defaultOpenbooleanfalse
onOpenChange(open: boolean) => void—
validateName(name: string) => Promise<string | boolean | null> | string | boolean | null—300ms 쉬고 부른다. 문자열이면 그 오류 문장, true 면 기본 중복 문장(labels.duplicate), null 이면 통과
onCreate필수(input: { name: string; kind: "public" | "private" }) => Promise<void> | void—
defaultKind"public" | "private""public"
lang"ko" | "en" | "ja""ko"
labelsPartial<CreateChannelLabels>—

MessageItem

prop타입기본값설명
id필수string—
author필수MessageAuthor—
time필수string—UTC ISO. lang · timeZone 으로 바꿔 보이고, 손을 올리면 전체 날짜(title)
body필수ReactNode—문자열이면 마크다운으로 그린다(원시 HTML 은 해석하지 않는다). 노드를 주면 그대로
groupedbooleanfalse같은 사람 · 5분 안 · 같은 날이면 목록이 true 로 넘긴다. 아바타 · 이름을 빼고 시각은 hover · 포커스에만
state"sent" | "sending" | "failed""sent"기본 sent
errorstring—failed 의 이유. 없으면 labels.failed
editedAtstring—UTC ISO. 있으면 본문 끝에 (수정됨)
deletedbooleanfalse삭제됨. 스레드 답글이 있을 때만 자리를 남긴다(없으면 목록에서 뺀다). 반응 · 동작 막대 없음
replyTo{ id: string; author: string; excerpt: string; onJump: () => void }—답장 인용 한 줄. 누르면 원문으로
fromThread{ rootExcerpt: string; onOpen: () => void }—채널에도 보낸 스레드 답글
threadReactNode—ThreadSummary
reactionsReactNode—Reactions
attachmentsReactNode—FileAttachment · ImageGrid · LinkPreview
actionsReactNode—동작 막대를 통째로 바꾼다. 없으면 빠른 반응 · 반응 추가 · 스레드 · (내 것) 수정 · 더 보기
quickReactionsstring[][]빠른 반응(최근 사용 앞 3)
onReact(emoji: string) => void—
onAddReaction() => void—반응 추가(이모지 고르기를 앱이 연다)
ownbooleanfalse내 메시지. 수정 · 삭제 항목과 E · ⌫ 키를 켠다
canDeleteboolean—관리자처럼 남의 메시지도 지울 수 있다
activebooleanfalse스레드 패널에 열린 메시지(primary-soft 면)
highlightedbooleanfalse도착 표시. 1.6초 뒤 스스로 걷는다
selfIdstring—보는 사람의 id. 본문에서 나를 부른 멘션 칩을 강조한다(결정 #30)
onRetry() => void—같은 client id 로 다시 보내 중복을 막는 것은 앱이 한다
onDelete() => void—실패 메시지 지우기 · 삭제 확인 뒤
onReply() => void—
onOpenThread() => void—
onEdit() => void—
onCopyLink() => void—
onMarkUnread() => void—
onPin() => void—
onAuthorClick() => void—
onMentionClick(mention: MentionRef) => void—본문 멘션 칩을 눌렀을 때(사람 카드 · 채널 이동)
renderMention(mention: MentionRef & { self: boolean }) => ReactNode—멘션 칩을 직접 그린다(ProfileCardTrigger 로 감싸기 등)
timeZonestring—표시 시간대(IANA). 없으면 lib/format 의 기본값
lang"ko" | "en" | "ja""ko"
labelsPartial<MessageItemLabels>—
classNamestring—

MentionChip

prop타입기본값설명
kind"user" | "channel""user"user: @이름 · channel: #이름
id필수string—사람 · 채널 id. 마크다운의 mention:id · channel:id 그대로다
label필수string—보이는 이름. @ · # 는 컴포넌트가 붙인다
selfbooleanfalse나를 부른 멘션. primary-soft 면 + primary 1px 테두리 + 600(결정 #30: 행이 아니라 칩만 강조한다)
unknownbooleanfalse나간 사람 · 권한 없는 비공개 채널. 칩을 풀고 흐린 글자만 남긴다
hrefstring—주면 링크(사람 카드 · 채널 주소)
onClick() => void—href 가 없으면 버튼(사람 카드 열기)
asChildbooleanfalse자식 요소(라우터 링크 · ProfileCardTrigger 의 단추)에 칩 모양을 입힌다
childrenReactElement—
lang"ko" | "en" | "ja"—
labelsPartial<MentionChipLabels>—
classNamestring—

MessageList

prop타입기본값설명
items필수T[]—오래된 것 → 새것
renderItem필수( item: T, ctx: { grouped: boolean; active: boolean; highlighted: boolean }, ) => ReactNode—보통 <MessageItem grouped={ctx.grouped} active={ctx.active} …>
lastReadIdstring—마지막으로 읽은 메시지 id. 처음 받은 값으로 '새 메시지' 선을 고정하고 목록이 다시 마운트될 때까지 옮기지 않는다
onMarkRead() => void—안 읽음 띠의 '모두 읽음으로 표시' · Esc
hasMoreBeforebooleanfalse
loadingBeforebooleanfalse
errorBeforestring | null—
onLoadBefore() => void—위 끝에서 300px 안에 들어오면 미리 부른다. errorBefore 는 문장(빈 문자열이면 기본 문구)
onRetryBefore() => void—
introReactNode—위 끝까지 다 불렀을 때 맨 위에 그릴 것(채널의 시작, ChannelIntro)
stickToBottombooleantrue바닥 따라가기. 기본 true
onReachBottom() => void—바닥이 보이면(읽음 처리)
newCountnumber0위로 올려 읽는 동안 새로 온 메시지 수. '최신으로' 단추에 더한다
typingReactNode—TypingIndicator. 목록 밖 아래 20 을 늘 잡아 둔다
loadingbooleanfalse처음 불러오는 중(뼈대 4줄, 300ms 뒤 · 최소 400ms)
errorstring | null—
onRetry() => void—
emptyReactNode—빈 채널. 없으면 기본 빈 상태
groupWindowMsnumber5 * 60_000묶음 시간. 기본 5분
timeZonestring—날짜 구분 · 시각의 시간대(IANA). 없으면 lib/format 의 기본값
nowstring—오늘 · 어제를 가르는 기준(UTC ISO). 테스트 · 서버 렌더 · 시각 회귀에서 고정
onJumpToDate(target: JumpTarget) => void—날짜 이름표 메뉴. 없으면 이름표는 누를 수 없는 글자다
activeIdstring—스레드에 열린 메시지
highlightIdstring—도착 표시를 줄 메시지. 가운데로 스크롤한다
selfIdstring—보는 사람의 id. 내가 보낸 메시지는 언제나 바닥으로 데려가고 라이브 영역에서 읽지 않는다
getAnnouncement(item: T) => { name: string; text: string } | null—라이브 영역이 읽을 이름 · 첫 줄. 없으면 개수만 읽는다
announceScopeRefRefObject<HTMLElement | null>—라이브 영역은 포커스가 이 안(대화 영역: 목록 + 작성창)에 있을 때만 읽는다. 없으면 목록 안
onJumpToLatest() => void—'최신으로' 를 누른 뒤(앱이 작성창으로 포커스를 돌려보낸다)
label필수string—목록 접근성 이름 '#디자인 메시지'
lang"ko" | "en" | "ja""ko"
labelsPartial<MessageListLabels>—
classNamestring—

ChannelIntro

prop타입기본값설명
name필수string—
kind"public" | "private""public"
descriptionReactNode—만든 사람과 날짜 같은 한 줄
actionsReactNode—멤버 추가 같은 동작(sm 버튼)
lang"ko" | "en" | "ja""ko"
labelsPartial<MessageListLabels>—

DayDivider

prop타입기본값설명
time필수string—그 날의 한 시각(UTC ISO)
timeZonestring—
nowDate—오늘 · 어제를 가르는 기준. 테스트 · 서버 렌더에서 고정
onJumpToDate(target: JumpTarget) => void—
lang"ko" | "en" | "ja""ko"
labelsPartial<MessageListLabels>—

NewMessagesDivider

prop타입기본값설명
label필수string—

JumpToLatest

prop타입기본값설명
countnumber0
onClick필수() => void—
lang"ko" | "en" | "ja""ko"
labelsPartial<MessageListLabels>—

ThreadPanel

prop타입기본값설명
root필수ReactNode—원 메시지. 보통 <MessageItem>(스레드 요약은 그리지 않는다 — 여기가 그 스레드다)
rootDeletedbooleanfalse'원 메시지가 삭제되었습니다' 자리만 남기고 답글은 보인다
channelName필수string—머리의 '#디자인'
replyCount필수number—
replies필수ReactNode—<MessageList>(같은 바닥 따라가기 · 새 메시지 선)
composer필수ReactNode—<Composer placeholder={labels.replyPlaceholder}>
alsoSendToChannelbooleanfalse채널에도 보내기. 기억하지 않는다 — 패널을 열 때마다 false 로 시작한다(제어)
onAlsoSendChange(value: boolean) => void—
followingboolean—⋯ 메뉴 '스레드 알림 끄기 · 켜기'
onFollowChange(value: boolean) => void—
onCopyLink() => void—
onOpenInWindow() => void—
onClose필수() => void—
loadingbooleanfalse답글 뼈대
errorstring | null—
onRetry() => void—
variant"panel" | "sheet""panel"panel: 본문 오른쪽(폭 400). lg(1024) 미만이면 앱이 sheet 를 넘긴다
widthnumber—패널 폭(--uyoo-thread-w). 320–560
autoFocusbooleantrue그릴 때 작성창으로 포커스. 기본 true(답글을 쓰러 왔다)
lang"ko" | "en" | "ja""ko"
labelsPartial<ThreadPanelLabels>—
classNamestring—

ThreadSummary

prop타입기본값설명
count필수number—
participants필수ThreadParticipant[]—앞 3명만 그린다
lastReplyAt필수string—UTC ISO → '10분 전' · '어제'(lang)
unreadbooleanfalse
onOpen필수() => void—
nowstring—상대 시각의 기준. 테스트 · 서버 렌더에서 고정
timeZonestring—
lang"ko" | "en" | "ja""ko"
labelsPartial<ThreadPanelLabels>—
classNamestring—