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 <evgeny@poberezkin.com>
This commit is contained in:
Evgeny @ SimpleX ChatandEvgeny Poberezkin authored and GitHub committed 2026-10-25 18:03:11 +00:00
1 parent 4f81c6f632
commit 7810fc4752
103 files changed
+5098 -901

No files matched your search

@@ -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<ChatPeerType> {
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<ChatLayoutMode> {
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
)
@@ -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<HintPref> = 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<String, String>? = null,
var uiThemes: List<ThemeOverrides>? = 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()
)
}
}
@@ -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<ThemeOverrides>.skipDuplicates(): List<ThemeOverrides> {
@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 (
@@ -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 {
@@ -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<ListItem>,
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 */
@@ -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<Boolean>,
reveal: (Boolean) -> Unit
) {
@@ -1954,11 +1958,10 @@ fun BoxScope.ChatItemsList(
}
@Composable
fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State<IntRange?>, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f) {
fun ChatItemViewShortHand(cItem: ChatItem, itemSeparation: ItemSeparation, range: State<IntRange?>, fillMaxWidth: Boolean = true, swipeOffset: Float = 0f, highlighted: State<Boolean> = 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<IntSize>?,
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
@@ -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()
}
}
}
@@ -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()
)
}
}
}
@@ -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,
@@ -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)
}
}
}
@@ -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())
) {
@@ -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)
}
}
}
@@ -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)
}
}
}
@@ -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)
@@ -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()
}
}
}
}
@@ -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) }
}
}
}
@@ -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<ChatItemReactionMenuItem>()) }
@@ -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<ChatItem>): AnnotatedString {
fun eventItemViewText(reversedChatItems: List<ChatItem>, 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) {
@@ -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()
}
}
}
}
@@ -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)
}
}
@@ -47,10 +47,17 @@ fun FramedItemView(
scrollToItem: (Long) -> Unit = {},
scrollToItemId: MutableState<Long?>,
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
}
}
@@ -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()
}
}
}
}
@@ -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<FormattedText>, 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)
@@ -23,19 +23,21 @@ import kotlinx.datetime.Clock
fun MarkedDeletedItemView(chatsCtx: ChatModel.ChatsContext, ci: ChatItem, chatInfo: ChatInfo, timedMessagesTTL: Int?, revealed: State<Boolean>, 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)
}
}
}
@@ -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<Interaction> = 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<AlignmentLine, Int>): 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<IntRange?>,
reversedChatItems: State<List<ChatItem>>,
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)
}
}
}
@@ -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<String, CIMention>? = 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<TextLayoutResult?>(null) }
@@ -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<Boolean>,
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(
@@ -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<Pair<Long, ThemeModeOverrides?>?>) {
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<Pair<Long, ThemeModeOverrides?>?>) {
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<Pair<Long, ThemeModeOverrides?>?>, perUserTheme: MutableState<ThemeModeOverride>) {
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
@@ -633,14 +633,16 @@ private fun BusinessAddressToggle(addressSettingsState: MutableState<AddressSett
businessAddress = true,
autoAccept = true,
autoAcceptIncognito = false,
autoReply = addressSettingsState.value.autoReply
autoReply = addressSettingsState.value.autoReply,
chatLayout = addressSettingsState.value.chatLayout
)
else
AddressSettingsState(
businessAddress = false,
autoAccept = addressSettingsState.value.autoAccept,
autoAcceptIncognito = addressSettingsState.value.autoAcceptIncognito,
autoReply = addressSettingsState.value.autoReply
autoReply = addressSettingsState.value.autoReply,
chatLayout = addressSettingsState.value.chatLayout
)
saveAddressSettings(addressSettingsState.value)
}
@@ -659,14 +661,16 @@ private fun AutoAcceptToggle(addressSettingsState: MutableState<AddressSettingsS
businessAddress = addressSettingsState.value.businessAddress,
autoAccept = true,
autoAcceptIncognito = false,
autoReply = ""
autoReply = "",
chatLayout = addressSettingsState.value.chatLayout
)
else
AddressSettingsState(
businessAddress = false,
autoAccept = false,
autoAcceptIncognito = false,
autoReply = ""
autoReply = "",
chatLayout = addressSettingsState.value.chatLayout
)
saveAddressSettings(addressSettingsState.value)
}
@@ -692,12 +696,15 @@ private class AddressSettingsState {
private set
var autoReply: String = ""
private set
var chatLayout: ChatLayoutMode? = null
private set
constructor(businessAddress: Boolean = false, autoAccept: Boolean = false, autoAcceptIncognito: Boolean = false, autoReply: String = "") {
constructor(businessAddress: Boolean = false, autoAccept: Boolean = false, autoAcceptIncognito: Boolean = false, autoReply: String = "", chatLayout: ChatLayoutMode? = null) {
this.businessAddress = businessAddress
this.autoAccept = autoAccept
this.autoAcceptIncognito = autoAcceptIncognito
this.autoReply = autoReply
this.chatLayout = chatLayout
}
constructor(settings: AddressSettings) {
@@ -705,6 +712,7 @@ private class AddressSettingsState {
this.autoAccept = settings.autoAccept != null
this.autoAcceptIncognito = settings.autoAccept?.acceptIncognito == true
this.autoReply = settings.autoReply?.text ?: ""
this.chatLayout = settings.chatLayout
}
val addressSettings: AddressSettings
@@ -712,7 +720,8 @@ private class AddressSettingsState {
return AddressSettings(
businessAddress = this.businessAddress,
autoAccept = if (this.autoAccept) AutoAccept(acceptIncognito = this.autoAcceptIncognito) else null,
autoReply = if (this.autoReply.isEmpty()) null else MsgContent.MCText(this.autoReply)
autoReply = if (this.autoReply.isEmpty()) null else MsgContent.MCText(this.autoReply),
chatLayout = this.chatLayout
)
}
@@ -723,6 +732,7 @@ private class AddressSettingsState {
&& this.autoAccept == other.autoAccept
&& this.autoAcceptIncognito == other.autoAcceptIncognito
&& this.autoReply == other.autoReply
&& this.chatLayout == other.chatLayout
)
}
@@ -731,6 +741,7 @@ private class AddressSettingsState {
result = 31 * result + autoAccept.hashCode()
result = 31 * result + autoAcceptIncognito.hashCode()
result = 31 * result + autoReply.hashCode()
result = 31 * result + chatLayout.hashCode()
return result
}
}
@@ -747,7 +758,8 @@ private fun AcceptIncognitoToggle(addressSettingsState: MutableState<AddressSett
businessAddress = addressSettingsState.value.businessAddress,
autoAccept = addressSettingsState.value.autoAccept,
autoAcceptIncognito = incognitoToggle,
autoReply = addressSettingsState.value.autoReply
autoReply = addressSettingsState.value.autoReply,
chatLayout = addressSettingsState.value.chatLayout
)
}
}
@@ -768,7 +780,8 @@ private fun AutoReplyEditor(addressSettingsState: MutableState<AddressSettingsSt
businessAddress = addressSettingsState.value.businessAddress,
autoAccept = addressSettingsState.value.autoAccept,
autoAcceptIncognito = addressSettingsState.value.autoAcceptIncognito,
autoReply = autoReply.value
autoReply = autoReply.value,
chatLayout = addressSettingsState.value.chatLayout
)
}
}
@@ -2304,6 +2304,11 @@
<string name="chat_theme_apply_to_all_modes">All color modes</string>
<string name="chat_theme_apply_to_light_mode">Light mode</string>
<string name="chat_theme_apply_to_dark_mode">Dark mode</string>
<string name="chat_layout">Chat layout</string>
<string name="chat_layout_classic">Classic</string>
<string name="chat_layout_terminal">Terminal</string>
<string name="chat_layout_chat_suffix">(chat)</string>
<string name="chat_layout_default_suffix">(default)</string>
<!-- Preferences.kt -->