mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-11 09:37:45 +00:00
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:
1 parent
4f81c6f632
commit
7810fc4752
103 files changed
+5098
-901
No files matched your search
+55
-1
@@ -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
|
||||
)
|
||||
|
||||
+14
-3
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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 (
|
||||
|
||||
+49
-8
@@ -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 {
|
||||
|
||||
+5
-2
@@ -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 */
|
||||
|
||||
+62
-18
@@ -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
|
||||
|
||||
+24
-22
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-16
@@ -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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-1
@@ -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,
|
||||
|
||||
+28
-25
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-2
@@ -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())
|
||||
) {
|
||||
|
||||
+62
-56
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-31
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
-21
@@ -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)
|
||||
|
||||
+56
-48
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+23
-15
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-29
@@ -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) {
|
||||
|
||||
+18
-16
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+254
-205
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+19
-17
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+60
-16
@@ -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)
|
||||
|
||||
+13
-11
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+383
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-15
@@ -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) }
|
||||
|
||||
+37
-17
@@ -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(
|
||||
|
||||
+133
-43
@@ -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
|
||||
|
||||
+21
-8
@@ -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 -->
|
||||
|
||||
Reference in new issue
Block a user