Window insets and more UI work

This commit is contained in:
2026-07-26 16:40:00 +03:00
Unverified
parent 2b46c88bf3
commit b07cd8abaf
19 changed files with 582 additions and 97 deletions
@@ -1,14 +1,17 @@
package ru.fromchat.desktop package ru.fromchat.desktop
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.BitmapPainter
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Notification import androidx.compose.ui.window.Notification
import androidx.compose.ui.window.Tray import androidx.compose.ui.window.Tray
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
@@ -20,10 +23,12 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.skia.Image
import ru.fromchat.AppForeground import ru.fromchat.AppForeground
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.ui.App import ru.fromchat.ui.App
import ru.fromchat.ui.LocalExtraStatusBarTop
import java.io.BufferedReader import java.io.BufferedReader
import java.io.InputStreamReader import java.io.InputStreamReader
import java.io.OutputStreamWriter import java.io.OutputStreamWriter
@@ -32,6 +37,7 @@ import java.net.ServerSocket
import java.net.Socket import java.net.Socket
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.swing.JRootPane
import kotlin.concurrent.thread import kotlin.concurrent.thread
private object DesktopApplicationBootstrap { private object DesktopApplicationBootstrap {
@@ -189,7 +195,7 @@ fun main(args: Array<String>) {
val windowState = rememberWindowState() val windowState = rememberWindowState()
var windowVisible by remember { mutableStateOf(true) } var windowVisible by remember { mutableStateOf(true) }
val trayState = rememberTrayState() val trayState = rememberTrayState()
val trayIcon = remember { createTrayIconPainter() } val appIcon = remember { loadAppIconPainter() }
val traySupported = remember { java.awt.SystemTray.isSupported() } val traySupported = remember { java.awt.SystemTray.isSupported() }
DisposableEffect(trayState) { DisposableEffect(trayState) {
@@ -206,7 +212,7 @@ fun main(args: Array<String>) {
if (traySupported) { if (traySupported) {
Tray( Tray(
icon = trayIcon, icon = appIcon,
state = trayState, state = trayState,
tooltip = "FromChat", tooltip = "FromChat",
onAction = { windowVisible = true }, onAction = { windowVisible = true },
@@ -229,21 +235,39 @@ fun main(args: Array<String>) {
title = "FromChat", title = "FromChat",
state = windowState, state = windowState,
visible = windowVisible || !traySupported, visible = windowVisible || !traySupported,
icon = trayIcon, icon = appIcon,
) { ) {
LaunchedEffect(window) {
enableEdgeToEdgeTitleBar(window.rootPane)
}
DisposableEffect(Unit) { DisposableEffect(Unit) {
AppForeground.setForeground(true) AppForeground.setForeground(true)
onDispose { } onDispose { }
} }
CompositionLocalProvider(
LocalExtraStatusBarTop provides if (isMacOs()) 28.dp else 0.dp,
) {
App() App()
} }
} }
}
private fun createTrayIconPainter(): Painter = object : Painter() {
override val intrinsicSize: Size = Size(32f, 32f)
override fun DrawScope.onDraw() {
drawRect(Color(0xFF4484F4))
} }
} }
private fun isMacOs(): Boolean =
System.getProperty("os.name").orEmpty().lowercase().contains("mac")
private fun enableEdgeToEdgeTitleBar(rootPane: JRootPane) {
if (!isMacOs()) return
rootPane.putClientProperty("apple.awt.fullWindowContent", true)
rootPane.putClientProperty("apple.awt.transparentTitleBar", true)
rootPane.putClientProperty("apple.awt.windowTitleVisible", false)
}
private fun loadAppIconPainter(): Painter {
val bytes = DesktopApplicationBootstrap::class.java.classLoader
.getResourceAsStream("app_icon.png")
?.use { it.readBytes() }
?: return BitmapPainter(ImageBitmap(32, 32))
val skiaImage = Image.makeFromEncoded(bytes)
return BitmapPainter(skiaImage.toComposeImageBitmap())
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

@@ -0,0 +1,3 @@
package ru.fromchat
actual fun supportsMouseMessageInteraction(): Boolean = false
@@ -63,6 +63,7 @@
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string> <string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string> <string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
<string name="chats">Чаты</string> <string name="chats">Чаты</string>
<string name="select_chat_placeholder">Выберите чат, чтобы начать переписку</string>
<string name="contacts">Контакты</string> <string name="contacts">Контакты</string>
<string name="profile">Профиль</string> <string name="profile">Профиль</string>
<string name="dms">Личные чаты</string> <string name="dms">Личные чаты</string>
@@ -71,6 +71,7 @@
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string> <string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
<!-- Main Screen --> <!-- Main Screen -->
<string name="chats">Chats</string> <string name="chats">Chats</string>
<string name="select_chat_placeholder">Select a chat to start messaging</string>
<string name="contacts">Contacts</string> <string name="contacts">Contacts</string>
<string name="profile">Profile</string> <string name="profile">Profile</string>
<string name="dms">Private chats</string> <string name="dms">Private chats</string>
@@ -0,0 +1,7 @@
package ru.fromchat
/**
* True on desktop/JVM where mouse left-click should select message text and
* right-click opens the context menu.
*/
expect fun supportsMouseMessageInteraction(): Boolean
@@ -11,6 +11,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -24,6 +25,7 @@ import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -100,6 +102,10 @@ import ru.fromchat.ui.chat.panels.dm.DmProfileRoute
import ru.fromchat.ui.chat.panels.publicchat.PublicChatChatRoute import ru.fromchat.ui.chat.panels.publicchat.PublicChatChatRoute
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
import ru.fromchat.ui.main.ConversationListDetailShell
import ru.fromchat.ui.main.ConversationProfileThirdPaneMinWidth
import ru.fromchat.ui.main.EmptyConversationPlaceholder
import ru.fromchat.ui.main.MAIN_PAGE_CHATS
import ru.fromchat.ui.main.MainScreen import ru.fromchat.ui.main.MainScreen
import ru.fromchat.ui.main.chats.ChatsSearchScreen import ru.fromchat.ui.main.chats.ChatsSearchScreen
import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY import ru.fromchat.ui.main.settings.LOG_FILE_OPEN_RESULT_KEY
@@ -130,6 +136,20 @@ private fun isConversationChatRoute(route: String?): Boolean =
route == PublicChatNav.CHAT_ROUTE || route == PublicChatNav.CHAT_ROUTE ||
(route != null && route.startsWith("dm/") && "/chat" in route) (route != null && route.startsWith("dm/") && "/chat" in route)
private fun isConversationProfileRoute(route: String?): Boolean =
route == PublicChatNav.PROFILE_ROUTE ||
(route != null && route.startsWith("dm/") && route.endsWith("/profile"))
private fun isConversationShellRoute(route: String?): Boolean =
route == "chat" ||
isConversationChatRoute(route) ||
isConversationProfileRoute(route)
private fun dmUserIdFromRoute(route: String?): Int? {
if (route == null || !route.startsWith("dm/")) return null
return route.substringAfter("dm/").substringBefore("/").toIntOrNull()?.takeIf { it > 0 }
}
private val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing) private val rootNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
private fun rootNavEnterTransition(): EnterTransition = private fun rootNavEnterTransition(): EnterTransition =
@@ -437,31 +457,27 @@ fun App(
) { ) {
if (startDestination != null) { if (startDestination != null) {
val currentEntry by navController.currentBackStackEntryAsState() val currentEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentEntry?.destination?.route
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
val showConversationListDetail = val showConversationListDetail =
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.EXPANDED && widthSizeClass != WindowWidthSizeClass.COMPACT &&
isConversationChatRoute(currentEntry?.destination?.route) isConversationShellRoute(currentRoute)
ScreenSurface { ScreenSurface {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) { BoxWithConstraints(Modifier.fillMaxSize()) {
if (showConversationListDetail) { val showProfileThirdPane =
Box( showConversationListDetail &&
modifier = Modifier isConversationProfileRoute(currentRoute) &&
.width(360.dp) maxWidth >= ConversationProfileThirdPaneMinWidth
.fillMaxHeight(),
) { @Composable
MainScreen( fun AppNavHost(modifier: Modifier = Modifier) {
sharedTransitionScope = this@SharedTransitionLayout,
snackbarHostState = profileLookupSnackbarHostState,
listPaneOnly = true,
)
}
VerticalDivider()
}
Box(Modifier.weight(1f).fillMaxSize()) {
NavHost( NavHost(
navController = navController, navController = navController,
startDestination = startDestination!!, startDestination = startDestination!!,
modifier = modifier,
enterTransition = { rootNavEnterTransition() }, enterTransition = { rootNavEnterTransition() },
exitTransition = { rootNavExitTransition() }, exitTransition = { rootNavExitTransition() },
popEnterTransition = { rootNavPopEnterTransition() }, popEnterTransition = { rootNavPopEnterTransition() },
@@ -517,12 +533,17 @@ fun App(
} }
composable("chat") { composable("chat") {
if (showConversationListDetail) {
EmptyConversationPlaceholder()
} else {
MainScreen( MainScreen(
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this, animatedVisibilityScope = this,
snackbarHostState = profileLookupSnackbarHostState snackbarHostState = profileLookupSnackbarHostState,
initialPage = pendingMainTab,
) )
} }
}
composable(PublicChatNav.CHAT_ROUTE) { composable(PublicChatNav.CHAT_ROUTE) {
PublicChatChatRoute( PublicChatChatRoute(
@@ -778,6 +799,63 @@ fun App(
} }
} }
} }
if (showConversationListDetail) {
ConversationListDetailShell(
widthSizeClass = widthSizeClass,
showProfilePane = showProfileThirdPane,
listPane = {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
snackbarHostState = profileLookupSnackbarHostState,
listPaneOnly = true,
)
},
detailPane = {
if (showProfileThirdPane) {
androidx.compose.animation.AnimatedVisibility(
visible = true,
enter = EnterTransition.None,
exit = ExitTransition.None,
) {
val dmUserId = dmUserIdFromRoute(currentRoute)
when {
dmUserId != null -> DmChatRoute(
otherUserId = dmUserId,
scrollToMessageId = null,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
currentRoute == PublicChatNav.PROFILE_ROUTE ||
currentRoute == PublicChatNav.CHAT_ROUTE -> {
PublicChatChatRoute(
scrollToMessageId = scrollToMessageId,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
}
}
}
} else {
AppNavHost(Modifier.fillMaxSize())
}
},
profilePane = {
AppNavHost(Modifier.fillMaxSize())
},
onSelectMainTab = { page ->
pendingMainTab = page
navController.navigate("chat") {
popUpTo("chat") { inclusive = true }
launchSingleTop = true
}
},
)
} else {
AppNavHost(Modifier.fillMaxSize())
}
} }
CallOverlay(Modifier.fillMaxSize()) CallOverlay(Modifier.fillMaxSize())
@@ -0,0 +1,28 @@
package ru.fromchat.ui
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
/**
* Extra top inset for desktop edge-to-edge windows where the title bar
* is treated like a status bar.
*/
val LocalExtraStatusBarTop = staticCompositionLocalOf { 0.dp }
/**
* [WindowInsets.statusBars] plus [LocalExtraStatusBarTop] (title-bar inset on desktop).
*/
val WindowInsets.Companion.extraStatusBars: WindowInsets
@Composable
get() {
val extra = LocalExtraStatusBarTop.current
if (extra <= 0.dp) return statusBars
val density = LocalDensity.current
val extraPx = with(density) { extra.roundToPx() }
return statusBars.union(WindowInsets(0, extraPx, 0, 0))
}
@@ -149,6 +149,7 @@ import ru.fromchat.ui.chat.utils.imageAttachmentKey
import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.SuspendedAccountSupportSheet import ru.fromchat.ui.components.SuspendedAccountSupportSheet
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.NetworkConnectivity
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.haptic.HapticFeedbackEvent import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -1060,8 +1061,9 @@ fun ChatScreen(
} }
} }
) { innerPadding -> ) { innerPadding ->
val density = LocalDensity.current val statusBarTopDp = with(density) {
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } WindowInsets.extraStatusBars.getTop(this).toDp()
}
val floatingHeaderClearance = val floatingHeaderClearance =
statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius
SideEffect { SideEffect {
@@ -72,6 +72,7 @@ import ru.fromchat.chat_members_count
import ru.fromchat.ui.chat.utils.TypingUser import ru.fromchat.ui.chat.utils.TypingUser
import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.ConnectingEllipsis
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.profile.StatusBadge import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.ui.profile.resolveVerificationStatus import ru.fromchat.ui.profile.resolveVerificationStatus
@@ -338,7 +339,7 @@ fun ChatTopBar(
val topBarPlaceable = subcompose("topBar") { val topBarPlaceable = subcompose("topBar") {
TopAppBar( TopAppBar(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
windowInsets = WindowInsets.statusBars, windowInsets = WindowInsets.extraStatusBars,
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent, containerColor = Color.Transparent,
scrolledContainerColor = Color.Transparent, scrolledContainerColor = Color.Transparent,
@@ -62,6 +62,7 @@ import ru.fromchat.api.local.download.resolveSavableMessageFile
import ru.fromchat.api.local.download.resolveSavableMessageImage import ru.fromchat.api.local.download.resolveSavableMessageImage
import ru.fromchat.api.local.messages.isQueuedOutbound import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.supportsMouseMessageInteraction
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import com.pr0gramm3r101.utils.scaleOnPress import com.pr0gramm3r101.utils.scaleOnPress
@@ -188,11 +189,19 @@ fun MessageContextMenu(
val density = LocalDensity.current val density = LocalDensity.current
val paddingPx = with(density) { 16.dp.toPx().toInt() } val paddingPx = with(density) { 16.dp.toPx().toInt() }
val allowOutsideWindow = supportsMouseMessageInteraction()
val rightEdge = screenWidthPx - paddingPx val rightEdge = screenWidthPx - paddingPx
val bottomEdge = screenHeightPx - paddingPx val bottomEdge = screenHeightPx - paddingPx
val adjustedOffset = remember(measuredSize, state.position, rightEdge, bottomEdge, paddingPx) { val adjustedOffset = remember(
if (measuredSize == IntSize.Zero) { measuredSize,
state.position,
rightEdge,
bottomEdge,
paddingPx,
allowOutsideWindow,
) {
if (allowOutsideWindow || measuredSize == IntSize.Zero) {
state.position state.position
} else { } else {
var x = state.position.x var x = state.position.x
@@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Timer import androidx.compose.material.icons.outlined.Timer
import androidx.compose.material.icons.rounded.ErrorOutline import androidx.compose.material.icons.rounded.ErrorOutline
@@ -45,8 +46,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Rect import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.isSecondaryPressed
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
@@ -77,6 +82,7 @@ import ru.fromchat.message_edited_suffix
import ru.fromchat.message_reply_jump_cd import ru.fromchat.message_reply_jump_cd
import ru.fromchat.message_reply_photo import ru.fromchat.message_reply_photo
import ru.fromchat.message_send_failed import ru.fromchat.message_send_failed
import ru.fromchat.supportsMouseMessageInteraction
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
import ru.fromchat.ui.chat.utils.imageAttachmentKey import ru.fromchat.ui.chat.utils.imageAttachmentKey
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
@@ -246,7 +252,14 @@ fun MessageItem(
var replyPressed by remember(message.id) { mutableStateOf(false) } var replyPressed by remember(message.id) { mutableStateOf(false) }
var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) } var rowLayoutCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
var bubbleContentCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) } var bubbleContentCoords by remember(message.id) { mutableStateOf<LayoutCoordinates?>(null) }
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f LaunchedEffect(isContextMenuForThisMessage) {
if (!isContextMenuForThisMessage) isPressed = false
}
val scaleTarget = when {
isContextMenuForThisMessage -> 0.96f
isPressed && !isContextMenuOpen -> 0.96f
else -> 1f
}
val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f val replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f
val scale by animateFloatAsState( val scale by animateFloatAsState(
@@ -455,9 +468,29 @@ fun MessageItem(
) )
} }
val mouseMessageUi = supportsMouseMessageInteraction()
val rowLongPress = val rowLongPress =
if (isContextMenuOpen) Modifier if (isContextMenuOpen) Modifier
else Modifier.pointerInput(message.id, onLongPress) { else Modifier.pointerInput(message.id, onLongPress, mouseMessageUi) {
if (mouseMessageUi) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
if (event.type == PointerEventType.Press &&
event.buttons.isSecondaryPressed
) {
event.changes.forEach { it.consume() }
val local = event.changes.firstOrNull()?.position ?: continue
val coords = rowLayoutCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(local))
}
onLongPress()
}
}
}
} else {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
isPressed = true isPressed = true
@@ -476,6 +509,7 @@ fun MessageItem(
}, },
) )
} }
}
Row( Row(
modifier = Modifier modifier = Modifier
@@ -580,7 +614,29 @@ fun MessageItem(
if (isContextMenuOpen) Modifier if (isContextMenuOpen) Modifier
else Modifier else Modifier
.onGloballyPositioned { bubbleContentCoords = it } .onGloballyPositioned { bubbleContentCoords = it }
.pointerInput(message.id, onBubbleTap, onLongPress) { .pointerInput(message.id, onBubbleTap, onLongPress, mouseMessageUi) {
if (mouseMessageUi) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent(PointerEventPass.Main)
when {
event.type == PointerEventType.Press &&
event.buttons.isSecondaryPressed -> {
event.changes.forEach { it.consume() }
val local =
event.changes.firstOrNull()?.position
?: continue
val coords = bubbleContentCoords
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(local))
}
isPressed = true
onLongPress()
}
}
}
}
} else {
detectTapGestures( detectTapGestures(
onPress = { onPress = {
isPressed = true isPressed = true
@@ -600,6 +656,7 @@ fun MessageItem(
}, },
) )
} }
}
Box { Box {
Column( Column(
@@ -1021,6 +1078,8 @@ fun MessageItem(
!isCorrupted && !isCorrupted &&
!isFilenameOnlyMessageCaption(message) !isFilenameOnlyMessageCaption(message)
) { ) {
if (mouseMessageUi) {
SelectionContainer {
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
@@ -1028,6 +1087,15 @@ fun MessageItem(
modifier = Modifier.padding(horizontal = 14.dp) modifier = Modifier.padding(horizontal = 14.dp)
) )
} }
} else {
Text(
text = message.content,
style = MaterialTheme.typography.bodyLarge,
color = contentColor,
modifier = Modifier.padding(horizontal = 14.dp)
)
}
}
} }
} }
} }
@@ -0,0 +1,197 @@
package ru.fromchat.ui.main
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Chat
import androidx.compose.material.icons.filled.Contacts
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.utils.NavigationItem
import com.pr0gramm3r101.utils.SimpleNavigationRail
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.chats
import ru.fromchat.contacts
import ru.fromchat.profile
import ru.fromchat.settings
import ru.fromchat.ui.components.Text
/** Minimum width to show chat + profile side-by-side beside the list. */
val ConversationProfileThirdPaneMinWidth = 1100.dp
private val ProfilePaneSlideEasing = CubicBezierEasing(0.22f, 1f, 0.36f, 1f)
private const val ProfilePaneAnimMs = 520
@Composable
fun ConversationListDetailShell(
widthSizeClass: WindowWidthSizeClass,
showProfilePane: Boolean,
listPane: @Composable () -> Unit,
detailPane: @Composable () -> Unit,
profilePane: @Composable () -> Unit,
onSelectMainTab: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
val useRail = widthSizeClass == WindowWidthSizeClass.EXPANDED
val chatsLabel = stringResource(Res.string.chats)
val contactsLabel = stringResource(Res.string.contacts)
val settingsLabel = stringResource(Res.string.settings)
val profileLabel = stringResource(Res.string.profile)
BoxWithConstraints(modifier.fillMaxSize()) {
val canFitProfilePane = maxWidth >= ConversationProfileThirdPaneMinWidth
val profileVisible = showProfilePane && canFitProfilePane
Row(Modifier.fillMaxSize()) {
if (useRail) {
SimpleNavigationRail(
selectedItem = MAIN_PAGE_CHATS,
modifier = Modifier.fillMaxHeight(),
NavigationItem(
name = chatsLabel,
selectedIcon = Icons.AutoMirrored.Filled.Chat,
onClick = { onSelectMainTab(MAIN_PAGE_CHATS) },
),
NavigationItem(
name = contactsLabel,
selectedIcon = Icons.Filled.Contacts,
onClick = { onSelectMainTab(MAIN_PAGE_CONTACTS) },
),
NavigationItem(
name = settingsLabel,
selectedIcon = Icons.Filled.Settings,
onClick = { onSelectMainTab(MAIN_PAGE_SETTINGS) },
),
NavigationItem(
name = profileLabel,
selectedIcon = Icons.Filled.Person,
onClick = { onSelectMainTab(MAIN_PAGE_PROFILE) },
),
)
}
Column(Modifier.weight(1f).fillMaxHeight()) {
Row(Modifier.weight(1f).fillMaxWidth()) {
Box(
Modifier
.width(360.dp)
.fillMaxHeight(),
) {
listPane()
}
VerticalDivider()
Box(
Modifier
.weight(1f)
.fillMaxHeight(),
) {
detailPane()
}
AnimatedVisibility(
visible = profileVisible,
enter = slideInHorizontally(
animationSpec = tween(
durationMillis = ProfilePaneAnimMs,
easing = ProfilePaneSlideEasing,
),
initialOffsetX = { it },
),
exit = slideOutHorizontally(
animationSpec = tween(
durationMillis = ProfilePaneAnimMs,
easing = ProfilePaneSlideEasing,
),
targetOffsetX = { it },
),
) {
Row(Modifier.fillMaxHeight()) {
VerticalDivider()
Box(
Modifier
.widthIn(min = 320.dp, max = 420.dp)
.fillMaxHeight()
.width(380.dp),
) {
profilePane()
}
}
}
}
if (!useRail) {
NavigationBar(
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceContainer)
.imePadding(),
containerColor = Color.Transparent,
tonalElevation = 0.dp,
windowInsets = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom),
) {
NavigationBarItem(
selected = true,
onClick = { onSelectMainTab(MAIN_PAGE_CHATS) },
label = { Text(chatsLabel) },
icon = {
Icon(Icons.AutoMirrored.Filled.Chat, contentDescription = null)
},
)
NavigationBarItem(
selected = false,
onClick = { onSelectMainTab(MAIN_PAGE_CONTACTS) },
label = { Text(contactsLabel) },
icon = {
Icon(Icons.Filled.Contacts, contentDescription = null)
},
)
NavigationBarItem(
selected = false,
onClick = { onSelectMainTab(MAIN_PAGE_SETTINGS) },
label = { Text(settingsLabel) },
icon = {
Icon(Icons.Filled.Settings, contentDescription = null)
},
)
NavigationBarItem(
selected = false,
onClick = { onSelectMainTab(MAIN_PAGE_PROFILE) },
label = { Text(profileLabel) },
icon = {
Icon(Icons.Filled.Person, contentDescription = null)
},
)
}
}
}
}
}
}
@@ -0,0 +1,40 @@
package ru.fromchat.ui.main
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.select_chat_placeholder
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars
@Composable
fun EmptyConversationPlaceholder(modifier: Modifier = Modifier) {
val density = LocalDensity.current
val topInset = with(density) {
WindowInsets.extraStatusBars.getTop(this).toDp()
}
Box(
modifier = modifier
.fillMaxSize()
.padding(top = topInset)
.padding(horizontal = 32.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = stringResource(Res.string.select_chat_placeholder),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
@@ -34,6 +34,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -75,16 +76,23 @@ import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
import ru.fromchat.ui.main.chats.ChatsTab import ru.fromchat.ui.main.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.extraStatusBars
private const val PAGE_CHATS = 0 const val MAIN_PAGE_CHATS = 0
private const val PAGE_CONTACTS = 1 const val MAIN_PAGE_CONTACTS = 1
private const val PAGE_SETTINGS = 2 const val MAIN_PAGE_SETTINGS = 2
private const val PAGE_PROFILE = 3 const val MAIN_PAGE_PROFILE = 3
private const val PAGE_CHATS = MAIN_PAGE_CHATS
private const val PAGE_CONTACTS = MAIN_PAGE_CONTACTS
private const val PAGE_SETTINGS = MAIN_PAGE_SETTINGS
private const val PAGE_PROFILE = MAIN_PAGE_PROFILE
private const val PAGE_COUNT = 4 private const val PAGE_COUNT = 4
/** /**
* @param listPaneOnly When true, renders only the chats list (no nav rail/bar) for use as the * @param listPaneOnly When true, renders only the chats list (no nav rail/bar) for use as the
* list side of an expanded listdetail conversation layout. * list side of a listdetail conversation layout. Host owns navigation chrome.
* @param initialPage Pager page when showing the full MainScreen.
*/ */
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -93,11 +101,12 @@ fun MainScreen(
animatedVisibilityScope: AnimatedVisibilityScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null,
snackbarHostState: SnackbarHostState? = null, snackbarHostState: SnackbarHostState? = null,
listPaneOnly: Boolean = false, listPaneOnly: Boolean = false,
initialPage: Int = PAGE_CHATS,
) { ) {
val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() } val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() }
val navController = LocalNavController.current val navController = LocalNavController.current
val pagerState = rememberPagerState( val pagerState = rememberPagerState(
initialPage = PAGE_CHATS, initialPage = initialPage.coerceIn(0, PAGE_COUNT - 1),
pageCount = { PAGE_COUNT }, pageCount = { PAGE_COUNT },
) )
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -107,20 +116,30 @@ fun MainScreen(
val contextMenuHazeState = rememberHazeState() val contextMenuHazeState = rememberHazeState()
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
val useNavigationRail = !listPaneOnly && val widthClass = currentWindowAdaptiveInfo().widthSizeClass
currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT // Compact/medium: bottom bar. Expanded: rail. List pane never owns chrome.
val useNavigationRail = !listPaneOnly && widthClass == WindowWidthSizeClass.EXPANDED
val useBottomBar = !listPaneOnly && !useNavigationRail
LaunchedEffect(initialPage) {
if (!listPaneOnly && pagerState.currentPage != initialPage) {
pagerState.scrollToPage(initialPage.coerceIn(0, PAGE_COUNT - 1))
}
}
val selectedPage = pagerState.currentPage val selectedPage = pagerState.currentPage
val isChatsPage = selectedPage == PAGE_CHATS val isChatsPage = selectedPage == PAGE_CHATS
val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress val chatMenuBlurProgress = chatContextMenuOverlay.blurProgress
val statusBarTopDp = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val statusBarTop = with(density) {
WindowInsets.extraStatusBars.getTop(this).toDp()
}
var bottomChromeHeightDp by remember { mutableStateOf(0.dp) } var bottomChromeHeightDp by remember { mutableStateOf(0.dp) }
val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp, useNavigationRail, listPaneOnly) { val mainChromeInsets = remember(statusBarTop, bottomChromeHeightDp, useNavigationRail, useBottomBar) {
MainChromeInsets( MainChromeInsets(
top = statusBarTopDp, top = statusBarTop,
bottom = if (useNavigationRail || listPaneOnly) 0.dp else bottomChromeHeightDp, bottom = if (useBottomBar) bottomChromeHeightDp else 0.dp,
) )
} }
@@ -242,7 +261,7 @@ fun MainScreen(
} }
} }
if (!useNavigationRail && !listPaneOnly) { if (useBottomBar) {
Column( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
@@ -75,6 +75,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import ru.fromchat.ui.extraStatusBars
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -374,7 +375,7 @@ private fun ChatsNormalTopBar(
) { ) {
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
windowInsets = WindowInsets.statusBars, windowInsets = WindowInsets.extraStatusBars,
colors = chatsTopAppBarColors(blurReveal), colors = chatsTopAppBarColors(blurReveal),
title = { title = {
Row( Row(
@@ -468,7 +469,7 @@ private fun ChatsSelectionTopBar(
TopAppBar( TopAppBar(
modifier = modifier, modifier = modifier,
windowInsets = WindowInsets.statusBars, windowInsets = WindowInsets.extraStatusBars,
colors = chatsTopAppBarColors(blurReveal), colors = chatsTopAppBarColors(blurReveal),
navigationIcon = { navigationIcon = {
IconButton(onClick = onClose) { IconButton(onClick = onClose) {
@@ -0,0 +1,3 @@
package ru.fromchat
actual fun supportsMouseMessageInteraction(): Boolean = false
@@ -0,0 +1,3 @@
package ru.fromchat
actual fun supportsMouseMessageInteraction(): Boolean = true