mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fixes
This commit is contained in:
@@ -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 (list–detail); null in compact.
|
||||
*/
|
||||
val LocalPaneHazeState = staticCompositionLocalOf<HazeState?> { null }
|
||||
|
||||
/**
|
||||
* Rounded lowest-surface container for list–detail panes and other full-area content.
|
||||
@@ -23,8 +32,15 @@ fun AppPanel(
|
||||
shape: Shape = RoundedCornerShape(24.dp),
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
val paneHazeState = LocalPaneHazeState.current
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
modifier = modifier.then(
|
||||
if (paneHazeState != null) {
|
||||
Modifier.hazeSource(paneHazeState)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
shape = shape,
|
||||
color = color,
|
||||
) {
|
||||
|
||||
+150
-58
@@ -7,7 +7,6 @@ import androidx.compose.animation.expandHorizontally
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -21,9 +20,14 @@ import androidx.compose.foundation.layout.union
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.RectangleShape
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -33,15 +37,45 @@ import ru.fromchat.ui.extraStatusBars
|
||||
/** Minimum width to show chat + profile side-by-side beside the list. */
|
||||
val ConversationProfileThirdPaneMinWidth = 1100.dp
|
||||
|
||||
/**
|
||||
* Horizontal inset for chat detail content (messages, top-bar pills, input) in list–detail
|
||||
* so progressive chrome blur can breathe at the pane edges while outer panes stay flush.
|
||||
*/
|
||||
val ConversationDetailContentPadding = 12.dp
|
||||
|
||||
private val PaneCornerRadius = 24.dp
|
||||
|
||||
/** Round only the outer (leading) corners — adjacent edge stays square so panes sit flush. */
|
||||
private val ListPaneShape: Shape = RoundedCornerShape(
|
||||
topStart = PaneCornerRadius,
|
||||
bottomStart = PaneCornerRadius,
|
||||
)
|
||||
|
||||
/** Round only the outer (trailing) corners for settings / profile detail panels. */
|
||||
private val DetailPaneShape: Shape = RoundedCornerShape(
|
||||
topEnd = PaneCornerRadius,
|
||||
bottomEnd = PaneCornerRadius,
|
||||
)
|
||||
|
||||
/**
|
||||
* True while the conversation list–detail shell is showing. Chat chrome uses this for
|
||||
* pill-shaped top bars and edge-to-edge detail layout.
|
||||
*/
|
||||
val LocalConversationListDetailActive = staticCompositionLocalOf { false }
|
||||
|
||||
private const val ProfilePaneAnimMs = 280
|
||||
|
||||
/**
|
||||
* List–detail (optional profile) shell. The list pane uses [AppPanel]; detail and profile
|
||||
* panes stay plain (no rounded surface wrapper). Spacing alone separates panes.
|
||||
* List–detail (optional profile) shell. The list pane always uses [AppPanel].
|
||||
* Settings detail uses [AppPanel] too ([detailInPanel]); chat detail is edge-to-edge
|
||||
* ([detailEdgeToEdge]) so floating chrome can blur past its inner padding toward the window.
|
||||
*
|
||||
* Panes sit flush (no gutter) so the window background does not show as a vertical strip.
|
||||
*
|
||||
* Pads with [WindowInsets.safeDrawing] ∪ [WindowInsets.extraStatusBars] so panes clear the
|
||||
* macOS title bar / system bars, then consumes those insets so child top bars do not double-pad.
|
||||
* Window background stays edge-to-edge behind this layer.
|
||||
* macOS title bar / system bars, then consumes those insets so child top bars do not double-pad
|
||||
* — except when [detailEdgeToEdge], where the detail column omits outer padding and keeps
|
||||
* insets available for its own chrome.
|
||||
*/
|
||||
@Composable
|
||||
fun ConversationListDetailShell(
|
||||
@@ -51,68 +85,126 @@ fun ConversationListDetailShell(
|
||||
detailPane: @Composable () -> Unit,
|
||||
profilePane: @Composable () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
detailInPanel: Boolean = false,
|
||||
detailEdgeToEdge: Boolean = false,
|
||||
) {
|
||||
BoxWithConstraints(modifier.fillMaxSize()) {
|
||||
val profileVisible = showProfilePane && maxWidth >= ConversationProfileThirdPaneMinWidth
|
||||
val fadeAnim = tween<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(
|
||||
|
||||
Reference in New Issue
Block a user