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 0000000..1ba37a2 Binary files /dev/null and b/app/desktop/src/main/resources/app_icon.png differ diff --git a/app/desktop/src/main/resources/app_icon.webp b/app/desktop/src/main/resources/app_icon.webp new file mode 100644 index 0000000..1f43575 Binary files /dev/null and b/app/desktop/src/main/resources/app_icon.webp differ diff --git a/app/shared/src/androidMain/kotlin/ru/fromchat/MouseMessageInteraction.android.kt b/app/shared/src/androidMain/kotlin/ru/fromchat/MouseMessageInteraction.android.kt new file mode 100644 index 0000000..4c68140 --- /dev/null +++ b/app/shared/src/androidMain/kotlin/ru/fromchat/MouseMessageInteraction.android.kt @@ -0,0 +1,3 @@ +package ru.fromchat + +actual fun supportsMouseMessageInteraction(): Boolean = false diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index deea7a9..479943b 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -63,6 +63,7 @@ Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку. Вход через Яндекс 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