This commit is contained in:
2026-07-26 18:54:25 +03:00
Unverified
parent 7c28f8539d
commit c724c7642f
12 changed files with 680 additions and 179 deletions
+1
View File
@@ -10,6 +10,7 @@ val javafxModules = listOf("base", "graphics", "controls", "media", "web", "swin
dependencies { dependencies {
implementation(compose.desktop.currentOs) implementation(compose.desktop.currentOs)
implementation(libs.compose.components.resources)
implementation(project(":app:shared")) implementation(project(":app:shared"))
implementation(project(":utils:shared")) implementation(project(":utils:shared"))
implementation(libs.kotlinx.coroutines.swing) implementation(libs.kotlinx.coroutines.swing)
@@ -53,11 +53,29 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import org.jetbrains.skia.Image import org.jetbrains.skia.Image
import ru.fromchat.AppForeground import ru.fromchat.AppForeground
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.Res
import ru.fromchat.action_copy
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.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.App
import ru.fromchat.ui.LocalExtraStatusBarTop import ru.fromchat.ui.LocalExtraStatusBarTop
@@ -196,7 +214,8 @@ private object DesktopProtocolRegistration {
fun main(args: Array<String>) { fun main(args: Array<String>) {
if (isMacOs()) { 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") System.setProperty("apple.laf.useScreenMenuBar", "true")
} }
if (!DesktopSingleInstance.acquireOrForward(args)) return if (!DesktopSingleInstance.acquireOrForward(args)) return
@@ -220,6 +239,10 @@ fun main(args: Array<String>) {
val windowIcon = remember { loadAppIconPainter(tray = false) } val windowIcon = remember { loadAppIconPainter(tray = false) }
val traySupported = remember { java.awt.SystemTray.isSupported() } val traySupported = remember { java.awt.SystemTray.isSupported() }
val mac = remember { isMacOs() } 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) { DisposableEffect(trayState) {
DesktopNotifier.sink = { title, body -> DesktopNotifier.sink = { title, body ->
@@ -247,13 +270,13 @@ fun main(args: Array<String>) {
Tray( Tray(
icon = trayIcon, icon = trayIcon,
state = trayState, state = trayState,
tooltip = "FromChat", tooltip = appName,
onAction = { windowVisible = true }, onAction = { windowVisible = true },
menu = { menu = {
Item("Show") { windowVisible = true } Item(trayShow) { windowVisible = true }
Item("About FromChat") { aboutOpen = true } Item(aboutApp) { aboutOpen = true }
Separator() Separator()
Item("Quit") { exitApplication() } Item(quit) { exitApplication() }
}, },
) )
} }
@@ -267,7 +290,7 @@ fun main(args: Array<String>) {
exitApplication() exitApplication()
} }
}, },
title = "FromChat", title = appName,
state = windowState, state = windowState,
visible = windowVisible || !traySupported, visible = windowVisible || !traySupported,
icon = windowIcon, icon = windowIcon,
@@ -330,7 +353,7 @@ fun main(args: Array<String>) {
if (aboutOpen) { if (aboutOpen) {
Window( Window(
onCloseRequest = { aboutOpen = false }, onCloseRequest = { aboutOpen = false },
title = "About FromChat", title = aboutApp,
state = aboutWindowState, state = aboutWindowState,
icon = windowIcon, icon = windowIcon,
) { ) {
@@ -347,45 +370,57 @@ private fun FrameWindowScope.FromChatMenuBar(
onMinimize: () -> Unit, onMinimize: () -> Unit,
onZoom: () -> 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 { MenuBar {
Menu("Actions", mnemonic = 'A') { Menu(actions, mnemonic = 'A') {
Item("About FromChat", onClick = onAbout) Item(aboutApp, onClick = onAbout)
Item( Item(
"Show FromChat", showApp,
shortcut = KeyShortcut(Key.N, meta = true, shift = true), shortcut = KeyShortcut(Key.N, meta = true, shift = true),
onClick = onShow, onClick = onShow,
) )
} }
Menu("Edit", mnemonic = 'E') { Menu(edit, mnemonic = 'E') {
Item( Item(
"Cut", cut,
shortcut = KeyShortcut(Key.X, meta = true), shortcut = KeyShortcut(Key.X, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.cutAction) }, onClick = { performAwtEditAction(DefaultEditorKit.cutAction) },
) )
Item( Item(
"Copy", copy,
shortcut = KeyShortcut(Key.C, meta = true), shortcut = KeyShortcut(Key.C, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.copyAction) }, onClick = { performAwtEditAction(DefaultEditorKit.copyAction) },
) )
Item( Item(
"Paste", paste,
shortcut = KeyShortcut(Key.V, meta = true), shortcut = KeyShortcut(Key.V, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.pasteAction) }, onClick = { performAwtEditAction(DefaultEditorKit.pasteAction) },
) )
Separator() Separator()
Item( Item(
"Select All", selectAll,
shortcut = KeyShortcut(Key.A, meta = true), shortcut = KeyShortcut(Key.A, meta = true),
onClick = { performAwtEditAction(DefaultEditorKit.selectAllAction) }, onClick = { performAwtEditAction(DefaultEditorKit.selectAllAction) },
) )
} }
Menu("Window", mnemonic = 'W') { Menu(windowMenu, mnemonic = 'W') {
Item( Item(
"Minimize", minimize,
shortcut = KeyShortcut(Key.M, meta = true), shortcut = KeyShortcut(Key.M, meta = true),
onClick = onMinimize, onClick = onMinimize,
) )
Item("Zoom", onClick = onZoom) Item(zoom, onClick = onZoom)
} }
} }
} }
@@ -18,6 +18,18 @@
<string name="auth_legal_notice_prefix">Регистрируясь, вы соглашаетесь с </string> <string name="auth_legal_notice_prefix">Регистрируясь, вы соглашаетесь с </string>
<string name="auth_legal_notice_and"> и </string> <string name="auth_legal_notice_and"> и </string>
<string name="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string> <string name="app_desc">100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.</string>
<string name="desktop_menu_actions">Действия</string>
<string name="desktop_menu_edit">Правка</string>
<string name="desktop_menu_window">Окно</string>
<string name="desktop_about_app">О FromChat</string>
<string name="desktop_tray_show">Показать</string>
<string name="desktop_show_app">Показать FromChat</string>
<string name="desktop_quit">Выйти</string>
<string name="desktop_cut">Вырезать</string>
<string name="desktop_paste">Вставить</string>
<string name="desktop_select_all">Выделить всё</string>
<string name="desktop_minimize">Свернуть</string>
<string name="desktop_zoom">Масштабировать</string>
<string name="welcome">Добро пожаловать!</string> <string name="welcome">Добро пожаловать!</string>
<string name="login">Войти</string> <string name="login">Войти</string>
<string name="login_d">Войдите в свой аккаунт</string> <string name="login_d">Войдите в свой аккаунт</string>
@@ -22,6 +22,20 @@
<string name="auth_legal_notice_and"> and </string> <string name="auth_legal_notice_and"> and </string>
<string name="app_desc">100% free and open messenger. Supports self-hosted installation on your own server.</string> <string name="app_desc">100% free and open messenger. Supports self-hosted installation on your own server.</string>
<!-- Desktop menu / tray -->
<string name="desktop_menu_actions">Actions</string>
<string name="desktop_menu_edit">Edit</string>
<string name="desktop_menu_window">Window</string>
<string name="desktop_about_app">About FromChat</string>
<string name="desktop_tray_show">Show</string>
<string name="desktop_show_app">Show FromChat</string>
<string name="desktop_quit">Quit</string>
<string name="desktop_cut">Cut</string>
<string name="desktop_paste">Paste</string>
<string name="desktop_select_all">Select All</string>
<string name="desktop_minimize">Minimize</string>
<string name="desktop_zoom">Zoom</string>
<!-- Authentication --> <!-- Authentication -->
<string name="welcome">Welcome!</string> <string name="welcome">Welcome!</string>
<string name="login">Log in</string> <string name="login">Log in</string>
@@ -1,5 +1,6 @@
package ru.fromchat.ui package ru.fromchat.ui
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedContentScope import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
@@ -10,6 +11,7 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize 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.EditProfileScreen
import ru.fromchat.ui.profile.ProfileRoutes import ru.fromchat.ui.profile.ProfileRoutes
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.components.LocalPaneHazeState
import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.ScreenSurface
import dev.chrisbanes.haze.rememberHazeState
import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.NetworkConnectivity
val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") } val LocalNavController = compositionLocalOf<NavController> { error("NavController not provided") }
@@ -144,28 +148,32 @@ private fun isConversationProfileRoute(route: String?): Boolean =
(route != null && route.startsWith("dm/") && route.endsWith("/profile")) (route != null && route.startsWith("dm/") && route.endsWith("/profile"))
private fun isStandaloneProfileRoute(route: String?): Boolean = 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 * Nav destination.route is the pattern (`profile/{userId}…`), not the filled path —
return route * read the userId from entry args / saved state.
.substringAfter("profile/") */
.substringBefore("?") private fun standaloneProfileUserId(entry: NavBackStackEntry?): Int? {
.toIntOrNull() val route = entry?.destination?.route ?: return null
?.takeIf { it > 0 } if (!isStandaloneProfileRoute(route)) return null
return entry.savedStateHandle.get<String>("userId")?.toIntOrNull()?.takeIf { it > 0 }
} }
private fun isConversationShellRoute( private fun isConversationShellRoute(
route: String?, route: String?,
previousRoute: String?, previousRoute: String?,
ownUserId: Int?, ownUserId: Int?,
profileUserId: Int?,
): Boolean { ): Boolean {
if (route == "chat" || isConversationChatRoute(route) || isConversationProfileRoute(route)) { if (route == "chat" || isConversationChatRoute(route) || isConversationProfileRoute(route)) {
return true return true
} }
if (isStandaloneProfileRoute(route)) { if (isStandaloneProfileRoute(route)) {
if (isConversationChatRoute(previousRoute)) return true 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 != null && route.startsWith("settings/")) return true
if (route == "about") return true if (route == "about") return true
@@ -491,12 +499,18 @@ fun App(
val currentRoute = currentEntry?.destination?.route val currentRoute = currentEntry?.destination?.route
val previousRoute = val previousRoute =
navController.previousBackStackEntry?.destination?.route navController.previousBackStackEntry?.destination?.route
val profileUserId = standaloneProfileUserId(currentEntry)
val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass val widthSizeClass = currentWindowAdaptiveInfo().widthSizeClass
var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) } var pendingMainTab by remember { mutableIntStateOf(MAIN_PAGE_CHATS) }
val ownUserId = ApiClient.user?.id val ownUserId = ApiClient.user?.id
val showConversationListDetail = val showConversationListDetail =
widthSizeClass != WindowWidthSizeClass.COMPACT && widthSizeClass != WindowWidthSizeClass.COMPACT &&
isConversationShellRoute(currentRoute, previousRoute, ownUserId) isConversationShellRoute(
currentRoute,
previousRoute,
ownUserId,
profileUserId,
)
ScreenSurface { ScreenSurface {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
@@ -517,7 +531,8 @@ fun App(
showConversationListDetail && showConversationListDetail &&
isStandaloneProfileRoute(currentRoute) && isStandaloneProfileRoute(currentRoute) &&
!showProfileThirdPane && !showProfileThirdPane &&
standaloneProfileUserId(currentRoute) == ownUserId profileUserId != null &&
profileUserId == ownUserId
val listPaneWidth = val listPaneWidth =
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) { if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
448.dp 448.dp
@@ -860,27 +875,61 @@ fun App(
} }
if (showConversationListDetail) { if (showConversationListDetail) {
ConversationListDetailShell( val detailPaneState = when {
showProfilePane = showProfileThirdPane, showProfileThirdPane -> "split"
listPaneWidth = listPaneWidth, currentRoute == "chat" -> "empty-$pendingMainTab"
listPane = { else -> "nav"
MainScreen( }
sharedTransitionScope = this@SharedTransitionLayout, val detailEdgeToEdge =
snackbarHostState = profileLookupSnackbarHostState, showProfileThirdPane || isConversationChatRoute(currentRoute)
embeddedInListDetail = true, // Empty settings placeholder stays transparent (no AppPanel fill).
initialPage = pendingMainTab, val detailInPanel =
forceSettingsTab = forceSettingsListTab, settingsProfileSplit || isSettingsDetailRoute(currentRoute)
onPageChanged = { pendingMainTab = it }, val detailPaneAnim = tween<Float>(
) 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 = { detailPane = {
when { AnimatedContent(
showProfileThirdPane -> { targetState = detailPaneState,
androidx.compose.animation.AnimatedVisibility( transitionSpec = {
visible = true, (
enter = EnterTransition.None, fadeIn(animationSpec = detailPaneAnim) +
exit = ExitTransition.None, 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 = val chatRouteForDetail =
when { when {
isConversationProfileRoute(currentRoute) -> isConversationProfileRoute(currentRoute) ->
@@ -911,23 +960,22 @@ fun App(
} }
} }
} }
} state.startsWith("empty-") -> {
isSettingsDetailRoute(currentRoute) -> when (pendingMainTab) {
AppNavHost(Modifier.fillMaxSize()) MAIN_PAGE_CONTACTS -> EmptyContactsPlaceholder()
currentRoute == "chat" -> { MAIN_PAGE_SETTINGS -> EmptySettingsPlaceholder()
when (pendingMainTab) { else -> EmptyConversationPlaceholder()
MAIN_PAGE_CONTACTS -> EmptyContactsPlaceholder() }
MAIN_PAGE_SETTINGS -> EmptySettingsPlaceholder()
else -> EmptyConversationPlaceholder()
} }
else -> AppNavHost(Modifier.fillMaxSize())
} }
else -> AppNavHost(Modifier.fillMaxSize())
} }
}, },
profilePane = { profilePane = {
AppNavHost(Modifier.fillMaxSize()) AppNavHost(Modifier.fillMaxSize())
}, },
) )
}
} else { } else {
AppNavHost(Modifier.fillMaxSize()) AppNavHost(Modifier.fillMaxSize())
} }
@@ -8,6 +8,7 @@ import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -98,6 +99,8 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Logger import ru.fromchat.Logger
import ru.fromchat.ui.main.ConversationDetailContentPadding
import ru.fromchat.ui.main.LocalConversationListDetailActive
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.presence_recently import ru.fromchat.presence_recently
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
@@ -429,6 +432,9 @@ fun ChatScreen(
val profileUserId = panelState.profileUserId val profileUserId = panelState.profileUserId
val hazeState = rememberHazeState() val hazeState = rememberHazeState()
val hazeBlurEnabled = !(panelState.isLoading && panelState.messages.isEmpty()) 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 currentTypingUsers = panelState.typingUsers // Directly use from panelState
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
@@ -833,10 +839,12 @@ fun ChatScreen(
} }
} }
) { ) {
val inputPad = Modifier.padding(horizontal = detailContentPad)
if (peerDeleted && dmRecipientId != null) { if (peerDeleted && dmRecipientId != null) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.then(inputPad)
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), .padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
) { ) {
Button( Button(
@@ -863,6 +871,7 @@ fun ChatScreen(
} }
} }
} else { } else {
Column(modifier = inputPad) {
ChatInput( ChatInput(
text = inputText, text = inputText,
onTextChange = { inputText = it }, onTextChange = { inputText = it },
@@ -1058,14 +1067,18 @@ fun ChatScreen(
} }
) )
} }
}
} }
} }
) { innerPadding -> ) { innerPadding ->
val statusBarTopDp = with(density) { val statusBarTopDp = with(density) {
WindowInsets.extraStatusBars.getTop(this).toDp() 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 statusBarTopDp + 64.dp + 12.dp + ChatFloatingHeaderBottomArcRadius
}
SideEffect { SideEffect {
chatScrollClearancePx.value = with(density) { chatScrollClearancePx.value = with(density) {
floatingHeaderClearance.roundToPx() to innerPadding.calculateBottomPadding().roundToPx() floatingHeaderClearance.roundToPx() to innerPadding.calculateBottomPadding().roundToPx()
@@ -1106,6 +1119,7 @@ fun ChatScreen(
} }
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState), .hazeSource(hazeState),
contentPadding = PaddingValues(horizontal = detailContentPad),
userScrollEnabled = !contextMenuState.isOpen, userScrollEnabled = !contextMenuState.isOpen,
reverseLayout = true, reverseLayout = true,
) { ) {
@@ -1371,7 +1385,7 @@ fun ChatScreen(
.align(Alignment.BottomEnd) .align(Alignment.BottomEnd)
.zIndex(2f) .zIndex(2f)
.padding( .padding(
end = 13.dp, end = 13.dp + detailContentPad,
bottom = innerPadding.calculateBottomPadding() + 12.dp, bottom = innerPadding.calculateBottomPadding() + 12.dp,
), ),
) { ) {
@@ -1402,6 +1416,7 @@ fun ChatScreen(
ChatTopBar( ChatTopBar(
hazeState = hazeState, hazeState = hazeState,
hazeBlurEnabled = hazeBlurEnabled, hazeBlurEnabled = hazeBlurEnabled,
pillChrome = listDetailActive,
onBack = { onBack = {
runNav { navController.navigateUp() } runNav { navController.navigateUp() }
}, },
@@ -19,9 +19,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width 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.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.rounded.Call 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.HazeState
import dev.chrisbanes.haze.blur.HazeBlurStyle import dev.chrisbanes.haze.blur.HazeBlurStyle
import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.HazeColorEffect
import dev.chrisbanes.haze.blur.HazeProgressive
import dev.chrisbanes.haze.blur.blurEffect import dev.chrisbanes.haze.blur.blurEffect
import dev.chrisbanes.haze.blur.materials.HazeMaterials
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res 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.ConnectingEllipsis
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.ConversationDetailContentPadding
import ru.fromchat.ui.profile.StatusBadge import ru.fromchat.ui.profile.StatusBadge
import ru.fromchat.ui.profile.peerIsDeleted import ru.fromchat.ui.profile.peerIsDeleted
import ru.fromchat.ui.profile.resolveVerificationStatus import ru.fromchat.ui.profile.resolveVerificationStatus
@@ -328,7 +332,23 @@ fun ChatTopBar(
titleChrome: @Composable () -> Unit, titleChrome: @Composable () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
hazeBlurEnabled: Boolean = true, 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 val bottomCornerRadius = ChatFloatingHeaderBottomArcRadius
// [BottomInsetTopBarShape] draws the arc in the strip from y = (content height) .. (content + r); // [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. // 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 /** 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). * Cubic-fit constant for a quarter circle: `4/3 * tan(pi/8)` (~0.5522847498).
@@ -7,10 +7,19 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp 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 (listdetail); null in compact.
*/
val LocalPaneHazeState = staticCompositionLocalOf<HazeState?> { null }
/** /**
* Rounded lowest-surface container for listdetail panes and other full-area content. * Rounded lowest-surface container for listdetail panes and other full-area content.
@@ -23,8 +32,15 @@ fun AppPanel(
shape: Shape = RoundedCornerShape(24.dp), shape: Shape = RoundedCornerShape(24.dp),
content: @Composable BoxScope.() -> Unit, content: @Composable BoxScope.() -> Unit,
) { ) {
val paneHazeState = LocalPaneHazeState.current
Surface( Surface(
modifier = modifier, modifier = modifier.then(
if (paneHazeState != null) {
Modifier.hazeSource(paneHazeState)
} else {
Modifier
},
),
shape = shape, shape = shape,
color = color, color = color,
) { ) {
@@ -7,7 +7,6 @@ import androidx.compose.animation.expandHorizontally
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.shrinkHorizontally
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row 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.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.Dp
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp 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. */ /** Minimum width to show chat + profile side-by-side beside the list. */
val ConversationProfileThirdPaneMinWidth = 1100.dp val ConversationProfileThirdPaneMinWidth = 1100.dp
/**
* Horizontal inset for chat detail content (messages, top-bar pills, input) in listdetail
* 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 listdetail 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 private const val ProfilePaneAnimMs = 280
/** /**
* Listdetail (optional profile) shell. The list pane uses [AppPanel]; detail and profile * Listdetail (optional profile) shell. The list pane always uses [AppPanel].
* panes stay plain (no rounded surface wrapper). Spacing alone separates panes. * 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 * 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. * 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. * except when [detailEdgeToEdge], where the detail column omits outer padding and keeps
* insets available for its own chrome.
*/ */
@Composable @Composable
fun ConversationListDetailShell( fun ConversationListDetailShell(
@@ -51,68 +85,126 @@ fun ConversationListDetailShell(
detailPane: @Composable () -> Unit, detailPane: @Composable () -> Unit,
profilePane: @Composable () -> Unit, profilePane: @Composable () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
detailInPanel: Boolean = false,
detailEdgeToEdge: Boolean = false,
) { ) {
BoxWithConstraints(modifier.fillMaxSize()) { BoxWithConstraints(modifier.fillMaxSize()) {
val profileVisible = showProfilePane && maxWidth >= ConversationProfileThirdPaneMinWidth val profileVisible = showProfilePane && maxWidth >= ConversationProfileThirdPaneMinWidth
val fadeAnim = tween<Float>(
durationMillis = ProfilePaneAnimMs,
easing = FastOutSlowInEasing,
)
val sizeAnim = tween<IntSize>(
durationMillis = ProfilePaneAnimMs,
easing = FastOutSlowInEasing,
)
val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars) val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)
Row( CompositionLocalProvider(LocalConversationListDetailActive provides true) {
Modifier if (detailEdgeToEdge) {
.fillMaxSize() Row(Modifier.fillMaxSize()) {
.windowInsetsPadding(paneInsets) AppPanel(
.consumeWindowInsets(paneInsets) Modifier
.padding(8.dp), .windowInsetsPadding(paneInsets)
horizontalArrangement = Arrangement.spacedBy(8.dp), .consumeWindowInsets(paneInsets)
) { .padding(start = 8.dp, top = 8.dp, bottom = 8.dp)
AppPanel( .width(listPaneWidth)
Modifier .fillMaxHeight(),
.width(listPaneWidth) shape = ListPaneShape,
.fillMaxHeight(), ) {
) { listPane()
listPane() }
} Box(
Box( Modifier
Modifier .weight(1f)
.weight(1f) .fillMaxHeight(),
.fillMaxHeight(), ) {
) { detailPane()
detailPane() }
} ProfilePaneSlot(
AnimatedVisibility( visible = profileVisible,
visible = profileVisible, modifier = Modifier
enter = fadeIn(animationSpec = fadeAnim) + expandHorizontally( .windowInsetsPadding(paneInsets)
animationSpec = sizeAnim, .consumeWindowInsets(paneInsets)
expandFrom = Alignment.End, .padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp),
clip = false, content = profilePane,
), )
exit = fadeOut( }
animationSpec = tween( } else {
durationMillis = 220, Row(
easing = FastOutSlowInEasing,
),
) + shrinkHorizontally(
animationSpec = sizeAnim,
shrinkTowards = Alignment.End,
clip = false,
),
) {
Box(
Modifier Modifier
.widthIn(min = 320.dp, max = 420.dp) .fillMaxSize()
.width(380.dp) .windowInsetsPadding(paneInsets)
.fillMaxHeight(), .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<Float>(
durationMillis = ProfilePaneAnimMs,
easing = FastOutSlowInEasing,
)
val sizeAnim = tween<IntSize>(
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()
}
}
}
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -39,9 +40,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity 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.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 androidx.compose.ui.zIndex
import com.pr0gramm3r101.utils.WindowWidthSizeClass import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
@@ -64,6 +74,7 @@ import ru.fromchat.settings
import ru.fromchat.ui.LocalNavController import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
import ru.fromchat.ui.components.FromChatSnackbarHost import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.LocalPaneHazeState
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.extraStatusBars
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController 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.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen import ru.fromchat.ui.profile.ProfileScreen
import kotlin.math.roundToInt
const val MAIN_PAGE_CHATS = 0 const val MAIN_PAGE_CHATS = 0
const val MAIN_PAGE_CONTACTS = 1 const val MAIN_PAGE_CONTACTS = 1
@@ -112,8 +124,11 @@ fun MainScreen(
val density = LocalDensity.current val density = LocalDensity.current
val navBarHazeState = rememberHazeState() val navBarHazeState = rememberHazeState()
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle() val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
val contextMenuHazeState = rememberHazeState() val paneHazeState = LocalPaneHazeState.current
val contextMenuHazeState = paneHazeState ?: rememberHazeState()
val paneProvidesHazeSource = paneHazeState != null
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() } val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
var contextMenuBlurPaneBoundsInWindow by remember { mutableStateOf(IntRect.Zero) }
val widthClass = currentWindowAdaptiveInfo().widthSizeClass val widthClass = currentWindowAdaptiveInfo().widthSizeClass
@@ -194,11 +209,29 @@ fun MainScreen(
selectedPage 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.hazeSource(contextMenuHazeState), .then(
if (paneProvidesHazeSource) {
Modifier
} else {
Modifier.hazeSource(contextMenuHazeState)
},
),
) { ) {
Scaffold( Scaffold(
snackbarHost = { snackbarHost = {
@@ -327,9 +360,9 @@ fun MainScreen(
ChatContextMenuBlurLayer( ChatContextMenuBlurLayer(
hazeState = contextMenuHazeState, hazeState = contextMenuHazeState,
blurProgress = chatMenuBlurProgress, blurProgress = chatMenuBlurProgress,
modifier = Modifier paneBoundsInWindow = contextMenuBlurPaneBoundsInWindow,
.fillMaxSize() expandBeyondPane = paneProvidesHazeSource,
.zIndex(2f), modifier = Modifier.zIndex(2f),
) )
} }
@@ -348,22 +381,78 @@ fun MainScreen(
private fun ChatContextMenuBlurLayer( private fun ChatContextMenuBlurLayer(
hazeState: HazeState, hazeState: HazeState,
blurProgress: Float, blurProgress: Float,
paneBoundsInWindow: IntRect,
expandBeyondPane: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val blurRadius = 12.dp * blurProgress val blurRadius = 12.dp * blurProgress
if (blurRadius <= 0.dp) return 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( val blurModifier = Modifier.hazeEffect(state = hazeState) {
modifier = modifier.hazeEffect(state = hazeState) { blurEffect {
blurEffect { style = HazeBlurStyle(
style = HazeBlurStyle( blurRadius = blurRadius,
blurRadius = blurRadius, colorEffects = emptyList(),
colorEffects = emptyList(), backgroundColor = Color.Transparent,
backgroundColor = Color.Transparent, noiseFactor = 0f,
noiseFactor = 0f, fallbackColorEffect = HazeColorEffect.tint(Color.Transparent),
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),
)
}
} }
@@ -15,7 +15,6 @@ import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box 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. * Mouse primary long-press does not enter selection.
*/ */
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@@ -145,6 +145,7 @@ private fun Modifier.chatRowOpenAndSelectGestures(
interactionSource: MutableInteractionSource, interactionSource: MutableInteractionSource,
onOpen: () -> Unit, onOpen: () -> Unit,
onEnterSelection: () -> Unit, onEnterSelection: () -> Unit,
onMouseContextMenu: (localOffset: Offset) -> Unit,
): Modifier = ): Modifier =
combinedClickable( combinedClickable(
interactionSource = interactionSource, interactionSource = interactionSource,
@@ -164,7 +165,7 @@ private fun Modifier.chatRowOpenAndSelectGestures(
return@awaitEachGesture return@awaitEachGesture
} }
downEvent.changes.forEach { it.consume() } downEvent.changes.forEach { it.consume() }
onEnterSelection() onMouseContextMenu(down.position)
} }
PointerType.Touch, PointerType.Stylus -> { PointerType.Touch, PointerType.Stylus -> {
try { try {
@@ -254,6 +255,16 @@ internal fun ChatConversationsList(
listItemPosition: ListItemPosition, listItemPosition: ListItemPosition,
groupItemCount: Int, groupItemCount: Int,
) -> Unit, ) -> 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, onEnterSelectionMode: (lazyIndex: Int, target: ChatContextMenuTarget, userId: Int?) -> Unit,
onRowPositioned: (lazyIndex: Int, offset: Offset, size: IntSize) -> Unit, onRowPositioned: (lazyIndex: Int, offset: Offset, size: IntSize) -> Unit,
) { ) {
@@ -360,6 +371,18 @@ internal fun ChatConversationsList(
groupCount, groupCount,
) )
}, },
onMouseContextMenu = { menuPosition, rowOffset, rowSize ->
onMouseRowContextMenu(
ChatListLayout.PUBLIC_CHAT_ROW,
ChatContextMenuTarget.Public,
null,
menuPosition,
rowOffset,
rowSize,
position,
groupCount,
)
},
onBodyLongPress = { onBodyLongPress = {
onEnterSelectionMode(ChatListLayout.PUBLIC_CHAT_ROW, ChatContextMenuTarget.Public, null) onEnterSelectionMode(ChatListLayout.PUBLIC_CHAT_ROW, ChatContextMenuTarget.Public, null)
}, },
@@ -429,6 +452,18 @@ internal fun ChatConversationsList(
groupCount, groupCount,
) )
}, },
onMouseContextMenu = { menuPosition, rowOffset, rowSize ->
onMouseRowContextMenu(
lazyIndex,
ChatContextMenuTarget.Dm,
conversation.otherUserId,
menuPosition,
rowOffset,
rowSize,
position,
groupCount,
)
},
onBodyLongPress = { onBodyLongPress = {
onEnterSelectionMode(lazyIndex, ChatContextMenuTarget.Dm, conversation.otherUserId) onEnterSelectionMode(lazyIndex, ChatContextMenuTarget.Dm, conversation.otherUserId)
}, },
@@ -500,6 +535,7 @@ internal fun SearchConversationsList(
onAvatarPressStart = { _, _ -> }, onAvatarPressStart = { _, _ -> },
onAvatarPressEnd = {}, onAvatarPressEnd = {},
onAvatarLongPress = { _, _, _ -> }, onAvatarLongPress = { _, _, _ -> },
onMouseContextMenu = { _, _, _ -> },
onBodyLongPress = {}, onBodyLongPress = {},
onRowPositioned = { _, _ -> }, onRowPositioned = { _, _ -> },
avatarEnabled = true, avatarEnabled = true,
@@ -589,6 +625,7 @@ internal fun ChatRowScaleContainer(
pressScale: Float, pressScale: Float,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
shadowElevationPx: Float = 0f, shadowElevationPx: Float = 0f,
interactionModifier: Modifier = Modifier,
content: @Composable () -> Unit, content: @Composable () -> Unit,
) { ) {
val clipShape = listItemClipShape(listItemPosition, groupItemCount) val clipShape = listItemClipShape(listItemPosition, groupItemCount)
@@ -605,7 +642,8 @@ internal fun ChatRowScaleContainer(
clip = true clip = true
} }
.clip(clipShape) .clip(clipShape)
.background(containerColor, clipShape), .background(containerColor, clipShape)
.then(interactionModifier),
) { ) {
content() content()
} }
@@ -656,17 +694,29 @@ internal fun ChatRowAvatar(
.size(40.dp) .size(40.dp)
.pointerInput(enabled) { .pointerInput(enabled) {
if (!enabled) return@pointerInput if (!enabled) return@pointerInput
detectTapGestures( awaitEachGesture {
onPress = { var downEvent: PointerEvent
onPressStart() do {
try { downEvent = awaitPointerEvent()
awaitRelease() } while (!downEvent.changes.fastAll { it.changedToDown() })
} finally { val down = downEvent.changes.firstOrNull() ?: return@awaitEachGesture
onPressEnd() // 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()
} }
}, } catch (_: PointerEventTimeoutCancellationException) {
onLongPress = onLongPress, onLongPress(down.position)
) waitForUpOrCancellation()
} finally {
onPressEnd()
}
}
}, },
) { ) {
Avatar( Avatar(
@@ -748,6 +798,7 @@ internal fun PublicChatRow(
onAvatarPressStart: (rowOffset: Offset, rowSize: IntSize) -> Unit, onAvatarPressStart: (rowOffset: Offset, rowSize: IntSize) -> Unit,
onAvatarPressEnd: () -> Unit, onAvatarPressEnd: () -> Unit,
onAvatarLongPress: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit, onAvatarLongPress: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit,
onMouseContextMenu: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit,
onBodyLongPress: () -> Unit, onBodyLongPress: () -> Unit,
onRowPositioned: (offset: Offset, size: IntSize) -> Unit, onRowPositioned: (offset: Offset, size: IntSize) -> Unit,
) { ) {
@@ -760,14 +811,12 @@ internal fun PublicChatRow(
LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) { LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) {
when { when {
isHiddenForOverlay -> pressScaleAnim.snapTo(contextMenuScale) isHiddenForOverlay -> pressScaleAnim.snapTo(contextMenuScale)
isPressingForContextMenu || (contextMenuPressScaleActive && rowRevealProgress > 0f) -> isPressingForContextMenu || contextMenuPressScaleActive ->
pressScaleAnim.animateTo(ChatRowContextMenuPressScale, ChatRowPressSpring) pressScaleAnim.animateTo(ChatRowContextMenuPressScale, ChatRowPressSpring)
else -> pressScaleAnim.animateTo(1f, ChatRowPressSpring) else -> pressScaleAnim.animateTo(1f, ChatRowPressSpring)
} }
} }
val clipShape = listItemClipShape(listItemPosition, groupItemCount)
ChatRowScaleContainer( ChatRowScaleContainer(
listItemPosition = listItemPosition, listItemPosition = listItemPosition,
groupItemCount = groupItemCount, groupItemCount = groupItemCount,
@@ -775,18 +824,20 @@ internal fun PublicChatRow(
modifier = Modifier modifier = Modifier
.alpha(if (isHiddenForOverlay) 0f else 1f) .alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth() .fillMaxWidth()
.clip(clipShape)
.chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource,
onOpen = onOpenPublic,
onEnterSelection = onBodyLongPress,
)
.onGloballyPositioned { coords -> .onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot() rowRootOffset = coords.positionInRoot()
rowSize = coords.size rowSize = coords.size
onRowPositioned(rowRootOffset, rowSize) onRowPositioned(rowRootOffset, rowSize)
}, },
interactionModifier = Modifier.chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource,
onOpen = onOpenPublic,
onEnterSelection = onBodyLongPress,
onMouseContextMenu = { localOffset ->
onMouseContextMenu(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
),
) { ) {
PublicChatRowContent( PublicChatRowContent(
publicChatTitle = publicChatTitle, publicChatTitle = publicChatTitle,
@@ -893,6 +944,7 @@ internal fun DmConversationRow(
onAvatarPressStart: (rowOffset: Offset, rowSize: IntSize) -> Unit, onAvatarPressStart: (rowOffset: Offset, rowSize: IntSize) -> Unit,
onAvatarPressEnd: () -> Unit, onAvatarPressEnd: () -> Unit,
onAvatarLongPress: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit, onAvatarLongPress: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit,
onMouseContextMenu: (menuPosition: Offset, rowOffset: Offset, rowSize: IntSize) -> Unit,
onBodyLongPress: () -> Unit, onBodyLongPress: () -> Unit,
onRowPositioned: (offset: Offset, size: IntSize) -> Unit, onRowPositioned: (offset: Offset, size: IntSize) -> Unit,
avatarEnabled: Boolean = listMode == ChatsListMode.Normal, avatarEnabled: Boolean = listMode == ChatsListMode.Normal,
@@ -906,14 +958,12 @@ internal fun DmConversationRow(
LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) { LaunchedEffect(isPressingForContextMenu, isHiddenForOverlay, rowRevealProgress, contextMenuPressScaleActive) {
when { when {
isHiddenForOverlay -> pressScaleAnim.snapTo(contextMenuScale) isHiddenForOverlay -> pressScaleAnim.snapTo(contextMenuScale)
isPressingForContextMenu || (contextMenuPressScaleActive && rowRevealProgress > 0f) -> isPressingForContextMenu || contextMenuPressScaleActive ->
pressScaleAnim.animateTo(ChatRowContextMenuPressScale, ChatRowPressSpring) pressScaleAnim.animateTo(ChatRowContextMenuPressScale, ChatRowPressSpring)
else -> pressScaleAnim.animateTo(1f, ChatRowPressSpring) else -> pressScaleAnim.animateTo(1f, ChatRowPressSpring)
} }
} }
val clipShape = listItemClipShape(listItemPosition, groupItemCount)
ChatRowScaleContainer( ChatRowScaleContainer(
listItemPosition = listItemPosition, listItemPosition = listItemPosition,
groupItemCount = groupItemCount, groupItemCount = groupItemCount,
@@ -921,18 +971,20 @@ internal fun DmConversationRow(
modifier = Modifier modifier = Modifier
.alpha(if (isHiddenForOverlay) 0f else 1f) .alpha(if (isHiddenForOverlay) 0f else 1f)
.fillMaxWidth() .fillMaxWidth()
.clip(clipShape)
.chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource,
onOpen = onOpenConversation,
onEnterSelection = onBodyLongPress,
)
.onGloballyPositioned { coords -> .onGloballyPositioned { coords ->
rowRootOffset = coords.positionInRoot() rowRootOffset = coords.positionInRoot()
rowSize = coords.size rowSize = coords.size
onRowPositioned(rowRootOffset, rowSize) onRowPositioned(rowRootOffset, rowSize)
}, },
interactionModifier = Modifier.chatRowOpenAndSelectGestures(
selectionEnabled = listMode == ChatsListMode.Normal,
interactionSource = rowInteractionSource,
onOpen = onOpenConversation,
onEnterSelection = onBodyLongPress,
onMouseContextMenu = { localOffset ->
onMouseContextMenu(rowRootOffset + localOffset, rowRootOffset, rowSize)
},
),
) { ) {
DmConversationRowContent( DmConversationRowContent(
conversation = conversation, conversation = conversation,
@@ -634,10 +634,6 @@ fun ChatsTab(
ChatContextMenuTarget.Public -> publicChatSelected = true ChatContextMenuTarget.Public -> publicChatSelected = true
ChatContextMenuTarget.Dm -> userId?.let { selectedOtherUserIds = setOf(it) } ChatContextMenuTarget.Dm -> userId?.let { selectedOtherUserIds = setOf(it) }
} }
scope.launch {
selectionTransitionProgress.snapTo(0f)
selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
}
} }
fun exitSelectionMode() { fun exitSelectionMode() {
@@ -656,6 +652,13 @@ fun ChatsTab(
} }
} }
LaunchedEffect(listMode) {
if (listMode == ChatsListMode.Selecting) {
selectionTransitionProgress.snapTo(0f)
selectionTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
}
}
fun refreshDmList() { fun refreshDmList() {
scope.launch { scope.launch {
runCatching { runCatching {
@@ -1020,6 +1023,22 @@ fun ChatsTab(
groupItemCount = groupCount, 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 -> onEnterSelectionMode = { _, target, userId ->
if (suspensionState.isSuspended) return@ChatConversationsList if (suspensionState.isSuspended) return@ChatConversationsList
enterSelectionModeFor(target, userId) enterSelectionModeFor(target, userId)
@@ -1449,7 +1468,7 @@ private fun ChatContextMenuOverlay(
LaunchedEffect(contextMenuState.phase, menuSize, layoutReady) { LaunchedEffect(contextMenuState.phase, menuSize, layoutReady) {
if (contextMenuState.phase != ChatContextMenuPhase.Open || !layoutReady) return@LaunchedEffect if (contextMenuState.phase != ChatContextMenuPhase.Open || !layoutReady) return@LaunchedEffect
if (revealProgress.value < 1f) { if (revealProgress.value < 1f) {
revealProgress.snapTo(1f) revealProgress.animateTo(1f, ChatContextMenuRevealSpring)
} }
if (menuOpenProgress < 1f) { if (menuOpenProgress < 1f) {
animate( animate(