Files
simplex-chat/apps/ios/spec/client/chat-view.md
T
Evgeny @ SimpleX Chat 93e2207603 core, ui: report reason with rule text, ios gaps as in plain text (#8035)
* ui: references of the flat layout names, italic and paragraphs

* ui: what's new references with the crowdfunding link

* core, ui: report reason with rule text, ios gaps as in plain text

* ui: references of report reason, ios gaps and right-to-left text
2026-11-05 13:50:56 +00:00

45 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 (paragraphs, headings, lists, quotes, code blocks, tables, rules) are drawn as a column of views by MarkdownBlocksView; code blocks and tables scroll sideways. The core sends markdownBlocks for text with block syntax or an empty line, up to 2000 markup tokens. A report whose blocks have no text ranges (only rules) is drawn as one text: the reason, then the source text of the rules. In bubbles and outside chats, the gap of n empty lines is n × (font.lineHeight + font.leading), with one font.leading more after a block, as the same lines in one text, where the leading is added between lines only; the gap has zero width: the bubble is as wide as its widest block.

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. Gaps follow the paragraph unit terminalParagraphGap (user, terminal spacing mockup):

row padding        top: topGap
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
                   top-aligned with the name
                   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 in the body text style, semibold (primary for own items, secondary otherwise)
                   role label in the body text style, medium, secondary, 15pt after the name; line box of the body font
                   start padding chatItemTextPadding; below: terminalParagraphGap when the content starts with a card
                   (contentStartsWithCard: terminalItemStartsWithCard), otherwise 0
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, has another sender, or is terminalNameRepeatInterval (7.5 minutes) or more older. Same sender: channel as sender on both items, or CIDirection.sameDirection when neither item has it
terminalRowGap(item, older, separated, largeGap, hasSenderRow) terminalParagraphGap when the older item exists, the rows are not separated, and one holds: large gap (getItemSeparation: another sender, or more than 60 seconds apart), sender row, either item not one-line, the older item has reactions. Otherwise 0
ChatItem.isOneLine Text at the item top (textAtItemTop: text content without a quote or header, hasQuoteOrHeader), no block markdown, no line break in the text; a large emoji is one-line; reactions do not count. Items without a sender (events) are not one-line
terminalItemStartsWithCard(item, revealed) The first element of the item is a card: a marked deleted item drawn as a card; a quote without a report, deleted or live header above it (hasHeaderAboveQuote). Without a quote or header: content other than a message, except calls (deleted, error and group invitation cards, invalid data); media (terminalMediaCard: image, video, voice, file, link preview, chat link); text starting with a code block, table or rule (MarkdownBlock.isCard); a large emoji (text content, or FramedItemView.showsEmoji)
terminalCardTopGap(item) terminalParagraphGap above media, voice, file and emoji when a quote or header is above them (hasQuoteOrHeader), otherwise 0
terminalCardBottomGap(item) terminalParagraphGap below a card that ends the item when reactions follow, otherwise 0
terminalTextTopGap(item) Above the text of FramedItemView: 0 at the item top; terminalParagraphGap below a quote or media, or when the text starts with a code block, table or rule (0 when the text starts with empty lines, which are at least as high: startsWithEmptyLine; block markdown: leadingEmptyLines; other text: a line break at its start, except in reports, whose reason is drawn first); terminalLevelStep below a header when the text starts with a heading; otherwise 0
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.

A visible cell is rebuilt when the hash of its merged item changes (ListItem.genHash). The hash of a single item includes whether the older item (nextItem) has reactions and whether it is one-line, so the cell is rebuilt, and terminalRowGap computed again, when the first reaction is added to the older item, its last reaction is removed, or an edit adds or removes its line break or block markdown. The gap of a grouped row does not depend on the older item's reactions or lines: its newest item is a deleted item or an event (mergeCategory), which is not one-line.

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; no spacing above or below, as a line of text
                   (classic: 3pt above, 4pt below); below a card that ends the item: terminalCardBottomGap of the card
                   the next row: terminalParagraphGap above it (terminalRowGap)

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%; above: terminalParagraphGap below a report, deleted or live
                      header (hasHeaderAboveQuote), otherwise 0; below: none, the content below it has the gap
                      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)
                      media, voice, file and emoji: terminalCardTopGap above
                      captionless media, chat link without text, voice and file without text (metaPlacement.gapBelowBlock),
                      emoji: terminalCardBottomGap below
                      text: terminalTextTopGap above
MsgContentView        line box terminalLineBox; meta line for live messages, and in the line box after empty lines at the text end
                      text ends with the transparent copy of the meta (reserveSpaceForMeta)
                      text without markdownBlocks (over 2000 markup tokens) keeps one full line per empty line
MarkdownBlocksView    all gaps have zero width: the column is as wide as its widest block
                      code block, table or rule next to another block (MarkdownBlock.isCard): terminalParagraphGap,
                      or the typed empty lines when they are higher
                      heading after another block: terminalLevelStep
                      block other than a heading after a heading: terminalHeadingBottomGap
                      each empty line typed before or between blocks and list entries: terminalParagraphGap added
                      empty lines after the last block: line breaks of the tokens after it, less one when the text does not end
                      with a line break (the core removes one final line break); terminalParagraphGap each, then the meta line
                      at most maxEmptyLines (100) empty lines in one gap (emptyLines)
                      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 (cardMargins): last block of a live message: terminalParagraphGap below,
                      above the typing line; otherwise no margins
                      last block of a message that is not live, without empty lines after it: 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: 0 above, terminalCardBottomGap below; MessageLastLine
                      FramedItemView: terminalCardTopGap above, terminalCardBottomGap below; MessageLastLine
voice                 CIVoiceView: received layout for both directions, 0 above, terminalCardBottomGap below
                      FramedCIVoiceView: terminalCardTopGap above, 0 below, player background
                      VoiceMessagePlayer circle in the quote colour (chatItemFrameContextColor); classic: the bubble colour
files                 CIFileView: 0 above, icon without top padding; FramedItemView: terminalCardTopGap above
events                terminalEventPadding: chatItemTextPadding at the sides, 0 above and below; 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, lastBlockGap, 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; lastBlockGap not applied

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: lastBlockGap below the content
                   (FramedItemView: terminalCardBottomGap)
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):

terminalParagraphGap        7.33     ⅓ of the line height at the 16sp body: 16 × 22/16 ÷ 3
terminalLevelStep           4.75
terminalHeadingBottomGap    2.375
terminalHeaderTopGap        2.5
terminalHeaderBottomGap     1.5
terminalMetaGap             6

terminalNameRepeatInterval: 7.5 minutes; after this pause the name is shown again (terminalItemShowsSender).

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, with the gaps of terminalRowGap and
                          terminalItemStartsWithCard; 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