Screen Blueprints — Messaging
Status: Active Last reviewed: 2026-09-11
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 - Shell: DetailShell · title = group title (or
newGroupfallback) / other participant'sdisplayName(orinboxTitlefallback) · subtitle = member count (group only) · titleLeading =AvatarStack(group, max 3) orAvatarBubble(1:1) · headerRight: group →HeaderIconButton(MoreHorizontal) openingParticipantsModal; 1:1 →HeaderActionsMenu(신고/차단) - Design: ratified
- Verified: 2026-09-17
Purpose. The 1:1 or group DM conversation surface — send/receive messages (gated behind a friend/club-mate accept flow before first send), plus group management (rename, add/remove member, leave).
Anatomy.
├─ [DetailShell] scroll=false title/subtitle/titleLeading/headerRight as above
│ ▸ hasBlockingMessageError (poll/fetch failed AND zero messages) →
│ [QueryErrorState] onRetry=handleRefresh, everything below unmounted
└─ [KeyboardAvoidingView] iOS behavior=padding + keyboardVerticalOffset=
insets.top+resolveAppHeaderHeight(hasSubtitle); Android behavior=undefined
(app.json softwareKeyboardLayoutMode=pan already pans the OS viewport)
├─ ▸ isLoadingMessages && isLoadingLatestMessages → [SkeletonCard]
├─ ▸ hasNonBlockingMessageError (poll failed, messages still on screen) →
│ [QueryErrorState variant=compact] onRetry=handleRefresh (thread stays open)
├─ [FeedList] chat log — FlashList v2 chat API,
│ maintainVisibleContentPosition={startRenderingFromBottom:true} (NOT inverted)
│ onStartReached → fetchNextPage (older messages), onStartReachedThreshold=0.3
│ ├─ header → [ThreadInfoHeader] centered avatar/AvatarStack + name + started-on date·time
│ └─ item → [DmMessageRow] own=right-aligned $primary bubble; theirs=left-aligned
│ $surfaceSecondary bubble, avatar on first message of a run, sender name
│ above bubble (group only), timestamp on the run's last message
└─ ◇ persistent bottom bar (NOT BottomCtaBand)
├─ ▸ isAccepted (or group, always accepted) → [ComposeBar] image picker +
│ text input + circular send
└─ ▸ else → [RequestGateBar] status=pending|declined —
recipient sees 수락/거절, initiator sees "waiting", declined is informational
[ParticipantsModal] (ModalPanel, group only) — member list + role badges,
rename (admin, useRenameGroupThread), remove member (admin, confirm), 나가기 (leave, all)
[ModalPanel] per-message long-press action sheet — quick-reaction emoji row,
delete (own message, <60s old), report (others' message)
[ReportModal] shared by message long-press + header-menu report pathsStates.
- Loading:
SkeletonCardwhile bothuseDmMessagesanduseDmLatestMessagesare in flight. - Blocking error:
QueryErrorStatereplaces the whole body only when a fetch/poll fails AND zero messages exist on screen. - Non-blocking error: a compact
QueryErrorStatebanner above the list when a poll/refetch fails but messages are already visible — pull-to-refresh or its inline retry, thread stays open. isAccepted(or any group thread) →ComposeBar; otherwiseRequestGateBarwith recipient/initiator/declined sub-states.- 1:1 vs group: different header menu (
HeaderActionsMenuvsMoreHorizontal→ParticipantsModal), different message-row chrome (sender name,AvatarStack). - Per-message action sheet: delete row only for own messages sent <60s ago; report row only for others' messages.
- Pagination: older messages load on scroll-to-top via
onStartReached. - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via an earlyOfflineEmptyStatereturn — never the empty state (2026-09-11 offline sweep). - Missing record: settled but no thread row (bad/inaccessible threadId) →
NotFoundStatein place of the message list, and neitherComposeBarnorRequestGateBarrenders (2026-09-16 bug-class fix —isAccepteddefaults totruewhenthreadis null, so the ComposeBar used to stay live over a generic title + the same empty-conversation state a real thread with no messages shows).
Actions.
- Header back →
appRouter.back(). - 1:1 header menu → 신고 (
ReportModal) / 차단 (confirm →blockUsermutation → back). - Group header
MoreHorizontal→ opensParticipantsModal. ComposeBar: text send, image pick+send.RequestGateBar: 수락/거절 (recipient) →decideRequestmutation.- Message long-press → action sheet: quick-reaction tap, delete (own, <60s), report (others').
ParticipantsModal: rename (admin), remove member (admin, confirm), invite →routes.dmAddMembers(threadId), leave (confirm).- Pull-to-refresh / inline retry on the non-blocking error banner.
Gaps. none found.
Notes. 2026-09-17: composer photo pick routes through usePickImages (one permission dialog, resize) — no visual change.
DM Add Members (멤버 초대)
- Route:
/messages/add-members - Screen:
DmAddMembersScreen—packages/features/messaging/src/dm-add-members-screen.tsx - Shell: DetailShell · title
t().dm.addMembersTitle("멤버 초대") · scroll=false - Design: unreviewed
- Verified: 2026-09-16
Purpose. Lets an existing group-thread member invite more of their friends into that group.
Anatomy.
├─ [DetailShell] title=멤버 초대 onBack scroll=false
├─ [FeedList] perfLabel=dm-add-members contentContainerStyle=SCROLL_CONTENT.railedBanded
│ ├─ header → [Text] addMembersSubtitle + [SectionHeader] title=membersLabel
│ ├─ item → [FriendPickerRow] avatar + name + [SelectionCheck circle] per candidate
│ ├─ ▸ loading → 3×[SkeletonRow]
│ ├─ ▸ error → [QueryErrorState] onRetry=friendsQuery.refetch
│ └─ ▸ empty → [EmptyState] compact ✦ 친구 찾기 → routes.profileFriendsAdd
└─ [BottomCtaBand]
└─ [ActionButton primary lg] addMembersConfirm(selected.size)
disabled ▸ selected.size===0 or addMembers.isPendingStates.
- Candidates = the viewer's friends (
useFriends) minus the thread's current participants (useDmThreadParticipants). - Loading: 3×
SkeletonRowwhile the friends query loads. - Error:
QueryErrorStateon friends-fetch failure. - Empty: zero eligible candidates (all friends already in the thread, or no friends) →
EmptyState. - CTA disabled while selection is empty or the mutation is pending.
- Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+offlineState— never the empty state (2026-09-11 offline sweep).
Actions.
- Row tap → toggle selection (
FriendPickerRow'sSelectionCheck). - BottomCtaBand CTA →
add_thread_participantsmutation with selecteduserIds→appRouter.back()on success. - Empty-state action →
routes.profileFriendsAdd. - Back →
appRouter.back().
Gaps. none found.
DM Inbox (메시지)
- Route:
/messages - Screen:
DmInboxScreen—packages/features/messaging/src/dm-inbox-screen.tsx - Shell: DetailShell · title
t().dm.inboxTitle("메시지") · scroll=false · headerRight:HeaderIconButton(MessageCircle) →routes.dmCreateGroup() - Design: unreviewed
- Verified: 2026-09-16
Purpose. The user's DM conversation list, split into 주요 (accepted threads) and 요청 (pending requests needing accept/decline), with an entry point to start a new group chat.
Anatomy.
├─ [DetailShell] title=메시지 onBack scroll=false
│ headerRight=[HeaderIconButton MessageCircle] → routes.dmCreateGroup()
└─ [YStack paddingTop=$4]
└─ [SegmentedTabs] items=[주요, 요청(badge=pendingCount, badgeTone=live)] preload=all
renderTab(tab) →
├─ 'primary' → [PrimaryTab] — [FeedList] infinite
│ data=accepted threads (useDmThreadsByStatus 'accepted'), blocked 1:1 users filtered out
│ item → [DmThreadRow] unreadCount, onPressIn=prefetchDmThread → routes.dmThread(id)
│ ▸ loading → 3×[SkeletonRow]
│ ▸ empty → [EmptyState] compact icon=MessageSquare title=noMessages
│ ▸ error → [QueryErrorState] onRetry=refetch
└─ 'requests' → [RequestsTab] — [FeedList] infinite
data=pending threads (useDmThreadsByStatus 'pending'), blocked 1:1 users filtered out
item → [DmThreadRow isRequest] — inline 수락/거절 buttons, non-navigating row
▸ loading → 2×[SkeletonRow]
▸ empty → [EmptyState] compact icon=Inbox title=noRequests
▸ error → [QueryErrorState] onRetry=refetchStates.
- 주요 tab: loading (3×
SkeletonRow) / empty (EmptyStateicon=MessageSquare) / populated infinite list. - 요청 tab: loading (2×
SkeletonRow) / empty (EmptyStateicon=Inbox) / populated infinite list. - 요청 badge hidden when
pendingCountis 0, shown (capped at 99) otherwise,badgeTone="live". - Both tabs filter out threads whose other 1:1 participant is on the viewer's blocked list (
useBlockedUsers) — group threads are never filtered by block state. - Both tabs: error (
QueryErrorState, retry); pull-to-refresh; infinite-scroll load-more. - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+offlineState— never the empty state (2026-09-11 offline sweep).
Actions.
HeaderIconButton(MessageCircle) →routes.dmCreateGroup().- Tab switch 주요/요청 → local
setTab. - Thread row tap (주요 only — 요청 rows don't navigate) →
routes.dmThread(threadId); press-in prefetches viaprefetchDmThread. - 요청 row inline 수락/거절 →
useDecideDmRequest(owned insideDmThreadRowitself, shared with the club chat pane). - Pull-to-refresh; infinite scroll load-more per tab.
Gaps. none found.
DM Create Group (단톡방 만들기)
- Route:
/messages/new-group - Screen:
DmCreateGroupScreen—packages/features/messaging/src/dm-create-group-screen.tsx - Shell: DetailShell · title
t().dm.newGroup("단톡방 만들기") · scroll=false - Design: unreviewed
- Verified: 2026-09-16
Purpose. Create a new group chat — name it and pick members, either pre-seeded from a session/club context (all pre-selected) or from the viewer's friends when opened bare from the inbox "+".
Anatomy.
├─ [DetailShell] title=단톡방 만들기 onBack scroll=false
├─ [FeedList] perfLabel=dm-create-group contentContainerStyle=SCROLL_CONTENT.railedBanded
│ ├─ header → [LabeledField groupNameLabel][StyledInput maxLength=50] +
│ │ [SectionHeader title=membersLabel]
│ ├─ item → [FriendPickerRow] avatar + name + [SelectionCheck circle] per candidate
│ ├─ ▸ loading → 3×[SkeletonRow]
│ ├─ ▸ error → [QueryErrorState] onRetry=refetchCandidates
│ └─ ▸ empty → [EmptyState] compact ✦ 친구 찾기 → routes.profileFriendsAdd
└─ [BottomCtaBand]
└─ [ActionButton primary lg] createGroupCta(selected.size)
disabled ▸ title.trim()==='' or selected.size===0 or createGroup.isPendingStates.
- Candidate source: seeded
candidateIdsprop (session/club context, resolved viauseProfilesByIds) — all pre-selected on first load ("invite everyone" default, one-shot via aseededRefguard) — OR, with no seed, the viewer's friends (useFriends). - Loading/error reflect whichever candidate source is active (
isCandidatesLoading/isCandidatesErrorswitch onhasSeed). - Empty: zero candidates from either source →
EmptyState. - CTA disabled until
title.trim()is non-empty AND ≥1 member selected AND the mutation isn't pending. - Offline cold start (query paused, nothing fetched) →
OfflineEmptyStatewith retry, via the list'sisPausedOffline+offlineState— never the empty state (2026-09-11 offline sweep).
Actions.
- Group-name text input (
StyledInput, maxLength 50). - Row tap → toggle member selection.
- BottomCtaBand CTA →
create_group_threadmutation ({title, memberIds, contextSessionId}) →appRouter.replace(routes.dmThread(newThreadId))(replace, not push — so Back from the new thread returns to the inbox, not this form). - Empty-state action →
routes.profileFriendsAdd. - Back →
appRouter.back().
Gaps.
- No surface passes
contextSessionIdyet — the session-group entry point (dm.newGroupFromSession, 일정 단톡방 만들기) is unbuilt, so the server carve-out that admits non-friend co-participants is dormant client-side. Building that entry is an owner product decision (wireframe first).