From b07cd8abafc5d15683e382a043ca9444f348b919 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 26 Jul 2026 16:40:00 +0300 Subject: [PATCH] Window insets and more UI work --- .../main/kotlin/ru/fromchat/desktop/Main.kt | 48 +++-- app/desktop/src/main/resources/app_icon.png | Bin 0 -> 26137 bytes app/desktop/src/main/resources/app_icon.webp | Bin 0 -> 18278 bytes .../MouseMessageInteraction.android.kt | 3 + .../composeResources/values-ru/strings.xml | 1 + .../composeResources/values/strings.xml | 1 + .../ru/fromchat/MouseMessageInteraction.kt | 7 + .../commonMain/kotlin/ru/fromchat/ui/App.kt | 124 +++++++++-- .../kotlin/ru/fromchat/ui/StatusBarInsets.kt | 28 +++ .../kotlin/ru/fromchat/ui/chat/ChatScreen.kt | 6 +- .../kotlin/ru/fromchat/ui/chat/ChatTopBar.kt | 3 +- .../ru/fromchat/ui/chat/MessageContextMenu.kt | 13 +- .../kotlin/ru/fromchat/ui/chat/MessageItem.kt | 152 ++++++++++---- .../ui/main/ConversationListDetailShell.kt | 197 ++++++++++++++++++ .../ui/main/EmptyConversationPlaceholder.kt | 40 ++++ .../kotlin/ru/fromchat/ui/main/MainScreen.kt | 45 ++-- .../ru/fromchat/ui/main/chats/ChatsTab.kt | 5 +- .../fromchat/MouseMessageInteraction.ios.kt | 3 + .../fromchat/MouseMessageInteraction.jvm.kt | 3 + 19 files changed, 582 insertions(+), 97 deletions(-) create mode 100644 app/desktop/src/main/resources/app_icon.png create mode 100644 app/desktop/src/main/resources/app_icon.webp create mode 100644 app/shared/src/androidMain/kotlin/ru/fromchat/MouseMessageInteraction.android.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/MouseMessageInteraction.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/StatusBarInsets.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/EmptyConversationPlaceholder.kt create mode 100644 app/shared/src/iosMain/kotlin/ru/fromchat/MouseMessageInteraction.ios.kt create mode 100644 app/shared/src/jvmMain/kotlin/ru/fromchat/MouseMessageInteraction.jvm.kt diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index b600172..12cd90f 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -1,14 +1,17 @@ package ru.fromchat.desktop +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter 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.Tray import androidx.compose.ui.window.Window @@ -20,10 +23,12 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import org.jetbrains.skia.Image import ru.fromchat.AppForeground import ru.fromchat.api.ApiClient import ru.fromchat.api.local.workers.AttachmentTransferBootstrap import ru.fromchat.ui.App +import ru.fromchat.ui.LocalExtraStatusBarTop import java.io.BufferedReader import java.io.InputStreamReader import java.io.OutputStreamWriter @@ -32,6 +37,7 @@ import java.net.ServerSocket import java.net.Socket import java.nio.charset.StandardCharsets import java.util.concurrent.atomic.AtomicBoolean +import javax.swing.JRootPane import kotlin.concurrent.thread private object DesktopApplicationBootstrap { @@ -189,7 +195,7 @@ fun main(args: Array) { val windowState = rememberWindowState() var windowVisible by remember { mutableStateOf(true) } val trayState = rememberTrayState() - val trayIcon = remember { createTrayIconPainter() } + val appIcon = remember { loadAppIconPainter() } val traySupported = remember { java.awt.SystemTray.isSupported() } DisposableEffect(trayState) { @@ -206,7 +212,7 @@ fun main(args: Array) { if (traySupported) { Tray( - icon = trayIcon, + icon = appIcon, state = trayState, tooltip = "FromChat", onAction = { windowVisible = true }, @@ -229,21 +235,39 @@ fun main(args: Array) { title = "FromChat", state = windowState, visible = windowVisible || !traySupported, - icon = trayIcon, + icon = appIcon, ) { + LaunchedEffect(window) { + enableEdgeToEdgeTitleBar(window.rootPane) + } DisposableEffect(Unit) { AppForeground.setForeground(true) onDispose { } } - App() + CompositionLocalProvider( + LocalExtraStatusBarTop provides if (isMacOs()) 28.dp else 0.dp, + ) { + App() + } } } } -private fun createTrayIconPainter(): Painter = object : Painter() { - override val intrinsicSize: Size = Size(32f, 32f) +private fun isMacOs(): Boolean = + System.getProperty("os.name").orEmpty().lowercase().contains("mac") - override fun DrawScope.onDraw() { - drawRect(Color(0xFF4484F4)) - } +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()) } diff --git a/app/desktop/src/main/resources/app_icon.png b/app/desktop/src/main/resources/app_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1ba37a2c71970866352231ca356943383a26f8f0 GIT binary patch literal 26137 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 p8m%?jE)5OY_#hQhNTE<*as>?Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку. Вход через Яндекс ID отменён или не удался. Чаты + Выберите чат, чтобы начать переписку Контакты Профиль Личные чаты diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 50ffb6e..20e0af8 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -71,6 +71,7 @@ Yandex sign-in was cancelled or failed. Chats + Select a chat to start messaging Contacts Profile Private chats diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/MouseMessageInteraction.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/MouseMessageInteraction.kt new file mode 100644 index 0000000..0c8c3fc --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/MouseMessageInteraction.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 3d311ba..271c5ec 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -11,6 +11,7 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -24,6 +25,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.PublicChatNav 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.chats.ChatsSearchScreen 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 != 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(durationMillis = 250, easing = FastOutSlowInEasing) private fun rootNavEnterTransition(): EnterTransition = @@ -437,31 +457,27 @@ fun App( ) { if (startDestination != null) { val currentEntry by navController.currentBackStackEntryAsState() + val currentRoute = currentEntry?.destination?.route + val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass + var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) } val showConversationListDetail = - currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.EXPANDED && - isConversationChatRoute(currentEntry?.destination?.route) + widthSizeClass != WindowWidthSizeClass.COMPACT && + isConversationShellRoute(currentRoute) ScreenSurface { Box(Modifier.fillMaxSize()) { - Row(Modifier.fillMaxSize()) { - if (showConversationListDetail) { - Box( - modifier = Modifier - .width(360.dp) - .fillMaxHeight(), - ) { - MainScreen( - sharedTransitionScope = this@SharedTransitionLayout, - snackbarHostState = profileLookupSnackbarHostState, - listPaneOnly = true, - ) - } - VerticalDivider() - } - Box(Modifier.weight(1f).fillMaxSize()) { + BoxWithConstraints(Modifier.fillMaxSize()) { + val showProfileThirdPane = + showConversationListDetail && + isConversationProfileRoute(currentRoute) && + maxWidth >= ConversationProfileThirdPaneMinWidth + + @Composable + fun AppNavHost(modifier: Modifier = Modifier) { NavHost( navController = navController, startDestination = startDestination!!, + modifier = modifier, enterTransition = { rootNavEnterTransition() }, exitTransition = { rootNavExitTransition() }, popEnterTransition = { rootNavPopEnterTransition() }, @@ -517,11 +533,16 @@ fun App( } composable("chat") { - MainScreen( - sharedTransitionScope = this@SharedTransitionLayout, - animatedVisibilityScope = this, - snackbarHostState = profileLookupSnackbarHostState - ) + if (showConversationListDetail) { + EmptyConversationPlaceholder() + } else { + MainScreen( + sharedTransitionScope = this@SharedTransitionLayout, + animatedVisibilityScope = this, + snackbarHostState = profileLookupSnackbarHostState, + initialPage = pendingMainTab, + ) + } } composable(PublicChatNav.CHAT_ROUTE) { @@ -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()) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/StatusBarInsets.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/StatusBarInsets.kt new file mode 100644 index 0000000..42b033b --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/StatusBarInsets.kt @@ -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)) + } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index 9b9520b..4f94e50 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -149,6 +149,7 @@ import ru.fromchat.ui.chat.utils.imageAttachmentKey import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.SuspendedAccountSupportSheet +import ru.fromchat.ui.extraStatusBars import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.haptic.HapticFeedbackEvent @@ -1060,8 +1061,9 @@ fun ChatScreen( } } ) { innerPadding -> - val density = LocalDensity.current - val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() } + val statusBarTopDp = with(density) { + WindowInsets.extraStatusBars.getTop(this).toDp() + } val floatingHeaderClearance = statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius SideEffect { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt index 3cebf86..f183d10 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatTopBar.kt @@ -72,6 +72,7 @@ import ru.fromchat.chat_members_count import ru.fromchat.ui.chat.utils.TypingUser import ru.fromchat.ui.components.ConnectingEllipsis import ru.fromchat.ui.components.Text +import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.profile.StatusBadge import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.resolveVerificationStatus @@ -338,7 +339,7 @@ fun ChatTopBar( val topBarPlaceable = subcompose("topBar") { TopAppBar( modifier = Modifier.fillMaxWidth(), - windowInsets = WindowInsets.statusBars, + windowInsets = WindowInsets.extraStatusBars, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, scrolledContainerColor = Color.Transparent, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt index 271af80..f6da6b6 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageContextMenu.kt @@ -62,6 +62,7 @@ import ru.fromchat.api.local.download.resolveSavableMessageFile import ru.fromchat.api.local.download.resolveSavableMessageImage import ru.fromchat.api.local.messages.isQueuedOutbound import ru.fromchat.api.schema.messages.Message +import ru.fromchat.supportsMouseMessageInteraction import ru.fromchat.ui.components.Text import com.pr0gramm3r101.utils.scaleOnPress @@ -188,11 +189,19 @@ fun MessageContextMenu( val density = LocalDensity.current val paddingPx = with(density) { 16.dp.toPx().toInt() } + val allowOutsideWindow = supportsMouseMessageInteraction() val rightEdge = screenWidthPx - paddingPx val bottomEdge = screenHeightPx - paddingPx - val adjustedOffset = remember(measuredSize, state.position, rightEdge, bottomEdge, paddingPx) { - if (measuredSize == IntSize.Zero) { + val adjustedOffset = remember( + measuredSize, + state.position, + rightEdge, + bottomEdge, + paddingPx, + allowOutsideWindow, + ) { + if (allowOutsideWindow || measuredSize == IntSize.Zero) { state.position } else { var x = state.position.x diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index 422e806..7211ddb 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -28,6 +28,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Timer 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.geometry.Offset import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.TransformOrigin 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.layout.LayoutCoordinates 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_photo import ru.fromchat.message_send_failed +import ru.fromchat.supportsMouseMessageInteraction import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage import ru.fromchat.ui.chat.utils.imageAttachmentKey import ru.fromchat.ui.components.Text @@ -246,7 +252,14 @@ fun MessageItem( var replyPressed by remember(message.id) { mutableStateOf(false) } var rowLayoutCoords by remember(message.id) { mutableStateOf(null) } var bubbleContentCoords by remember(message.id) { mutableStateOf(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 replyScaleTarget = if (replyPressed && !isContextMenuOpen) 0.96f else 1f val scale by animateFloatAsState( @@ -455,26 +468,47 @@ fun MessageItem( ) } + val mouseMessageUi = supportsMouseMessageInteraction() + val rowLongPress = if (isContextMenuOpen) Modifier - else Modifier.pointerInput(message.id, onLongPress) { - detectTapGestures( - onPress = { - isPressed = true - try { - awaitRelease() - } finally { - isPressed = false + 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() + } } - }, - onLongPress = { localOffset -> - val coords = rowLayoutCoords - if (coords != null && coords.isAttached) { - onTapPosition(coords.localToRoot(localOffset)) - } - onLongPress() - }, - ) + } + } else { + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false + } + }, + onLongPress = { localOffset -> + val coords = rowLayoutCoords + if (coords != null && coords.isAttached) { + onTapPosition(coords.localToRoot(localOffset)) + } + onLongPress() + }, + ) + } } Row( @@ -580,25 +614,48 @@ fun MessageItem( if (isContextMenuOpen) Modifier else Modifier .onGloballyPositioned { bubbleContentCoords = it } - .pointerInput(message.id, onBubbleTap, onLongPress) { - detectTapGestures( - onPress = { - isPressed = true - try { - awaitRelease() - } finally { - isPressed = false + .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() + } + } } - }, - onTap = { onBubbleTap?.invoke() }, - onLongPress = { localOffset -> - val coords = bubbleContentCoords - if (coords != null && coords.isAttached) { - onTapPosition(coords.localToRoot(localOffset)) - } - onLongPress() - }, - ) + } + } else { + detectTapGestures( + onPress = { + isPressed = true + try { + awaitRelease() + } finally { + isPressed = false + } + }, + onTap = { onBubbleTap?.invoke() }, + onLongPress = { localOffset -> + val coords = bubbleContentCoords + if (coords != null && coords.isAttached) { + onTapPosition(coords.localToRoot(localOffset)) + } + onLongPress() + }, + ) + } } Box { @@ -1021,12 +1078,23 @@ fun MessageItem( !isCorrupted && !isFilenameOnlyMessageCaption(message) ) { - Text( - text = message.content, - style = MaterialTheme.typography.bodyLarge, - color = contentColor, - modifier = Modifier.padding(horizontal = 14.dp) - ) + if (mouseMessageUi) { + SelectionContainer { + Text( + text = message.content, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + modifier = Modifier.padding(horizontal = 14.dp) + ) + } + } else { + Text( + text = message.content, + style = MaterialTheme.typography.bodyLarge, + color = contentColor, + modifier = Modifier.padding(horizontal = 14.dp) + ) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt new file mode 100644 index 0000000..dd666a8 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt @@ -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) + }, + ) + } + } + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/EmptyConversationPlaceholder.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/EmptyConversationPlaceholder.kt new file mode 100644 index 0000000..28c8be0 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/EmptyConversationPlaceholder.kt @@ -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, + ) + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt index cecd133..c29d514 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/MainScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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.settings.SettingsTab import ru.fromchat.ui.profile.ProfileScreen +import ru.fromchat.ui.extraStatusBars -private const val PAGE_CHATS = 0 -private const val PAGE_CONTACTS = 1 -private const val PAGE_SETTINGS = 2 -private const val PAGE_PROFILE = 3 +const val MAIN_PAGE_CHATS = 0 +const val MAIN_PAGE_CONTACTS = 1 +const val MAIN_PAGE_SETTINGS = 2 +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 /** * @param listPaneOnly When true, renders only the chats list (no nav rail/bar) for use as the - * list side of an expanded list–detail conversation layout. + * list side of a list–detail conversation layout. Host owns navigation chrome. + * @param initialPage Pager page when showing the full MainScreen. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -93,11 +101,12 @@ fun MainScreen( animatedVisibilityScope: AnimatedVisibilityScope? = null, snackbarHostState: SnackbarHostState? = null, listPaneOnly: Boolean = false, + initialPage: Int = PAGE_CHATS, ) { val effectiveSnackbarHostState = snackbarHostState ?: remember { SnackbarHostState() } val navController = LocalNavController.current val pagerState = rememberPagerState( - initialPage = PAGE_CHATS, + initialPage = initialPage.coerceIn(0, PAGE_COUNT - 1), pageCount = { PAGE_COUNT }, ) val scope = rememberCoroutineScope() @@ -107,20 +116,30 @@ fun MainScreen( val contextMenuHazeState = rememberHazeState() val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } - val useNavigationRail = !listPaneOnly && - currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT + val widthClass = currentWindowAdaptiveInfo().widthSizeClass + // 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 isChatsPage = selectedPage == PAGE_CHATS 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) } - val mainChromeInsets = remember(statusBarTopDp, bottomChromeHeightDp, useNavigationRail, listPaneOnly) { + val mainChromeInsets = remember(statusBarTop, bottomChromeHeightDp, useNavigationRail, useBottomBar) { MainChromeInsets( - top = statusBarTopDp, - bottom = if (useNavigationRail || listPaneOnly) 0.dp else bottomChromeHeightDp, + top = statusBarTop, + bottom = if (useBottomBar) bottomChromeHeightDp else 0.dp, ) } @@ -242,7 +261,7 @@ fun MainScreen( } } - if (!useNavigationRail && !listPaneOnly) { + if (useBottomBar) { Column( modifier = Modifier .align(Alignment.BottomCenter) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index 64f9dd4..6bfc79e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -75,6 +75,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import ru.fromchat.ui.extraStatusBars import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -374,7 +375,7 @@ private fun ChatsNormalTopBar( ) { TopAppBar( modifier = modifier, - windowInsets = WindowInsets.statusBars, + windowInsets = WindowInsets.extraStatusBars, colors = chatsTopAppBarColors(blurReveal), title = { Row( @@ -468,7 +469,7 @@ private fun ChatsSelectionTopBar( TopAppBar( modifier = modifier, - windowInsets = WindowInsets.statusBars, + windowInsets = WindowInsets.extraStatusBars, colors = chatsTopAppBarColors(blurReveal), navigationIcon = { IconButton(onClick = onClose) { diff --git a/app/shared/src/iosMain/kotlin/ru/fromchat/MouseMessageInteraction.ios.kt b/app/shared/src/iosMain/kotlin/ru/fromchat/MouseMessageInteraction.ios.kt new file mode 100644 index 0000000..4c68140 --- /dev/null +++ b/app/shared/src/iosMain/kotlin/ru/fromchat/MouseMessageInteraction.ios.kt @@ -0,0 +1,3 @@ +package ru.fromchat + +actual fun supportsMouseMessageInteraction(): Boolean = false diff --git a/app/shared/src/jvmMain/kotlin/ru/fromchat/MouseMessageInteraction.jvm.kt b/app/shared/src/jvmMain/kotlin/ru/fromchat/MouseMessageInteraction.jvm.kt new file mode 100644 index 0000000..6f0ad8b --- /dev/null +++ b/app/shared/src/jvmMain/kotlin/ru/fromchat/MouseMessageInteraction.jvm.kt @@ -0,0 +1,3 @@ +package ru.fromchat + +actual fun supportsMouseMessageInteraction(): Boolean = true