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