Skip to content

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: DmThreadScreenpackages/features/messaging/src/dm-thread-screen.tsx
  • Shell: DetailShell · title = group title (or newGroup fallback) / other participant's displayName (or inboxTitle fallback) · subtitle = member count (group only) · titleLeading = AvatarStack (group, max 3) or AvatarBubble (1:1) · headerRight: group → HeaderIconButton(MoreHorizontal) opening ParticipantsModal; 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 paths

States.

  • Loading: SkeletonCard while both useDmMessages and useDmLatestMessages are in flight.
  • Blocking error: QueryErrorState replaces the whole body only when a fetch/poll fails AND zero messages exist on screen.
  • Non-blocking error: a compact QueryErrorState banner 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; otherwise RequestGateBar with recipient/initiator/declined sub-states.
  • 1:1 vs group: different header menu (HeaderActionsMenu vs MoreHorizontalParticipantsModal), 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) → OfflineEmptyState with retry, via an early OfflineEmptyState return — never the empty state (2026-09-11 offline sweep).
  • Missing record: settled but no thread row (bad/inaccessible threadId) → NotFoundState in place of the message list, and neither ComposeBar nor RequestGateBar renders (2026-09-16 bug-class fix — isAccepted defaults to true when thread is 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 → blockUser mutation → back).
  • Group header MoreHorizontal → opens ParticipantsModal.
  • ComposeBar: text send, image pick+send.
  • RequestGateBar: 수락/거절 (recipient) → decideRequest mutation.
  • 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: DmAddMembersScreenpackages/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.isPending

States.

  • Candidates = the viewer's friends (useFriends) minus the thread's current participants (useDmThreadParticipants).
  • Loading: 3×SkeletonRow while the friends query loads.
  • Error: QueryErrorState on 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) → OfflineEmptyState with retry, via the list's isPausedOffline + offlineState — never the empty state (2026-09-11 offline sweep).

Actions.

  • Row tap → toggle selection (FriendPickerRow's SelectionCheck).
  • BottomCtaBand CTA → add_thread_participants mutation with selected userIdsappRouter.back() on success.
  • Empty-state action → routes.profileFriendsAdd.
  • Back → appRouter.back().

Gaps. none found.


DM Inbox (메시지)

  • Route: /messages
  • Screen: DmInboxScreenpackages/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=refetch

States.

  • 주요 tab: loading (3×SkeletonRow) / empty (EmptyState icon=MessageSquare) / populated infinite list.
  • 요청 tab: loading (2×SkeletonRow) / empty (EmptyState icon=Inbox) / populated infinite list.
  • 요청 badge hidden when pendingCount is 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) → OfflineEmptyState with retry, via the list's isPausedOffline + 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 via prefetchDmThread.
  • 요청 row inline 수락/거절 → useDecideDmRequest (owned inside DmThreadRow itself, 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: DmCreateGroupScreenpackages/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.isPending

States.

  • Candidate source: seeded candidateIds prop (session/club context, resolved via useProfilesByIds) — all pre-selected on first load ("invite everyone" default, one-shot via a seededRef guard) — OR, with no seed, the viewer's friends (useFriends).
  • Loading/error reflect whichever candidate source is active (isCandidatesLoading/isCandidatesError switch on hasSeed).
  • 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) → OfflineEmptyState with retry, via the list's isPausedOffline + 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_thread mutation ({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 contextSessionId yet — 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).

Markdown remains the source of truth. Run yarn docs:check before handoff.