Files
simplex-chat/apps/multiplatform/spec/client/chat-view.md
T
Evgeny @ SimpleX ChatandEvgeny Poberezkin 1217fbea42 ui: even insets, line heights and time placement in classic bubbles (#7896)
* 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

* ui: even insets, line heights and exact time space in classic bubbles

* ui: draw the time once in classic decryption errors

* tests: two-line text in the bubble inset test

* ui: exact time space in classic group invitations

* docs: classic bubbles plan: results and open questions

* ui: the time alone moves to the next line in classic bubbles

* ui: file icon on the size-line baseline in classic bubbles

* ui: calls in classic chats as on master

* ui: headings at 1.5625, 1.25 and 1 of the body size in both chat layouts

* ui: 11sp text inset and 14dp sides in classic bubbles

* ui: tail space in classic deleted and error items

* ui: events in classic chats grouped in runs

* ui: group sender name at the bubble edge in classic chats

* docs: classic bubbles plan: results of the 2026-10-26 decisions

* ui: events classified by the sender rule, one file icon size

* tests: no invisible character in the bubble copy test

* docs: classic bubbles plan: the shared sender rule

* docs: product concepts: the renamed meta copy

* ui: inline the copy of the time in decryption errors

* docs: chat view spec: line links after the classic bubbles changes

* ui: group invitation time on the last baseline, joined to the last word

* docs: classic bubbles: tail and time of a message above an event run

* ui: time on the last line of wrapped deleted and error texts

* ui: one layout switch in deleted and error items

* ui: one layout switch in message text padding

* ui: one name for the chat typography flag

* ui: one check whether a chat item tail is drawn

* ui: event run gaps in the chat list, row separation as on master

* ui: chat item previews draw bubbles as the chat does

* docs: classic bubbles: item cards, tail check, previews

* ui: deleted and error items without tails, insets fixed at large fonts

* ui, ci: references of classic bubbles, chat view previews in bubbles

* ci: desktop references of classic bubbles with message text at 95%

* ui: time on the drawn text baseline in desktop bubbles

* ci: desktop references with the time on the drawn text baseline

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2026-10-29 14:35:23 +00:00

60 KiB
Raw Blame History

Chat View Specification

Source: common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt


Table of Contents

  1. Overview
  2. ChatView Composable
  3. Message List
  4. ChatItemView
  5. Message Types
  6. Context Menu Actions
  7. ChatInfoView
  8. GroupChatInfoView
  9. Source Files
  10. Terminal Layout

Executive Summary

The Chat View is the primary message display and interaction surface in SimpleX Chat. It is built around the ChatView composable, which orchestrates a ChatLayout containing a reverse-scrolling LazyColumn of ChatItemView items and a ComposeView for message input. The view supports direct chats, group chats, local notes, and contact connections, with per-chat theming, search/filter, multi-select, and side-panel info modals. Message rendering is delegated to type-specific composables in the views/chat/item/ package.


1. Overview

ChatView
|-- ChatLayout
|   |-- ChatInfoToolbar            (top/bottom app bar with back, title, call, search, menu)
|   |-- SupportChatsCountToolbar   (reports/support banner, group only)
|   |-- ChatItemsList              (LazyColumnWithScrollBar, reverse layout)
|   |   |-- ChatViewListItem
|   |   |   |-- DateSeparator
|   |   |   |-- MemberNameAndRole  (group received messages)
|   |   |   |-- MemberImage        (group received messages)
|   |   |   |-- ChatItemView       (message type routing)
|   |   |   +-- TerminalChatItemRow (terminal layout, replaces the three lines above)
|   |   |       |-- sender row, avatar in groups
|   |   |       +-- ChatItemView
|   |   |-- ChatBannerView         (first item: chat profile banner)
|   |   +-- FloatingButtons        (scroll-to-bottom, unread counter)
|   |-- ComposeView               (message composition area)
|   |   |-- ContextItemView        (reply/edit/forward/report indicator)
|   |   |-- previewView            (link/media/voice/file preview)
|   |   +-- SendMsgView            (text input + send/voice/timed buttons)
|   |-- GroupMentions              (mention autocomplete popup)
|   |-- CommandsMenuView           (bot commands popup)
|   +-- ChooseAttachmentView       (bottom sheet for attachment type)
|-- ChatInfoView                   (contact info, end modal)
+-- GroupChatInfoView              (group management, end modal)

2. ChatView Composable

Location: ChatView.kt#L98

fun ChatView(
  chatsCtx: ChatModel.ChatsContext,
  staleChatId: State<String?>,
  scrollToItemId: MutableState<Long?>,
  onComposed: suspend (chatId: String) -> Unit
)

State Management

State Variable Type Purpose
showSearch MutableState<Boolean> Controls search bar visibility
searchText MutableState<String> Current search query text
composeState MutableState<ComposeState> Full compose area state (message, preview, context, mentions)
attachmentOption MutableState<AttachmentOption?> Selected attachment type from bottom sheet
selectedChatItems MutableState<Set<Long>?> Multi-select mode item IDs; null = selection off
showCommandsMenu MutableState<Boolean> Bot commands menu visibility
contentFilter MutableState<ContentFilter?> Active content type filter (images, videos, etc.)
availableContent MutableState<List<ContentFilter>> Content types available in this chat
activeChat State<Chat?> Derived from chatModel.chats matching staleChatId
unreadCount State<Int> Unread message count derived from chat stats

Chat Loading

On chat ID change (via snapshotFlow on chatModel.chatId.value):

  1. Marks unread chat as read (markUnreadChatAsRead)
  2. Clears group members state
  3. Resets search, content filter, and selection
  4. Fetches available content types (updateAvailableContent)
  5. For direct chats, loads contact info and connection stats
  6. For groups with pending membership, opens member support chat

Chat Type Routing

The outer when (chatInfo) branches:

ChatInfo Type Behavior
ChatInfo.Direct, ChatInfo.Group, ChatInfo.Local Full ChatLayout with compose, search, reactions, per-chat theme
ChatInfo.ContactConnection ModalView wrapping ContactConnectionInfoView
ChatInfo.InvalidJSON ModalView with raw JSON display and share button

3. Message List

Location: ChatView.kt#L1758 (ChatItemsList composable)

The message list is a LazyColumnWithScrollBar with reverseLayout = true, meaning index 0 is the newest message at the bottom of the screen.

Key Behaviors

  • Merged Items: Messages are grouped via MergedItems.create(), which collapses consecutive similar system events into expandable groups. Revealed state is tracked in revealedItems; MergedItem.revealed is true for a single item and for a revealed group row.
  • Pagination: PreloadItems triggers loadMessages with ChatPagination.Before (older) or ChatPagination.Last (newer) when the user scrolls near list boundaries.
  • Scroll To Item: scrollToItem lambda supports animated scrolling to a specific item ID, used by search result taps and quoted message navigation.
  • Unread Marking: MarkItemsReadAfterDelay composable marks newly visible received items as read after a brief delay.
  • Date Separators: DateSeparator composable renders between messages when the date changes (via ItemSeparation.date).
  • Swipe to Reply: SwipeToDismiss modifier on each item (EndToStart direction, 30dp threshold) sets ComposeContextItem.QuotedItem.
  • Selection Mode: When selectedChatItems is non-null, a checkbox overlay appears on each item; a full-width clickable overlay toggles selection.

Item Layout (ChatViewListItem)

  • Group received messages with showAvatar = true: Column layout with MemberNameAndRole header, MemberImage (clickable to showMemberInfo), and message bubble. The name starts at the bubble edge, 4dp after the avatar, as the bubble (user, as iOS).
  • Group received without avatar: Indented to align with avatar-bearing messages.
  • Sent messages (group or direct): Right-aligned with larger start padding.
  • Direct messages: Symmetric padding (76dp opposite side).
  • Terminal layout: When LocalTerminalLayout.current is true, all chat types render through TerminalChatItemRow, which wraps ChatItemView, instead of the branches above (see section 10). The selection offset applies to sent items too.

4. ChatItemView

Location: item/ChatItemView.kt#L70

fun ChatItemView(
  chatsCtx, rhId, chat, cItem, composeState, imageProvider,
  useLinkPreviews, linkMode, revealed, highlighted, hoveredItemId,
  range, selectedChatItems, searchIsNotBlank, fillMaxWidth,
  selectChatItem, deleteMessage, deleteMessages, archiveReports,
  receiveFile, cancelFile, joinGroup, acceptCall, acceptFeature,
  openDirectChat, forwardItem, scrollToItem, scrollToItemId,
  scrollToQuotedItemFromItem, setReaction, showItemDetails,
  reveal, showMemberInfo, showChatInfo, developerTools, showViaProxy,
  showTimestamp, itemSeparation, ...
)

The composable routes based on cItem.content and cItem.meta.itemDeleted:

  • Deleted items -> DeletedItemView or MarkedDeletedItemView
  • Message content (SndMsgContent, RcvMsgContent) -> FramedItemView or specialized views depending on msgContent type
  • Call items -> CICallItemView
  • Integrity/decryption errors -> IntegrityErrorItemView, CIRcvDecryptionError
  • Group invitations -> CIGroupInvitationView
  • Events (group/direct/connection events) -> CIEventView
  • Feature changes -> CIChatFeatureView, CIFeaturePreferenceView
  • E2EE info -> CIEventView
  • Chat banner -> handled at list level, not in ChatItemView
  • Invalid JSON -> CIInvalidJSONView

Reactions

ChatItemReactions row renders below each message bubble, showing emoji reaction counts. Tapping own reactions removes them; tapping others' opens a member list dropdown.

Context Menu

Long-press or right-click opens a dropdown menu with context-sensitive actions (see section 6).

Terminal Layout Adjustments

When LocalTerminalLayout.current is true, ChatItemView and the item views it calls keep their content and start at one text edge, CHAT_ITEM_TEXT_PADDING (12dp) inside the item; messages are drawn without the bubble fill and tail:

  • Items are start-aligned for both directions (alignEnd). The item click has no indication; the row draws it (section 10). GoToItemButton is placed as described in section 10 (L345, L819). Voice items use the arrangement of received items for both directions.
  • chatItemTailEnabled() returns false, so shapeStyle draws no tail.
  • FramedItemView has a transparent background. Headers (report, moderated, blocked, deleted, live, forwarded) and the quote have no background and start at the text edge. The quote has the bar of Markdown quotes (Modifier.quoteBar), its text in onSurface at 70%, 15sp. The bar and the padding, TERMINAL_BLOCK_TOP_GAP (5.75sp) above and TERMINAL_BLOCK_BOTTOM_GAP (5sp) below, span the whole quote row, including an image or video thumbnail; the thumbnail has the message corner clip of media (thumbnailClip, clipChatItem, rounded by the chatItemRoundness preference, square at 0) and stands 12dp after the quote text, or 12dp after the bar when the quote has no text and no sender (QuotedMsgOrGap, QUOTE_BAR_GAP); the file, voice and chat link icons of the quote have no top padding. Headers: TERMINAL_HEADER_TOP_GAP (2.5sp) above, TERMINAL_HEADER_BOTTOM_GAP (1.5sp) below; the header text uses chatTextStyle at its size in secondary, and the icon is centred on it and takes no height (Modifier.inlineIcon()).
  • Images, videos, link previews and chat link cards start at the text edge, TERMINAL_BLOCK_TOP_GAP below the part above them, and keep the message corner clip (mediaModifier). Link previews and chat links are cards in the quote colour (cardModifier). The item itself is not clipped (clipChatItem of FramedItemView and ChatItemView is skipped), so headings and emoji are drawn whole. An image or video without caption, or a chat link card without text (gapBelowBlock, computed with metaReserved, which is passed to ItemWithMeta), has TERMINAL_BLOCK_BOTTOM_GAP below it, outside ItemWithMeta. Captions are selected by the classic conditions.
  • Captions (user, D30): the text below an image, video, link preview, chat link card, voice message or file is laid out as the text of a close follow-up message, the time on it only. PriorityLayout measures it in the full width of the item (limitToPriorityWidth = false; classic: the width of the media) and places the elements at the start edge in both directions (placeRelative), the caption of a link preview has no width limit (classic: DEFAULT_MAX_IMAGE_WIDTH), and a chat link card keeps its own width (width(IntrinsicSize.Max)). Its gap below the media is the gap from the same media without text to a close follow-up message. An image or video without caption keeps the classic limit (limitToPriorityWidth = true): a quote or header above it takes the width of the media, so the white time over the media stays on its bottom end corner.
  • CIMarkdownText: top padding terminalTop (required): 0 below the sender row, a header, the quote, a voice message or a file, TERMINAL_BLOCK_BOTTOM_GAP below an image, video, link preview or chat link card; plus terminalHeadingGap(item), TERMINAL_LEVEL_STEP when the text starts with a heading and is not at the top of the item (below a quote, a header or a block); no bottom padding. Its text style: body1 in messageTextColor(), merged with chatTextStyle at the body1 size. The default style of MarkdownText is classic and is used outside the rows (the compose context preview).
  • Code block cards, table cards and rules of MarkdownBlocks: TERMINAL_BLOCK_TOP_GAP above, TERMINAL_BLOCK_BOTTOM_GAP below, except the last block of a message without a meta line (marksLastBlock; a live message has the meta line with the typing indicator, so its last block keeps the gap). That block has no bottom margin and is marked with messageLastBlock(): TERMINAL_BLOCK_BOTTOM_GAP is added below it when the time stays beside it, and the time moved under it is TERMINAL_META_GAP below its edge (section 10). In a message of rules only, the rules are at least as wide as the copy of the time (ruleMinWidth of MarkdownBlocks, measured by MarkdownText; user, D31), as in classic, where the meta line sets the width; the time stays beside them unless a wider quote or header widens the item so that the rule leaves no room for the time.
  • Text blocks of MarkdownBlocks: a paragraph break, and a heading that does not start the text, add TERMINAL_LEVEL_STEP (4.75sp), also in bubbles (outside chats: half the line height, before a paragraph break only); above a heading that starts the text, the gap is added by terminalRowGap or terminalHeadingGap. A block after a heading, other than a heading, has TERMINAL_HEADING_BOTTOM_GAP (half of TERMINAL_LEVEL_STEP, 2.375sp) above it instead, so a heading is nearer to its own text than to the text above it.
  • Text styles: chatTextStyle(fontSize, color) sets the line height to font size × CHAT_LINE_HEIGHT_RATIO (22/16) × f(w) and LineHeightStyle(Proportional, Trim.None), in terminal rows and in bubbles (usesChatTypography(): LocalTerminalLayout or LocalBubbleLayout); outside chats it returns TextStyle.Default. Every line takes the full line height, one-line texts included. The space added to a line is split in the ratio of the font's ascent to its descent. On desktop each run of a line is given its own line box; with this alignment, runs of different sizes (the 12sp time in the copy of the meta) are placed on one baseline in a line of the body line height. f(w) = 1 − CHAT_LINE_HEIGHT_CONTRAST_SLOPE (0.1515) × (1 − w); w: the L* contrast of the text colour against MaterialTheme.colors.background, divided by that of messageTextColor(), limited to 0…1, and 1 when the message text has no contrast. Used for message text and captions (the style of CIMarkdownText), Markdown quotes (text colour at 70%), code, headings (body line height × scale), quote text and the quote's sender (in body1, Inter), the sender name and role, event texts (chatEventTextStyle(base): 12sp, secondary, merged into base, by default LocalTextStyle.current; in both layouts the text direction is left unspecified, which Compose resolves to the layout direction, so that an event starting with a name in the opposite direction keeps the copy of the time at the end of the row), header texts and the time. For names and the white time over media (lineHeightColor of CIMetaView), f is computed with the message text colour. Line heights in the light and dark themes: message text 22sp, quote text 19.6sp and 19.9sp, event text and time 15.1sp and 15.4sp.
  • Headings: body size × TERMINAL_HEADING_RATIO (1.25) to the power 2, 1, 0 for levels 1, 2, 3, 25, 20 and 16sp for 16sp text; levels 4–6 also at the body size; all bold (also in bubbles; Markdown outside chats: 1.5, 1.3, 1.15); line heights scaled the same. Modifier.headingBox lays a heading out with the space of a body line above its capitals and below its last baseline (Inter cap height 2048/2816 em). It uses the px ratio of the body and heading font sizes: Android scales font sizes non-linearly from font scale 1.03 and, above 1.05, sets line heights in proportion to the font size in px, so the capitals are placed as body capitals at every font scale (within 1px at 1, 1.15 and 1.3).
  • Files: TERMINAL_FILE_TOP_GAP (2sp) above the file row, no padding below, no padding of its icon. Voice messages drawn by FramedItemView (with a caption, a quote or a header; hasText = true): the layout of a voice message drawn alone (4sp above, 6sp below, its indicator), without its own time; no padding below the item.
  • Emoji-only items, also under a quote or a header: TERMINAL_EMOJI_TOP_GAP (4.25sp) above, TERMINAL_EMOJI_BOTTOM_GAP (1.5sp) below (classic: 8dp; under a quote or header the text paddings of a bubble). Under a quote or a header the emoji is not stretched to the item width, so its reported last line is the emoji itself; PriorityLayout places it at the start edge in both directions.
  • Item gaps: one gap above each row, terminalRowGap(item, older, separated, largeGap, hasSenderRow), passed by ChatItemsList as topGap of the row. It is TERMINAL_LEVEL_STEP when an older item exists, the rows are not separated and one of these holds: the gap to the older item is large; the row shows a sender row; either item is an event (content without a terminal sender); the text of the item is at its top (text or unknown content without a file, without quote, header, deletion, live state or report) and its first block is a heading. Otherwise it is 0. separated (terminalRowsSeparated): a date separator is drawn between the two rows (the older row is revealed, a single item or a revealed group row, and its date differs from that of the oldest item of the row), or the older item is the chat banner. For a collapsed group, older is the item older than the group, and largeGap is computed against the oldest item of the group. The bottom of a row is TERMINAL_LEVEL_STEP when a date separator is drawn below it, 8dp for the newest row, as classic, otherwise 0 (ChatItemBox(top, bottom); classic: half of BUBBLE_CLOSE_GAP or BUBBLE_FAR_GAP above and below). Above a sender row, TERMINAL_SENDER_TOP_GAP (6.5sp) is added; below the name, TERMINAL_SENDER_BOTTOM_GAP (5.5sp). The gaps are constants in sp at the top of TerminalItemView.kt, converted to dp with the font scale. The bottom padding of call items (8dp), the paddings of voice messages drawn without FramedItemView (no caption, quote, header, deletion or live state; 4sp above, 6sp below) and the 6dp between the two rows of the forwarded-from header are classic.
  • Vertical gaps, measured from the baseline (or a block's bottom edge) to the top of the tallest letter, a heading's capitals, or a block's top edge:
level 1   lines of a quote
level 2   lines of a message; messages of one sender within 60 seconds; a block against the text line or block next to it
level 3   messages of one sender 60 seconds or more apart; sender name → message or header; paragraph break; above a heading, also at the start of a message
level 4   last line of a message → next sender name; above and below an event
  • Event, feature and member-created items use chatEventPadding(): in terminal rows the text edge at the sides, TERMINAL_EVENT_TOP_GAP (8.25sp) above and TERMINAL_EVENT_BOTTOM_GAP (1.5sp) below, so that the capitals of the event text are where the capitals of a sender name would be and its last baseline is as far from the row end as that of a message line (classic: 6dp at the sides, above and below). The icons of feature and preference items are centred on the text and take no height (Modifier.inlineIcon()); the row of merged feature icons is one event text line high; both also in bubbles. CIEventView has no line limit in terminal rows, so the copy of the time stays at the end of the text (user, D23; classic: 4 lines). Voice and call items and reactions start at the text edge. The voice play button and file status circle are filled with the quote colour (sentQuote, receivedQuote; classic: sentMessage, receivedMessage).
  • Time and status: placed at the end of the row by ItemWithMeta, the baseline of the time on the last baseline of the content (section 10). Message text ends with metaReserveCopy, in both layouts: a transparent copy of the meta with no-break spaces, in FontWeight.Normal. In the terminal layout the copy starts with a word joiner (U+2060), which forbids a line break before the copy, also after the three spaces of an idle live message (noTyping), so the time is not moved alone to the next line. In bubbles the copy starts with a breaking space (breakBefore, user) when text precedes it in its paragraph, so the time alone moves to the next line when it does not fit; the copy of the meta line under a last card or rule (range empty) starts without a space. The direction mark of its paragraph follows (U+200E left-to-right, U+200F right-to-left), so that the bidirectional algorithm places the copy at the end of the line, also after words in the other direction on a wrapped last line. Its spaces and texts are appended by reserveSpaceForMeta, which has the conditions of CIMetaText, except that the timer is reserved for every timed message, and returns the classic string unchanged, through span parameters: icon spaces and the spaces between them are no-break spaces at the body1 size in dp (16dp converted to sp with the density, so that they keep their width at every font scale, as the icons do; also after a heading and in event text), 18dp for each icon, wider than a 12dp icon with its spacer (15dp or 16dp); after the 17dp icons one more space is added (22.5dp against 21dp): the sent and received status icons (tallStatusIcon, also used by CIMetaText) and the via-proxy icon; the time and the timer text of a disappearing message are at 12sp. The copy includes the file encryption and signature icons (encrypted and signedFileVerified of MarkdownText, passed by CIMarkdownText from the item). In the terminal layout, when the last word and the copy do not fit in the line, the last word moves to the next line with the time. Text whose paragraph direction (textLayoutDirection: right-to-left when the first 50 characters of the message text hold a right-to-left character; all blocks and lines of a message take it) differs from the outer direction (parentDirection, passed by the blocks and by the meta line of a live message; otherwise LocalLayoutDirection) ends with a line break instead of the copy, so the time is placed on an added empty line. Emoji, and the last code block, table or rule of a message, report their width (Modifier.messageLastLine(), Modifier.messageLastBlock()); the time moves under them when the row leaves no room for it beside them. The meta line with the time is drawn under such a block only for live messages, for the typing indicator. File text keeps the space reserved for the time at the end of its size line. Images and videos without a caption (captionlessMedia) keep the meta in secondary beside the media when it fits in the row, otherwise the meta is drawn over the bottom end of the media in white, as in the classic layout (overlayMeta). Event items are wrapped in ChatEventWithMeta, which passes the separator and the end of the event text apart (separator, ts): in the classic layout two spaces and the time without a style, in the terminal layout three transparent no-break spaces in chatEventStyle and the transparent metaReserveCopy of the meta without status and edit icons, so the last word of the event text is moved to the next line with the time when they do not fit. Each view puts the separator where the classic layout has two spaces (user, D28): after the event text, or in the "Open" and "Accept" links, so the classic output is unchanged.
  • Group invitations, deleted items and error items (IntegrityErrorItemView, CIRcvDecryptionError) keep their rounded coloured card.

Bubbles

When LocalBubbleLayout.current is true (classic chats, provided by ChatView with LocalTerminalLayout, the classic theme preview, and the chat item and chat view previews through BubblePreview), bubbles follow one inset (plans/2026-10-25-classic-bubbles.md). The previews of deleted and error items clip them as ChatItemView does (clipChatItem).

  • Texts use chatTextStyle, as terminal rows: message text, captions, Markdown blocks, quote text and sender (body1, Inter), headers, the time, event texts; the texts of DeletedItemView and error items through bubbleTextStyle(fontSize, color), chatTextStyle in bubbles only, so the cards of terminal rows keep their style (the one-line text of MarkedDeletedItemView keeps its style, its line height rounded by bubbleLineHeight). Call items are drawn as in master: ChatItemView provides LocalBubbleLayout false for them. Markdown blocks in bubbles have the gaps of terminal rows (paragraph breaks, headings, cards and rules) and their heading sizes and headingBox.
  • On desktop, bubbleLineHeight(lineHeight) rounds a line height in bubbles to whole pixels, with the proportional alignment of chatTextStyle: chatTextStyle applies it in bubbles, and so do the last text of a group invitation (18sp, or "Tap to join" at the line height of Inter, INTER_LINE_HEIGHT), "Fix connection" of decryption errors and the size line of files (the line height of Inter) and the text of MarkedDeletedItemView (22sp); on Android the line heights are unchanged. Skia draws the baseline of a line at its top plus the rounded ascent and reports the rounded line height less the descent, which Compose rounds into FirstBaseline and LastBaseline; both are one pixel row only when ascent and descent add up to whole pixels, so the time, placed by reported baselines, is drawn on the baseline of the text (owner report).
  • BUBBLE_TEXT_INSET (11dp, user) is the distance from a bubble or band edge to the capitals of the text under it, and from the last baseline to the edge under it; in dp, so it keeps its size at larger fonts (user). Message text, captions and an emoji under a quote or header have bubbleTextBoxPadding(): that inset less the space of 16sp text with the full line height above its capitals (BUBBLE_LINE_ABOVE_CAPS, 6sp) and under its last baseline (BUBBLE_LINE_BELOW_BASELINE, 4.375sp), at least 0; 5dp and 6.625dp at font scale 1. BUBBLE_SIDE_INSET (14dp, user) is the distance from the bubble sides to message text, captions, an emoji under a quote or header and the end of the time. Header bands, the quote band and deleted and error items use Modifier.bubbleTextPadding(firstLineFontSize, bottom): BUBBLE_SIDE_INSET at the sides and the inset by the first and last baselines; a header followed by a quote or a forwarded header has no inset below. The header starts at BUBBLE_SIDE_INSET and its icon takes no height. Deleted and error items are drawn by ItemTextCard (DeletedItemView, MarkedDeletedItemView, CIMsgError) and the private DecryptionErrorCard (the decryption error items), each with one switch between the bubble and the rounded card of terminal rows: in bubbles they fill the shape of clipChatItem (Surface with its default RectangleShape, user). That shape has no tail for items without message content (shapeStyleWithTail: msgContent null; user): deleted, moderated, blocked, error, decryption error and invalid JSON items. chatItemTailRendered(chatItem, tailVisible, revealed) tells whether the tail is drawn, from the shape style of the item as clipChatItem computes it; FramedItemView, chatItemOffset and the sender rows of ChatItemsList use it. The file row ends BUBBLE_TEXT_INSET under the baseline of its size line; the ink bottom of the file icon is aligned to that baseline (fileIconOnBaseline: the icon size × FILE_ICON_INK_INSET, the space under the ink of ic_draft_filled, rounded down).
  • The time: Modifier.bubbleMeta() places the baseline of the time BUBBLE_TEXT_INSET above the bubble bottom, on the last baseline of the text; ticks without a time where they stand next to a time (metaBaseline, shared with TerminalMetaLayout). In deleted and error items the time follows the text on its last baseline (bubbleMetaOnBaseline, the text aligned by LastBaseline), so a wrapped text keeps the time on its last line; in group invitations MetaOnLastBaseline(meta, content) places it at the end of the card on its last baseline (the card bottom without a baseline, in intrinsic measurement; user). Message text, an emoji under a quote or header, decryption errors and group invitations end with metaReserveCopy; after text it starts with a breaking space (breakBefore), so the time alone moves to the next line, except in group invitations, where the word joiner keeps the time with the last word (user); the size line of a file keeps the string of reserveSpaceForMeta.
  • Gaps between rows: BUBBLE_CLOSE_GAP (2sp) between close messages, BUBBLE_FAR_GAP (8sp) at a large gap, half above and half below each row. Event rows (isEvent: items without a sender, hasTerminalSender false, other than the chat banner: event, feature, preference and end-to-end encryption items, also collapsed) form runs (user): eventRunLargeGap makes the gap between two events close and the gap between an event and another row large; ChatItemsList applies it to the gap above a row (eventGapAbove) and to the separation below it (eventGapBelow, a large gap with the time), so a message above an event shows its tail and time. The avatar column of an event has no 8dp top padding.

Both layouts: MarkdownText draws message text in messageTextColor(), onSurface at 95% in dark themes.


5. Message Types

CIContent Variant MsgContent Type View Composable Source File
SndMsgContent / RcvMsgContent MCText FramedItemView -> TextItemView or EmojiItemView TextItemView.kt, EmojiItemView.kt
SndMsgContent / RcvMsgContent MCLink FramedItemView (with link preview) FramedItemView.kt
SndMsgContent / RcvMsgContent MCImage CIImageView (inside FramedItemView) CIImageView.kt
SndMsgContent / RcvMsgContent MCVideo CIVideoView (inside FramedItemView) CIVideoView.kt
SndMsgContent / RcvMsgContent MCVoice CIVoiceView CIVoiceView.kt
SndMsgContent / RcvMsgContent MCFile CIFileView CIFileView.kt
SndMsgContent / RcvMsgContent MCReport FramedItemView (with report styling) FramedItemView.kt
SndCall / RcvCall -- CICallItemView CICallItemView.kt
RcvIntegrityError -- IntegrityErrorItemView IntegrityErrorItemView.kt
RcvDecryptionError -- CIRcvDecryptionError CIRcvDecryptionError.kt
RcvGroupInvitation / SndGroupInvitation -- CIGroupInvitationView CIGroupInvitationView.kt
RcvDirectEventContent -- CIEventView CIEventView.kt
RcvGroupEventContent / SndGroupEventContent -- CIEventView CIEventView.kt
RcvConnEventContent / SndConnEventContent -- CIEventView CIEventView.kt
RcvChatFeature / SndChatFeature -- CIChatFeatureView CIChatFeatureView.kt
RcvChatPreference / SndChatPreference -- CIFeaturePreferenceView CIFeaturePreferenceView.kt
RcvGroupFeature / SndGroupFeature -- CIChatFeatureView CIChatFeatureView.kt
SndModerated / RcvModerated / RcvBlocked -- MarkedDeletedItemView MarkedDeletedItemView.kt
SndDirectE2EEInfo / RcvDirectE2EEInfo -- CIEventView CIEventView.kt
SndGroupE2EEInfo / RcvGroupE2EEInfo -- CIEventView CIEventView.kt
RcvChatFeatureRejected / RcvGroupFeatureRejected -- CIChatFeatureView CIChatFeatureView.kt
ChatBanner -- ChatBannerView (inline in ChatItemsList) ChatView.kt
InvalidJSON -- CIInvalidJSONView CIInvalidJSONView.kt
CIMemberCreatedContact -- CIMemberCreatedContactView CIMemberCreatedContactView.kt

Animated Images

SimpleAndAnimatedImageView is expect/actual. Android delegates to coil, which drives the animation itself. Desktop decodes frames with Skia's Codec in platform/AnimatedImage.desktop.kt, where rememberAnimatedImage(data, still, hidden) returns the frame to draw and falls back to the still image when the data is not an animation, exceeds the decode bounds, or fails before showing a frame. Decoding runs off the UI thread on two threads of the shared pool, and pauses while the window is minimized or hidden, while the image is behind the privacy blur, and while a full screen modal covers the chat. An animation whose frames cost too much to decode stops on the frame it reached rather than falling back to the still. The chat list preview (smallView) stays a still image. Only GIF reaches this path: desktop decodes stills with ImageIO, which has no WebP reader, so a received .webp renders only as the sender's preview and never opens full screen.


6. Context Menu Actions

Context menu actions are built dynamically in ChatItemView based on message type, direction, chat type, and feature flags.

Action Condition Effect
Reply Message content (not event/deleted), not local notes Sets ComposeContextItem.QuotedItem
Edit Sent message, editable (meta.editable), text/link content Sets ComposeContextItem.EditingItem
Delete for me Any deletable item apiDeleteChatItems with cidmInternal mode
Delete for everyone Sent + within time window, or moderator privilege apiDeleteChatItems with cidmBroadcast mode
Moderate Group moderator + received message apiDeleteMemberChatItems
Forward Message content, not live message Opens share sheet via SharedContent.Forward
Select Any selectable item Enters multi-select mode (selectedChatItems)
React Message content, reactions enabled Opens emoji picker; calls apiChatItemReaction
Report Received group message, reports enabled Sets ComposeContextItem.ReportedItem with reason
Info Any message Opens ChatItemInfoView in end modal
Copy Text content present Copies text to clipboard
Save Image/video/file with completed download Saves media to device
Open File with completed download Opens file with system handler
Reveal / Hide Part of a merged group; expanded or collapsed Toggles revealedItems state

7. ChatInfoView

Location: ChatInfoView.kt

Opened via the info callback when the user taps the toolbar title in a direct chat. Displayed in ModalManager.end.

Preloads apiContactInfo (connection stats, server profile) and apiGetContactCode (verification code) before showing the modal.

Key sections: contact profile, local alias, connection stats, shared media, disappearing messages preference, voice/call/file feature toggles, encryption verification, and contact deletion.


8. GroupChatInfoView

Location: group/GroupChatInfoView.kt

Opened via the info callback for group chats. Displayed in ModalManager.end.

Preloads group members (setGroupMembers) and group link (apiGetGroupLink).

Key sections: group profile, group link, member list with roles, group preferences (disappearing messages, direct messages, full deletion, voice, files, SimpleX links, history), member admission, welcome message, reports view, and group deletion/leave.


9. Source Files

views/chat/

File Description
ChatView.kt Main chat view, ChatLayout, ChatItemsList, ChatInfoToolbar
ChatInfoView.kt Contact info modal
ChatItemInfoView.kt Individual message delivery/read info; below a file's expiry, for a user without a badge, a button opening the badges view
ChatItemsLoader.kt Pagination and message loading logic
ChatItemsMerger.kt MergedItems grouping of consecutive events
CommandsMenuView.kt Bot /command menu popup
ComposeContextContactRequestActionsView.kt Contact request action buttons in compose area
ComposeContextGroupDirectInvitationActionsView.kt Group direct invitation compose actions
ComposeContextPendingMemberActionsView.kt Pending member compose actions
ComposeContextProfilePickerView.kt Profile picker in compose context
ComposeFileView.kt File attachment preview in compose
ComposeImageView.kt Image/video attachment preview in compose
ComposeView.kt Main compose area (ComposeState, send logic)
ComposeVoiceView.kt Voice recording preview in compose
ContactPreferences.kt Per-contact feature preferences
ContextItemView.kt Reply/edit/forward context indicator
ScanCodeView.kt QR code scanner
SelectableChatItemToolbars.kt Multi-select toolbar (delete, forward, moderate)
SendMsgView.kt Text input field, send button, voice record button
VerifyCodeView.kt Contact/member encryption verification

views/chat/item/

File Description
ChatItemView.kt Message type routing, context menu, reactions
CIBrokenComposableView.kt Fallback for rendering errors
CICallItemView.kt Call event display (incoming/outgoing/missed)
CIChatFeatureView.kt Chat feature change event
CIEventView.kt Generic event display (group/direct/connection)
CIFeaturePreferenceView.kt Feature preference change event
CIFileView.kt File message (download/upload progress)
CIGroupInvitationView.kt Group invitation card
CIImageView.kt Image message (thumbnail + fullscreen)
CIInvalidJSONView.kt Invalid JSON fallback display
CIMemberCreatedContactView.kt Member-created contact event
CIMetaView.kt Message metadata (time, status indicators)
CIRcvDecryptionError.kt Decryption error display
CIVideoView.kt Video message (thumbnail + player)
CIVoiceView.kt Voice message (waveform + player)
DeletedItemView.kt Deleted message placeholder; ItemTextCard, the card of deleted and error items
EmojiItemView.kt Large emoji-only message
FramedItemView.kt Message bubble frame (quoted item, text, media)
ImageFullScreenView.kt Fullscreen image gallery
MarkdownFileView.kt Markdown file viewer, formatted or as text
IntegrityErrorItemView.kt Message integrity error
MarkedDeletedItemView.kt Marked-as-deleted / moderated message
TerminalItemView.kt Terminal layout: LocalTerminalLayout, sender row rule, TerminalChatItemRow, ItemWithMeta
TextItemView.kt Plain text message with markdown; blocks of markdownBlocks drawn as a column of views by MarkdownBlocks (MarkdownBlocks.kt)

views/chat/group/

File Description
AddGroupMembersView.kt Add members to group
GroupChatInfoView.kt Group info and management
GroupLinkView.kt Group link display and management
GroupMemberInfoView.kt Individual member info
GroupMembersToolbar.kt Members toolbar in group info
GroupMentions.kt @mention autocomplete
GroupPreferences.kt Group feature preferences
GroupProfileView.kt Group profile editor
GroupReportsView.kt Group reports list view
MemberAdmission.kt Member admission settings
MemberSupportChatView.kt Member support chat (scoped context)
MemberSupportView.kt Support chat list for moderators
WelcomeMessageView.kt Group welcome message editor
ChannelRelaysView.kt Channel relay list. Owner-only Add relay entry opens AddGroupRelayView with existingRelayIds = groupRelays.mapNotNull { it.userChatRelay.chatRelayId }.toSet() — every relay currently in groupRelays is excluded regardless of relayStatus, mirroring the backend APIAddGroupRelays gate. Long-press menu offers Remove relay for relays that can be removed.
AddGroupRelayView.kt Sheet to pick relays to add to a channel

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). In GroupMemberInfoView, an additional "Status: rejected by relay operator" InfoRow appears when groupRelay?.relayStatus == 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.

The RelayStatusIndicator composable in AddChannelView.kt renders RsRejected with a red dot and "rejected" text, matching the connFailed/removed rendering.


10. Terminal Layout

Location: item/TerminalItemView.kt

Rendering of a chat whose ChatInfo.activeChatLayout is ChatLayoutMode.Terminal: full-width start-aligned rows, the sender name once per group of messages, avatars in groups only, messages without bubbles (item-level changes: section 4). Android and desktop; iOS: apps/ios/spec/client/chat-view.md, section 9.

Activation

Step Location
terminal = chatInfo.activeChatLayout(user) == ChatLayoutMode.Terminal ChatView
LocalTerminalLayout provides terminal and LocalBubbleLayout provides !terminal, next to LocalAppBarHandler and LocalSelectionManager ChatView
drawWallpaperImage = false of chatViewBackgroundModifier ChatLayout composable

LocalTerminalLayout is false outside ChatView and the theme screens (below); chat item info keeps the classic look. The value applies to every ChatView of the chat: main view, member support chats and reports.

Colours: the app, user and chat themes apply as in the classic layout. Wallpaper: chatViewBackgroundModifier fills the chat with the background colour of the wallpaper (preset or custom) and omits the image.

Functions

Function Purpose
chatTextStyle(fontSize, color) Terminal rows and bubbles (usesChatTypography()): line height and line height style of a text (section 4); outside chats: TextStyle.Default, so that merging it changes nothing
terminalItemShowsSender(item, older) Whether a row shows the sender row (sender rule below). older: the next older item in the list
sameSender(item, older) Private; same sender for grouping
terminalRowGap(item, older, separated, largeGap, hasSenderRow) The gap above a row (section 4, item gaps): TERMINAL_LEVEL_STEP or 0. Used by ChatItemsList and the theme preview
terminalHeadingGap(item) TERMINAL_LEVEL_STEP above a heading that starts the text of an item below a quote, a header or a block; added to the top padding of CIMarkdownText
ChatItem.hasQuoteOrHeader A quote, a forwarded header, a deletion, the live state or a report: the item is drawn by FramedItemView with a quote or header above its content. Used by ChatItemView to choose EmojiItemView or CIVoiceView and by the heading gaps
CIContent.hasTerminalSender Also used by the event runs of bubbles (isEvent in ChatView: false and not the chat banner). True for sent messages, sent deleted and moderated items, sent calls and group invitations, and received content with showMemberName (messages, deleted, moderated and blocked items, calls, errors, group invitations, invalid JSON); false for events, feature and preference changes, E2EE info and the chat banner
TerminalRowInteractionSource Interaction source of the row and its item: press interactions are emitted at the position where the pointer went down in the row, so the ripple starts there
Modifier.terminalRowIndication(source) Records the pointer-down position; hover and press indication of the whole row
Modifier.terminalStartOffset(offset) Shifts the row by the selection offset and narrows it by the same amount
Modifier.messageLastLine(), Modifier.messageLastBlock() messageLastLine() sets the private alignment lines LastLineLeft and LastLineRight to the left and right edges of the element: emoji. messageLastBlock() also sets LastBlockBottom at the bottom edge of the element: the last code block, table or rule of a message without a meta line, which has no bottom margin
Modifier.inlineIcon() Terminal: lays the element out with no height, centred on its position, so that a row with Alignment.CenterVertically takes the height of its text and centres the element on it: icons of feature and preference items and of message headers. Also in bubbles; outside chats: the modifier unchanged
ItemWithMeta(meta, overlayMeta, metaReserved, endButton, content) Classic: content with the meta passed as inlineMeta. Terminal: content without meta, and the meta in a box with 12dp end padding at the end of the available width, the message column of the row (private TerminalMetaLayout). endButton, in a box with the same end padding, is placed at the end of the row, centred vertically between the content top and the top of the time box (the whole content when the time is over media); the content is measured in the width less the wider of this box and, without metaReserved, the time box. The meta is measured first. Without metaReserved and overlayMeta, the content is measured in the width less the width of the box, so that it does not reach under the time. metaReserved is true for content with its own reserve of the space or a reported last line: text with the transparent copy (metaReserved of FramedItemView), files, emoji and event items. The first baseline of the box (the baseline of the time; without a time, the baseline of a 12sp time centred in the box) is placed on the last baseline of the content. When the content reports its last line and the line and the box together are wider than the row, the box is placed TERMINAL_META_GAP (6sp) under the content and the bottom of the row moves down by that gap and the height of the box. When the box stays beside a last block reported with LastBlockBottom at the content bottom (a table, or a code card or rule, which span the width of the item), TERMINAL_BLOCK_BOTTOM_GAP is added below the content. Content without a baseline (a message of a rule only, a failed call), and captionless media with overlayMeta: the box ends TERMINAL_META_GAP above the content bottom; when the content and the box together are wider than the row, the overlayMeta box is placed at the bottom end of the content instead, TERMINAL_META_GAP above its bottom. Either box is placed at the top of the content at most, and the row is at least as tall as the box. When the rule leaves room for the time beside it, the time stays on the baseline of the last text line above it (D24); otherwise it is placed under the rule.
ChatItemSender Name, badge, role label, own, avatar
chatItemSender(chatInfo, cItem, range, reversedChatItems, showMemberInfo, showChatInfo) Sender of a row (table below)
SenderNameAndRole(sender, modifier) Private; name and badge (13.5sp; primary for own items, secondary otherwise) and role label
terminalGutter(chatInfo) Gutter width: the member image size (scaled by fontSizeSqrtMultiplier) plus CHAT_ITEM_TEXT_PADDING in groups, 0 in other chats
TerminalChatItemRow(sender, gutter, topGap, modifier, senderTopGap, content) Row layout below

In ChatView.kt, shared with the classic date separator:

Function Purpose
terminalRowsSeparated(olderRow) A date separator or the chat banner between a row and the older row: the newest item of the older row is the banner, or rowItemSeparation(row) of the older row has a date. False without an older row
rowItemSeparation(row) Private; the separation of a row, also used by ChatItemsList for the date separator it draws: getItemSeparation of the newest item of the row and the next newer item (the oldest item of the newer row) when the row is revealed (MergedItem.revealed: a single item or a revealed group row), without the newer item otherwise (user, D32). In bubbles ChatItemsList applies the event run gap to it (section 4, Bubbles)

Row Layout

row padding        top: topGap (terminalRowGap), plus senderTopGap (TERMINAL_SENDER_TOP_GAP, 6.5sp) with a sender row
gutter             terminalGutter(chatInfo)
  avatar           top of the gutter, CHAT_ITEM_TEXT_PADDING from the start, sender rows of groups only
one padding        12dp (user, I26): screen edge to the text or the avatar, avatar to the text, time to the screen edge
message column     starts at the gutter
  sender row       name and badge (13.5sp; primary for own messages, secondary otherwise), role label; start padding CHAT_ITEM_TEXT_PADDING; text style chatTextStyle(13.5sp)
  item             ChatItemView; TERMINAL_SENDER_BOTTOM_GAP (5.5sp) below the sender row

ChatItemsList passes showAvatar = terminalItemShowsSender(item, merged.oldest().nextItem), and the row gets chatItemSender(...) when it is true, null otherwise; the gap above the row, with terminalRowsSeparated of the older row (the next merged item of the reversed list), is passed to ChatViewListItem as terminalGap.

Sender Rule

terminalItemShowsSender: a row shows the sender row when the item has a sender (hasTerminalSender) and one of these holds:

  • it is the oldest loaded item;
  • the older item has no sender (an event);
  • sameSender is false.

The time between the items and a date change do not repeat the sender row.

sameSender: when either item is posted as the channel (ChannelRcv, or GroupSnd with meta.showGroupAsSender), both must be; two GroupRcv items need the same memberId; other items need the same direction class.

Sender Resolution

Direction Name and badge Role label Avatar Avatar click Own colour
ChannelRcv, GroupSnd with showGroupAsSender Channel name "channel" (channel_role_label) ProfileImage of the group, as in the classic layout showChatInfo For GroupSnd
DirectRcv Contact display name and badge None None None No
GroupRcv memberNames of the merged members; badge for one member Member role when above Member, for one member MemberImage showMemberInfo No
GroupSnd groupInfo.membership display name and badge Membership role when above Member MemberImage(membership) None Yes
Other, incognito chat "Incognito" None None None Yes
Other (DirectSnd, LocalSnd, LocalRcv) Current user display name and badge None None None Yes

The sender row has no time; the time of every item is placed by ItemWithMeta.

Row Background

State Background
Selected (selection mode) primary at 10%
Highlighted (highlightedItems contains the item, e.g. after jumping to a quoted message) primary at 14%
Otherwise Transparent

Hover and press are drawn by terminalRowIndication over the whole row.

Go-to Button

GoToItemButton is activated by the row hover (parentActivated; the row interaction source is passed as bubbleInteractionSource).

  • Search results: the search icon is drawn by ChatItemView after the item, as for classic received items: at the end of the row, centred on the item; every row of the results is narrowed by the button and its 13dp start padding, so their times stay aligned.
  • Forwarded items drawn by FramedItemView: the arrow is passed as endButton and placed by ItemWithMeta at the end of the row, centred on the item above the line with the time (the forwarded header line for a one-line message); the text is narrowed by the arrow, and the time keeps its place.
  • Forwarded items shown as marked deleted (markedDeleted, no header line): the arrow after the item, as in search results.

Selection and Swipe

The selection checkbox (8dp start padding) and the full-row selection click overlay are shared with the classic layout. The row is moved by Modifier.terminalStartOffset for sent and received items alike. Swipe to reply uses the swipeableModifier of the classic layout on the whole row (Android; off in selection mode).

Theme Screens

LocalTerminalLayout is provided to the theme screens of a terminal chat or profile:

Screen Value
Chat theme (ChatWallpaperEditorModal) activeChatLayout of the chat
Appearance › Themes, preset selector (ThemesSection) profileChatLayout of the current user, or of none for the app theme destination
Customize theme, profile theme, colour editors the same, through profileLayoutIsTerminal and the terminal parameter of editColor

With the value true:

  • the preview draws two terminal rows ("alice", then "bob" in the own colour) without gutter, the gap between them by terminalRowGap and TERMINAL_SENDER_TOP_GAP, as in the chat; PreviewInEitherLayout measures both layouts and shows the active one centred in the larger height, so a layout switch keeps the preview height;
  • the preview and the preset tiles show the wallpaper colour without the image;
  • the custom image tile, the image scale, the scale slider and the wallpaper accent row are hidden.

Layout controls:

Control Values Stored in
Chat theme screen, first section (ChatLayoutSection) the chat default, "(chat)" when the chat layout is the default, otherwise "(default)"; the other layout chat theme record, by setChatLayout and apiSetChatUIThemes
Appearance › Themes, app theme destination (ChatLayoutRow) Classic, Terminal; the selection is profileChatLayout(null) appPrefs.chatLayout
Appearance › Themes, profile destination profileChatLayout(null) "(default)"; the other layout profile theme record, by saveThemeToDatabase and apiSetUserUIThemes