From 8aa342061c53bc63bb637bbcf7cc8f22cd80bf40 Mon Sep 17 00:00:00 2001 From: sh Date: Tue, 13 Oct 2026 14:29:35 +0000 Subject: [PATCH] desktop: layered surfaces, reply line and rail tiles --- .../kotlin/chat/simplex/common/App.kt | 14 +++- .../simplex/common/views/chat/ComposeView.kt | 5 +- .../views/chat/item/FlatChatItemView.kt | 18 +++- .../common/views/chat/item/FramedItemView.kt | 36 +++++++- .../common/views/chatlist/DesktopRail.kt | 84 +++++++++++++++---- .../chatlist/ChatListNavLinkView.desktop.kt | 8 +- 6 files changed, 139 insertions(+), 26 deletions(-) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt index 5a9049a3ab..a9caa2f04b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/App.kt @@ -447,10 +447,14 @@ private fun DesktopScreenLayout(userPickerState: MutableStateFlow if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner && chat.chatInfo.groupChatScope() == null) generalGetString(MR.strings.compose_view_broadcast) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FlatChatItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FlatChatItemView.kt index a99fabb0fc..91d7a6e63d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FlatChatItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FlatChatItemView.kt @@ -23,6 +23,7 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.offset import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import chat.simplex.common.LocalFlatLayout import chat.simplex.common.model.* import chat.simplex.common.platform.chatModel import chat.simplex.common.ui.theme.* @@ -42,6 +43,8 @@ private val FLAT_HEADER_TOP_PADDING = 8.dp // matches the horizontal text padding in CIMarkdownText val FLAT_TEXT_START_PADDING = 12.dp val FLAT_TEXT_VERTICAL_PADDING = 2.dp +private val FLAT_ROW_INSET = 4.dp +private val FLAT_ROW_CORNER = 6.dp private const val FLAT_SUBTLE_FILL_ALPHA = 0.04f private const val FLAT_HIGHLIGHT_ALPHA = 0.12f private const val FLAT_BORDER_ALPHA = 0.12f @@ -57,6 +60,9 @@ private const val FLAT_GROUP_SECONDS = 60 val FLAT_META_BOTTOM_PADDING = 1.dp private val FLAT_QUOTE_BAR_WIDTH = 3.dp val FLAT_DELETED_FONT_SIZE = 14.sp +val FLAT_REPLY_AVATAR_SIZE = 16.dp +val FLAT_REPLY_ICON_SIZE = 14.dp +val FLAT_REPLY_FONT_SIZE = 13.sp private const val FLAT_QUOTE_BAR_ALPHA = 0.25f private val FLAT_PILL_SHAPE = RoundedCornerShape(50) val FLAT_PILL_HORIZONTAL_PADDING = 6.dp @@ -146,7 +152,7 @@ fun FlatChatItemRow( hovered -> MaterialTheme.colors.onBackground.copy(alpha = FLAT_SUBTLE_FILL_ALPHA) else -> Color.Transparent } - Box(modifier.fillMaxWidth().hoverable(interactionSource).background(background)) { + Box(modifier.fillMaxWidth().padding(horizontal = FLAT_ROW_INSET).hoverable(interactionSource).background(background, RoundedCornerShape(FLAT_ROW_CORNER))) { Row( Modifier .heightIn(min = if (cItem.content.msgContent != null) FLAT_ROW_MIN_HEIGHT else 0.dp) @@ -410,3 +416,13 @@ private val SELECTION_CHECKBOX_SIZE = 22.dp fun flatSelectionTopPadding(showHeader: Boolean): Dp = if (showHeader) FLAT_HEADER_TOP_PADDING + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier - SELECTION_CHECKBOX_SIZE * fontSizeMultiplier) / 2 else FLAT_TEXT_VERTICAL_PADDING + 1.dp + +private val FLAT_COMPOSE_CORNER = 8.dp +private const val FLAT_COMPOSE_FILL_ALPHA = 0.06f + +@Composable +fun Modifier.flatComposeField(): Modifier = + if (!LocalFlatLayout.current) this + else padding(horizontal = DEFAULT_PADDING_HALF * 1.5f, vertical = DEFAULT_PADDING_HALF) + .clip(RoundedCornerShape(FLAT_COMPOSE_CORNER)) + .background(MaterialTheme.colors.onBackground.copy(alpha = FLAT_COMPOSE_FILL_ALPHA)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index 6fdb0059f8..a50c636f26 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -1,5 +1,6 @@ package chat.simplex.common.views.chat.item +import chat.simplex.common.views.chat.group.MemberProfileImage import androidx.compose.foundation.* import androidx.compose.foundation.layout.* import androidx.compose.material.* @@ -60,14 +61,14 @@ fun FramedItemView( } @Composable - fun ciQuotedMsgTextView(qi: CIQuote, lines: Int, showTimestamp: Boolean, stripLink: String? = null, prefix: AnnotatedString? = null) { + fun ciQuotedMsgTextView(qi: CIQuote, lines: Int, showTimestamp: Boolean, stripLink: String? = null, prefix: AnnotatedString? = null, style: TextStyle = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface)) { MarkdownText( qi.text, qi.formattedText, toggleSecrets = true, maxLines = lines, overflow = TextOverflow.Ellipsis, - style = TextStyle(fontSize = 15.sp, color = MaterialTheme.colors.onSurface), + style = style, linkMode = linkMode, uriHandler = if (appPlatform.isDesktop) uriHandler else null, showTimestamp = showTimestamp, @@ -220,6 +221,35 @@ fun FramedItemView( } } + @Composable + fun ciReplyLine(qi: CIQuote) { + Row( + Modifier.padding(start = FLAT_TEXT_START_PADDING, top = FLAT_TEXT_VERTICAL_PADDING, end = 12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(painterResource(MR.images.ic_reply), null, Modifier.size(FLAT_REPLY_ICON_SIZE), tint = MaterialTheme.colors.secondary) + val quotedDir = qi.chatDir + if (quotedDir is CIDirection.GroupRcv) { + MemberProfileImage(FLAT_REPLY_AVATAR_SIZE, quotedDir.groupMember) + } + val sender = qi.sender(membership()) + if (sender != null) { + Text(sender, fontSize = FLAT_REPLY_FONT_SIZE, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colors.secondary, maxLines = 1) + } + val replyStyle = TextStyle(fontSize = FLAT_REPLY_FONT_SIZE, color = MaterialTheme.colors.secondary) + Box(Modifier.weight(1f, fill = false)) { + when { + qi.text.isNotEmpty() -> ciQuotedMsgTextView(qi, lines = 1, showTimestamp = false, style = replyStyle) + qi.content is MsgContent.MCImage -> Text(stringResource(MR.strings.image_descr), style = replyStyle, maxLines = 1) + qi.content is MsgContent.MCVideo -> Text(stringResource(MR.strings.video_descr), style = replyStyle, maxLines = 1) + qi.content is MsgContent.MCVoice -> Text(stringResource(MR.strings.voice_message), style = replyStyle, maxLines = 1) + qi.content is MsgContent.MCFile -> Text(stringResource(MR.strings.icon_descr_file), style = replyStyle, maxLines = 1) + } + } + } + } + @Composable fun ciFileView(ci: ChatItem, text: String) { CIFileView(ci.file, ci.meta, chatTTL, showViaProxy, showTimestamp, showMenu, false, receiveFile) @@ -309,7 +339,7 @@ fun FramedItemView( .onRightClick { showMenu.value = true } ) { Header() - ciQuoteView(ci.quotedItem) + if (flat) ciReplyLine(ci.quotedItem) else ciQuoteView(ci.quotedItem) } } else { Header() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DesktopRail.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DesktopRail.kt index 3a46abee83..4c959db08f 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DesktopRail.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DesktopRail.kt @@ -1,6 +1,10 @@ package chat.simplex.common.views.chatlist +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.animateIntAsState import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape @@ -11,6 +15,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -21,7 +26,6 @@ import chat.simplex.common.platform.* import chat.simplex.common.ui.theme.* import chat.simplex.common.views.chat.item.ReactionIcon import chat.simplex.common.views.helpers.* -import chat.simplex.common.views.localauth.VerticalDivider import chat.simplex.common.views.usersettings.SettingsView import chat.simplex.res.* import dev.icerock.moko.resources.ImageResource @@ -37,7 +41,31 @@ private val RAIL_ACTIVE_BACKGROUND_WIDTH = 40.dp private val RAIL_ACTIVE_BACKGROUND_HEIGHT = 32.dp private val RAIL_UNREAD_DOT_SIZE = 6.dp private const val RAIL_ACTIVE_BACKGROUND_ALPHA = 0.12f -private const val RAIL_BACKGROUND_ALPHA = 0.04f +private val RAIL_ITEM_INSET = 6.dp +private const val RAIL_TILE_CORNER_PERCENT = 30 +private const val RAIL_TILE_CIRCLE_PERCENT = 50 +private const val RAIL_TILE_ALPHA = 0.15f +private const val RAIL_MARK_ACTIVE_FRACTION = 0.8f +private const val RAIL_MARK_HOVER_FRACTION = 0.4f +// background darkening of the rail and the chat list; dark themes need more for a visible step +private const val FLAT_RAIL_SHADE_LIGHT = 0.07f +private const val FLAT_RAIL_SHADE_DARK = 0.4f +private const val FLAT_LIST_SHADE_LIGHT = 0.035f +private const val FLAT_LIST_SHADE_DARK = 0.2f + +@Composable +fun flatRailColor(): Color = flatShade(if (MaterialTheme.colors.isLight) FLAT_RAIL_SHADE_LIGHT else FLAT_RAIL_SHADE_DARK) + +@Composable +fun flatListColor(): Color = flatShade(if (MaterialTheme.colors.isLight) FLAT_LIST_SHADE_LIGHT else FLAT_LIST_SHADE_DARK) + +@Composable +private fun flatShade(amount: Float): Color = MaterialTheme.colors.background.mixWith(Color.Black, 1 - amount) + +@Composable +fun FlatListSurface(content: @Composable () -> Unit) { + MaterialTheme(colors = MaterialTheme.colors.copy(background = flatListColor()), typography = MaterialTheme.typography, shapes = MaterialTheme.shapes, content = content) +} // Spec: spec/client/chat-list.md#DesktopRail @Composable @@ -46,10 +74,9 @@ fun DesktopRail(userPickerState: MutableStateFlow, setPerform Modifier .width(DESKTOP_RAIL_WIDTH * fontSizeSqrtMultiplier) .fillMaxHeight() - .background(MaterialTheme.colors.onBackground.copy(alpha = RAIL_BACKGROUND_ALPHA)) + .background(flatRailColor()) ) { RailContent(userPickerState, setPerformLA) - VerticalDivider() } } @@ -93,20 +120,30 @@ private fun RailUsers(userPickerState: MutableStateFlow) { val switchEnabled = switchAllowed && (remember { chatModel.chatRunning }.value == true || chatModel.connectedToRemote) users.forEach { u -> val user = u.user - Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { - if (user.activeUser) { - Box( - Modifier - .align(Alignment.CenterStart) - .size(RAIL_ACTIVE_MARK_WIDTH, RAIL_ITEM_SIZE * fontSizeSqrtMultiplier / 2) - .background(MaterialTheme.colors.primary, RoundedCornerShape(topEnd = RAIL_ACTIVE_MARK_WIDTH, bottomEnd = RAIL_ACTIVE_MARK_WIDTH)) - ) + val interactionSource = remember { MutableInteractionSource() } + val hovered = interactionSource.collectIsHoveredAsState().value + val tileSize = RAIL_ITEM_SIZE * fontSizeSqrtMultiplier + val markHeight by animateDpAsState( + when { + user.activeUser -> tileSize * RAIL_MARK_ACTIVE_FRACTION + hovered -> tileSize * RAIL_MARK_HOVER_FRACTION + else -> 0.dp } + ) + val corner by animateIntAsState(if (user.activeUser || hovered) RAIL_TILE_CORNER_PERCENT else RAIL_TILE_CIRCLE_PERCENT) + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { + Box( + Modifier + .align(Alignment.CenterStart) + .size(RAIL_ACTIVE_MARK_WIDTH, markHeight) + .background(MaterialTheme.colors.primary, RoundedCornerShape(topEnd = RAIL_ACTIVE_MARK_WIDTH, bottomEnd = RAIL_ACTIVE_MARK_WIDTH)) + ) Box { Box( Modifier - .clip(CircleShape) - .clickable(enabled = if (user.activeUser) switchAllowed else switchEnabled) { + .size(tileSize) + .clip(RoundedCornerShape(percent = corner)) + .clickable(interactionSource = interactionSource, indication = LocalIndication.current, enabled = if (user.activeUser) switchAllowed else switchEnabled) { if (user.activeUser) { ModalManager.start.closeModals() userPickerState.value = AnimatedViewState.VISIBLE @@ -116,7 +153,7 @@ private fun RailUsers(userPickerState: MutableStateFlow) { } } ) { - ProfileImage(RAIL_ITEM_SIZE * fontSizeSqrtMultiplier, user.profile.image) + RailProfileTile(user) } if (!user.activeUser && u.unreadCount > 0) { userUnreadBadge(u.unreadCount, user.showNtfs, hasPadding = false) @@ -126,6 +163,21 @@ private fun RailUsers(userPickerState: MutableStateFlow) { } } +@Composable +private fun RailProfileTile(user: User) { + val image = user.profile.image + if (image != null) { + val bitmap = remember(image) { base64ToBitmap(image) } + Image(bitmap, user.displayName, Modifier.fillMaxSize(), contentScale = ContentScale.Crop) + } else { + val name = user.displayName + val initial = name.take(if (name.firstOrNull()?.isHighSurrogate() == true) 2 else 1).uppercase() + Box(Modifier.fillMaxSize().background(MaterialTheme.colors.primary.copy(alpha = RAIL_TILE_ALPHA)), contentAlignment = Alignment.Center) { + Text(initial, color = MaterialTheme.colors.primary, fontSize = 18.sp, fontWeight = FontWeight.SemiBold) + } + } +} + @Composable private fun RailTags() { val presetTags = remember { chatModel.presetTags } @@ -190,6 +242,8 @@ private fun RailItem( Column( Modifier .fillMaxWidth() + .padding(horizontal = RAIL_ITEM_INSET) + .clip(RoundedCornerShape(DEFAULT_PADDING_HALF)) .combinedClickable(enabled = enabled, onClick = onClick, onLongClick = onLongClick) .onRightClick(onLongClick) .padding(vertical = 4.dp), diff --git a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt index cea8b1630e..c96ee987c6 100644 --- a/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt +++ b/apps/multiplatform/common/src/desktopMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.desktop.kt @@ -1,5 +1,7 @@ package chat.simplex.common.views.chatlist +import androidx.compose.ui.draw.clip +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.* import androidx.compose.foundation.interaction.InteractionSource import androidx.compose.foundation.layout.* @@ -25,6 +27,10 @@ object NoIndication : IndicationNodeFactory { override fun equals(other: Any?) = other === this } +private val FLAT_ROW_INSET = 6.dp +private val FLAT_ROW_GAP = 1.dp +private val FLAT_ROW_CORNER = 6.dp + @Composable actual fun ChatListNavLinkLayout( chatLinkPreview: @Composable () -> Unit, @@ -36,7 +42,7 @@ actual fun ChatListNavLinkLayout( nextChatSelected: State, ) { val flat = LocalFlatLayout.current - var modifier = Modifier.fillMaxWidth() + var modifier = if (flat) Modifier.fillMaxWidth().padding(horizontal = FLAT_ROW_INSET, vertical = FLAT_ROW_GAP).clip(RoundedCornerShape(FLAT_ROW_CORNER)) else Modifier.fillMaxWidth() if (!disabled) modifier = modifier .combinedClickable(onClick = click, onLongClick = { showMenu.value = true }) .onRightClick { showMenu.value = true }