desktop: layered surfaces, reply line and rail tiles

This commit is contained in:
sh committed 2026-10-14 07:43:26 +00:00
1 parent 6102e4e0ed
commit 8aa342061c
6 files changed
+139 -26

No files matched your search

@@ -447,10 +447,14 @@ private fun DesktopScreenLayout(userPickerState: MutableStateFlow<AnimatedViewSt
DesktopRail(userPickerState, setPerformLA = AppLock::setPerformLA)
}
Box(Modifier.padding(start = railWidth).width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
StartPartOfScreen(userPickerState)
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState, setPerformLA = AppLock::setPerformLA)
@Composable
fun StartPart() {
StartPartOfScreen(userPickerState)
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState, setPerformLA = AppLock::setPerformLA)
}
}
if (LocalFlatLayout.current) FlatListSurface { StartPart() } else StartPart()
}
Box(Modifier.padding(start = railWidth).widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
ModalManager.start.showInView()
@@ -480,7 +484,9 @@ private fun DesktopScreenLayout(userPickerState: MutableStateFlow<AnimatedViewSt
})
)
}
VerticalDivider(Modifier.padding(start = railWidth + DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
if (!LocalFlatLayout.current) {
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
}
ModalManager.fullscreen.showInView()
}
@@ -1,6 +1,7 @@
@file:UseSerializers(UriSerializer::class, ComposeMessageSerializer::class)
package chat.simplex.common.views.chat
import chat.simplex.common.LocalFlatLayout
import SectionItemView
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@@ -1763,7 +1764,7 @@ fun ComposeView(
}
Surface(color = MaterialTheme.colors.background, contentColor = MaterialTheme.colors.onBackground) {
Divider()
if (!LocalFlatLayout.current) Divider()
if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.nextConnectPrepared) {
if (chat.chatInfo.groupInfo.businessChat == null) {
val isChannel = chat.chatInfo.groupInfo.useRelays
@@ -1838,7 +1839,7 @@ fun ComposeView(
groupDirectInv = chat.chatInfo.contact.groupDirectInv
)
} else {
Row(Modifier.padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
Row(Modifier.flatComposeField().padding(end = 8.dp), verticalAlignment = Alignment.Bottom) {
AttachmentAndCommandsButtons()
val broadcastPlaceholder = (chat.chatInfo as? ChatInfo.Group)?.groupInfo?.let { gi ->
if (gi.useRelays && gi.membership.memberRole >= GroupMemberRole.Owner && chat.chatInfo.groupChatScope() == null) generalGetString(MR.strings.compose_view_broadcast)
@@ -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))
@@ -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()
@@ -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<AnimatedViewState>, 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<AnimatedViewState>) {
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<AnimatedViewState>) {
}
}
) {
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<AnimatedViewState>) {
}
}
@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),
@@ -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<Boolean>,
) {
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 }