From 7810fc47529f24735f681d4e921e2ef2ee2aa7fc Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Sun, 25 Oct 2026 18:03:11 +0000 Subject: [PATCH] core, ui: terminal chat layout for LLM and service bot chats (#7811) * 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 --------- Co-authored-by: Evgeny Poberezkin --- apps/ios/Shared/Model/AppAPITypes.swift | 6 +- apps/ios/Shared/Views/Chat/ChatInfoView.swift | 6 +- .../Views/UserSettings/AppSettings.swift | 2 + .../UserSettings/AppearanceSettings.swift | 8 +- .../Views/UserSettings/SettingsView.swift | 4 + .../Views/UserSettings/UserAddressView.swift | 5 +- apps/ios/SimpleXChat/ChatTypes.swift | 37 ++ apps/ios/SimpleXChat/Theme/ThemeTypes.swift | 8 +- apps/ios/spec/services/theme.md | 17 +- apps/multiplatform/common/build.gradle.kts | 6 +- .../chat/simplex/common/model/ChatModel.kt | 56 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 17 +- .../chat/simplex/common/ui/theme/Theme.kt | 9 +- .../simplex/common/views/chat/ChatInfoView.kt | 57 ++- .../common/views/chat/ChatItemsMerger.kt | 7 +- .../simplex/common/views/chat/ChatView.kt | 80 ++- .../common/views/chat/item/CICallItemView.kt | 46 +- .../views/chat/item/CIChatFeatureView.kt | 39 +- .../common/views/chat/item/CIEventView.kt | 34 +- .../chat/item/CIFeaturePreferenceView.kt | 53 +- .../common/views/chat/item/CIFileView.kt | 11 +- .../views/chat/item/CIGroupInvitationView.kt | 118 ++--- .../chat/item/CIMemberCreatedContactView.kt | 65 +-- .../common/views/chat/item/CIMetaView.kt | 120 ++++- .../views/chat/item/CIRcvDecryptionError.kt | 104 ++-- .../common/views/chat/item/CIVoiceView.kt | 38 +- .../common/views/chat/item/ChatItemView.kt | 72 +-- .../common/views/chat/item/DeletedItemView.kt | 34 +- .../common/views/chat/item/EmojiItemView.kt | 21 +- .../common/views/chat/item/FramedItemView.kt | 459 ++++++++++-------- .../views/chat/item/IntegrityErrorItemView.kt | 36 +- .../common/views/chat/item/MarkdownBlocks.kt | 76 ++- .../views/chat/item/MarkedDeletedItemView.kt | 24 +- .../views/chat/item/TerminalItemView.kt | 383 +++++++++++++++ .../common/views/chat/item/TextItemView.kt | 48 +- .../common/views/helpers/ThemeModeEditor.kt | 54 ++- .../common/views/usersettings/Appearance.kt | 176 +++++-- .../views/usersettings/UserAddressView.kt | 29 +- .../moko-resources/base/strings.xml | 5 + .../chat/simplex/app/TerminalItemViewTest.kt | 234 +++++++++ .../chat/simplex/app/ChatEventTextTest.kt | 53 ++ .../kotlin/chat/simplex/app/LayoutRecorder.kt | 34 ++ .../chat/simplex/app/PriorityLayoutTest.kt | 66 +++ .../chat/simplex/app/TerminalMetaCopyTest.kt | 158 ++++++ .../simplex/app/TerminalMetaLayoutTest.kt | 160 ++++++ apps/multiplatform/gradle.properties | 1 + apps/multiplatform/product/concepts.md | 1 + apps/multiplatform/product/views/chat.md | 8 +- apps/multiplatform/spec/README.md | 4 +- apps/multiplatform/spec/client/chat-view.md | 189 +++++++- apps/multiplatform/spec/impact.md | 119 ++--- apps/multiplatform/spec/services/theme.md | 40 +- apps/multiplatform/spec/state.md | 13 + .../src/BadgeService/Group.hs | 3 +- bots/api/TYPES.md | 15 + bots/src/API/Docs/Commands.hs | 1 + bots/src/API/Docs/Types.hs | 2 + docs/protocol/simplex-chat.md | 2 + docs/protocol/simplex-chat.schema.json | 1 + .../types/typescript/src/types.ts | 10 + .../typescript/src/client.ts | 30 +- packages/simplex-chat-nodejs/src/api.ts | 10 +- packages/simplex-chat-nodejs/src/bot.ts | 3 +- packages/simplex-chat-nodejs/src/util.ts | 3 +- .../tests/bot.unit.test.ts | 28 ++ .../src/simplex_chat/bot.py | 4 + .../src/simplex_chat/types/_types.py | 7 + .../tests/test_client_and_waiters.py | 54 ++- plans/2026-10-13-agent-profile-chat-layout.md | 337 +++++++++++++ plans/2026-10-13-flat-chat-layout-ui.md | 263 ++++++++++ plans/2026-10-13-message-parts.md | 400 +++++++++++++++ plans/2026-10-13-message-priority.md | 399 +++++++++++++++ plans/2026-10-13-message-read-more.md | 400 +++++++++++++++ simplex-chat.cabal | 2 + src/Simplex/Chat/AppSettings.hs | 16 +- src/Simplex/Chat/Bot.hs | 4 +- src/Simplex/Chat/Controller.hs | 3 +- src/Simplex/Chat/Library/Commands.hs | 35 +- src/Simplex/Chat/Library/Internal.hs | 12 +- src/Simplex/Chat/Library/Subscriber.hs | 4 +- src/Simplex/Chat/Protocol.hs | 2 + src/Simplex/Chat/Store/Connections.hs | 8 +- src/Simplex/Chat/Store/ContactRequest.hs | 2 +- src/Simplex/Chat/Store/Direct.hs | 13 +- src/Simplex/Chat/Store/Groups.hs | 39 +- src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20261019_chat_layout.hs | 23 + .../Store/Postgres/Migrations/chat_schema.sql | 9 +- src/Simplex/Chat/Store/Profiles.hs | 17 +- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20261019_chat_layout.hs | 22 + .../SQLite/Migrations/agent_query_plans.txt | 4 - .../SQLite/Migrations/chat_query_plans.txt | 42 +- .../Store/SQLite/Migrations/chat_schema.sql | 5 +- src/Simplex/Chat/Store/Shared.hs | 24 +- src/Simplex/Chat/Types.hs | 4 +- src/Simplex/Chat/Types/UITheme.hs | 29 +- src/Simplex/Chat/View.hs | 25 +- tests/Bots/BadgeService/GroupTests.hs | 3 +- tests/ChatTests/Groups.hs | 28 ++ tests/ChatTests/Profiles.hs | 169 ++++++- tests/ChatTests/Utils.hs | 16 + tests/ProtocolTests.hs | 9 +- 103 files changed, 5098 insertions(+), 901 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt create mode 100644 apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/TerminalItemViewTest.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatEventTextTest.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/PriorityLayoutTest.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaCopyTest.kt create mode 100644 apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaLayoutTest.kt create mode 100644 plans/2026-10-13-agent-profile-chat-layout.md create mode 100644 plans/2026-10-13-flat-chat-layout-ui.md create mode 100644 plans/2026-10-13-message-parts.md create mode 100644 plans/2026-10-13-message-priority.md create mode 100644 plans/2026-10-13-message-read-more.md create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20261019_chat_layout.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20261019_chat_layout.hs diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 6af17e3fb8..2d7855b56b 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -1622,6 +1622,7 @@ struct AddressSettings: Codable, Hashable { var businessAddress: Bool var autoAccept: AutoAccept? var autoReply: MsgContent? + var chatLayout: ChatLayout? } struct AutoAccept: Codable, Hashable { @@ -2300,6 +2301,7 @@ struct AppSettings: Codable, Equatable { var uiThemes: [ThemeOverrides]? = nil var oneHandUI: Bool? = nil var chatBottomBar: Bool? = nil + var uiChatLayout: ChatLayout? = nil func prepareForExport() -> AppSettings { var empty = AppSettings() @@ -2336,6 +2338,7 @@ struct AppSettings: Codable, Equatable { if uiThemes != def.uiThemes { empty.uiThemes = uiThemes } if oneHandUI != def.oneHandUI { empty.oneHandUI = oneHandUI } if chatBottomBar != def.chatBottomBar { empty.chatBottomBar = chatBottomBar } + if uiChatLayout != def.uiChatLayout { empty.uiChatLayout = uiChatLayout } return empty } @@ -2372,7 +2375,8 @@ struct AppSettings: Codable, Equatable { uiCurrentThemeIds: nil as [String: String]?, uiThemes: nil as [ThemeOverrides]?, oneHandUI: true, - chatBottomBar: true + chatBottomBar: true, + uiChatLayout: .classic ) } } diff --git a/apps/ios/Shared/Views/Chat/ChatInfoView.swift b/apps/ios/Shared/Views/Chat/ChatInfoView.swift index 3d1e7ef477..7857646177 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoView.swift @@ -1023,14 +1023,14 @@ struct ChatWallpaperEditorSheet: View { let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile)) if currentMode == DefaultThemeMode.light { dark.wallpaper?.imageFile = filePath - changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark) + changedThemes?.dark = dark } else { light.wallpaper?.imageFile = filePath - changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark) + changedThemes?.light = light } } } else { - changedThemes = nil + changedThemes = changedThemes?.layoutOnly() } wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile) wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile) diff --git a/apps/ios/Shared/Views/UserSettings/AppSettings.swift b/apps/ios/Shared/Views/UserSettings/AppSettings.swift index 3554ce720f..849abb4d62 100644 --- a/apps/ios/Shared/Views/UserSettings/AppSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppSettings.swift @@ -67,6 +67,7 @@ extension AppSettings { if let val = uiThemes { themeOverridesDefault.set(val.skipDuplicates()) } if let val = oneHandUI { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_ONE_HAND_UI) } if let val = chatBottomBar { groupDefaults.setValue(val, forKey: GROUP_DEFAULT_CHAT_BOTTOM_BAR) } + if let val = uiChatLayout { chatLayoutDefault.set(val) } } public static var current: AppSettings { @@ -104,6 +105,7 @@ extension AppSettings { c.uiThemes = themeOverridesDefault.get() c.oneHandUI = groupDefaults.bool(forKey: GROUP_DEFAULT_ONE_HAND_UI) c.chatBottomBar = groupDefaults.bool(forKey: GROUP_DEFAULT_CHAT_BOTTOM_BAR) + c.uiChatLayout = chatLayoutDefault.get() return c } } diff --git a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift index 54a60eed19..d45ca12f75 100644 --- a/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift +++ b/apps/ios/Shared/Views/UserSettings/AppearanceSettings.swift @@ -775,14 +775,14 @@ struct UserWallpaperEditorSheet: View { let filePath = saveWallpaperFile(url: getWallpaperFilePath(imageFile)) if currentMode == DefaultThemeMode.light { dark.wallpaper?.imageFile = filePath - changedThemes = ThemeModeOverrides(light: changedThemes?.light, dark: dark) + changedThemes?.dark = dark } else { light.wallpaper?.imageFile = filePath - changedThemes = ThemeModeOverrides(light: light, dark: changedThemes?.dark) + changedThemes?.light = light } } } else { - changedThemes = nil + changedThemes = changedThemes?.layoutOnly() } wallpaperFiles.remove(changedThemes?.light?.wallpaper?.imageFile) wallpaperFiles.remove(changedThemes?.dark?.wallpaper?.imageFile) @@ -1135,7 +1135,7 @@ private func saveThemeToDatabase(_ themeUserDestination: (Int64, ThemeModeOverri private func removeUserThemeModeOverrides(_ themeUserDestination: Binding<(Int64, ThemeModeOverrides?)?>, _ perUserTheme: Binding) { guard let dest = themeUserDestination.wrappedValue else { return } perUserTheme.wrappedValue = ThemeModeOverride(mode: CurrentColors.base.mode) - themeUserDestination.wrappedValue = (dest.0, nil) + themeUserDestination.wrappedValue = (dest.0, dest.1?.layoutOnly()) var wallpaperFilesToDelete: [String] = [] if let type = ChatModel.shared.currentUser?.uiThemes?.light?.type, case let WallpaperType.image(filename, _, _) = type { wallpaperFilesToDelete.append(filename) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index f7042b0c39..493bf7914d 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -57,6 +57,7 @@ let DEFAULT_USER_INTERFACE_STYLE = "userInterfaceStyle" // deprecated, only used let DEFAULT_PROFILE_IMAGE_CORNER_RADIUS = "profileImageCornerRadius" let DEFAULT_CHAT_ITEM_ROUNDNESS = "chatItemRoundness" let DEFAULT_CHAT_ITEM_TAIL = "chatItemTail" +let DEFAULT_CHAT_LAYOUT = "chatLayout" let DEFAULT_ONE_HAND_UI_CARD_SHOWN = "oneHandUICardShown" let DEFAULT_ADDRESS_CREATION_CARD_SHOWN = "addressCreationCardShown" let DEFAULT_SUPPORTER_BANNER_SHOWN = "supporterBannerShown" @@ -123,6 +124,7 @@ let appDefaults: [String: Any] = [ DEFAULT_PROFILE_IMAGE_CORNER_RADIUS: defaultProfileImageCorner, DEFAULT_CHAT_ITEM_ROUNDNESS: defaultChatItemRoundness, DEFAULT_CHAT_ITEM_TAIL: true, + DEFAULT_CHAT_LAYOUT: ChatLayout.classic.rawValue, DEFAULT_ONE_HAND_UI_CARD_SHOWN: false, DEFAULT_ADDRESS_CREATION_CARD_SHOWN: false, DEFAULT_SUPPORTER_BANNER_SHOWN: false, @@ -214,6 +216,8 @@ let shouldDeleteDatabaseBackupsDefault = BoolDefault(defaults: UserDefaults.stan let connectViaLinkTabDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CONNECT_VIA_LINK_TAB, withDefault: .scan) +let chatLayoutDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CHAT_LAYOUT, withDefault: .classic) + let privacySimplexLinkModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_PRIVACY_SIMPLEX_LINK_MODE, withDefault: .description) let privacyLocalAuthModeDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_LA_MODE, withDefault: .system) diff --git a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift index 61c808dda1..f25733a002 100644 --- a/apps/ios/Shared/Views/UserSettings/UserAddressView.swift +++ b/apps/ios/Shared/Views/UserSettings/UserAddressView.swift @@ -582,6 +582,7 @@ struct AddressSettingsState: Equatable { var autoAccept = false var autoAcceptIncognito = false var autoReply = "" + var chatLayout: ChatLayout? init() {} @@ -590,13 +591,15 @@ struct AddressSettingsState: Equatable { self.autoAccept = settings.autoAccept != nil self.autoAcceptIncognito = settings.autoAccept?.acceptIncognito == true self.autoReply = settings.autoReply?.text ?? "" + self.chatLayout = settings.chatLayout } var addressSettings: AddressSettings { AddressSettings( businessAddress: self.businessAddress, autoAccept: self.autoAccept ? AutoAccept(acceptIncognito: self.autoAcceptIncognito) : nil, - autoReply: self.autoReply.isEmpty ? nil : MsgContent.text(self.autoReply) + autoReply: self.autoReply.isEmpty ? nil : MsgContent.text(self.autoReply), + chatLayout: self.chatLayout ) } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b40d14cd56..bc59534a86 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -261,6 +261,41 @@ extension ChatPeerType: Codable { } } +public enum ChatLayout: Hashable { + case classic + case terminal + case unknown(String) +} + +extension ChatLayout: RawRepresentable { + public init(rawValue: String) { + switch rawValue { + case "classic": self = .classic + case "terminal": self = .terminal + default: self = .unknown(rawValue) + } + } + + public var rawValue: String { + switch self { + case .classic: "classic" + case .terminal: "terminal" + case let .unknown(s): s + } + } +} + +extension ChatLayout: Codable { + public init(from decoder: Decoder) throws { + self.init(rawValue: try decoder.singleValueContainer().decode(String.self)) + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + try c.encode(rawValue) + } +} + public func peerTypeIcon(_ peerType: ChatPeerType?) -> Image { switch peerType { case .bot: Image(systemName: "cube.fill") @@ -3072,6 +3107,7 @@ public struct GroupProfile: Codable, NamedChat, Hashable { public var publicGroup: PublicGroupProfile? public var groupPreferences: GroupPreferences? public var memberAdmission: GroupMemberAdmission? + public var chatLayout: ChatLayout? public var localAlias: String { "" } public var memberAdmission_: GroupMemberAdmission { @@ -3119,6 +3155,7 @@ public struct ContactShortLinkData: Codable, Hashable { public var profile: Profile public var message: MsgContent? public var business: Bool + public var chatLayout: ChatLayout? // set by the core when building the connection plan: the link profile's badge, verified and crypto-free public var localBadge: LocalBadge? } diff --git a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift index a4e8050c6e..f8e800f15e 100644 --- a/apps/ios/SimpleXChat/Theme/ThemeTypes.swift +++ b/apps/ios/SimpleXChat/Theme/ThemeTypes.swift @@ -570,15 +570,21 @@ extension [ThemeOverrides] { public struct ThemeModeOverrides: Codable, Hashable { public var light: ThemeModeOverride? = nil public var dark: ThemeModeOverride? = nil + public var chatLayout: ChatLayout? = nil - public init(light: ThemeModeOverride? = nil, dark: ThemeModeOverride? = nil) { + public init(light: ThemeModeOverride? = nil, dark: ThemeModeOverride? = nil, chatLayout: ChatLayout? = nil) { self.light = light self.dark = dark + self.chatLayout = chatLayout } public func preferredMode(_ darkTheme: Bool) -> ThemeModeOverride? { darkTheme ? dark : light } + + public func layoutOnly() -> ThemeModeOverrides? { + chatLayout.map { ThemeModeOverrides(chatLayout: $0) } + } } // Spec: spec/services/theme.md#ThemeModeOverride diff --git a/apps/ios/spec/services/theme.md b/apps/ios/spec/services/theme.md index 321f3307f9..3b14e7175b 100644 --- a/apps/ios/spec/services/theme.md +++ b/apps/ios/spec/services/theme.md @@ -91,7 +91,7 @@ struct ActiveTheme: Equatable { 1. Determine base theme from `currentThemeDefault`: - If `"system"`: use light or dark based on [`systemInDarkThemeCurrently`](../../Shared/Theme/Theme.swift#L95) - Dark mode maps to `systemDarkThemeDefault` (Dark, SimpleX, or Black) -2. Get base color palette ([`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650), [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629), [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671), [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692)) +2. Get base color palette ([`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L656), [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L635), [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L677), [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L698)) 3. Look up app settings theme override (`themeOverridesDefault`) 4. Look up per-user theme override (`User.uiThemes`) 5. Look up per-chat theme override (from ChatInfo) @@ -135,10 +135,10 @@ Each base theme defines two palette types: - [`AppColors`](../../SimpleXChat/Theme/ThemeTypes.swift#L90): App-specific colors (sentMessage, receivedMessage, title, primaryVariant2) Palette instances: -- [`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L650) / [`LightColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L662) -- [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L629) / [`DarkColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L641) -- [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L671) / [`SimplexColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L683) -- [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L692) / [`BlackColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L704) +- [`LightColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L656) / [`LightColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L668) +- [`DarkColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L635) / [`DarkColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L647) +- [`SimplexColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L677) / [`SimplexColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L689) +- [`BlackColorPalette`](../../SimpleXChat/Theme/ThemeTypes.swift#L698) / [`BlackColorPaletteApp`](../../SimpleXChat/Theme/ThemeTypes.swift#L710) --- @@ -168,10 +168,13 @@ Stored on the `User` object (`User.uiThemes: ThemeModeOverrides?`), persisted in struct ThemeModeOverrides: Codable { var light: ThemeModeOverride? var dark: ThemeModeOverride? + var chatLayout: ChatLayout? } ``` -#### [`ThemeModeOverride`](../../SimpleXChat/Theme/ThemeTypes.swift#L585) +`chatLayout` is set by the Android and desktop apps; iOS keeps it in theme writes: "Reset to app theme" and "Remove image" keep only the layout (`layoutOnly()`). + +#### [`ThemeModeOverride`](../../SimpleXChat/Theme/ThemeTypes.swift#L591) ```swift struct ThemeModeOverride: Codable { @@ -374,7 +377,7 @@ Key functions in [`AppearanceSettings.swift`](../../Shared/Views/UserSettings/Ap | File | Path | Key Definitions | |------|------|-----------------| | Theme manager | [`Shared/Theme/ThemeManager.swift`](../../Shared/Theme/ThemeManager.swift) | `ThemeManager` (L15), `ActiveTheme` (L17) | -| Theme types & colors | [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | `DefaultTheme` (L13), `Colors` (L44), `AppColors` (L90), `AppWallpaper` (L142), `ThemeColors` (L230), `ThemeWallpaper` (L302), `ThemeOverrides` (L385), `ThemeModeOverrides` (L570), `ThemeModeOverride` (L585) | +| Theme types & colors | [`SimpleXChat/Theme/ThemeTypes.swift`](../../SimpleXChat/Theme/ThemeTypes.swift) | `DefaultTheme` (L13), `Colors` (L44), `AppColors` (L90), `AppWallpaper` (L142), `ThemeColors` (L230), `ThemeWallpaper` (L302), `ThemeOverrides` (L385), `ThemeModeOverrides` (L570), `ThemeModeOverride` (L591) | | Wallpaper types | [`SimpleXChat/Theme/ChatWallpaperTypes.swift`](../../SimpleXChat/Theme/ChatWallpaperTypes.swift) | `PresetWallpaper` (L13), `WallpaperType` (L311) | | Color utilities | [`SimpleXChat/Theme/Color.swift`](../../SimpleXChat/Theme/Color.swift) | Hex color conversion | | App theme observable | [`Shared/Theme/Theme.swift`](../../Shared/Theme/Theme.swift) | `AppTheme` (L22), `CurrentColors` (L14), `systemInDarkThemeCurrently` (L95) | diff --git a/apps/multiplatform/common/build.gradle.kts b/apps/multiplatform/common/build.gradle.kts index e9503bc6ce..0ed62884ba 100644 --- a/apps/multiplatform/common/build.gradle.kts +++ b/apps/multiplatform/common/build.gradle.kts @@ -146,7 +146,11 @@ kotlin { implementation("com.squareup.okhttp3:okhttp:4.12.0") } } - val desktopTest by getting + val desktopTest by getting { + dependencies { + implementation(compose.desktop.currentOs) + } + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 2b9c4d8201..2dbec6edde 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -1865,6 +1865,24 @@ sealed class ChatInfo: SomeChat, NamedChat { else -> emptyList() } + val chatLayout: ChatLayoutMode? get() = when (this) { + is Direct -> contact.chatLayout + is Group -> groupInfo.groupProfile.chatLayout + else -> null + } + + val uiThemes: ThemeModeOverrides? get() = when (this) { + is Direct -> contact.uiThemes + is Group -> groupInfo.uiThemes + else -> null + } + + fun defaultChatLayout(user: User?): ChatLayoutMode = + chatLayout.known ?: profileChatLayout(user) + + fun activeChatLayout(user: User?): ChatLayoutMode = + uiThemes?.chatLayout.known ?: defaultChatLayout(user) + val contactCard: Boolean get() = when (this) { is Direct -> contact.isContactCard @@ -1908,6 +1926,7 @@ data class Contact( val chatItemTTL: Long?, override val chatDeleted: Boolean, val uiThemes: ThemeModeOverrides? = null, + val chatLayout: ChatLayoutMode? = null, ): SomeChat, NamedChat { override val chatType get() = ChatType.Direct override val id get() = "@$contactId" @@ -2202,6 +2221,39 @@ object ChatPeerTypeSerializer : KSerializer { override fun serialize(encoder: Encoder, value: ChatPeerType) = encoder.encodeString(value.text) } +@Serializable(with = ChatLayoutModeSerializer::class) +sealed class ChatLayoutMode { + @Serializable @SerialName("classic") object Classic: ChatLayoutMode() + @Serializable @SerialName("terminal") object Terminal: ChatLayoutMode() + @Serializable @SerialName("unknown") data class Unknown(val type: String): ChatLayoutMode() + + val text: String + get() = when (this) { + is Classic -> "classic" + is Terminal -> "terminal" + is Unknown -> type + } + + companion object { + fun fromText(text: String): ChatLayoutMode = when (text) { + "classic" -> Classic + "terminal" -> Terminal + else -> Unknown(text) + } + } +} + +object ChatLayoutModeSerializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ChatLayoutMode", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder): ChatLayoutMode = ChatLayoutMode.fromText(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: ChatLayoutMode) = encoder.encodeString(value.text) +} + +private val ChatLayoutMode?.known: ChatLayoutMode? get() = if (this is ChatLayoutMode.Unknown) null else this + +fun profileChatLayout(user: User?): ChatLayoutMode = + user?.uiThemes?.chatLayout.known ?: ChatController.appPrefs.chatLayout.state.value.known ?: ChatLayoutMode.Classic + fun peerTypeIcon(peerType: ChatPeerType?): ImageResource = when (peerType) { ChatPeerType.Bot -> MR.images.ic_cube @@ -2696,7 +2748,8 @@ data class GroupProfile ( val publicGroup: PublicGroupProfile? = null, override val localAlias: String = "", val groupPreferences: GroupPreferences? = null, - val memberAdmission: GroupMemberAdmission? = null + val memberAdmission: GroupMemberAdmission? = null, + val chatLayout: ChatLayoutMode? = null ): NamedChat { val isChannel: Boolean get() = publicGroup?.groupType == GroupType.Channel @@ -2735,6 +2788,7 @@ data class ContactShortLinkData ( val profile: Profile, val message: MsgContent?, val business: Boolean, + val chatLayout: ChatLayoutMode? = null, // set by the core when building the connection plan: the link profile's badge, verified and crypto-free val localBadge: LocalBadge? = null ) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index da453bf585..e4fb6d2cd1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -279,6 +279,10 @@ class AppPreferences { val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, true) val chatBottomBar = mkBoolPreference(SHARED_PREFS_CHAT_BOTTOM_BAR, true) + val chatLayout = SharedPreference( + get = fun() = ChatLayoutMode.fromText(settings.getString(SHARED_PREFS_CHAT_LAYOUT, ChatLayoutMode.Classic.text)), + set = fun(value: ChatLayoutMode) = settings.putString(SHARED_PREFS_CHAT_LAYOUT, value.text) + ) val hintPreferences: List = listOf( hintPref(laNoticeShown, false), @@ -501,6 +505,7 @@ class AppPreferences { private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades" private const val SHARED_PREFS_ONE_HAND_UI = "OneHandUI" private const val SHARED_PREFS_CHAT_BOTTOM_BAR = "ChatBottomBar" + private const val SHARED_PREFS_CHAT_LAYOUT = "ChatLayout" private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct" private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName" private const val SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED = "PQExperimentalEnabled" // no longer used @@ -7779,7 +7784,8 @@ data class UserContactLinkRec( data class AddressSettings( val businessAddress: Boolean, val autoAccept: AutoAccept?, - val autoReply: MsgContent? + val autoReply: MsgContent?, + val chatLayout: ChatLayoutMode? = null ) @Serializable @@ -8735,7 +8741,8 @@ data class AppSettings( var uiCurrentThemeIds: Map? = null, var uiThemes: List? = null, var oneHandUI: Boolean? = null, - var chatBottomBar: Boolean? = null + var chatBottomBar: Boolean? = null, + var uiChatLayout: ChatLayoutMode? = null ) { fun prepareForExport(): AppSettings { val empty = AppSettings() @@ -8773,6 +8780,7 @@ data class AppSettings( if (uiThemes != def.uiThemes) { empty.uiThemes = uiThemes } if (oneHandUI != def.oneHandUI) { empty.oneHandUI = oneHandUI } if (chatBottomBar != def.chatBottomBar) { empty.chatBottomBar = chatBottomBar } + if (uiChatLayout != def.uiChatLayout) { empty.uiChatLayout = uiChatLayout } return empty } @@ -8821,6 +8829,7 @@ data class AppSettings( uiThemes?.let { def.themeOverrides.set(it.skipDuplicates()) } oneHandUI?.let { def.oneHandUI.set(it) } chatBottomBar?.let { if (appPlatform.isAndroid) def.chatBottomBar.set(it) else def.chatBottomBar.set(true) } + uiChatLayout?.let { def.chatLayout.set(it) } } companion object { @@ -8859,6 +8868,7 @@ data class AppSettings( uiThemes = null, oneHandUI = true, chatBottomBar = true, + uiChatLayout = ChatLayoutMode.Classic, ) val current: AppSettings @@ -8897,7 +8907,8 @@ data class AppSettings( uiCurrentThemeIds = def.currentThemeIds.get(), uiThemes = def.themeOverrides.get(), oneHandUI = def.oneHandUI.get(), - chatBottomBar = def.chatBottomBar.get() + chatBottomBar = def.chatBottomBar.get(), + uiChatLayout = def.chatLayout.get() ) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt index b6d6a4d771..aade56f305 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.* import chat.simplex.common.model.ChatController import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatLayoutMode import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.ui.theme.ThemeManager.toReadableHex @@ -474,14 +475,20 @@ fun List.skipDuplicates(): List { @Serializable data class ThemeModeOverrides ( val light: ThemeModeOverride? = null, - val dark: ThemeModeOverride? = null + val dark: ThemeModeOverride? = null, + val chatLayout: ChatLayoutMode? = null ) { fun preferredMode(darkTheme: Boolean): ThemeModeOverride? = when (darkTheme) { false -> light else -> dark } + + fun layoutOnly(): ThemeModeOverrides? = chatLayout?.let { ThemeModeOverrides(chatLayout = it) } } +fun ThemeModeOverrides?.withChatLayout(chatLayout: ChatLayoutMode?): ThemeModeOverrides? = + (this ?: ThemeModeOverrides()).copy(chatLayout = chatLayout).takeIf { it != ThemeModeOverrides() } + // Spec: spec/services/theme.md#ThemeModeOverride @Serializable data class ThemeModeOverride ( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt index 82575984f6..16834a2ae9 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt @@ -40,6 +40,7 @@ import chat.simplex.common.views.helpers.* import chat.simplex.common.views.usersettings.* import chat.simplex.common.platform.* import chat.simplex.common.views.chat.group.ChatTTLOption +import chat.simplex.common.views.chat.item.LocalTerminalLayout import chat.simplex.common.views.chat.item.MarkdownText import chat.simplex.common.views.chatlist.updateChatSettings import chat.simplex.common.views.newchat.* @@ -1296,13 +1297,40 @@ fun ModalData.ChatWallpaperEditorModal(chat: Chat) { globalThemeUsed.value = preferred == null preferred ?: ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) } - ChatWallpaperEditor( - initialTheme, - applyToMode = if (themes.light == themes.dark) null else initialTheme.mode, - globalThemeUsed = globalThemeUsed, - save = { applyToMode, newTheme -> - save(applyToMode, newTheme, chatModel.getChat(chat.id) ?: chat) - }) + CompositionLocalProvider(LocalTerminalLayout provides (chat.chatInfo.activeChatLayout(chatModel.currentUser.value) == ChatLayoutMode.Terminal)) { + ChatWallpaperEditor( + initialTheme, + applyToMode = if (themes.light == themes.dark) null else initialTheme.mode, + globalThemeUsed = globalThemeUsed, + layoutSection = { ChatLayoutSection(chat.remoteHostId, chat.chatInfo) }, + save = { applyToMode, newTheme -> + save(applyToMode, newTheme, chatModel.getChat(chat.id) ?: chat) + }) + } +} + +@Composable +private fun ChatLayoutSection(rhId: Long?, chatInfo: ChatInfo) { + val defaultChatLayout = chatInfo.defaultChatLayout(chatModel.currentUser.value) + val otherLayout = otherChatLayout(defaultChatLayout) + val defaultSuffix = generalGetString(if (chatInfo.chatLayout == defaultChatLayout) MR.strings.chat_layout_chat_suffix else MR.strings.chat_layout_default_suffix) + val values = listOf( + null to "${chatLayoutName(defaultChatLayout)} $defaultSuffix", + otherLayout to chatLayoutName(otherLayout) + ) + val userChatLayout = chatInfo.uiThemes?.chatLayout + val selection = remember(userChatLayout, otherLayout) { mutableStateOf(if (userChatLayout == otherLayout) otherLayout else null) } + SectionView { + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_layout), + values, + selection, + onSelected = { layout -> + selection.value = layout + withBGApi { setChatLayout(rhId, chatInfo, layout) } + } + ) + } } suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, chat: Chat) { @@ -1335,7 +1363,7 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c } changedThemes } else { - null + changedThemes?.layoutOnly() } val wallpaperFilesToDelete = wallpaperFiles - changedThemes?.light?.wallpaper?.imageFile - changedThemes?.dark?.wallpaper?.imageFile wallpaperFilesToDelete.forEach(::removeWallpaperFile) @@ -1351,6 +1379,19 @@ suspend fun save(applyToMode: DefaultThemeMode?, newTheme: ThemeModeOverride?, c } } +private suspend fun setChatLayout(rhId: Long?, chatInfo: ChatInfo, layout: ChatLayoutMode?) { + val themes = chatInfo.uiThemes.withChatLayout(layout) + if (controller.apiSetChatUIThemes(rhId, chatInfo.id, themes)) { + withContext(Dispatchers.Main) { + if (chatInfo is ChatInfo.Direct) { + chatModel.chatsContext.updateChatInfo(rhId, chatInfo.copy(contact = chatInfo.contact.copy(uiThemes = themes))) + } else if (chatInfo is ChatInfo.Group) { + chatModel.chatsContext.updateChatInfo(rhId, chatInfo.copy(groupInfo = chatInfo.groupInfo.copy(uiThemes = themes))) + } + } + } +} + private fun setContactAlias(chat: Chat, localAlias: String, chatModel: ChatModel) = withBGApi { val chatRh = chat.remoteHostId chatModel.controller.apiSetContactAlias(chatRh, chat.chatInfo.apiId, localAlias)?.let { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt index f9d32892ee..cad263144f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsMerger.kt @@ -111,12 +111,15 @@ data class MergedItems ( sealed class MergedItem { abstract val startIndexInReversedItems: Int + abstract val revealed: Boolean // the item that is always single, cannot be grouped and always revealed data class Single( val item: ListItem, override val startIndexInReversedItems: Int, - ): MergedItem() + ): MergedItem() { + override val revealed: Boolean get() = true + } /** The item that can contain multiple items or just one depending on revealed state. When the whole group of merged items is revealed, * there will be multiple [Grouped] items with revealed flag set to true. When the whole group is collapsed, it will be just one instance @@ -125,7 +128,7 @@ sealed class MergedItem { @Stable data class Grouped ( val items: ArrayList, - val revealed: Boolean, + override val revealed: Boolean, // it stores ids for all consecutive revealed items from the same group in order to hide them all on user's action // it's the same list instance for all Grouped items within revealed group /** @see reveal */ diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 960efd0350..9b872c59c4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.core.* import androidx.compose.desktop.ui.tooling.preview.Preview import androidx.compose.foundation.* import androidx.compose.foundation.gestures.* +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.* import androidx.compose.foundation.shape.RoundedCornerShape @@ -264,9 +265,11 @@ fun ChatView( } } val clipboard = LocalClipboardManager.current + val terminal = chatInfo.activeChatLayout(user) == ChatLayoutMode.Terminal CompositionLocalProvider( LocalAppBarHandler provides rememberAppBarHandler(chatInfo.id, keyboardCoversBar = false), LocalSelectionManager provides selectionManager, + LocalTerminalLayout provides terminal, ) { when (chatInfo) { is ChatInfo.Direct, is ChatInfo.Group, is ChatInfo.Local -> { @@ -1004,7 +1007,7 @@ fun ChatLayout( sheetShape = RoundedCornerShape(topStart = 18.dp, topEnd = 18.dp) ) { val composeViewHeight = remember { mutableStateOf(0.dp) } - Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.secondaryContextFilter == null)) { + Box(Modifier.fillMaxSize().chatViewBackgroundModifier(MaterialTheme.colors, MaterialTheme.wallpaper, LocalAppBarHandler.current?.backgroundGraphicsLayerSize, LocalAppBarHandler.current?.backgroundGraphicsLayer, drawWallpaper = chatsCtx.secondaryContextFilter == null, drawWallpaperImage = !LocalTerminalLayout.current)) { val remoteHostId = remember { remoteHostId }.value val chat = remember { chat }.value val chatInfo = chat?.chatInfo @@ -1930,6 +1933,7 @@ fun BoxScope.ChatItemsList( cItem: ChatItem, itemSeparation: ItemSeparation, previousItemSeparationLargeGap: Boolean, + terminalGap: TextUnit, revealed: State, reveal: (Boolean) -> Unit ) { @@ -1954,11 +1958,10 @@ fun BoxScope.ChatItemsList( } @Composable - fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f) { + fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f, highlighted: State = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } }, bubbleInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }) { tryOrShowError("${cItem.id}ChatItem", error = { - CIBrokenComposableView(if (cItem.chatDir.sent) Alignment.CenterEnd else Alignment.CenterStart) + CIBrokenComposableView(if (cItem.chatDir.sent && !LocalTerminalLayout.current) Alignment.CenterEnd else Alignment.CenterStart) }) { - val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } LaunchedEffect(Unit) { snapshotFlow { highlighted.value } .distinctUntilChanged() @@ -1968,7 +1971,7 @@ fun BoxScope.ChatItemsList( highlightedItems.value = setOf() } } - ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp, swipeOffset = swipeOffset) + ChatItemView(chatsCtx, remoteHostId, chat, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, revealed = revealed, highlighted = highlighted, hoveredItemId = hoveredItemId, range = range, searchIsNotBlank = searchValueIsNotBlank, fillMaxWidth = fillMaxWidth, selectedChatItems = selectedChatItems, selectChatItem = { selectUnselectChatItem(true, cItem, revealed, selectedChatItems, reversedChatItems) }, deleteMessage = deleteMessage, deleteMessages = deleteMessages, archiveReports = archiveReports, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = joinGroup, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, forwardItem = forwardItem, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, scrollToItemId = scrollToItemId, scrollToQuotedItemFromItem = scrollToQuotedItemFromItem, setReaction = setReaction, showItemDetails = showItemDetails, reveal = reveal, showMemberInfo = showMemberInfo, showChatInfo = showChatInfo, developerTools = developerTools, showViaProxy = showViaProxy, itemSeparation = itemSeparation, showTimestamp = itemSeparation.timestamp, swipeOffset = swipeOffset, bubbleInteractionSource = bubbleInteractionSource) } } @@ -1994,18 +1997,19 @@ fun BoxScope.ChatItemsList( swipeDistance = with(LocalDensity.current) { 30.dp.toPx() }, ) val sent = cItem.chatDir.sent + val itemTopGap = if (previousItemSeparationLargeGap) 4.dp else 1.dp @Composable - fun ChatItemBox(modifier: Modifier = Modifier, content: @Composable () -> Unit = { }) { + fun ChatItemBox(modifier: Modifier = Modifier, top: Dp = itemTopGap, bottom: Dp? = null, content: @Composable () -> Unit = { }) { Box( modifier = modifier.padding( bottom = if (itemSeparation.largeGap) { if (itemAtZeroIndexInWholeList) { 8.dp } else { - 4.dp + bottom ?: 4.dp } - } else 1.dp, top = if (previousItemSeparationLargeGap) 4.dp else 1.dp + } else bottom ?: 1.dp, top = top ), contentAlignment = Alignment.CenterStart ) { @@ -2025,9 +2029,33 @@ fun BoxScope.ChatItemsList( Box { val voiceWithTransparentBack = cItem.content.msgContent is MsgContent.MCVoice && cItem.content.text.isEmpty() && cItem.quotedItem == null && cItem.meta.itemForwarded == null val selectionVisible = selectedChatItems.value != null && cItem.canBeDeletedForSelf - val selectionOffset by animateDpAsState(if (selectionVisible && !sent) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) + val terminal = LocalTerminalLayout.current + val selectionOffset by animateDpAsState(if (selectionVisible && (!sent || terminal)) 4.dp + 22.dp * fontSizeMultiplier else 0.dp) val swipeableOrSelectionModifier = (if (selectionVisible) Modifier else swipeableModifier).graphicsLayer { translationX = selectionOffset.toPx() } - if (chatInfo is ChatInfo.Group) { + if (terminal) { + val rowInteractionSource = remember { TerminalRowInteractionSource() } + val highlighted = remember { derivedStateOf { highlightedItems.value.contains(cItem.id) } } + val rowColor = animateColorAsState( + when { + selectedChatItems.value?.contains(cItem.id) == true -> MaterialTheme.colors.primary.copy(alpha = 0.1f) + highlighted.value -> MaterialTheme.colors.primary.copy(alpha = 0.14f) + else -> Color.Transparent + } + ) + ChatItemBox(Modifier.drawBehind { drawRect(rowColor.value) }.terminalRowIndication(rowInteractionSource), top = 0.dp, bottom = if (itemSeparation.date != null) TERMINAL_LEVEL_STEP.toDp() else 0.dp) { + AnimatedVisibility(selectionVisible, enter = fadeIn(), exit = fadeOut()) { + SelectedListItem(Modifier.padding(start = 8.dp), cItem.id, selectedChatItems) + } + TerminalChatItemRow( + if (showAvatar) chatItemSender(chatInfo, cItem, range, reversedChatItems, showMemberInfo, showChatInfo) else null, + terminalGutter(chatInfo), + terminalGap.toDp(), + Modifier.terminalStartOffset { selectionOffset }.then(if (selectionVisible) Modifier else swipeableModifier) + ) { + ChatItemViewShortHand(cItem, itemSeparation, range, swipeOffset = dismissState.offset.value, highlighted = highlighted, bubbleInteractionSource = rowInteractionSource) + } + } + } else if (chatInfo is ChatInfo.Group) { if (cItem.chatDir is CIDirection.GroupRcv) { if (showAvatar) { Column( @@ -2428,21 +2456,26 @@ fun BoxScope.ChatItemsList( } else { null } - val showAvatar = shouldShowAvatar(item, merged.oldest().nextItem) + val older = merged.oldest().nextItem + val showAvatar = if (LocalTerminalLayout.current) terminalItemShowsSender(item, older) else shouldShowAvatar(item, older) + val terminalGap = if (!LocalTerminalLayout.current) 0.sp else terminalRowGap( + item, older, + separated = terminalRowsSeparated(mergedItemsValue.items.getOrNull(index + 1)), + largeGap = older != null && getItemSeparationLargeGap(older, merged.oldest().item), + hasSenderRow = showAvatar + ) val isRevealed = remember { derivedStateOf { revealedItems.value.contains(item.id) } } - val itemSeparation: ItemSeparation + val itemSeparation = rowItemSeparation(merged) val prevItemSeparationLargeGap: Boolean if (merged is MergedItem.Single || isRevealed.value) { val prev = listItem.prevItem - itemSeparation = getItemSeparation(item, prev) val nextForGap = if ((item.mergeCategory != null && item.mergeCategory == prev?.mergeCategory) || isLastItem) null else listItem.nextItem prevItemSeparationLargeGap = if (nextForGap == null) false else getItemSeparationLargeGap(nextForGap, item) } else { - itemSeparation = getItemSeparation(item, null) prevItemSeparationLargeGap = false } CompositionLocalProvider(LocalItemContext provides ItemContext(selectionIndex = index)) { - ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, isRevealed) { + ChatViewListItem(index == 0, rememberUpdatedState(range), showAvatar, item, itemSeparation, prevItemSeparationLargeGap, terminalGap, isRevealed) { if (merged is MergedItem.Grouped) merged.reveal(it, revealedItems) } } @@ -3505,7 +3538,7 @@ private fun markUnreadChatAsRead(chatId: String) { } @Composable -private fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount: Int): String { +fun memberNames(member: GroupMember, prevMember: GroupMember?, memCount: Int): String { val name = member.displayName val prevName = prevMember?.displayName return if (prevName != null) { @@ -3524,9 +3557,10 @@ fun Modifier.chatViewBackgroundModifier( wallpaper: AppWallpaper, backgroundGraphicsLayerSize: MutableState?, backgroundGraphicsLayer: GraphicsLayer?, - drawWallpaper: Boolean + drawWallpaper: Boolean, + drawWallpaperImage: Boolean ): Modifier { - val wallpaperImage = wallpaper.type.image + val wallpaperImage = if (drawWallpaperImage) wallpaper.type.image else null val wallpaperType = wallpaper.type val backgroundColor = if (drawWallpaper) wallpaper.background ?: wallpaperType.defaultBackgroundColor(CurrentColors.value.base, colors.background) else colors.background val tintColor = wallpaper.tint ?: wallpaperType.defaultTintColor(CurrentColors.value.base) @@ -3775,6 +3809,16 @@ private fun getItemSeparation(chatItem: ChatItem, prevItem: ChatItem?): ItemSepa ) } +private fun rowItemSeparation(row: MergedItem): ItemSeparation { + val newest = row.newest() + return getItemSeparation(newest.item, if (row.revealed) newest.prevItem else null) +} + +fun terminalRowsSeparated(olderRow: MergedItem?): Boolean { + if (olderRow == null) return false + return olderRow.newest().item.content is CIContent.ChatBanner || rowItemSeparation(olderRow).date != null +} + private fun getItemSeparationLargeGap(chatItem: ChatItem, nextItem: ChatItem?): Boolean { if (nextItem == null) { return true diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt index d3cc76fb7a..9eb00e6cad 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CICallItemView.kt @@ -25,30 +25,32 @@ fun CICallItemView( timedMessagesTTL: Int? ) { val sent = cItem.chatDir.sent - Column( - Modifier - .padding(horizontal = 4.dp) - .padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) { - @Composable fun ConnectingCallIcon() = Icon(painterResource(MR.images.ic_settings_phone), stringResource(MR.strings.icon_descr_call_connecting), tint = SimplexGreen) - when (status) { - CICallStatus.Pending -> if (sent) { - Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_pending_sent)) - } else { - AcceptCallButton(cInfo, acceptCall) + ItemWithMeta(meta = { CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp) }) { inlineMeta -> + Column( + Modifier + .padding(horizontal = if (LocalTerminalLayout.current) CHAT_ITEM_TEXT_PADDING else 4.dp) + .padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally) { + @Composable fun ConnectingCallIcon() = Icon(painterResource(MR.images.ic_settings_phone), stringResource(MR.strings.icon_descr_call_connecting), tint = SimplexGreen) + when (status) { + CICallStatus.Pending -> if (sent) { + Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_pending_sent)) + } else { + AcceptCallButton(cInfo, acceptCall) + } + CICallStatus.Missed -> Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_missed), tint = Color.Red) + CICallStatus.Rejected -> Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_rejected), tint = Color.Red) + CICallStatus.Accepted -> ConnectingCallIcon() + CICallStatus.Negotiated -> ConnectingCallIcon() + CICallStatus.Progress -> Icon(painterResource(MR.images.ic_phone_in_talk_filled), stringResource(MR.strings.icon_descr_call_progress), tint = SimplexGreen) + CICallStatus.Ended -> Row { + Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp)) + Text(durationText(duration), color = MaterialTheme.colors.secondary) + } + CICallStatus.Error -> {} } - CICallStatus.Missed -> Icon(painterResource(MR.images.ic_call), stringResource(MR.strings.icon_descr_call_missed), tint = Color.Red) - CICallStatus.Rejected -> Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_rejected), tint = Color.Red) - CICallStatus.Accepted -> ConnectingCallIcon() - CICallStatus.Negotiated -> ConnectingCallIcon() - CICallStatus.Progress -> Icon(painterResource(MR.images.ic_phone_in_talk_filled), stringResource(MR.strings.icon_descr_call_progress), tint = SimplexGreen) - CICallStatus.Ended -> Row { - Icon(painterResource(MR.images.ic_call_end), stringResource(MR.strings.icon_descr_call_ended), tint = MaterialTheme.colors.secondary, modifier = Modifier.padding(end = 4.dp)) - Text(durationText(duration), color = MaterialTheme.colors.secondary) - } - CICallStatus.Error -> {} - } - CIMetaView(cItem, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp) + inlineMeta?.invoke() + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt index c743d78d1c..1b5c6e3eaf 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIChatFeatureView.kt @@ -8,11 +8,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* import chat.simplex.common.model.ChatModel.getChatItemIndexOrNull import chat.simplex.common.platform.onRightClick +import chat.simplex.common.views.helpers.toDp @Composable fun CIChatFeatureView( @@ -35,9 +37,11 @@ fun CIChatFeatureView( .onRightClick { showMenu.value = true } ) { if (!revealed.value && merged != null) { + val terminal = LocalTerminalLayout.current Row( - Modifier.padding(horizontal = 6.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(4.dp) + Modifier.padding(chatEventPadding(vertical = 8.dp)).then(if (terminal) Modifier.height(chatEventTextStyle().lineHeight.toDp()) else Modifier), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = if (terminal) Alignment.CenterVertically else Alignment.Top ) { merged.forEach { FeatureIconView(it) @@ -102,11 +106,11 @@ private fun featureInfo(ci: ChatItem, chatInfo: ChatInfo): FeatureInfo? = @Composable private fun FeatureIconView(f: FeatureInfo) { - val icon = @Composable { Icon(f.icon.icon, null, Modifier.size(20.dp), tint = f.color) } + val icon = @Composable { Icon(f.icon.icon, null, Modifier.inlineIcon().size(20.dp), tint = f.color) } if (f.param != null) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { icon() - Text(chatEventText(f.param, ""), maxLines = 1) + Text(chatEventText(f.param, AnnotatedString(" ")), maxLines = 1, style = chatEventTextStyle()) } } else { icon() @@ -120,17 +124,20 @@ private fun FullFeatureView( iconColor: Color, icon: Painter? = null ) { - Row( - Modifier.padding(horizontal = 6.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon(icon ?: feature.iconFilled(), feature.text, Modifier.size(20.dp), tint = iconColor) - Text( - chatEventText(chatItem), - Modifier, - // this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp - fontSize = 12.sp - ) + ChatEventWithMeta(chatItem) { separator, ts -> + Row( + Modifier.padding(chatEventPadding()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(icon ?: feature.iconFilled(), feature.text, Modifier.inlineIcon().size(20.dp), tint = iconColor) + Text( + chatEventText(chatItem, ts = separator + ts), + Modifier, + // this is important. Otherwise, aligning will be bad because annotated string has a Span with size 12.sp + fontSize = 12.sp, + style = chatEventTextStyle() + ) + } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt index 529a0e458b..afba9f87f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt @@ -1,18 +1,50 @@ package chat.simplex.common.views.chat.item import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.material.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.* +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.model.ChatItem import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.helpers.toDp @Composable fun CIEventView(text: AnnotatedString) { - Text(text, Modifier.padding(horizontal = 6.dp, vertical = 6.dp), style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), maxLines = 4) + Text(text, Modifier.padding(chatEventPadding()), style = chatEventTextStyle(MaterialTheme.typography.body1.copy(lineHeight = 22.sp)), maxLines = if (LocalTerminalLayout.current) Int.MAX_VALUE else 4) +} + +@Composable +fun chatEventTextStyle(base: TextStyle = LocalTextStyle.current): TextStyle = + if (LocalTerminalLayout.current) { + base.merge(terminalTextStyle(12.sp, MaterialTheme.colors.secondary)) + } else { + base + } + +@Composable +fun chatEventPadding(vertical: Dp = 6.dp): PaddingValues = + if (LocalTerminalLayout.current) PaddingValues(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_EVENT_TOP_GAP.toDp(), end = CHAT_ITEM_TEXT_PADDING, bottom = TERMINAL_EVENT_BOTTOM_GAP.toDp()) + else PaddingValues(horizontal = 6.dp, vertical = vertical) + +@Composable +fun ChatEventWithMeta(ci: ChatItem, content: @Composable (separator: AnnotatedString, ts: AnnotatedString) -> Unit) { + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL = null, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = true) }, metaReserved = true) { inlineMeta -> + if (inlineMeta != null) { + content(AnnotatedString(" "), AnnotatedString(ci.timestampText)) + } else { + val copy = terminalReserveSpaceForMeta(ci.meta, chatTTL = null, encrypted = null, signedFileVerified = null, secondaryColor = MaterialTheme.colors.secondary, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = true) + content( + buildAnnotatedString { withStyle(chatEventStyle) { withStyle(reserveTimestampStyle) { append("\u00A0\u00A0\u00A0") } } }, + buildAnnotatedString { withStyle(reserveTimestampStyle) { append(copy) } } + ) + } + } } @Preview/*( uiMode = Configuration.UI_MODE_NIGHT_YES, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt index 27e605df4f..0ae7860314 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFeaturePreferenceView.kt @@ -21,33 +21,36 @@ fun CIFeaturePreferenceView( allowed: FeatureAllowed, acceptFeature: (Contact, ChatFeature, Int?) -> Unit ) { - Row( - Modifier.padding(horizontal = 6.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - Icon(feature.icon, feature.text, Modifier.size(18.dp), tint = MaterialTheme.colors.secondary) - if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) { - val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp) - val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null - val acceptTextId = if (setParam) MR.strings.accept_feature_set_1_day else MR.strings.accept_feature - val param = if (setParam) 86400 else null - val annotatedText = buildAnnotatedString { - withStyle(chatEventStyle) { append(chatItem.content.text + " ") } - withAnnotation(tag = "Accept", annotation = "Accept") { - withStyle(acceptStyle) { append(generalGetString(acceptTextId) + " ") } + ChatEventWithMeta(chatItem) { separator, ts -> + Row( + Modifier.padding(chatEventPadding()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon(feature.icon, feature.text, Modifier.inlineIcon().size(18.dp), tint = MaterialTheme.colors.secondary) + if (contact != null && allowed != FeatureAllowed.NO && contact.allowsFeature(feature) && !contact.userAllowsFeature(feature)) { + val acceptStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp) + val setParam = feature == ChatFeature.TimedMessages && contact.mergedPreferences.timedMessages.userPreference.pref.ttl == null + val acceptTextId = if (setParam) MR.strings.accept_feature_set_1_day else MR.strings.accept_feature + val param = if (setParam) 86400 else null + val annotatedText = buildAnnotatedString { + withStyle(chatEventStyle) { append(chatItem.content.text + " ") } + withAnnotation(tag = "Accept", annotation = "Accept") { + withStyle(acceptStyle) { append(generalGetString(acceptTextId)); append(separator) } + } + withStyle(chatEventStyle) { append(ts) } } - withStyle(chatEventStyle) { append(chatItem.timestampText) } + fun accept(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Accept", start = offset, end = offset).isNotEmpty() + ClickableText( + annotatedText, + style = chatEventTextStyle(TextStyle.Default), + onClick = { if (accept(it)) { acceptFeature(contact, feature, param) } }, + shouldConsumeEvent = ::accept + ) + } else { + Text(AnnotatedString(chatItem.content.text) + separator + ts, + fontSize = 12.sp, fontWeight = FontWeight.Light, color = MaterialTheme.colors.secondary, style = chatEventTextStyle()) } - fun accept(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Accept", start = offset, end = offset).isNotEmpty() - ClickableText( - annotatedText, - onClick = { if (accept(it)) { acceptFeature(contact, feature, param) } }, - shouldConsumeEvent = ::accept - ) - } else { - Text(chatItem.content.text + " " + chatItem.timestampText, - fontSize = 12.sp, fontWeight = FontWeight.Light, color = MaterialTheme.colors.secondary) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt index 42cbff3a2f..f857d6a190 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIFileView.kt @@ -43,6 +43,7 @@ fun CIFileView( val saveFileLauncher = rememberSaveFileLauncher(ciFile = file) val sizeMultiplier = 1f val progressSizeMultiplier = if (smallView) 0.7f else 1f + val terminal = LocalTerminalLayout.current @Composable fun fileIcon( innerIcon: Painter? = null, @@ -133,7 +134,7 @@ fun CIFileView( fun fileIndicator() { Box( Modifier - .padding(top = 2.sp.toDp()) + .padding(top = if (terminal) 0.dp else 2.sp.toDp()) .size(40.sp.toDp() * sizeMultiplier) .clip(RoundedCornerShape(4.sp.toDp() * sizeMultiplier)), contentAlignment = Alignment.Center @@ -205,7 +206,13 @@ fun CIFileView( onClick = onClick, onLongClick = { showMenu.value = true } ) - .padding(if (smallView) PaddingValues() else PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp())), + .padding( + when { + smallView -> PaddingValues() + terminal -> PaddingValues(top = TERMINAL_FILE_TOP_GAP.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp()) + else -> PaddingValues(top = 4.sp.toDp(), bottom = 6.sp.toDp(), start = 6.sp.toDp(), end = 12.sp.toDp()) + } + ), verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.spacedBy(2.sp.toDp()) ) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt index 14ecd723b9..38eaa6f170 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIGroupInvitationView.kt @@ -85,71 +85,77 @@ fun CIGroupInvitationView( val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { - inProgress.value = true - joinGroup(groupInvitation.groupId) { inProgress.value = false } - }) else Modifier, - shape = RoundedCornerShape(18.dp), - color = if (sent) sentColor else receivedColor, - contentColor = LocalContentColor.current - ) { - Box( - Modifier - .width(IntrinsicSize.Min) - .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), - contentAlignment = Alignment.BottomEnd + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp) }) { inlineMeta -> + Surface( + modifier = if (action && !inProgress.value) Modifier.clickable(onClick = { + inProgress.value = true + joinGroup(groupInvitation.groupId) { inProgress.value = false } + }) else Modifier, + shape = RoundedCornerShape(18.dp), + color = if (sent) sentColor else receivedColor, + contentColor = LocalContentColor.current ) { Box( - contentAlignment = Alignment.Center + Modifier + .width(IntrinsicSize.Min) + .padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 4.dp), + contentAlignment = Alignment.BottomEnd ) { - Column( - Modifier - .defaultMinSize(minWidth = 220.dp) - .padding(bottom = 4.dp), + Box( + contentAlignment = Alignment.Center ) { - groupInfoView() - val secondaryColor = MaterialTheme.colors.secondary - Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) - Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { - if (groupChat) { - Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor) - } - if (action) { - Text(groupInvitationStr(), fontSize = 13.sp, lineHeight = 18.sp) - Text( - buildAnnotatedString { - append(generalGetString(if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join)) - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) } - }, - color = if (inProgress.value) - MaterialTheme.colors.secondary - else - if (chatIncognito) Indigo else MaterialTheme.colors.primary - ) - } else { - Text( - buildAnnotatedString { - append(groupInvitationStr()) - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) } - }, - fontSize = 13.sp, - lineHeight = 18.sp, - ) + Column( + Modifier + .defaultMinSize(minWidth = 220.dp) + .padding(bottom = 4.dp), + ) { + groupInfoView() + val secondaryColor = MaterialTheme.colors.secondary + Divider(Modifier.fillMaxWidth().padding(top = 8.dp)) + Column(Modifier.padding(top = 8.dp, bottom = 4.dp, start = 4.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) { + if (groupChat) { + Text(if (sent) String.format(stringResource(MR.strings.group_invitation_for_member_only), groupInvitation.localDisplayName) else stringResource(MR.strings.group_invitation_for_you_only), fontSize = 12.sp, color = secondaryColor) + } + if (action) { + Text(groupInvitationStr(), fontSize = 13.sp, lineHeight = 18.sp) + Text( + buildAnnotatedString { + append(generalGetString(if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join)) + if (inlineMeta != null) { + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) } + } + }, + color = if (inProgress.value) + MaterialTheme.colors.secondary + else + if (chatIncognito) Indigo else MaterialTheme.colors.primary + ) + } else { + Text( + buildAnnotatedString { + append(groupInvitationStr()) + if (inlineMeta != null) { + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, timedMessagesTTL, encrypted = null, showStatus = false, showEdited = false, secondaryColor = secondaryColor, showTimestamp = showTimestamp)) } + } + }, + fontSize = 13.sp, + lineHeight = 18.sp, + ) + } } } + + if (progressByTimeout) { + CircularProgressIndicator( + Modifier.size(32.dp), + color = if (isInDarkTheme()) FileDark else FileLight, + strokeWidth = 3.dp + ) + } } - if (progressByTimeout) { - CircularProgressIndicator( - Modifier.size(32.dp), - color = if (isInDarkTheme()) FileDark else FileLight, - strokeWidth = 3.dp - ) - } + inlineMeta?.invoke() } - - CIMetaView(ci, timedMessagesTTL, showStatus = false, showEdited = false, showViaProxy = false, showTimestamp = showTimestamp) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt index bbd7788f29..3eba43c685 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMemberCreatedContactView.kt @@ -32,39 +32,42 @@ fun CIMemberCreatedContactView( } } - Row( - Modifier.padding(horizontal = 6.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) - ) { - if (chatItem.chatDir is CIDirection.GroupRcv && chatItem.chatDir.groupMember.memberContactId != null) { - val openChatStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp) - val annotatedText = buildAnnotatedString { - append(eventText()) - append(" ") - withAnnotation(tag = "Open", annotation = "Open") { - withStyle(openChatStyle) { append(generalGetString(MR.strings.rcv_group_event_open_chat) + " ") } - } - withStyle(chatEventStyle) { append(chatItem.timestampText) } - } - - fun open(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Open", start = offset, end = offset).isNotEmpty() - ClickableText( - annotatedText, - onClick = { - if (open(it)) { - openDirectChat(chatItem.chatDir.groupMember.memberContactId) + ChatEventWithMeta(chatItem) { separator, ts -> + Row( + Modifier.padding(chatEventPadding()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + if (chatItem.chatDir is CIDirection.GroupRcv && chatItem.chatDir.groupMember.memberContactId != null) { + val openChatStyle = SpanStyle(color = MaterialTheme.colors.primary, fontSize = 12.sp) + val annotatedText = buildAnnotatedString { + append(eventText()) + append(" ") + withAnnotation(tag = "Open", annotation = "Open") { + withStyle(openChatStyle) { append(generalGetString(MR.strings.rcv_group_event_open_chat)); append(separator) } } - }, - shouldConsumeEvent = ::open - ) - } else { - val annotatedText = buildAnnotatedString { - append(eventText()) - append(" ") - withStyle(chatEventStyle) { append(chatItem.timestampText) } + withStyle(chatEventStyle) { append(ts) } + } + + fun open(offset: Int): Boolean = annotatedText.getStringAnnotations(tag = "Open", start = offset, end = offset).isNotEmpty() + ClickableText( + annotatedText, + style = chatEventTextStyle(TextStyle.Default), + onClick = { + if (open(it)) { + openDirectChat(chatItem.chatDir.groupMember.memberContactId) + } + }, + shouldConsumeEvent = ::open + ) + } else { + val annotatedText = buildAnnotatedString { + append(eventText()) + append(separator) + withStyle(chatEventStyle) { append(ts) } + } + Text(annotatedText, style = chatEventTextStyle()) } - Text(annotatedText) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt index e318f406de..ba22ae5966 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt @@ -8,8 +8,19 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.desktop.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import chat.simplex.common.model.* @@ -38,14 +49,17 @@ fun CIMetaView( showEdited: Boolean = true, showTimestamp: Boolean, showViaProxy: Boolean, + lineHeightColor: Color = metaColor, ) { + val timeStyle = LocalTextStyle.current.merge(terminalTextStyle(12.sp, lineHeightColor)) Row(Modifier.padding(start = 3.dp), verticalAlignment = Alignment.CenterVertically) { if (chatItem.isDeletedContent) { Text( chatItem.timestampText, color = metaColor, fontSize = 12.sp, - modifier = Modifier.padding(start = 3.dp) + modifier = Modifier.padding(start = 3.dp), + style = timeStyle ) } else { CIMetaText( @@ -54,6 +68,7 @@ fun CIMetaView( encrypted = chatItem.encryptedFile, metaColor, paleMetaColor, + timeStyle, showStatus = showStatus, showEdited = showEdited, showViaProxy = showViaProxy, @@ -72,6 +87,7 @@ private fun CIMetaText( encrypted: Boolean?, color: Color, paleColor: Color, + timeStyle: TextStyle, showStatus: Boolean = true, showEdited: Boolean = true, showTimestamp: Boolean, @@ -87,7 +103,7 @@ private fun CIMetaText( StatusIconText(painterResource(MR.images.ic_timer), color) val ttl = meta.itemTimed?.ttl if (ttl != chatTTL) { - Text(shortTimeText(ttl), color = color, fontSize = 12.sp) + Text(shortTimeText(ttl), color = color, fontSize = 12.sp, style = timeStyle) } } if (showViaProxy && meta.sentViaProxy == true) { @@ -99,7 +115,7 @@ private fun CIMetaText( val statusIcon = meta.statusIcon(MaterialTheme.colors.primary, color, paleColor) if (statusIcon != null) { val (icon, statusColor) = statusIcon - if (meta.itemStatus is CIStatus.SndSent || meta.itemStatus is CIStatus.SndRcvd) { + if (meta.tallStatusIcon) { Icon(painterResource(icon), null, Modifier.height(17.dp), tint = statusColor) } else { StatusIconText(painterResource(icon), statusColor) @@ -122,11 +138,10 @@ private fun CIMetaText( if (showTimestamp) { Spacer(Modifier.width(4.dp)) - Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(meta.timestampText, color = color, fontSize = 12.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, style = timeStyle) } } -// the conditions in this function should match CIMetaText fun reserveSpaceForMeta( meta: CIMeta, chatTTL: Int?, @@ -137,63 +152,126 @@ fun reserveSpaceForMeta( showViaProxy: Boolean = false, showTimestamp: Boolean, signedFileVerified: Boolean? = null -): String { +): String = buildString { + reserveSpaceForMeta(meta, chatTTL, encrypted, secondaryColor, showStatus, showEdited, showViaProxy, showTimestamp, signedFileVerified, tallIconSpace = "", spaces = { append(it) }, text = { append(it) }) +} + +// the conditions in this function should match CIMetaText +private fun reserveSpaceForMeta( + meta: CIMeta, + chatTTL: Int?, + encrypted: Boolean?, + secondaryColor: Color, + showStatus: Boolean, + showEdited: Boolean, + showViaProxy: Boolean, + showTimestamp: Boolean, + signedFileVerified: Boolean?, + tallIconSpace: String, + spaces: (String) -> Unit, + text: (String) -> Unit +) { val showSignature = appPreferences.privacyShowSignature.state.value val showEncryption = appPreferences.privacyShowEncryption.state.value val iconSpace = " \u00A0\u00A0\u00A0" val whiteSpace = "\u00A0" - var res = if (showTimestamp) "" else iconSpace + if (!showTimestamp) spaces(iconSpace) var space: String? = null fun appendSpace() { - if (space != null) { - res += space - space = null - } + space?.let(spaces) + space = null } if (showEdited && meta.itemEdited) { - res += iconSpace + spaces(iconSpace) } if (meta.itemTimed != null) { - res += iconSpace + spaces(iconSpace) val ttl = meta.itemTimed.ttl if (ttl != chatTTL) { - res += shortTimeText(ttl) + text(shortTimeText(ttl)) } space = whiteSpace } if (showViaProxy && meta.sentViaProxy == true) { appendSpace() - res += iconSpace + spaces(iconSpace + tallIconSpace) } if (showStatus) { appendSpace() if (meta.statusIcon(secondaryColor) != null) { - res += iconSpace + spaces(if (meta.tallStatusIcon) iconSpace + tallIconSpace else iconSpace) } else if (!meta.disappearing) { - res += iconSpace + spaces(iconSpace) } space = whiteSpace } if (encrypted != null && showEncryption) { appendSpace() - res += iconSpace + spaces(iconSpace) space = whiteSpace } if ((showSignature && meta.msgVerified?.verified == true && signedFileVerified != false) || meta.msgVerified is MsgVerified.SigMissing) { appendSpace() - res += iconSpace + spaces(iconSpace) space = whiteSpace } if (showTimestamp) { appendSpace() - res += meta.timestampText + text(meta.timestampText) } - return res } +@Composable +fun terminalReserveSpaceForMeta( + meta: CIMeta, + chatTTL: Int?, + encrypted: Boolean?, + signedFileVerified: Boolean?, + secondaryColor: Color, + showStatus: Boolean = true, + showEdited: Boolean = true, + showViaProxy: Boolean, + showTimestamp: Boolean +): AnnotatedString = + terminalReserveSpaceForMeta( + meta, chatTTL, encrypted, signedFileVerified, secondaryColor, showStatus, showEdited, showViaProxy, showTimestamp, + density = LocalDensity.current, + bodyFontSize = MaterialTheme.typography.body1.fontSize, + direction = LocalLayoutDirection.current + ) + +fun terminalReserveSpaceForMeta( + meta: CIMeta, + chatTTL: Int?, + encrypted: Boolean?, + signedFileVerified: Boolean?, + secondaryColor: Color, + showStatus: Boolean, + showEdited: Boolean, + showViaProxy: Boolean, + showTimestamp: Boolean, + density: Density, + bodyFontSize: TextUnit, + direction: LayoutDirection +): AnnotatedString { + val spaceStyle = SpanStyle(fontSize = with(density) { bodyFontSize.value.dp.toSp() }, fontWeight = FontWeight.Normal) + val textStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Normal) + return buildAnnotatedString { + append(if (direction == LayoutDirection.Ltr) "\u2060\u200E" else "\u2060\u200F") + reserveSpaceForMeta( + meta, chatTTL, encrypted, secondaryColor, showStatus, showEdited, showViaProxy, showTimestamp, signedFileVerified, tallIconSpace = "\u00A0", + spaces = { withStyle(spaceStyle) { append(it.replace(' ', '\u00A0')) } }, + text = { withStyle(textStyle) { append(it.replace(' ', '\u00A0')) } } + ) + } +} + +private val CIMeta.tallStatusIcon: Boolean get() = + itemStatus is CIStatus.SndSent || itemStatus is CIStatus.SndRcvd + @Composable private fun StatusIconText(icon: Painter, color: Color) { Icon(icon, null, Modifier.height(12.dp), tint = color) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt index 589b2a6fa6..42a8720018 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIRcvDecryptionError.kt @@ -139,44 +139,48 @@ fun DecryptionErrorItemFixButton( syncSupported: Boolean ) { val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - Modifier.clickable(onClick = onClick), - shape = RoundedCornerShape(18.dp), - color = receivedColor, - contentColor = LocalContentColor.current - ) { - Box( - Modifier.padding(vertical = 6.dp, horizontal = 12.dp), - contentAlignment = Alignment.BottomEnd, + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false, showTimestamp = true) }) { inlineMeta -> + Surface( + Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = receivedColor, + contentColor = LocalContentColor.current ) { - Column( - verticalArrangement = Arrangement.spacedBy(2.dp) + Box( + Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + contentAlignment = Alignment.BottomEnd, ) { - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) - ) - Row { - Icon( - painterResource(MR.images.ic_sync_problem), - stringResource(MR.strings.fix_connection), - tint = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary - ) - Spacer(Modifier.padding(2.dp)) - val secondaryColor = MaterialTheme.colors.secondary + Column( + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { Text( buildAnnotatedString { - append(generalGetString(MR.strings.fix_connection)) - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor, showTimestamp = true)) } - withStyle(reserveTimestampStyle) { append(" ") } // for icon + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } }, - color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) ) + Row { + Icon( + painterResource(MR.images.ic_sync_problem), + stringResource(MR.strings.fix_connection), + tint = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + Spacer(Modifier.padding(2.dp)) + val secondaryColor = MaterialTheme.colors.secondary + Text( + buildAnnotatedString { + append(generalGetString(MR.strings.fix_connection)) + if (inlineMeta != null) { + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor, showTimestamp = true)) } + withStyle(reserveTimestampStyle) { append(" ") } // for icon + } + }, + color = if (syncSupported) MaterialTheme.colors.primary else MaterialTheme.colors.secondary + ) + } } + inlineMeta?.invoke() } - CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false, showTimestamp = true) } } } @@ -187,25 +191,29 @@ fun DecryptionErrorItem( onClick: () -> Unit ) { val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - Modifier.clickable(onClick = onClick), - shape = RoundedCornerShape(18.dp), - color = receivedColor, - contentColor = LocalContentColor.current - ) { - Box( - Modifier.padding(vertical = 6.dp, horizontal = 12.dp), - contentAlignment = Alignment.BottomEnd, + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false, showTimestamp = true) }) { inlineMeta -> + Surface( + Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = receivedColor, + contentColor = LocalContentColor.current ) { - val secondaryColor = MaterialTheme.colors.secondary - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } - withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor, showTimestamp = true)) } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) - ) - CIMetaView(ci, timedMessagesTTL = null, showViaProxy = false, showTimestamp = true) + Box( + Modifier.padding(vertical = 6.dp, horizontal = 12.dp), + contentAlignment = Alignment.BottomEnd, + ) { + val secondaryColor = MaterialTheme.colors.secondary + Text( + buildAnnotatedString { + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } + if (inlineMeta != null) { + withStyle(reserveTimestampStyle) { append(reserveSpaceForMeta(ci.meta, null, encrypted = null, secondaryColor = secondaryColor, showTimestamp = true)) } + } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) + ) + inlineMeta?.invoke() + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt index 5aeefd9668..aa49808fe8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIVoiceView.kt @@ -44,10 +44,11 @@ fun CIVoiceView( receiveFile: (Long) -> Unit, ) { val sizeMultiplier = if (smallView) voiceMessageSizeBasedOnSquareSize(36f) / 56f else 1f + val classicWithText = hasText && !LocalTerminalLayout.current val padding = when { smallView -> PaddingValues() - hasText -> PaddingValues(top = 14.sp.toDp() * sizeMultiplier, bottom = 14.sp.toDp() * sizeMultiplier, start = 6.sp.toDp() * sizeMultiplier, end = 6.sp.toDp() * sizeMultiplier) - else -> PaddingValues(top = 4.sp.toDp() * sizeMultiplier, bottom = 6.sp.toDp() * sizeMultiplier, start = 0.dp, end = 0.dp) + classicWithText -> PaddingValues(top = 14.sp.toDp() * sizeMultiplier, bottom = 14.sp.toDp() * sizeMultiplier, start = 6.sp.toDp() * sizeMultiplier, end = 6.sp.toDp() * sizeMultiplier) + else -> PaddingValues(top = 4.sp.toDp() * sizeMultiplier, bottom = 6.sp.toDp() * sizeMultiplier, start = if (LocalTerminalLayout.current) CHAT_ITEM_TEXT_PADDING else 0.dp, end = 0.dp) } Row( Modifier.padding(padding), @@ -87,7 +88,7 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, { chatModel.openWhenReceived(file, smallView); receiveFile(it) }) { + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, classicWithText, timedMessagesTTL, showViaProxy, showTimestamp, sizeMultiplier, play, pause, longClick, { chatModel.openWhenReceived(file, smallView); receiveFile(it) }) { AudioPlayer.seekTo(it, progress, fileSource.value?.filePath) } if (smallView) { @@ -120,6 +121,7 @@ private fun VoiceLayout( brokenAudio: Boolean, sent: Boolean, hasText: Boolean, + classicWithText: Boolean, timedMessagesTTL: Int?, showViaProxy: Boolean, showTimestamp: Boolean, @@ -182,7 +184,7 @@ private fun VoiceLayout( } } } - hasText -> { + classicWithText -> { val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage Spacer(Modifier.width(6.sp.toDp() * sizeMultiplier)) @@ -192,7 +194,7 @@ private fun VoiceLayout( Slider(if (ci.chatDir.sent) sentColor else receivedColor) } } - sent -> { + sent && !LocalTerminalLayout.current -> { Column(horizontalAlignment = Alignment.End) { Row { Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.End) { @@ -208,19 +210,25 @@ private fun VoiceLayout( } } else -> { - Column(horizontalAlignment = Alignment.Start) { - Row { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, 1f, play, pause, longClick, receiveFile) - Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { - DurationText(text, PaddingValues(start = 12.sp.toDp() * sizeMultiplier)) - Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp)) - Spacer(Modifier.height(56.sp.toDp() * sizeMultiplier)) + @Composable + fun VoiceRow(inlineMeta: (@Composable () -> Unit)?) { + Column(horizontalAlignment = Alignment.Start) { + Row { + VoiceMsgIndicator(file, audioPlaying.value, sent, false, progress, duration, brokenAudio, 1f, play, pause, longClick, receiveFile) + Row(Modifier.weight(1f, false), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Start) { + DurationText(text, PaddingValues(start = 12.sp.toDp() * sizeMultiplier)) + Slider(MaterialTheme.colors.background, PaddingValues(start = DEFAULT_PADDING_HALF + 3.dp)) + Spacer(Modifier.height(56.sp.toDp() * sizeMultiplier)) + } + } + if (inlineMeta != null) { + Box(Modifier.padding(top = 6.sp.toDp() * sizeMultiplier)) { + inlineMeta() + } } } - Box(Modifier.padding(top = 6.sp.toDp() * sizeMultiplier)) { - CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } } + if (hasText) VoiceRow(null) else ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) }) { VoiceRow(it) } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt index 822aef9aa2..4305a35502 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt @@ -50,12 +50,12 @@ private val msgTailMaxHeightDp = msgTailWidthDp * 1.732f // 60deg val chatEventStyle = SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = CurrentColors.value.colors.secondary) -fun chatEventText(ci: ChatItem, isChannel: Boolean = false): AnnotatedString = - chatEventText(ci.content.text(isChannel), ci.timestampText) +fun chatEventText(ci: ChatItem, isChannel: Boolean = false, ts: AnnotatedString): AnnotatedString = + chatEventText(ci.content.text(isChannel), ts) -fun chatEventText(eventText: String, ts: String): AnnotatedString = +fun chatEventText(eventText: String, ts: AnnotatedString): AnnotatedString = buildAnnotatedString { - withStyle(chatEventStyle) { append("$eventText $ts") } + withStyle(chatEventStyle) { append(eventText); append(ts) } } data class ChatItemReactionMenuItem ( @@ -64,6 +64,9 @@ data class ChatItemReactionMenuItem ( val onClick: (() -> Unit)? ) +private val REACTION_SPACING = 5.dp +private val REACTION_PADDING = 2.dp + // Spec: spec/client/chat-view.md#ChatItemView @Composable fun ChatItemView( @@ -113,11 +116,14 @@ fun ChatItemView( itemSeparation: ItemSeparation, preview: Boolean = false, swipeOffset: Float = 0f, + bubbleInteractionSource: MutableInteractionSource = remember { MutableInteractionSource() }, ) { val cInfo = chat.chatInfo val uriHandler = LocalUriHandler.current val sent = cItem.chatDir.sent - val alignment = if (sent) Alignment.CenterEnd else Alignment.CenterStart + val terminal = LocalTerminalLayout.current + val alignEnd = sent && !terminal + val alignment = if (alignEnd) Alignment.CenterEnd else Alignment.CenterStart val showMenu = remember { mutableStateOf(false) } val fullDeleteAllowed = remember(cInfo) { cInfo.featureEnabled(ChatFeature.FullDelete) } val onLinkLongClick = { _: String -> showMenu.value = true } @@ -139,7 +145,7 @@ fun ChatItemView( @Composable fun ChatItemReactions() { - Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.chatItemOffset(cItem, itemSeparation.largeGap, inverted = true, revealed = true)) { + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.chatItemOffset(cItem, itemSeparation.largeGap, inverted = true, revealed = true).padding(start = if (terminal) CHAT_ITEM_TEXT_PADDING - REACTION_SPACING - REACTION_PADDING else 0.dp)) { cItem.reactions.forEach { r -> val showReactionMenu = remember { mutableStateOf(false) } val reactionMenuItems = remember { mutableStateOf(emptyList()) } @@ -153,7 +159,7 @@ fun ChatItemView( } } - var modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp).clip(RoundedCornerShape(8.dp)) + var modifier = Modifier.padding(horizontal = REACTION_SPACING, vertical = 2.dp).clip(RoundedCornerShape(8.dp)) if (cInfo.featureEnabled(ChatFeature.Reactions)) { fun showReactionsMenu() { when (cInfo) { @@ -206,7 +212,7 @@ fun ChatItemView( ) .onRightClick { showReactionsMenu() } } - Row(modifier.padding(2.dp), verticalAlignment = Alignment.CenterVertically) { + Row(modifier.padding(REACTION_PADDING), verticalAlignment = Alignment.CenterVertically) { ReactionIcon(r.reaction.text, fontSize = 12.sp) DefaultDropdownMenu(showMenu = showReactionMenu) { reactionMenuItems.value.forEach { m -> @@ -301,20 +307,20 @@ fun ChatItemView( } } - Column(horizontalAlignment = if (cItem.chatDir.sent) Alignment.End else Alignment.Start) { + Column(horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start) { val canReply = (cItem.content is CIContent.SndMsgContent || cItem.content is CIContent.RcvMsgContent) && cInfo !is ChatInfo.Local && !cItem.isReport && !cItem.meta.isLive && cItem.meta.itemDeleted == null Box { Row(verticalAlignment = Alignment.CenterVertically) { - val bubbleInteractionSource = remember { MutableInteractionSource() } val bubbleHovered = bubbleInteractionSource.collectIsHoveredAsState() - if (cItem.chatDir.sent) { + val markedDeleted = cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent) + if (alignEnd) { GoToItemButton(true, bubbleHovered) } Column(Modifier.weight(1f, fill = false)) { val enterInteraction = remember { HoverInteraction.Enter() } LaunchedEffect(highlighted.value, hoveredItemId.value) { - if (highlighted.value || hoveredItemId.value == cItem.id) { + if ((highlighted.value && !terminal) || hoveredItemId.value == cItem.id) { bubbleInteractionSource.emit(enterInteraction) } else { bubbleInteractionSource.emit(HoverInteraction.Exit(enterInteraction)) @@ -322,7 +328,7 @@ fun ChatItemView( } Column( Modifier - .clipChatItem(cItem, itemSeparation.largeGap, revealed.value) + .then(if (terminal) Modifier else Modifier.clipChatItem(cItem, itemSeparation.largeGap, revealed.value)) .hoverable(bubbleInteractionSource) .combinedClickable( onLongClick = { showMenu.value = true }, @@ -331,12 +337,12 @@ fun ChatItemView( hoveredItemId.value = if (hoveredItemId.value == cItem.id) null else cItem.id } onClick() - }, interactionSource = bubbleInteractionSource, indication = LocalIndication.current) + }, interactionSource = bubbleInteractionSource, indication = if (terminal) null else LocalIndication.current) .onRightClick { showMenu.value = true }, ) { @Composable fun framedItemView() { - FramedItemView(chatsCtx, chat, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem) + FramedItemView(chatsCtx, chat, cItem, uriHandler, imageProvider, linkMode = linkMode, showViaProxy = showViaProxy, showMenu, showTimestamp = showTimestamp, tailVisible = itemSeparation.largeGap, receiveFile, onLinkLongClick, scrollToItem, scrollToItemId, scrollToQuotedItemFromItem, endButton = if (terminal && !searchIsNotBlank.value && cItem.meta.itemForwarded?.chatTypeApiIdMsgId != null) { { GoToItemButton(false, bubbleHovered) } } else null) } fun deleteMessageQuestionText(): String { @@ -571,7 +577,7 @@ fun ChatItemView( @Composable fun ContentItem() { val mc = cItem.content.msgContent - if (cItem.quotedItem == null && cItem.meta.itemForwarded == null && cItem.meta.itemDeleted == null && !cItem.meta.isLive) { + if (!cItem.hasQuoteOrHeader) { if (mc is MsgContent.MCText && isShortEmoji(cItem.content.text)) { EmojiItemView(cItem, cInfo.timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { @@ -622,24 +628,26 @@ fun ChatItemView( } } - fun eventItemViewText(reversedChatItems: List): AnnotatedString { + fun eventItemViewText(reversedChatItems: List, ts: AnnotatedString): AnnotatedString { val memberDisplayName = cItem.memberDisplayName val t = mergedGroupEventText(cItem, reversedChatItems) return if (!revealed.value && t != null) { - chatEventText(t, cItem.timestampText) + chatEventText(t, ts) } else if (memberDisplayName != null) { buildAnnotatedString { withStyle(chatEventStyle) { append(memberDisplayName) } append(" ") - }.plus(chatEventText(cItem, cInfo.isChannel)) + }.plus(chatEventText(cItem, cInfo.isChannel, ts)) } else { - chatEventText(cItem, cInfo.isChannel) + chatEventText(cItem, cInfo.isChannel, ts) } } @Composable fun EventItemView() { val reversedChatItems = chatsCtx.chatItems.value.asReversed() - CIEventView(eventItemViewText(reversedChatItems)) + ChatEventWithMeta(cItem) { separator, ts -> + CIEventView(eventItemViewText(reversedChatItems, separator + ts)) + } } @Composable fun PendingReviewEventItemView() { @@ -647,7 +655,8 @@ fun ChatItemView( buildAnnotatedString { withStyle(chatEventStyle.copy(fontWeight = FontWeight.Bold)) { append(cItem.content.text(cInfo.isChannel)) } }, - Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + Modifier.padding(chatEventPadding()), + style = chatEventTextStyle() ) } @@ -677,7 +686,8 @@ fun ChatItemView( buildAnnotatedString { withStyle(chatEventStyle) { append(annotatedStringResource(sId)) } }, - Modifier.padding(horizontal = 6.dp, vertical = 6.dp) + Modifier.padding(chatEventPadding()), + style = chatEventTextStyle() ) } @@ -695,7 +705,7 @@ fun ChatItemView( e2eeInfoText(if (e2EEInfo.public == true) MR.strings.e2ee_info_no_e2ee else MR.strings.e2ee_info_no_pq) } - if (cItem.meta.itemDeleted != null && (!revealed.value || cItem.isDeletedContent)) { + if (markedDeleted) { MarkedDeletedItemView(chatsCtx, cItem, cInfo, cInfo.timedMessagesTTL, revealed, showViaProxy = showViaProxy, showTimestamp = showTimestamp) MarkedDeletedItemDropdownMenu() } else { @@ -803,7 +813,7 @@ fun ChatItemView( } } } - if (!cItem.chatDir.sent) { + if (!alignEnd && (!terminal || searchIsNotBlank.value || markedDeleted)) { GoToItemButton(false, bubbleHovered) } } @@ -823,6 +833,9 @@ fun ChatItemView( } } +val ChatItem.hasQuoteOrHeader: Boolean get() = + quotedItem != null || meta.itemForwarded != null || meta.itemDeleted != null || meta.isLive || isReport + @Composable expect fun ReactionIcon(text: String, fontSize: TextUnit = TextUnit.Unspecified) @@ -1203,10 +1216,12 @@ fun ItemAction(text: String, color: Color = Color.Unspecified, onClick: () -> Un } } +@Composable +fun chatItemTailEnabled(): Boolean = remember { appPreferences.chatItemTail.state }.value && !LocalTerminalLayout.current + @Composable fun Modifier.chatItemOffset(cItem: ChatItem, tailVisible: Boolean, inverted: Boolean = false, revealed: Boolean): Modifier { - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(cItem, chatItemTail.value, tailVisible, revealed) + val style = shapeStyle(cItem, chatItemTailEnabled(), tailVisible, revealed) val offset = if (style is ShapeStyle.Bubble) { if (style.tailVisible) { @@ -1222,8 +1237,7 @@ fun Modifier.chatItemOffset(cItem: ChatItem, tailVisible: Boolean, inverted: Boo @Composable fun Modifier.clipChatItem(chatItem: ChatItem? = null, tailVisible: Boolean = false, revealed: Boolean = false): Modifier { val chatItemRoundness = remember { appPreferences.chatItemRoundness.state } - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(chatItem, chatItemTail.value, tailVisible, revealed) + val style = shapeStyle(chatItem, chatItemTailEnabled(), tailVisible, revealed) val cornerRoundness = chatItemRoundness.value.coerceIn(0f, 1f) return when (style) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt index 17245c4e75..70bf88a51c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/DeletedItemView.kt @@ -20,23 +20,25 @@ fun DeletedItemView(ci: ChatItem, timedMessagesTTL: Int?, showViaProxy: Boolean, val sent = ci.chatDir.sent val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - shape = RoundedCornerShape(18.dp), - color = if (sent) sentColor else receivedColor, - contentColor = LocalContentColor.current - ) { - Row( - Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.Bottom + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) }) { inlineMeta -> + Surface( + shape = RoundedCornerShape(18.dp), + color = if (sent) sentColor else receivedColor, + contentColor = LocalContentColor.current ) { - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), - modifier = Modifier.padding(end = 8.dp) - ) - CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + Row( + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + buildAnnotatedString { + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = MaterialTheme.colors.secondary)) { append(ci.content.text) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + modifier = Modifier.padding(end = if (inlineMeta != null) 8.dp else 0.dp) + ) + inlineMeta?.invoke() + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt index 3bcd02411f..4b8633c4b3 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/EmojiItemView.kt @@ -15,6 +15,7 @@ import chat.simplex.common.model.ChatItem import chat.simplex.common.model.MREmojiChar import chat.simplex.common.ui.theme.EmojiFont import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.toDp import java.sql.Timestamp val largeEmojiFont: TextStyle = TextStyle(fontSize = 48.sp, fontFamily = EmojiFont) @@ -25,18 +26,20 @@ fun EmojiItemView(chatItem: ChatItem, timedMessagesTTL: Int?, showViaProxy: Bool val emojiText = chatItem.content.text.trim() val isSelected = setupEmojiSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, emojiText.length) - Column( - Modifier.padding(vertical = 8.dp, horizontal = 12.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - if (isSelected) { - Box(Modifier.background(SelectionHighlightColor)) { + ItemWithMeta(meta = { CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) }, metaReserved = true) { inlineMeta -> + Column( + if (LocalTerminalLayout.current) Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_EMOJI_TOP_GAP.toDp(), end = CHAT_ITEM_TEXT_PADDING, bottom = TERMINAL_EMOJI_BOTTOM_GAP.toDp()).messageLastLine() else Modifier.padding(vertical = 8.dp, horizontal = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (isSelected) { + Box(Modifier.background(SelectionHighlightColor)) { + EmojiText(chatItem.content.text) + } + } else { EmojiText(chatItem.content.text) } - } else { - EmojiText(chatItem.content.text) + inlineMeta?.invoke() } - CIMetaView(chatItem, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index d1b56add6b..acae6c4c44 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -47,10 +47,17 @@ fun FramedItemView( scrollToItem: (Long) -> Unit = {}, scrollToItemId: MutableState, scrollToQuotedItemFromItem: (Long) -> Unit = {}, + endButton: (@Composable () -> Unit)? = null, ) { val chatInfo = chat.chatInfo val sent = ci.chatDir.sent val chatTTL = chatInfo.timedMessagesTTL + val terminal = LocalTerminalLayout.current + val mediaModifier = if (terminal) Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_BLOCK_TOP_GAP.toDp()).clipChatItem(ci, revealed = true) else Modifier + val quoteColor = if (sent) MaterialTheme.appColors.sentQuote else MaterialTheme.appColors.receivedQuote + val cardModifier = mediaModifier.background(quoteColor) + val thumbnailClip = if (terminal) Modifier.clipChatItem(ci, revealed = true) else Modifier + val quoteBaseStyle = if (terminal) MaterialTheme.typography.body1 else TextStyle() fun membership(): GroupMember? { return if (chatInfo is ChatInfo.Group) chatInfo.groupInfo.membership else null @@ -58,6 +65,7 @@ fun FramedItemView( @Composable fun ciQuotedMsgTextView(qi: CIQuote, lines: Int, showTimestamp: Boolean, stripLink: String? = null, prefix: AnnotatedString? = null) { + val color = if (terminal) MaterialTheme.colors.onSurface.copy(alpha = 0.7f) else MaterialTheme.colors.onSurface MarkdownText( qi.text, qi.formattedText, @@ -65,7 +73,7 @@ fun FramedItemView( toggleSecrets = true, maxLines = lines, overflow = TextOverflow.Ellipsis, - style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface), + style = quoteBaseStyle.copy(fontSize = 15.sp, color = color).merge(terminalTextStyle(15.sp, color)), linkMode = linkMode, uriHandler = if (appPlatform.isDesktop) uriHandler else null, showTimestamp = showTimestamp, @@ -81,7 +89,7 @@ fun FramedItemView( // this width limitation prevents crash on calculating constraints that may happen if you post veeeery long message and then quote it. // Top level layout wants `IntrinsicWidth.Max` and very long layout makes the crash in this case .widthIn(max = 50000.dp) - .padding(vertical = 6.dp, horizontal = 12.dp), + .then(if (terminal) Modifier.padding(end = 12.dp) else Modifier.padding(vertical = 6.dp, horizontal = 12.dp)), contentAlignment = Alignment.TopStart ) { val sender = qi.sender(membership()) @@ -91,7 +99,7 @@ fun FramedItemView( ) { Text( sender, - style = TextStyle(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary), + style = quoteBaseStyle.copy(fontSize = 13.5.sp, color = if (qi.chatDir is CIDirection.GroupSnd) CurrentColors.value.colors.primary else CurrentColors.value.colors.secondary).merge(terminalTextStyle(13.5.sp)), maxLines = 1 ) ciQuotedMsgTextView(qi, lines = 2, showTimestamp = showTimestamp, stripLink = stripLink, prefix = prefix) @@ -111,7 +119,7 @@ fun FramedItemView( append(caption) } }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp).merge(terminalTextStyle(fontSize, MaterialTheme.colors.secondary)), maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -119,13 +127,14 @@ fun FramedItemView( @Composable fun headerModifier(pad: Boolean, onClick: (() -> Unit)? = null): Modifier { - val sentColor = MaterialTheme.appColors.sentQuote - val receivedColor = MaterialTheme.appColors.receivedQuote return Modifier - .background(if (sent) sentColor else receivedColor) + .then(if (terminal) Modifier else Modifier.background(quoteColor)) .fillMaxWidth() .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp) + .then( + if (terminal) Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_HEADER_TOP_GAP.toDp(), end = 12.dp, bottom = TERMINAL_HEADER_BOTTOM_GAP.toDp()) + else Modifier.padding(start = 8.dp, top = 6.dp, end = 12.dp, bottom = if (pad || (ci.quotedItem == null && ci.meta.itemForwarded == null)) 6.dp else 0.dp) + ) } @Composable @@ -139,7 +148,7 @@ fun FramedItemView( Icon( icon, caption, - Modifier.size(18.dp), + Modifier.inlineIcon().size(18.dp), tint = iconColor ?: if (isInDarkTheme()) FileDark else FileLight ) } @@ -155,11 +164,9 @@ fun FramedItemView( @Composable fun ciQuoteView(qi: CIQuote) { val blurred = remember { mutableStateOf(appPreferences.privacyMediaBlurRadius.get() > 0) } - val sentColor = MaterialTheme.appColors.sentQuote - val receivedColor = MaterialTheme.appColors.receivedQuote Row( Modifier - .background(if (sent) sentColor else receivedColor) + .then(if (terminal) Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_BLOCK_TOP_GAP.toDp(), bottom = TERMINAL_BLOCK_BOTTOM_GAP.toDp()).quoteBar(MaterialTheme.colors.onSurface) else Modifier.background(quoteColor)) .fillMaxWidth() ) { when (qi.content) { @@ -172,7 +179,7 @@ fun FramedItemView( imageBitmap, contentDescription = stringResource(MR.strings.image_descr), contentScale = ContentScale.Crop, - modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }) + modifier = Modifier.size(68.dp).then(thumbnailClip).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }) ) } is MsgContent.MCVideo -> { @@ -184,7 +191,7 @@ fun FramedItemView( imageBitmap, contentDescription = stringResource(MR.strings.video_descr), contentScale = ContentScale.Crop, - modifier = Modifier.size(68.dp).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }) + modifier = Modifier.size(68.dp).then(thumbnailClip).clipToBounds().desktopModifyBlurredState(blurred, showMenu).privacyBlur(fullSize = false, imageBitmap, blurred, chatViewScrollState.collectAsState(), onLongClick = { showMenu.value = true }) ) } is MsgContent.MCFile, is MsgContent.MCVoice -> { @@ -195,7 +202,7 @@ fun FramedItemView( if (qi.content is MsgContent.MCFile) painterResource(MR.images.ic_draft_filled) else painterResource(MR.images.ic_mic_filled), if (qi.content is MsgContent.MCFile) stringResource(MR.strings.icon_descr_file) else stringResource(MR.strings.voice_message), Modifier - .padding(top = 6.dp, end = 4.dp) + .padding(top = if (terminal) 0.dp else 6.dp, end = 4.dp) .size(22.dp), tint = if (isInDarkTheme()) FileDark else FileLight ) @@ -210,7 +217,7 @@ fun FramedItemView( Icon( painterResource(qi.content.chatLink.smallIconRes), null, - Modifier.padding(top = 6.dp, end = 4.dp).size(22.dp), + Modifier.padding(top = if (terminal) 0.dp else 6.dp, end = 4.dp).size(22.dp), tint = if (isInDarkTheme()) FileDark else FileLight ) } @@ -223,220 +230,248 @@ fun FramedItemView( fun ciFileView(ci: ChatItem, text: String) { CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, receiveFile) if (text != "" || ci.meta.isLive) { - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = 0.dp) } } - val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) && - !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null && ci.meta.itemForwarded == null + val captionlessMedia = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) && + !ci.meta.isLive && ci.content.text.isEmpty() + val transparentBackground = captionlessMedia && ci.quotedItem == null && ci.meta.itemForwarded == null val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage + val (metaReserved, gapBelowBlock) = when (val mc = ci.content.msgContent) { + is MsgContent.MCVoice -> (mc.text != "") to false + is MsgContent.MCChat -> (mc.text != mc.chatLink.connLinkStr).let { it to !it } + else -> !captionlessMedia to captionlessMedia + } Box(Modifier - .clipChatItem(ci, tailVisible, revealed = true) + .then( + when { + !terminal -> Modifier.clipChatItem(ci, tailVisible, revealed = true) + gapBelowBlock -> Modifier.padding(bottom = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) + else -> Modifier + } + ) .background( when { - transparentBackground -> Color.Transparent + transparentBackground || terminal -> Color.Transparent sent -> sentColor else -> receivedColor } )) { var metaColor = MaterialTheme.colors.secondary - Box(contentAlignment = Alignment.BottomEnd) { - val chatItemTail = remember { appPreferences.chatItemTail.state } - val style = shapeStyle(ci, chatItemTail.value, tailVisible, true) - val tailRendered = style is ShapeStyle.Bubble && style.tailVisible - Column( - Modifier - .width(IntrinsicSize.Max) - .padding(start = if (!sent && tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) - ) { - PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID) { - @Composable - fun Header() { - if (ci.isReport) { - if (ci.meta.itemDeleted == null) { - FramedItemHeader( - stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), - true, - painterResource(MR.images.ic_flag), - iconColor = Color.Red - ) - } else { - val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { - stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName) + ItemWithMeta( + meta = { CIMetaView(ci, chatTTL, metaColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp) }, + overlayMeta = if (captionlessMedia) { { CIMetaView(ci, chatTTL, Color.White, showViaProxy = showViaProxy, showTimestamp = showTimestamp, lineHeightColor = messageTextColor()) } } else null, + metaReserved = metaReserved, + endButton = endButton + ) { inlineMeta -> + Box(contentAlignment = Alignment.BottomEnd) { + val style = shapeStyle(ci, chatItemTailEnabled(), tailVisible, true) + val tailRendered = style is ShapeStyle.Bubble && style.tailVisible + Column( + Modifier + .width(IntrinsicSize.Max) + .padding(start = if (!sent && tailRendered) msgTailWidthDp else 0.dp, end = if (sent && tailRendered) msgTailWidthDp else 0.dp) + ) { + PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID, limitToPriorityWidth = !terminal || captionlessMedia) { + @Composable + fun Header() { + if (ci.isReport) { + if (ci.meta.itemDeleted == null) { + FramedItemHeader( + stringResource(if (ci.chatDir.sent) MR.strings.report_item_visibility_submitter else MR.strings.report_item_visibility_moderators), + true, + painterResource(MR.images.ic_flag), + iconColor = Color.Red + ) } else { - stringResource(MR.strings.report_item_archived) - } - FramedItemHeader(text, true, painterResource(MR.images.ic_flag)) - } - } else if (ci.meta.itemDeleted != null) { - when (ci.meta.itemDeleted) { - is CIDeleted.Moderated -> { - FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) - } - is CIDeleted.Blocked -> { - FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) - } - is CIDeleted.BlockedByAdmin -> { - FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand)) - } - is CIDeleted.Deleted -> { - FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) - } - } - } else if (ci.meta.isLive) { - FramedItemHeader(stringResource(MR.strings.live), false) - } - } - if (ci.quotedItem != null) { - Column( - Modifier - .combinedClickable( - onLongClick = { showMenu.value = true }, - onClick = { - if (ci.quotedItem.itemId != null) { - if (ci.isReport && chatsCtx.secondaryContextFilter != null) { - scrollToItemId.value = ci.quotedItem.itemId - } else { - scrollToItem(ci.quotedItem.itemId) - } - } else { - scrollToQuotedItemFromItem(ci.id) - } + val text = if (ci.meta.itemDeleted is CIDeleted.Moderated && ci.meta.itemDeleted.byGroupMember.groupMemberId != (chatInfo as ChatInfo.Group?)?.groupInfo?.membership?.groupMemberId) { + stringResource(MR.strings.report_item_archived_by).format(ci.meta.itemDeleted.byGroupMember.displayName) + } else { + stringResource(MR.strings.report_item_archived) } - ) - .onRightClick { showMenu.value = true } - ) { - Header() - ciQuoteView(ci.quotedItem) - } - } else { - Header() - val forwarded = ci.meta.itemForwarded - if (forwarded != null) { - val twoRowHeader = if (chatInfo.chatType == ChatType.Local) { - forwarded.chatTypeApiIdMsgId != null || forwarded.sourceGroupLink != null - } else { - when (forwarded) { - is CIForwardedFrom.Group -> forwarded.groupType != null - is CIForwardedFrom.GroupLink -> true - else -> false + FramedItemHeader(text, true, painterResource(MR.images.ic_flag)) } - } - if (twoRowHeader) { - val caption = stringResource(if (chatInfo.chatType == ChatType.Local) MR.strings.saved_from else MR.strings.forwarded_from) - Column( - headerModifier(pad = true, onClick = { - val target = forwarded.chatTypeApiIdMsgId - val link = forwarded.sourceGroupLink - if (target != null) { - val (chatType, apiId, itemId) = target - withBGApi { openChat(secondaryChatsCtx = null, chat.remoteHostId, chatType, apiId, itemId) } - } else if (link != null) { - withBGApi { planAndConnect(chat.remoteHostId, link, close = null) } - } - }), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - HeaderRow(Modifier, caption, true, painterResource(MR.images.ic_forward), null) - HeaderText(forwarded.chatName, italic = false, fontSize = 15.sp, modifier = Modifier.offset(y = (-2).dp)) + } else if (ci.meta.itemDeleted != null) { + when (ci.meta.itemDeleted) { + is CIDeleted.Moderated -> { + FramedItemHeader(String.format(stringResource(MR.strings.moderated_item_description), ci.meta.itemDeleted.byGroupMember.chatViewName), true, painterResource(MR.images.ic_flag)) + } + is CIDeleted.Blocked -> { + FramedItemHeader(stringResource(MR.strings.blocked_item_description), true, painterResource(MR.images.ic_back_hand)) + } + is CIDeleted.BlockedByAdmin -> { + FramedItemHeader(stringResource(MR.strings.blocked_by_admin_item_description), true, painterResource(MR.images.ic_back_hand)) + } + is CIDeleted.Deleted -> { + FramedItemHeader(stringResource(MR.strings.marked_deleted_description), true, painterResource(MR.images.ic_delete)) + } } - } else { - FramedItemHeader(forwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + } else if (ci.meta.isLive) { + FramedItemHeader(stringResource(MR.strings.live), false) } } - } - if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { - Box(Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { + if (ci.quotedItem != null) { Column( Modifier - .padding(bottom = 2.dp) - .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - EmojiText(ci.content.text) - Text( - reserveSpaceForMeta(ci.meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp), - color = Color.Transparent, - style = MaterialTheme.typography.body1 - ) - } - } - } else { - when (val mc = ci.content.msgContent) { - is MsgContent.MCImage -> { - CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile) - if (mc.text == "" && !ci.meta.isLive) { - metaColor = Color.White - } else { - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } - } - is MsgContent.MCVideo -> { - CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile) - if (mc.text == "" && !ci.meta.isLive) { - metaColor = Color.White - } else { - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } - } - is MsgContent.MCVoice -> { - CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) - if (mc.text != "") { - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } - } - is MsgContent.MCFile -> ciFileView(ci, mc.text) - is MsgContent.MCUnknown -> - if (ci.file == null) { - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } else { - ciFileView(ci, mc.text) - } - is MsgContent.MCLink -> { - ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true }) - Box(Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } - } - is MsgContent.MCChat -> { - val hasText = mc.text != mc.chatLink.connLinkStr - Box( - Modifier.combinedClickable( + .combinedClickable( + onLongClick = { showMenu.value = true }, onClick = { - withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } - }, - onLongClick = { showMenu.value = true } + if (ci.quotedItem.itemId != null) { + if (ci.isReport && chatsCtx.secondaryContextFilter != null) { + scrollToItemId.value = ci.quotedItem.itemId + } else { + scrollToItem(ci.quotedItem.itemId) + } + } else { + scrollToQuotedItemFromItem(ci.id) + } + } ) - ) { - CIChatLinkHeader(chatLink = mc.chatLink, ownerSig = mc.ownerSig, hasText = hasText) - } - if (hasText) { - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, stripLink = mc.chatLink.connLinkStr) - } + .onRightClick { showMenu.value = true } + ) { + Header() + ciQuoteView(ci.quotedItem) } - is MsgContent.MCReport -> { - val prefix = buildAnnotatedString { - withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { - append(itemPrefixText(ci)) + } else { + Header() + val forwarded = ci.meta.itemForwarded + if (forwarded != null) { + val twoRowHeader = if (chatInfo.chatType == ChatType.Local) { + forwarded.chatTypeApiIdMsgId != null || forwarded.sourceGroupLink != null + } else { + when (forwarded) { + is CIForwardedFrom.Group -> forwarded.groupType != null + is CIForwardedFrom.GroupLink -> true + else -> false } } - CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix) + if (twoRowHeader) { + val caption = stringResource(if (chatInfo.chatType == ChatType.Local) MR.strings.saved_from else MR.strings.forwarded_from) + Column( + headerModifier(pad = true, onClick = { + val target = forwarded.chatTypeApiIdMsgId + val link = forwarded.sourceGroupLink + if (target != null) { + val (chatType, apiId, itemId) = target + withBGApi { openChat(secondaryChatsCtx = null, chat.remoteHostId, chatType, apiId, itemId) } + } else if (link != null) { + withBGApi { planAndConnect(chat.remoteHostId, link, close = null) } + } + }), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + HeaderRow(Modifier, caption, true, painterResource(MR.images.ic_forward), null) + HeaderText(forwarded.chatName, italic = false, fontSize = 15.sp, modifier = Modifier.offset(y = (-2).dp)) + } + } else { + FramedItemHeader(forwarded.text(chatInfo.chatType), true, painterResource(MR.images.ic_forward), pad = true) + } + } + } + if (ci.file == null && ci.formattedText == null && !ci.meta.isLive && isShortEmoji(ci.content.text)) { + Box(if (terminal) Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, top = TERMINAL_EMOJI_TOP_GAP.toDp(), end = CHAT_ITEM_TEXT_PADDING, bottom = TERMINAL_EMOJI_BOTTOM_GAP.toDp()) else Modifier.padding(vertical = 6.dp, horizontal = 12.dp)) { + Column( + if (terminal) Modifier.messageLastLine() else Modifier.padding(bottom = 2.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + EmojiText(ci.content.text) + if (!terminal) { + Text( + reserveSpaceForMeta(ci.meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp), + color = Color.Transparent, + style = MaterialTheme.typography.body1 + ) + } + } + } + } else { + when (val mc = ci.content.msgContent) { + is MsgContent.MCImage -> { + val provider = imageProvider ?: return@PriorityLayout + Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).then(mediaModifier)) { + CIImageView(image = mc.image, file = ci.file, provider, showMenu, false, receiveFile) + } + if (mc.text == "" && !ci.meta.isLive) { + if (!terminal) metaColor = Color.White + } else { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) + } + } + is MsgContent.MCVideo -> { + val provider = imageProvider ?: return@PriorityLayout + Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).then(mediaModifier)) { + CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, showMenu, smallView = false, receiveFile = receiveFile) + } + if (mc.text == "" && !ci.meta.isLive) { + if (!terminal) metaColor = Color.White + } else { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) + } + } + is MsgContent.MCVoice -> { + CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp, longClick = { onLinkLongClick("") }, receiveFile = receiveFile) + if (mc.text != "") { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = 0.dp) + } + } + is MsgContent.MCFile -> ciFileView(ci, mc.text) + is MsgContent.MCUnknown -> + if (ci.file == null) { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = 0.dp) + } else { + ciFileView(ci, mc.text) + } + is MsgContent.MCLink -> { + Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).then(if (terminal) cardModifier.padding(bottom = 8.dp) else Modifier)) { + ChatItemLinkView(mc.preview, showMenu, onLongClick = { showMenu.value = true }) + } + Box(if (terminal) Modifier else Modifier.widthIn(max = DEFAULT_MAX_IMAGE_WIDTH)) { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) + } + } + is MsgContent.MCChat -> { + val hasText = mc.text != mc.chatLink.connLinkStr + Box( + Modifier.then(if (terminal) cardModifier.width(IntrinsicSize.Max) else Modifier).combinedClickable( + onClick = { + withBGApi { planAndConnect(chat.remoteHostId, mc.chatLink.connLinkStr, linkOwnerSig = mc.ownerSig, close = null) } + }, + onLongClick = { showMenu.value = true } + ) + ) { + CIChatLinkHeader(chatLink = mc.chatLink, ownerSig = mc.ownerSig, hasText = hasText) + } + if (hasText) { + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp, stripLink = mc.chatLink.connLinkStr, terminalTop = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) + } + } + is MsgContent.MCReport -> { + val prefix = buildAnnotatedString { + withStyle(SpanStyle(color = Color.Red, fontStyle = FontStyle.Italic)) { + append(itemPrefixText(ci)) + } + } + CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = prefix, terminalTop = 0.dp) + } + else -> CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, terminalTop = 0.dp) } - else -> CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode, uriHandler, onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } } - } - Box( - Modifier - .padding( - bottom = 6.dp, - end = 12.dp + if (tailRendered && sent) msgTailWidthDp else 0.dp, - ) - ) { - CIMetaView(ci, chatTTL, metaColor, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + if (inlineMeta != null) { + Box( + Modifier + .padding( + bottom = 6.dp, + end = 12.dp + if (tailRendered && sent) msgTailWidthDp else 0.dp, + ) + ) { + inlineMeta() + } + } } } } @@ -454,18 +489,29 @@ fun CIMarkdownText( showViaProxy: Boolean, showTimestamp: Boolean, prefix: AnnotatedString? = null, - stripLink: String? = null + stripLink: String? = null, + terminalTop: Dp ) { val chatInfo = chat.chatInfo val text = if (ci.meta.isLive) ci.content.msgContent?.text ?: ci.text else ci.text val bases = remember(ci, linkMode) { itemDisplay(ci, linkMode)?.bases ?: listOf(0) } val selection = setupItemSelection(LocalSelectionManager.current, LocalItemContext.current.selectionIndex, ci.meta.isLive == true, bases) + val terminal = LocalTerminalLayout.current - Box(Modifier.padding(vertical = 7.dp, horizontal = 12.dp)) { + Box( + Modifier + .padding( + start = CHAT_ITEM_TEXT_PADDING, + top = if (terminal) terminalTop + terminalHeadingGap(ci).toDp() else CHAT_ITEM_TEXT_VERTICAL_PADDING, + end = CHAT_ITEM_TEXT_PADDING, + bottom = if (terminal) 0.dp else CHAT_ITEM_TEXT_VERTICAL_PADDING + ) + ) { MarkdownText( text, if (text.isEmpty()) emptyList() else ci.formattedText, if (text.isEmpty()) null else ci.markdownBlocks, toggleSecrets = true, sendCommandMsg = if (chatInfo.useCommands && chat.chatInfo.sndReady) { { msg -> sendCommandMsg(chatsCtx, chat, msg) } } else null, - meta = ci.meta, chatTTL = chatTTL, linkMode = linkMode, + meta = ci.meta, chatTTL = chatTTL, encrypted = ci.encryptedFile, signedFileVerified = ci.file?.loaded, linkMode = linkMode, + style = MaterialTheme.typography.body1.copy(color = messageTextColor(), lineHeight = 22.sp).merge(terminalTextStyle(MaterialTheme.typography.body1.fontSize)), mentions = ci.mentions, userMemberId = when { chatInfo is ChatInfo.Group -> chatInfo.groupInfo.membership.memberId else -> null @@ -509,6 +555,8 @@ const val CHAT_IMAGE_LAYOUT_ID = "chatImage" const val CHAT_BUBBLE_LAYOUT_ID = "chatBubble" const val CHAT_COMPOSE_LAYOUT_ID = "chatCompose" const val CONSOLE_COMPOSE_LAYOUT_ID = "consoleCompose" +val CHAT_ITEM_TEXT_PADDING = 12.dp +val CHAT_ITEM_TEXT_VERTICAL_PADDING = 7.dp /** * Compose shows "Can't represent a width of ... and height ... in Constraints" even when using built-in method for measuring max @@ -524,6 +572,7 @@ private fun horizontalPaddingAroundCustomLayouts(density: Float): Int = fun PriorityLayout( modifier: Modifier = Modifier, priorityLayoutId: String, + limitToPriorityWidth: Boolean = true, content: @Composable () -> Unit ) { Layout( @@ -540,9 +589,9 @@ fun PriorityLayout( if (it.layoutId == priorityLayoutId) imagePlaceable!! else - it.measure(constraints.copy(maxWidth = imagePlaceable?.width ?: constraints.maxWidth)) } + it.measure(constraints.copy(maxWidth = if (limitToPriorityWidth) imagePlaceable?.width ?: constraints.maxWidth else constraints.maxWidth)) } // Limit width for every other element to width of important element and height for a sum of all elements. - val width = imagePlaceable?.measuredWidth ?: placeables.maxOf { it.width } + val width = if (limitToPriorityWidth) imagePlaceable?.measuredWidth ?: placeables.maxOf { it.width } else placeables.maxOf { it.width } val height = placeables.sumOf { it.height } val adjustedConstraints = Constraints.fitPrioritizingHeight(constraints.minWidth, width, constraints.minHeight, height) layout( @@ -551,7 +600,7 @@ fun PriorityLayout( ) { var y = 0 placeables.forEach { - it.place(0, y) + if (limitToPriorityWidth) it.place(0, y) else it.placeRelative(0, y) y += it.measuredHeight } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt index 9b378ea9ea..2869ce4168 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/IntegrityErrorItemView.kt @@ -52,24 +52,26 @@ fun IntegrityErrorItemView(msgError: MsgErrorType, ci: ChatItem, showTimestamp: @Composable fun CIMsgError(ci: ChatItem, showTimestamp: Boolean, timedMessagesTTL: Int?, onClick: () -> Unit) { val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - Modifier.clickable(onClick = onClick), - shape = RoundedCornerShape(18.dp), - color = receivedColor, - contentColor = LocalContentColor.current - ) { - Row( - Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.Bottom + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL, showViaProxy = false, showTimestamp = showTimestamp) }) { inlineMeta -> + Surface( + Modifier.clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + color = receivedColor, + contentColor = LocalContentColor.current ) { - Text( - buildAnnotatedString { - withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } - }, - style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), - modifier = Modifier.padding(end = 8.dp) - ) - CIMetaView(ci, timedMessagesTTL, showViaProxy = false, showTimestamp = showTimestamp) + Row( + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + buildAnnotatedString { + withStyle(SpanStyle(fontStyle = FontStyle.Italic, color = Color.Red)) { append(ci.content.text) } + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp), + modifier = Modifier.padding(end = if (inlineMeta != null) 8.dp else 0.dp) + ) + inlineMeta?.invoke() + } } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt index 75a778e567..637b53a23b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt @@ -21,13 +21,18 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.takeOrElse import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.AnnotatedString @@ -35,6 +40,9 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.takeOrElse @@ -44,6 +52,8 @@ import chat.simplex.common.views.helpers.* import chat.simplex.res.* import dev.icerock.moko.resources.compose.painterResource import dev.icerock.moko.resources.compose.stringResource +import kotlin.math.pow +import kotlin.math.roundToInt @Composable fun MarkdownBlocks( @@ -53,7 +63,8 @@ fun MarkdownBlocks( modifier: Modifier, cardColor: Color, blockText: @Composable (index: Int, range: SegmentRange, style: TextStyle, modifier: Modifier, softWrap: Boolean, last: Boolean) -> Unit, - metaLine: @Composable () -> Unit + metaLine: (@Composable () -> Unit)?, + ruleMinWidth: Dp ) { val roundness = remember { appPreferences.chatItemRoundness.state }.value.coerceIn(0f, 1f) val cardShape = RoundedCornerShape(12.dp * roundness) @@ -61,6 +72,7 @@ fun MarkdownBlocks( val textColor = style.color.takeOrElse { MaterialTheme.colors.onSurface } val fontSize = style.fontSize.takeOrElse { 16.sp } val lineHeight = style.lineHeight.takeOrElse { 22.sp } + val terminal = LocalTerminalLayout.current val starts = remember(blocks) { blocks.runningFold(0) { n, b -> n + b.textRanges.size } } val lastIsText = when (blocks.lastOrNull()) { is MarkdownBlock.Paragraph, is MarkdownBlock.Heading, is MarkdownBlock.Quote, is MarkdownBlock.ItemList -> true @@ -72,25 +84,31 @@ fun MarkdownBlocks( } Column(modifier) { blocks.forEachIndexed { k, block -> - if (k > 0 && paragraphBreak(formattedText, blocks[k - 1].end, block.start)) { - Spacer(Modifier.height(with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() })) + if (k > 0 && terminal && blocks[k - 1] is MarkdownBlock.Heading && block !is MarkdownBlock.Heading) { + Spacer(Modifier.height(TERMINAL_HEADING_BOTTOM_GAP.toDp())) + } else if (k > 0 && (paragraphBreak(formattedText, blocks[k - 1].end, block.start) || terminal && block is MarkdownBlock.Heading)) { + Spacer(Modifier.height(if (terminal) TERMINAL_LEVEL_STEP.toDp() else with(LocalDensity.current) { (lineHeight.toPx() / 2).toDp() })) } val start = starts[k] + val marksLastBlock = k == blocks.lastIndex && metaLine == null + val cardMargins = if (!terminal) Modifier else if (marksLastBlock) Modifier.padding(top = TERMINAL_BLOCK_TOP_GAP.toDp()).messageLastBlock() else Modifier.padding(top = TERMINAL_BLOCK_TOP_GAP.toDp(), bottom = TERMINAL_BLOCK_BOTTOM_GAP.toDp()) when (block) { is MarkdownBlock.Paragraph -> BlockText(start, block.content, style) is MarkdownBlock.Heading -> { - val scale = when (block.level) { + val scale = if (terminal) TERMINAL_HEADING_RATIO.pow((3 - block.level).coerceAtLeast(0)) else when (block.level) { 1 -> 1.5f 2 -> 1.3f 3 -> 1.15f else -> 1f } - BlockText(start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold)) + BlockText( + start, block.content, style.copy(fontSize = fontSize * scale, lineHeight = lineHeight * scale, fontWeight = FontWeight.Bold), + if (terminal) Modifier.headingBox(scale, fontSize) else Modifier + ) } - is MarkdownBlock.Quote -> Row(Modifier.height(IntrinsicSize.Min)) { - Box(Modifier.width(4.dp).fillMaxHeight().clip(RoundedCornerShape(2.dp)).background(textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f))) - Spacer(Modifier.width(10.dp)) - BlockText(start, block.content, style.copy(color = textColor.copy(alpha = 0.7f))) + is MarkdownBlock.Quote -> Box(Modifier.quoteBar(textColor)) { + val quoteColor = textColor.copy(alpha = 0.7f) + BlockText(start, block.content, style.copy(color = quoteColor).merge(terminalTextStyle(fontSize, quoteColor))) } is MarkdownBlock.ItemList -> listEntries(block.items, 0).forEachIndexed { j, (entry, level) -> Row(Modifier.padding(start = 20.dp * level)) { @@ -99,13 +117,14 @@ fun MarkdownBlocks( BlockText(start + j, entry.content, style, Modifier.weight(1f, fill = false)) } } - is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content)) { m -> - BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = fontSize * 0.875f, lineHeight = lineHeight * 0.9f), m, softWrap = false) + is MarkdownBlock.CodeBlock -> CodeBlockCard(cardShape, cardColor, roundness, codeText(formattedText, block.content), cardMargins) { m -> + val codeFontSize = fontSize * 0.875f + BlockText(start, block.content, style.copy(fontFamily = FontFamily.Monospace, fontSize = codeFontSize, lineHeight = lineHeight * 0.9f).merge(terminalTextStyle(codeFontSize)), m, softWrap = false) } is MarkdownBlock.Table -> { val tableRows = listOf(block.header) + block.rows val rowStarts = tableRows.runningFold(start) { n, row -> n + row.size } - Box(Modifier.horizontalScroll(rememberScrollState())) { + Box(cardMargins.horizontalScroll(rememberScrollState())) { TableGrid( block.columns, tableRows.map { it.size }, cardColor, MaterialTheme.colors.secondary.copy(alpha = 0.1f), lineColor, Modifier.clip(cardShape).border(1.dp, lineColor, cardShape) @@ -121,13 +140,38 @@ fun MarkdownBlocks( } } } - is MarkdownBlock.Rule -> Box(Modifier.padding(vertical = 6.dp).fillMaxWidth().height(1.dp).background(lineColor)) + is MarkdownBlock.Rule -> Box((if (terminal) cardMargins.widthIn(min = ruleMinWidth) else Modifier.padding(vertical = 6.dp)).fillMaxWidth().height(1.dp).background(lineColor)) } } - if (!lastIsText) metaLine() + if (!lastIsText) metaLine?.invoke() } } +@Composable +fun Modifier.quoteBar(textColor: Color): Modifier { + val color = textColor.copy(alpha = if (MaterialTheme.colors.isLight) 0.16f else 0.22f) + val barWidth = 4.dp + return drawBehind { + val width = barWidth.roundToPx().toFloat() + drawRoundRect(color, Offset(if (layoutDirection == LayoutDirection.Ltr) 0f else size.width - width, 0f), Size(width, size.height), CornerRadius(2.dp.toPx())) + }.padding(start = barWidth + 10.dp) +} + +private fun Modifier.headingBox(scale: Float, bodyFontSize: TextUnit): Modifier = + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + val firstBaseline = placeable[FirstBaseline] + val lastBaseline = placeable[LastBaseline] + if (firstBaseline == AlignmentLine.Unspecified || lastBaseline == AlignmentLine.Unspecified) { + layout(placeable.width, placeable.height) { placeable.placeRelative(0, 0) } + } else { + val bodyRatio = bodyFontSize.toPx() / (bodyFontSize * scale).toPx() + val top = (firstBaseline * (1 - bodyRatio) - INTER_CAP_HEIGHT * ((bodyFontSize * scale).toPx() - bodyFontSize.toPx())).roundToInt() + val bottom = ((placeable.height - lastBaseline) * (1 - bodyRatio)).roundToInt() + layout(placeable.width, placeable.height - top - bottom) { placeable.placeRelative(0, -top) } + } + } + private val MarkdownBlock.start: Int get() = when (this) { is MarkdownBlock.Rule -> mark.from else -> textRanges.firstOrNull()?.range?.from ?: 0 @@ -151,13 +195,13 @@ private fun codeText(fts: List, r: SegmentRange): String = } @Composable -private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, content: @Composable (Modifier) -> Unit) { +private fun CodeBlockCard(shape: Shape, color: Color, roundness: Float, copyText: String, modifier: Modifier, content: @Composable (Modifier) -> Unit) { var tapped by remember { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } val hovered by interactionSource.collectIsHoveredAsState() val clipboard = LocalClipboardManager.current Box( - Modifier + modifier .fillMaxWidth() .clip(shape) .background(color) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt index c488b4172b..67ac3b293f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkedDeletedItemView.kt @@ -23,19 +23,21 @@ import kotlinx.datetime.Clock fun MarkedDeletedItemView(chatsCtx: ChatModel.ChatsContext, ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State, showViaProxy: Boolean, showTimestamp: Boolean) { val sentColor = MaterialTheme.appColors.sentMessage val receivedColor = MaterialTheme.appColors.receivedMessage - Surface( - shape = RoundedCornerShape(18.dp), - color = if (ci.chatDir.sent) sentColor else receivedColor, - contentColor = LocalContentColor.current - ) { - Row( - Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically + ItemWithMeta(meta = { CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) }) { inlineMeta -> + Surface( + shape = RoundedCornerShape(18.dp), + color = if (ci.chatDir.sent) sentColor else receivedColor, + contentColor = LocalContentColor.current ) { - Box(Modifier.weight(1f, false)) { - MergedMarkedDeletedText(chatsCtx, ci, chatInfo, revealed) + Row( + Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box(Modifier.weight(1f, false)) { + MergedMarkedDeletedText(chatsCtx, ci, chatInfo, revealed) + } + inlineMeta?.invoke() } - CIMetaView(ci, timedMessagesTTL, showViaProxy = showViaProxy, showTimestamp = showTimestamp) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt new file mode 100644 index 0000000000..3682c54d9d --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt @@ -0,0 +1,383 @@ +package chat.simplex.common.views.chat.item + +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.indication +import androidx.compose.foundation.interaction.Interaction +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.PressInteraction +import androidx.compose.foundation.layout.* +import androidx.compose.material.LocalTextStyle +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ProvideTextStyle +import androidx.compose.material.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToDownIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.AlignmentLine +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.HorizontalAlignmentLine +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.Placeable +import androidx.compose.ui.layout.VerticalAlignmentLine +import androidx.compose.ui.layout.layout +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.offset +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.platform.chatModel +import chat.simplex.common.ui.theme.DEFAULT_PADDING_HALF +import chat.simplex.common.views.chat.* +import chat.simplex.common.views.helpers.* +import chat.simplex.res.* +import dev.icerock.moko.resources.compose.stringResource +import kotlinx.coroutines.flow.Flow +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.pow +import kotlin.math.roundToInt + +val LocalTerminalLayout = staticCompositionLocalOf { false } + +val TERMINAL_BLOCK_TOP_GAP = 5.75.sp +val TERMINAL_BLOCK_BOTTOM_GAP = 5.sp +val TERMINAL_LEVEL_STEP = 4.75.sp +val TERMINAL_HEADING_BOTTOM_GAP = TERMINAL_LEVEL_STEP / 2 +val TERMINAL_SENDER_TOP_GAP = 6.5.sp +private val TERMINAL_SENDER_BOTTOM_GAP = 5.5.sp +val TERMINAL_HEADER_TOP_GAP = 2.5.sp +val TERMINAL_HEADER_BOTTOM_GAP = 1.5.sp +val TERMINAL_EVENT_TOP_GAP = 8.25.sp +val TERMINAL_EVENT_BOTTOM_GAP = 1.5.sp +val TERMINAL_FILE_TOP_GAP = 2.sp +val TERMINAL_EMOJI_TOP_GAP = 4.25.sp +val TERMINAL_EMOJI_BOTTOM_GAP = 1.5.sp +val TERMINAL_META_GAP = 6.sp +const val TERMINAL_HEADING_RATIO = 1.3125f +private const val TERMINAL_LINE_HEIGHT_RATIO = 22f / 16f +private const val TERMINAL_LINE_HEIGHT_CONTRAST_SLOPE = 0.1515f +const val INTER_CAP_HEIGHT = 2048f / 2816f +private val TERMINAL_LINE_HEIGHT_STYLE = LineHeightStyle(LineHeightStyle.Alignment.Proportional, LineHeightStyle.Trim.None) + +@Composable +fun terminalTextStyle(fontSize: TextUnit, color: Color = messageTextColor()): TextStyle = + if (LocalTerminalLayout.current) { + TextStyle(lineHeight = fontSize * (TERMINAL_LINE_HEIGHT_RATIO * contrastLineHeightFactor(color)), lineHeightStyle = TERMINAL_LINE_HEIGHT_STYLE) + } else { + TextStyle.Default + } + +@Composable +private fun contrastLineHeightFactor(color: Color): Float { + val background = MaterialTheme.colors.background + val textContrast = lightnessContrast(messageTextColor(), background) + val weight = if (textContrast > 0f) (lightnessContrast(color, background) / textContrast).coerceIn(0f, 1f) else 1f + return 1 - TERMINAL_LINE_HEIGHT_CONTRAST_SLOPE * (1 - weight) +} + +private fun lightnessContrast(color: Color, background: Color): Float = + abs(lightness(color.compositeOver(background)) - lightness(background)) + +private fun lightness(color: Color): Float { + val y = color.luminance() + return if (y > 216f / 24389f) 116f * y.pow(1f / 3) - 16f else y * 24389f / 27f +} + +fun terminalItemShowsSender(item: ChatItem, older: ChatItem?): Boolean = + item.content.hasTerminalSender && (older == null || !older.content.hasTerminalSender || !sameSender(item, older)) + +private fun sameSender(item: ChatItem, older: ChatItem): Boolean { + val d = item.chatDir + val o = older.chatDir + return when { + item.channelIsSender || older.channelIsSender -> item.channelIsSender && older.channelIsSender + d is CIDirection.GroupRcv -> o is CIDirection.GroupRcv && o.groupMember.memberId == d.groupMember.memberId + else -> d::class == o::class + } +} + +private val ChatItem.channelIsSender: Boolean get() = + chatDir is CIDirection.ChannelRcv || (chatDir is CIDirection.GroupSnd && meta.showGroupAsSender) + +fun terminalRowGap(item: ChatItem, older: ChatItem?, separated: Boolean, largeGap: Boolean, hasSenderRow: Boolean): TextUnit = + if (older != null && !separated && (largeGap || hasSenderRow || !item.content.hasTerminalSender || !older.content.hasTerminalSender || item.textAtItemTop && item.startsWithHeading)) TERMINAL_LEVEL_STEP else 0.sp + +fun terminalHeadingGap(item: ChatItem): TextUnit = + if (!item.textAtItemTop && item.startsWithHeading) TERMINAL_LEVEL_STEP else 0.sp + +private val ChatItem.textAtItemTop: Boolean get() = + (content.msgContent is MsgContent.MCText || content.msgContent is MsgContent.MCUnknown && file == null) && !hasQuoteOrHeader + +private val ChatItem.startsWithHeading: Boolean get() = + markdownBlocks?.firstOrNull() is MarkdownBlock.Heading + +private val CIContent.hasTerminalSender: Boolean get() = when (this) { + is CIContent.SndMsgContent, + is CIContent.SndDeleted, + is CIContent.SndCall, + is CIContent.SndGroupInvitation, + is CIContent.SndModerated -> true + else -> showMemberName +} + +class TerminalRowInteractionSource : MutableInteractionSource { + private val source = MutableInteractionSource() + private var press: PressInteraction.Press? = null + var downPosition = Offset.Zero + override val interactions: Flow = source.interactions + override suspend fun emit(interaction: Interaction) = source.emit(rowInteraction(interaction)) + override fun tryEmit(interaction: Interaction): Boolean = source.tryEmit(rowInteraction(interaction)) + + private fun rowInteraction(interaction: Interaction): Interaction = when (interaction) { + is PressInteraction.Press -> PressInteraction.Press(downPosition).also { press = it } + is PressInteraction.Release -> PressInteraction.Release(press ?: interaction.press) + is PressInteraction.Cancel -> PressInteraction.Cancel(press ?: interaction.press) + else -> interaction + } +} + +@Composable +fun Modifier.terminalRowIndication(interactionSource: TerminalRowInteractionSource): Modifier = + this + .pointerInput(interactionSource) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Initial).changes.firstOrNull { it.changedToDownIgnoreConsumed() }?.let { interactionSource.downPosition = it.position } + } + } + } + .hoverable(interactionSource) + .indication(interactionSource, LocalIndication.current) + +fun Modifier.terminalStartOffset(offset: () -> Dp): Modifier = + layout { measurable, constraints -> + val start = offset().roundToPx() + val placeable = measurable.measure(constraints.offset(horizontal = -start)) + layout(placeable.width + start, placeable.height) { placeable.placeRelative(start, 0) } + } + +private val LastLineLeft = VerticalAlignmentLine(::min) +private val LastLineRight = VerticalAlignmentLine(::max) +private val LastBlockBottom = HorizontalAlignmentLine(::max) + +fun Modifier.messageLastLine(): Modifier = lastLine { emptyMap() } + +fun Modifier.messageLastBlock(): Modifier = lastLine { mapOf(LastBlockBottom to it.height) } + +private fun Modifier.lastLine(extraAlignmentLines: (Placeable) -> Map): Modifier = + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, placeable.height, mapOf(LastLineLeft to 0, LastLineRight to placeable.width) + extraAlignmentLines(placeable)) { placeable.placeRelative(0, 0) } + } + +@Composable +fun Modifier.inlineIcon(): Modifier = + if (LocalTerminalLayout.current) { + layout { measurable, constraints -> + val placeable = measurable.measure(constraints) + layout(placeable.width, 0) { placeable.placeRelative(0, -placeable.height / 2) } + } + } else { + this + } + +@Composable +fun ItemWithMeta(meta: @Composable () -> Unit, overlayMeta: (@Composable () -> Unit)? = null, metaReserved: Boolean = false, endButton: (@Composable () -> Unit)? = null, content: @Composable (inlineMeta: (@Composable () -> Unit)?) -> Unit) { + if (LocalTerminalLayout.current) { + TerminalMetaLayout(metaReserved, hasOverlay = overlayMeta != null) { + content(null) + listOfNotNull(meta, overlayMeta, endButton).forEach { m -> + Box(Modifier.padding(end = 12.dp)) { + m() + } + } + } + } else { + content(meta) + } +} + +@Composable +private fun TerminalMetaLayout(metaReserved: Boolean, hasOverlay: Boolean, contentWithMeta: @Composable () -> Unit) { + Layout(contentWithMeta) { measurables, constraints -> + val metaConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val meta = measurables[1].measure(metaConstraints) + val overlay = if (hasOverlay) measurables[2].measure(metaConstraints) else null + val endButton = measurables.getOrNull(if (hasOverlay) 3 else 2)?.measure(metaConstraints) + val besideMeta = !metaReserved && overlay == null + val reservedWidth = max(if (besideMeta) meta.width else 0, endButton?.width ?: 0) + val contentMaxWidth = if (constraints.hasBoundedWidth) (constraints.maxWidth - reservedWidth).coerceAtLeast(0) else constraints.maxWidth + val content = measurables[0].measure(constraints.copy(minWidth = 0, maxWidth = contentMaxWidth)) + val width = if (constraints.hasBoundedWidth) constraints.maxWidth else max(content.width + reservedWidth, meta.width) + val gap = TERMINAL_META_GAP.toPx().roundToInt() + val lineEnd = if (layoutDirection == LayoutDirection.Ltr) { + content[LastLineRight].takeIf { it != AlignmentLine.Unspecified } + } else { + content[LastLineLeft].takeIf { it != AlignmentLine.Unspecified }?.let { content.width - it } + } + val metaBelow = lineEnd != null && lineEnd + meta.width > width + val textBaseline = content[LastBaseline] + val metaBaseline = meta[FirstBaseline].takeIf { it != AlignmentLine.Unspecified } ?: ((meta.height + 12.sp.toPx() * INTER_CAP_HEIGHT) / 2).roundToInt() + val metaTop = when { + metaBelow -> content.height + gap + overlay == null && textBaseline != AlignmentLine.Unspecified -> textBaseline - metaBaseline + else -> content.height - gap - meta.height + }.coerceAtLeast(0) + val blockGap = if (content[LastBlockBottom] == content.height) TERMINAL_BLOCK_BOTTOM_GAP.toPx().roundToInt() else 0 + val placedOverlay = overlay?.takeIf { content.width + meta.width > width } + val endButtonHeight = endButton?.height ?: 0 + val endButtonTop = (((if (placedOverlay != null) content.height else metaTop) - endButtonHeight) / 2).coerceAtLeast(0) + layout(width, maxOf(content.height + blockGap, metaTop + meta.height, placedOverlay?.height ?: 0, endButtonTop + endButtonHeight)) { + content.placeRelative(0, 0) + if (placedOverlay != null) { + placedOverlay.placeRelative(content.width - placedOverlay.width, (content.height - gap - placedOverlay.height).coerceAtLeast(0)) + } else { + meta.placeRelative(width - meta.width, metaTop) + } + endButton?.placeRelative(width - endButton.width, endButtonTop) + } + } +} + +class ChatItemSender(val name: String, val badge: LocalBadge?, val role: String?, val own: Boolean, val image: (@Composable () -> Unit)?) + +@Composable +fun chatItemSender( + chatInfo: ChatInfo, + cItem: ChatItem, + range: State, + reversedChatItems: State>, + showMemberInfo: (GroupInfo, GroupMember) -> Unit, + showChatInfo: () -> Unit +): ChatItemSender { + val dir = cItem.chatDir + return when { + cItem.channelIsSender && chatInfo is ChatInfo.Group -> + ChatItemSender(chatInfo.chatViewName, null, stringResource(MR.strings.channel_role_label), dir.sent) { + Box(Modifier.clickable { showChatInfo() }) { + ProfileImage(MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier, chatInfo.groupInfo.image, chatInfo.groupInfo.chatIconName, backgroundColor = MaterialTheme.colors.background) + } + } + dir is CIDirection.DirectRcv -> + ChatItemSender(chatInfo.displayName, chatInfo.nameBadge, null, false, null) + dir is CIDirection.GroupRcv && chatInfo is ChatInfo.Group -> { + val member = dir.groupMember + val rangeValue = range.value + val (prevMember, memCount) = if (rangeValue != null) chatModel.getPrevHiddenMember(member, rangeValue, reversedChatItems.value) else null to 1 + ChatItemSender( + memberNames(member, prevMember, memCount), + if (prevMember == null && memCount == 1) member.nameBadge else null, + if (memCount == 1) memberRole(member, chatInfo.isChannel) else null, + false + ) { + Box(Modifier.clickable { showMemberInfo(chatInfo.groupInfo, member) }) { + MemberImage(member) + } + } + } + dir is CIDirection.GroupSnd && chatInfo is ChatInfo.Group -> { + val membership = chatInfo.groupInfo.membership + ChatItemSender(membership.displayName, membership.nameBadge, memberRole(membership, chatInfo.isChannel), true) { + MemberImage(membership) + } + } + chatInfo.incognito -> + ChatItemSender(stringResource(MR.strings.incognito), null, null, true, null) + else -> { + val user = chatModel.currentUser.value + ChatItemSender(user?.displayName ?: "", user?.profile?.localBadge, null, true, null) + } + } +} + +private fun memberRole(member: GroupMember, isChannel: Boolean): String? = + if (member.memberRole > GroupMemberRole.Member) member.memberRole.text(isChannel) else null + +@Composable +private fun SenderNameAndRole(sender: ChatItemSender, modifier: Modifier) { + Row(modifier) { + NameWithBadge( + sender.name, + sender.badge, + Modifier.weight(1f, false), + fontSize = 13.5.sp, + color = if (sender.own) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, + overflow = TextOverflow.Ellipsis, + maxLines = 1 + ) + if (sender.role != null) { + Text( + sender.role, + Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f), + fontSize = 13.5.sp, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colors.secondary, + maxLines = 1 + ) + } + } +} + +@Composable +fun terminalGutter(chatInfo: ChatInfo): Dp = + if (chatInfo is ChatInfo.Group) MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier + 4.dp else 0.dp + +@Composable +fun TerminalChatItemRow( + sender: ChatItemSender?, + gutter: Dp, + topGap: Dp, + modifier: Modifier = Modifier, + senderTopGap: Dp = TERMINAL_SENDER_TOP_GAP.toDp(), + content: @Composable () -> Unit +) { + Layout( + content = { + Box { + sender?.image?.invoke() + } + Box { + if (sender != null) { + ProvideTextStyle(LocalTextStyle.current.merge(terminalTextStyle(13.5.sp))) { + SenderNameAndRole(sender, Modifier.padding(start = CHAT_ITEM_TEXT_PADDING, bottom = TERMINAL_SENDER_BOTTOM_GAP.toDp())) + } + } + } + Box { + content() + } + }, + modifier + .padding(top = topGap + if (sender != null) senderTopGap else 0.dp, start = 8.dp, end = 12.dp) + .fillMaxWidth() + ) { measurables, constraints -> + val messageStart = gutter.roundToPx() + val looseConstraints = constraints.copy(minWidth = 0, minHeight = 0) + val messageConstraints = looseConstraints.copy(maxWidth = (constraints.maxWidth - messageStart).coerceAtLeast(0)) + val avatar = measurables[0].measure(looseConstraints) + val name = measurables[1].measure(messageConstraints) + val message = measurables[2].measure(messageConstraints) + val messageTop = name.height + layout(constraints.maxWidth, max(messageTop + message.height, avatar.height)) { + avatar.placeRelative(0, 0) + name.placeRelative(messageStart, 0) + message.placeRelative(messageStart, messageTop) + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt index f28717e115..ec79fd73c4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt @@ -50,6 +50,9 @@ import kotlinx.coroutines.* val reserveTimestampStyle = SpanStyle(color = Color.Transparent) val boldFont = SpanStyle(fontWeight = FontWeight.Medium) +@Composable +fun messageTextColor(): Color = MaterialTheme.colors.onSurface.copy(alpha = if (MaterialTheme.colors.isLight) 1f else 0.87f) + fun appendSender(b: AnnotatedString.Builder, sender: String?, senderBold: Boolean) { if (sender != null) { if (senderBold) b.withStyle(boldFont) { append(sender) } @@ -174,11 +177,13 @@ fun MarkdownText ( sender: String? = null, meta: CIMeta? = null, chatTTL: Int? = null, + encrypted: Boolean? = null, + signedFileVerified: Boolean? = null, mentions: Map? = null, userMemberId: String? = null, toggleSecrets: Boolean, sendCommandMsg: ((String) -> Unit)? = null, - style: TextStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onSurface, lineHeight = 22.sp), + style: TextStyle = MaterialTheme.typography.body1.copy(color = messageTextColor(), lineHeight = 22.sp), maxLines: Int = Int.MAX_VALUE, overflow: TextOverflow = TextOverflow.Clip, uriHandler: UriHandler? = null, @@ -204,36 +209,49 @@ fun MarkdownText ( val textLayoutDirection = remember (text) { if (isRtl(text.subSequence(0, kotlin.math.min(50, text.length)))) LayoutDirection.Rtl else LayoutDirection.Ltr } + val metaCopy = if (meta != null && LocalTerminalLayout.current) { + terminalReserveSpaceForMeta(meta, chatTTL, encrypted, signedFileVerified, MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp) + } else null if (blockLayout && formattedText != null && markdownBlocks != null && range == null) { val direction = LocalLayoutDirection.current + val terminal = LocalTerminalLayout.current + val ruleMinWidth = if (metaCopy != null && markdownBlocks.isNotEmpty() && markdownBlocks.all { it is MarkdownBlock.Rule }) { + with(LocalDensity.current) { rememberTextMeasurer().measure(metaCopy, style).size.width.toDp() } + } else 0.dp CompositionLocalProvider(LocalLayoutDirection provides textLayoutDirection) { MarkdownBlocks( formattedText, markdownBlocks, style, modifier, cardColor ?: MaterialTheme.appColors.receivedQuote, blockText = { index, r, blockStyle, blockModifier, blockSoftWrap, last -> MarkdownText( text, formattedText, markdownBlocks, sender = if (index == 0) sender else null, meta = if (last) meta else null, chatTTL = chatTTL, + encrypted = encrypted, signedFileVerified = signedFileVerified, mentions = mentions, userMemberId = userMemberId, toggleSecrets = toggleSecrets, sendCommandMsg = sendCommandMsg, style = blockStyle, uriHandler = uriHandler, senderBold = senderBold, modifier = blockModifier, linkMode = linkMode, onLinkLongClick = onLinkLongClick, showViaProxy = showViaProxy, showTimestamp = showTimestamp, prefix = if (index == 0) prefix else null, selection = selection, textIndex = index, range = r.from until r.to, softWrap = blockSoftWrap, parentDirection = direction ) }, - metaLine = { - MarkdownText( - text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, toggleSecrets = toggleSecrets, style = style, - linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY - ) - } + metaLine = if (terminal && meta?.isLive != true) null else { + { + MarkdownText( + text, formattedText, markdownBlocks, meta = meta, chatTTL = chatTTL, encrypted = encrypted, signedFileVerified = signedFileVerified, toggleSecrets = toggleSecrets, style = style, + linkMode = linkMode, showViaProxy = showViaProxy, showTimestamp = showTimestamp, range = IntRange.EMPTY, parentDirection = if (terminal) direction else null + ) + } + }, + ruleMinWidth = ruleMinWidth ) } return } - val reserve = if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current) && meta != null) { - "\n" - } else if (meta != null) { - reserveSpaceForMeta(meta, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp) - } else { - " " + val reserve = meta?.let { + if (textLayoutDirection != (parentDirection ?: LocalLayoutDirection.current)) { + AnnotatedString("\n") + } else if (metaCopy != null) { + metaCopy + } else { + AnnotatedString(reserveSpaceForMeta(it, chatTTL, null, secondaryColor = MaterialTheme.colors.secondary, showViaProxy = showViaProxy, showTimestamp = showTimestamp)) + } } val scope = rememberCoroutineScope() CompositionLocalProvider( @@ -284,7 +302,7 @@ fun MarkdownText ( if (meta?.isLive == true) { append(typingIndicator(meta.recent, typingIdx)) } - if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } + if (reserve != null) withStyle(reserveTimestampStyle) { append(reserve) } } val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } if (textSelection != null) { @@ -440,7 +458,7 @@ fun MarkdownText ( // With RTL language set globally links looks bad sometimes, better to add a new line to bo sure everything looks good /*if (metaText != null && hasLinks && LocalLayoutDirection.current == LayoutDirection.Rtl) withStyle(reserveTimestampStyle) { append("\n" + metaText) } - else */if (meta != null) withStyle(reserveTimestampStyle) { append(reserve) } + else */if (reserve != null) withStyle(reserveTimestampStyle) { append(reserve) } } val clampedRange = textSelection?.highlightRange?.let { it.first .. minOf(it.last, selectableEnd - 1) }?.takeUnless { it.isEmpty() } val layoutResult = remember { mutableStateOf(null) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt index 98ea245886..807d1b2f54 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/helpers/ThemeModeEditor.kt @@ -13,9 +13,11 @@ import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import chat.simplex.common.model.ChatController.appPrefs +import chat.simplex.common.model.ChatLayoutMode import chat.simplex.common.model.yaml import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* +import chat.simplex.common.views.chat.item.LocalTerminalLayout import chat.simplex.common.views.usersettings.* import chat.simplex.common.views.usersettings.AppearanceScope.WallpaperPresetSelector import chat.simplex.common.views.usersettings.AppearanceScope.editColor @@ -95,11 +97,13 @@ fun ModalData.UserWallpaperEditor( } } + val terminal = LocalTerminalLayout.current val editColor = { name: ThemeColor -> editColor( name, wallpaperType, wallpaperImage, + terminal, onColorChange = { color -> preApplyGlobalIfNeeded(themeModeOverride.value.type) ThemeManager.applyThemeColor(name, color, themeModeOverride) @@ -235,6 +239,7 @@ fun ModalData.ChatWallpaperEditor( theme: ThemeModeOverride, applyToMode: DefaultThemeMode?, globalThemeUsed: MutableState, + layoutSection: @Composable () -> Unit, save: suspend (applyToMode: DefaultThemeMode?, ThemeModeOverride?) -> Unit ) { ColumnWithScrollBar { @@ -249,6 +254,10 @@ fun ModalData.ChatWallpaperEditor( AppBarTitle(stringResource(MR.strings.settings_section_title_chat_theme)) + layoutSection() + + SectionDividerSpaced() + val onTypeCopyFromSameTheme: (WallpaperType?) -> Boolean = { type -> if (type is WallpaperType.Image && chatModel.remoteHostId() != null) { false @@ -277,6 +286,7 @@ fun ModalData.ChatWallpaperEditor( withBGApi { save(applyToMode.value, themeModeOverride.value) } } + val terminal = LocalTerminalLayout.current val editColor: (ThemeColor) -> Unit = { name: ThemeColor -> ModalManager.end.showModal { val currentTheme by remember(themeModeOverride.value, CurrentColors.collectAsState().value) { @@ -300,23 +310,25 @@ fun ModalData.ChatWallpaperEditor( ThemeColor.RECEIVED_MESSAGE -> currentTheme.appColors.receivedMessage ThemeColor.RECEIVED_QUOTE -> currentTheme.appColors.receivedQuote } - AppearanceScope.ColorEditor( - name, - initialColor, - CurrentColors.collectAsState().value.base, - themeModeOverride.value.type, - themeModeOverride.value.type?.image, - currentTheme.wallpaper.background, - currentTheme.wallpaper.tint, - currentColors = { - ThemeManager.currentColors(null, themeModeOverride.value, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get()) - }, - onColorChange = { color -> - preApplyGlobalIfNeeded(themeModeOverride.value.type) - ThemeManager.applyThemeColor(name, color, themeModeOverride) - withBGApi { save(applyToMode.value, themeModeOverride.value) } - } - ) + CompositionLocalProvider(LocalTerminalLayout provides terminal) { + AppearanceScope.ColorEditor( + name, + initialColor, + CurrentColors.collectAsState().value.base, + themeModeOverride.value.type, + themeModeOverride.value.type?.image, + currentTheme.wallpaper.background, + currentTheme.wallpaper.tint, + currentColors = { + ThemeManager.currentColors(null, themeModeOverride.value, chatModel.currentUser.value?.uiThemes, appPreferences.themeOverrides.get()) + }, + onColorChange = { color -> + preApplyGlobalIfNeeded(themeModeOverride.value.type) + ThemeManager.applyThemeColor(name, color, themeModeOverride) + withBGApi { save(applyToMode.value, themeModeOverride.value) } + } + ) + } } } @@ -524,6 +536,14 @@ private fun SetDefaultThemeButton(onClick: () -> Unit) { } } +fun chatLayoutName(layout: ChatLayoutMode): String = when (layout) { + is ChatLayoutMode.Classic -> generalGetString(MR.strings.chat_layout_classic) + is ChatLayoutMode.Terminal -> generalGetString(MR.strings.chat_layout_terminal) + is ChatLayoutMode.Unknown -> layout.type +} + +fun otherChatLayout(layout: ChatLayoutMode): ChatLayoutMode = if (layout == ChatLayoutMode.Terminal) ChatLayoutMode.Classic else ChatLayoutMode.Terminal + @Composable private fun AdvancedSettingsButton(onClick: () -> Unit) { SettingsActionItem( diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt index a661568a0d..179ec48175 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.* import androidx.compose.ui.graphics.* +import androidx.compose.ui.layout.Layout import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.input.TextFieldValue @@ -38,7 +39,12 @@ import chat.simplex.common.model.ChatModel.controller import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.ThemeManager.colorFromReadableHex import chat.simplex.common.ui.theme.ThemeManager.toReadableHex +import chat.simplex.common.views.chat.item.ChatItemSender +import chat.simplex.common.views.chat.item.LocalTerminalLayout import chat.simplex.common.views.chat.item.PreviewChatItemView +import chat.simplex.common.views.chat.item.TERMINAL_SENDER_TOP_GAP +import chat.simplex.common.views.chat.item.TerminalChatItemRow +import chat.simplex.common.views.chat.item.terminalRowGap import chat.simplex.common.views.chat.item.msgTailWidthDp import chat.simplex.res.* import com.godaddy.android.colorpicker.ClassicColorPicker @@ -279,13 +285,14 @@ object AppearanceScope { val themeBackgroundColor = MaterialTheme.colors.background val backgroundColor = backgroundColor ?: wallpaperType?.defaultBackgroundColor(theme, MaterialTheme.colors.background) val tintColor = tintColor ?: wallpaperType?.defaultTintColor(theme) + val terminal = LocalTerminalLayout.current Column(Modifier .drawWithCache { - if (wallpaperImage != null && wallpaperType != null && backgroundColor != null && tintColor != null) { + if (wallpaperImage != null && wallpaperType != null && backgroundColor != null && tintColor != null && !terminal) { chatViewBackground(wallpaperImage, wallpaperType, backgroundColor, tintColor, null, null) } else { onDrawBehind { - drawRect(themeBackgroundColor) + drawRect(backgroundColor ?: themeBackgroundColor) } } } @@ -293,22 +300,54 @@ object AppearanceScope { ) { if (withMessages) { val chatItemTail = remember { appPreferences.chatItemTail.state } + val alice = remember { ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), generalGetString(MR.strings.wallpaper_preview_hello_bob)) } + val reply = ChatItem.getSampleData(2, CIDirection.DirectSnd(), Clock.System.now(), stringResource(MR.strings.wallpaper_preview_hello_alice), + quotedItem = CIQuote(alice.chatDir, alice.id, sentAt = alice.meta.itemTs, formattedText = alice.formattedText, content = MsgContent.MCText(alice.content.text)) + ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = if (chatItemTail.value) Modifier else Modifier.padding(horizontal = msgTailWidthDp)) { - val alice = remember { ChatItem.getSampleData(1, CIDirection.DirectRcv(), Clock.System.now(), generalGetString(MR.strings.wallpaper_preview_hello_bob)) } - PreviewChatItemView(alice) - PreviewChatItemView( - ChatItem.getSampleData(2, CIDirection.DirectSnd(), Clock.System.now(), stringResource(MR.strings.wallpaper_preview_hello_alice), - quotedItem = CIQuote(alice.chatDir, alice.id, sentAt = alice.meta.itemTs, formattedText = alice.formattedText, content = MsgContent.MCText(alice.content.text)) - ) - ) + @Composable + fun TerminalPreviewRow(item: ChatItem, older: ChatItem?, sender: ChatItemSender) { + TerminalChatItemRow(sender, gutter = 0.dp, topGap = terminalRowGap(item, older, separated = false, largeGap = true, hasSenderRow = true).toDp(), senderTopGap = if (older == null) 0.dp else TERMINAL_SENDER_TOP_GAP.toDp()) { + PreviewChatItemView(item) + } } + + PreviewInEitherLayout( + terminal, + classic = { + Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = if (chatItemTail.value) Modifier else Modifier.padding(horizontal = msgTailWidthDp)) { + PreviewChatItemView(alice) + PreviewChatItemView(reply) + } + }, + terminalRows = { + Column { + TerminalPreviewRow(alice, null, ChatItemSender("alice", null, null, own = false, image = null)) + TerminalPreviewRow(reply, alice, ChatItemSender("bob", null, null, own = true, image = null)) + } + } + ) } else { Box(Modifier.fillMaxSize()) } } } + @Composable + private fun PreviewInEitherLayout(terminal: Boolean, classic: @Composable () -> Unit, terminalRows: @Composable () -> Unit) { + Layout({ + CompositionLocalProvider(LocalTerminalLayout provides false, content = classic) + CompositionLocalProvider(LocalTerminalLayout provides true, content = terminalRows) + }) { measurables, constraints -> + val placeables = measurables.map { it.measure(constraints) } + val shown = placeables[if (terminal) 1 else 0] + val height = placeables.maxOf { it.height } + layout(placeables.maxOf { it.width }, height) { + shown.placeRelative(0, (height - shown.height) / 2) + } + } + } + @Composable fun WallpaperPresetSelector( selectedWallpaper: WallpaperType?, @@ -326,6 +365,7 @@ object AppearanceScope { } val backgrounds = PresetWallpaper.entries.toList() + val terminal = LocalTerminalLayout.current fun LazyGridScope.gridContent(width: Dp, height: Dp) { @Composable @@ -398,8 +438,10 @@ object AppearanceScope { items(items = backgrounds) { background -> BackgroundItem(background) } - item { - OwnBackgroundItem(selectedWallpaper) + if (!terminal) { + item { + OwnBackgroundItem(selectedWallpaper) + } } } @@ -417,7 +459,7 @@ object AppearanceScope { val gridPadding = 12.dp val cardPadding = if (LocalCardScreen.current) CARD_PADDING * 2 else 0.dp val itemSize = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - cardPadding - gridPadding * 5) / 4 - val rows = ceil((PresetWallpaper.entries.size + 2) / 4f).roundToInt() + val rows = ceil((PresetWallpaper.entries.size + if (terminal) 1 else 2) / 4f).roundToInt() LazyVerticalGrid( columns = GridCells.Fixed(4), Modifier.height(itemSize * rows + gridPadding * (rows + 1)), @@ -530,19 +572,22 @@ object AppearanceScope { SectionView(stringResource(MR.strings.settings_section_title_themes)) { ThemeDestinationPicker(themeUserDestination) + ChatLayoutRow(themeUserDestination) val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> if (to != null) onImport(to) } - WallpaperPresetSelector( - selectedWallpaper = wallpaperType, - baseTheme = currentTheme.base, - currentColors = { type -> - currentColors(type) - }, - onChooseType = { onChooseType(it, importWallpaperLauncher) }, - ) + CompositionLocalProvider(LocalTerminalLayout provides profileLayoutIsTerminal(if (themeUserDestination.value == null) null else chatModel.currentUser.value)) { + WallpaperPresetSelector( + selectedWallpaper = wallpaperType, + baseTheme = currentTheme.base, + currentColors = { type -> + currentColors(type) + }, + onChooseType = { onChooseType(it, importWallpaperLauncher) }, + ) + } val type = MaterialTheme.wallpaper.type if (type is WallpaperType.Image && (themeUserDestination.value == null || perUserTheme.value.wallpaper?.imageFile != null)) { SectionItemView(disabled = chatModel.remoteHostId != null && themeUserDestination.value != null, click = { @@ -596,7 +641,9 @@ object AppearanceScope { val importWallpaperLauncher = rememberFileChooserLauncher(true) { to: URI? -> if (to != null) onImport(to) } - CustomizeThemeView { onChooseType(it, importWallpaperLauncher) } + CompositionLocalProvider(LocalTerminalLayout provides profileLayoutIsTerminal(null)) { + CustomizeThemeView { onChooseType(it, importWallpaperLauncher) } + } } } else { ModalManager.start.showModalCloseable(cardScreen = true) { close -> @@ -619,11 +666,13 @@ object AppearanceScope { val wallpaperType = MaterialTheme.wallpaper.type val baseTheme = CurrentColors.collectAsState().value.base + val terminal = LocalTerminalLayout.current val editColor = { name: ThemeColor -> editColor( name, wallpaperType, wallpaperImage, + terminal, onColorChange = { color -> ThemeManager.saveAndApplyThemeColor(baseTheme, name, color) saveThemeToDatabase(null) @@ -787,7 +836,7 @@ object AppearanceScope { } } - fun editColor(name: ThemeColor, wallpaperType: WallpaperType, wallpaperImage: ImageBitmap?, onColorChange: (Color?) -> Unit) { + fun editColor(name: ThemeColor, wallpaperType: WallpaperType, wallpaperImage: ImageBitmap?, terminal: Boolean, onColorChange: (Color?) -> Unit) { ModalManager.start.showModal { val baseTheme = CurrentColors.collectAsState().value.base val wallpaperBackgroundColor = MaterialTheme.wallpaper.background ?: wallpaperType.defaultBackgroundColor(baseTheme, MaterialTheme.colors.background) @@ -808,9 +857,11 @@ object AppearanceScope { ThemeColor.RECEIVED_MESSAGE -> MaterialTheme.appColors.receivedMessage ThemeColor.RECEIVED_QUOTE -> MaterialTheme.appColors.receivedQuote } - ColorEditor(name, initialColor, baseTheme, MaterialTheme.wallpaper.type, wallpaperImage, currentColors = { CurrentColors.value }, - onColorChange = onColorChange - ) + CompositionLocalProvider(LocalTerminalLayout provides terminal) { + ColorEditor(name, initialColor, baseTheme, MaterialTheme.wallpaper.type, wallpaperImage, currentColors = { CurrentColors.value }, + onColorChange = onColorChange + ) + } } } @@ -823,13 +874,15 @@ object AppearanceScope { globalThemeUsed.value = preferred == null preferred ?: ThemeManager.defaultActiveTheme(chatModel.currentUser.value?.uiThemes, appPrefs.themeOverrides.get()) } - UserWallpaperEditor( - initialTheme, - applyToMode = if (themes.value.light == themes.value.dark) null else initialTheme.mode, - globalThemeUsed = globalThemeUsed, - save = { applyToMode, newTheme -> - save(applyToMode, newTheme, themes.value, userId, remoteHostId) - }) + CompositionLocalProvider(LocalTerminalLayout provides profileLayoutIsTerminal(chatModel.currentUser.value)) { + UserWallpaperEditor( + initialTheme, + applyToMode = if (themes.value.light == themes.value.dark) null else initialTheme.mode, + globalThemeUsed = globalThemeUsed, + save = { applyToMode, newTheme -> + save(applyToMode, newTheme, themes.value, userId, remoteHostId) + }) + } KeyChangeEffect(chatModel.currentUser.value?.userId, chatModel.remoteHostId) { close() } @@ -871,7 +924,7 @@ object AppearanceScope { } changedThemes } else { - null + changedThemes?.layoutOnly() } val wallpaperFilesToDelete = wallpaperFiles - changedThemes?.light?.wallpaper?.imageFile - changedThemes?.dark?.wallpaper?.imageFile @@ -890,6 +943,40 @@ object AppearanceScope { } } + private fun profileLayoutIsTerminal(user: User?): Boolean = + profileChatLayout(user) == ChatLayoutMode.Terminal + + @Composable + private fun ChatLayoutRow(themeUserDestination: MutableState?>) { + val globalLayout = remember { derivedStateOf { profileChatLayout(null) } } + val dest = themeUserDestination.value + if (dest == null) { + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_layout), + listOf(ChatLayoutMode.Classic, ChatLayoutMode.Terminal).map { it to chatLayoutName(it) }, + globalLayout, + onSelected = { layout -> appPrefs.chatLayout.set(layout) } + ) + } else { + val (userId, themes) = dest + val userLayout = themes?.chatLayout + val otherLayout = otherChatLayout(globalLayout.value) + val selection = remember(userLayout, otherLayout) { mutableStateOf(if (userLayout == otherLayout) otherLayout else null) } + ExposedDropDownSettingRow( + generalGetString(MR.strings.chat_layout), + listOf( + null to "${chatLayoutName(globalLayout.value)} ${generalGetString(MR.strings.chat_layout_default_suffix)}", + otherLayout to chatLayoutName(otherLayout) + ), + selection, + onSelected = { layout -> + themeUserDestination.value = userId to themes.withChatLayout(layout) + saveThemeToDatabase(themeUserDestination.value) + } + ) + } + } + @Composable fun ThemeDestinationPicker(themeUserDestination: MutableState?>) { val themeUserDest = remember(themeUserDestination.value?.first) { mutableStateOf(themeUserDestination.value?.first) } @@ -897,7 +984,7 @@ object AppearanceScope { if (themeUserDestination.value == null) { // Easiest way to hide per-user customization. // Otherwise, it would be needed to make global variable and to use it everywhere for making a decision to include these overrides into active theme constructing or not - chatModel.currentUser.value = chatModel.currentUser.value?.copy(uiThemes = null) + chatModel.currentUser.value = chatModel.currentUser.value?.let { it.copy(uiThemes = it.uiThemes?.layoutOnly()) } } else { chatModel.updateCurrentUserUiThemes(chatModel.remoteHostId(), chatModel.users.firstOrNull { it.user.userId == chatModel.currentUser.value?.userId }?.user?.uiThemes) } @@ -1198,10 +1285,11 @@ fun WallpaperSetupView( firstSectionTitle: String? = null, ) { val hasWallpaperSettings = wallpaperType is WallpaperType.Preset || wallpaperType is WallpaperType.Image + val terminal = LocalTerminalLayout.current if (hasWallpaperSettings) { SectionView(firstSectionTitle) { - if (wallpaperType is WallpaperType.Image) { + if (wallpaperType is WallpaperType.Image && !terminal) { val state = remember(wallpaperType.scaleType, initialWallpaper?.type) { mutableStateOf(wallpaperType.scaleType ?: (initialWallpaper?.type as? WallpaperType.Image)?.scaleType ?: WallpaperScaleType.FILL) } val values = remember { WallpaperScaleType.entries.map { it to generalGetString(it.text) } @@ -1216,7 +1304,7 @@ fun WallpaperSetupView( ) } - if (wallpaperType is WallpaperType.Preset || (wallpaperType is WallpaperType.Image && wallpaperType.scaleType == WallpaperScaleType.REPEAT)) { + if ((wallpaperType is WallpaperType.Preset || (wallpaperType is WallpaperType.Image && wallpaperType.scaleType == WallpaperScaleType.REPEAT)) && !terminal) { val state = remember(wallpaperType, initialWallpaper?.type?.scale) { mutableStateOf(wallpaperType.scale ?: initialWallpaper?.type?.scale ?: 1f) } Row(Modifier.padding(horizontal = DEFAULT_PADDING), verticalAlignment = Alignment.CenterVertically) { Text("${state.value}".substring(0, min("${state.value}".length, 4)), Modifier.width(50.dp)) @@ -1240,11 +1328,13 @@ fun WallpaperSetupView( Text(title) Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperBackgroundColor) } - val wallpaperTintColor = initialWallpaper?.tint ?: wallpaperType.defaultTintColor(theme) - SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_TINT) }) { - val title = generalGetString(MR.strings.color_wallpaper_tint) - Text(title) - Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperTintColor) + if (!terminal) { + val wallpaperTintColor = initialWallpaper?.tint ?: wallpaperType.defaultTintColor(theme) + SectionItemViewSpaceBetween({ editColor(ThemeColor.WALLPAPER_TINT) }) { + val title = generalGetString(MR.strings.color_wallpaper_tint) + Text(title) + Icon(painterResource(MR.images.ic_circle_filled), title, tint = wallpaperTintColor) + } } } SectionDividerSpaced() @@ -1290,7 +1380,7 @@ private fun ColorPicker(initialColor: Color, showAlphaBar: Boolean, onColorChang private fun removeUserThemeModeOverrides(themeUserDestination: MutableState?>, perUserTheme: MutableState) { val dest = themeUserDestination.value ?: return perUserTheme.value = ThemeModeOverride() - themeUserDestination.value = dest.first to null + themeUserDestination.value = dest.first to dest.second?.layoutOnly() val wallpaperFilesToDelete = listOf( (chatModel.currentUser.value?.uiThemes?.light?.type as? WallpaperType.Image)?.filename, (chatModel.currentUser.value?.uiThemes?.dark?.type as? WallpaperType.Image)?.filename diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt index 826648ddb8..674aa3831b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/UserAddressView.kt @@ -633,14 +633,16 @@ private fun BusinessAddressToggle(addressSettingsState: MutableStateAll color modes Light mode Dark mode + Chat layout + Classic + Terminal + (chat) + (default) diff --git a/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/TerminalItemViewTest.kt b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/TerminalItemViewTest.kt new file mode 100644 index 0000000000..91b7854dae --- /dev/null +++ b/apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/TerminalItemViewTest.kt @@ -0,0 +1,234 @@ +package chat.simplex.app + +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.views.chat.ListItem +import chat.simplex.common.views.chat.MergedItem +import chat.simplex.common.views.chat.terminalRowsSeparated +import chat.simplex.common.views.chat.item.TERMINAL_LEVEL_STEP +import chat.simplex.common.views.chat.item.terminalHeadingGap +import chat.simplex.common.views.chat.item.terminalItemShowsSender +import chat.simplex.common.views.chat.item.terminalRowGap +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.serialization.json.JsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TerminalItemViewTest { + private val noon = Instant.parse("2026-10-12T12:00:00Z") + private val alice = GroupMember.sampleData.copy(groupMemberId = 1, memberId = "alice") + private val bob = GroupMember.sampleData.copy(groupMemberId = 2, memberId = "bob") + + private fun msgAt(id: Long, dir: CIDirection, ts: Instant) = + ChatItem.getSampleData(id, dir, ts).let { + if (dir.sent) it else it.copy(content = CIContent.RcvMsgContent(MsgContent.MCText("hello"))) + } + + private fun msg(id: Long, dir: CIDirection, secondsAfterNoon: Long) = + msgAt(id, dir, Instant.fromEpochSeconds(noon.epochSeconds + secondsAfterNoon)) + + private fun integrityError(id: Long, secondsAfterNoon: Long) = + msg(id, CIDirection.DirectRcv(), secondsAfterNoon).copy(content = CIContent.RcvIntegrityError(MsgErrorType.MsgSkipped(1, 2))) + + private fun featureEvent(id: Long, secondsAfterNoon: Long) = + ChatItem( + CIDirection.DirectRcv(), + CIMeta.getSample(id, Instant.fromEpochSeconds(noon.epochSeconds + secondsAfterNoon), ""), + CIContent.RcvChatFeature(ChatFeature.Voice, FeatureEnabled(forUser = true, forContact = true)), + reactions = emptyList() + ) + + @Test + fun senderShownForOldestItem() { + assertTrue(terminalItemShowsSender(msg(1, CIDirection.DirectRcv(), 0), older = null)) + } + + @Test + fun senderHiddenForSameSender() { + assertFalse(terminalItemShowsSender(msg(2, CIDirection.DirectSnd(), 30), older = msg(1, CIDirection.DirectSnd(), 0))) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.GroupRcv(alice), 59), older = msg(1, CIDirection.GroupRcv(alice), 0))) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.DirectRcv(), 3600), older = msg(1, CIDirection.DirectRcv(), 0))) + } + + @Test + fun senderShownForDifferentGroupMember() { + assertTrue(terminalItemShowsSender(msg(2, CIDirection.GroupRcv(bob), 10), older = msg(1, CIDirection.GroupRcv(alice), 0))) + } + + @Test + fun senderShownWhenDirectionChanges() { + assertTrue(terminalItemShowsSender(msg(2, CIDirection.DirectSnd(), 10), older = msg(1, CIDirection.DirectRcv(), 0))) + assertTrue(terminalItemShowsSender(msg(2, CIDirection.GroupSnd(), 10), older = msg(1, CIDirection.GroupRcv(alice), 0))) + } + + @Test + fun senderShownAfterEventItem() { + assertTrue(terminalItemShowsSender(msg(2, CIDirection.DirectRcv(), 10), older = featureEvent(1, 0))) + } + + @Test + fun senderHiddenOnDateChange() { + val midnight = LocalDate(2026, 10, 13).atStartOfDayIn(TimeZone.currentSystemDefault()) + val older = msgAt(1, CIDirection.DirectRcv(), Instant.fromEpochSeconds(midnight.epochSeconds - 10)) + val item = msgAt(2, CIDirection.DirectRcv(), Instant.fromEpochSeconds(midnight.epochSeconds + 10)) + assertFalse(terminalItemShowsSender(item, older)) + } + + @Test + fun senderShownWhenChannelPostFollowsMemberMessage() { + assertTrue(terminalItemShowsSender(msg(2, CIDirection.GroupRcv(alice), 10), older = msg(1, CIDirection.ChannelRcv(), 0))) + assertTrue(terminalItemShowsSender(msg(2, CIDirection.ChannelRcv(), 10), older = msg(1, CIDirection.GroupRcv(alice), 0))) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.ChannelRcv(), 10), older = msg(1, CIDirection.ChannelRcv(), 0))) + } + + @Test + fun senderShownWhenOwnerSwitchesBetweenChannelAndOwnName() { + val asChannel = msg(1, CIDirection.GroupSnd(), 0).let { it.copy(meta = it.meta.copy(showGroupAsSender = true)) } + assertTrue(terminalItemShowsSender(msg(2, CIDirection.GroupSnd(), 10), older = asChannel)) + val asChannelToo = msg(2, CIDirection.GroupSnd(), 10).let { it.copy(meta = it.meta.copy(showGroupAsSender = true)) } + assertFalse(terminalItemShowsSender(asChannelToo, older = asChannel)) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.ChannelRcv(), 10), older = asChannel)) + } + + @Test + fun integrityErrorGroupedWithSender() { + assertFalse(terminalItemShowsSender(integrityError(2, 10), older = msg(1, CIDirection.DirectRcv(), 0))) + assertFalse(terminalItemShowsSender(msg(3, CIDirection.DirectRcv(), 20), older = integrityError(2, 10))) + } + + @Test + fun senderHiddenAfterDeletedMessageFromSameSender() { + val deleted = msg(1, CIDirection.DirectRcv(), 0).let { it.copy(meta = it.meta.copy(itemDeleted = CIDeleted.Deleted(null))) } + val legacyDeleted = msg(1, CIDirection.DirectRcv(), 0).copy(content = CIContent.RcvDeleted(CIDeleteMode.cidmBroadcast)) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.DirectRcv(), 10), older = deleted)) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.DirectRcv(), 10), older = legacyDeleted)) + } + + @Test + fun senderGroupsLocalNotes() { + assertTrue(terminalItemShowsSender(msg(1, CIDirection.LocalSnd(), 0), older = null)) + assertFalse(terminalItemShowsSender(msg(2, CIDirection.LocalSnd(), 10), older = msg(1, CIDirection.LocalSnd(), 0))) + } + + @Test + fun noSenderForEventItem() { + assertFalse(terminalItemShowsSender(featureEvent(2, 10), older = msg(1, CIDirection.DirectRcv(), 0))) + assertFalse(terminalItemShowsSender(featureEvent(1, 0), older = null)) + } + + private fun withBlocks(item: ChatItem, vararg blocks: MarkdownBlock) = item.copy(markdownBlocks = blocks.toList()) + + private fun heading() = MarkdownBlock.Heading(1, SegmentRange(0, 1)) + + private fun quoted(item: ChatItem) = item.copy(quotedItem = CIQuote(CIDirection.DirectRcv(), 1, sentAt = noon, content = MsgContent.MCText("question"))) + + private fun forwarded(item: ChatItem) = item.copy(meta = item.meta.copy(itemForwarded = CIForwardedFrom.Unknown)) + + private fun gap(item: ChatItem, older: ChatItem?, separated: Boolean = false, largeGap: Boolean = false, hasSenderRow: Boolean = false) = + terminalRowGap(item, older, separated, largeGap, hasSenderRow) + + private fun single(item: ChatItem, prev: ChatItem?) = MergedItem.Single(ListItem(item, prev, null, 0), 0) + + private fun grouped(item: ChatItem, prev: ChatItem?, revealed: Boolean) = + MergedItem.Grouped(arrayListOf(ListItem(item, prev, null, 0)), revealed, mutableListOf(), MutableStateFlow(0..0), item.mergeCategory, mutableSetOf(), 0) + + @Test + fun rowGapZeroForOldestItem() { + assertEquals(0.sp, gap(msg(1, CIDirection.DirectSnd(), 0), older = null, largeGap = true, hasSenderRow = true)) + } + + @Test + fun rowGapZeroBetweenCloseMessages() { + assertEquals(0.sp, gap(msg(2, CIDirection.DirectSnd(), 10), msg(1, CIDirection.DirectSnd(), 0))) + } + + @Test + fun rowGapAfterLargeGapOrAboveSenderRow() { + val older = msg(1, CIDirection.DirectSnd(), 0) + assertEquals(TERMINAL_LEVEL_STEP, gap(msg(2, CIDirection.DirectSnd(), 70), older, largeGap = true)) + assertEquals(TERMINAL_LEVEL_STEP, gap(msg(2, CIDirection.DirectSnd(), 10), older, hasSenderRow = true)) + } + + @Test + fun rowGapNextToEvent() { + assertEquals(TERMINAL_LEVEL_STEP, gap(featureEvent(2, 10), msg(1, CIDirection.DirectRcv(), 0))) + assertEquals(TERMINAL_LEVEL_STEP, gap(msg(2, CIDirection.DirectRcv(), 10), featureEvent(1, 0))) + } + + @Test + fun rowGapZeroWhenRowsAreSeparated() { + assertEquals(0.sp, gap(featureEvent(2, 10), msg(1, CIDirection.DirectRcv(), 0), separated = true, largeGap = true, hasSenderRow = true)) + } + + @Test + fun rowsSeparatedByDateBannerOrRevealedRow() { + val midnight = LocalDate(2026, 10, 13).atStartOfDayIn(TimeZone.currentSystemDefault()) + val older = msgAt(1, CIDirection.DirectRcv(), Instant.fromEpochSeconds(midnight.epochSeconds - 10)) + val nextDay = msgAt(2, CIDirection.DirectRcv(), Instant.fromEpochSeconds(midnight.epochSeconds + 10)) + assertFalse(terminalRowsSeparated(null)) + assertTrue(terminalRowsSeparated(single(older, nextDay))) + assertFalse(terminalRowsSeparated(single(msg(1, CIDirection.DirectRcv(), 0), msg(2, CIDirection.DirectRcv(), 10)))) + val banner = ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(1, Instant.fromEpochSeconds(midnight.epochSeconds + 5), ""), CIContent.ChatBanner, reactions = emptyList()) + assertTrue(terminalRowsSeparated(single(banner, nextDay))) + val event = featureEvent(1, 0).copy(meta = featureEvent(1, 0).meta.copy(itemTs = older.meta.itemTs)) + assertFalse(terminalRowsSeparated(grouped(event, nextDay, revealed = false))) + assertTrue(terminalRowsSeparated(grouped(event, nextDay, revealed = true))) + } + + @Test + fun headingAtItemTopTakesRowGap() { + val item = withBlocks(msg(2, CIDirection.DirectSnd(), 10), heading()) + assertEquals(TERMINAL_LEVEL_STEP, gap(item, msg(1, CIDirection.DirectSnd(), 0))) + assertEquals(0.sp, terminalHeadingGap(item)) + } + + @Test + fun headingBelowQuoteOrHeaderTakesHeadingGap() { + val older = msg(1, CIDirection.DirectSnd(), 0) + listOf(quoted(msg(2, CIDirection.DirectSnd(), 10)), forwarded(msg(2, CIDirection.DirectSnd(), 10))).forEach { + val item = withBlocks(it, heading()) + assertEquals(0.sp, gap(item, older)) + assertEquals(TERMINAL_LEVEL_STEP, terminalHeadingGap(item)) + } + } + + @Test + fun headingBelowDeletionLiveFileOrMediaTakesHeadingGap() { + val older = msg(1, CIDirection.DirectSnd(), 0) + val base = msg(2, CIDirection.DirectSnd(), 10) + val file = CIFile(fileId = 1, fileName = "a.txt", fileSize = 1, fileStatus = CIFileStatus.SndComplete, fileProtocol = FileProtocol.XFTP) + listOf( + base.copy(meta = base.meta.copy(itemDeleted = CIDeleted.Deleted(null))), + base.copy(meta = base.meta.copy(itemLive = true)), + base.copy(content = CIContent.SndMsgContent(MsgContent.MCUnknown(text = "# Notes", json = JsonPrimitive("unknown"))), file = file), + base.copy(content = CIContent.SndMsgContent(MsgContent.MCImage("# Caption", ""))) + ).forEach { + val item = withBlocks(it, heading()) + assertEquals(0.sp, gap(item, older)) + assertEquals(TERMINAL_LEVEL_STEP, terminalHeadingGap(item)) + } + } + + @Test + fun headingInUnknownContentWithoutFileTakesRowGap() { + val base = msg(2, CIDirection.DirectSnd(), 10) + val item = withBlocks(base.copy(content = CIContent.SndMsgContent(MsgContent.MCUnknown(text = "# Notes", json = JsonPrimitive("unknown")))), heading()) + assertEquals(TERMINAL_LEVEL_STEP, gap(item, msg(1, CIDirection.DirectSnd(), 0))) + assertEquals(0.sp, terminalHeadingGap(item)) + } + + @Test + fun noHeadingGapsWithoutLeadingHeading() { + val older = msg(1, CIDirection.DirectSnd(), 0) + val headingSecond = withBlocks(msg(2, CIDirection.DirectSnd(), 10), MarkdownBlock.Paragraph(SegmentRange(0, 1)), heading()) + assertEquals(0.sp, gap(headingSecond, older)) + assertEquals(0.sp, terminalHeadingGap(headingSecond)) + assertEquals(0.sp, terminalHeadingGap(quoted(msg(2, CIDirection.DirectSnd(), 10)))) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatEventTextTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatEventTextTest.kt new file mode 100644 index 0000000000..0c515695b1 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatEventTextTest.kt @@ -0,0 +1,53 @@ +package chat.simplex.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.chat.item.* +import kotlinx.datetime.Clock +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ChatEventTextTest { + private val event = ChatItem(CIDirection.DirectRcv(), CIMeta.getSample(1, Clock.System.now(), "", CIStatus.RcvRead()), CIContent.RcvDirectE2EEInfo(E2EEInfo(pqEnabled = false)), reactions = listOf()) + + private fun separatorAndTime(terminal: Boolean): Pair { + var result: Pair? = null + renderScene { + SimpleXTheme { + CompositionLocalProvider(LocalTerminalLayout provides terminal) { + ChatEventWithMeta(event) { separator, ts -> + result = separator to ts + Box(Modifier.size(1.dp)) + } + } + } + } + return result!! + } + + @Test + fun classicEventTextUnchanged() { + val (separator, ts) = separatorAndTime(terminal = false) + assertEquals(AnnotatedString(" "), separator) + assertEquals(AnnotatedString(event.timestampText), ts) + val base = buildAnnotatedString { withStyle(chatEventStyle) { append("alice joined the group ${event.timestampText}") } } + assertEquals(base, chatEventText("alice joined the group", separator + ts)) + } + + @Test + fun terminalSeparatorJoinsCopyByNoBreakSpaces() { + val (separator, ts) = separatorAndTime(terminal = true) + assertEquals("\u00A0\u00A0\u00A0", separator.text) + assertEquals(listOf(chatEventStyle, reserveTimestampStyle), separator.spanStyles.map { it.item }) + assertTrue(ts.text.startsWith("\u2060\u200E") && ts.text.endsWith(event.timestampText.replace(' ', '\u00A0')), ts.text) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt new file mode 100644 index 0000000000..392edf225c --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/LayoutRecorder.kt @@ -0,0 +1,34 @@ +package chat.simplex.app + +import androidx.compose.runtime.Composable +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.FirstBaseline +import androidx.compose.ui.layout.LastBaseline +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import kotlin.math.roundToInt + +data class Placed(val x: Int, val y: Int, val width: Int, val height: Int) + +fun Modifier.record(into: MutableMap, name: String): Modifier = onGloballyPositioned { + val p = it.positionInRoot() + into[name] = Placed(p.x.roundToInt(), p.y.roundToInt(), it.size.width, it.size.height) +} + +fun Modifier.fixed(width: Int, height: Int, baseline: Int?): Modifier = layout { measurable, _ -> + val placeable = measurable.measure(Constraints.fixed(width, height)) + layout(width, height, if (baseline == null) emptyMap() else mapOf(FirstBaseline to baseline, LastBaseline to baseline)) { placeable.place(0, 0) } +} + +fun renderScene(direction: LayoutDirection = LayoutDirection.Ltr, content: @Composable () -> Unit) { + val scene = ImageComposeScene(width = 400, height = 400, density = Density(1f), layoutDirection = direction, content = content) + scene.render() + scene.close() +} + +fun Placed.relativeTo(origin: Placed) = Placed(x - origin.x, y - origin.y, width, height) diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/PriorityLayoutTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/PriorityLayoutTest.kt new file mode 100644 index 0000000000..d0ce540765 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/PriorityLayoutTest.kt @@ -0,0 +1,66 @@ +package chat.simplex.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import chat.simplex.common.views.chat.item.CHAT_IMAGE_LAYOUT_ID +import chat.simplex.common.views.chat.item.PriorityLayout +import kotlin.test.Test +import kotlin.test.assertEquals + +class PriorityLayoutTest { + private val placed = mutableMapOf() + + private fun layOut(limitToPriorityWidth: Boolean, direction: LayoutDirection = LayoutDirection.Ltr): Map { + renderScene(direction) { + Box(Modifier.width(300.dp)) { + PriorityLayout(Modifier.record(placed, "layout"), CHAT_IMAGE_LAYOUT_ID, limitToPriorityWidth) { + Box(Modifier.width(280.dp).height(30.dp).record(placed, "header")) + Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).record(placed, "media").size(200.dp, 100.dp)) + Box(Modifier.fillMaxWidth().height(40.dp).record(placed, "caption")) + } + } + } + val layout = placed.getValue("layout") + return listOf("layout", "header", "media", "caption").associateWith { placed.getValue(it).relativeTo(layout) } + } + + @Test + fun terminalElementsAtTheirOwnWidth() { + val r = layOut(limitToPriorityWidth = false) + assertEquals(Placed(0, 0, 300, 170), r["layout"]) + assertEquals(Placed(0, 0, 280, 30), r["header"]) + assertEquals(Placed(0, 30, 200, 100), r["media"]) + assertEquals(Placed(0, 130, 300, 40), r["caption"]) + } + + @Test + fun classicElementsAtMediaWidth() { + val r = layOut(limitToPriorityWidth = true) + assertEquals(Placed(0, 0, 200, 170), r["layout"]) + assertEquals(Placed(0, 0, 200, 30), r["header"]) + assertEquals(Placed(0, 30, 200, 100), r["media"]) + assertEquals(Placed(0, 130, 200, 40), r["caption"]) + } + + @Test + fun terminalElementsAtStartEdgeRightToLeft() { + val r = layOut(limitToPriorityWidth = false, direction = LayoutDirection.Rtl) + assertEquals(Placed(20, 0, 280, 30), r["header"]) + assertEquals(Placed(100, 30, 200, 100), r["media"]) + assertEquals(Placed(0, 130, 300, 40), r["caption"]) + } + + @Test + fun classicPlacementUnmirroredRightToLeft() { + val r = layOut(limitToPriorityWidth = true, direction = LayoutDirection.Rtl) + assertEquals(Placed(0, 0, 200, 30), r["header"]) + assertEquals(Placed(0, 30, 200, 100), r["media"]) + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaCopyTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaCopyTest.kt new file mode 100644 index 0000000000..483ad6a9a3 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaCopyTest.kt @@ -0,0 +1,158 @@ +package chat.simplex.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.Paragraph +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.createFontFamilyResolver +import androidx.compose.ui.text.style.TextDirection +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.sp +import chat.simplex.common.model.* +import chat.simplex.common.ui.theme.Inter +import chat.simplex.common.ui.theme.SimpleXTheme +import chat.simplex.common.views.chat.item.LocalTerminalLayout +import chat.simplex.common.views.chat.item.MarkdownText +import chat.simplex.common.views.chat.item.reserveSpaceForMeta +import chat.simplex.common.views.chat.item.terminalReserveSpaceForMeta +import kotlinx.datetime.Clock +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class TerminalMetaCopyTest { + private val resolver = createFontFamilyResolver() + private val iconSpace = " \u00A0\u00A0\u00A0" + private val sentEdited = CIMeta.getSample(1, Clock.System.now(), "", status = CIStatus.SndSent(SndCIStatusProgress.Complete), itemEdited = true) + private val receivedTimed = CIMeta.getSample(2, Clock.System.now(), "", status = CIStatus.RcvRead(), itemTimed = CITimed(30, null)) + private val sentViaProxy = CIMeta.getSample(3, Clock.System.now(), "", status = CIStatus.SndSent(SndCIStatusProgress.Complete), sentViaProxy = true) + + private fun copy(meta: CIMeta, density: Density, direction: LayoutDirection, showViaProxy: Boolean = false) = + terminalReserveSpaceForMeta( + meta, null, null, null, Color.Gray, showStatus = true, showEdited = true, showViaProxy = showViaProxy, showTimestamp = true, + density = density, bodyFontSize = 16.sp, direction = direction + ) + + private fun paragraph(text: AnnotatedString, density: Density, textDirection: TextDirection, width: Int) = + Paragraph( + text = text.text, + style = TextStyle(fontFamily = Inter, fontSize = 16.sp, textDirection = textDirection), + constraints = Constraints(maxWidth = width), + density = density, + fontFamilyResolver = resolver, + spanStyles = text.spanStyles + ) + + private fun Paragraph.bounds(range: IntRange): Rect = + range.map { getBoundingBox(it) }.reduce { a, b -> Rect(minOf(a.left, b.left), minOf(a.top, b.top), maxOf(a.right, b.right), maxOf(a.bottom, b.bottom)) } + + @Test + fun classicReserveUnchanged() { + assertEquals(iconSpace + iconSpace + "\u00A0" + sentEdited.timestampText, reserveSpaceForMeta(sentEdited, null, null, Color.Gray, showTimestamp = true)) + assertEquals(iconSpace + shortTimeText(30) + "\u00A0" + iconSpace + "\u00A0" + receivedTimed.timestampText, reserveSpaceForMeta(receivedTimed, null, null, Color.Gray, showTimestamp = true)) + assertEquals(iconSpace + iconSpace + iconSpace, reserveSpaceForMeta(sentEdited, null, null, Color.Gray, showTimestamp = false)) + assertEquals(iconSpace + iconSpace + "\u00A0" + sentViaProxy.timestampText, reserveSpaceForMeta(sentViaProxy, null, null, Color.Gray, showViaProxy = true, showTimestamp = true)) + } + + @Test + fun copyHasClassicSpacesAndDirectionMark() { + val ltr = copy(sentEdited, Density(1f), LayoutDirection.Ltr) + assertEquals("\u2060\u200E" + (iconSpace + iconSpace + "\u00A0" + "\u00A0" + sentEdited.timestampText).replace(' ', '\u00A0'), ltr.text) + val rtl = copy(receivedTimed, Density(1f), LayoutDirection.Rtl) + assertEquals("\u2060\u200F" + (iconSpace + shortTimeText(30) + "\u00A0" + iconSpace + "\u00A0" + receivedTimed.timestampText).replace(' ', '\u00A0'), rtl.text) + val proxy = copy(sentViaProxy, Density(1f), LayoutDirection.Ltr, showViaProxy = true) + assertEquals("\u2060\u200E" + (iconSpace + "\u00A0" + iconSpace + "\u00A0" + "\u00A0" + sentViaProxy.timestampText).replace(' ', '\u00A0'), proxy.text) + } + + @Test + fun iconSpacesKeepWidthAcrossFontScales() { + val ts = sentEdited.timestampText + val widths = listOf(0.7f, 0.75f, 1f, 1.3f).associateWith { s -> + val density = Density(1f, s) + val text = copy(sentEdited, density, LayoutDirection.Ltr) + val p = paragraph(text, density, TextDirection.Ltr, 1000) + val timeStart = text.length - ts.length + assertTrue(p.getHorizontalPosition(2, true) < 0.5f, "marks at scale $s: ${p.getHorizontalPosition(2, true)} px") + val icons = p.getHorizontalPosition(timeStart, true) - p.getHorizontalPosition(2, true) + val time = p.getHorizontalPosition(text.length, true) - p.getHorizontalPosition(timeStart, true) + icons to time + } + val (icons1, time1) = widths.getValue(1f) + for ((s, w) in widths) { + assertTrue(abs(w.first - icons1) < 0.5f, "icon spaces at scale $s: ${w.first} px, at scale 1: $icons1 px") + assertTrue(abs(w.second / time1 - s) < 0.05f, "time at scale $s: ${w.second} px, at scale 1: $time1 px") + } + } + + @Test + fun ruleOnlyMessageAsWideAsCopy() { + val placed = mutableMapOf() + renderScene { + SimpleXTheme { + CompositionLocalProvider(LocalTerminalLayout provides true, LocalDensity provides Density(1f)) { + Box(Modifier.width(IntrinsicSize.Max)) { + MarkdownText( + "---", listOf(FormattedText("---")), listOf(MarkdownBlock.Rule(SegmentRange(0, 1))), meta = sentEdited, + toggleSecrets = false, linkMode = SimplexLinkMode.DESCRIPTION, blockLayout = true, modifier = Modifier.record(placed, "rule") + ) + } + } + } + } + val copyWidth = paragraph(copy(sentEdited, Density(1f), LayoutDirection.Ltr), Density(1f), TextDirection.Ltr, 1000).maxIntrinsicWidth + assertTrue(abs(placed.getValue("rule").width - copyWidth) <= 1f, "rule ${placed.getValue("rule").width} px, copy $copyWidth px") + } + + @Test + fun copyAfterSpacesStaysWithLastWord() { + val density = Density(1f) + val head = "The bot replied at once" + val text = buildAnnotatedString { + append("$head ") + append(copy(sentEdited, density, LayoutDirection.Ltr)) + } + val oneLine = paragraph(text, density, TextDirection.Ltr, 10000) + val width = (oneLine.getHorizontalPosition(head.length + 3, true) + oneLine.maxIntrinsicWidth) / 2 + val p = paragraph(text, density, TextDirection.Ltr, width.toInt()) + assertEquals(2, p.lineCount) + assertEquals(p.getLineForOffset(head.lastIndexOf(' ') + 1), p.getLineForOffset(text.length - 1)) + } + + @Test + fun copyStaysAtLineEndAfterWordInOtherDirection() { + val cases = listOf( + "שלום לכולם, זו הפסקה האחרונה של ההודעה" to LayoutDirection.Ltr, + "This is the last paragraph of the message" to LayoutDirection.Rtl, + ) + for ((head, direction) in cases) { + val density = Density(1f) + val text = buildAnnotatedString { + append(head) + append(copy(sentEdited, density, direction)) + } + val p = paragraph(text, density, if (direction == LayoutDirection.Ltr) TextDirection.Ltr else TextDirection.Rtl, 230) + val lastLine = p.lineCount - 1 + val lineStart = p.getLineStart(lastLine) + assertTrue(lineStart in 1 until head.length, "$direction: last line start $lineStart") + assertEquals(lastLine, p.getLineForOffset(text.length - 1), "$direction: time line") + val words = p.bounds(lineStart until head.length) + val time = p.bounds(head.length + 2 until text.length) + if (direction == LayoutDirection.Ltr) { + assertTrue(time.left >= words.right - 0.5f, "$direction: copy ${time.left}..${time.right}, words ${words.left}..${words.right}") + } else { + assertTrue(time.right <= words.left + 0.5f, "$direction: copy ${time.left}..${time.right}, words ${words.left}..${words.right}") + } + } + } +} diff --git a/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaLayoutTest.kt b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaLayoutTest.kt new file mode 100644 index 0000000000..b76d5abe39 --- /dev/null +++ b/apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaLayoutTest.kt @@ -0,0 +1,160 @@ +package chat.simplex.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.layout.layoutId +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import chat.simplex.common.views.chat.item.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class TerminalMetaLayoutTest { + private val density = Density(1f) + private val rowWidth = 300 + private val metaWidth = 60 + private val metaHeight = 16 + private val metaBaseline = 12 + private val metaBoxWidth = metaWidth + 12 + private val gap = with(density) { TERMINAL_META_GAP.roundToPx() } + private val blockGap = with(density) { TERMINAL_BLOCK_BOTTOM_GAP.roundToPx() } + private val placed = mutableMapOf() + + private class Result(val row: Placed, val content: Placed, val meta: Placed?, val overlay: Placed?, val endButton: Placed?, val drawn: Set) + + private fun layOut( + direction: LayoutDirection = LayoutDirection.Ltr, + metaReserved: Boolean = true, + overlay: Boolean = false, + endButton: Boolean = false, + content: @Composable (Modifier) -> Unit + ): Result { + val drawn = mutableSetOf() + renderScene(direction) { + CompositionLocalProvider(LocalTerminalLayout provides true) { + Box(Modifier.width(rowWidth.dp).record(placed, "row")) { + ItemWithMeta( + meta = { Box(Modifier.record(placed, "meta").drawBehind { drawn += "meta" }.fixed(metaWidth, metaHeight, metaBaseline)) }, + overlayMeta = if (overlay) { { Box(Modifier.record(placed, "overlay").drawBehind { drawn += "overlay" }.fixed(metaWidth, metaHeight, metaBaseline)) } } else null, + metaReserved = metaReserved, + endButton = if (endButton) { { Box(Modifier.record(placed, "endButton").fixed(22, 22, null)) } } else null + ) { content(Modifier.record(placed, "content")) } + } + } + } + val row = placed.getValue("row") + return Result(Placed(0, 0, row.width, row.height), placed.getValue("content").relativeTo(row), placed["meta"]?.relativeTo(row), placed["overlay"]?.relativeTo(row), placed["endButton"]?.relativeTo(row), drawn) + } + + @Test + fun metaBaselineOnContentLastBaseline() { + val r = layOut(metaReserved = false) { Box(it.fixed(100, 40, 30)) } + assertEquals(Placed(0, 0, 100, 40), r.content) + assertEquals(Placed(rowWidth - metaBoxWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta) + assertEquals(40, r.row.height) + } + + @Test + fun contentNarrowedByMetaWithoutReserve() { + val r = layOut(metaReserved = false) { Box(it.fillMaxWidth().height(40.dp)) } + assertEquals(Placed(0, 0, rowWidth - metaBoxWidth, 40), r.content) + } + + @Test + fun metaBesideLastLineWithRoom() { + val r = layOut { Box(it.messageLastLine().fixed(100, 40, 30)) } + assertEquals(Placed(rowWidth - metaBoxWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta) + assertEquals(40, r.row.height) + } + + @Test + fun metaUnderLastLineWithoutRoom() { + val r = layOut { Box(it.messageLastLine().fixed(260, 40, 30)) } + assertEquals(Placed(rowWidth - metaBoxWidth, 40 + gap, metaWidth, metaHeight), r.meta) + assertEquals(40 + gap + metaHeight, r.row.height) + } + + @Test + fun blockGapBelowLastBlockWithMetaBeside() { + val r = layOut { Box(it.messageLastBlock().fixed(100, 40, 30)) } + assertEquals(Placed(rowWidth - metaBoxWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta) + assertEquals(40 + blockGap, r.row.height) + } + + @Test + fun metaAboveBottomOfContentWithoutBaseline() { + val r = layOut(metaReserved = false) { Box(it.fixed(100, 50, null)) } + assertEquals(Placed(rowWidth - metaBoxWidth, 50 - gap - metaHeight, metaWidth, metaHeight), r.meta) + assertEquals(50, r.row.height) + } + + @Test + fun rowAtLeastAsTallAsMeta() { + val r = layOut { Box(it.fixed(100, 10, 8)) } + assertEquals(Placed(rowWidth - metaBoxWidth, 0, metaWidth, metaHeight), r.meta) + assertEquals(metaHeight, r.row.height) + } + + @Test + fun overlayMetaAtContentEndWhenRowIsFull() { + val r = layOut(metaReserved = false, overlay = true) { Box(it.fixed(260, 100, null)) } + assertEquals(Placed(260 - metaBoxWidth, 100 - gap - metaHeight, metaWidth, metaHeight), r.overlay) + assertEquals(setOf("overlay"), r.drawn) + assertEquals(100, r.row.height) + } + + @Test + fun overlayMetaAtTopOfShortContent() { + val r = layOut(metaReserved = false, overlay = true) { Box(it.fixed(260, 10, null)) } + assertEquals(Placed(260 - metaBoxWidth, 0, metaWidth, metaHeight), r.overlay) + assertEquals(setOf("overlay"), r.drawn) + assertEquals(metaHeight, r.row.height) + } + + @Test + fun overlayMetaOnMediaUnderWiderQuote() { + val r = layOut(metaReserved = false, overlay = true) { + Column(it.width(IntrinsicSize.Max)) { + PriorityLayout(Modifier, CHAT_IMAGE_LAYOUT_ID, limitToPriorityWidth = true) { + Box(Modifier.width(290.dp).height(30.dp)) + Box(Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID).record(placed, "media").size(260.dp, 100.dp)) + } + } + } + val media = placed.getValue("media").relativeTo(placed.getValue("row")) + assertEquals(Placed(media.x + media.width - metaBoxWidth, media.y + media.height - gap - metaHeight, metaWidth, metaHeight), r.overlay) + assertEquals(setOf("overlay"), r.drawn) + } + + @Test + fun endButtonCentredAboveTimeLine() { + val r = layOut(endButton = true) { + Column(it.fillMaxWidth()) { + Box(Modifier.fixed(80, 24, 18)) + Box(Modifier.fixed(100, 40, 30)) + } + } + assertEquals(Placed(0, 0, rowWidth - 12 - 22, 64), r.content) + assertEquals(Placed(rowWidth - metaBoxWidth, 54 - metaBaseline, metaWidth, metaHeight), r.meta) + assertEquals(Placed(rowWidth - 12 - 22, (54 - metaBaseline - 22) / 2, 22, 22), r.endButton) + assertEquals(64, r.row.height) + } + + @Test + fun metaBesideLastLineRightToLeft() { + val r = layOut(direction = LayoutDirection.Rtl) { Box(it.messageLastLine().fixed(100, 40, 30)) } + assertEquals(Placed(rowWidth - 100, 0, 100, 40), r.content) + assertEquals(Placed(metaBoxWidth - metaWidth, 30 - metaBaseline, metaWidth, metaHeight), r.meta) + assertEquals(40, r.row.height) + } +} diff --git a/apps/multiplatform/gradle.properties b/apps/multiplatform/gradle.properties index 484b8c7f94..8654bf1006 100644 --- a/apps/multiplatform/gradle.properties +++ b/apps/multiplatform/gradle.properties @@ -7,6 +7,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +kotlin.daemon.jvmargs=-Xmx4096m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/apps/multiplatform/product/concepts.md b/apps/multiplatform/product/concepts.md index aa739ef636..dd208e89fa 100644 --- a/apps/multiplatform/product/concepts.md +++ b/apps/multiplatform/product/concepts.md @@ -50,6 +50,7 @@ This document provides a structured mapping between product-level concepts, thei | PC29 | User Address | [README.md](README.md) (Contacts, User Management) | [spec/api.md](../spec/api.md) | `common/.../views/usersettings/UserAddressView.kt`, `UserAddressLearnMore.kt` | `Controller.hs` (`APICreateMyAddress`) | | PC30 | Member Support Chat | [README.md](README.md) (Groups) | [spec/api.md](../spec/api.md) | `common/.../views/chat/group/MemberSupportView.kt`, `MemberSupportChatView.kt`, `MemberAdmission.kt` | `Messages.hs` (`GroupChatScope`), `Controller.hs` | | PC31 | Channels (Relays) | [views/group-info.md](views/group-info.md) | [spec/client/chat-view.md](../spec/client/chat-view.md), [spec/state.md](../spec/state.md) | `common/.../model/ChatModel.kt` (`RelayStatus` incl. `RsRejected`, `GroupRelay`, `GroupMemberRole.Relay`, `GroupMemberStatus.MemRejected`), `common/.../views/chat/group/ChannelRelaysView.kt`, `GroupMemberInfoView.kt` (rejected-status row), `common/.../views/newchat/AddChannelView.kt` (`RelayStatusIndicator` rejected branch), `common/.../views/chat/group/AddGroupRelayView.kt` | `Controller.hs` (`APIAddGroupRelays`, `APIAllowRelayGroup`, `XGrpRelayReject` CONF handler) | +| PC32 | Terminal Chat Layout | [views/chat.md](views/chat.md) (Terminal Layout) | [spec/client/chat-view.md](../spec/client/chat-view.md#TerminalLayout), [spec/state.md](../spec/state.md), [spec/services/theme.md](../spec/services/theme.md#ThemeModeOverrides) | `common/.../views/chat/item/TerminalItemView.kt`, `common/.../views/chat/ChatView.kt` (`LocalTerminalLayout` provider, terminal row branch), `common/.../views/chat/item/ChatItemView.kt` (`alignEnd`, `chatItemTailEnabled`), `FramedItemView.kt` (text edge, meta), `CIEventView.kt` (`chatEventPadding`, `chatEventTextStyle`, `ChatEventWithMeta`), `CIMetaView.kt` (`terminalReserveSpaceForMeta`), `MarkdownBlocks.kt` (terminal block gaps, `headingBox`), `TextItemView.kt` (copy of the meta), `common/.../model/ChatModel.kt` (`ChatLayoutMode`, `activeChatLayout`), `common/.../ui/theme/Theme.kt` (`ThemeModeOverrides.chatLayout`), `common/.../views/usersettings/Appearance.kt` (`ChatLayoutRow`, theme previews), `common/.../views/chat/ChatInfoView.kt` (`ChatLayoutSection`); tests: `common/src/commonTest/kotlin/chat/simplex/app/TerminalItemViewTest.kt`, `common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaCopyTest.kt`, `common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaLayoutTest.kt`, `common/src/desktopTest/kotlin/chat/simplex/app/PriorityLayoutTest.kt`, `common/src/desktopTest/kotlin/chat/simplex/app/ChatEventTextTest.kt` | `Types/UITheme.hs` (`ChatLayout`, `UIThemeEntityOverrides`), `Types.hs` (`GroupProfile`), `Controller.hs` (`SetGroupChatLayout`, `SetAddressSettings`) | **Legend for abbreviated paths:** - `common/.../` expands to `common/src/commonMain/kotlin/chat/simplex/common/` diff --git a/apps/multiplatform/product/views/chat.md b/apps/multiplatform/product/views/chat.md index c920e40146..49ed6b8eff 100644 --- a/apps/multiplatform/product/views/chat.md +++ b/apps/multiplatform/product/views/chat.md @@ -45,7 +45,7 @@ Rendered by `LazyColumnWithScrollBar` with pagination: | Merged items | Adjacent messages grouped with `ItemSeparation` (timestamp, large gap, date separators) | | Floating buttons | Scroll-to-bottom button with unread count | | Date separators | Date headers between messages from different days | -| Wallpaper | Per-chat themed background via `perChatTheme` from contact/group `uiThemes` | +| Wallpaper | Per-chat themed background via `perChatTheme` from contact/group `uiThemes`; terminal layout: background colour without the image | | Content filter | Filter messages by type via `ContentFilter` (images, files, links, etc.) | ### Message Types @@ -126,6 +126,12 @@ Shown when `selectedChatItems != null`: When `timedMessageAllowed` is true, compose bar includes a timer icon for setting message disappear time via `customDisappearingMessageTimePref`. +### Terminal Layout + +On Android and desktop, a chat with the terminal layout shows messages as full-width rows without bubbles, starting at one edge for sent and received messages, with the sender's name above each group of messages and the time at the end of the last line. The wallpaper is drawn as its colour without the image. + +A chat uses the user's choice for that chat, otherwise the layout set by the group owner or by the address the contact came from, otherwise the user's choice for the profile and then the app setting, otherwise classic. + ## Source Files | File | Path | diff --git a/apps/multiplatform/spec/README.md b/apps/multiplatform/spec/README.md index 59f3fbe78a..b29a44075e 100644 --- a/apps/multiplatform/spec/README.md +++ b/apps/multiplatform/spec/README.md @@ -88,7 +88,7 @@ Common Module (commonMain) | API | [spec/api.md](api.md) | ChatController command dispatch, ~150 API functions in 11 categories, CC/CR/API types | | Database | [spec/database.md](database.md) | SQLite database files, migrations, encryption, backup/restore | | Impact | [spec/impact.md](impact.md) | Source file → product concept mapping for change impact analysis | -| Chat View | [spec/client/chat-view.md](client/chat-view.md) | ChatView, ChatItemView, message rendering, item interactions | +| Chat View | [spec/client/chat-view.md](client/chat-view.md) | ChatView, ChatItemView, message rendering, item interactions, terminal layout (TerminalItemView) | | Chat List | [spec/client/chat-list.md](client/chat-list.md) | ChatListView, ChatPreviewView, filtering, search, tags | | Compose | [spec/client/compose.md](client/compose.md) | ComposeView, SendMsgView, ComposeState, attachments, mentions | | Navigation | [spec/client/navigation.md](client/navigation.md) | App screen routing, onboarding, settings, new chat flows | @@ -104,7 +104,7 @@ Common Module (commonMain) | Category | Path | Topic | |---|---|---| | Overview | [product/README.md](../product/README.md) | Product overview, capability map, navigation map | -| Concepts | [product/concepts.md](../product/concepts.md) | 30 product concepts (PC1-PC30) mapped to docs + source | +| Concepts | [product/concepts.md](../product/concepts.md) | 32 product concepts (PC1-PC32) mapped to docs + source | | Glossary | [product/glossary.md](../product/glossary.md) | Domain term definitions (9 sections) | | Rules | [product/rules.md](../product/rules.md) | 18 business rules in 6 categories | | Gaps | [product/gaps.md](../product/gaps.md) | 8 known gaps with recommendations | diff --git a/apps/multiplatform/spec/client/chat-view.md b/apps/multiplatform/spec/client/chat-view.md index c7d6f0674f..a6e39c01b0 100644 --- a/apps/multiplatform/spec/client/chat-view.md +++ b/apps/multiplatform/spec/client/chat-view.md @@ -15,12 +15,13 @@ Source: `common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt 7. [ChatInfoView](#7-chatinfoview) 8. [GroupChatInfoView](#8-groupchatinfoview) 9. [Source Files](#9-source-files) +10. [Terminal Layout](#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 (line ~96 in `ChatView.kt`), 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. +The Chat View is the primary message display and interaction surface in SimpleX Chat. It is built around the [`ChatView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L99) 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. --- @@ -36,7 +37,10 @@ ChatView | | | |-- DateSeparator | | | |-- MemberNameAndRole (group received messages) | | | |-- MemberImage (group received messages) -| | | +-- ChatItemView (message type routing) +| | | |-- 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) @@ -56,7 +60,7 @@ ChatView ## 2. ChatView Composable -**Location:** [`ChatView.kt#L97`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L97) +**Location:** [`ChatView.kt#L98`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L98) ```kotlin fun ChatView( @@ -84,7 +88,7 @@ fun ChatView( ### Chat Loading -On chat ID change (via `snapshotFlow` on `chatModel.chatId.value`, line ~162): +On chat ID change (via [`snapshotFlow` on `chatModel.chatId.value`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L180)): 1. Marks unread chat as read (`markUnreadChatAsRead`) 2. Clears group members state @@ -95,7 +99,7 @@ On chat ID change (via `snapshotFlow` on `chatModel.chatId.value`, line ~162): ### Chat Type Routing -The outer `when (chatInfo)` (line ~229) branches: +The outer [`when (chatInfo)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L274) branches: | ChatInfo Type | Behavior | |---|---| @@ -107,13 +111,13 @@ The outer `when (chatInfo)` (line ~229) branches: ## 3. Message List -**Location:** [`ChatView.kt#L1592`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1592) (`ChatItemsList` composable) +**Location:** [`ChatView.kt#L1757`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1757) (`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()` (line ~1653), which collapses consecutive similar system events into expandable groups. Revealed state is tracked in `revealedItems`. +- **Merged Items:** Messages are grouped via [`MergedItems.create()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1821), 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. @@ -127,6 +131,7 @@ The message list is a `LazyColumnWithScrollBar` with `reverseLayout = true`, mea - **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. --- @@ -134,7 +139,7 @@ The message list is a `LazyColumnWithScrollBar` with `reverseLayout = true`, mea ## 4. ChatItemView -**Location:** [`item/ChatItemView.kt#L66`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L66) +**Location:** [`item/ChatItemView.kt#L70`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L70) ```kotlin fun ChatItemView( @@ -171,6 +176,37 @@ The composable routes based on `cItem.content` and `cItem.meta.itemDeleted`: 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L125)). The item click has no indication; the row draws it (section 10). `GoToItemButton` is placed as described in section 10 ([L345](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L345), [L817](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L817)). Voice items use the arrangement of received items for both directions. +- [`chatItemTailEnabled()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L1220) 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt#L151)), 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); 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 `terminalTextStyle` 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt#L56)). Link previews and chat links are cards in the quote colour ([`cardModifier`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt#L58)). 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt#L481): 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)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L121-L122), `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 `terminalTextStyle` 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/MarkdownBlocks.kt#L59): a paragraph break, and a heading that does not start the text, add `TERMINAL_LEVEL_STEP` (4.75sp) (classic: 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: [`terminalTextStyle(fontSize, color)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L78-L84) sets the line height to font size × `TERMINAL_LINE_HEIGHT_RATIO` (22/16) × f(w) and `LineHeightStyle(Proportional, Trim.None)`. 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 − `TERMINAL_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, in terminal rows), the sender name and role, event texts ([`chatEventTextStyle(base)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt#L22-L28): 12sp, `secondary`, merged into `base`, by default `LocalTextStyle.current`; classic: `base`; 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 (classic: unchanged). 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 20.1sp, event text and time 15.1sp and 15.5sp. +- Headings: body size × `TERMINAL_HEADING_RATIO` (1.3125) to the power 2, 1, 0 for levels 1, 2, 3, 27.6, 21 and 16sp for 16sp text; levels 4–6 also at the body size; all bold (classic: 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 6dp above, 8dp below). 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)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L118-L119), 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: 1dp or 4dp above, 1dp or 4dp 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(vertical)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt#L30-L33): 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, `vertical` above and below, 6dp by default, 8dp for the merged feature icons). 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. `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. +- Time and status: placed at the end of the row by [`ItemWithMeta`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L200-L214), the baseline of the time on the last baseline of the content (section 10). Message text ends with [`terminalReserveSpaceForMeta`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt#L227-L270): a transparent copy of the meta joined to the last word by no-break spaces, in `FontWeight.Normal`. 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. 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIMetaView.kt#L145-L225), 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). 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/CIEventView.kt#L35-L48), 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 `terminalReserveSpaceForMeta` 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. + +Both layouts: `MarkdownText` draws message text in [`messageTextColor()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TextItemView.kt#L54), `onSurface` at 87% in dark themes. + --- ## 5. Message Types @@ -316,6 +352,7 @@ Key sections: group profile, group link, member list with roles, group preferenc | `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/` @@ -343,3 +380,139 @@ Key sections: group profile, group link, member list with roles, group preferenc 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 #`, 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt) + +Rendering of a chat whose [`ChatInfo.activeChatLayout`](../state.md) 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 draws the classic layout. + +### Activation + +| Step | Location | +|---|---| +| `terminal = chatInfo.activeChatLayout(user) == ChatLayoutMode.Terminal` | [`ChatView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L268) | +| `LocalTerminalLayout provides terminal`, next to `LocalAppBarHandler` and `LocalSelectionManager` | [`ChatView`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L269-L273) | +| `drawWallpaperImage = false` of `chatViewBackgroundModifier` | [`ChatLayout` composable](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L1010) | + +[`LocalTerminalLayout`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L56) 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 | +|---|---| +| [`terminalTextStyle(fontSize, color)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L78-L84) | Terminal: line height and line height style of a text in the rows (section 4); classic: `TextStyle.Default`, so that merging it changes nothing | +| [`terminalItemShowsSender(item, older)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L102-L103) | Whether a row shows the sender row (sender rule below). `older`: the next older item in the list | +| [`sameSender(item, older)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L105-L113) | Private; same sender for grouping | +| [`terminalRowGap(item, older, separated, largeGap, hasSenderRow)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L118-L119) | The gap above a row (section 4, item gaps): `TERMINAL_LEVEL_STEP` or 0. Used by `ChatItemsList` and the theme preview | +| [`terminalHeadingGap(item)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L121-L122) | `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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L836-L837) | 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L130-L137) | Private. 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L139-L153) | 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)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L155-L166) | Records the pointer-down position; hover and press indication of the whole row | +| [`Modifier.terminalStartOffset(offset)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L168-L173) | Shifts the row by the selection offset and narrows it by the same amount | +| [`Modifier.messageLastLine()`, `Modifier.messageLastBlock()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L179-L187) | `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()`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L189-L198) | 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. Classic: the modifier unchanged | +| [`ItemWithMeta(meta, overlayMeta, metaReserved, endButton, content)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L200-L214) | 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L216-L256)). `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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L258) | Name, badge, role label, `own`, avatar | +| [`chatItemSender(chatInfo, cItem, range, reversedChatItems, showMemberInfo, showChatInfo)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L260-L307) | Sender of a row (table below) | +| [`SenderNameAndRole(sender, modifier)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L312-L335) | Private; name and badge (13.5sp; primary for own items, secondary otherwise) and role label | +| [`terminalGutter(chatInfo)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L337-L339) | Gutter width: the member image size (scaled by `fontSizeSqrtMultiplier`) plus 4dp in groups, 0 in other chats | +| [`TerminalChatItemRow(sender, gutter, topGap, modifier, senderTopGap, content)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/TerminalItemView.kt#L341-L383) | Row layout below | + +In `ChatView.kt`, shared with the classic date separator: + +| Function | Purpose | +|---|---| +| [`terminalRowsSeparated(olderRow)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L3817-L3820) | 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)`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L3812-L3815) 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) | + +### Row Layout + +``` +row padding top: topGap (terminalRowGap), plus senderTopGap (TERMINAL_SENDER_TOP_GAP, 6.5sp) with a sender row; start 8dp; end 12dp +gutter terminalGutter(chatInfo) + avatar top of the gutter, sender rows of groups only +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 terminalTextStyle(13.5sp) + item ChatItemView; TERMINAL_SENDER_BOTTOM_GAP (5.5sp) below the sender row +``` + +`ChatItemsList` passes [`showAvatar`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L2460) = `terminalItemShowsSender(item, merged.oldest().nextItem)`, and the row gets `chatItemSender(...)` when it is true, `null` otherwise; the [gap above the row](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt#L2461-L2466), 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/ChatItemView.kt#L290) 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt#L1300)) | `activeChatLayout` of the chat | +| Appearance › Themes, preset selector ([`ThemesSection`](../../common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt#L581)) | `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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt#L337) 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`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt#L1313)) | the chat default, "(chat)" when the chat layout is the default, otherwise "(default)"; the other layout | chat theme record, by [`setChatLayout`](../../common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatInfoView.kt#L1382) and `apiSetChatUIThemes` | +| Appearance › Themes, app theme destination ([`ChatLayoutRow`](../../common/src/commonMain/kotlin/chat/simplex/common/views/usersettings/Appearance.kt#L950)) | 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` | diff --git a/apps/multiplatform/spec/impact.md b/apps/multiplatform/spec/impact.md index 6ae1e8d9f6..8c5b474dfd 100644 --- a/apps/multiplatform/spec/impact.md +++ b/apps/multiplatform/spec/impact.md @@ -41,6 +41,7 @@ | PC29 | User Address | | PC30 | Member Support Chat | | PC31 | Channels (Relays) | +| PC32 | Terminal Chat Layout | --- @@ -52,13 +53,13 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `App.kt` | PC1 through PC31 | High | Root composable — navigation scaffold for all features | +| `App.kt` | PC1 through PC32 | High | Root composable — navigation scaffold for all features | | `AppLock.kt` | PC22 | Medium | App lock state and authorization lifecycle | -| `model/ChatModel.kt` | PC1 through PC31 | High | Central state object — every feature reads or writes here | -| `model/SimpleXAPI.kt` | PC1 through PC31 | High | FFI bridge to Haskell core — all commands and responses | +| `model/ChatModel.kt` | PC1 through PC32 | High | Central state object — every feature reads or writes here | +| `model/SimpleXAPI.kt` | PC1 through PC32 | High | FFI bridge to Haskell core — all commands and responses | | `model/CryptoFile.kt` | PC10, PC23 | Medium | Encrypted file read/write helpers | -| `platform/Core.kt` | PC1 through PC31 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic | -| `platform/AppCommon.kt` | PC1 through PC31 | Medium | Shared app initialization logic | +| `platform/Core.kt` | PC1 through PC32 | High | Native FFI declarations (`chatMigrateInit`, `chatSendCmd`, etc.) — all API traffic | +| `platform/AppCommon.kt` | PC1 through PC32 | Medium | Shared app initialization logic | | `platform/Files.kt` | PC10, PC23, PC26 | Medium | File path resolution, temp dirs, encryption utilities | | `platform/NtfManager.kt` | PC18 | High | Notification manager expect declarations | | `platform/Notifications.kt` | PC18 | Medium | Notification channel and permission abstractions | @@ -68,7 +69,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `platform/Cryptor.kt` | PC23 | Medium | Keystore encryption expect declarations | | `platform/Share.kt` | PC10, PC12 | Low | Share sheet abstractions | | `platform/Images.kt` | PC10, PC19 | Low | Image processing utilities | -| `platform/Platform.kt` | PC1 through PC31 | Low | Platform detection and capability flags | +| `platform/Platform.kt` | PC1 through PC32 | Low | Platform detection and capability flags | | `platform/PlatformTextField.kt` | PC4 | Low | Native text input expect declarations | | `platform/Back.kt` | PC1 | Low | Back navigation handling | | `platform/UI.kt` | PC24 | Low | UI density and locale helpers | @@ -82,7 +83,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| | `ui/theme/ThemeManager.kt` | PC24 | Medium | Theme resolution engine — all color and wallpaper logic | -| `ui/theme/Theme.kt` | PC24 | Medium | Theme composables and `SimpleXTheme` | +| `ui/theme/Theme.kt` | PC24, PC32 | Medium | Theme composables and `SimpleXTheme` | | `ui/theme/Color.kt` | PC24 | Low | Color palette definitions | | `ui/theme/Shape.kt` | PC24 | Low | Shape token definitions | | `ui/theme/Type.kt` | PC24 | Low | Typography definitions | @@ -108,7 +109,7 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11 | High | Core conversation UI — most messaging features | +| `views/chat/ChatView.kt` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9, PC11, PC32 | High | Core conversation UI — most messaging features | | `views/chat/ComposeView.kt` | PC4, PC6, PC9, PC10, PC11 | High | Message composition — send path for all messages | | `views/chat/SendMsgView.kt` | PC4, PC9 | Medium | Send button and voice record toggle | | `views/chat/ComposeEmojiPicker.kt` | PC4 | Low | Emoji button expect declaration, emoji insertion | @@ -117,11 +118,11 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | `views/chat/ComposeImageView.kt` | PC10 | Low | Image attachment preview in compose area | | `views/chat/ContextItemView.kt` | PC6 | Low | Reply/edit quote preview | | `views/chat/SelectableChatItemToolbars.kt` | PC7, PC10 | Medium | Multi-select toolbar (delete, forward) | -| `views/chat/ChatInfoView.kt` | PC2, PC13, PC20 | Medium | Contact details and verification | +| `views/chat/ChatInfoView.kt` | PC2, PC13, PC20, PC32 | Medium | Contact details and verification | | `views/chat/ContactPreferences.kt` | PC2, PC8 | Medium | Per-contact feature preferences | | `views/chat/ChatItemInfoView.kt` | PC2, PC3 | Low | Message delivery detail | | `views/chat/ChatItemsLoader.kt` | PC2, PC3 | Medium | Pagination and message loading logic | -| `views/chat/ChatItemsMerger.kt` | PC2, PC3 | Medium | Merges incremental message updates | +| `views/chat/ChatItemsMerger.kt` | PC2, PC3, PC32 | Medium | Merges incremental message updates | | `views/chat/VerifyCodeView.kt` | PC13 | Medium | Contact security code verification | | `views/chat/ScanCodeView.kt` | PC13 | Low | QR code scanning for verification | | `views/chat/CommandsMenuView.kt` | PC4 | Low | Slash-command menu | @@ -134,28 +135,30 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `views/chat/item/ChatItemView.kt` | PC2, PC3, PC5, PC6, PC7, PC8 | High | Root chat item renderer with context menus | -| `views/chat/item/TextItemView.kt` | PC2, PC3, PC4 | Medium | Text message bubble rendering | -| `views/chat/item/FramedItemView.kt` | PC4, PC6, PC10, PC11 | Medium | Framed (quoted/forwarded) message container | +| `views/chat/item/ChatItemView.kt` | PC2, PC3, PC5, PC6, PC7, PC8, PC32 | High | Root chat item renderer with context menus | +| `views/chat/item/TextItemView.kt` | PC2, PC3, PC4, PC32 | Medium | Text message bubble rendering | +| `views/chat/item/MarkdownBlocks.kt` | PC4, PC32 | Medium | Block Markdown rendering; terminal block gaps and headings | +| `views/chat/item/FramedItemView.kt` | PC4, PC6, PC10, PC11, PC32 | Medium | Framed (quoted/forwarded) message container | +| `views/chat/item/TerminalItemView.kt` | PC2, PC3, PC32 | Medium | Terminal layout rows, sender rows, time placement | | `views/chat/item/CIImageView.kt` | PC10 | Medium | Image message rendering | | `views/chat/item/CIVideoView.kt` | PC10 | Medium | Video message rendering | -| `views/chat/item/CIFileView.kt` | PC10 | Medium | File message rendering | -| `views/chat/item/CIVoiceView.kt` | PC9 | Medium | Voice message rendering and playback | -| `views/chat/item/EmojiItemView.kt` | PC5 | Low | Emoji reaction display | -| `views/chat/item/CIMetaView.kt` | PC2, PC3, PC8 | Low | Timestamp, delivery status, timed message indicator | -| `views/chat/item/CICallItemView.kt` | PC17 | Low | Call event item rendering | -| `views/chat/item/CIEventView.kt` | PC3, PC14, PC16 | Low | Group event item rendering | -| `views/chat/item/CIGroupInvitationView.kt` | PC3, PC14 | Low | Group invitation item rendering | -| `views/chat/item/CIMemberCreatedContactView.kt` | PC3, PC12 | Low | Member-created contact event | -| `views/chat/item/CIChatFeatureView.kt` | PC8 | Low | Feature change event rendering | -| `views/chat/item/CIFeaturePreferenceView.kt` | PC8 | Low | Feature preference change rendering | -| `views/chat/item/CIRcvDecryptionError.kt` | PC2, PC3 | Low | Decryption error display | -| `views/chat/item/DeletedItemView.kt` | PC7 | Low | Deleted message placeholder | -| `views/chat/item/MarkedDeletedItemView.kt` | PC7 | Low | Moderated/marked-deleted placeholder | +| `views/chat/item/CIFileView.kt` | PC10, PC32 | Medium | File message rendering | +| `views/chat/item/CIVoiceView.kt` | PC9, PC32 | Medium | Voice message rendering and playback | +| `views/chat/item/EmojiItemView.kt` | PC5, PC32 | Low | Emoji reaction display | +| `views/chat/item/CIMetaView.kt` | PC2, PC3, PC8, PC32 | Low | Timestamp, delivery status, timed message indicator | +| `views/chat/item/CICallItemView.kt` | PC17, PC32 | Low | Call event item rendering | +| `views/chat/item/CIEventView.kt` | PC3, PC14, PC16, PC32 | Low | Group event item rendering | +| `views/chat/item/CIGroupInvitationView.kt` | PC3, PC14, PC32 | Low | Group invitation item rendering | +| `views/chat/item/CIMemberCreatedContactView.kt` | PC3, PC12, PC32 | Low | Member-created contact event | +| `views/chat/item/CIChatFeatureView.kt` | PC8, PC32 | Low | Feature change event rendering | +| `views/chat/item/CIFeaturePreferenceView.kt` | PC8, PC32 | Low | Feature preference change rendering | +| `views/chat/item/CIRcvDecryptionError.kt` | PC2, PC3, PC32 | Low | Decryption error display | +| `views/chat/item/DeletedItemView.kt` | PC7, PC32 | Low | Deleted message placeholder | +| `views/chat/item/MarkedDeletedItemView.kt` | PC7, PC32 | Low | Moderated/marked-deleted placeholder | | `views/chat/item/ImageFullScreenView.kt` | PC10 | Low | Full-screen image viewer | | `views/chat/item/CIBrokenComposableView.kt` | — | Low | Fallback for render failures | | `views/chat/item/CIInvalidJSONView.kt` | — | Low | Fallback for malformed items | -| `views/chat/item/IntegrityErrorItemView.kt` | PC2, PC3 | Low | Message integrity error display | +| `views/chat/item/IntegrityErrorItemView.kt` | PC2, PC3, PC32 | Low | Message integrity error display | ### 1.6 Views — Groups @@ -207,13 +210,13 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| | `views/usersettings/SettingsView.kt` | PC18, PC22, PC23, PC24, PC25, PC29 | Medium | Settings navigation hub | -| `views/usersettings/Appearance.kt` | PC24 | Low | Theme and appearance customization | +| `views/usersettings/Appearance.kt` | PC24, PC32 | Low | Theme and appearance customization | | `views/usersettings/PrivacySettings.kt` | PC20, PC22 | Medium | Privacy and lock settings | | `views/usersettings/UserProfileView.kt` | PC19 | Medium | Profile display name and image editing | | `views/usersettings/UserProfilesView.kt` | PC19, PC21 | Medium | Multi-profile management | | `views/usersettings/HiddenProfileView.kt` | PC21 | Medium | Hidden profile access | | `views/usersettings/IncognitoView.kt` | PC20 | Low | Incognito mode explanation | -| `views/usersettings/UserAddressView.kt` | PC29 | Medium | User SimpleX address management | +| `views/usersettings/UserAddressView.kt` | PC29, PC32 | Medium | User SimpleX address management | | `views/usersettings/UserAddressLearnMore.kt` | PC29 | Low | Address educational content | | `views/usersettings/NotificationsSettingsView.kt` | PC18 | Medium | Notification mode configuration | | `views/usersettings/CallSettings.kt` | PC17 | Low | Call-related settings | @@ -271,15 +274,15 @@ Path prefix: `common/src/commonMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `views/helpers/AlertManager.kt` | PC1 through PC31 | Medium | Modal alert system used across all features | -| `views/helpers/ModalView.kt` | PC1 through PC31 | Medium | Modal navigation stack | -| `views/helpers/Utils.kt` | PC1 through PC31 | Low | Shared formatting, clipboard, and utility functions | +| `views/helpers/AlertManager.kt` | PC1 through PC32 | Medium | Modal alert system used across all features | +| `views/helpers/ModalView.kt` | PC1 through PC32 | Medium | Modal navigation stack | +| `views/helpers/Utils.kt` | PC1 through PC32 | Low | Shared formatting, clipboard, and utility functions | | `views/helpers/DatabaseUtils.kt` | PC23 | Medium | Keystore passphrase and database helpers | | `views/helpers/LinkPreviews.kt` | PC11 | Medium | Link preview fetching and rendering | | `views/helpers/LocalAuthentication.kt` | PC22 | Medium | Biometric/passcode authentication expect | | `views/helpers/ChatWallpaper.kt` | PC24 | Low | Chat wallpaper rendering | | `views/helpers/ChatInfoImage.kt` | PC19 | Low | Profile image composable | -| `views/helpers/ThemeModeEditor.kt` | PC24 | Low | Theme mode toggle | +| `views/helpers/ThemeModeEditor.kt` | PC24, PC32 | Low | Theme mode toggle | | `views/helpers/ChooseAttachmentView.kt` | PC10 | Low | Attachment picker | | `views/helpers/GetImageView.kt` | PC10, PC19 | Low | Image capture and crop | | `views/helpers/TextEditor.kt` | PC4 | Low | Rich text editor helpers | @@ -326,8 +329,8 @@ Path prefix: `android/src/main/java/chat/simplex/app/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `SimplexApp.kt` | PC1 through PC31 | High | Application class — initializes core, preferences, and notification channels | -| `MainActivity.kt` | PC1 through PC31 | High | Single-activity host — intent handling, lifecycle, deep links | +| `SimplexApp.kt` | PC1 through PC32 | High | Application class — initializes core, preferences, and notification channels | +| `MainActivity.kt` | PC1 through PC32 | High | Single-activity host — intent handling, lifecycle, deep links | | `SimplexService.kt` | PC18 | High | Foreground service — keeps message receiver alive | | `CallService.kt` | PC17 | Medium | Foreground service for active calls | | `MessagesFetcherWorker.kt` | PC18 | Medium | WorkManager periodic message fetch | @@ -341,7 +344,7 @@ Path prefix: `common/src/androidMain/kotlin/chat/simplex/common/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `platform/AppCommon.android.kt` | PC1 through PC31 | Medium | Android app initialization actual declarations | +| `platform/AppCommon.android.kt` | PC1 through PC32 | Medium | Android app initialization actual declarations | | `platform/SimplexService.android.kt` | PC18 | Medium | Android foreground service actual implementation | | `platform/Files.android.kt` | PC10, PC23, PC26 | Medium | Android file paths and content-URI resolution | | `platform/Cryptor.android.kt` | PC23 | Medium | Android Keystore encryption actual implementation | @@ -408,7 +411,7 @@ Path prefix: `desktop/src/jvmMain/kotlin/chat/simplex/desktop/` | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `Main.kt` | PC1 through PC31 | High | JVM entry point — Haskell init, migrations, app launch | +| `Main.kt` | PC1 through PC32 | High | JVM entry point — Haskell init, migrations, app launch | ### 3.2 Desktop Platform Implementations (desktopMain) @@ -423,7 +426,7 @@ Path prefix: `common/src/desktopMain/kotlin/chat/simplex/common/` | `WindowActivation.kt` | — | Low | `_NET_ACTIVE_WINDOW` request that raises the window on X11 and XWayland | | `platform/AppLinkScheme.desktop.kt` | — | Medium | Runtime `simplexchat:` and `simplex:` scheme registration (Windows HKCU, AppImage desktop entry) | | `model/NtfManager.desktop.kt` | PC18 | Medium | Desktop system tray notification display | -| `platform/AppCommon.desktop.kt` | PC1 through PC31 | Medium | Desktop app initialization actual declarations | +| `platform/AppCommon.desktop.kt` | PC1 through PC32 | Medium | Desktop app initialization actual declarations | | `platform/SimplexService.desktop.kt` | PC18 | Low | Desktop background receiver (no foreground service) | | `platform/Files.desktop.kt` | PC10, PC23, PC26 | Medium | Desktop file path resolution | | `platform/Cryptor.desktop.kt` | PC23 | Medium | Desktop keystore encryption actual implementation | @@ -487,14 +490,14 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | Source File | Product Concepts Affected | Risk Level | Notes | |-------------|--------------------------|------------|-------| -| `src/Simplex/Chat.hs` | PC1 through PC31 | High | Main chat module — top-level orchestration | -| `src/Simplex/Chat/Controller.hs` | PC1 through PC31 | High | Command processor — all API commands dispatched here | -| `src/Simplex/Chat/Types.hs` | PC1 through PC31 | High | Core data types shared across all features | -| `src/Simplex/Chat/Core.hs` | PC1 through PC31 | High | Chat engine lifecycle (start, stop, subscribe) | -| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC31 | High | API command handler implementations | -| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC31 | High | Internal helpers for command processing | -| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC31 | High | Event subscriber — incoming message routing | -| `src/Simplex/Chat/Protocol.hs` | PC2, PC3, PC4, PC5, PC6, PC7 | High | Chat-level message protocol (x-events) | +| `src/Simplex/Chat.hs` | PC1 through PC32 | High | Main chat module — top-level orchestration | +| `src/Simplex/Chat/Controller.hs` | PC1 through PC32 | High | Command processor — all API commands dispatched here | +| `src/Simplex/Chat/Types.hs` | PC1 through PC32 | High | Core data types shared across all features | +| `src/Simplex/Chat/Core.hs` | PC1 through PC32 | High | Chat engine lifecycle (start, stop, subscribe) | +| `src/Simplex/Chat/Library/Commands.hs` | PC1 through PC32 | High | API command handler implementations | +| `src/Simplex/Chat/Library/Internal.hs` | PC1 through PC32 | High | Internal helpers for command processing | +| `src/Simplex/Chat/Library/Subscriber.hs` | PC1 through PC32 | High | Event subscriber — incoming message routing | +| `src/Simplex/Chat/Protocol.hs` | PC2, PC3, PC4, PC5, PC6, PC7, PC32 | High | Chat-level message protocol (x-events) | | `src/Simplex/Chat/Messages.hs` | PC2, PC3, PC4, PC5, PC6, PC7, PC8, PC9 | High | Message types and content | | `src/Simplex/Chat/Messages/CIContent.hs` | PC4, PC5, PC6, PC7, PC8, PC9, PC11 | Medium | Chat item content variants | | `src/Simplex/Chat/Messages/CIContent/Events.hs` | PC3, PC14, PC16 | Medium | Group event content types | @@ -503,15 +506,15 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | `src/Simplex/Chat/Files.hs` | PC10 | Medium | File transfer orchestration | | `src/Simplex/Chat/Delivery.hs` | PC2, PC3 | Medium | Message delivery engine | | `src/Simplex/Chat/Markdown.hs` | PC4 | Low | Markdown parsing for message formatting | -| `src/Simplex/Chat/Store.hs` | PC1 through PC31 | High | Database store interface | -| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC31 | Medium | Shared store utilities | +| `src/Simplex/Chat/Store.hs` | PC1 through PC32 | High | Database store interface | +| `src/Simplex/Chat/Store/Shared.hs` | PC1 through PC32 | Medium | Shared store utilities | | `src/Simplex/Chat/Store/Messages.hs` | PC4, PC5, PC6, PC7, PC8 | High | Message persistence | -| `src/Simplex/Chat/Store/Groups.hs` | PC3, PC14, PC15, PC16, PC30 | High | Group persistence | -| `src/Simplex/Chat/Store/Direct.hs` | PC2, PC12, PC13 | High | Contact persistence | +| `src/Simplex/Chat/Store/Groups.hs` | PC3, PC14, PC15, PC16, PC30, PC32 | High | Group persistence | +| `src/Simplex/Chat/Store/Direct.hs` | PC2, PC12, PC13, PC32 | High | Contact persistence | | `src/Simplex/Chat/Store/Files.hs` | PC10 | Medium | File transfer persistence | -| `src/Simplex/Chat/Store/Profiles.hs` | PC19, PC21 | Medium | User profile persistence | -| `src/Simplex/Chat/Store/Connections.hs` | PC2, PC12 | High | Connection persistence and entity resolution | -| `src/Simplex/Chat/Store/ContactRequest.hs` | PC12 | Medium | Contact request persistence | +| `src/Simplex/Chat/Store/Profiles.hs` | PC19, PC21, PC32 | Medium | User profile persistence | +| `src/Simplex/Chat/Store/Connections.hs` | PC2, PC12, PC32 | High | Connection persistence and entity resolution | +| `src/Simplex/Chat/Store/ContactRequest.hs` | PC12, PC32 | Medium | Contact request persistence | | `src/Simplex/Chat/Store/NoteFolders.hs` | PC1 | Low | Note folder (self-chat) persistence | | `src/Simplex/Chat/Store/Delivery.hs` | PC2, PC3 | Medium | Delivery task persistence | | `src/Simplex/Chat/Store/AppSettings.hs` | PC25 | Low | App settings persistence | @@ -525,19 +528,19 @@ The Haskell core is compiled as a shared native library (`libsimplex.so` / `libs | `src/Simplex/Chat/Remote/RevHTTP.hs` | PC27 | Low | Reverse HTTP for remote desktop | | `src/Simplex/Chat/Remote/AppVersion.hs` | PC27 | Low | Remote version negotiation | | `src/Simplex/Chat/ProfileGenerator.hs` | PC20 | Low | Random profile generation for incognito | -| `src/Simplex/Chat/Types/UITheme.hs` | PC24 | Low | Theme data types for UI customization | +| `src/Simplex/Chat/Types/UITheme.hs` | PC24, PC32 | Low | Theme data types for UI customization | | `src/Simplex/Chat/Types/Preferences.hs` | PC2, PC3, PC8 | Medium | Chat feature preferences (timed messages, etc.) | | `src/Simplex/Chat/Types/Shared.hs` | PC3, PC16 | Medium | Shared types including GroupMemberRole | | `src/Simplex/Chat/Types/MemberRelations.hs` | PC3, PC16, PC30 | Medium | Member relationship state machine | | `src/Simplex/Chat/Operators.hs` | PC25 | Medium | Server operator management | | `src/Simplex/Chat/Operators/Presets.hs` | PC25 | Low | Preset server operators | | `src/Simplex/Chat/Operators/Conditions.hs` | PC25 | Low | Operator usage conditions | -| `src/Simplex/Chat/AppSettings.hs` | PC25 | Low | App settings sync types | -| `src/Simplex/Chat/Mobile.hs` | PC1 through PC31 | High | C FFI exports — JNI bridge target | +| `src/Simplex/Chat/AppSettings.hs` | PC25, PC32 | Low | App settings sync types | +| `src/Simplex/Chat/Mobile.hs` | PC1 through PC32 | High | C FFI exports — JNI bridge target | | `src/Simplex/Chat/Mobile/File.hs` | PC10 | Medium | Mobile file read/write FFI | -| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC31 | Medium | Shared FFI helpers | +| `src/Simplex/Chat/Mobile/Shared.hs` | PC1 through PC32 | Medium | Shared FFI helpers | | `src/Simplex/Chat/Mobile/WebRTC.hs` | PC17 | Low | WebRTC FFI helpers | -| `src/Simplex/Chat/View.hs` | PC1 through PC31 | Low | Terminal view rendering (not used by mobile/desktop UI) | +| `src/Simplex/Chat/View.hs` | PC1 through PC32 | Low | Terminal view rendering (not used by mobile/desktop UI) | | `src/Simplex/Chat/Stats.hs` | PC25 | Low | Server statistics tracking | | `src/Simplex/Chat/Util.hs` | — | Low | General Haskell utilities | | `src/Simplex/Chat/Styled.hs` | — | Low | Terminal styled text (not used by mobile/desktop UI) | diff --git a/apps/multiplatform/spec/services/theme.md b/apps/multiplatform/spec/services/theme.md index e5839fc193..e278dbe2a9 100644 --- a/apps/multiplatform/spec/services/theme.md +++ b/apps/multiplatform/spec/services/theme.md @@ -139,7 +139,7 @@ Converts `Color` to `#AARRGGBB` hex string with intelligent alpha handling. ## 3. Default Themes -[`Theme.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L26): +[`Theme.kt`](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L27): ```kotlin enum class DefaultTheme { @@ -160,7 +160,7 @@ enum class DefaultTheme { `SYSTEM` is a virtual theme name that resolves to LIGHT or the configured dark variant at runtime. -`DefaultThemeMode` ([line 46](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L46)): `LIGHT` or `DARK`, serialized as `"light"` / `"dark"`. +`DefaultThemeMode` ([line 47](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L47)): `LIGHT` or `DARK`, serialized as `"light"` / `"dark"`. --- @@ -168,9 +168,9 @@ enum class DefaultTheme { -### AppColors (line 53) +### AppColors (line 54) -[`Theme.kt` L53](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L53): +[`Theme.kt` L54](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L54): ```kotlin @Stable @@ -188,9 +188,9 @@ Mutable state properties (for efficient recomposition) representing chat-specifi -### AppWallpaper (line 106) +### AppWallpaper (line 107) -[`Theme.kt` L106](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L106): +[`Theme.kt` L107](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L107): ```kotlin @Stable @@ -205,7 +205,7 @@ Represents the active wallpaper state with optional background color, tint overl -### ThemeColor (line 140) +### ThemeColor (line 141) Enum of all customizable color slots: @@ -215,17 +215,17 @@ Each has a `fromColors()` method to extract the current value and a `text` prope -### ThemeColors (line 183) +### ThemeColors (line 184) -[`Theme.kt` L183](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L183): +[`Theme.kt` L184](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L184): Serializable data class with optional hex color strings for each slot. Uses `@SerialName` annotations for YAML compatibility (`accent` for `primary`, `accentVariant` for `primaryVariant`, `menus` for `surface`, etc.). -### ThemeWallpaper (line 224) +### ThemeWallpaper (line 225) -[`Theme.kt` L224](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L224): +[`Theme.kt` L225](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L225): ```kotlin @Serializable @@ -248,9 +248,9 @@ Key methods: -### ThemeOverrides (line 304) +### ThemeOverrides (line 305) -[`Theme.kt` L304](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L304): +[`Theme.kt` L305](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L305): ```kotlin @Serializable @@ -269,25 +269,29 @@ A complete theme override entry. Multiple can coexist (one per wallpaper type pe -### ThemeModeOverrides (line 475) +### ThemeModeOverrides (line 476) -[`Theme.kt` L475](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L475): +[`Theme.kt` L476](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L476): ```kotlin @Serializable data class ThemeModeOverrides( val light: ThemeModeOverride? = null, val dark: ThemeModeOverride? = null, + val chatLayout: ChatLayoutMode? = null ) ``` -Container for per-user or per-chat overrides, with separate light and dark mode variants. Stored on the `User` model as `uiThemes`. +Container for per-user or per-chat overrides, with separate light and dark mode variants, and the chat layout chosen for the profile or the chat (both colour modes). Stored on the `User` model as `uiThemes`. + +- `layoutOnly()`: the overrides with the colours and wallpapers removed, `null` without a layout. Used by "Reset to app theme", "Remove image" and the app theme destination of Appearance, so the layout is kept. +- `ThemeModeOverrides?.withChatLayout(chatLayout)`: the overrides with the layout set, `null` when empty. -### ThemeModeOverride (line 487) +### ThemeModeOverride (line 494) -[`Theme.kt` L487](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L487): +[`Theme.kt` L494](../../common/src/commonMain/kotlin/chat/simplex/common/ui/theme/Theme.kt#L494): ```kotlin @Serializable diff --git a/apps/multiplatform/spec/state.md b/apps/multiplatform/spec/state.md index 5003bb60e5..87acb64072 100644 --- a/apps/multiplatform/spec/state.md +++ b/apps/multiplatform/spec/state.md @@ -300,6 +300,18 @@ data class ChatStats( | `ChatInfo.ContactConnection` | `"contactConnection"` | `contactConnection: PendingContactConnection` | | `ChatInfo.InvalidJSON` | `"invalidJSON"` | `json: String` | +[`ChatInfo.activeChatLayout(user)`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1876) is computed in the app, skipping `ChatLayoutMode.Unknown` values: + +``` +chat choice ChatInfo.uiThemes.chatLayout (contact or group theme record) +chat layout ChatInfo.chatLayout: groupProfile.chatLayout of a group; Contact.chatLayout (address link data, stored when the contact is prepared) +profile choice User.uiThemes.chatLayout (profileChatLayout) +app choice appPrefs.chatLayout +otherwise ChatLayoutMode.Classic +``` + +[`defaultChatLayout(user)`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L1873) is the same without the chat choice. Note folders have no chat values, so the profile and app choices apply. `ChatView` reads `chatModel.currentUser`, and `profileChatLayout` reads `appPrefs.chatLayout.state`, so a change of either recomposes the open chat; the chat list is not reloaded. The chat choice is written with `apiSetChatUIThemes` (`setChatLayout`, `ChatInfoView.kt`), the profile choice with `apiSetUserUIThemes` (`ChatLayoutRow`, `Appearance.kt`), together with the colours of the same record. + ### RelayStatus (Channels) `RelayStatus` is an `enum class` at [`ChatModel.kt line 2288`](../common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt#L2288) modelling a relay's lifecycle for a channel on the owner's side. Serialized as a lowercase string via `@SerialName`. @@ -450,6 +462,7 @@ val appPrefs: AppPreferences by lazy { AppPreferences() } |---|---|---|---| | `oneHandUI` | `Boolean` | `true` | One-hand mode | | `chatBottomBar` | `Boolean` | `true` | Bottom bar in chat | +| `chatLayout` | `ChatLayoutMode` | `Classic` | App chat layout (`activeChatLayout`) | #### Remote Access (lines 238-243) diff --git a/apps/simplex-badge-service/src/BadgeService/Group.hs b/apps/simplex-badge-service/src/BadgeService/Group.hs index 28117655ef..b072277a76 100644 --- a/apps/simplex-badge-service/src/BadgeService/Group.hs +++ b/apps/simplex-badge-service/src/BadgeService/Group.hs @@ -76,7 +76,8 @@ buildGroupProfile GroupConfig {gDisplayName, gDescription} = image = Nothing, publicGroup = Nothing, groupPreferences = Just (emptyGroupPrefs {commands = Just groupCommands} :: GroupPreferences), - memberAdmission = Nothing + memberAdmission = Nothing, + chatLayout = Nothing } ensureManagedGroup :: ChatController -> GroupConfig -> IO (Maybe GroupId) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 7b3985e132..57fc0a2a30 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -50,6 +50,7 @@ This file is generated automatically. - [ChatInfo](#chatinfo) - [ChatItem](#chatitem) - [ChatItemDeletion](#chatitemdeletion) +- [ChatLayout](#chatlayout) - [ChatListQuery](#chatlistquery) - [ChatPeerType](#chatpeertype) - [ChatRef](#chatref) @@ -273,6 +274,7 @@ This file is generated automatically. **Record type**: - businessAddress: bool - autoAccept: [AutoAccept](#autoaccept)? +- chatLayout: [ChatLayout](#chatlayout)? - autoReply: [MsgContent](#msgcontent)? @@ -1586,6 +1588,15 @@ Message deletion result. - toChatItem: [AChatItem](#achatitem)? +--- + +## ChatLayout + +**Enum type**: +- "classic" +- "terminal" + + --- ## ChatListQuery @@ -1977,6 +1988,7 @@ Error: - chatTags: [int64] - chatItemTTL: int64? - uiThemes: [UIThemeEntityOverrides](#uithemeentityoverrides)? +- chatLayout: [ChatLayout](#chatlayout)? - chatDeleted: bool - customData: JSONObject? @@ -2019,6 +2031,7 @@ ContactViaAddress: - profile: [Profile](#profile) - message: [MsgContent](#msgcontent)? - business: bool +- chatLayout: [ChatLayout](#chatlayout)? - localBadge: [LocalBadge](#localbadge)? @@ -2784,6 +2797,7 @@ UpdateRequired: - publicGroup: [PublicGroupProfile](#publicgroupprofile)? - groupPreferences: [GroupPreferences](#grouppreferences)? - memberAdmission: [GroupMemberAdmission](#groupmemberadmission)? +- chatLayout: [ChatLayout](#chatlayout)? --- @@ -4641,6 +4655,7 @@ Handshake: **Record type**: - light: [UIThemeEntityOverride](#uithemeentityoverride)? - dark: [UIThemeEntityOverride](#uithemeentityoverride)? +- chatLayout: [ChatLayout](#chatlayout)? --- diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index d13817c439..f8d3552929 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -299,6 +299,7 @@ cliCommands = "SetClientService", "SetContactFeature", "SetContactTimedMessages", + "SetGroupChatLayout", "SetGroupFeature", "SetGroupFeatureRole", "SetGroupMemberAdmissionReview", diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index ab76cc6581..26553b58cb 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -236,6 +236,7 @@ chatTypesDocsData = (sti @BadgeType, STEnum, "BT", ["BTUnknown"], "", ""), (sti @ChatFeature, STEnum, "CF", [], "", ""), (sti @ChatItemDeletion, STRecord, "", [], "", "Message deletion result."), + (sti @ChatLayout, STEnum, "CL", ["CLUnknown"], "", ""), (sti @ChatPeerType, STEnum, "CPT", ["CPTUnknown"], "", ""), (sti @ChatRef, STRecord, "", [], Param "chatType" <> Param "chatId" <> Optional "" (Param "$0") "chatScope", "Used in API commands. Chat scope can only be passed with groups."), (sti @ChatSettings, STRecord, "", [], "", ""), @@ -474,6 +475,7 @@ deriving instance Generic ChatError deriving instance Generic ChatErrorType deriving instance Generic ChatFeature deriving instance Generic ChatItemDeletion +deriving instance Generic ChatLayout deriving instance Generic ChatPeerType deriving instance Generic ChatRef deriving instance Generic ChatSettings diff --git a/docs/protocol/simplex-chat.md b/docs/protocol/simplex-chat.md index 0538978766..9400d15b68 100644 --- a/docs/protocol/simplex-chat.md +++ b/docs/protocol/simplex-chat.md @@ -264,6 +264,8 @@ Currently members can have one of four roles - `owner`, `admin`, `member` and `o `x.grp.info` message is sent to all members by the member who updated group profile. Only group owners can update group profiles. Clients MAY implement some conflict resolution strategy - it is currently not implemented by SimpleX Chat client. This message MUST only be sent by members with `owner` role. Receiving clients MUST ignore this message if it is received from member other than with `owner` role. +Group profile can include optional `chatLayout` property (`classic` or `terminal`) set by a group owner. Clients use it as the layout of the group chat, also after it is changed by `x.grp.info`, unless the user chose another layout for this chat. Clients MUST treat unknown values as absent. + `x.grp.direct.inv` message is sent to a group member to propose establishing a direct connection between members, thus creating a contact with another member. `x.grp.msg.forward` message is sent by inviting member to forward messages between introduced members, while they are connecting. This message MUST NOT contain another `x.grp.msg.forward` message. diff --git a/docs/protocol/simplex-chat.schema.json b/docs/protocol/simplex-chat.schema.json index a9bb2d8735..095e7207be 100644 --- a/docs/protocol/simplex-chat.schema.json +++ b/docs/protocol/simplex-chat.schema.json @@ -61,6 +61,7 @@ "type": "string", "metadata": {"comment": "group purpose / short description (160 characters)"} }, + "chatLayout": {"enum": ["classic", "terminal"]}, "groupPreferences": { "optionalProperties": { "commands": { diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index cef7f41de5..1a4b2643bc 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -25,6 +25,7 @@ export interface AddRelayResult { export interface AddressSettings { businessAddress: boolean autoAccept?: AutoAccept + chatLayout?: ChatLayout autoReply?: MsgContent } @@ -1879,6 +1880,11 @@ export interface ChatItemDeletion { toChatItem?: AChatItem } +export enum ChatLayout { + Classic = "classic", + Terminal = "terminal", +} + export type ChatListQuery = ChatListQuery.Filters | ChatListQuery.Search export namespace ChatListQuery { @@ -2289,6 +2295,7 @@ export interface Contact { chatTags: number[] // int64 chatItemTTL?: number // int64 uiThemes?: UIThemeEntityOverrides + chatLayout?: ChatLayout chatDeleted: boolean customData?: object } @@ -2348,6 +2355,7 @@ export interface ContactShortLinkData { profile: Profile message?: MsgContent business: boolean + chatLayout?: ChatLayout localBadge?: LocalBadge } @@ -3113,6 +3121,7 @@ export interface GroupProfile { publicGroup?: PublicGroupProfile groupPreferences?: GroupPreferences memberAdmission?: GroupMemberAdmission + chatLayout?: ChatLayout } export interface GroupRelay { @@ -5408,6 +5417,7 @@ export interface UIThemeEntityOverride { export interface UIThemeEntityOverrides { light?: UIThemeEntityOverride dark?: UIThemeEntityOverride + chatLayout?: ChatLayout } export interface UpdatedMessage { diff --git a/packages/simplex-chat-client/typescript/src/client.ts b/packages/simplex-chat-client/typescript/src/client.ts index 67e470e52d..403ad60b46 100644 --- a/packages/simplex-chat-client/typescript/src/client.ts +++ b/packages/simplex-chat-client/typescript/src/client.ts @@ -104,18 +104,32 @@ export class ChatClient { } async enableAddressAutoAccept(userId: number, autoReply?: T.MsgContent, businessAddress = false): Promise { - const r = await this.sendChatCmd( - CC.APISetAddressSettings.cmdString({userId, settings: {businessAddress, autoAccept: {acceptIncognito: false}, autoReply}}) - ) - if (r.type !== "userContactLinkUpdated") { - throw new ChatCommandError("error changing user contact address mode", r) - } + const settings = await this.apiGetAddressSettings(userId) + await this.apiSetAddressSettings(userId, {...settings, businessAddress, autoAccept: {acceptIncognito: false}, autoReply}) } async disableAddressAutoAccept(userId: number, businessAddress = false): Promise { - const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings: {businessAddress}})) + const settings = await this.apiGetAddressSettings(userId) + await this.apiSetAddressSettings(userId, {...settings, businessAddress, autoAccept: undefined, autoReply: undefined}) + } + + async apiGetAddressSettings(userId: number): Promise { + const r = await this.sendChatCmd(CC.APIShowMyAddress.cmdString({userId})) + switch (r.type) { + case "userContactLink": + return r.contactLink.addressSettings + default: + if (r.type === "chatCmdError" && r.chatError.type === "errorStore" && r.chatError.storeError.type === "userContactLinkNotFound") { + return undefined + } + throw new ChatCommandError("error loading user address", r) + } + } + + async apiSetAddressSettings(userId: number, settings: T.AddressSettings): Promise { + const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings})) if (r.type !== "userContactLinkUpdated") { - throw new ChatCommandError("error changing user contact address mode", r) + throw new ChatCommandError("error changing user contact address settings", r) } } diff --git a/packages/simplex-chat-nodejs/src/api.ts b/packages/simplex-chat-nodejs/src/api.ts index fc090d037c..f01219ef9b 100644 --- a/packages/simplex-chat-nodejs/src/api.ts +++ b/packages/simplex-chat-nodejs/src/api.ts @@ -39,12 +39,15 @@ export interface BotAddressSettings { * @default false */ businessAddress?: boolean + + chatLayout?: T.ChatLayout } export const defaultBotAddressSettings: BotAddressSettings = { autoAccept: true, welcomeMessage: undefined, - businessAddress: false + businessAddress: false, + chatLayout: undefined } export type EventSubscriberFunc = (event: ChatEvent & {type: K}, chat: ChatApi) => void | Promise @@ -413,12 +416,13 @@ export class ChatApi { * Set bot address settings. * Network usage: interactive. */ - async apiSetAddressSettings(userId: number, {autoAccept, welcomeMessage, businessAddress}: BotAddressSettings): Promise { + async apiSetAddressSettings(userId: number, {autoAccept, welcomeMessage, businessAddress, chatLayout}: BotAddressSettings): Promise { const autoReply = welcomeMessage || defaultBotAddressSettings.welcomeMessage const settings: T.AddressSettings = { autoAccept: (autoAccept === undefined ? defaultBotAddressSettings.autoAccept : autoAccept) ? {acceptIncognito: false} : undefined, autoReply: typeof autoReply === "string" ? {type: "text", text: autoReply} : autoReply, - businessAddress: businessAddress || defaultBotAddressSettings.businessAddress || false + businessAddress: businessAddress || defaultBotAddressSettings.businessAddress || false, + chatLayout } const r = await this.sendChatCmd(CC.APISetAddressSettings.cmdString({userId, settings})) if (r.type !== "userContactLinkUpdated") { diff --git a/packages/simplex-chat-nodejs/src/bot.ts b/packages/simplex-chat-nodejs/src/bot.ts index 83037e310e..1501262680 100644 --- a/packages/simplex-chat-nodejs/src/bot.ts +++ b/packages/simplex-chat-nodejs/src/bot.ts @@ -82,7 +82,8 @@ function fullOptions(options: BotOptions): Required { opts.addressSettings = { autoAccept: opts.addressSettings.autoAccept ?? defaultOpts.addressSettings.autoAccept, welcomeMessage: typeof welcomeMessage === "string" ? {type: "text", text: welcomeMessage} : welcomeMessage, - businessAddress: opts.addressSettings.businessAddress ?? defaultOpts.addressSettings.businessAddress + businessAddress: opts.addressSettings.businessAddress ?? defaultOpts.addressSettings.businessAddress, + chatLayout: opts.addressSettings.chatLayout } return opts } diff --git a/packages/simplex-chat-nodejs/src/util.ts b/packages/simplex-chat-nodejs/src/util.ts index 6b8915e93a..f7fd83e304 100644 --- a/packages/simplex-chat-nodejs/src/util.ts +++ b/packages/simplex-chat-nodejs/src/util.ts @@ -49,7 +49,8 @@ export function botAddressSettings({addressSettings}: T.UserContactLink): BotAdd return { autoAccept: addressSettings.autoAccept ? true : false, welcomeMessage: addressSettings.autoReply, - businessAddress: addressSettings.businessAddress + businessAddress: addressSettings.businessAddress, + chatLayout: addressSettings.chatLayout } } diff --git a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts index 62ff10aab4..9b55cf4b37 100644 --- a/packages/simplex-chat-nodejs/tests/bot.unit.test.ts +++ b/packages/simplex-chat-nodejs/tests/bot.unit.test.ts @@ -127,4 +127,32 @@ describe("run", () => { await runBot("calc.simplex", {updateAddress: false}) expect(chat.apiSetUserDomain).not.toHaveBeenCalled() }) + + it("updates the address settings when they changed", async () => { + const chat = fakeChat() + await runBot(undefined, {addressSettings: {welcomeMessage: "hello"}}) + expect(chat.apiSetAddressSettings).toHaveBeenCalledWith(1, {autoAccept: true, welcomeMessage: {type: "text", text: "hello"}, businessAddress: false}) + }) + + it("updates the address settings when the chat layout changed", async () => { + const chat = fakeChat() + await runBot(undefined, {addressSettings: {chatLayout: T.ChatLayout.Terminal}}) + expect(chat.apiSetAddressSettings).toHaveBeenCalledWith(1, {autoAccept: true, businessAddress: false, chatLayout: T.ChatLayout.Terminal}) + }) + + it("does not update the address settings when they are the same", async () => { + const chat = fakeChat() + chat.apiGetUserAddress.mockResolvedValue({...address, addressSettings: {...address.addressSettings, chatLayout: T.ChatLayout.Terminal}}) + await runBot(undefined, {addressSettings: {chatLayout: T.ChatLayout.Terminal}}) + expect(chat.apiSetAddressSettings).not.toHaveBeenCalled() + }) +}) + +describe("apiSetAddressSettings", () => { + it("writes the chat layout of the bot address settings", async () => { + const sendChatCmd = jest.fn().mockResolvedValue({type: "userContactLinkUpdated"}) + await api.ChatApi.prototype.apiSetAddressSettings.call({sendChatCmd} as unknown as api.ChatApi, 1, {autoAccept: true, chatLayout: T.ChatLayout.Terminal}) + const cmd: string = sendChatCmd.mock.calls[0][0] + expect(JSON.parse(cmd.slice(cmd.indexOf("{")))).toEqual({businessAddress: false, autoAccept: {acceptIncognito: false}, chatLayout: "terminal"}) + }) }) diff --git a/packages/simplex-chat-python/src/simplex_chat/bot.py b/packages/simplex-chat-python/src/simplex_chat/bot.py index 2fa0ed39b4..2bc17f7f5d 100644 --- a/packages/simplex-chat-python/src/simplex_chat/bot.py +++ b/packages/simplex-chat-python/src/simplex_chat/bot.py @@ -96,6 +96,7 @@ class Bot(Client): update_profile: bool = True, auto_accept: bool = True, business_address: bool = False, + chat_layout: T.ChatLayout | None = None, allow_files: bool = False, log_contacts: bool = True, log_network: bool = False, @@ -115,6 +116,7 @@ class Bot(Client): self._update_address = update_address self._auto_accept = auto_accept self._business_address = business_address + self._chat_layout = chat_layout self._allow_files = allow_files # ------------------------------------------------------------------ # @@ -153,6 +155,8 @@ class Bot(Client): desired: T.AddressSettings = {"businessAddress": self._business_address} if self._auto_accept: desired["autoAccept"] = {"acceptIncognito": False} + if self._chat_layout is not None: + desired["chatLayout"] = self._chat_layout if self._welcome is not None: desired["autoReply"] = ( {"type": "text", "text": self._welcome} diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 5457f9c154..910a7ba81d 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -23,6 +23,7 @@ class AddRelayResult(TypedDict): class AddressSettings(TypedDict): businessAddress: bool autoAccept: NotRequired["AutoAccept"] + chatLayout: NotRequired["ChatLayout"] autoReply: NotRequired["MsgContent"] class AgentCryptoError_DECRYPT_AES(TypedDict): @@ -1307,6 +1308,8 @@ class ChatItemDeletion(TypedDict): deletedChatItem: "AChatItem" toChatItem: NotRequired["AChatItem"] +ChatLayout = Literal["classic", "terminal"] + class ChatListQuery_filters(TypedDict): type: Literal["filters"] favorite: bool @@ -1598,6 +1601,7 @@ class Contact(TypedDict): chatTags: list[int] # int64 chatItemTTL: NotRequired[int] # int64 uiThemes: NotRequired["UIThemeEntityOverrides"] + chatLayout: NotRequired["ChatLayout"] chatDeleted: bool customData: NotRequired[dict[str, object]] @@ -1639,6 +1643,7 @@ class ContactShortLinkData(TypedDict): profile: "Profile" message: NotRequired["MsgContent"] business: bool + chatLayout: NotRequired["ChatLayout"] localBadge: NotRequired["LocalBadge"] ContactStatus = Literal["active", "deleted", "deletedByUser", "rejected"] @@ -2171,6 +2176,7 @@ class GroupProfile(TypedDict): publicGroup: NotRequired["PublicGroupProfile"] groupPreferences: NotRequired["GroupPreferences"] memberAdmission: NotRequired["GroupMemberAdmission"] + chatLayout: NotRequired["ChatLayout"] class GroupRelay(TypedDict): groupRelayId: int # int64 @@ -3776,6 +3782,7 @@ class UIThemeEntityOverride(TypedDict): class UIThemeEntityOverrides(TypedDict): light: NotRequired["UIThemeEntityOverride"] dark: NotRequired["UIThemeEntityOverride"] + chatLayout: NotRequired["ChatLayout"] class UpdatedMessage(TypedDict): msgContent: "MsgContent" diff --git a/packages/simplex-chat-python/tests/test_client_and_waiters.py b/packages/simplex-chat-python/tests/test_client_and_waiters.py index 1ecf146ec3..c4500439a5 100644 --- a/packages/simplex-chat-python/tests/test_client_and_waiters.py +++ b/packages/simplex-chat-python/tests/test_client_and_waiters.py @@ -729,6 +729,7 @@ class _StubApi: def __init__(self) -> None: self.profiles: list[dict] = [] + self.address_settings: list[dict] = [] self.user: dict = {"userId": 1, "profile": {"displayName": "x", "fullName": ""}} self.address: dict | None = None @@ -758,8 +759,8 @@ class _StubApi: async def api_get_user_address(self, _user_id): return self.address - async def api_set_address_settings(self, _user_id, _settings): - pass + async def api_set_address_settings(self, _user_id, settings): + self.address_settings.append(settings) async def send_chat_cmd(self, _cmd): return {"type": "cmdOk"} @@ -942,6 +943,55 @@ def test_sync_profile_keeps_the_bot_address_in_the_profile(monkeypatch): assert api.profiles[0]["contactLink"] == "https://l" +def test_bot_address_settings_include_the_chat_layout(monkeypatch): + import simplex_chat.client as client_mod + + api = _StubApi() + api.address = { + "connLinkContact": {"connFullLink": "https://l"}, + "addressSettings": {"businessAddress": False, "autoAccept": {"acceptIncognito": False}}, + } + monkeypatch.setattr(client_mod, "ChatApi", _init_returning(api)) + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + update_profile=False, + chat_layout="terminal", + ) + + async def go(): + async with bot: + pass + + asyncio.run(go()) + assert api.address_settings == [ + {"businessAddress": False, "autoAccept": {"acceptIncognito": False}, "chatLayout": "terminal"} + ] + + +def test_bot_address_settings_remove_the_chat_layout_not_in_bot_settings(monkeypatch): + import simplex_chat.client as client_mod + + api = _StubApi() + api.address = { + "connLinkContact": {"connFullLink": "https://l"}, + "addressSettings": {"businessAddress": False, "chatLayout": "terminal"}, + } + monkeypatch.setattr(client_mod, "ChatApi", _init_returning(api)) + bot = Bot( + profile=BotProfile(display_name="x"), + db=SqliteDb(file_prefix="/tmp/test"), + update_profile=False, + ) + + async def go(): + async with bot: + pass + + asyncio.run(go()) + assert api.address_settings == [{"businessAddress": False, "autoAccept": {"acceptIncognito": False}}] + + def test_profile_can_be_replaced(monkeypatch): c, _ = _client_with_stub_api(monkeypatch) c.profile = Profile(display_name="other", full_name="Other") diff --git a/plans/2026-10-13-agent-profile-chat-layout.md b/plans/2026-10-13-agent-profile-chat-layout.md new file mode 100644 index 0000000000..b2169ae22b --- /dev/null +++ b/plans/2026-10-13-agent-profile-chat-layout.md @@ -0,0 +1,337 @@ +# Chat layout and agent peer type + +Date: 2026-10-13. Revised: 2026-10-21. + +The layout of a chat (`classic` or `terminal`) is computed by the apps from the user's choice for the chat, the layout of the chat, the user's choice for the profile and the app setting. LLM agents are identified by the peer type `agent` (section 11, implemented in master). + +## 1. Types + +``` +Types/UITheme.hs + ChatLayout (:47) + CLClassic "classic" + CLTerminal "terminal" + CLUnknown Text any other text, stored and sent as received + UIThemeEntityOverrides (:33) + chatLayout :: Maybe ChatLayout user's choice for the chat or the profile, both colour modes +Types.hs + Contact (:223) + chatLayout :: Maybe ChatLayout layout of the address the contact was prepared from + GroupProfile (:924) + chatLayout :: Maybe ChatLayout set by a group owner +AppSettings.hs + AppSettings (:62) + uiChatLayout :: Maybe ChatLayout app setting; default Just CLClassic +Store/Profiles.hs + AddressSettings (:515) + chatLayout :: Maybe ChatLayout set by the address owner +Protocol.hs + ContactShortLinkData (:1616) + chatLayout :: Maybe ChatLayout address link data, next to business +``` + +- Chat protocol version: unchanged. +- JSON: a string; the key is omitted for `Nothing` (`defaultJSON`). +- `ChatLayout` is defined in `Types/UITheme.hs`, imported by `Types.hs`. +- `GroupProfile.chatLayout` is sent in every JSON encoding of the group profile: + - `x.grp.info` + - `x.grp.inv` + - `x.grp.link.inv` + - `x.grp.link.reject` + - group link data (`GroupShortLinkData`) + - chat link card `MCLGroup` + +## 2. Layout of the chat + +Group: `groupProfile.chatLayout`, stored in `group_profiles.chat_layout` and updated with the profile, for every member. + +``` +writer function +group created by the user createNewGroup (Store/Groups.hs:383) +group of a received invitation createGroupInvitation_ (:481); repeated invitation: updateGroupProfile +group prepared from link data createPreparedGroup, createGroup_ (:928) +group joined via a link without prepare createGroupViaLink', createGroup_ +x.grp.info, group link data updateGroupProfile (:2729) +x.grp.link.inv of a prepared group updatePreparedUserAndHostMembers' (:821), updateGroupProfile +business member profile update updateGroupProfileFromMember (:2823): chat_layout of the stored profile kept +``` + +Contact: `contacts.chat_layout`, written once. + +``` +record chat_layout +contact prepared from an address ContactShortLinkData.chatLayout: APIPrepareContact (Library/Commands.hs:2222), createPreparedContact (Store/Direct.hs:407), createContact_ (Store/Shared.hs:419) +contact of an invitation link NULL +contact of an address, joined without prepare NULL +contact of the address owner NULL +member contact NULL +``` + +Business chat: + +``` +customer, prepared from a business address businessGroupProfile profile groupPreferences chatLayout (Library/Internal.hs:1218), chatLayout of the link data +customer, joined without prepare x.grp.link.inv of acceptBusinessJoinRequestAsync (Internal.hs:1129), AddressSettings.chatLayout (Library/Subscriber.hs:1500) +business group profile of the customer (createBusinessRequestGroup, Store/Groups.hs:2205): NULL +``` + +- A later change of the address layout applies to new business chats only. + +## 3. Sources + +### 3.1. Group profile + +``` +owner sets /_group, /_public group: profile JSON with chatLayout + /_group_profile # + /set layout # classic|terminal|default SetGroupChatLayout (Library/Commands.hs:3670): updateGroupProfileByName gName $ \p -> p {chatLayout} +member receives x.grp.info, group link data, x.grp.link.inv of a prepared group +``` + +- A layout-only change of a group profile: the item "group profile updated" is created; `CRGroupUpdated` and `CEvtGroupUpdated` are sent. +- Business chat: the profile change is stored by the business. Current-version members receive `x.grp.prefs`, older members receive `x.grp.info` (`runUpdateGroupProfile`, `Library/Commands.hs:4202-4215`). Business chat members apply only the preferences of `x.grp.info` (`xGrpInfo`, `Library/Subscriber.hs:3862`), so the layout of their chats is unchanged. + +### 3.2. Address + +``` +APISetAddressSettings (Library/Commands.hs:2532) setAddressSettings user pqRatchet_ (const settings): the whole object replaces the stored settings +SetAddressSettings (:2534), /auto_accept setAddressSettings user pqRatchet_ (\stored -> settings {chatLayout = fromMaybe stored layout change}) +setAddressSettings (:4160) stored settings read once; compared with the new settings; link data written when shortLinkDataSet; + updateUserAddressSettings (Store/Profiles.hs:640) +contactShortLinkData (:4843) ContactShortLinkData p msg business layout Nothing +``` + +- Invitation links: `chatLayout` of the link data is `Nothing`. + +### 3.3. User choices + +``` +chat uiThemes.chatLayout of the contact or group /_set theme @|# APISetChatUIThemes (Library/Commands.hs:1657) +profile uiThemes.chatLayout of the user /_set theme user APISetUserUIThemes (:1650) +app Kotlin appPrefs.chatLayout, iOS chatLayoutDefault, AppSettings.uiChatLayout +``` + +- Each theme command writes the whole record; the apps keep `chatLayout` in every theme write (section 6). + +## 4. Precedence + +Computed by the apps; `CLUnknown` counts as absent. + +``` +activeChatLayout chat choice, else defaultChatLayout +defaultChatLayout layout of the chat, else profile choice, else app setting, else classic +``` + +- Note folders have no chat values: the profile choice and the app setting apply. + +## 5. Storage + +Migration `M20261019_chat_layout`, SQLite and Postgres: + +``` +contacts.chat_layout TEXT +group_profiles.chat_layout TEXT +user_contact_links.chat_layout TEXT +``` + +- The three columns are dropped by the down migration. +- User choices are stored in `ui_themes` of `users`, `contacts` and `groups`. + +``` +column read written +contacts.chat_layout ContactRow', after chat_item_ttl (Store/Shared.hs) createContact_ (Store/Shared.hs:419) +group_profiles.chat_layout after member_admission: groupInfoQueryFields, getConnectionEntityKeys, createNewGroup, createGroupInvitation_, createGroup_, updateGroupProfile + getGroupProfileById (Store/Groups.hs:2831) +user_contact_links.chat_layout toUserContactLink (Store/Profiles.hs:531) updateUserAddressSettings (:640) +``` + +Files regenerated by the test suite: + +- `src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql`: `tests/SchemaDump.hs` +- `src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt`: `tests/SchemaDump.hs` +- `src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql`: `tests/PostgresSchemaDump.hs` + +## 6. Apps + +``` + Kotlin iOS +ChatLayout ChatLayoutMode (ChatModel.kt:2214), serializer ChatTypes.swift:262, RawRepresentable, Codable +Contact.chatLayout ChatModel.kt:1919 not decoded +GroupProfile.chatLayout ChatModel.kt:2741 ChatTypes.swift:3107 +ContactShortLinkData ChatModel.kt:2780 ChatTypes.swift:3155 +ThemeModeOverrides chatLayout, layoutOnly(), withChatLayout (Theme.kt:476) chatLayout, layoutOnly() (ThemeTypes.swift:570) +AddressSettings SimpleXAPI.kt:7785; AddressSettingsState AppAPITypes.swift:1625; AddressSettingsState + (UserAddressView.kt:699) (UserAddressView.swift:585) +AppSettings.uiChatLayout SimpleXAPI.kt:8742 AppAPITypes.swift:2304, AppSettings.swift +app setting appPrefs.chatLayout (SimpleXAPI.kt:280) chatLayoutDefault (SettingsView.swift:219) +``` + +Kotlin computation, `model/ChatModel.kt`: + +``` +ChatInfo.chatLayout (:1861) contact.chatLayout; groupInfo.groupProfile.chatLayout +ChatInfo.uiThemes (:1867) uiThemes of the contact or group +defaultChatLayout (:1873) chatLayout, else profileChatLayout +activeChatLayout (:1876) uiThemes.chatLayout, else defaultChatLayout +profileChatLayout (:2243) user.uiThemes.chatLayout, else the app setting, else Classic +``` + +Controls, Kotlin. iOS has none until it draws terminal rows. + +``` +chat theme screen, first section ChatLayoutSection (views/chat/ChatInfoView.kt:1313) + values " (chat)" when the chat layout is the default, else " (default)"; the other layout + write setChatLayout (:1382): uiThemes.withChatLayout(layout), apiSetChatUIThemes, chat info updated in the model +Settings › Appearance › Themes ChatLayoutRow (views/usersettings/Appearance.kt:948), after the destination picker + app theme destination Classic, Terminal: appPrefs.chatLayout, sent with the app settings + profile destination " (default)"; the other layout: themeUserDestination updated, saveThemeToDatabase (:818) +strings chat_layout, chat_layout_classic, chat_layout_terminal, chat_layout_chat_suffix, chat_layout_default_suffix +``` + +Theme writes that keep the layout: + +``` +"Reset to app theme", chat save (ChatInfoView.kt): layoutOnly() when both modes are removed; iOS ChatWallpaperEditorSheet.save +"Reset to app theme", profile save (Appearance.kt); iOS UserWallpaperEditorSheet.save +"Remove image", profile destination removeUserThemeModeOverrides: layoutOnly(); iOS the same +app theme destination of Appearance Kotlin: currentUser.uiThemes = uiThemes.layoutOnly(); the open chat keeps the profile layout +image copied for both modes iOS: the changed mode is set on the record +``` + +- A change of any choice recomposes the open chat; the chat list is not reloaded. +- `GroupProfile`, `ContactShortLinkData`, `AddressSettings` and the theme records are sent back to the core whole by the apps; with the new fields the layout is kept. + +## 7. API and CLI + +``` +/_set theme user APISetUserUIThemes, existing; chatLayout in the JSON +/_set theme APISetChatUIThemes, existing; chatLayout in the JSON +/set layout # classic|terminal|default SetGroupChatLayout GroupName (Maybe ChatLayout); CRGroupUpdated +/_group_profile # APIUpdateGroupProfile, existing +/_group APINewGroup, existing +/_show_address APIShowMyAddress, existing +/_address_settings APISetAddressSettings, existing; the whole settings object +/auto_accept on [business|incognito=on|off] [layout=classic|terminal|default] [text ] deprecated; SetAddressSettings +/auto_accept off [layout=classic|terminal|default] +/_prepare contact APIPrepareContact, existing; chatLayout in the link data JSON +``` + +- Constructors: `Controller.hs:596`, `:682`. Parsers: `chatCommandP` (`Library/Commands.hs:6629`, `:6665`), `chatLayoutP`, `chatLayoutP_` (`:6815-6816`), `autoAcceptP` (`:6937`). +- `SetAddressSettings {pqRatchet, settings, chatLayout :: Maybe (Maybe ChatLayout)}`: `Nothing` keeps the stored layout (`/auto_accept` without `layout=`), `Just Nothing` removes it (`layout=default`). + +## 8. Terminal (`View.hs`) + +``` +viewChatLayout (:1936) "chat layout: ", when set +viewContactInfo (:1896) chatLayout of the contact +viewAddressSettings (:1192) after the auto_accept line +viewGroupUpdated (:2117) "chat layout changed to: " or "chat layout removed" +viewGroupProfile (:2163) after the welcome message lines +/u, /i the user and chat choices: "UI themes" JSON line +``` + +## 9. Libraries and docs + +Clients read the address settings with `/_show_address` and write back the whole object with `/_address_settings`: + +``` +src/Simplex/Chat/Bot.hs initializeBotAddress': SetAddressSettings Nothing settings Nothing; the stored layout is kept +packages/simplex-chat-nodejs/src/api.ts BotAddressSettings.chatLayout (:43), default undefined; apiSetAddressSettings (:419) writes it +packages/simplex-chat-nodejs/src/util.ts botAddressSettings (:48): chatLayout of the address, compared by createOrUpdateAddress +packages/simplex-chat-client/typescript/src/client.ts apiGetAddressSettings (:116), apiSetAddressSettings (:129) + enableAddressAutoAccept (:106), disableAddressAutoAccept (:111): the stored settings, then their parameters +packages/simplex-chat-python/src/simplex_chat/bot.py Bot(chat_layout: T.ChatLayout | None = None): None removes the stored layout +bots/src/API/Docs/Commands.hs cliCommands: "SetGroupChatLayout" +docs/protocol/simplex-chat.schema.json groupProfile.optionalProperties: chatLayout {"enum": ["classic", "terminal"]} +docs/protocol/simplex-chat.md x.grp.info: chatLayout of the group profile +apps/multiplatform/spec state.md (precedence), services/theme.md (ThemeModeOverrides), client/chat-view.md (section 10) +apps/ios/spec/services/theme.md ThemeModeOverrides.chatLayout +regenerated by tests/APIDocs.hs bots/api/TYPES.md + packages/simplex-chat-client/types/typescript/src/types.ts + packages/simplex-chat-python/src/simplex_chat/types/_types.py +``` + +## 10. Compatibility and tests + +``` +group profile with chatLayout, older core the key is ignored +x.grp.info or link data of an older owner group_profiles.chat_layout is replaced with NULL; the chat follows +unsigned x.grp.info forwarded by an older relay encoded again: chatLayout is removed from the forwarded profile +signed x.grp.info forwarded as received bytes +address link data with chatLayout, older client the key is ignored; chat_layout of the contact is NULL, also after the upgrade +theme record with chatLayout, older app the key is ignored; a theme written by the older app has no chatLayout +unknown layout value stored and sent as received (CLUnknown); skipped by the precedence +directory service layout-only change of a listed group: sameProfile (Directory/Service.hs:533) is true; the listing is unchanged +remote desktop chat and profile choices: theme records of the host; app setting: of the desktop +databases that ran the earlier migration keep groups.chat_layout, groups.user_chat_layout, contacts.user_chat_layout, users.chat_layout, unused +``` + +``` +tests/ChatTests/Profiles.hs + testSetUITheme (:3810) theme with chatLayout terminal written and read for the user, a contact and a group + testGroupChatLayout (:3860) group created with terminal; member by invitation: terminal + /set layout #team classic: owner and member classic; /group_profile line + member prepared from the link: classic; /set layout #team default: owner and member NULL; the prepared member joins: NULL + testGroupLinkChatLayout (:3918) member joined by /c without prepare: terminal + testUnknownChatLayout (:3935) group created with "wide": CLUnknown "wide" stored and read + testAddressChatLayout (:3941) /_address_settings with chatLayout; prepared contact: terminal, also after the connection; contact of the address owner: NULL + /auto_accept without layout= keeps the stored layout; layout=default removes it; /_address_settings writes the whole object + testBusinessAddressChatLayout (:3985) prepared business chat: terminal; chat of the business: NULL; customer joined by the full link: terminal +tests/ChatTests/Groups.hs + testChannelChatLayout (:10555) /set layout #team terminal by the channel owner: owner, relay and subscriber joined via the short link: terminal +tests/ProtocolTests.hs (:357, :360) x.grp.info with "terminal"; "wide" decoded as CLUnknown "wide" +tests/ChatTests/Utils.hs (:734, :741) contactChatLayout: Contact.chatLayout; groupChatLayout: groupProfile.chatLayout +nodejs tests/bot.unit.test.ts apiSetAddressSettings writes chatLayout; runBot updates the address when chatLayout differs, and not when the settings are the same +python tests/test_client_and_waiters.py Bot(chat_layout="terminal") sets the layout (:946); Bot() removes the stored layout (:972) +``` + +Untested: + +- contact created without prepare +- prepared contact of an invitation link: NULL +- member contact: NULL +- business chat: later change of the address layout +- business chat: `x.info` profile update (`updateGroupProfileFromMember`) +- `x.grp.info` of an older owner +- address layout in link data after a user profile update (`updateProfile_`) +- Kotlin controls and theme writes: checked on desktop on 2026-10-21; no automated tests +- iOS theme writes: not compiled here +- Postgres down migration + +## 11. Peer type `agent` + +Implemented in master: #7738 (type and icons), #7751 (incognito profiles keep the peer type), #7752 (the MCP server sets it). Peer type and layout are independent: the layout of a chat with an agent follows section 4; `chatLayout` of the agent's address is the layout of the chat. + +``` +Types.hs (:753, :772, :778) ChatPeerType: CPTAgent, text "agent" +Kotlin ChatModel.kt (:2183-2211) ChatPeerType.Agent, "agent", in text and serializer +Kotlin ChatModel.kt (:2246) peerTypeIcon: Bot ic_cube, Agent ic_agent, otherwise ic_account_circle_filled +iOS ChatTypes.swift (:227, :297) ChatPeerType.agent, "agent"; peerTypeIcon: bot cube.fill, agent Image("agent") of SharedAssets.xcassets, otherwise person.crop.circle.fill +contact icon Contact.chatIconName (ChatModel.kt:1975), Contact.chatIcon (ChatTypes.swift:2581): peerTypeIcon +link alert icon ConnectPlan.kt:720, NewChatView.swift:1134: peerTypeIcon, business icon for business addresses +isBot peerType bot only (ChatModel.kt:1970, ChatTypes.swift:2573): an agent contact has no commands menu and commands are not sent on tap (useCommands, menuCommands) +incognito generateIncognitoProfile (Library/Internal.hs:1341): the incognito profile keeps the peer type of the user +set by APICreateActiveUser and APIUpdateProfile with peerType "agent": packages/simplex-chat-mcp/src/serve.ts:89, :189-190 +generated bots/api/TYPES.md, types.ts, _types.py; docs/protocol/simplex-chat.schema.json peerType enum +tests tests/ProtocolTests.hs: x.info with agent peer type +``` + +## Interfaces + +``` +ChatLayout Kotlin ChatLayoutMode: Classic, Terminal, Unknown(type); iOS: classic, terminal, unknown +GroupProfile.chatLayout layout of the group, for every member +Contact.chatLayout layout of the address, stored when the contact is prepared +UIThemeEntityOverrides.chatLayout user's choice for the chat or the profile +ChatInfo.activeChatLayout Kotlin: section 4 +core /set layout # classic|terminal|default + /_set theme user + /_set theme +Kotlin fun ThemeModeOverrides.layoutOnly(): ThemeModeOverrides? + fun ThemeModeOverrides?.withChatLayout(chatLayout: ChatLayoutMode?): ThemeModeOverrides? +iOS func ThemeModeOverrides.layoutOnly() -> ThemeModeOverrides? +``` + +## Depends on + +- `2026-10-13-flat-chat-layout-ui.md`: reader of `ChatInfo.activeChatLayout`; owner of drawing and of the theme screens of terminal chats. diff --git a/plans/2026-10-13-flat-chat-layout-ui.md b/plans/2026-10-13-flat-chat-layout-ui.md new file mode 100644 index 0000000000..6cd111acb9 --- /dev/null +++ b/plans/2026-10-13-flat-chat-layout-ui.md @@ -0,0 +1,263 @@ +# Terminal chat layout: UI + +Date: 2026-10-13. Revised: 2026-10-14, 2026-10-19, 2026-10-21, 2026-10-24. + +Chats with the layout `terminal` are drawn by the Android and desktop apps as full-width start-aligned rows without bubbles. iOS draws the classic layout; its layout pickers were removed until it draws terminal rows. + +D1–D33: the numbered decisions of the layout review of 2026-10-23 and 2026-10-24. + +``` +specification apps/multiplatform/spec/client/chat-view.md, sections 4 and 10 +product apps/multiplatform/product/views/chat.md, "Terminal Layout" +layout value ChatInfo.activeChatLayout (agent profile plan, section 4) +``` + +## 1. Scope + +``` +terminal main chat view of a direct chat, group, business chat or channel + member support chat view and reports view of a terminal group + note folder, when the profile choice or the app setting is terminal + theme screens of a terminal chat or profile: preview and wallpaper presets +classic chat item info +unchanged chat list preview + notification text + compose view + date separators + context menu + multi-select +``` + +## 2. Decisions + +``` +row the classic item without bubble fill and tail; start-aligned for both directions +text edge CHAT_ITEM_TEXT_PADDING (12dp) inside the item: text, quotes, headers, media, cards, events, voice, calls, reactions +sender row classic group geometry and style: name 13.5sp, role label; own name in the primary colour +avatars groups and channels only; gutter: member image width and 4dp +gutter other chats: none (user, 2026-10-25) +sender groups new sender row after an event or a sender change, as classic: time gaps and date changes do not repeat it +time, status at the end of the row, for every item with a time; baseline of the time on the last baseline of the content (D3) + message text ends with a transparent copy of the time, joined to the last word by no-break spaces + last line without room: the last word moves to the next line with the time + code block, table, rule or emoji without room: the time under them + images and videos without caption: over the media in white, as classic, when the row has no space beside them +quote Markdown quote design: bar, text at 70%, 15sp; 5.75sp above, 5sp below, over the whole quote row +headers all item headers without background; 2.5sp above, 1.5sp below; name → header level 3, header → text level 2 (D8) + icon 18dp, as classic, centred on the header text without height +media message corner clip, start at the text edge, 5.75sp above; quote thumbnails: message corner clip; roundness preference (D22) +captions laid out as the text of a close follow-up message: full row width, the same gap below the media, the time on the caption only (D30) + media, chat link cards and voice at their own width and layout, as without text +rule-only message rules as wide as the copy of the time; the time beside them unless a wider quote or header widens the rule (D31) +item clip none, so headings and emoji are drawn whole +cards link preview and chat link in the quote colour, message corner radius +message text onSurface at 87% in dark themes, both layouts +line height font size × 22/16 × f(w): message text, captions, quotes, code, headings, sender row, headers, event text, time (D1) + cards, files, voice and calls: classic until their redesign + f(w) = 1 − 0.1515 × (1 − w); w: L* contrast of the text colour divided by that of the message text, 0…1; names and the white time: f = 1 + full line height for every line, one-line texts included (D7); extra space split by ascent and descent (Proportional, Trim.None) + slope of f(w) set by the quote (D2); light theme: message 22sp, quote 19.6sp, event text and time 15.1sp; dark theme: quote 20.1sp +spacing level 1 lines of a quote +spacing level 2 lines of a message; messages of one sender within 60 s; blocks of a message against the text next to them +spacing level 3 messages of one sender 60 s or more apart; sender name → message or header; paragraph break; above a heading, also at the start of a message (D9b) +spacing level 4 last line of a message → next sender name; above and below an event, whatever the time between them (D9a) +level targets measured from a baseline or a block edge to the top of the tallest letter or a block edge + level 2: the line gap; level 3: 15.1sp; level 4: 20.8sp (D5) + levels and heading sizes built by multiplying base values upward + accepted: desktop message → event 19px (D9c) + all gaps: sp constants at the top of TerminalItemView.kt (D10) + one gap above a row, terminalRowGap: 4.75sp after a large gap, above a sender row, next to an event, above text at the item top starting with a heading; otherwise 0 + 0 below a date separator and the chat banner; 4.75sp above a date separator + sender row 6.5sp above, 5.5sp below; events 8.25sp above, 1.5sp below + text top padding: 0 below the sender row, a header, the quote, voice or a file, 5sp below media and cards; bottom 0 + text starting with a heading below a quote, header or block: 4.75sp more above, terminalHeadingGap + item ending with a block (image or video without caption, chat link without text): 5sp below + code cards, table cards and rules: 5.75sp above, 5sp below all but the last block of a message without a meta line + that last block with the time beside it: 5sp below, by messageLastBlock() and TerminalMetaLayout; the time under it: 6sp below its edge + paragraph break and a heading inside a message: 4.75sp + below a heading: 2.375sp (half of 4.75sp), so a heading is nearer to its own text (D21) + headings: body × 1.72, 1.3125, 1 for levels 1, 2, 3 (D15, revised 2026-10-25); capitals placed as body capitals + files: 2sp above; voice drawn by FramedItemView (caption, quote or header): as voice alone, 4sp above, 6sp below + emoji-only items, also under a quote or header: 4.25sp above, 1.5sp below; not stretched to the item width + feature and preference icons: centred on the event text, no height; merged feature icons: one event line high, centred + classic: call item bottom, voice drawn without FramedItemView, the two rows of the forwarded-from header +meta width content measured without the meta width, except text with the transparent copy, files, emoji, events and captionless media with overlayMeta +meta placement never above the content; the row at least as tall as the time + after a rule with room beside it: the time on the baseline of the last text line above it (D24); otherwise under the rule +meta copy FontWeight.Normal; icon spaces at the body1 size in dp; time and timer text at 12sp + conditions of CIMetaText, except that the timer is reserved for every timed message; file encryption and signature included; tall status icon condition shared with CIMetaView + one more space after the 17dp icons: sent and received status, via proxy (D29) +event text layout direction in both layouts (unspecified text direction); separator and copy passed apart by ChatEventWithMeta, each view puts the separator where classic has two spaces (D28); no line limit (D23) +meta copy mark U+200E (left-to-right layout) or U+200F (right-to-left) before the copy, so the copy stays at the line end after text in the other direction + U+2060 (word joiner) before the mark, so the copy stays with the last word after the spaces of an idle live message +event time joined to the last word by no-break spaces +go-to button search results: after the item at the row end, centred on it, as classic received items; all times of the results move left together + forwarded items: at the row end, centred on the item above the time line (one-line message: the forwarded line); + text narrowed by the arrow, the time keeps its place (user, 2026-10-25) +row tint hover and press: indication over the whole row; selected: primary 10%; highlighted: primary 14% +colours app, user and chat themes, as classic +wallpaper background colour of the wallpaper, preset or custom; image omitted +theme screens preview as terminal rows ("alice", then "bob" in the own colour), one height for both layouts + wallpaper presets as colours + custom image tile, image scale, scale slider and wallpaper accent hidden +theme export colours and wallpaper; the layout is not exported +classic unchanged apart from the message text colour: no copy of the time in bubbles (decided 2026-10-23), no spacing or line-height changes +build kotlin.daemon.jvmargs=-Xmx4096m in apps/multiplatform/gradle.properties (D33) +rejected hover action bar + reaction pills + reply line + date pill + compose field shape + hidden or moved timestamps + gutter time + colour-mode palette +``` + +## 3. Kotlin + +Paths are relative to `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/`. + +``` +views/chat/ChatView.kt + ChatView (:268-:273) terminal = activeChatLayout == Terminal; LocalTerminalLayout provided + ChatLayout composable (:1010) chatViewBackgroundModifier(drawWallpaperImage = !LocalTerminalLayout.current) + ChatItemsList (:2460-:2466) showAvatar = terminalItemShowsSender(item, older) when terminal + terminalGap = terminalRowGap(item, older, separated, largeGap, showAvatar) + separated = terminalRowsSeparated(older row) (:3817): the banner, or rowItemSeparation of the older row with a date + rowItemSeparation(row) (:3812): one rule for the date separator, drawn and in the gap (D32) + local ChatItemView (:2035) terminal branch: + ChatItemBox(top, bottom) with the row tint and terminalRowIndication; top 0 + bottom TERMINAL_LEVEL_STEP above a date separator; 8dp below the newest row; otherwise 0 + SelectedListItem when selecting + TerminalChatItemRow(chatItemSender or null, terminalGutter, terminalGap, terminalStartOffset and swipeableModifier) + ChatItemViewShortHand with the row interaction source + memberNames public, used by chatItemSender + classic sender rows and shouldShowAvatar as on master +views/chat/ChatItemsMerger.kt MergedItem.revealed: true for Single, the flag of Grouped +views/chat/item/TerminalItemView.kt LocalTerminalLayout, gap constants, terminalTextStyle, terminalItemShowsSender, sameSender, terminalRowGap, terminalHeadingGap + row interaction, messageLastLine, messageLastBlock, inlineIcon, ItemWithMeta (endButton centred above the time line) + ChatItemSender, chatItemSender, SenderNameAndRole, TerminalChatItemRow +views/chat/item/ChatItemView.kt alignEnd = sent && !terminal; item click without indication; reactions at the text edge + GoToItemButton on the side given by alignEnd; terminal: after the item in search results + and for marked deleted items, otherwise endButton of FramedItemView + chatItemTailEnabled false; clipChatItem skipped + hasQuoteOrHeader: the choice of EmojiItemView or CIVoiceView, and the heading gaps +views/chat/item/FramedItemView.kt transparent background; headers and quote without background; quoteBar; mediaModifier; cardModifier; thumbnailClip; endButton to ItemWithMeta + CIMarkdownText: parameter terminalTop plus terminalHeadingGap, no bottom padding, terminal text style, file encryption and signature for the copy + metaReserved: passed to ItemWithMeta, bottom gap without text; captions by the classic conditions; clipChatItem skipped + emoji: TERMINAL_EMOJI_* paddings, not stretched; quote row: TERMINAL_BLOCK_* paddings, icons without top padding + CHAT_ITEM_TEXT_PADDING (text edge); classic vertical text padding named CHAT_ITEM_TEXT_VERTICAL_PADDING + PriorityLayout(limitToPriorityWidth = !terminal || captionlessMedia), placeRelative when not limited; link preview caption without width limit; chat link card at its own width +views/chat/item/MarkdownBlocks.kt Modifier.quoteBar, shared with Markdown quotes; Markdown quote text with terminalTextStyle and the colour rule + heading scale TERMINAL_HEADING_RATIO, Modifier.headingBox with the px ratio of the font sizes + spacers: TERMINAL_HEADING_BOTTOM_GAP after a heading, TERMINAL_LEVEL_STEP at a paragraph break or above a heading + card margins of code, tables and rules; messageLastBlock() on the last block without a meta line (marksLastBlock) + ruleMinWidth: rules of a rule-only message as wide as the copy of the time (D31) + code: terminalTextStyle at the code size (classic: line height × 0.9); metaLine null in terminal rows unless live +views/chat/item/CIFileView.kt row padding TERMINAL_FILE_TOP_GAP, icon without top padding; the reserve in the size line kept +views/chat/item/CIVoiceView.kt voice drawn by FramedItemView: the layout of voice alone without its time; otherwise classic, start at the text edge +views/chat/item/EmojiItemView.kt TERMINAL_EMOJI_* paddings at the text edge, messageLastLine() +views/chat/item/TextItemView.kt messageTextColor(); terminalReserveSpaceForMeta at the end of message text + meta line of a live message: encryption, signature and the outer direction, as the blocks + ruleMinWidth measured from the copy of a rule-only message +views/chat/item/CIMetaView.kt reserveSpaceForMeta with span parameters: the classic string and terminalReserveSpaceForMeta + terminalReserveSpaceForMeta: word joiner and direction mark, icon spaces in dp; tallStatusIcon shared with CIMetaText + time style with lineHeightColor +views/chat/item/CIChatFeatureView.kt merged feature icons: one event line high, centred in terminal rows +views/chat/item/CIEventView.kt chatEventPadding(), chatEventTextStyle(), ChatEventWithMeta (separator, and time or copy, apart; classic as before, D28) + CIEventView without a line limit in terminal rows (D23) +item views with ItemWithMeta FramedItemView, EmojiItemView, CIVoiceView, CICallItemView, DeletedItemView, MarkedDeletedItemView + IntegrityErrorItemView, CIRcvDecryptionError, CIGroupInvitationView +item views with ChatEventWithMeta CIChatFeatureView, CIFeaturePreferenceView, CIMemberCreatedContactView, event items of ChatItemView +views/chat/ChatInfoView.kt ChatLayoutSection, setChatLayout; LocalTerminalLayout of the chat theme screen +views/helpers/ThemeModeEditor.kt layoutSection slot of ChatWallpaperEditor; terminal passed to editColor +views/usersettings/Appearance.kt ChatLayoutRow; PreviewInEitherLayout; terminal preview rows with terminalRowGap + presets as colours; custom image tile, image scale, scale slider and accent hidden + LocalTerminalLayout by profileLayoutIsTerminal +``` + +## 4. iOS + +``` +drawing classic +controls none; the pickers return with terminal rows +theme writes chatLayout kept (agent profile plan, section 6) +terminal rows in this pull request, after the Kotlin part is approved and reviewed (D11) +``` + +## 5. Tests + +`apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/TerminalItemViewTest.kt`, `terminalItemShowsSender`: + +- oldest item: shown +- same sender: hidden, also an hour later +- another group member; a direction change: shown +- after an event item: shown +- date change: hidden +- channel post after a member message: shown +- owner switching between posting as the channel and under the own name: shown +- integrity error: grouped with its sender +- after a deleted message of the same sender: hidden +- note folder items: grouped +- event item: no sender row + +`terminalRowGap` and `terminalHeadingGap`: + +- oldest item; close messages: 0 +- large gap; sender row; next to an event: `TERMINAL_LEVEL_STEP` +- heading at the item top: row gap, no heading gap +- heading below a quote or a forwarded header: heading gap, no row gap +- heading not first; text without heading: neither +- separated rows: 0 +- heading below a deletion, the live state, a file or media: heading gap; in unknown content without a file: row gap + +`terminalRowsSeparated`: a date change of a single or revealed row, the chat banner: separated; a collapsed group, the same date, no older row: not separated + +`apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaCopyTest.kt`: + +- classic reserve strings: unchanged, via proxy included +- copy text: word joiner and direction mark, the classic spaces, one more space after the tall status and via-proxy icons +- icon spaces: one width at font scales 0.7, 0.75, 1 and 1.3; the time scaled with the font; the marks without width +- copy after spaces, as in an idle live message: on the line of the last word +- copy after words in the other direction on a wrapped last line: at the line end, in both directions +- a message of a rule only: the rule as wide as the copy (D31) + +`apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/TerminalMetaLayoutTest.kt`, `ItemWithMeta` in terminal rows: + +- time baseline on the last baseline of the content +- content narrowed by the time box without `metaReserved` +- last line with room: time beside it; without room: `TERMINAL_META_GAP` under the content, the row taller +- last block at the bottom with the time beside it: `TERMINAL_BLOCK_BOTTOM_GAP` added +- content without a baseline: time `TERMINAL_META_GAP` above the content bottom +- content shorter than the time: time at the top, the row as tall as the time +- overlay meta when content and time exceed the row: at the bottom end of the content; under a wider quote, on the media corner +- overlay meta on content shorter than the time: at the top of the content, the row as tall as the time +- end button: at the row end, centred above the time box; content narrowed by it; the time at the row end +- last line with room, right-to-left + +`apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/PriorityLayoutTest.kt`: terminal elements at their own width, classic elements at the media width; terminal placement at the start edge right-to-left, classic unmirrored. + +`apps/multiplatform/common/src/desktopTest/kotlin/chat/simplex/app/ChatEventTextTest.kt`: classic separator and event text as before (D28); terminal separator of transparent no-break spaces before the copy. + +Checked on desktop on 2026-10-21: + +1. Direct chat, group and channel: rows, sender rows, avatars in groups, time at the row end for every item kind. +2. Spacing: close messages at the line pitch (22px). +3. Search and forwarded items: go-to button in the gutter. +4. Theme screens of a terminal chat and profile: preview, presets, hidden controls. +5. Layout choices: chat, profile and app; the open chat is redrawn at once; "Reset to app theme" keeps the chat layout; the choices are kept after a restart. + +## Later + +``` +line heights app-wide, in the redesign (D6) +items link preview, chat link, files, voice and calls on the levels and line heights, in the redesign (D8) +style guide one document for both apps with platform differences, after merge (D13) +``` + +## Depends on + +``` +agent profile plan ChatLayout; ChatInfo.activeChatLayout; storage of the choices +block Markdown rendering MarkdownBlocks; Modifier.quoteBar shared with Markdown quotes +``` diff --git a/plans/2026-10-13-message-parts.md b/plans/2026-10-13-message-parts.md new file mode 100644 index 0000000000..ac6befddc3 --- /dev/null +++ b/plans/2026-10-13-message-parts.md @@ -0,0 +1,400 @@ +# Message parts + +A text longer than one chat message is sent as several parts and shown as one chat item; for an update of the text only the changed parts are sent. + +References are to `/code/simplex-chat-4`, master. Paths without a directory are in `src/Simplex/Chat/`. + +## 1. Limit of one message + +15602 bytes is the limit of every encoded chat message in every chat, and of the head and of every part of a split text. With PQ a body over 13380 bytes is delivered compressed. +``` +creation encodeChatMessage maxEncodedMsgLength (Library/Internal.hs:2353); over 15602: SELargeMsg (Store/Messages.hs:242) +batching batchMessages maxEncodedMsgLength (Library/Internal.hs:2493) +delivery connection without PQ: a body of up to 15602, as encoded (Library/Internal.hs:2578) + connection with PQ: a body over 13380 is compressed; still over 13380: CEException "large compressed body" (:2495-2501) + a contact connection has PQ after the first received message (:1842-1845) +relay forwarded batch of up to 15763 (Library/Subscriber.hs:4234, Messages/Batch.hs:90) +receiver up to 65536 bytes after decompression (Protocol.hs:944, 1028) +``` + +## 2. Protocol and types + +`Protocol.hs`: +``` +msgPartsVersion next free chat version (23); currentChatVersion (:96) is raised +maxMsgParts = 8 +maxQuotedTextLength = 2000 -- escaped bytes (3.1) +``` + +The head is the `x.msg.new` or `x.msg.update` message of an item. Part 1 is its `content.text`. The text of an item with parts is the field `text` of its `MsgContent`: the received parts, concatenated in order. + +New field `parts :: Maybe Int`, the number of parts; absent = 1: +``` +MsgContainer (:691, mcSimple :718) with silent :: Maybe Bool of 2026-10-13-silent-messages.md + and new partsId :: Maybe SharedMsgId: random ID of the parts of a new item +XMsgUpdate (:463, parser :1421, encoder :1514) last field; positional uses: + Library/Commands.hs:811, 845, 1474 + Library/Subscriber.hs:589, 769, 1088, 1339, 4048 +``` + +New event `x.msg.part`, tag `XMsgPart_`: +``` +XMsgPart + msgId :: SharedMsgId -- part of a new item: partsId of its head; part of an update, live part: ID of the item + partNo :: Int -- 2 .. maxMsgParts + text :: Text + live :: Maybe Bool -- applied on receipt to a live item + scope :: Maybe MsgScope + asGroup :: Maybe Bool + +sites ChatMsgEvent :460 + CMEventTag :1077 + StrEncoding (CMEventTag e) :1139 + StrEncoding ACMEventTag :1200 + toCMEventTag :1263 + appJsonToCM :1407 + chatToAppMessage :1500 +properties hasNotification XMsgPart_ = False (:1338) + hasDeliveryReceipt XMsgPart_ = False (:1349) + isForwardedGroupMsg XMsgPart {} = True (:528) + signableContent XMsgPart_ = False (:1374) + assertDirectAllowed: XMsgPart_ -> False (Library/Commands.hs:5826) +``` + +Messages of a change, in order: +``` +new item parts 2..n; x.msg.new +edit changed parts; x.msg.update +live update, head changed parts; x.msg.update with live = true +live update, other changed parts with live = true +end of live item changed parts; x.msg.update +``` + +The head of a live update is sent in each of these cases: +- the content with part 1 as its text differs from that of the item +- the mentions differ +- the number of parts is lower +- all parts are unchanged + +`Messages.hs`, `CIMeta` (`:509`) and `mkCIMeta` (`:535`): new `partsMissing :: BoolDef`, set when a part of 2..n is not received; next to `silent` of the silent messages plan. + +## 3. Sender + +### 3.1. Split and update + +New function `splitMsgParts` in `Library/Internal.hs`: +``` +input chat version range; head event with the whole text; parts of the item, for an update +output head event with part 1 and parts; texts of parts 2..n; numbers of the changed parts +encoded encodeChatMessage (Protocol.hs:986) with an item ID of the generated length +escaped t length of t as a JSON string of aeson, without the quotes +headBudget maxEncodedMsgLength − length of the encoded head with text "", parts = maxMsgParts and, in x.msg.new, partsId +partBudget maxEncodedMsgLength − length of the encoded x.msg.part with text "" and partNo = maxMsgParts +cut b t longest prefix of t with escaped ≤ b, in one pass over the characters +part 1 cut headBudget text +part k ≥ 2 cut partBudget (rest of the text) + +update item newText: + old = text of the item cut by item_parts -- one part for NULL + k = largest i < length old such that old[1..i] is a prefix of newText and, for i ≥ 1, the new head with old[1] fits + new = old[1..k] ++ parts from k + 1 of (newText without old[1..k]) + changed = [i | i <- [2 .. length new], i > length old || new[i] /= old[i]] +``` +- The text is the field `text` of `MsgContent`. `MCUnknown` is sent as one message. +- A text is split when the head with the whole text is over the limit. + +Errors: +``` +SELargeMsg headBudget is negative + the text has more than maxMsgParts parts + MCUnknown over the limit + direct chat: the peer version is below msgPartsVersion +CECommandError "long text: signed message" the message is signed (signMsgs, Library/Commands.hs:4937; reuseSign, :846) +CECommandError "long text: relay " channel: relay of the recipients is below msgPartsVersion +``` + +### 3.2. Sending + +Text is split in the three places of this section. Every other sender of content sends one message, as now. + +New item, `sendContactContentMessages` (`Library/Commands.hs:4877-4878`) and `sendGroupContentMessages_` (`:4961-4962`); the items of a command are sent in order: +1. Containers are built as now (`prepareMsgs` `:4898`, `prepareGroupMsg` `Library/Internal.hs:212`) and passed to `splitMsgParts`. `partsId` of an item with parts is generated by `getSharedMsgId` (`Library/Commands.hs:5159`). +2. Consecutive items without parts are sent by one call, as now. +3. Item with parts: its parts are sent by one call of `sendDirectContactMessages` (`Library/Internal.hs:2244`) or `sendGroupMessages` (`:2699`), then its head by the existing call, before the messages of the next item. +4. The results of the calls are joined in item order; in a group the `GroupSndResult` values are merged. +5. An error of a part is the error of its item (`prepareSndItemsData`, `Library/Commands.hs:5061`): its head is skipped, and the part messages created for it are deleted (new `deleteSndMessages`). + +Update, `APIUpdateChatItem` (`Library/Commands.hs:811-818`, `:845-854`): changed parts are sent by `sendDirectContactMessages` or `sendGroupMessages`; the head, when sent, by the existing calls (`:812`, `:847`). + +All three places: +- Recipients of parts are the recipients of the head. +- SMP flag of a call (`Library/Internal.hs:2248`, `:2748`), with `eventHasNotification` of the silent messages plan: +``` +parts only False +head x.msg.new True; False with silent +head x.msg.update False +``` +- `item_parts` and the links of the part messages are written by `setChatItemParts` in the transaction of the item: +``` +NewSndChatItemData (Library/Internal.hs:2983) new fields itemParts, partMsgIds; silent of the silent messages plan +saveSndChatItems.createItem (:3011) setChatItemParts +APIUpdateChatItem (Library/Commands.hs:813, :848) setChatItemParts +``` +- A version is recorded when a head is sent (`addInitialAndNewCIVersions`, `Library/Commands.hs:816, 851`). +- Item status is set from the delivery events of head messages: part messages are excluded in `getChatItemIdsByAgentMsgId` (`Store/Messages.hs:2522`), the query of every status update. +- Quote: the quoted text is cut to `maxQuotedTextLength` with `cut`, after `updatedMentionNames` (`Library/Internal.hs:225`, `Library/Commands.hs:4911`). `CIQuote.formattedText` is parsed from the cut text. The quote of a report is cut in the same way. + +## 4. Receiver + +`Library/Subscriber.hs`. Dispatch of `x.msg.part`; when a condition is false, the part is dropped with `messageError`: +``` +direct processEvent (:586) handler +group processEvent (:1080) inside checkSendAsGroup and memberCanSend with p.scope, as XMsgUpdate (:1088-1091) +processForwardedMsg (:4041) inside memberCanSend, as XMsgUpdate (:4048-4049) +``` + +### 4.1. Pseudocode + +Key of a pending part: chat (connection of the contact, or group), author (member of the message; none for a forwarded channel message), p.msgId, partNo. Key of a head, `key h`: `partsId` for `x.msg.new`, the item ID for `x.msg.update`. +``` +on x.msg.part p: + drop unless 2 ≤ p.partNo ≤ maxMsgParts and byte length of p.text ≤ maxEncodedMsgLength + if p.live == Just True: + item_ <- find item (chat, author, p.msgId, p.asGroup) -- as x.msg.update, :2091, :2342-2347 + case item_ of + Just item | item.live && allowed item p -> setPart item p + _ -> drop + else: + save pending p -- the pending part with the same key is deleted + pending parts of the chat and author over 4 × maxMsgParts: the oldest are deleted + +on head h with parts = n > 1: -- x.msg.new, x.msg.update + drop unless n ≤ maxMsgParts, h is unsigned, byte length of h.text ≤ maxEncodedMsgLength + ps = pending parts (chat, author, key h) with partNo ≤ n + parts = [ps[k] <|> part k of the item | k <- [2..n]] -- Nothing: not received + text = h.text ++ concat (catMaybes parts) + msgParts = (lengths of Just h.text : parts, messages of ps) + existing handler of h, with text as the content text and msgParts in RcvMessage + delete pending parts (chat, author, key h) that are left + +on head h without parts: + existing handler of h; item_parts of an updated item is set to NULL + +setPart item p: + item_parts is extended to p.partNo with parts that are not received + part p.partNo = p.text + content text = received parts concatenated + emit chatItemUpdated + +allowed item p: + the item content is CIRcvMsgContent + msgVerified of the item is other than MVSigned + group: p is sent by the author of the item; channel item: by an owner (as :2350-2356) + group: prohibitedSimplexLinks is False for the resulting text (as :2299) +``` + +### 4.2. Functions and transactions + +`RcvMessage` (`Messages.hs:1179`): new field `msgParts`; `Nothing` in `createNewRcvMessage` (`Store/Messages.hs:344`); set at dispatch of a head with parts. +``` +save pending saveRcvMsgPart, one transaction: replaced part message deleted; row inserted; limit applied +setPart one transaction: content, text, item_parts (getChatItemParts, setChatItemParts), link of the part message +head, pending parts getRcvMsgParts, before the handler +head, item created saveRcvChatItem' (Library/Internal.hs:3034): setChatItemParts with msg.msgParts +head, item updated transactions at :2082, :2098, :2328, :2373: setChatItemParts with msg.msgParts +``` +- `setChatItemParts` is called when the stored content is the message content. In that transaction `item_parts` is written, the merged part messages are inserted in `chat_item_messages`, and their pending rows are deleted. +- A pending part is deleted by deletion of its message; the row is deleted by cascade. +- The existing handlers of the head events are called with the whole text. Content checks, mentions, moderation and blocking are applied to it, as now. +- A version is recorded when a head is applied (`:2100`, `:2375`), as now. Only the content text and `item_parts` are changed by `setPart`. +- `partsMissing` of an item: `item_parts` has a part that is not received. +- Messages of one chat are processed one at a time, under the lock of the send commands (`:146`; `Library/Commands.hs:752, 758, 799, 824`). +- `cleanupManager` (`Library/Commands.hs:5909`), new step: pending parts older than one day are deleted (`deleteOldRcvMsgParts`). + +`Markdown.hs`, `hasObfuscatedSimplexLink` (`:195`): applied to windows of `2 × maxEncodedMsgLength` characters with a step of `maxEncodedMsgLength`. + +## 5. Storage + +Migration `M20261013_message_parts` (working name), SQLite and Postgres, registered in both `Migrations.hs` and `simplex-chat.cabal`. The migration is named with the date of its merge, and its name is ordered after every released migration name, the migration of the silent messages plan included (`Store/SQLite/Migrations.hs:363`, `Store/Postgres/Migrations.hs:117`; simplexmq `Agent/Store/Migrations.hs:32-34`). Down migration: the column, the index and the table are dropped. +``` +chat_items + item_parts TEXT -- JSON list of part lengths in characters; null: not received; NULL: one part + +rcv_msg_parts -- pending parts + message_id -- PRIMARY KEY; FK messages ON DELETE CASCADE + shared_msg_id -- ID of the item + part_no + +idx_rcv_msg_parts_shared_msg_id + shared_msg_id +``` +- Chat, author and time of a pending part: `connection_id`, `group_id`, `author_group_member_id`, `created_at` of its message (`Store/Messages.hs:336-342`). +- `item_parts` is read with the item, next to `item_silent` of the silent messages plan; the combined change of `getGroupChatItem` is listed in "Depends on" of the agent profile plan: +``` +ChatItemModeRow Store/Messages.hs:2283 +item queries :2711, :3105, :3218 +item row conversions :1096, :2308, :2369 +``` +- `item_parts` and `item_live` are set to NULL in the UPDATE of `updateGroupChatItemModerated` (`:2859`) and of `updateGroupCIBlockedByAdmin` (`:2931`). +- `chat_schema.sql` and `chat_query_plans.txt` are regenerated by the schema and query plan tests. + +## 6. Groups, channels, relays + +Delivery tasks of a forwarding member (relay, host), `Library/Subscriber.hs`; the result of `processEvent` (`:1069`) is a list of tasks (`:1058`, `:1133-1144`): +``` +live part applied one task with the context of the item (infoToDeliveryContext, as :2384) +pending part none +head accepted tasks of the merged part messages in part order, then the task of the head, with the context of the head +part or head dropped none +``` +- `channelContentChanged` is signalled for an applied live part (`Library/Subscriber.hs:1136`). +- Channel: a text is split when every relay of the recipients (`getGroupRecipients`, `Library/Internal.hs:1939`) supports `msgPartsVersion`. +- `batchDeliveryTasks1` (`Messages/Batch.hs:84`): the batch is closed at the first task that does not fit it (`:95`); task order is kept. A task over the batch limit is reported as large (`:90`), as now. + +History, `sendHistory` (`Library/Internal.hs:1414`): +``` +getGroupHistoryItems (Store/Messages.hs:3737) + result item_parts of each item + omitted items with a part that is not received + limit the newest items with at most 100 parts in total; an item without parts counts as one + +processContentItem (Library/Internal.hs:1528; item_parts is passed by itemForwardMsgs, :1450), item with parts + text the stored text; updatedMentionNames (:1536) is skipped + head prepareGroupMsg (:1551) with part 1 as the content text, live of the item + and silent of the item (silent messages plan); parts = n and a new partsId are set in the returned container + head size over the limit: encoded without the quote; still over: the item is omitted + parts XMsgPart 2..n with that partsId, cut by item_parts; VMUnsigned, envelope msgId = Nothing, as the XMsgFileDescr events (:1559) + order parts, head, file descriptions +``` + +## 7. API and CLI + +- Text of up to `maxMsgParts` parts is accepted by every command that sends or updates content through the places of 3.2. +- `CIMeta.partsMissing` is added to every chat item. Commands, the other types and events are unchanged. An item has the whole text in every response and event. +- Receiver events: `newChatItems` once for a new item; `chatItemUpdated` for each applied head and each applied live part. +- An item with `partsMissing` is skipped in forwarding, as a deleted item (`forwardMsgContent`, `Library/Commands.hs:5104`). It is quoted as any item. + +## 8. Kotlin, iOS, terminal + +Kotlin paths are in `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/`, iOS paths in `apps/ios/`. +``` +Kotlin model/ChatModel.kt:3871 CIMeta: val partsMissing: Boolean = false + views/chat/item/CIMetaView.kt:83 (meta text), :155 (reserved space): for partsMissing, StatusIconText(ic_warning, Color.Red) after the edited mark +iOS SimpleXChat/ChatTypes.swift:4229 CIMeta: partsMissing: Bool, false when absent + Shared/Views/Chat/ChatItem/CIMetaView.swift:127: for partsMissing, statusIconText("exclamationmark.triangle", red) after the edited mark + NSE: unchanged; parts and the head are counted in the 4 messages of a push (SimpleX NSE/NotificationService.swift:256, 545-549) +terminal View.hs:2362 viewReceivedMessage_, suffixes in appendLast, in this order: msgVerifiedStr; + " (silent)" of the silent messages plan; " (incomplete)" for an item with partsMissing +``` + +## 9. Libraries and docs + +- `CIMeta.partsMissing`: the output of the docs generator is regenerated (`bots/api/TYPES.md` and the TypeScript and Python type files of `packages/`). +- `packages/simplex-chat-nodejs`: the whole text is passed to the core by `apiSendMessages` and `apiUpdateChatItem` (`src/api.ts:443, 482`). Added to `tests/api.test.ts`: one item for a text of several parts. +- `docs/protocol/simplex-chat.md`: `parts` and `partsId` in `x.msg.new` (`:168`); `parts` in `x.msg.update` (`:185`); new section `x.msg.part`. +- `docs/protocol/simplex-chat.schema.json`: `parts` and `partsId` in `msgContainer` (`:113`); `parts` in `x.msg.update` (`:470`); new `x.msg.part`. + +## 10. Compatibility + +``` +contact below msgPartsVersion sender: SELargeMsg, as now; the version of an upgraded contact is recorded from its next message (Library/Internal.hs:1832-1840) +group member or subscriber below receives the parts and the head; part 1 is shown; one error event per part (Library/Subscriber.hs:1132, 4064) +member reached through a host below the head is encoded again without parts (Protocol.hs:1591); part 1 is shown; parts are dropped by the host +relay below sender: CECommandError with the relay name; its version is recorded from each of its messages to the owner (Library/Internal.hs:1326, 2932) +recipient queue item of n parts: n of its 128 messages (simplexmq Server/Env/STM.hs:273); a full queue is handled as now (Library/Subscriber.hs:1755-1762, 4407) +remote desktop partsMissing = false from an older mobile; bodies up to 64 MB and 512 MB (Remote/Protocol.hs:277-281) +bots one newChatItems event with the whole text; CIMeta.partsMissing is added +iOS push new item of more than 4 parts: the default notification text; the item is created when the app receives the head +``` + +## 11. Tests + +Unit tests: +- `tests/ProtocolTests.hs`: `x.msg.part`, and `parts` in `x.msg.new` and `x.msg.update`, are encoded and decoded +- `tests/MessageBatching.hs`: `batchDeliveryTasks1` with a task over the remaining space of the batch; task order is kept +- `tests/MarkdownTests.hs`: `hasObfuscatedSimplexLink` with repeated `simplex:/contact#/?` of `maxMsgParts × maxEncodedMsgLength` characters + +`tests/ChatTests/Direct.hs`: +- a 40 KB text: one item in one `newChatItems` event; the rows of `messages` are deleted with the item +- a silent item of 3 parts: `eventHasNotification` is False for every message; the item of the recipient is silent; `item_silent` is unchanged by a later live part +- uniformly random printable ASCII of 40 KB with PQ on is delivered; on failure the limit of a part on a PQ connection is `maxEncodedMsgLengthPQ` +- 4-byte characters and control characters at each part boundary +- a live text extended over two part boundaries; an update of one part is one message +- a live update with a changed part 1: one `chatItemUpdated` with the new text +- reply to a 40 KB item: the quote is 2000 escaped bytes +- a peer at the previous version: `SELargeMsg` +- a text of more than `maxMsgParts` parts: `SELargeMsg` +- a head or a part above the byte limit from a modified sender is dropped +- 6 items of 8 parts forwarded by one command are received complete + +`tests/ChatTests/Groups.hs`: +- a long text forwarded by the host to a member without a connection to the sender +- a member whose recorded version is below the real one receives the whole text +- a live item of several parts +- group with the `signMessages` preference: `CECommandError`; a part for a verified item is dropped +- a part from an observer and a part from a member other than the author are dropped +- reply to a long item whose mentioned member was renamed +- a head received with one of its parts lost: `partsMissing`; the item is skipped by forwarding and history +- a message of another member with the `partsId` of pending parts as its envelope `msgId`: the item is created with all parts + +`tests/ChatTests/ChatRelays.hs`: +- two relays: a long post is received in full; a new subscriber receives it in history +- a live post of several parts +- a part with `asGroup` from a subscriber is dropped by the relay +- 6 posts of 8 parts forwarded by one command are received complete +- a relay at the previous version: `CECommandError` with its name + +Manual, on Android, desktop and iOS: an `agent` contact with `chatLayout: flat` sends silent messages with `live`, then a completion message of several parts with a heading, a list, a code block and a table. Expected: one notification with the completion text; the row is a teaser; the blocks are shown by the viewer. + +Released before this plan: +- the linear parser of the block-markdown plan (section "Limits and cost") +- the read-more plan, 2.7 of the flat layout plan and part 2 of the rendering plan + +Measured before release, on a mid-range phone: the time of one part applied to an item of `maxMsgParts` parts is under 50 ms in the core and under 100 ms of app redraw; above it `maxMsgParts` is lowered. + +## Interfaces + +``` +protocol msgPartsVersion + x.msg.part, XMsgPart + MsgContainer.parts + MsgContainer.partsId + XMsgUpdate.parts + maxMsgParts + maxQuotedTextLength +core splitMsgParts +API, apps CIMeta.partsMissing + text of a sent or received item: at most maxMsgParts × maxEncodedMsgLength = 124,816 bytes + note folder items are created without a length check (Library/Commands.hs:5109-5122) +storage chat_items.item_parts + rcv_msg_parts + migration M20261013_message_parts (working name) +``` + +## Depends on + +`2026-10-13-silent-messages.md`, combined result: +``` +MsgContainer silent, parts, partsId; Nothing in mcSimple +CIMeta, mkCIMeta silent, partsMissing +ChatItemModeRow item_silent, item_parts +NewSndChatItemData silent, itemParts, partMsgIds +prepareGroupMsg one new parameter, silent (silent messages plan); part 1 and parts are set after it by splitMsgParts +sendHistory prepareGroupMsg (Library/Internal.hs:1551) with silent and live of the item and part 1 as the text +SMP flag any eventHasNotification of the events of a call; the table of 3.2 +receiver item_silent is set from the head; unchanged by parts +``` +- `2026-10-13-block-markdown.md`: the bound of the parsed text is its linear parser and `maxSegments`. The window change of `hasObfuscatedSimplexLink` (4.2) is named in its "Interfaces". +- `2026-10-13-message-read-more.md`: rows are bounded by the teaser; the chat list preview, the compose context item and the notification text by `msgPrefix`. +- `2026-10-13-flat-chat-layout-ui.md`: flat rows are bounded by its section 2.7; the `partsMissing` mark is part of `CIMetaView` in both layouts. +- `2026-10-13-block-markdown-rendering.md`: rows are bounded by `MSG_MAX_BLOCKS` block views (rendering plan, 2.2); the `partsMissing` mark is drawn in the added meta line. + +## Open questions + +1. "Read more" in the flat layout: a flat row above 500 lines (16,000 characters on a phone) is a teaser, against "CLI layout does not need read more". Recommended: accept; smaller texts are drawn in full. +2. Maximum text of one item: 8 parts (124,816 bytes, 16 items per recipient queue) or 32 parts (499,264 bytes, 4 items). Recommended: 8. +3. iOS push: 4 messages are received per push; with 4 or more silent messages, live updates or parts queued before the completion message its notification text is "message received". Recommended: accept in this release. +4. Long text is refused for a contact below `msgPartsVersion`, for signed messages (groups with the `signMessages` preference, signed channel posts) and for a channel with a relay below the version. Recommended: accept; parts of signed messages in a later chat version. +5. An item with a part that is not received is shown with the received text and a red warning icon in the meta; it is skipped by forwarding and history. Recommended: yes. +6. Name of the second layout. Recommended: `flat`. +7. Release grouping: three releases, message parts last. Recommended: yes. diff --git a/plans/2026-10-13-message-priority.md b/plans/2026-10-13-message-priority.md new file mode 100644 index 0000000000..c5ea825854 --- /dev/null +++ b/plans/2026-10-13-message-priority.md @@ -0,0 +1,399 @@ +# Message priority + +Date: 2026-10-13. Replaces `2026-10-13-silent-messages.md`. + +A group member marks a message with a priority, when it is sent or by an update; a high priority message is an attention item of the chat, as a mention or a reply, and is shown with a bell. + +References are to `/code/simplex-chat-4`, commit `be2e1c1cc`. Haskell paths without a directory are in `src/Simplex/Chat/`. Layout names are those of the code: `classic` and `terminal` (`flat` in `2026-10-13-flat-chat-layout-ui.md`). + +## 1. Rules +``` +priority integer of a message: -1 low, 0 normal, 1 high; absent: 0 + a received value of 0 or below is normal, of 1 or above is high +scope groups, business chats and channels +senders the API, the CLI and the libraries; the apps send normal priority +item priority set from x.msg.new when the item is created, limited to -1 .. 1 + raised to 1 by an x.msg.update of the sender of the item; unchanged by every other update +normal priority a priority message of a sender who is not allowed (section 3), and a priority message to a contact, + are sent and received with normal priority, without an error +read item an item already read when its priority is raised stays read; a notification is shown +attention item received item with userMention: mention or reply (as now), or high priority (new) +``` +- Notifications are created for every message, as now. Notification text and sound are unchanged. The SMP notification flag is set as now for new messages, and also for an update that raises the priority. +- Mode `MFMentions`: the apps notify for attention items, as now; a high priority item is one of them. A raise is notified once, for an unread and for a read item. +- An item that is a mention and high priority is shown as high priority. + +Counters of a chat (`ChatStats`): +``` +unreadCount unread items unchanged +unreadMentions unread attention items high priority included; groups only, as now +unreadHighPriority unread high priority items new; 0 in direct chats and note folders +``` +Chat list badge of a group: +``` +sign bell when unreadHighPriority > 0; otherwise "@" when unreadMentions > 0 +sign colour primary for MFAll and MFMentions; secondary for MFNone +number unreadCount; secondary for MFMentions and MFNone, as now +one unread an attention item: the sign alone in a circle, as "@" now +``` + +| Unread items | MFAll | MFMentions | MFNone (muted) | +|---|---|---|---| +| 2 normal, 1 high | primary bell, primary "3" | primary bell, secondary "3" | secondary bell, secondary "3" | +| 1 high | primary bell | primary bell | secondary bell | + +Other places: +``` +list label (user tag) bell in place of "●" when a chat of the list has unreadHighPriority > 0 and MFAll or MFMentions +muted chat (MFNone) notifications, list labels, the "unread" filter and profile totals: as now for a mention +chat view bell in the meta of the item, sent and received, in both layouts; its unread counters are unchanged +item info row "High priority" +member support chats a high priority item is counted in support_chat_items_mentions and shown with "@"; a raise by an update is ignored there +preference set and shown by the CLI; excluded from feature items and from the preference screens of the apps +``` + +## 2. Protocol and types +``` +Protocol.hs + MsgContainer (:691) priority :: Maybe Int -- new, after live; JSON "priority": 1; omitted for Nothing + mcSimple (:718) priority = Nothing + XMsgUpdate (:463) priority :: Maybe Int -- new last field; parsed by opt "priority" (:1421-1429); encoded by .=? (:1514) + positional uses: Library/Commands.hs:811, 845, 1474; Library/Subscriber.hs:589, 769, 1088, 1339, 4048 + limitPriority new: Int -> Int; the value limited to -1 .. 1 + cmPriority new, next to cmToQuotedMsg (:635): AChatMsgEvent -> Int + XMsgNew: limitPriority of the field; XMsgUpdate: 1 when the field is 1 or above; otherwise 0 + eventHasNotification new, next to hasNotification (:1338): ChatMsgEvent e -> Bool + True for XMsgUpdate with priority of 1 or above; otherwise hasNotification of the event tag +Types.hs + IntDef new newtype of Int, next to BoolDef (:2337); FromJSON omittedField = Just (IntDef 0) +Messages.hs + CIMeta (:509) priority :: Maybe Int -- new, after userMention; Nothing for 0 + mkCIMeta (:535) new parameter priority :: Int, after userMention + dummyMeta (:551) priority = Nothing + ChatStats (:458) unreadHighPriority :: IntDef -- new, after unreadMentions + emptyChatStats (:467) unreadHighPriority = IntDef 0 +Controller.hs + ComposedMessage (:1335) priority :: Maybe Int -- new; FromJSON (:1934): v .:? "priority" + UpdatedMessage (:1343) priority :: Maybe Int -- new last field; JSON derived (:1947) + positional uses: Library/Commands.hs:798, 2697, 2701, 6562; apps/simplex-badge-service/src/BadgeService/Group.hs:344 + PriorityRaised new record + newAttention :: Bool -- userMention of the item was False before the update + CEvtChatItemUpdated (:980) priorityRaised :: Maybe PriorityRaised -- new last field; Just for the update that raised the priority + Nothing at: Library/Internal.hs:709, 855, 888; Library/Commands.hs:4847; + Library/Subscriber.hs:420, 439, 726, 1227, 1572, 1601, 2027, 2077, 2085, 2104, 2334 + positional patterns: View.hs:455; Terminal/Output.hs:150, 188 +``` +- In a signed message `priority` is covered by the signature. The chat protocol version is unchanged. + +## 3. Preference +Who sends messages with a priority other than 0 in a group is set by a group preference. Direct chats are outside this preference: priority is ignored there (sections 4, 5). +``` +feature GFMsgPriority, SGFMsgPriority +JSON name msgPriority +name text "Message priority" +type MsgPriorityGroupPreference + enable :: GroupFeatureEnabled + role :: Maybe GroupMemberRole + as VoiceGroupPreference (Types/Preferences.hs:735): HasField "enable" (:800) and "role" (:927), GroupFeatureI (:851), GroupFeatureRoleI (:943) + FromJSON omittedField = on for admin, as SignMessagesGroupPreference (:1275) +default enable = on, role = admin: admins and owners, in groups, channels and business chats + business chat: the business is the owner (Store/Groups.hs:2245), the customer a member (:2262) +``` +- A member may send when `enable = on` and the member role is at or above `role` (`groupFeatureMemberAllowed'`, `Types/Preferences.hs:273`). A channel post is checked with the owner role. +- `Types/Preferences.hs`, a case or a field next to that of voice at `:205 :221 :250 :300 :321 :337 :353 :378 :396 :451 :498 :569 :588 :608 :1099 :1121`; True at `:300` and `:321`; `Nothing` in `emptyGroupPrefs` (`:588`) and `defaultBusinessGroupPrefs` (`:608`). +- The feature is excluded from `allGroupFeatures` (`:280`), as `SGFSessions`: the feature items of a chat and the items for preference changes are unchanged. +- CLI, parser next to that of voice (`Library/Commands.hs:6453`); the command is accepted from owners (`runUpdateGroupProfile`, `:4166`): +``` +/set priority # on|off [] SetGroupFeatureRole (AGFR SGFMsgPriority) +/set priority #team on member members and above; without a role: every member +``` +- CLI views, `AGF SGFMsgPriority` appended to the feature list of `viewGroupUpdated` (`View.hs:2121`) and `viewGroupProfile` (`:2149`): `Message priority: on for admins`. + +## 4. Core: sending +New message: +``` +contact priority of a composed message is ignored: the message is sent without the field; the item has priority 0 +group sent value = limitPriority of the value, when it differs from 0 and groupFeatureUserAllowed SGFMsgPriority gInfo (Types.hs:689); every scope + otherwise the message is sent with normal priority, without an error, as ttl without the timed messages feature (sndCITimed_, Library/Internal.hs:185) +``` +- `Library/Commands.hs:4861` `sendContactContentMessages`: `priority` of each composed message is replaced with Nothing. +- `Library/Commands.hs:4931` `sendGroupContentMessages_`: `priority` of each composed message is replaced with the sent value. +- `Library/Internal.hs:212` `prepareGroupMsg`: new parameter `priority :: Maybe Int`, set in the container (`:229`); passed at: + - `Library/Commands.hs:4994`: `priority` of the composed message + - `Library/Internal.hs:1551` (`sendHistory`): the priority of the stored item +- `Library/Internal.hs:2983` `NewSndChatItemData`: `priority :: Int`: + - `prepareSndItemsData` (`Library/Commands.hs:5061`): `priority` of the composed message, 0 for Nothing + - the other 4 constructions (`Library/Internal.hs:2979`, `Library/Commands.hs:3069, 3121, 3220`): 0 +- `Library/Internal.hs:3010` `saveSndChatItems.createItem`: `priority` is passed to `createNewSndChatItem` and `mkChatItem_`. +- `Library/Internal.hs:3081` `mkChatItem_`: new parameter `priority`; 0 at `:3079` (`mkChatItem`) and `:3414` (`createLocalChatItems`). +- The other values of `ComposedMessage` are built with `priority = Nothing`: `Library/Commands.hs:792, 1140, 2611, 2639, 2684, 3485, 5247`; `Bot.hs:90, 97`; `apps/simplex-badge-service/src/BadgeService/Group.hs:286`. A forwarded item and a report have normal priority. + +Update, `APIUpdateChatItem` (`Library/Commands.hs:798`): +``` +contact (:799), note folder priority of the updated message is ignored +group (:824) + raise limitPriority of the value is 1, the item priority is below 1, scope is Nothing, groupFeatureUserAllowed SGFMsgPriority gInfo + condition :840: changed || the item is live || raise + event :845: priority = Just 1 when raise; otherwise Nothing + SMP flag sendGroupSignedMessages_ (Library/Internal.hs:2748): any eventHasNotification of the events + unchanged: Library/Internal.hs:2248 (direct), :2552 (deliverMessage) + messages forwarded by relays and hosts: "Depends on" + item :852: edited = changed && itemLive /= Just True -- the present value in the present cases + :853: when raise, CIMeta.priority = Just 1 before updateGroupChatItem +``` + +## 5. Core: receiving +New item, `Library/Internal.hs:3030` `saveRcvChatItem'`, with unchanged callers: +``` +priority = if content is CIRcvMsgContent && rcvPriorityAllowed cd then cmPriority chatMsgEvent else 0 +userMention = (mention or reply, :3062, as now) || priority > 0 +priority is passed to createNewRcvChatItem and mkChatItem_ + +rcvPriorityAllowed cd = -- new, Library/Internal.hs + CDGroupRcv g _ m -> groupFeatureMemberAllowed SGFMsgPriority m g && not (memberBlocked m) + CDChannelRcv g _ -> groupFeatureMemberAllowed' SGFMsgPriority GROwner (fullGroupPreferences g) + CDDirectRcv, CDLocalRcv -> False +``` +- A message of a sender who is not allowed, and every direct message, is received as a normal message. +- Items of group and forwarded messages (`Library/Subscriber.hs:2209, 4008`), of history, and the item created by `x.msg.update` for a missing item (`:2327`) are created by this function: that item has the priority of the update. +- Support chat counters (`memberChatStats`, `Library/Internal.hs:3064`; `Store/Messages.hs:2195-2212`) are computed from `userMention`, unchanged. + +Update, `Library/Subscriber.hs:2297` `groupMessageUpdate`: new parameter, `priority` of `XMsgUpdate` (`:1088`, `:4048`). The field is ignored in the direct handler (`:589`). +``` +updateCI (:2368), new parameters: priority of the update; cd of the item (the values at :2351, :2354) + requested priority >= 1 + raise requested && the item priority is below 1 && scopeInfo is Nothing && rcvPriorityAllowed cd + condition :2371: changed || the item is live || requested + edited :2377: changed && itemLive /= Just True + item when raise: CIMeta.priority = Just 1 and userMention = True, before updateGroupChatItem (:2379) + event :2382: priorityRaised = Just PriorityRaised {newAttention = userMention of the item was False}, when raise + delivery as now (:2384) +``` +- The status of a raised item is unchanged: an unread item is counted by the stats queries; a read item stays read. The item priority is unchanged by an update without `priority` or with a value of 0 or below. + +Groups, channels, relays: +``` +forwarding the received event is forwarded: signed as its bytes; unsigned encoded again with priority (Protocol.hs:1591) + an update with requested priority is forwarded by the condition above, whatever the result of the raise for the forwarding member + each receiver applies rcvPriorityAllowed with the role of the author +history unsigned item: encoded with the priority of the stored item + signed item: its stored bytes, the last signed message of the item (updateChatItemSignedMsg, Store/Messages.hs:2780) +abuse every member with MFAll or MFMentions is notified by one message of an allowed member +limits the role of the preference; a message of a blocked member is received with normal priority; MFNone +``` + +## 6. Storage +Migration `M20261013_msg_priority`, SQLite and Postgres, registered in `Store/SQLite/Migrations.hs`, `Store/Postgres/Migrations.hs` and `simplex-chat.cabal`. Its name is ordered after `20261019_chat_layout` of the chat layout patch (`migrations = sortOn name`, `Store/SQLite/Migrations.hs:363`). It is one `ADD COLUMN`; indexes are unchanged. +``` +chat_items + item_priority INTEGER NOT NULL DEFAULT 0 -- SMALLINT in Postgres +``` +- Down migration: the column is dropped. The schema dumps and `chat_query_plans.txt` need regeneration by the schema and query plan tests; they are never edited by hand. + +`Store/Messages.hs`: +``` +createNewChatItem_ :591 new parameter priority, written to item_priority; 0 at Library/Internal.hs:3412 +createNewSndChatItem :549 new parameter priority; 0 in SendMessageBroadcast (Library/Commands.hs:2679) +createNewRcvChatItem :565 new parameter priority +createNewChatItemNoMsg :584 0 +updateGroupChatItem_ :2794 the UPDATE (:2803) also writes item_priority and user_mention from CIMeta +ChatItemModeRow :2283 item_priority added +item queries :2711 :3105 :3218 i.item_priority selected +item rows :1096 :2308 :2369 priority passed to mkCIMeta +toChatStats :787 unreadHighPriority = IntDef 0, for direct chats and note folders +GroupStatsRow :791 unreadHighPriority added after unreadMentions +findGroupChatPreviews_ :899 :925: SUM(CASE WHEN user_mention = 1 AND item_priority > 0 THEN 1 ELSE 0 END) AS UnreadHighPriority +getGroupUnreadCount_ :1739 :1743: the same sum in COALESCE; returns count, mentions, high priority; used at :1703, :1708, :1718 +``` +- `user_mention = 1` is tested first in both sums: the table row is read for attention items only, with `idx_chat_items_group_scope_stats_all` as it is. These two sums are the only aggregates of `item_priority`. A received high priority item always has `user_mention = 1`: both columns are written together at creation and at a raise. + +## 7. API and CLI +`/_send` (`Library/Commands.hs:6174`) and `/_update item` (`:6183`) syntax is unchanged. `priority` is a property of each composed message and of the updated message: +``` +/_send #1 json [{"msgContent": {"type": "text", "text": "done"}, "priority": 1}] +/_update item #1 5 json {"msgContent": {"type": "text", "text": "done"}, "mentions": {}, "priority": 1} +``` +- Streamed message: sent with `live=on`, updated with `live=on`, completed by `/_update item` without `live` and with `"priority": 1`. A message that is not live is raised by the same command, with or without a change of the text. +- For a contact and a note folder `priority` is ignored by both commands. Responses and events: `CIMeta.priority` in chat items; `ChatStats.unreadHighPriority` where the other counters are returned; `priorityRaised` in the event `chatItemUpdated`. + +## 8. Apps: model +`K`: `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/`. `I`: `apps/ios/`. +``` +CIMeta priority: Int? = null; highPriority: priority >= 1 + K model/ChatModel.kt:3907; I SimpleXChat/ChatTypes.swift:4272 +ChatStats unreadHighPriority: Int = 0 + K model/ChatModel.kt:1502; I SimpleXChat/ChatTypes.swift:2423, also a parameter of init +group preference msgPriority: RoleGroupPreference in FullGroupPreferences; optional in GroupPreferences; copied by toGroupPreferences + K model/SimpleXAPI.kt:6369, :6418, :6383; I SimpleXChat/ChatTypes.swift:1658, :1716, :1773 + returned unchanged when the other preferences are saved (K views/chat/group/GroupPreferences.kt:48; I Shared/Views/Chat/Group/GroupChatInfoView.swift:1142) + by the unchanged preference screens; GroupFeature is unchanged +new unread item event newChatItems: unreadHighPriority + 1 for a high priority item + K addChatItem, model/ChatModel.kt:612; I addChatItem, Shared/Model/ChatModel.swift:733 +items read /_read chat items: unreadHighPriority reduced by the count of read high priority items; /_read chat: 0 + K markChatItemsRead :884, markItemsReadInCurrentChat :904 (third result) + I markChatItemsRead ChatModel.swift:1041, apiMarkChatItemsRead SimpleXAPI.swift:1925 (new parameter highPriorityRead) + unreadItemIds ChatView.swift:1838 (third result), calls :1812, :1817 +item read or removed events chatItemsStatusesUpdated, chatItemUpdated, chatItemsDeleted, and removal of the items of a member + K decreaseCounterInPrimaryContext :940: new parameter item; unreadMentions and unreadHighPriority + are reduced by the flags of the item; calls :675 (pItem), :725 (cItem), :777 (item) + I removeChatItem ChatModel.swift:850, removeMemberItems :885: third delta + UnreadCollector :1062, changeUnreadCounter(_:by:unreadMentions:) :1162: third delta +priority raised event chatItemUpdated with priorityRaised: K CR.ChatItemUpdated (model/SimpleXAPI.kt:6805); I ChatEvent (Shared/Model/AppAPITypes.swift:1188) + active profile, unread item: unreadHighPriority + 1; unreadMentions + 1 when newAttention; list labels by the rule below + other profile, unread item, MFMentions, newAttention: profile total + 1 (as K :3026, I :2636 for a new item) + notification: notifyMessageReceived, for an unread and for a read item + K chatItemUpdateNotify (model/SimpleXAPI.kt:3762): new parameter priorityRaised; notify() also when it is present (:3775) + I case .chatItemUpdated (Shared/Model/SimpleXAPI.swift:2670), after chatItemSimpleUpdate (:3128); showNotification as at :2645 + NSE: new case chatItemUpdated in NSEChatEvent (SimpleX NSE/NSEAPITypes.swift:87) + receivedMsgNtf (SimpleX NSE/NotificationService.swift:1003): with priorityRaised as .newChatItems (:1024-1030); otherwise nil +``` +`ntfsEnabled` (K `model/ChatModel.kt:1809`; I `ChatTypes.swift:2290`) is computed from `userMention`, unchanged. Unchanged notification paths of new items: +``` +K platform/NtfManager.kt:39; model/SimpleXAPI.kt:3026, 3041, 3072, in the one receiver loop (startReceiver, :784) +I Shared/Model/NtfManager.swift:253; Shared/Model/SimpleXAPI.swift:2636, 2646, 2682, 3135 + SimpleX NSE/NotificationService.swift:1024 +``` +List labels: +``` +UnreadTag = None | Unread | HighPriority -- type of Chat.unreadTag; K model/ChatModel.kt:1475, I ChatModel.swift:1444 +unreadTag(chat): + MFAll HighPriority if unreadHighPriority > 0; Unread if unreadChat || unreadCount > 0 + MFMentions HighPriority if unreadHighPriority > 0; Unread if unreadChat || unreadMentions > 0 + MFNone, nil Unread if unreadChat + otherwise None +unreadTags[tag] chats of the tag with unreadTag /= None -- as now +priorityTags[tag] chats of the tag with unreadTag == HighPriority -- new map, next to unreadTags +update(chat, was): -- was: unreadTag before the change + now = chat.unreadTag + for tag in tags of the chat: + unreadTags[tag] += (now /= None) - (was /= None) + priorityTags[tag] += (now == HighPriority) - (was == HighPriority) +functions with this rule K model/ChatModel.kt: updateChatTags :319, moveChatTagUnread :380, updateChatTagReadInPrimaryContext :1052, + markChatTagRead :1068, markChatTagReadInPrimaryContext_ :1076 + I ChatModel.swift: updateChatTags :197, markChatTagRead :251, updateChatTagRead :257, decTagsReadCount :269 +Boolean uses of unreadTag compared with None: K views/chatlist/ChatListView.kt:1575, ChatListNavLinkView.kt:984 + I Shared/Views/ChatList/ChatListView.swift:676, TagListView.swift:201, :211 +other call sites unchanged: the value before the change is passed as now +``` + +## 9. Apps: views +``` +chat list badge K views/chatlist/ChatPreviewView.kt:465-493: ic_notifications in place of ic_alternate_email when unreadHighPriority > 0 + I Shared/Views/ChatList/ChatPreviewView.swift:233, 241: Text(Image(systemName: "bell.fill")) in place of MENTION_START +list label K views/chatlist/ChatListView.kt:1348: inline icon ic_notifications, primary, when priorityTags[tag] > 0; " ●" otherwise + I Shared/Views/ChatList/ChatListView.swift:1060: Text(Image(systemName: "bell.fill")) in the same place +item meta K views/chat/item/CIMetaView.kt: CIMetaText (:83), first element StatusIconText(ic_notifications, primary) when highPriority + reserveSpaceForMeta (:155): iconSpace for it + I Shared/Views/Chat/ChatItem/CIMetaView.swift: ciMetaText (:127), first element statusIconText("bell.fill", colorMode.resolve(primaryColor)), + as the "xmark.seal" icon (:167) +item info K views/chat/ChatItemInfoView.kt: Details (:254), InfoRow with ic_notifications after "Disappears at"; itemInfoShareText (:568) + I Shared/Views/Chat/ChatItemInfoView.swift: details (:135), itemInfoShareText (:520) +string info_row_high_priority: "High priority" (K moko-resources/base/strings.xml; I the same text) +``` + +## 10. Terminal +`View.hs:709` `viewChatItem`, mark `!!` for a high priority item of a group; `!` for a mention or a reply, as now: +``` +received #team alice!!> done ttyFromGroupAttention (:3100), fromGroupAttention_ (:3113): the mark in place of the Bool +sent #team !! done ttyTo "!! " after ttyToGroup (:739) +updated the same marks in viewItemUpdate (:864), at :892 and :901, also with "[edited]" +``` +- `Terminal/Output.hs:188`: `sendNtf` for an event with `priorityRaised`, as for a new item (`:181-185`). `markRead` (`:161`) and `setActiveChat` depend on `isUserMention`, unchanged. + +## 11. Libraries and docs +- `bots/src/API/TypeInfo.hs`: `"IntDef" -> ST TInt []` (`:174`); `MsgPriorityGroupPreference` in `roleGroupPrefTypes` (`:248`). `bots/src/API/Docs/Types.hs`: `PriorityRaised` registered as `UpdatedMessage` (`:402`, `:641`). +- Generated files are written by `tests/APIDocs.hs` (`testGenerate`, `:153`): `bots/api/TYPES.md`, `bots/api/EVENTS.md`, and the types and events files of `packages/simplex-chat-client/types/typescript/src` and `packages/simplex-chat-python/src/simplex_chat/types`. +- New optional parameter `priority`, set in the composed or the updated message; `apiSendMessages` (`api.ts:443`) and `api_send_messages` (`api.py:196`) are unchanged: + - `packages/simplex-chat-nodejs/src/api.ts`: `apiSendTextMessage` (`:466`), `apiSendTextReply` (`:474`), `apiUpdateChatItem` (`:482`) + - `packages/simplex-chat-python/src/simplex_chat/api.py`: `api_send_text_message` (`:225`), `api_send_text_reply` (`:236`), `api_update_chat_item` (`:241`) + - `packages/simplex-chat-python/src/simplex_chat/client.py`: `Message.reply` (`:74`) +- `bots/README.md`, new section "Message priority" before "How to create a bot" (`:87`): the field of a new message, the raise by an update, the group preference and its role, direct chats. +- `docs/protocol/simplex-chat.md`: `priority` in `x.msg.new` (`:168`) and `x.msg.update` (`:185`), the receiver rules, the preference. `docs/protocol/simplex-chat.schema.json`: optional `priority` of type `integer`, as `ttl`, in `msgContainer` (`:117`) and `x.msg.update` (`:478`). +- `apps/ios/spec` and `apps/multiplatform/spec`: the descriptions of the changed functions. + +## 12. Compatibility +``` +receiver, sender before this release "priority" is ignored by the parsers of x.msg.new and x.msg.update; a sender sends normal priority +forwarding host or relay before signed message: forwarded as received bytes, "priority" kept + unsigned message: encoded again from the parsed message (Protocol.hs:1591), "priority" dropped +owner before this release the preference is absent: the default; the received preference JSON is kept (prefsToRow, Types/Preferences.hs:181) and parsed after an upgrade + preferences saved by an app before this release are sent without msgPriority: the default applies again +remote desktop host JSON is decoded by the controller core (Remote/Protocol.hs:71, :185) + newer desktop, older mobile: priority absent, unreadHighPriority 0, msgPriority by omittedField, priorityRaised absent + older desktop, newer mobile: a high priority item is shown as a mention; a raise is shown as an update, without a notification +bots priority of ComposedMessage and UpdatedMessage is optional and ignored by a core before this release (Controller.hs:1934, :1947); the response fields of section 7 are added +downgrade item_priority is dropped; user_mention = 1 is kept: the items are shown as mentions +``` + +## 13. Tests +`tests/ProtocolTests.hs`: +- `x.msg.new` with `"priority":1` and with `"priority":-1`, and `x.msg.update` with `"priority":1`, are encoded and decoded +- the fixture `testGroupPreferences` gets `msgPriority = Nothing`; group preferences without `msgPriority` are decoded to the default + +`tests/ChatTests/Groups.hs`, `tests/ChatTests/ChatRelays.hs`, `tests/ChatTests/Profiles.hs`; `ChatStats` is read with `getChatPreviews` through `withCCTransaction` (`tests/ChatTests/Utils.hs:671`): +- `/_send #1 json` with `"priority": 1` by the owner: sender `#team !! done`, members `#team alice!!> done`; `unreadMentions = 1`, `unreadHighPriority = 1`; by a member: sent and received as normal +- `/set priority #team on member`, `/set priority #team off`: `Message priority: on for members` and `Message priority: off` in the CLI views of the owner and of members; feature items unchanged; the rule applied by senders and receivers +- `/unmute mentions #team`: a normal message is printed with ``, a high priority message without it; with a mention of the user: `!!`, counters 1 and 1 +- after `/_read chat items` for the high priority item: `unreadHighPriority = 0`; `"priority": -1`: a normal item with `priority = -1`; member blocked by the recipient: normal priority +- message forwarded by the host to a member without a direct connection, and history (unsigned and signed item): high priority +- channel through a relay: an owner post with priority is high priority for a subscriber; business chat: high priority for a message of the business, normal for a message of the customer +- raise: normal message, then `/_update item` with `"priority": 1` and the same text: sender `#team !! done`, members `#team alice!!> done`; one `chatItemUpdated` with `priorityRaised`; `newAttention` false for an item that mentions the user; raise of a read item: the item stays read, `unreadHighPriority = 0`, the item has priority 1 +- streamed message: `live=on`, live updates, completion with `"priority": 1`: high priority; a later edit without `priority`: priority kept +- raise by a member below the role: the update is sent without the field; raise in a member support chat: ignored; update with `"priority": 1` for an item deleted by the recipient: the created item is high priority +- message to a contact with `"priority": 1`, and its update with `"priority": 1`: sent without the field; the items have priority 0 + +Manual checks, Android, desktop, iOS: +1. Badge table of section 1 in a group; list label; the bell is removed when the item is read or deleted; muted chat. +2. Group set to "Mute" (`MFMentions`): notification, in-app banner and sound for a high priority message and for a raise only; app open, in background and closed; iOS push for a raise sent by the author to a member with a connection, and for a forwarded raise with the patch of `2026-10-13-forwarded-notification-flag.md`. Raise of a streamed message: one notification with the final text; the bell appears on the item, the badge and the list label. +3. Item meta in the classic and terminal layouts, sent and received, image without text; item info. Group preferences saved from an app after `/set priority`: the value is kept. Desktop linked to a mobile of the previous release, and the reverse: chats and items are shown. + +## Interfaces +``` +wire "priority" in x.msg.new + "priority" in x.msg.update +core types MsgContainer.priority + XMsgUpdate.priority + ComposedMessage.priority + UpdatedMessage.priority + CIMeta.priority + NewSndChatItemData.priority + ChatStats.unreadHighPriority + PriorityRaised + CEvtChatItemUpdated.priorityRaised + IntDef +core functions limitPriority + cmPriority + eventHasNotification + rcvPriorityAllowed + prepareGroupMsg, new parameter priority +feature GFMsgPriority with MsgPriorityGroupPreference + JSON name msgPriority +CLI /set priority # +terminal mark "!!" +storage chat_items.item_priority + migration M20261013_msg_priority +apps CIMeta.highPriority + UnreadTag + priorityTags +``` + +## Depends on +`2026-10-13-forwarded-notification-flag.md`, a separate patch: the notification flag of messages forwarded by relays and hosts (delivery jobs, `Library/Subscriber.hs:4416, 4483`) is set by that patch; `eventHasNotification` is the function used by it for an update that raises the priority. The author's own send (section 4) is part of this plan. + +This plan is implemented independently of the other plans. Their references to `silent` of the rejected plan are replaced: +``` +2026-10-13-message-parts.md MsgContainer: priority, parts, partsId; priority is a field of the head only + XMsgUpdate: priority, then parts, as new last fields; the positional uses are the same sites + item_priority is set or raised by the head; unchanged by x.msg.part + CIMeta, mkCIMeta: priority, partsMissing; ChatItemModeRow: item_priority, item_parts; NewSndChatItemData: priority, itemParts, partMsgIds + prepareGroupMsg: one new parameter, priority; in sendHistory with the priority and live of the item + SMP flag of a call: any eventHasNotification of its events; a call of parts only: False + its migration is named with a later date than M20261013_msg_priority +2026-10-13-block-markdown.md terminal suffixes: msgVerifiedStr, then " (incomplete)" +2026-10-13-message-read-more.md release grouping: this plan in place of silent messages +layout and rendering plans the bell is part of CIMetaView in both layouts and in the added meta line +``` + +## Open questions +1. Postgres: the plan of the two stats sums with the `user_mention = 1` guard is unmeasured. Recommended: measure with the data set used for SQLite before release; an index only if the rows of normal items are read. +2. Later: a compose control for high priority in the apps; a raise by an update in a member support chat; the bell on the unread counters of the chat view, on preset filters (contacts, groups, businesses) and in member support chat lists. Recommended: later. diff --git a/plans/2026-10-13-message-read-more.md b/plans/2026-10-13-message-read-more.md new file mode 100644 index 0000000000..0f247898b6 --- /dev/null +++ b/plans/2026-10-13-message-read-more.md @@ -0,0 +1,400 @@ +# Read more for long messages + +Date: 2026-10-13. Revised: 2026-10-14. + +The app shows a long message as a teaser followed by a "Read more" link; after a tap on the link the app shows the whole message in a viewer. + +## Scope + +Only the Kotlin and iOS apps are changed. References are to branch `ep/llm-ux`, commit `31a8c86fb`. + +- Input: `ChatItem.formattedText`, the list of blocks of the core parser (`MarkdownList = [MarkdownBlock]`, block Markdown plan, release 2). Release 1: the segment list of the item is taken as one paragraph. +- "Read more" is the app-only format `Format.Modal`: Kotlin `ChatModel.kt:5220`, iOS `ChatTypes.swift:5707`. Its other use, the description link (`ChatInfoView.kt:812`, `MsgContentView.swift:382`), is unchanged. +- Desktop linked to a mobile: the teaser is computed by the desktop app from the items of the mobile core. + +``` +agent profile plan 2026-10-13-agent-profile-chat-layout.md, patch 01 rework +layout plan 2026-10-13-flat-chat-layout-ui.md, layouts classic and terminal +block Markdown plan 2026-10-13-block-markdown.md +rendering plan 2026-10-13-block-markdown-rendering.md +message parts plan 2026-10-13-message-parts.md +``` + +## Items + +``` +text path Kotlin CIMarkdownText (FramedItemView.kt:445) + iOS FramedItemView.ciMsgContentView (FramedItemView.swift:359) +layout of a chat view ChatInfo.activeChatLayout, provided by the layout plan: Kotlin LocalChatLayout, iOS EnvironmentValues.chatLayout +texts every text drawn by the text path +chat link item the link is removed first (rendering plan) +report the reason prefix (itemPrefixText) is outside the count +live item truncated above the teaser cost while it is updated +``` + +Limits: + +``` +limit classic terminal layout, or search text not blank +maxLines MSG_MAX_LINES MSG_TERMINAL_MAX_LINES (layout plan) +teaserLines MSG_TEASER_LINES MSG_TERMINAL_TEASER_LINES (layout plan) +maxUnits without limit MSG_TERMINAL_MAX_LINES × MSG_LINE_CHARS +teaserUnits without limit MSG_TERMINAL_TEASER_LINES × MSG_LINE_CHARS +maxBlocks MSG_MAX_BLOCKS MSG_MAX_BLOCKS (rendering plan) +``` + +## Teaser rule + +Input: the blocks of the item. A text with `formattedText == null` is taken as one paragraph with one segment without format. Line breaks in content are text of segments without format (block Markdown plan, "Rendering contract"). + +``` +MSG_LINE_CHARS 32 on Android and iOS + 80 on desktop +MSG_MAX_LINES 24 +MSG_TEASER_LINES 16 +MSG_CODE_LINE_CHARS 240, the units drawn in CODE_MAX_WIDTH (rendering plan) +MSG_CELL_CHARS 30, the units drawn in a table column of 240 +MSG_PREVIEW_UNITS 1000 +unit UTF-16 code unit +display text of an inline segment: the displayText argument + Kotlin: itemSegmentDisplayText(ft, ci, linkMode) (TextItemView.kt:88), passed by itemTeaser + iOS: segmentDisplayText (new), the text drawn by messageText (MsgContentView.swift:446-570) +line break Kotlin: a match of the regular expression \R; Swift: a Character with isNewline +width 1 per unit + 2 per unit in U+2E80-U+A4CF + 2 per unit in U+AC00-U+D7A3 + 2 per unit in U+F900-U+FAFF + 2 per unit in U+FF00-U+FF60 +line cost max(1, ceil(width of the line / chars)); chars: MSG_LINE_CHARS unless stated +``` + +Cost and units of a block: + +``` +block cost units +paragraph line costs of the display text of its content display text +heading as paragraph display text +quote as paragraph display text +list item as paragraph display text +code block 1 for the header code text + line costs of the code lines, chars MSG_CODE_LINE_CHARS +table per row: the largest line cost of its cells, cell texts + chars MSG_CELL_CHARS +rule 1 0 +unknown block 0 0 +gap 1 before a block + 0 before the first drawn block and before an unknown block + 0 before a list item after a list item +``` + +``` +msgTeaser(blocks, live, maxLines, teaserLines, maxUnits, teaserUnits, maxBlocks): + limits: maxLines and maxUnits; for a live item: teaserLines and teaserUnits + cost, units and count of blocks within the limits and maxBlocks: null; the item is drawn in full + teaser: the blocks in order while cost <= teaserLines, units <= teaserUnits and count <= maxBlocks + the next block: cut by "Cut inside a block" to the remaining cost and units; left out when the cut is empty + return the teaser; it can be empty + +msgPrefix(segments, maxUnits): + segments: one text; release 2: markdownSegments(blocks) (block Markdown plan, "Rendering contract") + units <= maxUnits: the segments + otherwise: inline cut at p = maxUnits +``` + +### Cut inside a block + +p: the position of the remaining cost and units in the text of the block. + +``` +paragraph inline cut at p +heading inline cut at p +quote inline cut at p +list item inline cut at p +code block the lines before the first line that does not fit + that line, cut at the grapheme cluster boundary at or before p, when it is the first line +table the header row (first row) and the rows before the first row that does not fit + a table with the header row only is left out +rule left out +unknown block kept + +inline cut + p at a line break line breaks and empty lines before p are dropped + p inside a line moved back to the last white space within the last MSG_LINE_CHARS units before p + without such white space: to the grapheme cluster boundary at or before p + segment at p none: cut + bold: cut; the format is kept + italic: as bold + strikeThrough: as bold + snippet: as bold + small: as bold + colored: as bold + secret: as bold + strong: as bold + other: replaced by a segment without format that has the cut display text +``` + +- The cut inline content is a prefix of the segment list with the last segment cut. +- The display text of a mention is built from the names of `ci.mentions`; of a SimpleX link, from the SimpleX link mode. +- Command: the drawn text is "/" and `commandStr` (`TextItemView.kt:270-279`), the display text is `ft.text` (`:106`). + +## Truncated item + +``` +blocks teaser + paragraph of one segment: "Read more", Format.Modal with modal name "message" and text "" +drawn by a list of paragraphs only: one text, the segments of markdownSegments, by MarkdownText and messageText + any other list: MsgBlocksView of the rendering plan +end of text typing indicator of a live item and meta reserve: at the end of the last paragraph (rendering plan) +selection the paragraph of the link is outside the desktop selection +link drawn and handled by the Format.Modal code of the description link (TextItemView.kt:334-340, 392-411; MsgContentView.swift:234-235, 560-565) +``` + +## Viewer + +Kotlin `showFullMessage` (`TextItemView.kt`), iOS `FullMessageView` (`FullMessageView.swift`): + +``` +content the blocks of the item, drawn with the geometry and colours of the classic layout +chatLayout the layout of the chat view of the tap: Kotlin LocalChatLayout.current, iOS @Environment(\.chatLayout) +items msgLines: one MsgLine (block index, range in the display text of the block) per lazy item + one per line of a paragraph, heading, quote, list item and code block + a line above 15,000 units: items of 15,000 units by the inline cut, as MarkdownFileView.kt:57, :95 + one per table, rule and unknown block, with the range null + drawn by MsgBlockView(block, range, first, last) of the rendering plan +item looked up by ID at each draw: Kotlin chatsCtx.chatItems, snapshot state (ChatModel.kt:424) + iOS im.reversedChatItems, published with a 0.2 s throttle (ChatModel.swift:78-106) +closed the item is removed from the chat items: deleted, or unloaded by pagination + meta.itemDeleted of the item differs from its value at opening + the open chat is changed: Kotlin KeyChangeEffect on chatModel.chatId, as ChatView.kt:753-755; iOS onChange of ChatModel.chatId +live item the items are built from the looked-up item at each update; the texts are drawn without meta; the list state is kept +links actions of the item text (TextItemView.kt:371-382, MsgContentView.swift:197-222) +secrets shown per lazy item; reset with the item key +commands sendCommandMsg (FramedItemView.kt:479, CommandsMenuView.swift:164), enabled as in the item text (FramedItemView.kt:465, MsgContentView.swift:108) + then close (Kotlin), dismiss (iOS) +copy button whole text, ci.content.text +``` + +## Kotlin + +Paths are relative to `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/`. + +``` +model/ChatModel.kt, Format.Modal companion (:5221-5223) + const val Message = "message" +views/chat/item/TextItemView.kt, new + constants of "Teaser rule" + msgTeaser(blocks, live, maxLines, teaserLines, maxUnits, teaserUnits, maxBlocks, lineChars, displayText): List? + pure function; null: drawn in full + msgPrefix(segments, maxUnits, displayText): List + itemTeaser(ci, linkMode, maxLines, teaserLines, maxUnits, teaserUnits): List? + blocks drawn by CIMarkdownText: live text (FramedItemView.kt:459), chat link removed; maxBlocks = MSG_MAX_BLOCKS + MsgLine(block: Int, range: IntRange?); msgLines(blocks, displayText): List, items of "Viewer" + showFullMessage(chatsCtx, chat, ci, chatLayout) +views/chat/item/TextItemView.kt, MarkdownText (:125) + openModal: ((Format.Modal) -> Unit)? = null new parameter + MODAL tap (:392-396) openModal; null: openMarkdownModal + long press (:357-366) condition hasLinks || hasModals; MODAL annotation: onLinkLongClick("") +views/chat/TextSelection.kt, ItemContext (:48-50) + new fields the four line and unit limits of "Limits", Int = Int.MAX_VALUE +views/chat/ChatView.kt, ChatItemsList (:1750) + terminal limits LocalChatLayout.current == ChatLayout.Terminal || searchValue.value.isNotBlank() + :2440 ItemContext(selectionIndex = index, and the limits) + :2383 SelectionHandler(manager, listState, mergedItems, revealedItems, linkMode, and the limits) +views/chat/item/FramedItemView.kt + CIMarkdownText (:445) teaser = itemTeaser(ci, linkMode, the limits of LocalItemContext.current) + with a teaser blocks of "Truncated item"; link text MR.strings.whats_new_read_more + openModal = showFullMessage(chatsCtx, chat, ci, LocalChatLayout.current) + without a teaser drawn as by the rendering plan + :225, :372, :380, :386, :415 onLinkLongClick is passed to CIMarkdownText +views/chat/item/TextItemView.kt, showFullMessage + presentation main chat view (chatsCtx.secondaryContextFilter == null): ModalManager.center.showModalCloseable, as showMarkdownFile (MarkdownFileView.kt:99) + Android: full screen (ModalView.kt:249-253); desktop: over the conversation (App.kt:419-420), closed when another chat is opened (App.kt:392-400) + member support chat and reports view: ModalManager.end.showModalCloseable, stacked over that view, as item info (ChatView.kt:728-731) + after back that view is composed again (ModalView.kt:194-223) + title saved_message_title, sent_message or received_message, as ChatItemInfoView.kt:255 + toolbar copy_verb, with the toast `copied` + body SelectionContainer { MarkdownFileColumn(title, false) { the items of "Viewer" } } (MarkdownFileView.kt:158, :227) + MarkdownFileColumn is made internal; item key: index and text of the line + text the parameters of CIMarkdownText (FramedItemView.kt:463-475), without meta and selection; style as TextItemView.kt:60 + first line: the report reason prefix + selection SelectionContainer over the composed items; the copied text is the display text +views/chat/TextSelection.kt, desktop selection + SelectionManager.itemBlocks (ChatItem) -> List?, set in SelectionHandler with listState (:339-341) + itemTeaser(ci, linkMode, and the limits) ?: ci.formattedText + snapSelection (:121-137) itemBlocks(ci) + getSelectedCopiedText (:214-227) item with the range 0 until Int.MAX_VALUE (:256): the item prefix and ci.text (rendering plan, section 5) + anchor and focus items: itemBlocks(ci), the selected teaser text + offsets in the display text of markdownSegments, by the rendering plan (selectedItemCopiedText :264, snapOffset :295) +``` + +## iOS + +Paths are relative to `apps/ios/`. `FullMessageView.swift` is a new file, added to the SimpleX target in `project.pbxproj`. + +``` +SimpleXChat/ChatTypes.swift + Format, after :5713 static let modalMessage = "message" + after FormattedText (:5665) public msgPrefix(_ segments, maxUnits:, displayText:) -> [FormattedText] + public functions of "Cut inside a block", used by msgPrefix and msgTeaser + units by String.utf16 indices with a limit; cuts at Character boundaries + MSG_LINE_CHARS + MSG_PREVIEW_UNITS +Shared/Views/Chat/ChatItem/MsgContentView.swift + the other constants of "Teaser rule" + msgTeaser(_ blocks, live:, maxLines:, teaserLines:, maxUnits:, teaserUnits:, maxBlocks:, mentions:) -> [MarkdownBlock]? + segmentDisplayText(_ ft, mentions:) -> String display text of a segment, as drawn by messageText (:446-570) + MsgLine; msgLines(_ blocks, mentions:) -> [MsgLine] items of the viewer, as in Kotlin + MsgContentView.openModal: ((Format) -> Void)? = nil passed to msgTextResultView (:123) +Shared/Views/Chat/ChatItem/FramedItemView.swift, ciMsgContentView (:359) + terminal limits @Environment(\.chatLayout) is .terminal or @Environment(\.textSearch) is true + teaser msgTeaser(ft ?? [paragraph of FormattedText.plain(text)], live: ci.meta.isLive, the limits, maxBlocks: MSG_MAX_BLOCKS, mentions: ci.mentions) + ft: the blocks with the chat link removed (rendering plan) + with a teaser blocks of "Truncated item"; link text: the key "Read more" of MsgContentView.swift:379 + openModal = showFullMessage(chat, im, ci, chatLayout); chatLayout: @Environment(\.chatLayout) +Shared/Views/Chat/ChatItemView.swift, EnvironmentValues (:12-39) + textSearch: Bool default false +Shared/Views/Chat/ChatView.swift + chatItemWithMenu (:2145-2146) .environment(\.textSearch, searchText is not blank) + chatItemsList (:996) on a change of the blank state of searchText: scrollView.updateItems(mergedItems.boxedValue.items, true) +Shared/Views/Chat/ChatItem/FullMessageView.swift + showFullMessage(_ chat, _ im, _ ci, _ chatLayout) + showAppSheet (AppSheet.swift:34), as showMarkdownFile (MarkdownFileView.swift:31) + screen protection by its redaction (AppSheet.swift:22-30) + FullMessageView NavigationView with an inline title, as MarkdownFileView.swift:170-213 + title: the key "Saved message", "Sent message" or "Received message" of ChatItemInfoView.swift:61-67 + toolbar: "Copy" + ScrollView with LazyVStack of the items of "Viewer"; id: index and text of the line + @ObservedObject im; dismissed by the rules of "Viewer" + MessageLineView one item of "Viewer": MsgBlockView of the rendering plan with the chatLayout of "Viewer" + showSecrets state and msgTextResultView with sendCommand, as TextBubble (ChatItemInfoView.swift:308-322) +``` + +## Shared code + +``` +function this plan layout plan rendering plan +MarkdownText (TextItemView.kt:125) openModal chatLayout Strong; text of one block +CIMarkdownText (FramedItemView.kt:445) teaser; openModal paddings MsgBlocksView +messageText, MsgContentView openModal chatLayout strong; text of one block +ciMsgContentView (FramedItemView.swift:359) teaser; openModal paddings; frame MsgBlocksView +selectedItemCopiedText (TextSelection.kt:264) blocks of the item offsets in markdownSegments +snapOffset (TextSelection.kt:295) blocks of the item offsets in markdownSegments +chatItemWithMenu (ChatView.swift:2121) textSearch alignment coordinate space +``` + +Order of the edits: rendering plan part 1, layout plan, this plan, rendering plan part 2. Release 1: the segment list of the item as one paragraph; release 2: the blocks of the core. + +## Other views + +``` +chat list preview msgPrefix(markdownSegments(blocks), MSG_PREVIEW_UNITS) before the renderer: ChatPreviewView.kt:277, ChatPreviewView.swift:302 +compose context item the same: ContextItemView.kt:59, ContextItemView.swift:74 +quote in an item the same: FramedItemView.kt:61, FramedItemView.swift:337; 2 or 3 lines, as now (FramedItemView.kt:96-99, FramedItemView.swift:324-327) +notification text the same before hideSecrets, with displayText ft.text: NtfManager.kt:134, Notifications.swift:200 +hidden deleted item unchanged: header (ChatItemView.kt:698-700, ChatItemView.swift:75-76) +emoji only item unchanged (isShortEmoji) +scroll to item unchanged (ChatView.kt:3139, ChatView.swift:812) +item info unchanged: versions in full (ChatItemInfoView.kt:55-73, ChatItemInfoView.swift:298-322) +copy, share, edit unchanged: whole ci.content.text (ChatItemView.kt:426-448, 464-468; ChatView.swift:2479-2506, 2546-2557) +``` + +## Docs + +- `apps/multiplatform/spec/client/chat-view.md:319`: `TextItemView.kt` row: teaser, viewer. +- `apps/multiplatform/spec/client/navigation.md:266-267`: message viewer in the rows of `ModalManager.center` and `ModalManager.end`. +- `apps/ios/spec/client/chat-view.md:188-192`: `MsgContentView`: teaser; new `FullMessageView` entry. +- Text row, teaser and "Read more": `apps/multiplatform/product/views/chat.md:57`, `apps/ios/product/views/chat.md:52`. + +## Tests + +`apps/multiplatform/common/src/commonTest/kotlin/chat/simplex/app/MsgTeaserTest.kt`, `msgTeaser` with `lineChars` 32, limits 24 and 16: + +``` +24 lines "a" in one paragraph null +25 lines "a" in one paragraph lines 1 to 16 +"word " 600 times 102 words, 509 units +live: "a" 288 times, LF, "b" 225 times line 1, LF, "b" 224 times +one-line command: "/" and 2999 units one segment without format, 512 units +secret of 3000 units one secret segment, 512 units +1500 surrogate pairs 256 pairs +U+4E00 400 times 256 units +24 lines "a" separated by CRLF; 25 by U+2028 null; lines 1 to 16 +25 mentions of 41 units without white space, spaced 12 mentions +13 paragraphs of one line 8 paragraphs +30 list items of one line 16 list items +heading, then a paragraph of 30 lines heading; paragraph of 14 lines +code block of 30 lines header and 15 lines +terminal limits, one code line of 124,816 units code block of one line of 12,800 units +table: header row and 30 rows of short cells header row and 15 rows +table: header row, one row with a cell of 3000 units empty teaser +paragraph of 15 lines, rule, paragraph of 15 lines paragraph of 15 lines +quote of 30 lines quote of 16 lines +maxBlocks 3, 4 list items of one line 3 list items +msgPrefix: "word " 600 times, 1000 units 200 words, 999 units +``` + +`TextSelectionTest.kt`, `getSelectedCopiedText`: three items with the middle one truncated give the whole text of the middle item; a partly selected truncated item gives the selected teaser text. iOS: the vectors of `msgTeaser` and `msgPrefix`, checked as messages in a chat. + +Checks, Android, desktop and iOS: + +1. Classic layout: a message above the limit is drawn as the teaser with "Read more"; a message at the limit is drawn in full; the teaser has about 16 lines on a phone and in a desktop window. +2. Tap on "Read more" in the main chat view: the viewer with the whole message; each tap action of the item text; after a command tap the viewer is closed; after back the chat is at the same scroll position. +3. Viewer opened from a chat with the terminal layout: `*x*` is drawn by the rule of the layout plan. +4. Live item above the teaser cost: the item height is constant while the item is updated; the updates are shown in the open viewer. The viewer is closed when its item is deleted, marked deleted or removed by a timer. +5. Search with a match after the teaser: the match is drawn; after the search is closed the item is a teaser; iOS cell heights are correct in both states. +6. Desktop: with a drag selection over a truncated item the highlight ends at the teaser end; Ctrl+C over three items gives the whole text of the middle item. Kotlin: after a long press on "Read more" the item menu is shown. +7. Item of 124,816 bytes: chat list preview, compose context item, quote and notification show its first 1000 units. +8. Teaser with each block type last: code, table, list item, quote; the link paragraph and the meta after it. + +## Interfaces + +``` +both constants of "Teaser rule" + msgTeaser + msgPrefix + showFullMessage +Kotlin itemTeaser + MarkdownText(openModal) + Format.Modal.Message + ItemContext: the line and unit limits of "Limits" + SelectionManager.itemBlocks +iOS MsgContentView.openModal + Format.modalMessage + EnvironmentValues.textSearch +``` + +## Depends on + +``` +agent profile plan ChatLayout: Classic, Terminal, Unknown(type) + ChatInfo.activeChatLayout, computed by the core (patch 01 rework; ChatInfo.chatLayout on ep/llm-ux) +layout plan LocalChatLayout, default ChatLayout.Classic + EnvironmentValues.chatLayout, default .classic + MSG_TERMINAL_MAX_LINES = 500 + MSG_TERMINAL_TEASER_LINES = 400 + the chatLayout parameter of the texts + the forced cell reload on iOS +block Markdown plan MarkdownBlock of "Types", release 2: blocks without nesting; rows[0] of a table is the header row + formattedText == null for a plain text + markdownSegments: the one-text form +rendering plan app types of MarkdownBlock, in release 1 without JSON use + markdownSegments in the apps from release 1; iOS: in SimpleXChat, used by the NSE + MsgBlocksView: a teaser list with another block kind and the link paragraph; gaps between blocks (2.2) + meta and typing indicator at the end of the last paragraph (2.4); openModal passed to every text (3) + MsgBlockView(block, range, first, last): range in the display text of the block, null for the whole block + MSG_MAX_BLOCKS + CODE_MAX_WIDTH + horizontal scroll of code and tables; with wrapped code lines: chars MSG_LINE_CHARS for code + removal of the chat link from the blocks + desktop selection over markdownSegments, the link paragraph excluded (5) + a list of paragraphs only: one text, markdownSegments, by MarkdownText and messageText (1) +message parts plan item text of at most maxMsgParts × maxEncodedMsgLength = 124,816 bytes +``` + +## Open questions + +1. "Read more" in the terminal layout: the product owner said that the terminal layout does not need read more. A terminal row above 500 lines (16,000 characters on a phone, 40,000 on desktop) or above `MSG_MAX_BLOCKS` blocks is a teaser; smaller texts are drawn in full. Recommended: keep these limits: one row of 124,816 bytes is one lazy item, laid out as a whole. +2. Maximum text of one item: 8 parts (124,816 bytes, 16 items per recipient queue) or 32 parts (499,264 bytes, 4 items). Recommended: 8. +3. `*x*` is kept as `Bold` (bold in the classic layout, italic in the terminal layout) and `**x**` is `Strong` (bold in both). Surfaces of a terminal chat with italic `*x*`: rows only, or rows and the viewer. Recommended: rows and the viewer; bold in previews, compose context and item info. +4. Read more in the classic layout: truncated above 24 lines, teaser of 16 lines (line: 32 units on phones, 80 on desktop); a viewer screen in place of expansion in the item; live items truncated while updated; search results with the terminal limits. Recommended: accept; thresholds adjusted after a trial. +5. Release grouping: three releases: (1) message priority, chat layout, terminal layout, read more, `Strong` with rendering part 1; (2) blocks of the core with rendering part 2; (3) message parts. Recommended: yes. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 0525fdc4af..46fcfd405e 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -172,6 +172,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification Simplex.Chat.Store.Postgres.Migrations.M20261016_badge_store_receipts + Simplex.Chat.Store.Postgres.Migrations.M20261019_chat_layout else exposed-modules: Simplex.Chat.Archive @@ -354,6 +355,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification Simplex.Chat.Store.SQLite.Migrations.M20261016_badge_store_receipts Simplex.Chat.Store.SQLite.Migrations.M20261018_autoincrement_ids + Simplex.Chat.Store.SQLite.Migrations.M20261019_chat_layout other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/AppSettings.hs b/src/Simplex/Chat/AppSettings.hs index 22938dd48c..d7a13529e4 100644 --- a/src/Simplex/Chat/AppSettings.hs +++ b/src/Simplex/Chat/AppSettings.hs @@ -58,7 +58,8 @@ data AppSettings = AppSettings uiCurrentThemeIds :: Maybe (Map ThemeColorScheme Text), uiThemes :: Maybe [UITheme], oneHandUI :: Maybe Bool, - chatBottomBar :: Maybe Bool + chatBottomBar :: Maybe Bool, + uiChatLayout :: Maybe ChatLayout } deriving (Show) @@ -109,7 +110,8 @@ defaultAppSettings = uiCurrentThemeIds = Nothing, uiThemes = Nothing, oneHandUI = Just True, - chatBottomBar = Just True + chatBottomBar = Just True, + uiChatLayout = Just CLClassic } defaultParseAppSettings :: AppSettings @@ -147,7 +149,8 @@ defaultParseAppSettings = uiCurrentThemeIds = Nothing, uiThemes = Nothing, oneHandUI = Nothing, - chatBottomBar = Nothing + chatBottomBar = Nothing, + uiChatLayout = Nothing } combineAppSettings :: AppSettings -> AppSettings -> AppSettings @@ -185,7 +188,8 @@ combineAppSettings platformDefaults storedSettings = uiCurrentThemeIds = p uiCurrentThemeIds, uiThemes = p uiThemes, oneHandUI = p oneHandUI, - chatBottomBar = p chatBottomBar + chatBottomBar = p chatBottomBar, + uiChatLayout = p uiChatLayout } where p :: (AppSettings -> Maybe a) -> Maybe a @@ -240,6 +244,7 @@ instance FromJSON AppSettings where uiThemes <- p "uiThemes" oneHandUI <- p "oneHandUI" chatBottomBar <- p "chatBottomBar" + uiChatLayout <- p "uiChatLayout" pure AppSettings { appPlatform, @@ -274,7 +279,8 @@ instance FromJSON AppSettings where uiCurrentThemeIds, uiThemes, oneHandUI, - chatBottomBar + chatBottomBar, + uiChatLayout } where p key = v .:? key <|> pure Nothing diff --git a/src/Simplex/Chat/Bot.hs b/src/Simplex/Chat/Bot.hs index 3b1c4c6da0..d08a1994f4 100644 --- a/src/Simplex/Chat/Bot.hs +++ b/src/Simplex/Chat/Bot.hs @@ -67,8 +67,8 @@ initializeBotAddress' logAddress pqRatchet_ doAutoAccept cc = do putStrLn $ "Bot's contact address is: " <> B.unpack (maybe (strEncode uri) strEncode shortUri) when (isJust shortUri) $ putStrLn $ "Full contact address for old clients: " <> B.unpack (strEncode uri) let aa = if doAutoAccept then Just AutoAccept {acceptIncognito = False} else Nothing - settings = AddressSettings {businessAddress = False, autoAccept = aa, autoReply = Nothing} - void $ sendChatCmd cc $ SetAddressSettings Nothing settings + settings = AddressSettings {businessAddress = False, autoAccept = aa, chatLayout = Nothing, autoReply = Nothing} + void $ sendChatCmd cc $ SetAddressSettings Nothing settings Nothing sendMessage :: ChatController -> Contact -> Text -> IO () sendMessage cc ct = sendComposedMessage cc ct Nothing . MCText diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index f74b0c5a78..c2095ea9ef 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -593,7 +593,7 @@ data ChatCommand | APISetProfileAddress {userId :: UserId, enable :: Bool} | SetProfileAddress Bool | APISetAddressSettings {userId :: UserId, pqRatchet :: Maybe Bool, settings :: AddressSettings} - | SetAddressSettings {pqRatchet :: Maybe Bool, settings :: AddressSettings} + | SetAddressSettings {pqRatchet :: Maybe Bool, settings :: AddressSettings, chatLayout :: Maybe (Maybe ChatLayout)} | AcceptContact IncognitoEnabled ContactName | RejectContact ContactName Bool | ForwardMessage {toChatName :: ChatName, fromContactName :: ContactName, forwardedMsg :: Text} @@ -679,6 +679,7 @@ data ChatCommand | SetGroupFeature AGroupFeatureNoRole GroupName GroupFeatureEnabled | SetGroupFeatureRole AGroupFeatureRole GroupName GroupFeatureEnabled (Maybe GroupMemberRole) | SetGroupMemberAdmissionReview GroupName (Maybe MemberCriteria) + | SetGroupChatLayout GroupName (Maybe ChatLayout) | SetUserTimedMessages Bool -- UserId (not used in UI) | SetContactTimedMessages ContactName (Maybe TimedMessagesEnabled) | SetGroupTimedMessages GroupName (Maybe Int) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f347db40ee..ba76efd741 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -100,6 +100,7 @@ import Simplex.Chat.Store.Shared import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared +import Simplex.Chat.Types.UITheme (ChatLayout (..)) import Simplex.Chat.Util (liftIOEither, zipWith3') import qualified Simplex.Chat.Util as U import Simplex.Chat.Web (webPreviewWorker) @@ -2196,14 +2197,14 @@ processChatCommand cxt nm = \case pure $ CRConnectionPlan user ccLink planSimplexName otherSimplexName plan APIConnectPlan _ Nothing _ _ -> throwChatError CEInvalidConnReq APIPrepareContact userId accLink verifiedDomain contactSLinkData -> withUserId userId $ \user -> do - let ContactShortLinkData {profile, message, business} = contactSLinkData + let ContactShortLinkData {profile, message, business, chatLayout} = contactSLinkData welcomeSharedMsgId <- forM message $ \_ -> getSharedMsgId case accLink of ACCL SCMContact ccLink | business -> do let Profile {preferences} = profile groupPreferences = maybe defaultBusinessGroupPrefs businessGroupPrefs preferences - groupProfile = businessGroupProfile profile groupPreferences + groupProfile = businessGroupProfile profile groupPreferences chatLayout gVar <- asks random (gInfo, hostMember_) <- withStore $ \db -> createPreparedGroup db gVar cxt user groupProfile True ccLink welcomeSharedMsgId False GRMember Nothing verifiedDomain hostMember <- maybe (throwCmdError "no host member") pure hostMember_ @@ -2217,8 +2218,9 @@ processChatCommand cxt nm = \case Just (AChatItem SCTGroup dir _ ci) -> Chat cInfo [CChatItem dir ci] emptyChatStats {unreadCount = 1, minUnreadItemId = chatItemId' ci} _ -> Chat cInfo [] emptyChatStats pure $ CRNewPreparedChat user $ AChat SCTGroup chat - ACCL _ (CCLink cReq sLnk) -> do - ct <- withStore $ \db -> createPreparedContact db cxt user (linkPresHeader <$> sLnk) profile accLink welcomeSharedMsgId (True <$ verifiedDomain) + ACCL m (CCLink cReq sLnk) -> do + let ctLayout = case m of SCMContact -> chatLayout; SCMInvitation -> Nothing + ct <- withStore $ \db -> createPreparedContact db cxt user (linkPresHeader <$> sLnk) profile accLink welcomeSharedMsgId ctLayout (True <$ verifiedDomain) void $ createChatItem user (CDDirectSnd ct) False CIChatBanner Nothing Nothing (Just epochStart) let cd = CDDirectRcv ct createItem sharedMsgId content = createChatItem user cd False content sharedMsgId Nothing Nothing @@ -2540,8 +2542,9 @@ processChatCommand cxt nm = \case ucl'' <- if shortLinkDataSet then setMyAddressData False pqInitKeys user ucl' else pure ucl' withFastStore' $ \db -> updateUserAddressSettings db userContactLinkId settings pure $ CRUserContactLinkUpdated user ucl'' - SetAddressSettings pqRatchet_ settings -> withUser $ \User {userId} -> - processChatCommand cxt nm $ APISetAddressSettings userId pqRatchet_ settings + SetAddressSettings pqRatchet_ settings chatLayout_ -> withUser $ \user@User {userId} -> do + UserContactLink {addressSettings = AddressSettings {chatLayout}} <- withFastStore (`getUserAddress` user) + processChatCommand cxt nm $ APISetAddressSettings userId pqRatchet_ (settings :: AddressSettings) {chatLayout = fromMaybe chatLayout chatLayout_} AcceptContact incognito cName -> withUser $ \User {userId} -> do connReqId <- withFastStore $ \db -> getContactRequestIdByName db userId cName processChatCommand cxt nm $ APIAcceptContact incognito connReqId @@ -3676,6 +3679,8 @@ processChatCommand cxt nm = \case case memberAdmission of Nothing -> p {memberAdmission = Just (emptyGroupMemberAdmission :: GroupMemberAdmission) {review = reviewAdmissionApplication}} Just ma -> p {memberAdmission = Just (ma :: GroupMemberAdmission) {review = reviewAdmissionApplication}} + SetGroupChatLayout gName chatLayout -> + updateGroupProfileByName gName $ \p -> p {chatLayout} SetUserTimedMessages onOff -> withUserProfileLock "updateProfile" $ withUser $ \user@User {profile} -> do let allowed = if onOff then FAYes else FANo pref = TimedMessagesPreference allowed Nothing @@ -4836,7 +4841,8 @@ processChatCommand cxt nm = \case contactShortLinkData p settings = let msg = autoReply =<< settings business = maybe False businessAddress settings - contactData = ContactShortLinkData p msg business Nothing + layout = settings >>= \AddressSettings {chatLayout} -> chatLayout + contactData = ContactShortLinkData p msg business layout Nothing in encodeShortLinkData contactData relayShortLinkData :: Profile -> UserLinkData relayShortLinkData Profile {displayName, fullName, shortDescr, image} = @@ -6617,7 +6623,7 @@ chatCommandP = "/_profile_address " *> (APISetProfileAddress <$> A.decimal <* A.space <*> onOffP), ("/profile_address " <|> "/pa ") *> (SetProfileAddress <$> onOffP), "/_address_settings " *> (APISetAddressSettings <$> A.decimal <*> optional (" pq_ratchet=" *> onOffP) <* A.space <*> jsonP), - "/auto_accept" *> (SetAddressSettings <$> optional (" pq_ratchet=" *> onOffP) <* A.space <*> autoAcceptP), + "/auto_accept" *> (uncurry . SetAddressSettings <$> optional (" pq_ratchet=" *> onOffP) <* A.space <*> autoAcceptP), ("/accept" <|> "/ac") *> (AcceptContact <$> incognitoP <* A.space <* char_ '@' <*> displayNameP), ("/reject " <|> "/rc ") *> char_ '@' *> (RejectContact <$> displayNameP <*> (" notify" $> True <|> pure False)), ("/markdown" <|> "/m") $> ChatHelp HSMarkdown, @@ -6653,6 +6659,7 @@ chatCommandP = "/set support #" *> (SetGroupFeature (AGFNR SGFSupport) <$> displayNameP <*> (A.space *> strP)), "/set links #" *> (SetGroupFeatureRole (AGFR SGFSimplexLinks) <$> displayNameP <*> _strP <*> optional memberRole), "/set admission review #" *> (SetGroupMemberAdmissionReview <$> displayNameP <*> (A.space *> memberCriteriaP)), + "/set layout #" *> (SetGroupChatLayout <$> displayNameP <* A.space <*> chatLayoutP_), ("/incognito" <* optional (A.space *> onOffP)) $> ChatHelp HSIncognito, "/set device name " *> (SetLocalDeviceName <$> textP), "/list remote hosts" $> ListRemoteHosts, @@ -6796,12 +6803,14 @@ chatCommandP = { directMessages = Just DirectMessagesGroupPreference {enable = FEOn, role = Nothing}, history = Just HistoryGroupPreference {enable = FEOn} } - pure GroupProfile {displayName = gName, fullName = "", shortDescr, description = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences, memberAdmission = Nothing} + pure GroupProfile {displayName = gName, fullName = "", shortDescr, description = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences, memberAdmission = Nothing, chatLayout = Nothing} channelProfile = do p@GroupProfile {groupPreferences = prefs_} <- groupProfile let prefs = (fromMaybe emptyGroupPrefs prefs_) {support = Just SupportGroupPreference {enable = FEOff}} :: GroupPreferences pure p {groupPreferences = Just prefs} memberCriteriaP = ("all" $> Just MCAll) <|> ("off" $> Nothing) + chatLayoutP = ("classic" $> CLClassic) <|> ("terminal" $> CLTerminal) + chatLayoutP_ = ("default" $> Nothing) <|> (Just <$> chatLayoutP) shortDescrP = do descr <- A.takeWhile1 isSpace *> (T.dropWhileEnd isSpace <$> textP) <|> pure "" pure $ if T.null descr then Nothing else Just $ T.take 160 descr @@ -6922,10 +6931,12 @@ chatCommandP = dbEncryptionConfig currentKey newKey = DBEncryptionConfig {currentKey, newKey, keepKey = Just False} #endif -- TODO [short links] parser for address settings - autoAcceptP = ifM onOffP (businessAA <|> addressAA) (pure $ AddressSettings False Nothing Nothing) + autoAcceptP = ifM onOffP (businessAA <|> addressAA) (toSettings False Nothing <$> layoutP <*> pure Nothing) where - addressAA = AddressSettings False <$> (Just . AutoAccept <$> (" incognito=" *> onOffP <|> pure False)) <*> autoReply - businessAA = " business" *> (AddressSettings True (Just $ AutoAccept False) <$> autoReply) + addressAA = toSettings False <$> (Just . AutoAccept <$> (" incognito=" *> onOffP <|> pure False)) <*> layoutP <*> autoReply + businessAA = " business" *> (toSettings True (Just $ AutoAccept False) <$> layoutP <*> autoReply) + toSettings business autoAccept chatLayout_ autoReply_ = (AddressSettings business autoAccept Nothing autoReply_, chatLayout_) + layoutP = optional (" layout=" *> chatLayoutP_) autoReply = optional (A.space *> msgContentP) rcCtrlAddressP = RCCtrlAddress <$> ("addr=" *> strP) <*> (" iface=" *> (quotedP <|> text1P)) quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"') diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index cd89881e4f..f69772e96c 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -78,6 +78,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared +import Simplex.Chat.Types.UITheme (ChatLayout) import Simplex.Chat.Util (encryptFile, shuffle) import Simplex.FileTransfer.Description (FileDescriptionURI (..), ValidFileDescription) import qualified Simplex.FileTransfer.Description as FD @@ -1125,10 +1126,11 @@ acceptGroupJoinSendRejectAsync agentAcceptContactAsync cmdId acId False cReqInvId msg PQSupportOff subMode pure m -acceptBusinessJoinRequestAsync :: User -> Int64 -> GroupInfoKeys -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember) +acceptBusinessJoinRequestAsync :: User -> Int64 -> Maybe ChatLayout -> GroupInfoKeys -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember) acceptBusinessJoinRequestAsync user uclId + chatLayout (GIK gInfo@GroupInfo {membership = GroupMember {memberRole = userRole, memberId = userMemberId}} gks) clientMember@GroupMember {groupMemberId, memberId} UserContactRequest {agentInvitationId = AgentInvId cReqInvId, cReqChatVRange, xContactId} = do @@ -1143,7 +1145,7 @@ acceptBusinessJoinRequestAsync fromMemberName = displayName, fromMemberKey = Just $ groupMemberKey gks, invitedMember = MemberIdRole memberId GRMember, - groupProfile = businessGroupProfile userProfile groupPreferences, + groupProfile = businessGroupProfile userProfile groupPreferences chatLayout, accepted = Just GAAccepted, -- This refers to the "title member" that defines the group name and profile. -- This coincides with fromMember to be current user when accepting the connecting user, @@ -1213,9 +1215,9 @@ rejectRelayInvitationAsync user uclId cxt groupRelayInv presHeader_ invId reqCha createJoiningMemberConnection db user uclId (cmdId, acId) chatV reqChatVRange groupMemberId subMode agentAcceptContactAsync cmdId acId False invId msg PQSupportOff subMode -businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile -businessGroupProfile Profile {displayName, fullName, shortDescr, description, image} groupPreferences = - GroupProfile {displayName, fullName, description, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing} +businessGroupProfile :: Profile -> GroupPreferences -> Maybe ChatLayout -> GroupProfile +businessGroupProfile Profile {displayName, fullName, shortDescr, description, image} groupPreferences chatLayout = + GroupProfile {displayName, fullName, description, shortDescr, image, publicGroup = Nothing, groupPreferences = Just groupPreferences, memberAdmission = Nothing, chatLayout} introduceToModerators :: StoreCxt -> User -> GroupInfoKeys -> GroupMember -> CM () introduceToModerators cxt user gInfo@(GIK g@GroupInfo {groupId} _) m@GroupMember {memberRole, memberId} = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index e7738cddc2..aa4a0205ad 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -1497,7 +1497,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag -- ##### Contact requests (regular and business contacts) ##### Nothing -> do let UserContactLink {connLinkContact = CCLink connReq _, shortLinkDataSet, addressSettings} = ucl - AddressSettings {autoAccept} = addressSettings + AddressSettings {autoAccept, chatLayout} = addressSettings isSimplexTeam = sameConnReqContact connReq adminContactReq gVar <- asks random withStore (\db -> createOrUpdateContactRequest db gVar cxt user uclId ucl isSimplexTeam invId chatVRange (Just reqHeader) p xContactId_ welcomeMsgId_ requestMsg_ reqPQSup rejectionSupported) >>= \case @@ -1540,7 +1540,7 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag ct' <- acceptContactRequestAsync user uclId ct ucr incognitoProfile toView $ CEvtAcceptingContactRequest user ct' Just (REBusinessChat g@(GIK gInfo _) clientMember) -> do - (_gInfo', _clientMember') <- acceptBusinessJoinRequestAsync user uclId g clientMember ucr + (_gInfo', _clientMember') <- acceptBusinessJoinRequestAsync user uclId chatLayout g clientMember ucr let cd = CDGroupRcv gInfo Nothing clientMember void $ case prevUcr_ of Just UserContactRequest {requestSharedMsgId = prevSharedMsgId_} -> diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index a4fb12cc72..62b00bb46f 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -56,6 +56,7 @@ import Simplex.Chat.Options.DB (FromField (..), ToField (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared +import Simplex.Chat.Types.UITheme (ChatLayout) import qualified Simplex.FileTransfer.Description as FD import Simplex.Messaging.Agent.Store.DB (blobFieldDecoder, fromTextField_) import Simplex.Messaging.Compression (Compressed, compress1, decompress1, decompressedSize) @@ -1612,6 +1613,7 @@ data ContactShortLinkData = ContactShortLinkData { profile :: Profile, message :: Maybe MsgContent, business :: Bool, + chatLayout :: Maybe ChatLayout, -- set by the receiving client for the UI: the link profile's badge, verified and crypto-free. -- never part of the published link data (the link carries the proof inside profile). localBadge :: Maybe LocalBadge diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 0a58b1cfd9..66abd939d3 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -122,7 +122,7 @@ getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = d c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, p.preferences_json, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, cr2.rejection_supported, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, - c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, c.chat_layout, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contacts c @@ -132,7 +132,7 @@ getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = d |] (userId, contactId, CSActive) toContact' :: UTCTime -> Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact - toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, encodedPrefs, receivedPrefs, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, rejectionSupported_, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) = + toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, encodedPrefs, receivedPrefs, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, rejectionSupported_, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL, chatLayout) :. badgeRow :. domainRow) = let preferences = chatPrefsFromRow encodedPrefs receivedPrefs profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} @@ -141,7 +141,7 @@ getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = d preparedContact = toPreparedContact preparedContactRow contactRequest = UserContactRequestRef <$> contactRequestId <*> (unBI <$> rejectionSupported_) groupDirectInv = toGroupDirectInvitation groupDirectInvRow - in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactRequest, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactRequest, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatLayout, chatDeleted, customData} getGroupAndMember_ :: Int64 -> Connection -> ExceptT StoreError IO ((GroupInfo, GroupKeysRow), GroupMember) getGroupAndMember_ groupMemberId c = do currentTs <- liftIO getCurrentTime @@ -155,7 +155,7 @@ getConnectionEntityKeys db cxt user@User {userId, userContactId} agentConnId = d -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, gp.chat_layout, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, diff --git a/src/Simplex/Chat/Store/ContactRequest.hs b/src/Simplex/Chat/Store/ContactRequest.hs index 7db176bd50..897cda4c47 100644 --- a/src/Simplex/Chat/Store/ContactRequest.hs +++ b/src/Simplex/Chat/Store/ContactRequest.hs @@ -120,7 +120,7 @@ createOrUpdateContactRequest ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.chat_layout, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, diff --git a/src/Simplex/Chat/Store/Direct.hs b/src/Simplex/Chat/Store/Direct.hs index 698feb409f..2700aa8156 100644 --- a/src/Simplex/Chat/Store/Direct.hs +++ b/src/Simplex/Chat/Store/Direct.hs @@ -324,7 +324,7 @@ getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.chat_layout, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection @@ -404,12 +404,12 @@ createIncognitoProfile db User {userId} p = do createdAt <- getCurrentTime createIncognitoProfile_ db userId createdAt p -createPreparedContact :: DB.Connection -> StoreCxt -> User -> Maybe ProofPresHeader -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> Maybe Bool -> ExceptT StoreError IO Contact -createPreparedContact db cxt user presHeader_ p connLinkToConnect welcomeSharedMsgId verified_ = do +createPreparedContact :: DB.Connection -> StoreCxt -> User -> Maybe ProofPresHeader -> Profile -> ACreatedConnLink -> Maybe SharedMsgId -> Maybe ChatLayout -> Maybe Bool -> ExceptT StoreError IO Contact +createPreparedContact db cxt user presHeader_ p connLinkToConnect welcomeSharedMsgId chatLayout verified_ = do currentTs <- liftIO getCurrentTime let prepared = Just (connLinkToConnect, welcomeSharedMsgId) ctUserPreferences = newContactUserPrefs user p - ct <- getContact db cxt user =<< createContact_ db cxt user presHeader_ p ctUserPreferences prepared "" currentTs + ct <- getContact db cxt user =<< createContact_ db cxt user presHeader_ p ctUserPreferences prepared chatLayout "" currentTs liftIO $ maybe (pure ct) (setContactDomainVerified db user ct) verified_ updatePreparedContactUser :: DB.Connection -> StoreCxt -> User -> Contact -> User -> ExceptT StoreError IO Contact @@ -454,7 +454,7 @@ createDirectContact :: DB.Connection -> StoreCxt -> User -> Connection -> Maybe createDirectContact db cxt user Connection {connId, localAlias} presHeader_ p = do currentTs <- liftIO getCurrentTime let ctUserPreferences = newContactUserPrefs user p - contactId <- createContact_ db cxt user presHeader_ p ctUserPreferences Nothing localAlias currentTs + contactId <- createContact_ db cxt user presHeader_ p ctUserPreferences Nothing Nothing localAlias currentTs liftIO $ DB.execute db "UPDATE connections SET contact_id = ?, updated_at = ? WHERE connection_id = ?" (contactId, currentTs, connId) getContact db cxt user contactId @@ -927,6 +927,7 @@ createContactFromRequest db user@User {userId, profile = LocalProfile {preferenc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, + chatLayout = Nothing, chatDeleted = False, customData = Nothing } @@ -976,7 +977,7 @@ getContact_ db cxt user@User {userId} contactId deleted = do ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.chat_layout, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 839a3d5746..39b32b6e79 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -382,7 +382,7 @@ setGroupLinkShortLink db gLnk@GroupLink {userContactLinkId, connLinkContact = CC -- | creates completely new group with a single member - the current user createNewGroup :: DB.Connection -> StoreCxt -> User -> GroupProfile -> Maybe Profile -> MemberId -> GroupKeys -> Maybe Int64 -> ExceptT StoreError IO GroupInfo createNewGroup db cxt user@User {userId} groupProfile incognitoProfile memberId groupKeys publicMemberCount_ = ExceptT $ do - let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} = groupProfile + let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission, chatLayout} = groupProfile (groupType_, groupLink_, publicGroupId_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) Nothing -> (Nothing, Nothing, Nothing) @@ -406,11 +406,11 @@ createNewGroup db cxt user@User {userId} groupProfile incognitoProfile memberId (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, group_web_page, group_domain, domain_web_page, allow_embedding, group_domain_proof, - user_id, member_admission, created_at, updated_at, preferences, preferences_json) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + user_id, member_admission, chat_layout, created_at, updated_at, preferences, preferences_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup - :. (userId, memberAdmission, currentTs, currentTs) :. prefsToRow groupPreferences) + :. (userId, memberAdmission, chatLayout, currentTs, currentTs) :. prefsToRow groupPreferences) profileId <- insertedRowId db DB.execute db @@ -480,7 +480,7 @@ createGroupInvitation db cxt user@User {userId} inviter peerChatVRange GroupInvi DB.query db "SELECT group_id FROM groups WHERE inv_queue_info = ? AND user_id = ? LIMIT 1" (connRequest, userId) createGroupInvitation_ :: ExceptT StoreError IO (GroupInfoKeys, GroupMemberId) createGroupInvitation_ = do - let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission} = groupProfile + let GroupProfile {displayName, fullName, shortDescr, description, image, groupPreferences, memberAdmission, chatLayout} = groupProfile fullGroupPreferences = mergeGroupPreferences groupPreferences ExceptT $ withLocalDisplayName db userId displayName $ \localDisplayName -> runExceptT $ do @@ -488,8 +488,8 @@ createGroupInvitation db cxt user@User {userId} inviter peerChatVRange GroupInvi groupId <- liftIO $ do DB.execute db - "INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, member_admission, created_at, updated_at, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?)" - ((displayName, fullName, shortDescr, description, image, userId, memberAdmission, currentTs, currentTs) :. prefsToRow groupPreferences) + "INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, member_admission, chat_layout, created_at, updated_at, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" + ((displayName, fullName, shortDescr, description, image, userId, memberAdmission, chatLayout, currentTs, currentTs) :. prefsToRow groupPreferences) profileId <- insertedRowId db DB.execute db @@ -927,7 +927,7 @@ createGroupViaLink' createGroup_ :: DB.Connection -> UserId -> GroupProfile -> Maybe (CreatedLinkContact, Maybe SharedMsgId) -> Maybe BusinessChatInfo -> Bool -> Maybe RelayStatus -> Maybe Int64 -> Maybe C.PrivateKeyEd25519 -> UTCTime -> ExceptT StoreError IO (GroupId, Text) createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus publicMemberCount_ memberPrivKey_ currentTs = ExceptT $ do - let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} = groupProfile + let GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission, chatLayout} = groupProfile (groupType_, groupLink_, publicGroupId_) = case publicGroup of Just PublicGroupProfile {groupType, groupLink, publicGroupId} -> (Just groupType, Just groupLink, Just publicGroupId) Nothing -> (Nothing, Nothing, Nothing) @@ -940,11 +940,11 @@ createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus p (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, group_web_page, group_domain, domain_web_page, allow_embedding, group_domain_proof, - user_id, member_admission, created_at, updated_at, preferences, preferences_json) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + user_id, member_admission, chat_layout, created_at, updated_at, preferences, preferences_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) |] ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup - :. (userId, memberAdmission, currentTs, currentTs) :. prefsToRow groupPreferences) + :. (userId, memberAdmission, chatLayout, currentTs, currentTs) :. prefsToRow groupPreferences) profileId <- insertedRowId db DB.execute db @@ -1957,7 +1957,8 @@ createRelayRequestGroup db cxt user@User {userId} GroupRelayInvitation {fromMemb image = Nothing, publicGroup = Nothing, groupPreferences = Nothing, - memberAdmission = Nothing + memberAdmission = Nothing, + chatLayout = Nothing } (_, memberPrivKey) <- atomically $ C.generateKeyPair (drg cxt) (groupId, _groupLDN) <- createGroup_ db userId placeholderProfile Nothing Nothing True (Just relayStatus) Nothing (Just memberPrivKey) currentTs @@ -2726,7 +2727,7 @@ createMemberConnection_ db userId groupMemberId agentConnId chatV peerChatVRange createConnection_ db userId ConnMember (Just groupMemberId) agentConnId ConnNew chatV peerChatVRange viaContact Nothing Nothing connLevel currentTs subMode PQSupportOff updateGroupProfile :: DB.Connection -> User -> GroupInfo -> GroupProfile -> ExceptT StoreError IO GroupInfo -updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, publicGroup = oldPublicGroup}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} +updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, groupProfile = GroupProfile {displayName, publicGroup = oldPublicGroup}} p'@GroupProfile {displayName = newName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission, chatLayout} | displayName == newName = liftIO $ do currentTs <- getCurrentTime updateGroupProfile_ currentTs @@ -2762,7 +2763,7 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_type = ?, group_link = ?, group_web_page = ?, group_domain = CASE WHEN ? THEN ? ELSE group_domain END, domain_web_page = ?, allow_embedding = ?, group_domain_proof = ?, - member_admission = ?, updated_at = ? + member_admission = ?, chat_layout = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups @@ -2772,7 +2773,7 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName, ( prefsToRow groupPreferences :. (newName, fullName, shortDescr, description, image, groupType_, groupLink_) :. (groupWebPage_, isJust publicGroup, groupDomain_, domainWebPage_, allowEmbedding_, groupDomainProof_) - :. (memberAdmission, currentTs, userId, groupId) + :. (memberAdmission, chatLayout, currentTs, userId, groupId) ) updateGroup_ ldn currentTs = do DB.execute @@ -2836,17 +2837,17 @@ getGroupProfileById db groupId = SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - gp.preferences, gp.preferences_json, gp.member_admission + gp.preferences, gp.preferences_json, gp.member_admission, gp.chat_layout FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? |] (Only groupId) where - toGroupProfile ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (encodedPrefs, receivedPrefs, memberAdmission)) = + toGroupProfile ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (encodedPrefs, receivedPrefs, memberAdmission, chatLayout)) = let groupPreferences = groupPrefsFromRow encodedPrefs receivedPrefs publicGroupAccess = toPublicGroupAccess accessRow - in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission} + in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission, chatLayout} getGroupInfoByUserContactLinkConnReq :: DB.Connection -> StoreCxt -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo) getGroupInfoByUserContactLinkConnReq db cxt user@User {userId} (cReqSchema1, cReqSchema2) = do @@ -3312,7 +3313,7 @@ createMemberContact quotaErrCounter = 0 } mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito ctConn - pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactRequest = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatDeleted = False, customData = Nothing} + pure Contact {contactId, localDisplayName, profile = memberProfile, activeConn = Just ctConn, contactUsed = True, contactStatus = CSActive, chatSettings = defaultChatSettings, userPreferences, mergedPreferences, createdAt = currentTs, updatedAt = currentTs, chatTs = Just currentTs, preparedContact = Nothing, contactRequestId = Nothing, contactRequest = Nothing, contactGroupMemberId = Just groupMemberId, contactGrpInvSent = False, groupDirectInv = Nothing, chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, chatLayout = Nothing, chatDeleted = False, customData = Nothing} getMemberContact :: DB.Connection -> StoreCxt -> User -> ContactId -> ExceptT StoreError IO (GroupInfo, GroupMember, Contact, ConnReqInvitation) getMemberContact db cxt user contactId = do diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index ce096b81e0..28d1ecbf53 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -57,6 +57,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings import Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group import Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification import Simplex.Chat.Store.Postgres.Migrations.M20261016_badge_store_receipts +import Simplex.Chat.Store.Postgres.Migrations.M20261019_chat_layout import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -113,7 +114,8 @@ schemaMigrations = ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification), - ("20261016_badge_store_receipts", m20261016_badge_store_receipts, Just down_m20261016_badge_store_receipts) + ("20261016_badge_store_receipts", m20261016_badge_store_receipts, Just down_m20261016_badge_store_receipts), + ("20261019_chat_layout", m20261019_chat_layout, Just down_m20261019_chat_layout) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261019_chat_layout.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261019_chat_layout.hs new file mode 100644 index 0000000000..a92f87afe7 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261019_chat_layout.hs @@ -0,0 +1,23 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20261019_chat_layout where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20261019_chat_layout :: Text +m20261019_chat_layout = + [r| +ALTER TABLE contacts ADD COLUMN chat_layout TEXT; +ALTER TABLE group_profiles ADD COLUMN chat_layout TEXT; +ALTER TABLE user_contact_links ADD COLUMN chat_layout TEXT; +|] + +down_m20261019_chat_layout :: Text +down_m20261019_chat_layout = + [r| +ALTER TABLE contacts DROP COLUMN chat_layout; +ALTER TABLE group_profiles DROP COLUMN chat_layout; +ALTER TABLE user_contact_links DROP COLUMN chat_layout; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index d062fc41e2..1e68bb6206 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -795,7 +795,8 @@ CREATE TABLE test_chat_schema.contacts ( grp_direct_inv_from_group_id bigint, grp_direct_inv_from_group_member_id bigint, grp_direct_inv_from_member_conn_id bigint, - grp_direct_inv_started_connection smallint DEFAULT 0 NOT NULL + grp_direct_inv_started_connection smallint DEFAULT 0 NOT NULL, + chat_layout text ); @@ -1092,7 +1093,8 @@ CREATE TABLE test_chat_schema.group_profiles ( domain_web_page bigint, allow_embedding bigint, group_domain_proof text, - preferences_json text + preferences_json text, + chat_layout text ); @@ -1727,7 +1729,8 @@ CREATE TABLE test_chat_schema.user_contact_links ( short_link_contact bytea, short_link_data_set smallint DEFAULT 0 NOT NULL, short_link_large_data_set smallint DEFAULT 0 NOT NULL, - link_priv_sig_key bytea + link_priv_sig_key bytea, + chat_layout text ); diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index 3b52c46fef..061e572151 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -512,6 +512,7 @@ data GroupLinkInfo = GroupLinkInfo data AddressSettings = AddressSettings { businessAddress :: Bool, -- possibly, it can be wrapped together with acceptIncognito, or AutoAccept made sum type autoAccept :: Maybe AutoAccept, -- accept automatically + chatLayout :: Maybe ChatLayout, autoReply :: Maybe MsgContent -- included in short link information, sent on acceptance in case wasn't shown during connection } deriving (Eq, Show) @@ -527,11 +528,11 @@ $(J.deriveJSON defaultJSON ''AddressSettings) $(J.deriveJSON defaultJSON ''UserContactLink) -toUserContactLink :: (Int64, ConnReqContact, Maybe ShortLinkContact, BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe MsgContent) -> UserContactLink -toUserContactLink (userContactLinkId, connReq, shortLink, BI shortLinkDataSet, BI slLargeDataSet, BI businessAddress, BI autoAccept', BI acceptIncognito, autoReply) = +toUserContactLink :: (Int64, ConnReqContact, Maybe ShortLinkContact, BoolInt, BoolInt, BoolInt, BoolInt, BoolInt, Maybe MsgContent, Maybe ChatLayout) -> UserContactLink +toUserContactLink (userContactLinkId, connReq, shortLink, BI shortLinkDataSet, BI slLargeDataSet, BI businessAddress, BI autoAccept', BI acceptIncognito, autoReply, chatLayout) = UserContactLink userContactLinkId (CCLink connReq shortLink) shortLinkDataSet (BoolDef slLargeDataSet) $ let autoAccept = if autoAccept' then Just AutoAccept {acceptIncognito} else Nothing - in AddressSettings {businessAddress, autoAccept, autoReply} + in AddressSettings {businessAddress, autoAccept, chatLayout, autoReply} getUserAddress :: DB.Connection -> User -> ExceptT StoreError IO UserContactLink getUserAddress db User {userId} = @@ -544,7 +545,7 @@ getUserContactLinkById db userId userContactLinkId = DB.query db [sql| - SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role + SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content, chat_layout, group_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? |] @@ -586,7 +587,7 @@ getUserContactLinkViaTarget db User {userId, profile = LocalProfile {contactDoma userContactLinkQuery :: Query userContactLinkQuery = [sql| - SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content + SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content, chat_layout FROM user_contact_links |] @@ -637,15 +638,15 @@ getContactWithoutConnViaShortAddress db cxt user@User {userId} shortLink = do maybe (pure Nothing) (fmap eitherToMaybe . runExceptT . getContact db cxt user) ctId_ updateUserAddressSettings :: DB.Connection -> Int64 -> AddressSettings -> IO () -updateUserAddressSettings db userContactLinkId AddressSettings {businessAddress, autoAccept, autoReply} = +updateUserAddressSettings db userContactLinkId AddressSettings {businessAddress, autoAccept, chatLayout, autoReply} = DB.execute db [sql| UPDATE user_contact_links - SET auto_accept = ?, auto_accept_incognito = ?, business_address = ?, auto_reply_msg_content = ? + SET auto_accept = ?, auto_accept_incognito = ?, business_address = ?, auto_reply_msg_content = ?, chat_layout = ? WHERE user_contact_link_id = ? |] - (autoAcceptValues :. (BI businessAddress, autoReply, userContactLinkId)) + (autoAcceptValues :. (BI businessAddress, autoReply, chatLayout, userContactLinkId)) where autoAcceptValues = case autoAccept of Just AutoAccept {acceptIncognito} -> (BI True, BI acceptIncognito) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index dd2238adf5..2b5a29633d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -181,6 +181,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group import Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification import Simplex.Chat.Store.SQLite.Migrations.M20261016_badge_store_receipts import Simplex.Chat.Store.SQLite.Migrations.M20261018_autoincrement_ids +import Simplex.Chat.Store.SQLite.Migrations.M20261019_chat_layout import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -361,7 +362,8 @@ schemaMigrations = ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification), ("20261016_badge_store_receipts", m20261016_badge_store_receipts, Just down_m20261016_badge_store_receipts), - ("20261018_autoincrement_ids", m20261018_autoincrement_ids, Just down_m20261018_autoincrement_ids) + ("20261018_autoincrement_ids", m20261018_autoincrement_ids, Just down_m20261018_autoincrement_ids), + ("20261019_chat_layout", m20261019_chat_layout, Just down_m20261019_chat_layout) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261019_chat_layout.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261019_chat_layout.hs new file mode 100644 index 0000000000..494c258c12 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261019_chat_layout.hs @@ -0,0 +1,22 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20261019_chat_layout where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20261019_chat_layout :: Query +m20261019_chat_layout = + [sql| +ALTER TABLE contacts ADD COLUMN chat_layout TEXT; +ALTER TABLE group_profiles ADD COLUMN chat_layout TEXT; +ALTER TABLE user_contact_links ADD COLUMN chat_layout TEXT; +|] + +down_m20261019_chat_layout :: Query +down_m20261019_chat_layout = + [sql| +ALTER TABLE contacts DROP COLUMN chat_layout; +ALTER TABLE group_profiles DROP COLUMN chat_layout; +ALTER TABLE user_contact_links DROP COLUMN chat_layout; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index a02d379f10..a70bf5e47d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -1535,10 +1535,6 @@ Query: UPDATE ratchets SET ratchet_state = ? WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) -Query: UPDATE rcv_file_chunk_replicas SET delay = ?, retries = retries + 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? -Plan: -SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE rcv_file_chunk_replicas SET received = 1, updated_at = ? WHERE rcv_file_chunk_replica_id = ? Plan: SEARCH rcv_file_chunk_replicas USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index da47b5ccb2..fcfb9b9a64 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -130,7 +130,7 @@ Query: ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.chat_layout, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.xcontact_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, @@ -153,7 +153,7 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, gp.chat_layout, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, @@ -546,7 +546,7 @@ Query: display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, group_type = ?, group_link = ?, group_web_page = ?, group_domain = CASE WHEN ? THEN ? ELSE group_domain END, domain_web_page = ?, allow_embedding = ?, group_domain_proof = ?, - member_admission = ?, updated_at = ? + member_admission = ?, chat_layout = ?, updated_at = ? WHERE group_profile_id IN ( SELECT group_profile_id FROM groups @@ -758,7 +758,7 @@ Query: c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite, p.preferences, p.preferences_json, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id, cr2.rejection_supported, c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection, - c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, + c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl, c.chat_layout, p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified FROM contacts c @@ -1372,8 +1372,8 @@ Query: (display_name, full_name, short_descr, description, image, group_type, group_link, public_group_id, group_web_page, group_domain, domain_web_page, allow_embedding, group_domain_proof, - user_id, member_admission, created_at, updated_at, preferences, preferences_json) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + user_id, member_admission, chat_layout, created_at, updated_at, preferences, preferences_json) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -1520,7 +1520,7 @@ Query: ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.chat_layout, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection @@ -2121,7 +2121,7 @@ Query: ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite, cp.preferences, cp.preferences_json, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id, cr2.rejection_supported, ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection, - ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, + ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl, ct.chat_layout, cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified, -- Connection @@ -4003,7 +4003,7 @@ Query: SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - gp.preferences, gp.preferences_json, gp.member_admission + gp.preferences, gp.preferences_json, gp.member_admission, gp.chat_layout FROM group_profiles gp JOIN groups g ON gp.group_profile_id = g.group_profile_id WHERE g.group_id = ? @@ -4317,7 +4317,7 @@ Plan: SEARCH usage_conditions USING INTEGER PRIMARY KEY (rowid=?) Query: - SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content, group_id, group_link_member_role + SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content, chat_layout, group_id, group_link_member_role FROM user_contact_links WHERE user_id = ? AND user_contact_link_id = ? @@ -5468,11 +5468,11 @@ Query: badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, contact_domain = ?, contact_domain_proof = ? WHERE user_id = ? AND contact_profile_id = ? - + Plan: SEARCH contact_profiles USING INTEGER PRIMARY KEY (rowid=?) -Query: +Query: UPDATE contact_profiles SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, chat_peer_type = ?, preferences = NULL, preferences_json = NULL, updated_at = ?, badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?, @@ -5780,7 +5780,7 @@ SEARCH server_operators USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE user_contact_links - SET auto_accept = ?, auto_accept_incognito = ?, business_address = ?, auto_reply_msg_content = ? + SET auto_accept = ?, auto_accept_incognito = ?, business_address = ?, auto_reply_msg_content = ?, chat_layout = ? WHERE user_contact_link_id = ? Plan: @@ -5856,7 +5856,7 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, gp.chat_layout, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, @@ -5894,7 +5894,7 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, gp.chat_layout, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, @@ -5925,7 +5925,7 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, gp.chat_layout, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, @@ -6699,21 +6699,21 @@ Plan: SCAN usage_conditions Query: - SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content + SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content, chat_layout FROM user_contact_links WHERE user_id = ? AND conn_req_contact IN (?,?) Plan: SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=?) Query: - SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content + SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content, chat_layout FROM user_contact_links WHERE user_id = ? AND local_display_name = '' AND group_id IS NULL Plan: SEARCH user_contact_links USING INDEX sqlite_autoindex_user_contact_links_1 (user_id=? AND local_display_name=?) Query: - SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content + SELECT user_contact_link_id, conn_req_contact, short_link_contact, short_link_data_set, short_link_large_data_set, business_address, auto_accept, auto_accept_incognito, auto_reply_msg_content, chat_layout FROM user_contact_links WHERE user_id = ? AND short_link_contact = ? Plan: @@ -7375,7 +7375,7 @@ Query: INSERT INTO contacts (contact_profile_id, local_display_name, user_id, is Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) -Query: INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id) VALUES (?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id, chat_layout) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_1 (contact_id=?) @@ -7406,7 +7406,7 @@ Plan: Query: INSERT INTO files (user_id, group_id, file_name, file_size, chunk_size, file_inline, ci_file_status, protocol, file_type, shared_msg_id, roster_transfer_id, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?) Plan: -Query: INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, member_admission, created_at, updated_at, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?) +Query: INSERT INTO group_profiles (display_name, full_name, short_descr, description, image, user_id, member_admission, chat_layout, created_at, updated_at, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) Plan: Query: INSERT INTO group_profiles (display_name, full_name, short_descr, image, user_id, created_at, updated_at, preferences, preferences_json) VALUES (?,?,?,?,?,?,?,?,?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 91183ede3a..a11e986558 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -109,6 +109,7 @@ CREATE TABLE contacts( grp_direct_inv_from_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE SET NULL, grp_direct_inv_from_member_conn_id INTEGER REFERENCES connections(connection_id) ON DELETE SET NULL, grp_direct_inv_started_connection INTEGER NOT NULL DEFAULT 0, + chat_layout TEXT, FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -149,7 +150,8 @@ CREATE TABLE group_profiles( domain_web_page INTEGER, allow_embedding INTEGER, group_domain_proof TEXT, - preferences_json TEXT + preferences_json TEXT, + chat_layout TEXT ) STRICT; CREATE TABLE groups( group_id INTEGER PRIMARY KEY AUTOINCREMENT, -- local group ID @@ -417,6 +419,7 @@ CREATE TABLE user_contact_links( short_link_data_set INTEGER NOT NULL DEFAULT 0, short_link_large_data_set INTEGER NOT NULL DEFAULT 0, link_priv_sig_key BLOB, + chat_layout TEXT, UNIQUE(user_id, local_display_name) ) STRICT; CREATE TABLE contact_requests( diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index bf41d2de6e..122104fc95 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -414,10 +414,10 @@ setCommandConnId db User {userId} cmdId connId = do createContact :: DB.Connection -> StoreCxt -> User -> Profile -> ExceptT StoreError IO () createContact db cxt user profile = do currentTs <- liftIO getCurrentTime - void $ createContact_ db cxt user Nothing profile emptyChatPrefs Nothing "" currentTs + void $ createContact_ db cxt user Nothing profile emptyChatPrefs Nothing Nothing "" currentTs -createContact_ :: DB.Connection -> StoreCxt -> User -> Maybe ProofPresHeader -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId -createContact_ db cxt User {userId} presHeader_ p@Profile {displayName, fullName, shortDescr, description, image, contactLink, contactDomain, peerType, preferences} ctUserPreferences prepared localAlias currentTs = +createContact_ :: DB.Connection -> StoreCxt -> User -> Maybe ProofPresHeader -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> Maybe ChatLayout -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId +createContact_ db cxt User {userId} presHeader_ p@Profile {displayName, fullName, shortDescr, description, image, contactLink, contactDomain, peerType, preferences} ctUserPreferences prepared chatLayout localAlias currentTs = ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do (Profile {badge}, badgeVerified) <- profileBadgeVerified presHeader_ (badgeKeys cxt) Nothing p DB.execute @@ -427,8 +427,8 @@ createContact_ db cxt User {userId} presHeader_ p@Profile {displayName, fullName profileId <- insertedRowId db DB.execute db - "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)" - ((profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True) :. toPreparedContactRow prepared) + "INSERT INTO contacts (contact_profile_id, user_preferences, local_display_name, user_id, created_at, updated_at, chat_ts, contact_used, conn_full_link_to_connect, conn_short_link_to_connect, welcome_shared_msg_id, chat_layout) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)" + ((profileId, ctUserPreferences, ldn, userId, currentTs, currentTs, currentTs, BI True) :. toPreparedContactRow prepared :. Only chatLayout) contactId <- insertedRowId db pure $ Right contactId @@ -490,14 +490,14 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt) -type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Text, Maybe Text, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe BoolInt, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow :. ContactDomainRow +type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Text, Maybe Text, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe BoolInt, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64, Maybe ChatLayout) :. BadgeRow :. ContactDomainRow type ContactRow = Only ContactId :. ContactRow' type ContactDomainRow = (Maybe SimplexDomain, Maybe SimplexDomainProof, Maybe BoolInt) toContact :: UTCTime -> StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact -toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, encodedPrefs, receivedPrefs, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, rejectionSupported_, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) :. connRow) = +toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, encodedPrefs, receivedPrefs, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, rejectionSupported_, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL, chatLayout) :. badgeRow :. domainRow) :. connRow) = let preferences = chatPrefsFromRow encodedPrefs receivedPrefs profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias} activeConn = toMaybeConnection cxt connRow @@ -507,7 +507,7 @@ toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName preparedContact = toPreparedContact preparedContactRow contactRequest = UserContactRequestRef <$> contactRequestId <*> (unBI <$> rejectionSupported_) groupDirectInv = toGroupDirectInvitation groupDirectInvRow - in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactRequest, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatDeleted, customData} + in Contact {contactId, localDisplayName, profile, activeConn, contactUsed, contactStatus, chatSettings, userPreferences, mergedPreferences, createdAt, updatedAt, chatTs, preparedContact, contactRequestId, contactRequest, contactGroupMemberId, contactGrpInvSent, groupDirectInv, chatTags, chatItemTTL, uiThemes, chatLayout, chatDeleted, customData} rowToContactDomain :: ContactDomainRow -> Maybe SimplexDomainClaim rowToContactDomain (domain_, domainProof_, _) = (`SimplexDomainClaim` domainProof_) . StrJSON <$> domain_ @@ -696,7 +696,7 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Text, Maybe Text, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe VersionRoster, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact, Maybe BoolInt) :. GroupKeysRow :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Text, Maybe Text, Maybe GroupMemberAdmission, Maybe ChatLayout) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe VersionRoster, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact, Maybe BoolInt) :. GroupKeysRow :. GroupMemberRow type PublicGroupAccessRow = (Maybe Text, Maybe SimplexDomain, Maybe BoolInt, Maybe BoolInt, Maybe SimplexDomainProof) @@ -705,13 +705,13 @@ type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, Ver type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Text, Maybe Text) :. BadgeRow :. ContactDomainRow toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> (GroupInfo, GroupKeysRow) -toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, encodedPrefs, receivedPrefs, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri, groupDomainVerified) :. groupKeysRow :. userMemberRow) = +toGroupInfo now cxt userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, encodedPrefs, receivedPrefs, memberAdmission, chatLayout) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri, groupDomainVerified) :. groupKeysRow :. userMemberRow) = let membership = (toGroupMember now userContactId userMemberRow) {memberChatVRange = vr cxt} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} groupPreferences = groupPrefsFromRow encodedPrefs receivedPrefs fullGroupPreferences = mergeGroupPreferences groupPreferences publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow) - groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission} + groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission, chatLayout} businessChat = toBusinessChatInfo (toPublicGroupAccess accessRow >>= groupDomainClaim) businessRow preparedGroup = toPreparedGroup preparedGroupRow groupSummary = GroupSummary {currentMembers, publicMemberCount} @@ -843,7 +843,7 @@ groupInfoQueryFields = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id, gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding, gp.group_domain_proof, - g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, + g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.preferences_json, gp.member_admission, gp.chat_layout, g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id, g.business_chat, g.business_member_id, g.customer_member_id, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 94fd5d4fcd..842ad784a5 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -220,6 +220,7 @@ data Contact = Contact chatTags :: [ChatTagId], chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, + chatLayout :: Maybe ChatLayout, chatDeleted :: Bool, customData :: Maybe CustomData } @@ -919,7 +920,8 @@ data GroupProfile = GroupProfile image :: Maybe ImageData, publicGroup :: Maybe PublicGroupProfile, groupPreferences :: Maybe GroupPreferences, - memberAdmission :: Maybe GroupMemberAdmission + memberAdmission :: Maybe GroupMemberAdmission, + chatLayout :: Maybe ChatLayout } deriving (Eq, Show) diff --git a/src/Simplex/Chat/Types/UITheme.hs b/src/Simplex/Chat/Types/UITheme.hs index 096cda06f5..e20a20c61c 100644 --- a/src/Simplex/Chat/Types/UITheme.hs +++ b/src/Simplex/Chat/Types/UITheme.hs @@ -32,7 +32,8 @@ data UIColorMode = UCMLight | UCMDark data UIThemeEntityOverrides = UIThemeEntityOverrides { light :: Maybe UIThemeEntityOverride, - dark :: Maybe UIThemeEntityOverride + dark :: Maybe UIThemeEntityOverride, + chatLayout :: Maybe ChatLayout } deriving (Eq, Show) @@ -43,6 +44,30 @@ data UIThemeEntityOverride = UIThemeEntityOverride } deriving (Eq, Show) +data ChatLayout = CLClassic | CLTerminal | CLUnknown Text + deriving (Eq, Show) + +instance FromJSON ChatLayout where + parseJSON = textParseJSON "ChatLayout" + +instance ToJSON ChatLayout where + toJSON = J.String . textEncode + toEncoding = JE.text . textEncode + +instance FromField ChatLayout where fromField = fromTextField_ textDecode + +instance ToField ChatLayout where toField = toField . textEncode + +instance TextEncoding ChatLayout where + textDecode s = Just $ case s of + "classic" -> CLClassic + "terminal" -> CLTerminal + tag -> CLUnknown tag + textEncode = \case + CLClassic -> "classic" + CLTerminal -> "terminal" + CLUnknown tag -> tag + data DarkColorScheme = DCSDark | DCSBlack | DCSSimplex deriving (Eq, Ord, Show) @@ -172,4 +197,4 @@ instance ToField UIThemeEntityOverrides where toField = toField . encodeJSON instance FromField UIThemeEntityOverrides where - fromField = fromTextField_ $ Just . fromMaybe (UIThemeEntityOverrides Nothing Nothing) . decodeJSON + fromField = fromTextField_ $ Just . fromMaybe (UIThemeEntityOverrides Nothing Nothing Nothing) . decodeJSON diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 650209eadd..81bebe7dfe 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1190,16 +1190,17 @@ simplexDomainLine nameType (Just SimplexDomainClaim {domain, proof}) status = ca -- TODO [short links] show all settings viewAddressSettings :: AddressSettings -> [StyledString] -viewAddressSettings AddressSettings {businessAddress, autoAccept, autoReply} = case autoAccept of +viewAddressSettings AddressSettings {businessAddress, autoAccept, chatLayout, autoReply} = case autoAccept of Just AutoAccept {acceptIncognito} -> ("auto_accept on" <> aaInfo) - : maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply + : viewChatLayout chatLayout + <> maybe [] ((["auto reply:"] <>) . ttyMsgContent) autoReply where aaInfo | businessAddress = ", business" | acceptIncognito = ", incognito" | otherwise = "" - _ -> ["auto_accept off"] + _ -> "auto_accept off" : viewChatLayout chatLayout groupLink_ :: Bool -> StyledString -> GroupInfo -> GroupLink -> [StyledString] groupLink_ showFullLinks intro g GroupLink {connLinkContact = CCLink cReq shortLink, acceptMemberRole} = @@ -1893,7 +1894,7 @@ dayTime :: UTCTime -> Text dayTime = T.pack . formatTime defaultTimeLocale "%Y-%m-%d %H:%M" viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString] -viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, customData} stats incognitoProfile = +viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, chatLayout, customData} stats incognitoProfile = ["contact ID: " <> sShow contactId] <> viewContactBadge localBadge <> maybe [] ((bold' "description:" :) . map plain . T.lines) description @@ -1909,6 +1910,7 @@ viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, conta <> ["quantum resistant end-to-end encryption" | contactPQEnabled ct == CR.PQEncOn] <> maybe [] (\ac -> [viewPeerChatVRange (peerChatVRange ac)]) activeConn <> viewUITheme uiThemes + <> viewChatLayout chatLayout <> viewCustomData customData viewGroupInfo :: GroupInfo -> [StyledString] @@ -1931,6 +1933,9 @@ viewGroupInfo gInfo@GroupInfo {groupId, businessChat, groupDomainVerified, group viewUITheme :: Maybe UIThemeEntityOverrides -> [StyledString] viewUITheme = maybe [] (\uiThemes -> ["UI themes: " <> viewJSON uiThemes]) +viewChatLayout :: Maybe ChatLayout -> [StyledString] +viewChatLayout = maybe [] (\chatLayout -> ["chat layout: " <> plain (textEncode chatLayout)]) + viewCustomData :: Maybe CustomData -> [StyledString] viewCustomData = maybe [] (\(CustomData v) -> ["custom data: " <> viewJSON (J.Object v)]) @@ -2111,10 +2116,10 @@ countactUserPrefText cup = case cup of viewGroupUpdated :: GroupInfo -> GroupInfo -> Maybe GroupMember -> Maybe MsgSigStatus -> [StyledString] viewGroupUpdated - GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma, publicGroup = pg}} - g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma', publicGroup = pg'}} + GroupInfo {localDisplayName = n, groupProfile = GroupProfile {fullName, shortDescr, description, image, groupPreferences = gps, memberAdmission = ma, chatLayout = layout, publicGroup = pg}} + g'@GroupInfo {localDisplayName = n', groupProfile = GroupProfile {fullName = fullName', shortDescr = shortDescr', description = description', image = image', groupPreferences = gps', memberAdmission = ma', chatLayout = layout', publicGroup = pg'}} m signed = do - let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated <> publicGroupAccessUpdated + let update = groupProfileUpdated <> groupPrefsUpdated <> memberAdmissionUpdated <> chatLayoutUpdated <> publicGroupAccessUpdated if null update then [] else memberUpdated <> update @@ -2139,6 +2144,9 @@ viewGroupUpdated memberAdmissionUpdated | ma == ma' = [] | otherwise = ["changed member admission rules"] + chatLayoutUpdated + | layout == layout' = [] + | otherwise = ["chat layout " <> maybe "removed" (("changed to: " <>) . plain . textEncode) layout'] publicGroupAccessUpdated | access == access' = [] | otherwise = ["updated public group access:" <> viewAccess access'] @@ -2153,11 +2161,12 @@ viewGroupUpdated <> (if allowEmbedding then " embed=on" else "") viewGroupProfile :: GroupInfo -> [StyledString] -viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {shortDescr, description, image, groupPreferences = gps}} = +viewGroupProfile g@GroupInfo {groupProfile = GroupProfile {shortDescr, description, image, groupPreferences = gps, chatLayout}} = [ttyFullGroup g] <> maybe [] (\sd -> ["description: " <> plain sd]) shortDescr <> maybe [] (const ["has profile image"]) image <> maybe [] ((bold' "welcome message:" :) . map plain . T.lines) description + <> viewChatLayout chatLayout <> (bold' "group preferences:" : map viewPref (if useRelays' g then channelGroupFeatures else regularGroupFeatures)) where viewPref (AGF f) = plain $ groupPreferenceText (pref gps) diff --git a/tests/Bots/BadgeService/GroupTests.hs b/tests/Bots/BadgeService/GroupTests.hs index c592178a91..40baa25f12 100644 --- a/tests/Bots/BadgeService/GroupTests.hs +++ b/tests/Bots/BadgeService/GroupTests.hs @@ -123,7 +123,8 @@ badgeGroupTests = describe "badge group" $ do image = Nothing, publicGroup = Nothing, groupPreferences = Nothing, - memberAdmission = Nothing + memberAdmission = Nothing, + chatLayout = Nothing } it "says nothing when the config matches the group profile" $ do inertGroupConfig (cfg "SimpleX Badges" (Just "badge ops desk")) (profile "SimpleX Badges" (Just "badge ops desk")) diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index f56ba62102..01a504f13c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -43,6 +43,7 @@ import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNe import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) +import Simplex.Chat.Types.UITheme (ChatLayout (..)) import Simplex.Messaging.Agent (joinConnection, prepareConnectionToJoin, sendMessages, vrValue) import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval @@ -311,6 +312,7 @@ chatGroupTests = do it "should update channel profile (signed)" testChannelUpdateProfileSigned it "should preserve working link after profile update" testChannelLinkAfterProfileUpdate it "should preserve working link after welcome message update" testChannelLinkAfterWelcomeUpdate + it "should set chat layout of channel joined via relays from link data" testChannelChatLayout it "should preserve owner key in link data after profile update" testChannelOwnerKeyAfterLinkUpdate it "should update channel preferences (signed)" testChannelUpdatePrefsSigned it "should change member role (signed)" testChannelChangeRoleSigned @@ -10552,6 +10554,32 @@ testChannelLinkAfterWelcomeUpdate ps = bob <# "#team> hi" [cath, dan] *<# "#team> hi [>>]" +testChannelChatLayout :: HasCallStack => TestParams -> IO () +testChannelChatLayout ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChat ps "cath" cathProfile $ \cath -> do + (shortLink, _fullLink) <- prepareChannel1Relay "team" alice bob + alice ##> "/set layout #team terminal" + alice <## "chat layout changed to: terminal" + bob <## "alice updated group #team: (signed)" + bob <## "chat layout changed to: terminal" + cath ##> ("/c " <> shortLink) + cath <## "#team: connection started" + concurrentlyN_ + [ cath + <### [ "#team: joining the group (connecting to relay bob)...", + "#team: you joined the group (connected to relay bob)" + ], + do + bob <## "cath (Catherine): accepting request to join group #team..." + bob <## "#team: cath joined the group", + alice <### [EndsWith "introduced cath (Catherine) in the channel"] + ] + groupChatLayout alice "team" `shouldReturn` Just CLTerminal + groupChatLayout bob "team" `shouldReturn` Just CLTerminal + groupChatLayout cath "team" `shouldReturn` Just CLTerminal + testChannelOwnerKeyAfterLinkUpdate :: HasCallStack => TestParams -> IO () testChannelOwnerKeyAfterLinkUpdate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 004628f716..20134bbc6f 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -166,6 +166,12 @@ chatProfileTests = do it "files & media" testGroupPrefsFilesForRole it "SimpleX links" testGroupPrefsSimplexLinksForRole it "set user, contact and group UI theme" testSetUITheme + describe "chat layout" $ do + it "group chat layout from group profile" testGroupChatLayout + it "group chat layout from group link without prepare" testGroupLinkChatLayout + it "unknown chat layout stored and read" testUnknownChatLayout + it "contact chat layout from address" testAddressChatLayout + it "business chat layout from business address" testBusinessAddressChatLayout describe "short links" $ do it "should connect via one-time invitation" testShortLinkInvitation it "should plan and connect via one-time invitation" testPlanShortLinkInvitation @@ -1125,7 +1131,7 @@ testUserBadgeLinkDataOtherLink ps = do alice ##> "/_connect 1" (shortLink, _) <- getInvitations alice Right otherProof <- badgeProof pk cred (PHLink "other link key") - setTestInvLinkData alice $ ContactShortLinkData aliceProfile {badge = Just otherProof} Nothing False Nothing + setTestInvLinkData alice $ ContactShortLinkData aliceProfile {badge = Just otherProof} Nothing False Nothing Nothing bob ##> ("/_connect plan 1 " <> shortLink) bob <## "invitation link: ok to connect" sLinkData <- getTermLine bob @@ -1139,7 +1145,7 @@ testUserBadgeLinkDataNoProof = alice ##> "/_connect 1" (shortLink, _) <- getInvitations alice let shownBadge = ShownBadge BadgeInfo {badgeType = BTSupporter, badgeExpiry = futureDate, badgeExtra = ""} BSActive - setTestInvLinkData alice $ ContactShortLinkData aliceProfile Nothing False (Just shownBadge) + setTestInvLinkData alice $ ContactShortLinkData aliceProfile Nothing False Nothing (Just shownBadge) bob ##> ("/_connect plan 1 " <> shortLink) bob <## "invitation link: ok to connect" sLinkData <- getTermLine bob @@ -3835,7 +3841,7 @@ testSetUITheme = alice ##> "/i #team" groupInfo alice where - theme cm = T.unpack $ encodeJSON UIThemeEntityOverrides {light = Nothing, dark = Just $ UIThemeEntityOverride cm Nothing defaultUIColors} + theme cm = T.unpack $ encodeJSON UIThemeEntityOverrides {light = Nothing, dark = Just $ UIThemeEntityOverride cm Nothing defaultUIColors, chatLayout = Just CLTerminal} userInfo a name = do a <## ("user profile: " <> name) a <## "use /p [] to change it" @@ -3851,6 +3857,163 @@ testSetUITheme = a <## "group ID: 1" a <## "current members: 1" +testGroupChatLayout :: HasCallStack => TestParams -> IO () +testGroupChatLayout = + testChat3 aliceProfile bobProfile cathProfile $ \alice bob cath -> do + connectUsers alice cath + createGroupWithLayout alice "terminal" + groupChatLayout alice "team" `shouldReturn` Just CLTerminal + addMember "team" alice cath GRAdmin + cath ##> "/j team" + concurrently_ + (alice <## "#team: cath joined the group") + (cath <## "#team: you joined the group") + groupChatLayout cath "team" `shouldReturn` Just CLTerminal + alice ##> "/create link #team" + (shortLink, fullLink) <- getGroupLinks alice "team" GRMember True + alice ##> "/set layout #team classic" + alice <## "chat layout changed to: classic" + cath <## "alice updated group #team: (signed)" + cath <## "chat layout changed to: classic" + groupChatLayout alice "team" `shouldReturn` Just CLClassic + groupChatLayout cath "team" `shouldReturn` Just CLClassic + alice ##> "/group_profile team" + alice <## "#team" + alice <## "chat layout: classic" + alice <## "group preferences:" + mapM_ (\((_, feature), _, _) -> alice <## feature) $ groupFeatures_ 0 False + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "group link: ok to connect directly" + groupSLinkData <- getTermLine bob + bob ##> ("/_prepare group 1 " <> fullLink <> " " <> shortLink <> " " <> groupSLinkData) + bob <## "#team: group is prepared" + groupChatLayout bob "team" `shouldReturn` Just CLClassic + alice ##> "/set layout #team default" + alice <## "chat layout removed" + cath <## "alice updated group #team: (signed)" + cath <## "chat layout removed" + groupChatLayout alice "team" `shouldReturn` Nothing + groupChatLayout cath "team" `shouldReturn` Nothing + bob ##> "/_connect group #1" + bob <## "#team: connection started" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + bob <## "#team: member cath (Catherine) is connected", + do + cath <## "#team: alice added bob (Bob) to the group (connecting...)" + cath <## "#team: new member bob is connected" + ] + groupChatLayout bob "team" `shouldReturn` Nothing + +createGroupWithLayout :: HasCallStack => TestCC -> String -> IO () +createGroupWithLayout cc layout = do + cc ##> ("/_group 1 {\"displayName\": \"team\", \"fullName\": \"\", \"groupPreferences\": {\"directMessages\": {\"enable\": \"on\"}, \"history\": {\"enable\": \"on\"}}, \"chatLayout\": \"" <> layout <> "\"}") + cc <## "group #team is created" + cc <## "to add members use /a team or /create link #team" + +testGroupLinkChatLayout :: HasCallStack => TestParams -> IO () +testGroupLinkChatLayout = + testChat2 aliceProfile bobProfile $ \alice bob -> do + createGroupWithLayout alice "terminal" + alice ##> "/create link #team" + gLink <- getGroupLink alice "team" GRMember True + bob ##> ("/c " <> gLink) + bob <## "connection request sent!" + alice <## "bob (Bob): accepting request to join group #team..." + concurrentlyN_ + [ alice <## "#team: bob joined the group", + do + bob <## "#team: joining the group..." + bob <## "#team: you joined the group" + ] + groupChatLayout bob "team" `shouldReturn` Just CLTerminal + +testUnknownChatLayout :: HasCallStack => TestParams -> IO () +testUnknownChatLayout = + testChat aliceProfile $ \alice -> do + createGroupWithLayout alice "wide" + groupChatLayout alice "team" `shouldReturn` Just (CLUnknown "wide") + +testAddressChatLayout :: HasCallStack => TestParams -> IO () +testAddressChatLayout = + testChat2 aliceProfile bobProfile $ \alice bob -> do + alice ##> "/ad" + (shortLink, fullLink) <- getContactLinks alice True + alice ##> "/_address_settings 1 {\"businessAddress\": false, \"autoAccept\": {\"acceptIncognito\": false}, \"chatLayout\": \"terminal\"}" + alice <## "auto_accept on" + alice <## "chat layout: terminal" + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "contact address: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) + bob <## "alice: contact is prepared" + contactChatLayout bob "alice" `shouldReturn` Just CLTerminal + bob ##> "/_connect contact @2 text hello" + bob + <### [ "alice: connection started", + WithTime "@alice hello" + ] + alice <# "bob> hello" + alice <## "bob (Bob): accepting contact request..." + alice <## "bob (Bob): you can send messages to contact" + concurrently_ + (bob <## "alice (Alice): contact is connected") + (alice <## "bob (Bob): contact is connected") + contactChatLayout bob "alice" `shouldReturn` Just CLTerminal + contactChatLayout alice "bob" `shouldReturn` Nothing + alice ##> "/auto_accept on layout=classic" + alice <## "auto_accept on" + alice <## "chat layout: classic" + alice ##> "/auto_accept off" + alice <## "auto_accept off" + alice <## "chat layout: classic" + alice ##> "/auto_accept on business" + alice <## "auto_accept on, business" + alice <## "chat layout: classic" + alice ##> "/auto_accept off layout=default" + alice <## "auto_accept off" + alice ##> "/_address_settings 1 {\"businessAddress\": false, \"chatLayout\": \"terminal\"}" + alice <## "auto_accept off" + alice <## "chat layout: terminal" + alice ##> "/_address_settings 1 {\"businessAddress\": false}" + alice <## "auto_accept off" + +testBusinessAddressChatLayout :: HasCallStack => TestParams -> IO () +testBusinessAddressChatLayout = + testChat3 businessProfile aliceProfile bobProfile $ \biz alice bob -> do + biz ##> "/ad" + (shortLink, fullLink) <- getContactLinks biz True + biz ##> "/auto_accept on business layout=terminal" + biz <## "auto_accept on, business" + biz <## "chat layout: terminal" + bob ##> ("/_connect plan 1 " <> shortLink) + bob <## "business address: ok to connect" + contactSLinkData <- getTermLine bob + bob ##> ("/_prepare contact 1 " <> fullLink <> " " <> shortLink <> " " <> contactSLinkData) + bob <## "#biz: group is prepared" + groupChatLayout bob "biz" `shouldReturn` Just CLTerminal + bob ##> "/_connect group #1" + bob <## "#biz: connection started" + biz <## "#bob (Bob): accepting business address request..." + bob <## "#biz: joining the group..." + concurrently_ + (biz <## "#bob: bob_1 joined the group") + (bob <## "#biz: you joined the group") + groupChatLayout bob "biz" `shouldReturn` Just CLTerminal + groupChatLayout biz "bob" `shouldReturn` Nothing + alice ##> ("/c " <> fullLink) + alice <## "connection request sent!" + biz <## "#alice (Alice): accepting business address request..." + alice <## "#biz: joining the group..." + concurrently_ + (biz <## "#alice: alice_1 joined the group") + (alice <## "#biz: you joined the group") + groupChatLayout alice "biz" `shouldReturn` Just CLTerminal + testShortLinkInvitation :: HasCallStack => TestParams -> IO () testShortLinkInvitation = testChatOpts2 testOptsNoFullLinks aliceProfile bobProfile $ \alice bob -> do diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index c1f58aa525..a410d80ad3 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -33,9 +33,11 @@ import Simplex.Chat.Store.Direct (getContact, getContactIdByName, getContactRequ import Simplex.Chat.Store.Groups (getGroupIdByName, getGroupMember, getGroupMemberIdByName) import Simplex.Chat.Store.NoteFolders (createNoteFolder) import Simplex.Chat.Store.Profiles (getUserContactProfiles) +import Simplex.Chat.Store.Shared (getGroupInfo) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared +import Simplex.Chat.Types.UITheme (ChatLayout) import Simplex.FileTransfer.Description (FileSize (..)) import Simplex.Messaging.Agent.Client (agentClientStore) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow, withTransaction) @@ -729,6 +731,20 @@ getTestCCContact cc contactId = do withCCUser cc $ \user -> runExceptT (getContact db (storeCxt chatController) user contactId) >>= either (fail . show) pure +contactChatLayout :: TestCC -> ContactName -> IO (Maybe ChatLayout) +contactChatLayout cc cName = + withCCTransaction cc $ \db -> + withCCUser cc $ \user -> + runExceptT (getContact db (storeCxt $ chatController cc) user =<< getContactIdByName db user cName) + >>= either (fail . show) (\Contact {chatLayout} -> pure chatLayout) + +groupChatLayout :: TestCC -> GroupName -> IO (Maybe ChatLayout) +groupChatLayout cc gName = + withCCTransaction cc $ \db -> + withCCUser cc $ \user -> + runExceptT (getGroupInfo db (storeCxt $ chatController cc) user =<< getGroupIdByName db user gName) + >>= either (fail . show) (\GroupInfo {groupProfile = GroupProfile {chatLayout}} -> pure chatLayout) + contactBadgeHeader :: TestCC -> ContactName -> IO (Maybe (String, BadgeStatus)) contactBadgeHeader cc cName = withCCTransaction cc $ \db -> diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index 79415071f7..c93b9ee7ad 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -21,6 +21,7 @@ import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared +import Simplex.Chat.Types.UITheme (ChatLayout (..)) import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Compression (compress1) import qualified Simplex.Messaging.Crypto as C @@ -194,7 +195,7 @@ testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, description = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing} testGroupProfile :: GroupProfile -testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing} +testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing, chatLayout = Nothing} shortLinkDataTests :: Spec shortLinkDataTests = describe "Short link data encoding/decoding" $ do @@ -353,6 +354,12 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do it "x.grp.inv with group link id" $ "{\"v\":\"9\",\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"simplex:/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-4%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D3%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, fromMemberKey = Nothing, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, business = Nothing, groupLinkId = Just $ GroupLinkId "\1\2\3\4", groupSize = Nothing} + it "x.grp.info with chat layout" $ + "{\"v\":\"9\",\"event\":\"x.grp.info\",\"params\":{\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}},\"chatLayout\":\"terminal\"}}}" + #==# XGrpInfo (testGroupProfile {chatLayout = Just CLTerminal} :: GroupProfile) + it "x.grp.info with unknown chat layout" $ + "{\"v\":\"9\",\"event\":\"x.grp.info\",\"params\":{\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}},\"chatLayout\":\"wide\"}}}" + #==# XGrpInfo (testGroupProfile {chatLayout = Just $ CLUnknown "wide"} :: GroupProfile) it "x.grp.acpt without incognito profile" $ "{\"v\":\"9\",\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") Nothing Nothing