diff --git a/app/desktop/build.gradle.kts b/app/desktop/build.gradle.kts index 62931d2..8b7447d 100644 --- a/app/desktop/build.gradle.kts +++ b/app/desktop/build.gradle.kts @@ -10,6 +10,7 @@ val javafxModules = listOf("base", "graphics", "controls", "media", "web", "swin dependencies { implementation(compose.desktop.currentOs) + implementation(libs.compose.components.resources) implementation(project(":app:shared")) implementation(project(":utils:shared")) implementation(libs.kotlinx.coroutines.swing) 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 cb8f8d4..1de229e 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -53,11 +53,29 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource import org.jetbrains.skia.Image import ru.fromchat.AppForeground import ru.fromchat.Logger +import ru.fromchat.Res +import ru.fromchat.action_copy import ru.fromchat.api.ApiClient import ru.fromchat.api.local.workers.AttachmentTransferBootstrap +import ru.fromchat.app_name +import ru.fromchat.desktop_about_app +import ru.fromchat.desktop_cut +import ru.fromchat.desktop_menu_actions +import ru.fromchat.desktop_menu_edit +import ru.fromchat.desktop_menu_window +import ru.fromchat.desktop_minimize +import ru.fromchat.desktop_paste +import ru.fromchat.desktop_quit +import ru.fromchat.desktop_select_all +import ru.fromchat.desktop_show_app +import ru.fromchat.desktop_tray_show +import ru.fromchat.desktop_zoom import ru.fromchat.ui.App import ru.fromchat.ui.LocalExtraStatusBarTop @@ -196,7 +214,8 @@ private object DesktopProtocolRegistration { fun main(args: Array) { if (isMacOs()) { - System.setProperty("apple.awt.application.name", "FromChat") + val appName = runBlocking { getString(Res.string.app_name) } + System.setProperty("apple.awt.application.name", appName) System.setProperty("apple.laf.useScreenMenuBar", "true") } if (!DesktopSingleInstance.acquireOrForward(args)) return @@ -220,6 +239,10 @@ fun main(args: Array) { val windowIcon = remember { loadAppIconPainter(tray = false) } val traySupported = remember { java.awt.SystemTray.isSupported() } val mac = remember { isMacOs() } + val appName = stringResource(Res.string.app_name) + val aboutApp = stringResource(Res.string.desktop_about_app) + val trayShow = stringResource(Res.string.desktop_tray_show) + val quit = stringResource(Res.string.desktop_quit) DisposableEffect(trayState) { DesktopNotifier.sink = { title, body -> @@ -247,13 +270,13 @@ fun main(args: Array) { Tray( icon = trayIcon, state = trayState, - tooltip = "FromChat", + tooltip = appName, onAction = { windowVisible = true }, menu = { - Item("Show") { windowVisible = true } - Item("About FromChat") { aboutOpen = true } + Item(trayShow) { windowVisible = true } + Item(aboutApp) { aboutOpen = true } Separator() - Item("Quit") { exitApplication() } + Item(quit) { exitApplication() } }, ) } @@ -267,7 +290,7 @@ fun main(args: Array) { exitApplication() } }, - title = "FromChat", + title = appName, state = windowState, visible = windowVisible || !traySupported, icon = windowIcon, @@ -330,7 +353,7 @@ fun main(args: Array) { if (aboutOpen) { Window( onCloseRequest = { aboutOpen = false }, - title = "About FromChat", + title = aboutApp, state = aboutWindowState, icon = windowIcon, ) { @@ -347,45 +370,57 @@ private fun FrameWindowScope.FromChatMenuBar( onMinimize: () -> Unit, onZoom: () -> Unit, ) { + val actions = stringResource(Res.string.desktop_menu_actions) + val aboutApp = stringResource(Res.string.desktop_about_app) + val showApp = stringResource(Res.string.desktop_show_app) + val edit = stringResource(Res.string.desktop_menu_edit) + val cut = stringResource(Res.string.desktop_cut) + val copy = stringResource(Res.string.action_copy) + val paste = stringResource(Res.string.desktop_paste) + val selectAll = stringResource(Res.string.desktop_select_all) + val windowMenu = stringResource(Res.string.desktop_menu_window) + val minimize = stringResource(Res.string.desktop_minimize) + val zoom = stringResource(Res.string.desktop_zoom) + MenuBar { - Menu("Actions", mnemonic = 'A') { - Item("About FromChat", onClick = onAbout) + Menu(actions, mnemonic = 'A') { + Item(aboutApp, onClick = onAbout) Item( - "Show FromChat", + showApp, shortcut = KeyShortcut(Key.N, meta = true, shift = true), onClick = onShow, ) } - Menu("Edit", mnemonic = 'E') { + Menu(edit, mnemonic = 'E') { Item( - "Cut", + cut, shortcut = KeyShortcut(Key.X, meta = true), onClick = { performAwtEditAction(DefaultEditorKit.cutAction) }, ) Item( - "Copy", + copy, shortcut = KeyShortcut(Key.C, meta = true), onClick = { performAwtEditAction(DefaultEditorKit.copyAction) }, ) Item( - "Paste", + paste, shortcut = KeyShortcut(Key.V, meta = true), onClick = { performAwtEditAction(DefaultEditorKit.pasteAction) }, ) Separator() Item( - "Select All", + selectAll, shortcut = KeyShortcut(Key.A, meta = true), onClick = { performAwtEditAction(DefaultEditorKit.selectAllAction) }, ) } - Menu("Window", mnemonic = 'W') { + Menu(windowMenu, mnemonic = 'W') { Item( - "Minimize", + minimize, shortcut = KeyShortcut(Key.M, meta = true), onClick = onMinimize, ) - Item("Zoom", onClick = onZoom) + Item(zoom, onClick = onZoom) } } } diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index 1f981f8..d678bee 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -18,6 +18,18 @@ Регистрируясь, вы соглашаетесь с и 100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере. + Действия + Правка + Окно + О FromChat + Показать + Показать FromChat + Выйти + Вырезать + Вставить + Выделить всё + Свернуть + Масштабировать Добро пожаловать! Войти Войдите в свой аккаунт diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index e883fbe..2a5af2c 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -22,6 +22,20 @@ and 100% free and open messenger. Supports self-hosted installation on your own server. + + Actions + Edit + Window + About FromChat + Show + Show FromChat + Quit + Cut + Paste + Select All + Minimize + Zoom + Welcome! Log in 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 9b1c6fa..954131a 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -1,5 +1,6 @@ package ru.fromchat.ui +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition @@ -10,6 +11,7 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize @@ -130,7 +132,9 @@ import ru.fromchat.ui.profile.EditProfileFocusField import ru.fromchat.ui.profile.EditProfileScreen import ru.fromchat.ui.profile.ProfileRoutes import ru.fromchat.ui.profile.ProfileScreen +import ru.fromchat.ui.components.LocalPaneHazeState import ru.fromchat.ui.components.ScreenSurface +import dev.chrisbanes.haze.rememberHazeState import ru.fromchat.utils.NetworkConnectivity val LocalNavController = compositionLocalOf { error("NavController not provided") } @@ -144,28 +148,32 @@ private fun isConversationProfileRoute(route: String?): Boolean = (route != null && route.startsWith("dm/") && route.endsWith("/profile")) private fun isStandaloneProfileRoute(route: String?): Boolean = - route != null && route.startsWith("profile/") + route != null && + route.startsWith("profile/") && + !route.startsWith(ProfileRoutes.Edit.substringBefore("?")) -private fun standaloneProfileUserId(route: String?): Int? { - if (route == null || !isStandaloneProfileRoute(route)) return null - return route - .substringAfter("profile/") - .substringBefore("?") - .toIntOrNull() - ?.takeIf { it > 0 } +/** + * Nav destination.route is the pattern (`profile/{userId}…`), not the filled path — + * read the userId from entry args / saved state. + */ +private fun standaloneProfileUserId(entry: NavBackStackEntry?): Int? { + val route = entry?.destination?.route ?: return null + if (!isStandaloneProfileRoute(route)) return null + return entry.savedStateHandle.get("userId")?.toIntOrNull()?.takeIf { it > 0 } } private fun isConversationShellRoute( route: String?, previousRoute: String?, ownUserId: Int?, + profileUserId: Int?, ): Boolean { if (route == "chat" || isConversationChatRoute(route) || isConversationProfileRoute(route)) { return true } if (isStandaloneProfileRoute(route)) { if (isConversationChatRoute(previousRoute)) return true - return ownUserId != null && standaloneProfileUserId(route) == ownUserId + return ownUserId != null && profileUserId == ownUserId } if (route != null && route.startsWith("settings/")) return true if (route == "about") return true @@ -491,12 +499,18 @@ fun App( val currentRoute = currentEntry?.destination?.route val previousRoute = navController.previousBackStackEntry?.destination?.route + val profileUserId = standaloneProfileUserId(currentEntry) val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) } val ownUserId = ApiClient.user?.id val showConversationListDetail = widthSizeClass != WindowWidthSizeClass.COMPACT && - isConversationShellRoute(currentRoute, previousRoute, ownUserId) + isConversationShellRoute( + currentRoute, + previousRoute, + ownUserId, + profileUserId, + ) ScreenSurface { Box(Modifier.fillMaxSize()) { @@ -517,7 +531,8 @@ fun App( showConversationListDetail && isStandaloneProfileRoute(currentRoute) && !showProfileThirdPane && - standaloneProfileUserId(currentRoute) == ownUserId + profileUserId != null && + profileUserId == ownUserId val listPaneWidth = if (widthSizeClass == WindowWidthSizeClass.EXPANDED) { 448.dp @@ -860,27 +875,61 @@ fun App( } if (showConversationListDetail) { - ConversationListDetailShell( - showProfilePane = showProfileThirdPane, - listPaneWidth = listPaneWidth, - listPane = { - MainScreen( - sharedTransitionScope = this@SharedTransitionLayout, - snackbarHostState = profileLookupSnackbarHostState, - embeddedInListDetail = true, - initialPage = pendingMainTab, - forceSettingsTab = forceSettingsListTab, - onPageChanged = { pendingMainTab = it }, - ) - }, + val detailPaneState = when { + showProfileThirdPane -> "split" + currentRoute == "chat" -> "empty-$pendingMainTab" + else -> "nav" + } + val detailEdgeToEdge = + showProfileThirdPane || isConversationChatRoute(currentRoute) + // Empty settings placeholder stays transparent (no AppPanel fill). + val detailInPanel = + settingsProfileSplit || isSettingsDetailRoute(currentRoute) + val detailPaneAnim = tween( + durationMillis = 280, + easing = FastOutSlowInEasing, + ) + val listPaneHazeState = rememberHazeState() + CompositionLocalProvider( + LocalPaneHazeState provides listPaneHazeState, + ) { + ConversationListDetailShell( + showProfilePane = showProfileThirdPane, + listPaneWidth = listPaneWidth, + detailInPanel = detailInPanel, + detailEdgeToEdge = detailEdgeToEdge, + listPane = { + MainScreen( + sharedTransitionScope = this@SharedTransitionLayout, + snackbarHostState = profileLookupSnackbarHostState, + embeddedInListDetail = true, + initialPage = pendingMainTab, + forceSettingsTab = forceSettingsListTab, + onPageChanged = { pendingMainTab = it }, + ) + }, detailPane = { - when { - showProfileThirdPane -> { - androidx.compose.animation.AnimatedVisibility( - visible = true, - enter = EnterTransition.None, - exit = ExitTransition.None, - ) { + AnimatedContent( + targetState = detailPaneState, + transitionSpec = { + ( + fadeIn(animationSpec = detailPaneAnim) + + scaleIn( + initialScale = 0.98f, + animationSpec = detailPaneAnim, + ) + ) togetherWith fadeOut( + animationSpec = tween( + durationMillis = 180, + easing = FastOutSlowInEasing, + ), + ) + }, + label = "detailPane", + modifier = Modifier.fillMaxSize(), + ) { state -> + when { + state == "split" -> { val chatRouteForDetail = when { isConversationProfileRoute(currentRoute) -> @@ -911,23 +960,22 @@ fun App( } } } - } - isSettingsDetailRoute(currentRoute) -> - AppNavHost(Modifier.fillMaxSize()) - currentRoute == "chat" -> { - when (pendingMainTab) { - MAIN_PAGE_CONTACTS -> EmptyContactsPlaceholder() - MAIN_PAGE_SETTINGS -> EmptySettingsPlaceholder() - else -> EmptyConversationPlaceholder() + state.startsWith("empty-") -> { + when (pendingMainTab) { + MAIN_PAGE_CONTACTS -> EmptyContactsPlaceholder() + MAIN_PAGE_SETTINGS -> EmptySettingsPlaceholder() + else -> EmptyConversationPlaceholder() + } } + else -> AppNavHost(Modifier.fillMaxSize()) } - else -> AppNavHost(Modifier.fillMaxSize()) } }, profilePane = { AppNavHost(Modifier.fillMaxSize()) }, - ) + ) + } } else { AppNavHost(Modifier.fillMaxSize()) } 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 3ba34ea..be3f349 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 @@ -8,6 +8,7 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -98,6 +99,8 @@ import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive import org.jetbrains.compose.resources.stringResource import ru.fromchat.Logger +import ru.fromchat.ui.main.ConversationDetailContentPadding +import ru.fromchat.ui.main.LocalConversationListDetailActive import ru.fromchat.Res import ru.fromchat.presence_recently import ru.fromchat.api.ApiClient @@ -429,6 +432,9 @@ fun ChatScreen( val profileUserId = panelState.profileUserId val hazeState = rememberHazeState() val hazeBlurEnabled = !(panelState.isLoading && panelState.messages.isEmpty()) + val listDetailActive = LocalConversationListDetailActive.current + val detailContentPad = + if (listDetailActive) ConversationDetailContentPadding else 0.dp val currentTypingUsers = panelState.typingUsers // Directly use from panelState val statusMap by UserStatusStore.status.collectAsState() @@ -833,10 +839,12 @@ fun ChatScreen( } } ) { + val inputPad = Modifier.padding(horizontal = detailContentPad) if (peerDeleted && dmRecipientId != null) { Box( modifier = Modifier .fillMaxWidth() + .then(inputPad) .padding(start = 16.dp, end = 16.dp, bottom = 8.dp), ) { Button( @@ -863,6 +871,7 @@ fun ChatScreen( } } } else { + Column(modifier = inputPad) { ChatInput( text = inputText, onTextChange = { inputText = it }, @@ -1058,14 +1067,18 @@ fun ChatScreen( } ) } + } } } ) { innerPadding -> val statusBarTopDp = with(density) { WindowInsets.extraStatusBars.getTop(this).toDp() } - val floatingHeaderClearance = + val floatingHeaderClearance = if (listDetailActive) { + statusBarTopDp + 8.dp + 48.dp + 8.dp + } else { statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius + } SideEffect { chatScrollClearancePx.value = with(density) { floatingHeaderClearance.roundToPx() to innerPadding.calculateBottomPadding().roundToPx() @@ -1106,6 +1119,7 @@ fun ChatScreen( } .background(MaterialTheme.colorScheme.background) .hazeSource(hazeState), + contentPadding = PaddingValues(horizontal = detailContentPad), userScrollEnabled = !contextMenuState.isOpen, reverseLayout = true, ) { @@ -1371,7 +1385,7 @@ fun ChatScreen( .align(Alignment.BottomEnd) .zIndex(2f) .padding( - end = 13.dp, + end = 13.dp + detailContentPad, bottom = innerPadding.calculateBottomPadding() + 12.dp, ), ) { @@ -1402,6 +1416,7 @@ fun ChatScreen( ChatTopBar( hazeState = hazeState, hazeBlurEnabled = hazeBlurEnabled, + pillChrome = listDetailActive, onBack = { runNav { navController.navigateUp() } }, 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 f183d10..418e8b6 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 @@ -19,9 +19,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Call @@ -64,7 +65,9 @@ import com.pr0gramm3r101.utils.conditional import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeBlurStyle import dev.chrisbanes.haze.blur.HazeColorEffect +import dev.chrisbanes.haze.blur.HazeProgressive import dev.chrisbanes.haze.blur.blurEffect +import dev.chrisbanes.haze.blur.materials.HazeMaterials import dev.chrisbanes.haze.hazeEffect import org.jetbrains.compose.resources.stringResource import ru.fromchat.Res @@ -73,6 +76,7 @@ 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.main.ConversationDetailContentPadding import ru.fromchat.ui.profile.StatusBadge import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.resolveVerificationStatus @@ -328,7 +332,23 @@ fun ChatTopBar( titleChrome: @Composable () -> Unit, modifier: Modifier = Modifier, hazeBlurEnabled: Boolean = true, + pillChrome: Boolean = false, ) { + if (pillChrome) { + ChatTopBarPill( + hazeState = hazeState, + onBack = onBack, + backContentDescription = backContentDescription, + showCallButton = showCallButton, + onCallClick = onCallClick, + callContentDescription = callContentDescription, + titleChrome = titleChrome, + modifier = modifier, + hazeBlurEnabled = hazeBlurEnabled, + ) + return + } + val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius // [BottomInsetTopBarShape] draws the arc in the strip from y = (content height) .. (content + r); // stack measured bar height + that depth so the scallop is never overlapped by TopAppBar children. @@ -399,6 +419,94 @@ fun ChatTopBar( } } +/** + * Two-pane chat chrome: separate back pill + title pill. Matches the chat input bottom bar + * ([HazeMaterials.thin] + [androidx.compose.material3.ColorScheme.surfaceContainer] + progressive + * blur). The haze strip is full-bleed to the window top (under traffic lights); pills sit in + * inset content so blur breathes at the edges. + */ +@Composable +private fun ChatTopBarPill( + hazeState: HazeState, + onBack: () -> Unit, + backContentDescription: String, + showCallButton: Boolean, + onCallClick: () -> Unit, + callContentDescription: String, + titleChrome: @Composable () -> Unit, + modifier: Modifier = Modifier, + hazeBlurEnabled: Boolean = true, +) { + val hazeStyle = HazeMaterials.thin() + val chromeColor = MaterialTheme.colorScheme.surfaceContainer + val pillShape = RoundedCornerShape(28.dp) + // Soft chip over the frosted strip — not an opaque solid bar. + val pillBg = chromeColor.copy(alpha = 0.45f) + + Box(modifier = modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .matchParentSize() + .background(chromeColor) + .hazeEffect(state = hazeState) { + blurEffect { + blurEnabled = hazeBlurEnabled + style = hazeStyle + progressive = HazeProgressive.verticalGradient( + startIntensity = 1f, + endIntensity = 0f, + ) + } + }, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.extraStatusBars) + .padding(horizontal = ConversationDetailContentPadding, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .background(pillBg), + contentAlignment = Alignment.Center, + ) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = backContentDescription, + ) + } + } + Row( + modifier = Modifier + .weight(1f) + .clip(pillShape) + .background(pillBg) + .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(Modifier.weight(1f)) { + titleChrome() + } + if (showCallButton) { + IconButton(onClick = onCallClick) { + Icon( + imageVector = Icons.Rounded.Call, + contentDescription = callContentDescription, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + } + } + } + } + } +} + /** lol, i dont even know what this means because this file was vibecoded * * Cubic-fit constant for a quarter circle: `4/3 * tan(pi/8)` (~0.5522847498). diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/AppPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/AppPanel.kt index 6deb6bc..5373007 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/AppPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/AppPanel.kt @@ -7,10 +7,19 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeSource + +/** + * When non-null, [AppPanel] is a haze source so overlays (chat context-menu blur) include + * panel chrome / rounded outlines. Provide above the panel (list–detail); null in compact. + */ +val LocalPaneHazeState = staticCompositionLocalOf { null } /** * Rounded lowest-surface container for list–detail panes and other full-area content. @@ -23,8 +32,15 @@ fun AppPanel( shape: Shape = RoundedCornerShape(24.dp), content: @Composable BoxScope.() -> Unit, ) { + val paneHazeState = LocalPaneHazeState.current Surface( - modifier = modifier, + modifier = modifier.then( + if (paneHazeState != null) { + Modifier.hazeSource(paneHazeState) + } else { + Modifier + }, + ), shape = shape, color = color, ) { 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 index 0682c51..5dde297 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/ConversationListDetailShell.kt @@ -7,7 +7,6 @@ import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkHorizontally -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row @@ -21,9 +20,14 @@ import androidx.compose.foundation.layout.union import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp @@ -33,15 +37,45 @@ import ru.fromchat.ui.extraStatusBars /** Minimum width to show chat + profile side-by-side beside the list. */ val ConversationProfileThirdPaneMinWidth = 1100.dp +/** + * Horizontal inset for chat detail content (messages, top-bar pills, input) in list–detail + * so progressive chrome blur can breathe at the pane edges while outer panes stay flush. + */ +val ConversationDetailContentPadding = 12.dp + +private val PaneCornerRadius = 24.dp + +/** Round only the outer (leading) corners — adjacent edge stays square so panes sit flush. */ +private val ListPaneShape: Shape = RoundedCornerShape( + topStart = PaneCornerRadius, + bottomStart = PaneCornerRadius, +) + +/** Round only the outer (trailing) corners for settings / profile detail panels. */ +private val DetailPaneShape: Shape = RoundedCornerShape( + topEnd = PaneCornerRadius, + bottomEnd = PaneCornerRadius, +) + +/** + * True while the conversation list–detail shell is showing. Chat chrome uses this for + * pill-shaped top bars and edge-to-edge detail layout. + */ +val LocalConversationListDetailActive = staticCompositionLocalOf { false } + private const val ProfilePaneAnimMs = 280 /** - * List–detail (optional profile) shell. The list pane uses [AppPanel]; detail and profile - * panes stay plain (no rounded surface wrapper). Spacing alone separates panes. + * List–detail (optional profile) shell. The list pane always uses [AppPanel]. + * Settings detail uses [AppPanel] too ([detailInPanel]); chat detail is edge-to-edge + * ([detailEdgeToEdge]) so floating chrome can blur past its inner padding toward the window. + * + * Panes sit flush (no gutter) so the window background does not show as a vertical strip. * * Pads with [WindowInsets.safeDrawing] ∪ [WindowInsets.extraStatusBars] so panes clear the - * macOS title bar / system bars, then consumes those insets so child top bars do not double-pad. - * Window background stays edge-to-edge behind this layer. + * macOS title bar / system bars, then consumes those insets so child top bars do not double-pad + * — except when [detailEdgeToEdge], where the detail column omits outer padding and keeps + * insets available for its own chrome. */ @Composable fun ConversationListDetailShell( @@ -51,68 +85,126 @@ fun ConversationListDetailShell( detailPane: @Composable () -> Unit, profilePane: @Composable () -> Unit, modifier: Modifier = Modifier, + detailInPanel: Boolean = false, + detailEdgeToEdge: Boolean = false, ) { BoxWithConstraints(modifier.fillMaxSize()) { val profileVisible = showProfilePane && maxWidth >= ConversationProfileThirdPaneMinWidth - val fadeAnim = tween( - durationMillis = ProfilePaneAnimMs, - easing = FastOutSlowInEasing, - ) - val sizeAnim = tween( - durationMillis = ProfilePaneAnimMs, - easing = FastOutSlowInEasing, - ) val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars) - Row( - Modifier - .fillMaxSize() - .windowInsetsPadding(paneInsets) - .consumeWindowInsets(paneInsets) - .padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - AppPanel( - Modifier - .width(listPaneWidth) - .fillMaxHeight(), - ) { - listPane() - } - Box( - Modifier - .weight(1f) - .fillMaxHeight(), - ) { - detailPane() - } - AnimatedVisibility( - visible = profileVisible, - enter = fadeIn(animationSpec = fadeAnim) + expandHorizontally( - animationSpec = sizeAnim, - expandFrom = Alignment.End, - clip = false, - ), - exit = fadeOut( - animationSpec = tween( - durationMillis = 220, - easing = FastOutSlowInEasing, - ), - ) + shrinkHorizontally( - animationSpec = sizeAnim, - shrinkTowards = Alignment.End, - clip = false, - ), - ) { - Box( + CompositionLocalProvider(LocalConversationListDetailActive provides true) { + if (detailEdgeToEdge) { + Row(Modifier.fillMaxSize()) { + AppPanel( + Modifier + .windowInsetsPadding(paneInsets) + .consumeWindowInsets(paneInsets) + .padding(start = 8.dp, top = 8.dp, bottom = 8.dp) + .width(listPaneWidth) + .fillMaxHeight(), + shape = ListPaneShape, + ) { + listPane() + } + Box( + Modifier + .weight(1f) + .fillMaxHeight(), + ) { + detailPane() + } + ProfilePaneSlot( + visible = profileVisible, + modifier = Modifier + .windowInsetsPadding(paneInsets) + .consumeWindowInsets(paneInsets) + .padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp), + content = profilePane, + ) + } + } else { + Row( Modifier - .widthIn(min = 320.dp, max = 420.dp) - .width(380.dp) - .fillMaxHeight(), + .fillMaxSize() + .windowInsetsPadding(paneInsets) + .consumeWindowInsets(paneInsets) + .padding(8.dp), ) { - profilePane() + AppPanel( + Modifier + .width(listPaneWidth) + .fillMaxHeight(), + shape = ListPaneShape, + ) { + listPane() + } + if (detailInPanel) { + AppPanel( + Modifier + .weight(1f) + .fillMaxHeight(), + shape = if (profileVisible) RectangleShape else DetailPaneShape, + ) { + detailPane() + } + } else { + Box( + Modifier + .weight(1f) + .fillMaxHeight(), + ) { + detailPane() + } + } + ProfilePaneSlot( + visible = profileVisible, + content = profilePane, + ) } } } } } + +@Composable +private fun ProfilePaneSlot( + visible: Boolean, + content: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + val fadeAnim = tween( + durationMillis = ProfilePaneAnimMs, + easing = FastOutSlowInEasing, + ) + val sizeAnim = tween( + durationMillis = ProfilePaneAnimMs, + easing = FastOutSlowInEasing, + ) + AnimatedVisibility( + visible = visible, + enter = fadeIn(animationSpec = fadeAnim) + expandHorizontally( + animationSpec = sizeAnim, + expandFrom = Alignment.End, + clip = false, + ), + exit = fadeOut( + animationSpec = tween( + durationMillis = 220, + easing = FastOutSlowInEasing, + ), + ) + shrinkHorizontally( + animationSpec = sizeAnim, + shrinkTowards = Alignment.End, + clip = false, + ), + ) { + Box( + modifier + .widthIn(min = 320.dp, max = 420.dp) + .width(380.dp) + .fillMaxHeight(), + ) { + content() + } + } +} 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 5ae58b2..ea464f5 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 @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons @@ -39,9 +40,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.IntRect +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupPositionProvider +import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.zIndex import com.pr0gramm3r101.utils.WindowWidthSizeClass import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo @@ -64,6 +74,7 @@ import ru.fromchat.settings import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle import ru.fromchat.ui.components.FromChatSnackbarHost +import ru.fromchat.ui.components.LocalPaneHazeState import ru.fromchat.ui.components.Text import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController @@ -71,6 +82,7 @@ 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 kotlin.math.roundToInt const val MAIN_PAGE_CHATS = 0 const val MAIN_PAGE_CONTACTS = 1 @@ -112,8 +124,11 @@ fun MainScreen( val density = LocalDensity.current val navBarHazeState = rememberHazeState() val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle() - val contextMenuHazeState = rememberHazeState() + val paneHazeState = LocalPaneHazeState.current + val contextMenuHazeState = paneHazeState ?: rememberHazeState() + val paneProvidesHazeSource = paneHazeState != null val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } + var contextMenuBlurPaneBoundsInWindow by remember { mutableStateOf(IntRect.Zero) } val widthClass = currentWindowAdaptiveInfo().widthSizeClass @@ -194,11 +209,29 @@ fun MainScreen( selectedPage } - BoxWithConstraints(Modifier.fillMaxSize()) { + BoxWithConstraints( + Modifier + .fillMaxSize() + .onGloballyPositioned { coords -> + val bounds = coords.boundsInWindow() + contextMenuBlurPaneBoundsInWindow = IntRect( + left = bounds.left.roundToInt(), + top = bounds.top.roundToInt(), + right = bounds.right.roundToInt(), + bottom = bounds.bottom.roundToInt(), + ) + }, + ) { Box( modifier = Modifier .fillMaxSize() - .hazeSource(contextMenuHazeState), + .then( + if (paneProvidesHazeSource) { + Modifier + } else { + Modifier.hazeSource(contextMenuHazeState) + }, + ), ) { Scaffold( snackbarHost = { @@ -327,9 +360,9 @@ fun MainScreen( ChatContextMenuBlurLayer( hazeState = contextMenuHazeState, blurProgress = chatMenuBlurProgress, - modifier = Modifier - .fillMaxSize() - .zIndex(2f), + paneBoundsInWindow = contextMenuBlurPaneBoundsInWindow, + expandBeyondPane = paneProvidesHazeSource, + modifier = Modifier.zIndex(2f), ) } @@ -348,22 +381,78 @@ fun MainScreen( private fun ChatContextMenuBlurLayer( hazeState: HazeState, blurProgress: Float, + paneBoundsInWindow: IntRect, + expandBeyondPane: Boolean, modifier: Modifier = Modifier, ) { val blurRadius = 12.dp * blurProgress if (blurRadius <= 0.dp) return + val density = LocalDensity.current + val expandPx = if (expandBeyondPane) { + with(density) { (24.dp + blurRadius).roundToPx() } + } else { + 0 + } + val blurBounds = if (paneBoundsInWindow.width > 0 && paneBoundsInWindow.height > 0) { + IntRect( + left = (paneBoundsInWindow.left - expandPx).coerceAtLeast(0), + top = (paneBoundsInWindow.top - expandPx).coerceAtLeast(0), + right = paneBoundsInWindow.right + expandPx, + bottom = paneBoundsInWindow.bottom + expandPx, + ) + } else { + null + } - Box( - modifier = modifier.hazeEffect(state = hazeState) { - blurEffect { - style = HazeBlurStyle( - blurRadius = blurRadius, - colorEffects = emptyList(), - backgroundColor = Color.Transparent, - noiseFactor = 0f, - fallbackColorEffect = HazeColorEffect.tint(Color.Transparent), - ) - } + val blurModifier = Modifier.hazeEffect(state = hazeState) { + blurEffect { + style = HazeBlurStyle( + blurRadius = blurRadius, + colorEffects = emptyList(), + backgroundColor = Color.Transparent, + noiseFactor = 0f, + fallbackColorEffect = HazeColorEffect.tint(Color.Transparent), + ) + } + } + + if (blurBounds == null || !expandBeyondPane) { + Box( + modifier = modifier + .fillMaxSize() + .then(blurModifier), + ) + return + } + + // Escape AppPanel clip so blurred panel chrome / rounded outlines are covered. + Popup( + popupPositionProvider = object : PopupPositionProvider { + override fun calculatePosition( + anchorBounds: IntRect, + windowSize: IntSize, + layoutDirection: LayoutDirection, + popupContentSize: IntSize, + ): IntOffset = IntOffset(blurBounds.left, blurBounds.top) }, - ) + properties = PopupProperties( + focusable = false, + dismissOnBackPress = false, + dismissOnClickOutside = false, + clippingEnabled = false, + ), + ) { + Box( + modifier = modifier + .then( + with(density) { + Modifier.size( + width = blurBounds.width.toDp(), + height = blurBounds.height.toDp(), + ) + }, + ) + .then(blurModifier), + ) + } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt index 14c7d2a..17338ad 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatListShared.kt @@ -15,7 +15,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box @@ -136,7 +135,8 @@ private val ChatListCategoryMargin = PaddingValues( ) /** - * Opens on primary click. Selection mode: touch/stylus long-press, or mouse secondary click. + * Opens on primary click. Selection mode: touch/stylus long-press on the row body. + * Mouse secondary click opens the one-chat context menu (same flow as avatar long-press). * Mouse primary long-press does not enter selection. */ @OptIn(ExperimentalFoundationApi::class) @@ -145,6 +145,7 @@ private fun Modifier.chatRowOpenAndSelectGestures( interactionSource: MutableInteractionSource, onOpen: () -> Unit, onEnterSelection: () -> Unit, + onMouseContextMenu: (localOffset: Offset) -> Unit, ): Modifier = combinedClickable( interactionSource = interactionSource, @@ -164,7 +165,7 @@ private fun Modifier.chatRowOpenAndSelectGestures( return@awaitEachGesture } downEvent.changes.forEach { it.consume() } - onEnterSelection() + onMouseContextMenu(down.position) } PointerType.Touch, PointerType.Stylus -> { try { @@ -254,6 +255,16 @@ internal fun ChatConversationsList( listItemPosition: ListItemPosition, groupItemCount: Int, ) -> Unit, + onMouseRowContextMenu: ( + lazyIndex: Int, + target: ChatContextMenuTarget, + userId: Int?, + menuPosition: Offset, + rowOffset: Offset, + rowSize: IntSize, + listItemPosition: ListItemPosition, + groupItemCount: Int, + ) -> Unit, onEnterSelectionMode: (lazyIndex: Int, target: ChatContextMenuTarget, userId: Int?) -> Unit, onRowPositioned: (lazyIndex: Int, offset: Offset, size: IntSize) -> Unit, ) { @@ -360,6 +371,18 @@ internal fun ChatConversationsList( groupCount, ) }, + onMouseContextMenu = { menuPosition, rowOffset, rowSize -> + onMouseRowContextMenu( + ChatListLayout.PUBLIC_CHAT_ROW, + ChatContextMenuTarget.Public, + null, + menuPosition, + rowOffset, + rowSize, + position, + groupCount, + ) + }, onBodyLongPress = { onEnterSelectionMode(ChatListLayout.PUBLIC_CHAT_ROW, ChatContextMenuTarget.Public, null) }, @@ -429,6 +452,18 @@ internal fun ChatConversationsList( groupCount, ) }, + onMouseContextMenu = { menuPosition, rowOffset, rowSize -> + onMouseRowContextMenu( + lazyIndex, + ChatContextMenuTarget.Dm, + conversation.otherUserId, + menuPosition, + rowOffset, + rowSize, + position, + groupCount, + ) + }, onBodyLongPress = { onEnterSelectionMode(lazyIndex, ChatContextMenuTarget.Dm, conversation.otherUserId) }, @@ -500,6 +535,7 @@ internal fun SearchConversationsList( onAvatarPressStart = { _, _ -> }, onAvatarPressEnd = {}, onAvatarLongPress = { _, _, _ -> }, + onMouseContextMenu = { _, _, _ -> }, onBodyLongPress = {}, onRowPositioned = { _, _ -> }, avatarEnabled = true, @@ -589,6 +625,7 @@ internal fun ChatRowScaleContainer( pressScale: Float, modifier: Modifier = Modifier, shadowElevationPx: Float = 0f, + interactionModifier: Modifier = Modifier, content: @Composable () -> Unit, ) { val clipShape = listItemClipShape(listItemPosition, groupItemCount) @@ -605,7 +642,8 @@ internal fun ChatRowScaleContainer( clip = true } .clip(clipShape) - .background(containerColor, clipShape), + .background(containerColor, clipShape) + .then(interactionModifier), ) { content() } @@ -656,17 +694,29 @@ internal fun ChatRowAvatar( .size(40.dp) .pointerInput(enabled) { if (!enabled) return@pointerInput - detectTapGestures( - onPress = { - onPressStart() - try { - awaitRelease() - } finally { - onPressEnd() + awaitEachGesture { + var downEvent: PointerEvent + do { + downEvent = awaitPointerEvent() + } while (!downEvent.changes.fastAll { it.changedToDown() }) + val down = downEvent.changes.firstOrNull() ?: return@awaitEachGesture + // Mouse: avatar press/click is disabled; use row right-click for the menu. + if (down.type == PointerType.Mouse) return@awaitEachGesture + if (down.type != PointerType.Touch && down.type != PointerType.Stylus) { + return@awaitEachGesture + } + onPressStart() + try { + withTimeout(viewConfiguration.longPressTimeoutMillis) { + waitForUpOrCancellation() } - }, - onLongPress = onLongPress, - ) + } catch (_: PointerEventTimeoutCancellationException) { + onLongPress(down.position) + waitForUpOrCancellation() + } finally { + onPressEnd() + } + } }, ) { Avatar( @@ -748,6 +798,7 @@ internal fun PublicChatRow( onAvatarPressStart: (rowOffset: Offset, rowSize: IntSize) -> Unit, onAvatarPressEnd: () -> Unit, onAvatarLongPress: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit, + onMouseContextMenu: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit, onBodyLongPress: () -> Unit, onRowPositioned: (offset: Offset, size: IntSize) -> Unit, ) { @@ -760,14 +811,12 @@ internal fun PublicChatRow( LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) { when { isHiddenForOverlay -> pressScaleAnim.snapTo(contextMenuScale) - isPressingForContextMenu || (contextMenuPressScaleActive && rowRevealProgress > 0f) -> + isPressingForContextMenu || contextMenuPressScaleActive -> pressScaleAnim.animateTo(ChatRowContextMenuPressScale, ChatRowPressSpring) else -> pressScaleAnim.animateTo(1f, ChatRowPressSpring) } } - val clipShape = listItemClipShape(listItemPosition, groupItemCount) - ChatRowScaleContainer( listItemPosition = listItemPosition, groupItemCount = groupItemCount, @@ -775,18 +824,20 @@ internal fun PublicChatRow( modifier = Modifier .alpha(if (isHiddenForOverlay) 0f else 1f) .fillMaxWidth() - .clip(clipShape) - .chatRowOpenAndSelectGestures( - selectionEnabled = listMode == ChatsListMode.Normal, - interactionSource = rowInteractionSource, - onOpen = onOpenPublic, - onEnterSelection = onBodyLongPress, - ) .onGloballyPositioned { coords -> rowRootOffset = coords.positionInRoot() rowSize = coords.size onRowPositioned(rowRootOffset, rowSize) }, + interactionModifier = Modifier.chatRowOpenAndSelectGestures( + selectionEnabled = listMode == ChatsListMode.Normal, + interactionSource = rowInteractionSource, + onOpen = onOpenPublic, + onEnterSelection = onBodyLongPress, + onMouseContextMenu = { localOffset -> + onMouseContextMenu(rowRootOffset + localOffset, rowRootOffset, rowSize) + }, + ), ) { PublicChatRowContent( publicChatTitle = publicChatTitle, @@ -893,6 +944,7 @@ internal fun DmConversationRow( onAvatarPressStart: (rowOffset: Offset, rowSize: IntSize) -> Unit, onAvatarPressEnd: () -> Unit, onAvatarLongPress: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit, + onMouseContextMenu: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit, onBodyLongPress: () -> Unit, onRowPositioned: (offset: Offset, size: IntSize) -> Unit, avatarEnabled: Boolean = listMode == ChatsListMode.Normal, @@ -906,14 +958,12 @@ internal fun DmConversationRow( LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) { when { isHiddenForOverlay -> pressScaleAnim.snapTo(contextMenuScale) - isPressingForContextMenu || (contextMenuPressScaleActive && rowRevealProgress > 0f) -> + isPressingForContextMenu || contextMenuPressScaleActive -> pressScaleAnim.animateTo(ChatRowContextMenuPressScale, ChatRowPressSpring) else -> pressScaleAnim.animateTo(1f, ChatRowPressSpring) } } - val clipShape = listItemClipShape(listItemPosition, groupItemCount) - ChatRowScaleContainer( listItemPosition = listItemPosition, groupItemCount = groupItemCount, @@ -921,18 +971,20 @@ internal fun DmConversationRow( modifier = Modifier .alpha(if (isHiddenForOverlay) 0f else 1f) .fillMaxWidth() - .clip(clipShape) - .chatRowOpenAndSelectGestures( - selectionEnabled = listMode == ChatsListMode.Normal, - interactionSource = rowInteractionSource, - onOpen = onOpenConversation, - onEnterSelection = onBodyLongPress, - ) .onGloballyPositioned { coords -> rowRootOffset = coords.positionInRoot() rowSize = coords.size onRowPositioned(rowRootOffset, rowSize) }, + interactionModifier = Modifier.chatRowOpenAndSelectGestures( + selectionEnabled = listMode == ChatsListMode.Normal, + interactionSource = rowInteractionSource, + onOpen = onOpenConversation, + onEnterSelection = onBodyLongPress, + onMouseContextMenu = { localOffset -> + onMouseContextMenu(rowRootOffset + localOffset, rowRootOffset, rowSize) + }, + ), ) { DmConversationRowContent( conversation = conversation, 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 fcac91c..468141d 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 @@ -634,10 +634,6 @@ fun ChatsTab( ChatContextMenuTarget.Public -> publicChatSelected = true ChatContextMenuTarget.Dm -> userId?.let { selectedOtherUserIds = setOf(it) } } - scope.launch { - selectionTransitionProgress.snapTo(0f) - selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring) - } } fun exitSelectionMode() { @@ -656,6 +652,13 @@ fun ChatsTab( } } + LaunchedEffect(listMode) { + if (listMode == ChatsListMode.Selecting) { + selectionTransitionProgress.snapTo(0f) + selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring) + } + } + fun refreshDmList() { scope.launch { runCatching { @@ -1020,6 +1023,22 @@ fun ChatsTab( groupItemCount = groupCount, ) }, + onMouseRowContextMenu = { lazyIndex, target, userId, _, rowOffset, rowSize, position, groupCount -> + if (suspensionState.isSuspended) return@ChatConversationsList + haptic(HapticFeedbackEvent.ContextMenuOpened) + avatarPressMark = null + chatContextMenuOverlay.overlayCloneReady = false + contextMenuState = ChatContextMenuState( + phase = ChatContextMenuPhase.Animating, + target = target, + otherUserId = userId, + listIndex = lazyIndex, + rowOffset = rowOffset, + rowSize = rowSize, + listItemPosition = position, + groupItemCount = groupCount, + ) + }, onEnterSelectionMode = { _, target, userId -> if (suspensionState.isSuspended) return@ChatConversationsList enterSelectionModeFor(target, userId) @@ -1449,7 +1468,7 @@ private fun ChatContextMenuOverlay( LaunchedEffect(contextMenuState.phase, menuSize, layoutReady) { if (contextMenuState.phase != ChatContextMenuPhase.Open || !layoutReady) return@LaunchedEffect if (revealProgress.value < 1f) { - revealProgress.snapTo(1f) + revealProgress.animateTo(1f, ChatContextMenuRevealSpring) } if (menuOpenProgress < 1f) { animate(