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 {
implementation(compose.desktop.currentOs)
implementation(libs.compose.components.resources)
implementation(project(":app:shared"))
implementation(project(":utils:shared"))
implementation(libs.kotlinx.coroutines.swing)
@@ -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<String>) {
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<String>) {
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<String>) {
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<String>) {
exitApplication()
}
},
title = "FromChat",
title = appName,
state = windowState,
visible = windowVisible || !traySupported,
icon = windowIcon,
@@ -330,7 +353,7 @@ fun main(args: Array<String>) {
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)
}
}
}
@@ -18,6 +18,18 @@
<string name="auth_legal_notice_prefix">Регистрируясь, вы соглашаетесь с </string>
<string name="auth_legal_notice_and"> и </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="login">Войти</string>
<string name="login_d">Войдите в свой аккаунт</string>
@@ -22,6 +22,20 @@
<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>
<!-- 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 -->
<string name="welcome">Welcome!</string>
<string name="login">Log in</string>
@@ -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<NavController> { 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<String>("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<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 = {
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())
}
@@ -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() }
},
@@ -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).
@@ -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 (listdetail); null in compact.
*/
val LocalPaneHazeState = staticCompositionLocalOf<HazeState?> { null }
/**
* Rounded lowest-surface container for listdetail 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,
) {
@@ -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 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
/**
* Listdetail (optional profile) shell. The list pane uses [AppPanel]; detail and profile
* panes stay plain (no rounded surface wrapper). Spacing alone separates panes.
* Listdetail (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<Float>(
durationMillis = ProfilePaneAnimMs,
easing = FastOutSlowInEasing,
)
val sizeAnim = tween<IntSize>(
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<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.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),
)
}
}
@@ -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,
@@ -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(