# Message Composition Specification Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt`, `SendMsgView.kt` --- ## Table of Contents 1. [Overview](#1-overview) 2. [ComposeState Data Class](#2-composestate-data-class) 3. [ComposePreview Sealed Class](#3-composepreview-sealed-class) 4. [ComposeContextItem Sealed Class](#4-composecontextitem-sealed-class) 5. [SendMsgView](#5-sendmsgview) 6. [Attachment Handling](#6-attachment-handling) 7. [Draft Persistence](#7-draft-persistence) 8. [Source Files](#8-source-files) 9. [Emoji Picker](#9-emoji-picker) --- ## Executive Summary Message composition in SimpleX Chat is managed by `ComposeView` (line ~345 in `ComposeView.kt`) backed by the serializable `ComposeState` data class. The compose area supports text input, link previews, media/file/voice attachments, reply/edit/forward contexts, live (streaming) messages, member @mentions, message reports, and timed (disappearing) messages. The `SendMsgView` composable (in `SendMsgView.kt`) provides the text field and action buttons. Draft state persists across chat switches when the privacy preference is enabled. --- ## 1. Overview ``` ComposeView |-- contextItemView() | |-- ContextItemView (QuotedItem) [reply indicator] | |-- ContextItemView (EditingItem) [edit indicator] | |-- ContextItemView (ForwardingItems) [forward indicator] | +-- ContextItemView (ReportedItem) [report indicator] |-- ReportReasonView [report reason header] |-- MsgNotAllowedView [disabled send reason] |-- previewView() | |-- ComposeLinkView [link preview card] | |-- ComposeImageView [media thumbnails] | |-- ComposeVoiceView [voice recording waveform] | +-- ComposeFileView [file name display] |-- ComposeActionButtons | |-- CommandsButton [bot commands "//"] | |-- AttachmentButton [paperclip icon] | +-- ComposeEmojiButton [desktop emoji picker popup] +-- SendMsgView |-- PlatformTextField [multiline text input] |-- DeleteTextButton [clear text, shown on long text] |-- SendMsgButton [arrow/check icon] |-- RecordVoiceView [microphone + hold-to-record] |-- StartLiveMessageButton [bolt icon] |-- CancelLiveMessageButton [cancel live] +-- TimedMessageDropdown [disappearing message timer] ``` --- ## 2. ComposeState Data Class **Location:** [`ComposeView.kt#L98`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L98) ```kotlin @Serializable data class ComposeState( val message: ComposeMessage = ComposeMessage(), val parsedMessage: List = emptyList(), val liveMessage: LiveMessage? = null, val preview: ComposePreview = ComposePreview.NoPreview, val contextItem: ComposeContextItem = ComposeContextItem.NoContextItem, val inProgress: Boolean = false, val progressByTimeout: Boolean = false, val useLinkPreviews: Boolean, val mentions: MentionedMembers = emptyMap() ) ``` ### Fields | Field | Type | Description | |---|---|---| | `message` | `ComposeMessage` | Current text and cursor selection (`TextRange`) | | `parsedMessage` | `List` | Markdown-parsed representation of message text | | `liveMessage` | `LiveMessage?` | Active live (streaming) message state | | `preview` | `ComposePreview` | Attachment preview (link, media, voice, file) | | `contextItem` | `ComposeContextItem` | Reply/edit/forward/report context | | `inProgress` | `Boolean` | Send operation in flight | | `progressByTimeout` | `Boolean` | Show spinner after 1-second send delay | | `useLinkPreviews` | `Boolean` | Link preview feature flag | | `mentions` | `MentionedMembers` | Map of mention display name to `CIMention` | ### Computed Properties | Property | Type | Description | |---|---|---| | `editing` | `Boolean` | True when `contextItem` is `EditingItem` | | `forwarding` | `Boolean` | True when `contextItem` is `ForwardingItems` | | `reporting` | `Boolean` | True when `contextItem` is `ReportedItem` | | `sendEnabled` | `() -> Boolean` | True when there is content to send and not in progress | | `linkPreviewAllowed` | `Boolean` | True when no media/voice/file preview is active | | `linkPreview` | `LinkPreview?` | Extracts link preview from `CLinkPreview` | | `attachmentDisabled` | `Boolean` | True when editing, forwarding, live, in-progress, or reporting | | `attachmentPreview` | `Boolean` | True when a file or media preview is showing | | `empty` | `Boolean` | True when no text, no preview, and no context item | | `whitespaceOnly` | `Boolean` | True when message text contains only whitespace | | `placeholder` | `String` | Input placeholder text (report reason text or default) | | `memberMentions` | `Map` | Extracted member ID map for API calls | ### ComposeMessage ```kotlin @Serializable data class ComposeMessage( val text: String = "", val selection: TextRange = TextRange.Zero ) ``` ### LiveMessage ```kotlin @Serializable data class LiveMessage( val chatItem: ChatItem, val typedMsg: String, val sentMsg: String, val sent: Boolean ) ``` Tracks a live (streaming) message: the associated `ChatItem`, the currently typed text, the last sent text, and whether the initial send has occurred. ### Serialization `ComposeState` is fully `@Serializable` with a custom `Saver` (line ~214) that uses `json.encodeToString`/`decodeFromString` for `rememberSaveable` persistence across configuration changes. --- ## 3. ComposePreview Sealed Class **Location:** [`ComposeView.kt#L52`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L52) ```kotlin sealed class ComposePreview { object NoPreview : ComposePreview() class CLinkPreview(val linkPreview: LinkPreview?) : ComposePreview() class MediaPreview(val images: List, val content: List) : ComposePreview() data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean) : ComposePreview() class FilePreview(val fileName: String, val uri: URI) : ComposePreview() } ``` | Variant | Fields | View | |---|---|---| | `NoPreview` | -- | Nothing shown | | `CLinkPreview` | `linkPreview: LinkPreview?` (null = loading) | `ComposeLinkView`: title, description, image thumbnail, cancel button | | `MediaPreview` | `images: List` (base64 thumbnails), `content: List` | `ComposeImageView`: horizontal thumbnail strip, cancel button | | `VoicePreview` | `voice: String` (file path), `durationMs: Int`, `finished: Boolean` | `ComposeVoiceView`: waveform visualization, duration, play/pause | | `FilePreview` | `fileName: String`, `uri: URI` | `ComposeFileView`: file icon, file name, cancel button | ### UploadContent Used within `MediaPreview` to track the source type: - `SimpleImage(uri: URI)` -- still image - `AnimatedImage(uri: URI)` -- GIF or animated WebP - `Video(uri: URI, duration: Int)` -- video with duration in seconds --- ## 4. ComposeContextItem Sealed Class **Location:** [`ComposeView.kt#L61`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L61) ```kotlin sealed class ComposeContextItem { object NoContextItem : ComposeContextItem() class QuotedItem(val chatItem: ChatItem) : ComposeContextItem() class EditingItem(val chatItem: ChatItem) : ComposeContextItem() class ForwardingItems(val chatItems: List, val fromChatInfo: ChatInfo) : ComposeContextItem() class ReportedItem(val chatItem: ChatItem, val reason: ReportReason) : ComposeContextItem() } ``` | Variant | Trigger | Compose Behavior | |---|---|---| | `NoContextItem` | Default state | Normal message composition | | `QuotedItem` | Swipe-to-reply or reply menu action | Shows quoted message indicator; sends with `quoted` parameter | | `EditingItem` | Edit menu action | Populates text field with existing message; send button becomes checkmark; calls `apiUpdateChatItem` | | `ForwardingItems` | Forward action from another chat | Shows forwarded items indicator; calls `apiForwardChatItems`; can include optional text message | | `ReportedItem` | Report menu action | Shows report indicator with reason; placeholder changes to reason text; calls `apiReportMessage` | ### Context Item View `contextItemView()` (line ~1098 in `ComposeView.kt`) renders the active context as a dismissible bar above the text input: - Icon: reply (ic_reply), edit (ic_edit_filled), forward (ic_forward), report (ic_flag) - Content: quoted message preview text with sender name - Close button: resets `contextItem` to `NoContextItem` (or `clearState()` for editing) --- ## 5. SendMsgView **Location:** [`SendMsgView.kt#L36`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/SendMsgView.kt#L36) ```kotlin fun SendMsgView( composeState: MutableState, showVoiceRecordIcon: Boolean, recState: MutableState, isDirectChat: Boolean, liveMessageAlertShown: SharedPreference, sendMsgEnabled: Boolean, userCantSendReason: Pair?, sendButtonEnabled: Boolean, sendToConnect: (() -> Unit)?, hideSendButton: Boolean, nextConnect: Boolean, needToAllowVoiceToContact: Boolean, allowedVoiceByPrefs: Boolean, sendButtonColor: Color, allowVoiceToContact: () -> Unit, timedMessageAllowed: Boolean, customDisappearingMessageTimePref: SharedPreference?, placeholder: String, sendMessage: (Int?) -> Unit, sendLiveMessage: (suspend () -> Unit)?, updateLiveMessage: (suspend () -> Unit)?, cancelLiveMessage: (() -> Unit)?, editPrevMessage: () -> Unit, onFilesPasted: (List) -> Unit, onMessageChange: (ComposeMessage) -> Unit, textStyle: MutableState, focusRequester: FocusRequester? ) ``` ### Layout The view is a `Box` containing: 1. **PlatformTextField:** Multiline text input (platform-specific `expect`). Handles text changes via `onMessageChange`, up-arrow to `editPrevMessage`, file paste via `onFilesPasted`, and Enter to send. 2. **DeleteTextButton:** Shown when text is long; clears the field. 3. **Action area** (bottom-right, stacked): - **Progress indicator:** Shown when `progressByTimeout` is true. - **Report confirm button:** Checkmark icon when context is `ReportedItem`. - **Voice record button:** Shown when message is empty, not editing/forwarding, no preview active. - `RecordVoiceView`: Hold-to-record with waveform display. - `DisallowedVoiceButton`: Shown when voice is disabled by preferences. - `VoiceButtonWithoutPermissionByPlatform`: Shown when microphone permission is not granted. - **Live message button:** Bolt icon, starts streaming message (calls `sendLiveMessage`). - **Send button:** Arrow icon (new message) or checkmark (editing/live). Long-press opens dropdown: - "Send live message" option - Timed message options (1min, 5min, 1hr, 8hr, 1day, 1week, 1month, custom) ### RecordingState ```kotlin sealed class RecordingState { object NotStarted : RecordingState() class Started(val filePath: String, val progressMs: Int) : RecordingState() class Finished(val filePath: String, val durationMs: Int) : RecordingState() } ``` Voice recording of 300ms or less is auto-cancelled. ### Disabled State When `sendMsgEnabled` is false (e.g., contact not ready, group permissions), an overlay covers the text field. If `userCantSendReason` is provided, tapping the overlay shows an alert explaining why sending is disabled. --- ## 6. Attachment Handling ### Attachment Selection The `AttachmentSelection` composable (line ~263 in `ComposeView.kt`) is an `expect` function with platform-specific implementations: **Android:** - Camera launcher (image capture) - Gallery launcher (image/video picker, multi-select) - File picker (any file type) **Desktop:** - File chooser dialog (filters for images or all files) ### ChooseAttachmentView Bottom sheet (`ModalBottomSheetLayout`) presenting attachment type options: | Option | Result | |---|---| | Camera (Android) | Launches camera intent; result processed as `SimpleImage` | | Gallery | Launches media picker; results processed via `processPickedMedia` | | File | Launches file picker; result processed via `processPickedFile` | ### File Processing **`processPickedFile`** (line ~281): 1. Checks file size against `maxFileSize` (XFTP limit). 2. Extracts file name from URI. 3. Sets `ComposePreview.FilePreview` on compose state. **`processPickedMedia`** (line ~300): 1. For each URI, determines type (image, animated image, video). 2. Images: Gets bitmap, creates `SimpleImage` or `AnimatedImage` upload content. 3. Videos: Extracts thumbnail and duration, creates `Video` upload content. 4. Generates base64 preview thumbnails (max 14KB). 5. Sets `ComposePreview.MediaPreview` with thumbnails and content list. **`onFilesAttached`** (line ~270): Groups dropped/pasted files into images and non-images; routes to `processPickedMedia` or `processPickedFile`. ### Send Flow On send (line ~603, `sendMessageAsync`): 1. **Forwarding:** Calls `apiForwardChatItems`, then optionally sends a text message quoting the last forwarded item. 2. **Editing:** Calls `apiUpdateChatItem` with updated `MsgContent`. 3. **Reporting:** Calls `apiReportMessage` with reason and text. 4. **New message:** Iterates over `msgs` (one per media item or single for text/file/voice): - Saves file to app storage (or remote host). - For voice: encrypts if `privacyEncryptLocalFiles` is enabled. - Calls `apiSendMessages` or `apiCreateChatItems` (local notes). 5. On failure of the last message, restores compose state for retry. ### Link Preview When `privacyLinkPreviews` is enabled and the message contains a URL: 1. `showLinkPreview` extracts first non-SimpleX, non-cancelled link from parsed markdown. 2. Sets `ComposePreview.CLinkPreview(null)` (loading state). 3. After 1.5s debounce, calls `getLinkPreview(url)`. 4. On success, updates to `CLinkPreview(linkPreview)`. 5. Cancel button adds the URL to `cancelledLinks` set. --- ## 7. Draft Persistence **Location:** [`ComposeView.kt#L1394`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1394) (`KeyChangeEffect(chatModel.chatId.value)`) Controlled by the `privacySaveLastDraft` preference. ### Save Behavior When the user navigates away from a chat (`chatModel.chatId.value` changes): | Compose State | Action | |---|---| | Live message active (text present or already sent) | Sends the live message immediately, clears draft | | In progress | Clears in-progress flag, clears previous draft | | Non-empty (text, preview, or context) | If `saveLastDraft` is true: saves `composeState.value` to `chatModel.draft.value` and `chatModel.draftChatId.value` | | Empty but draft exists for current chat | Restores draft from `chatModel.draft` | | Empty, no draft | Clears previous draft, deletes unused files | ### Restore Behavior When entering a chat (line ~132 in `ChatView.kt`): 1. Checks if `chatModel.draftChatId.value` matches the chat ID. 2. If match and draft is not null (and not a cross-chat forward), initializes `composeState` from the draft. 3. Otherwise, creates a fresh `ComposeState`. ### Desktop-specific On desktop, a `DisposableEffect` (line ~1256) saves the draft on dispose when forwarding content, since the `KeyChangeEffect` mechanism is Android-specific. ### Draft Display in Chat List When a draft exists for a chat, `ChatPreviewView` shows a pencil icon with the draft text instead of the last message preview. --- ## 8. Source Files | File | Description | |---|---| | `ComposeView.kt` | ComposeState, ComposePreview, ComposeContextItem, ComposeView composable, send logic, link preview, draft persistence | | `SendMsgView.kt` | Text input field, send/voice/live/timed buttons, recording state | | `ComposeFileView.kt` | File attachment preview (name, cancel) | | `ComposeImageView.kt` | Media attachment preview (thumbnails, cancel) | | `ComposeVoiceView.kt` | Voice recording preview (waveform, duration, play) | | `ContextItemView.kt` | Reply/edit/forward/report context bar | | `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 | | `SelectableChatItemToolbars.kt` | Multi-select mode toolbar (delete, forward, moderate) | | `ComposeEmojiPicker.kt` (+ `.android.kt`, `.desktop.kt`) | Desktop emoji button and picker, emoji insertion | --- ## 9. Emoji Picker **Source:** [`ComposeEmojiPicker.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt), [`ComposeEmojiPicker.desktop.kt`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt) Desktop only; on Android the [`ComposeEmojiButton()`](../../common/src/androidMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.android.kt) actual is empty, as keyboards have their own emoji panel. An emoji button in [`ComposeActionButtons()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1346-L1363) toggles `emojiPickerShown`, owned by `ComposeView`. A picked emoji goes through [`onEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1290-L1292): [`insertingEmoji()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.kt#L10-L14) replaces the selection (clamped to the text) and moves the cursor after the emoji, then `onMessageChange` applies markdown parsing, link sanitizing and the large-emoji font. Emoji are ignored while a message is in progress. The picker stays open after picking. The text field collapses its selection to the end when the popup takes focus, so the emoji goes after selected text. [`emojiEnabled`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt#L1282-L1288) is false when sending is not allowed or a voice message is previewed; the picker closes then. When the picker closes, the text field gets focus through `focusRequester`. | Function | Behavior | |---|---| | [`ComposeEmojiButton()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L89-L115) | Button 4 dp after the attachment button (`EMOJI_BUTTON_START_PADDING`, against the row's -8 dp overlap). Focusable `Popup` above the button; closes on Esc (on key release, so the window does not also close its top modal), outside click or the button; its height is capped to the space above the button | | [`EmojiPicker()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L127-L178) | Search field, category tabs, `LazyVerticalGrid` with one section per `EmojiCategory`; tabs and cells are not focusable, so typing after a click goes to search. Picked emoji are not stored, so nothing about what the user typed is kept in preferences | Data: `desktopMain/resources/emoji/emoji.txt` is generated by `scripts/desktop/generate-emoji-data.py` from Unicode `emoji-test.txt` 15.0 (the version covered by the bundled NotoColorEmoji): fully-qualified emoji without skin tone variants, `@` lines followed by `\t` lines. [`parseEmojiData()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L59-L76) maps groups to `EmojiCategory` and fails on an unknown group; [`emojiSections`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L78-L82) loads it lazily. [`searchEmoji()`](../../common/src/desktopMain/kotlin/chat/simplex/common/views/chat/ComposeEmojiPicker.desktop.kt#L84-L87) matches the English name case-insensitively. [GAP] No skin tone selection, and search uses English emoji names only.