Files
simplex-chat/apps/ios/spec/client/chat-view.md
T
Evgeny @ SimpleX ChatandEvgeny Poberezkin c3c733e0d4 ios, ui, ci: terminal chat layout in the iOS app (#7898)
* plan: UI improvements to improve LLM usage via SimpleX

* core, ui: types for chat layout

* core, ui: settings for alternative terminal-like chat layout for managing LLMs or other service bots

* plan: message priority

* fixes

* query plan

* update plans

* remove silent messages plan

* rename migrations

* implement layout

* amend

* fix

* fix

* fix

* improve

* time on the right, sender name in blue

* no avatar in direct chats

* better previews

* update, fixes

* update

* time gap

* changes

* ui: terminal layout line heights, gaps and time placement

* ui: simplify terminal meta layout, correct its docs and tests

* ui: right-to-left emoji edge in terminal rows, time placement tests

* android, desktop: raise Kotlin compiler memory to 4 GB

* ui: terminal captions as follow-up text, classic event separators

* ui: terminal media edge and overlay time, plan decisions

* ui: simplify terminal row separation, correct doc line references

* ui: keep the media time inside terminal rows, restore caption conditions

* ui: terminal heading capitals at large font scales, idle live message time

* ui: rename the classic voice-with-text flag, correct the meta copy docs

* ui: smaller terminal layout diff, sender gap and meta names

* ui: terminal row as tall as the media time

* docs: remove the chat layout section from the bots readme

* docs: shorten the terminal layout section of the chat view

* core: address settings API as on master, /auto_accept reads the stored layout

* ui: smaller terminal headings

* ui: classic sender rows and avatar rule as on master

* ui: classic reply quote text colour as on master

* ios: keep the chat layout setting with other UI settings

* ui: go-to button at the end of terminal rows, no gutter in direct chats

* ui: forwarded arrow on the header line of terminal rows, above the time

* ui: forwarded arrow centred above the time line of terminal rows

* ios: terminal chat layout, chosen per app, profile and chat

* ios: event time reserve of three spaces, app layout picker as Kotlin

* ios: spec and plan of the terminal chat layout

* ios: classic message text at 87% in dark themes, as terminal rows

* ios: terminal gaps scaled by the body size, 17/16 of Kotlin values

* ios: terminal quote sender at 13.5pt, thumbnail at the item end

* ios: product docs of the terminal layout, plans updated for iOS

* ios: chat scenes rendered to PNG by a unit test in the simulator CI

* ios: snapshots rendered by the smoke test script, workflow unchanged

* ios: terminal names at 13.5 x 17/16 pt, as the gaps

* ios: Markdown views at the 87% message colour in dark themes

* ios: terminal quote thumbnail 12pt after the quote text or the bar

* ios: terminal rule-only message widened by a wider quote or header

* ios: member avatars scaled with the text size, as in the chat list

* ios: snapshot rows at their own height, as list cells, so text wraps

* ios: member avatars back at 34pt, for the snapshot before scaling

* ios: member avatars scaled with the text size, as in the chat list

* ui: terminal quote thumbnail 12dp after the quote text or the bar

* ios: classic group bubble width follows the scaled member avatar

* ci: ios-sim README describes the snapshots rendered after the smoke test

* ios: scratch, padding variants for the I7 and I8 renders, to revert

* ios: revert the scratch padding variants after their renders

* ui: contrast of the voice message button in terminal rows

* ios: scratch, spacing variants and list margins to render, to revert

* ios: revert the scratch spacing variants after their renders

* ios: snapshots at a fixed date, in UTC and the en_US locale

* ios: snapshots drawn as 8-bit sRGB without alpha

* ci: compare iOS snapshots with the references in the repository

* ci: record and check iOS snapshot references on GitHub's runner

* ios: scratch, list margins on the remaining screens, to revert

* ios: revert the scratch list margins after their measurement

* ci: snapshot scripts executable, as the other CI scripts

* ios: scratch, voice circle variants in light themes, to revert

* ios: revert the scratch voice circle variants

* ios: one 12pt padding at the edges and after avatars in terminal rows

* ios: classic group sender name on the bubble edge

* ui: one 12dp padding at the edges and after avatars in terminal rows

* ios: one heading scale in both chat layouts

* ios: snapshot references of the chat scenes

* docs: leave the headings and classic lines of the plan to ep/classic-bubbles

* ios: declare terminal layout functions inside types

* ios: keep layout constants at file scope

* ios: keep file-scope functions, move only extension members into types

* ios: snapshot tests record row heights at first layout and the appearance layout switch

* ios: appearance preview follows the chat layout setting at once

* ios: snapshot test measures first-layout rows by SwiftUI and by cells

* ios: chat rows measured at the chat width when they first appear

* ios: snapshot test checks that chat rows keep their first height after a reload

* ios, ci: references of terminal and classic chat scenes, dark message text

* ios: message text at 95% in dark themes

* ios, ci: references with message text at 95% in dark themes

* ios: media in terminal rows end at the time's right inset

* ios, ci: references with terminal media at the time's right inset

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2026-10-29 12:45:34 +00:00

40 KiB
Raw Blame History

SimpleX Chat iOS -- Chat View Module

Technical specification for the message rendering, chat item types, and context menu actions in the conversation view.

Related specs: Compose Module | State Management | API Reference | README Related product: Chat View

Source: ChatView.swift | ChatInfoView.swift | GroupChatInfoView.swift | ChannelMembersView.swift | ChannelRelaysView.swift


Table of Contents

  1. Overview
  2. ChatView
  3. ChatItemView -- Message Routing
  4. Message Renderers
  5. Media Views
  6. Metadata & Info
  7. Context Menu Actions
  8. Selection Mode
  9. Terminal Layout

1. Overview

The chat view module renders individual conversations. It consists of:

  • ChatView -- The main conversation screen with message list, compose bar, and navigation
  • ChatItemView -- Router that dispatches each chat item to the appropriate renderer
  • Specialized renderers -- FramedItemView (standard messages), EmojiItemView (emoji-only), CICallItemView (calls), event views, etc.
  • Media views -- CIImageView, CIVideoView, CIVoiceView, CIFileView for attachments
ChatView
├── Message List (ScrollView / LazyVStack)
│   ├── ChatItemView (per message)
│   │   ├── FramedItemView (text/media bubbles)
│   │   │   ├── MsgContentView (text with markdown)
│   │   │   ├── CIImageView / CIVideoView / CIVoiceView
│   │   │   └── CIMetaView (timestamp, status)
│   │   ├── EmojiItemView (emoji-only messages)
│   │   ├── CICallItemView (call events)
│   │   ├── CIEventView (system events)
│   │   ├── CIGroupInvitationView (group invitations)
│   │   ├── DeletedItemView / MarkedDeletedItemView
│   │   └── CIInvalidJSONView (decode errors)
│   └── ... (more items)
├── ComposeView (message input)
└── Navigation bar (contact/group info)

2. ChatView

File: Shared/Views/Chat/ChatView.swift

The main conversation view. Key responsibilities:

State

  • Uses ItemsModel.shared.reversedChatItems for the primary message list
  • ChatModel.shared.chatId identifies the active conversation
  • Manages compose state, scroll position, keyboard visibility
  • Tracks selection mode for multi-message actions

Message List

  • Renders messages in a ScrollViewReader with LazyVStack
  • Items are in reverse chronological order (newest at bottom)
  • Supports infinite scroll: preloads older messages when scrolling up via ItemsModel.preloadState
  • Handles pagination splits (chatState.splits) for non-contiguous loaded ranges

Navigation Bar

  • Title: contact name / group name with connection status indicator
  • Trailing button: navigates to ChatInfoView (direct) or GroupChatInfoView (group)
  • Search button: toggles in-chat message search

Scroll Behavior

  • Auto-scrolls to bottom on new sent/received messages (if already near bottom)
  • "Scroll to bottom" floating button when scrolled up
  • openAroundItemId support: scrolls to a specific message (e.g., from search or notification)

Key Functions

Function Line Description
body L75 Main view body
initChatView() L660 Initializes chat view state on appear
chatItemsList() L817 Builds the scrollable message list
scrollToItem(_:) L731 Scrolls to a specific message by ID
searchToolbar() L765 In-chat search toolbar UI
searchTextChanged(_:) L1095 Handles search query changes
loadChatItems(_:_:) L1531 Loads chat items with pagination
filtered(_:) L803 Filters items by content type
callButton(_:_:imageName:) L1273 Audio/video call toolbar button
searchButton() L1293 Search toggle toolbar button
addMembersButton() L1361 Group add-members toolbar button
forwardSelectedMessages() L1420 Forwards batch-selected messages
deletedSelectedMessages() L1411 Deletes batch-selected messages
onChatItemsUpdated() L1572 Reacts to chat items model changes
contentFilterMenu(withLabel:) L1301 Content filter dropdown menu

Supporting Types

Type Line Description
ChatItemWithMenu L1600 Wraps each chat item with context menu
FloatingButtonModel L2787 Manages scroll-to-bottom button state
ReactionContextMenu L2974 Reaction picker context menu
ToggleNtfsButton L3072 Mute/unmute notifications button
ContentFilter L3124 Enum for message content filter types
deleteMessages() L2870 Deletes messages with confirmation
archiveReports() L2917 Archives report messages

3. ChatItemView

File: Shared/Views/Chat/ChatItemView.swift

Routes each ChatItem to the appropriate renderer based on its CIContent type:

Content Types (CIContent enum)

Content Type Renderer Line Description
sndMsgContent / rcvMsgContent FramedItemView L14 Standard sent/received text+media message
sndDeleted / rcvDeleted DeletedItemView L14 Locally deleted message placeholder
sndCall / rcvCall CICallItemView L13 Call event (missed, ended, duration)
rcvIntegrityError IntegrityErrorItemView L14 Message integrity error
rcvDecryptionError CIRcvDecryptionError L16 Decryption failure
sndGroupInvitation / rcvGroupInvitation CIGroupInvitationView L14 Group invite
sndGroupEvent / rcvGroupEvent CIEventView L14 Group system event
rcvConnEvent / sndConnEvent CIEventView L14 Connection event
rcvChatFeature / sndChatFeature CIChatFeatureView L14 Feature toggle event
rcvChatPreference / sndChatPreference CIFeaturePreferenceView L14 Preference change
invalidJSON CIInvalidJSONView L14 Failed to decode

Bubble Direction

  • Sent messages: aligned right, sender-colored bubble
  • Received messages: aligned left, receiver-colored bubble
  • Events/system messages: centered, no bubble

Appearance Dependencies

Each ChatItemWithMenu may depend on the previous and next items for visual decisions:

  • Whether to show the sender name (group messages, different sender than previous)
  • Whether to show the tail on the bubble (last consecutive message from same sender)
  • Date separator between messages on different days

ChatItemDummyModel.shared.sendUpdate() forces a re-render of all items when global appearance changes.

Channel Message Rendering (.channelRcv)

Channel messages (CIDirection.channelRcv) are rendered with the group avatar and group name as sender, with "channel" as the role label. This mirrors the .groupRcv path's showGroupAsSender visual but uses a dedicated code branch in chatItemListView().

Key differences from .groupRcv:

  • No prevMember/memCount logic — channels have no per-member identity
  • Always shows group avatar (via ProfileImage with groupInfo.image / groupInfo.chatIconName)
  • Tapping avatar opens showChatInfoSheet (not member info)
  • shouldShowAvatar() treats consecutive .channelRcv items as same sender
  • getItemSeparation() treats consecutive .channelRcv items as sameMemberAndDirection
  • showMemberImage() returns true when previous item is .channelRcv (different sender type)
  • memberToModerate() returns nil for .channelRcv (no per-member moderation)

4. Message Renderers

FramedItemView

File: Shared/Views/Chat/ChatItem/FramedItemView.swift

The standard message bubble. Renders:

  • Quote/reply preview (if replying to another message)
  • Forwarded indicator
  • Sender name (in groups)
  • Message content (MsgContentView with markdown)
  • Attached media (image, video, voice, file, link preview)
  • Reaction summary bar
  • Metadata line (CIMetaView)

EmojiItemView

File: Shared/Views/Chat/ChatItem/EmojiItemView.swift

Renders emoji-only messages (messages containing only emoji characters) in a larger font without a bubble background.

MsgContentView

File: Shared/Views/Chat/ChatItem/MsgContentView.swift

Renders message text with SimpleX markdown formatting (bold, italic, code, links, mentions). Blocks of markdownBlocks (headings, lists, quotes, code blocks, tables, rules) are drawn as a column of views by MarkdownBlocksView; code blocks and tables scroll sideways.

Text colour: textColor, else messageTextColor(theme): onSurface, at 95% in dark themes. FramedItemView passes onSurface for quote text (terminal rows: 70%). MarkdownView (chat item info, group welcome message, profile description) draws its text in messageTextColor(theme).

DeletedItemView / MarkedDeletedItemView

Files: Shared/Views/Chat/ChatItem/DeletedItemView.swift | Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift

CIEventView

File: Shared/Views/Chat/ChatItem/CIEventView.swift

Centered system event text for group events (member joined, left, role changed) and connection events.

CIGroupInvitationView

File: Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift

Renders group invitation with accept/reject buttons.


5. Media Views

CIImageView

File: Shared/Views/Chat/ChatItem/CIImageView.swift

Renders inline images. Tapping opens FullScreenMediaView for zooming/panning. Images are compressed to MAX_IMAGE_SIZE (255KB) before sending.

CIVideoView

File: Shared/Views/Chat/ChatItem/CIVideoView.swift

Renders video thumbnails with play button. Tapping opens video player. Videos above auto-receive threshold require manual download.

CIVoiceView / FramedCIVoiceView

Files: Shared/Views/Chat/ChatItem/CIVoiceView.swift | Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift

Renders voice messages with waveform visualization, play/pause control, and duration. FramedCIVoiceView is the version inside a message bubble with additional context.

CIFileView

File: Shared/Views/Chat/ChatItem/CIFileView.swift

Renders file attachments with filename, size, and download/open actions. Shows transfer progress during upload/download.

MarkdownFileView

File: Shared/Views/Chat/ChatItem/MarkdownFileView.swift

Sheet with a viewer of Markdown files up to MAX_MARKDOWN_VIEW_SIZE (1 MB), formatted or as text, with Save. Opened by a tap on the file item, or when reception completes after the tap that started it.

CILinkView

File: Shared/Views/Chat/ChatItem/CILinkView.swift

Renders link preview cards with OpenGraph metadata (title, description, image).

AnimatedImageView

File: Shared/Views/Chat/ChatItem/AnimatedImageView.swift

Renders animated GIF images.

FullScreenMediaView

File: Shared/Views/Chat/ChatItem/FullScreenMediaView.swift

Full-screen media viewer with zoom, pan, and share actions. Supports images and videos.


6. Metadata & Info

CIMetaView

File: Shared/Views/Chat/ChatItem/CIMetaView.swift

Displays message metadata inline at the bottom of the bubble:

  • Timestamp (sent time)
  • Delivery status icon (sending, sent, delivered, read, error)
  • Edit indicator (pencil icon if message was edited)
  • Disappearing message timer (if timed message)

ChatItemInfoView

File: Shared/Views/Chat/ChatItemInfoView.swift

Detailed message information sheet (accessed via long-press menu "Info"):

  • Full delivery history (per-member delivery status in groups)
  • Edit history (all previous versions of edited messages)
  • Forward chain info
  • Message timestamps (created, updated, deleted)
  • When the file expires, and below it, for a user without a badge, a button opening the badges sheet

7. Context Menu Actions

Long-pressing a message shows a context menu with actions based on message type and ownership:

Action Available For API Command
Reply All messages Sets compose state to .replying
Forward Sent/received content messages apiForwardChatItems
Copy Text messages Copies to clipboard
Edit Own sent messages (within edit window) apiUpdateChatItem
Delete for me All messages apiDeleteChatItem(mode: .cidmInternal)
Delete for everyone Own sent messages apiDeleteChatItem(mode: .cidmBroadcast)
Moderate Group admin/owner for others' messages apiDeleteMemberChatItem
React Content messages (if reactions enabled) apiChatItemReaction
Select All messages Enters multi-select mode
Info All messages Opens ChatItemInfoView
Save Media messages Saves to photo library / files
Share Content messages iOS share sheet

8. Selection Mode

Multi-selection mode allows batch operations on messages:

  • Enter via long-press "Select" action
  • Toggle individual messages with tap
  • Toolbar appears with batch actions: Delete, Forward
  • Exit via cancel button or completing batch action

9. Terminal Layout

File: Shared/Views/Chat/ChatItem/TerminalItemView.swift

Rendering of a chat whose ChatInfo.activeChatLayout(profileChatLayout(currentUser)) is .terminal: full-width start-aligned rows, messages without bubbles, the sender name once per group of messages, avatars in groups only. Reference: apps/multiplatform/spec/client/chat-view.md, sections 4 and 10.

Activation

ChatView.terminal            chat.chatInfo.activeChatLayout(profileChatLayout(currentUser)) == .terminal
cells                        .environment(\.terminalLayout, terminal)
layout change                scrollView.updateItems(items, true): visible cells rebuilt
background                   wallpaper background colour; image omitted

EnvironmentValues.terminalLayout is false outside the chat cells and the theme screens: chat item info and the chat list are drawn classic.

Layout resolution, in ChatTypes.swift and ChatModel.swift:

ChatInfo.activeChatLayout(profileLayout)    chat uiThemes.chatLayout, else defaultChatLayout(profileLayout)
ChatInfo.defaultChatLayout(profileLayout)   ChatInfo.chatLayout (contact or group profile), else profileLayout
profileChatLayout(user)                     user uiThemes.chatLayout, else chatLayoutDefault (app setting), else classic
unknown values                              skipped

Rows

ChatItemWithMenu.terminalItemRow wraps chatItemWithMenu in TerminalChatItemRow:

row padding        top: topGap, plus terminalSenderTopGap with a sender row
gutter             groups and channels: memberImageSize + chatItemTextPadding, with the sender avatar chatItemTextPadding from the start; other chats: none
                   one 12pt padding (user, I26): screen edge to the text or the avatar, avatar to the text, time to the screen edge
avatar             memberImageSize: scaledMemberImageSize, 34pt × chat list profileImageSize ÷ 63 (dynamicSize at the text size, at most 75); classic rows too
                   classic group bubbles: maximum width less the avatar column, scaledMemberImageSize + 8pt
                   classic sender name: memberImageSize + 10pt after the avatar's start, on the bubble edge (user, I23)
sender row         name and badge at terminalNameSize, 13.5 × 17/16 pt (primary for own items, secondary otherwise); role label 15pt after the name
                   start padding chatItemTextPadding; terminalSenderBottomGap below
selection          SelectedChatItem 8pt from the start; row moved by 28pt; selected row tint primary 10%
media width        classic maxWidth, at most listWidth less the selection offset, the gutter and two text edges: media end at the time's end padding
bottom padding     newest row: 8pt; above a date separator: terminalLevelStep; otherwise 0
Function Rule
terminalItemShowsSender(item, older) Item with a sender (hasTerminalSender), and the older item is absent, has no sender or has another sender. Same sender: channel as sender on both items, or CIDirection.sameDirection when neither item has it
terminalRowGap(item, older, separated, largeGap, hasSenderRow) terminalLevelStep when the older item exists, the rows are not separated, and one holds: large gap, sender row, either item without a sender, text at the item top starting with a heading. Otherwise 0
terminalHeadingGap(item) terminalLevelStep above a heading that starts the text below a quote, header or block
terminalRowsSeparated(olderRow) The newest item of the older row is the chat banner, or its getItemSeparation has a date (against its newer item only for single and revealed rows)
chatItemSender(ci, range) Channel as sender: group name, role "channel", group image. Received direct: contact name and badge. Group member: memberNames, badge and role of a single member, member image (tap: member info). Own group items: membership name, badge, role, image. Other own items: user name and badge, or "Incognito"
CIContent.hasTerminalSender Sent messages, deleted and moderated items, calls and group invitations; received content with showMemberName

ChatView passes olderRow, the next merged item. MergedItem.revealed: true for .single, the flag of .grouped.

Items

chatItemWithMenu:

alignment          start for both directions (alignEnd = sent && !terminal); maximum width: the row
clip               ChatItemClipped skipped; its tail disabled in terminal rows
go-to button       search results and marked deleted items: after the item
                   forwarded items: endButton of FramedItemView, placed by ItemWithMeta
reactions          chatItemTextPadding − 6pt from the start

Item views start at the text edge chatItemTextPadding (12pt):

FramedItemView        background, quote background and header backgrounds omitted
                      headers: terminalHeaderTopGap above, terminalHeaderBottomGap below
                      quote: quoteBar(), text onSurface 70%, terminalBlockTopGap above, terminalBlockBottomGap below
                      quote sender: terminalNameSize (13.5 × 17/16 pt, Dynamic Type), no spacing above the text
                      quote thumbnail: 12pt after the quote text; a quote without text and sender: 12pt after the bar (quotedMsgOrGap)
                      quote thumbnail end padding: chatItemTextPadding, as the time's
                      quote thumbnails and media: message corner clip (msgRectMaxRadius × roundness)
                      link preview and chat link cards: quote colour, message corner clip (terminalBlock)
                      link preview card: at most maxWidth wide
                      media width limit for captionless media only (mediaLimit)
                      captionless media, chat link without text: terminalBlockBottomGap below (metaPlacement)
MsgContentView        line box terminalLineBox; meta line for live messages only
                      text ends with the transparent copy of the meta (reserveSpaceForMeta)
MarkdownBlocksView    paragraph break, heading after another block: terminalLevelStep
                      block other than a heading after a heading: terminalHeadingBottomGap
                      heading size: body × 1.25^max(0, 3 − level), bold (terminalHeadingFont); classic chat items too (chatHeadings, user)
                      item info, welcome message and profile description: body × 1.5, 1.3, 1.15 for levels 1–3 (headingFont)
                      code, tables and rules: terminalBlockTopGap above, terminalBlockBottomGap below (cardMargins)
                      last block of a message that is not live: no bottom margin, MessageLastBlock
                      rules of a rule-only message: as wide as the copy of the meta, or the item less its text edges
                      when a quote or header is wider (ruleMinWidth: msgWidth less 24pt; terminalQuote reports its full width)
emoji                 EmojiItemView and FramedItemView: terminalEmojiTopGap, terminalEmojiBottomGap, MessageLastLine
voice                 CIVoiceView: received layout for both directions, terminalGap(4) above, terminalGap(6) below
                      FramedCIVoiceView: terminalGap(4) above, terminalGap(6) below, player background
                      VoiceMessagePlayer circle in the quote colour (chatItemFrameContextColor); classic: the bubble colour
files                 CIFileView: terminalFileTopGap above, icon without top padding
events                terminalEventPadding, line box, line limit removed
                      feature and preference icons without height; merged feature icons one event line high
cards                 DeletedItemView, MarkedDeletedItemView, CIMsgError, CIRcvDecryptionError, CIGroupInvitationView:
                      ChatItemClipped(tailVisible: false), time beside the card

Time and Status

ItemWithMeta(showMeta, metaReserved, overlayMeta, endButton, meta, content):

classic            content(meta()): the meta drawn inside the item, as before
terminal           content(nil); the meta in a box with 12pt end padding and the caption line box
iOS 16             TerminalMetaLayout
iOS 15             HStack(alignment: .lastTextBaseline): content, end button, meta

TerminalMetaLayout:

content width      row width less the wider of: the end button; the meta, without metaReserved and overlayMeta
meta baseline      on the last text baseline of the content
meta below         content reports MessageLastLine.right and its right edge plus the meta width exceeds the row:
                   terminalMetaGap under the content
no baseline        meta ends terminalMetaGap above the content bottom
last block         MessageLastBlock.bottom at the content bottom, meta beside it: terminalBlockBottomGap below the content
overlayMeta        content and meta wider than the row: white meta over the media corner, terminalMetaGap above its bottom
end button         row end, centred between the content top and the meta top (overlayMeta: on the content)
row height         at least the meta height

metaReserved: content with its own reserve for the time: text with the copy, files, emoji, events.

ChatEventWithMeta(chatItem) { ts in } passes the end of the event text, placed after the classic separator: classic chatItem.timestampText; terminal metaCopyJoiner, two no-break spaces and the transparent unbreakable time in regular weight.

Meta copy, ciMetaText(unbreakable: true):

separators         no-break spaces
icons              word joiner after the edit, timer and via-proxy icons
time               no-break space inside
prefix             metaCopyJoiner(rightToLeft): U+2060, then U+200E or U+200F; three no-break spaces
content            file encryption and signature included (MsgContentView encrypted, signedFileVerified)

Spacing and Line Heights

Constants at the top of TerminalItemView.swift: the Kotlin values in sp at its 16sp body, converted by terminalGap, value × body point size ÷ 16 (17pt body by default; Dynamic Type included):

terminalBlockTopGap         5.75
terminalBlockBottomGap      5
terminalLevelStep           4.75
terminalHeadingBottomGap    2.375
terminalSenderTopGap        6.5
terminalSenderBottomGap     5.5
terminalHeaderTopGap        2.5
terminalHeaderBottomGap     1.5
terminalEventTopGap         8.25
terminalEventBottomGap      1.5
terminalFileTopGap          2
terminalEmojiTopGap         4.25
terminalEmojiBottomGap      1.5
terminalMetaGap             6

terminalLineBox(font, color, theme, capsOf:), a TerminalLineBox view modifier:

line height        font size × 22/16 × f(w)
f(w)               1 − 0.1515 × (1 − w); nil colour: 1
w                  L* contrast of the colour against the background ÷ that of messageTextColor, 0…1
extra height       split by ascent and descent: lineSpacing between lines, padding above and below
headings           capsOf body font: body line height scaled by the size ratio; capitals placed as body capitals
taps               handleTextTaps lays the text out with the same lineSpacing (withLineSpacing)

Theme Screens

Screen terminalLayout
Appearance: wallpaper presets profileChatLayout of the theme destination: app or current profile
Customize theme profileChatLayout(nil)
Profile theme profileChatLayout(currentUser)
Chat theme activeChatLayout(profileChatLayout(currentUser)) of the chat
ChatThemePreview          two TerminalChatItemRow rows: "alice", then "bob" as own; both layouts in one ZStack, one height
background                wallpaper background colour, image omitted; preset tiles drawn as colours
WallpaperPresetSelector   custom image tile hidden
WallpaperSetupView        image scale type, scale slider and wallpaper tint hidden

GroupChatInfoView — Channel Adaptations

When groupInfo.useRelays == true, GroupChatInfoView adapts its sections:

Section Structure (Channel)

Section Owner Subscriber
1. Links & Members Channel link (manage via GroupLinkView), Owners & subscribers Channel link (read-only QR from groupProfile.publicGroup?.groupLink), Owners
2. Profile & Welcome Edit channel profile, Welcome message Welcome message (if exists)
3. Theme & TTL Chat theme, Delete messages after Chat theme, Delete messages after
4. Actions Chat relays, Clear chat, Delete channel Chat relays, Clear chat, Leave channel

Hidden for channels: Member support, group reports, user support chat, send receipts, inline members list, group preferences.

Label Replacements

All "group" labels are replaced with "channel" equivalents via groupInfo.useRelays ? "Channel..." : ternary prepended before existing businessChat ternary. Affected: delete/leave buttons, delete/leave alerts, remove member alert, edit profile button, group link nav title. Channel link button uses a separate channelLinkButton() with hardcoded "Channel link" label.

channelMembersButton() → ChannelMembersView

Navigates to a dedicated members view with two sections:

  • Owners: current user (if owner) + members with memberRole >= .owner
  • Subscribers (admin+ only): members with memberRole < .owner

Member rows show profile image, display name (with verified shield), connection status, and role badge. Non-user rows link to GroupMemberInfoView.

Owner sees channelLinkButton() (navigates to GroupLinkView for full link management), guarded by groupInfo.isOwner && groupLink != nil — channel links can only be created during channel creation, not from the info view. A TODO marks the need for protocol changes to allow other owners to manage the same channel link. Non-owner sees read-only QR code displaying groupProfile.publicGroup?.groupLink via SimpleXLinkQRCode. apiGetGroupLink is skipped in onAppear for non-owner channels.

Groups use separate groupLinkButton() which supports both "Create group link" and "Group link" labels.

channelRelaysButton() → ChannelRelaysView

Navigates to relay list view with role-based branches:

  • Owner: loads [GroupRelay] via apiGetGroupRelays (owner-only API, guarded by assertUserGroupRole GROwner on backend). Joins with chatModel.groupMembers by groupMemberId for display names. Shows status indicators (colored circle + RelayStatus.text). When relayStatus == .rsRejected the indicator dot is red and the text reads "rejected", matching the connFailed/removed rendering.
  • Member: filters chatModel.groupMembers by .memberRole == .relay. Shows relay member display names only (no status data).
  • Add relay sheet: existingRelayIds excludes every chatRelayId present in groupRelays regardless of relayStatus, so an already-listed relay (including .rsInactive and .rsRejected) cannot be re-added from the sheet. This mirrors the backend gate at APIAddGroupRelays (existingRelayIds), which rejects duplicate chatRelayIds; operator must remove the relay first via the swipe action.

Relay Rejection Surface

When a relay operator runs /leave #channel, the relay sends x.grp.relay.reject over the owner-relay direct contact channel. Owner-side handling: the corresponding GroupRelay.relayStatus transitions RSInvited → RSRejected; the relay's GroupMember.memberStatus is set to .memLeft so the owner UI renders the rejected relay identically to one that explicitly ran /leave (.memRejected is reserved for the knocking-admission flow). The transition surfaces through CEvtGroupRelayUpdated. In GroupMemberInfoView, an additional Status: rejected by relay operator info row appears when groupRelay?.relayStatus == .rsRejected. The status is final on the owner side — clearable only by the relay operator running /group allow #<channel>, which has no owner-facing event.

Leave Button Logic

Sole channel owner cannot leave (only delete). Guard: members.filter({ $0.wrapped.memberRole == .owner && $0.wrapped.groupMemberId != groupInfo.membership.groupMemberId }).count > 0.


Source Files

File Path Line
Chat view Shared/Views/Chat/ChatView.swift L18
Item router Shared/Views/Chat/ChatItemView.swift L42
Framed bubble Shared/Views/Chat/ChatItem/FramedItemView.swift L14
Emoji message Shared/Views/Chat/ChatItem/EmojiItemView.swift L14
Image view Shared/Views/Chat/ChatItem/CIImageView.swift L14
Video view Shared/Views/Chat/ChatItem/CIVideoView.swift L16
Voice view Shared/Views/Chat/ChatItem/CIVoiceView.swift L14
File view Shared/Views/Chat/ChatItem/CIFileView.swift L14
Link preview Shared/Views/Chat/ChatItem/CILinkView.swift L14
Call event Shared/Views/Chat/ChatItem/CICallItemView.swift L13
Metadata Shared/Views/Chat/ChatItem/CIMetaView.swift L14
Message info Shared/Views/Chat/ChatItemInfoView.swift L13
System event Shared/Views/Chat/ChatItem/CIEventView.swift L14
Deleted placeholder Shared/Views/Chat/ChatItem/DeletedItemView.swift L14
Moderated placeholder Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift L14
Text content Shared/Views/Chat/ChatItem/MsgContentView.swift L28
Group invitation Shared/Views/Chat/ChatItem/CIGroupInvitationView.swift L14
Feature event Shared/Views/Chat/ChatItem/CIChatFeatureView.swift L14
Decryption error Shared/Views/Chat/ChatItem/CIRcvDecryptionError.swift L16
Integrity error Shared/Views/Chat/ChatItem/IntegrityErrorItemView.swift L14
Full-screen media Shared/Views/Chat/ChatItem/FullScreenMediaView.swift L16
Animated image Shared/Views/Chat/ChatItem/AnimatedImageView.swift L11
Framed voice Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift L16
Member contact Shared/Views/Chat/ChatItem/CIMemberCreatedContactView.swift L14
Terminal layout Shared/Views/Chat/ChatItem/TerminalItemView.swift L4
Channel members Shared/Views/Chat/Group/ChannelMembersView.swift L12
Channel relays Shared/Views/Chat/Group/ChannelRelaysView.swift L12