Screen Blueprints — Messaging
Status: Active Last reviewed: 2026-11-10
Current-state blueprints for the messaging screens, read from the components. Conventions and the screen index live in screen-blueprint.md. History lives in git and rebuild-log.md — not here.
DM Thread
- Route:
/messages/[threadId] - Screen:
DmThreadScreen—packages/features/messaging/src/dm-thread-screen.tsx(L3-42; boardflat-v2-detail#dm) - Shell: DetailShell (
scroll={false}) · title = the counterpart's name (a group: its title, with "멤버 N명" under it) · one ⋯ (threadHeaderOf): a 1:1 thread's holds 신고 and 차단 in the destructive group, a group's holds 멤버 보기 and 나가기 (destructive) · a request gate puts the pair band (CtaBand) in the shellfooter - Design: unreviewed
- Verified: 2026-11-15
- Composer: the field is one line high when empty (placeholder centred), grows with its text to the cap and scrolls inside past it; the
+and the send ball sit on its last line (alignItems="flex-end"), as on the post page. - Batch 63 (L20, N8), Actions: a message bubble's long press has a spoken name, 메시지 메뉴 (
dmPage.messageMenu, the primitive'slongPressLabel), so a screen reader reaches the per-message Sheet; the neutral refusal of a chat that cannot be opened is the server's too (dm_unavailable). - Batch 58 (ST3, 2026-11-13), States:
threadViewOf=pageState(readyis the conversation),PageStateView: loading (four person rows), cold offline (a blocking fetch failure while offline reads as offline), error (what 대화), not found (door 메시지로, 돌아가기 secondary); the quiet fetch Notice above messages is unchanged. - Batch 58 (ST1, 2026-11-13), Actions: A group name typed and not saved asks the edit guard before the Sheet or 멤버 초대 leaves; held while the rename is saved.
Purpose. The 1:1 or group DM conversation (gated behind an accept step before the first reply), with the group's members Sheet.
Anatomy.
├─ [DetailShell] title/sub/actions from threadHeaderOf; footer = [CtaBand 거절/수락] while a request waits for the viewer
│ (offline and not found: the back alone, no title, no ⋯)
└─ [KeyboardAvoidingView] iOS padding by insets.top + headerHeight; Android none (pan)
├─ ▸ view = threadViewOf(...)
│ ├─ loading → [SkeletonKind person ×4] ├─ error / blocking-error → [QueryErrorState what=대화]
│ ├─ offline → [OfflineEmptyState] └─ notfound → [NotFoundState] door 메시지로 (replace to the inbox)
└─ conversation
├─ ▸ failed fetch over messages → [Notice neutral] 새 메시지를 불러오지 못했어요 + 다시 시도
├─ ▸ request waiting for the viewer → [Notice neutral] "{name}님이 대화를 요청했어요"
├─ [ThreadMessages] FeedList (FlashList v2 chat API, startRenderingFromBottom) padded 12 top and bottom
│ ├─ [ThreadLine] date / system line: foot, secondary ink, 20 above (none at the top) and 20 below
│ ├─ [DmMessageRow] bubble r18 (6 on the run's last, sender side), pad 8/12, 306 wide (266 beside a group face); the viewer's in the ink block (`$block`), another's in the gray fill; time beside the run's last bubble (a clock while sending); state line under an own bubble; [Reactions summary] chips; group: [SenderFace 32] + [SenderLabel] on a run's first
│ ├─ gaps (`thread-model.ts`): 2 inside a run, 12 between runs, 0 after a line
│ ├─ empty → [EmptyState first compact] "{name}님과 대화를 시작해요" + 첫 메시지를 보내 보세요
│ └─ ▸ away from the bottom with unseen messages → tonal sm pill "새 메시지 N개" → scrollToEnd
└─ ▸ accepted → [ComposeBar] hairline when scrolled, [IconButton Plus 첨부], rounded field (r20, ≥40), send ball 36
[ParticipantsModal] (Sheet bleed, group): title 멤버 N명, PersonRow per member (→ 공개 프로필), admin: rename form, remove control, last Cell 멤버 초대 (closes, opens L4-10)
[MenuSheet] per-message long press: title = the sender (내 메시지 for the viewer), sub = the first words; Reactions quick row; 신고 (theirs) or 삭제 (yours, < 60 s, destructive)
[Dialogs via useConfirm] leave the group · block (names the person; the body is `dmPage.blockBody(friends)`: 더 이상 메시지를 주고받을 수 없어요, with ", 친구도 끊겨요" when the two are friends, `dm-o-block-friends`) · delete (removed for everyone within a minute): 닫기 beside a danger verb (`thread-dialogs.ts`); a refused block opens a second Dialog 차단하지 못했어요. (닫기 alone) and the thread stays
[ReportModal] shared by the long press and the ⋯ (a message report keeps `threadId` + `messageId`; the server records the message subject, so no `subject` is passed)Composer keys. On the web Enter sends and Shift+Enter adds a line; on a phone the return key adds a line and the send ball sends (the same rule as the comment box, ComposerField).
States. Every frame of the board is a case in dm-thread-screen.test.tsx, named by frame id: dm-ideal, dm-kb, dm-group, dm-states, dm-away, dm-request, dm-waiting, dm-declined, dm-empty, dm-banner, dm-loading, dm-error, dm-offline, dm-notfound and the overlays dm-o-message, dm-o-mine, dm-o-menu, dm-o-gmenu, dm-o-members (the Dialogs dm-o-leave, dm-o-block, dm-o-delete are thread-dialogs.test.ts's).
Write failures (ruling EP-3): 삭제, 반응, 나가기 and 수락/거절 of a request tell a failure in one Dialog over the thread (
dm.refusalby hint, else the write's own line), 닫기 forgets it; opening marks read as a background write (shown nothing, reported); never a Toast.Offline (F1c, the app's root strip says it): offline the cached thread stays; sending a message or a photo queues and stays live; 신고 on a message and renaming a group wait (write rows).
2026-11-13 (UX-7, the state helper):
threadViewOf=pageState(readyis the conversation),PageStateView: loading (four person rows), cold offline (a blocking fetch failure while offline reads as offline), error (what 대화), not found (door 메시지로, 돌아가기 secondary); the quiet fetch Notice above messages is unchanged.
Actions.
- Back →
appRouter.back(); ⋯ → report (ReportModal), block (Dialog →useBlockUserthroughrefusalOf→ back; a typed refusal is the second Dialog, an untyped failure propagates;friendsisuseFriendship(viewer, other).status === 'accepted'), 멤버 보기 (Sheet), 나가기 (Dialog →useLeaveThread→ back). - Composer: send (text), add button picks a photo and sends it. Request: 수락 / 거절 →
useDecideDmRequest. - Long press an acknowledged bubble → MenuSheet; a local (sending, queued, failed, interrupted) bubble is never actioned. A face in a group → 공개 프로필.
Gaps. Not built, with the reason: 답장 and the reply row (no reply store), 복사 (no clipboard hook), 카메라 and the Attach MenuSheet (no camera pick hook beside usePickImages), 다시 보내기 / 삭제 on a failed bubble (no outbox retry), and the counterpart's name as a door to 공개 프로필 (the Header takes no title press). The "새 메시지" pill counts others' messages after the row the viewer left the bottom at.
Notes. Reads: useDmThreadData, useDmChatItems (rows by buildChatItems), useDmComposer, useDmThreadActions, useDmMessageRenderer. The ThreadInfoHeader (a centred identity block) is gone: the board starts with the date line.
DM Add Members (멤버 초대)
- Route:
/messages/add-members - Screen:
DmAddMembersScreen—packages/features/messaging/src/dm-add-members-screen.tsx(L4-10; boardflat-v2-forms#am) - Shell: DetailShell · title
t().common.inviteMembers("멤버 초대") ·scroll={false}· footer = [CtaBand] - Design: unreviewed
- Verified: 2026-11-15
- Verb: the band says the verb with 하기: 초대하기 with nobody picked (soft), N명 초대하기 with N picked (
dmPage.inviteBare,inviteWith). - Batch 58 (ST3, 2026-11-13), States: As DM Create Group, error what 초대할 사람; nobody to invite is the page's first-run.
Purpose. Invite friends into a group chat that already exists.
Anatomy.
├─ [DetailShell] title=멤버 초대 onBack footer=[CtaBand primary "N명 초대하기" (soft at zero; 초대하는 중... in flight)]
└─ ▸ state (candidateStateOf)
├─ offline → [OfflineEmptyState] ├─ error → [QueryErrorState what=초대할 사람]
├─ empty → [EmptyState first] 초대할 수 있는 사람이 없어요 ✦ 친구 찾기 → routes.profileFriendsAdd
└─ loading | ready → [PeoplePickerBody] (`@twomore/ui`, the kit's picker; shared with 단톡방 만들기 and the draw's 참석 Sheet)
├─ header → [Shelf cell] 고른 사람 N명 (a face unticks; at most 5 faces) · [SearchField] 이름으로 검색
└─ rows → [SectionHead 친구 N명] + [PersonRow] with a trailing check box (a tap anywhere on the row toggles it)States. Every frame is a case in dm-add-members-screen.test.tsx: am-none, am-ideal, am-inviting, am-leave, am-empty, am-loading, am-cold, am-error.
- Write failures (ruling EP-3): a refused invite is the CTA band's danger helper (
dm.refusal); never a Toast. - 2026-11-13 (UX-7, the state helper): As DM Create Group, error what 초대할 사람; nobody to invite is the page's first-run.
Actions. A row (or a face in the strip) toggles; the band invites (useAddThreadParticipants) and pops back; back on a ticked form asks (useLeaveGuard, body 고른 친구는 저장되지 않아요).
Gaps. The head 함께 친 게스트 is not built: a thread carries no club to read the club's guests from.
DM Inbox (메시지)
- Route:
/messages - Screen:
DmInboxScreen—packages/features/messaging/src/dm-inbox-screen.tsx - Shell: DetailShell · title
메시지· scroll=false · navRoot · no actions - Design: unreviewed
- Verified: 2026-11-11
Purpose. The conversation list at once, no Tabs: the accepted threads and the requests the viewer sent (a request belongs to its recipient; the sender keeps theirs as a conversation, as Instagram and KakaoTalk do; recorded for the owner's veto). While a request waits, one door row on top opens 메시지 요청. The FAB 단톡방 makes a group chat. Built W4 batch 5 to flat-v2-list-a#ms-ideal (L3-14).
Anatomy.
├─ [DetailShell] 메시지 · onBack
├─ [FeedList] tail = fabTail (SCROLL_CONTENT.list when no FAB); useFabCollapse scroll props
│ ├─ header: 8 · [CellGroup top=false > TaskCell 메시지 요청, count, tone danger] (only while ≥1 request waits) · 16 (rows or skeleton follow)
│ └─ [DmThreadRow] × threads (app PersonRow `threads`): [dot][face / group][name][preview, 1 line][time]
└─ [Fab] 단톡방 (spoken 단톡방 만들기) placed=plain → routes.dmCreateGroup()States. Loading → nine person skeletons, FAB kept. Error → QueryErrorState, no FAB. Cold offline → OfflineEmptyState, no FAB. First-run → EmptyState kind="first" 아직 대화가 없어요 / 클럽 멤버나 매치 호스트에게 먼저 메시지를 보내 보세요, no button, FAB kept. The door shows the loaded count (the CountTag caps 99+); it stays over a first-run inbox.
- Offline (F1c, the app's root strip says it): the cached list stays under the root strip; the group FAB only navigates and stays live.
Data. useVisibleDmThreads(uid, 'accepted') (adapter view: accepted OR (pending AND initiated_by = viewer), keyset (updated_at, id)), useDmUnreadByThread for dots, useVisibleDmThreads(uid, 'pending') for the door's count (adapter view: pending AND initiated_by <> viewer). Blocked 1:1 threads are filtered, groups never.
Actions. Door → routes.dmRequests. Thread row → routes.dmThread(id) (press-in prefetch). FAB → routes.dmCreateGroup(). Pull-to-refresh, load-more. Web ≥840: the list pane of the messages list-detail (selectedThreadId marks the open row).
Notes. 2026-11-08: the Tabs (주요/요청), the header message action and the 3/2-row skeletons went; the requests became their own page. First Fab consumer, composed in the screen (a shell slot waits for a second consumer). The FAB tail uses fabTail(HOME_INSET, 'plain') because a feature may not read the safe area (a ui useFabTail would be exact). Thread times keep the shared formatThreadTime ladder (방금, N분, N시간, 어제, N일, M/D) because the club chat pane shares DmThreadRow: a recorded delta from the board's clock-for-today (12:30) and "N분 전".
Gaps. The door's count is the loaded page (50): a true 99+ needs a count RPC (not built). A sent request in the inbox shows as an ordinary thread; the thread screen carries the waiting state.
DM Requests (메시지 요청)
- Route:
/messages/requests - Screen:
DmRequestsScreen—packages/features/messaging/src/dm-requests-screen.tsx - Shell: DetailShell · title
메시지 요청· scroll=false - Design: unreviewed
- Verified: 2026-11-08
Purpose. The pushed list behind the inbox's door: the message requests addressed to the viewer, answered in the row. Built W4 batch 5 to flat-v2-list-a#ms-requests (L3-15). Pushed by routes.dmRequests; on the web it opens in the right pane at 840+ beside the inbox.
Anatomy. DetailShell → FeedList (header 16 while rows or skeleton show) of DmThreadRow isRequest (app PersonRow requests: [face][name][time · preview][수락][거절]); the row opens the requester's profile (userId), a bare tap never answers.
States. Loading → nine person skeletons. Error / cold offline → the primitives. Empty → EmptyState kind="first" 받은 요청이 없어요 / 새 요청이 오면 여기에 보여요.
Data. useVisibleDmThreads(uid, 'pending') (recipient-only). 수락/거절 → useDecideDmRequest (decide_dm_request); after 수락 the row leaves and the thread appears in the inbox (no auto-navigate); the door goes with the last request.
Notes. 2026-11-08: new page and route files (apps/mobile/app/messages/requests.tsx, apps/web/app/messages/requests/page.tsx); root cause of the old defect fixed in the adapter (the pending query filtered on status alone, so a sender saw 수락/거절 on their own request that the RPC refuses).
Actions. A row → the requester's 공개 프로필; 수락 · 거절 answer in the row; back → 메시지.
Gaps. No loading/error/offline frames on the board for this page (the inbox's pitch and the primitives are used).
DM Create Group (단톡방 만들기)
- Route:
/messages/new-group(seeded by userIds, title and sessionId query parameters) - Screen:
DmCreateGroupScreen—packages/features/messaging/src/dm-create-group-screen.tsx(L4-09; boardflat-v2-forms#ng) - Shell: DetailShell · title names the source (일정 단톡방 만들기 with a session, 클럽 단톡방 만들기 with a seed alone, 단톡방 만들기 from the inbox) ·
scroll={false}· footer = [CtaBand] - Design: unreviewed
- Verified: 2026-11-13
- Batch 58 (ST3, 2026-11-13), States:
candidateStateOf=pageState+ the page'sempty;PageStateView: loading (the picker with its skeleton), cold offline, error (what 친구), nobody to pick (first-run), picker.
Purpose. Start a group chat: a name and the people, seeded from a session or a club (everyone ticked once) or from the viewer's friends (nobody ticked).
Anatomy.
├─ [DetailShell] title onBack footer=[CtaBand primary "N명과 단톡방 만들기" (the bare verb at zero; soft = a name typed and nobody ticked; 만드는 중... in flight)]
└─ ▸ state (candidateStateOf)
├─ offline → [OfflineEmptyState] ├─ error → [QueryErrorState what=친구 목록]
├─ empty → [EmptyState first] 추가할 사람이 없어요 ✦ 친구 찾기
└─ loading | ready → [PeoplePickerBody] (`@twomore/ui`)
├─ lead → [Field 이름 required] [Input limit=50] (error 단톡방 이름을 입력해 주세요)
├─ header → [Shelf cell] 고른 사람 · [SearchField]
└─ rows → [SectionHead 멤버 | 친구 N명] (+ error line 멤버를 한 명 이상 골라 주세요) + [PersonRow] with a trailing check boxStates. Every frame is a case in dm-create-group-screen.test.tsx: ng-ideal, ng-inbox, ng-pressed, ng-nomember, ng-empty, ng-loading, ng-error, ng-cold, ng-creating, ng-kb, ng-long, ng-leave.
- Write failures (ruling EP-3): a refused group is the CTA band's danger helper (
dm.refusal); never a Toast. - 2026-11-13 (UX-7, the state helper):
candidateStateOf=pageState+ the page'sempty;PageStateView: loading (the picker with its skeleton), cold offline, error (what 친구), nobody to pick (first-run), picker.
Actions. A row toggles; two pending inputs keep the primary enabled — its press shows both lines and the name takes focus; one pending choice is soft. Success → appRouter.replace(routes.dmThread(id)) (guard released first). Back on a typed or ticked form asks (useLeaveGuard).
Gaps. The board's two heads for a session (클럽 멤버 and 친구) need club membership the seed does not carry: a seeded list is one head, 멤버, and the inbox's is 친구.