Improve chat UI

This commit is contained in:
2026-07-26 19:27:55 +03:00
Unverified
parent c724c7642f
commit f3c50557de
19 changed files with 636 additions and 617 deletions
@@ -213,6 +213,9 @@ private object DesktopProtocolRegistration {
} }
fun main(args: Array<String>) { fun main(args: Array<String>) {
// Separate AWT windows for Popup/Dialog so menus can draw past the app window edge
// (closer to Android Popup behavior). Must be set before any Compose UI runs.
System.setProperty("compose.layers.type", "WINDOW")
if (isMacOs()) { if (isMacOs()) {
val appName = runBlocking { getString(Res.string.app_name) } val appName = runBlocking { getString(Res.string.app_name) }
System.setProperty("apple.awt.application.name", appName) System.setProperty("apple.awt.application.name", appName)
@@ -13,7 +13,6 @@ import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.togetherWith 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
@@ -103,10 +102,11 @@ import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute import ru.fromchat.ui.chat.panels.publicchat.PublicChatProfileRoute
import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat import ru.fromchat.ui.chat.panels.publicchat.navigateToPublicChat
import ru.fromchat.ui.main.ConversationListDetailShell import ru.fromchat.ui.main.ConversationListDetailShell
import ru.fromchat.ui.main.ConversationProfileThirdPaneMinWidth import ru.fromchat.ui.main.DesktopTabDetailHosts
import ru.fromchat.ui.main.EmptyContactsPlaceholder import ru.fromchat.ui.main.EmptyContactsPlaceholder
import ru.fromchat.ui.main.EmptyConversationPlaceholder import ru.fromchat.ui.main.EmptyConversationPlaceholder
import ru.fromchat.ui.main.EmptySettingsPlaceholder import ru.fromchat.ui.main.EmptySettingsPlaceholder
import ru.fromchat.ui.main.LocalDesktopMainTab
import ru.fromchat.ui.main.MAIN_PAGE_CHATS import ru.fromchat.ui.main.MAIN_PAGE_CHATS
import ru.fromchat.ui.main.MAIN_PAGE_CONTACTS import ru.fromchat.ui.main.MAIN_PAGE_CONTACTS
import ru.fromchat.ui.main.MAIN_PAGE_SETTINGS import ru.fromchat.ui.main.MAIN_PAGE_SETTINGS
@@ -132,8 +132,10 @@ 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.AppPanel
import ru.fromchat.ui.components.LocalPaneHazeState import ru.fromchat.ui.components.LocalPaneHazeState
import ru.fromchat.ui.components.ScreenSurface import ru.fromchat.ui.components.ScreenSurface
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import ru.fromchat.utils.NetworkConnectivity import ru.fromchat.utils.NetworkConnectivity
@@ -514,37 +516,37 @@ fun App(
ScreenSurface { ScreenSurface {
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
BoxWithConstraints(Modifier.fillMaxSize()) { // Own profile from the Profile tab uses the settings detail host.
val canFitProfileThirdPane = // Profile opened from a chat stays in the chats detail (push/slide).
maxWidth >= ConversationProfileThirdPaneMinWidth
val showProfileThirdPane =
showConversationListDetail &&
canFitProfileThirdPane &&
(
isConversationProfileRoute(currentRoute) ||
(
isStandaloneProfileRoute(currentRoute) &&
isConversationChatRoute(previousRoute)
)
)
val settingsProfileSplit = val settingsProfileSplit =
showConversationListDetail && showConversationListDetail &&
isStandaloneProfileRoute(currentRoute) && isStandaloneProfileRoute(currentRoute) &&
!showProfileThirdPane &&
profileUserId != null && profileUserId != null &&
profileUserId == ownUserId profileUserId == ownUserId &&
!isConversationChatRoute(previousRoute)
val listPaneWidth = val listPaneWidth =
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) { if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
448.dp 448.dp
} else { } else {
360.dp 360.dp
} }
val forceSettingsListTab = // Only nudge the list pager when opening own profile from the
settingsProfileSplit || // Profile tab — do not yank the user back when they switch to Chats
( // while a settings destination is still on the back stack.
showConversationListDetail && val forceSettingsListTab = settingsProfileSplit
isSettingsDetailRoute(currentRoute)
) var retainedChatsRoute by remember { mutableStateOf<String?>(null) }
LaunchedEffect(currentRoute, pendingMainTab) {
when {
isConversationChatRoute(currentRoute) -> {
retainedChatsRoute = currentRoute
}
currentRoute == "chat" &&
pendingMainTab == MAIN_PAGE_CHATS -> {
retainedChatsRoute = null
}
}
}
@Composable @Composable
fun AppNavHost(modifier: Modifier = Modifier) { fun AppNavHost(modifier: Modifier = Modifier) {
@@ -875,28 +877,37 @@ fun App(
} }
if (showConversationListDetail) { if (showConversationListDetail) {
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>( val detailPaneAnim = tween<Float>(
durationMillis = 280, durationMillis = 280,
easing = FastOutSlowInEasing, easing = FastOutSlowInEasing,
) )
val chatsDetailKey = when {
isConversationChatRoute(currentRoute) ||
isConversationProfileRoute(currentRoute) ||
(
isStandaloneProfileRoute(currentRoute) &&
isConversationChatRoute(previousRoute)
) -> "nav"
retainedChatsRoute != null -> "retained:$retainedChatsRoute"
else -> "empty"
}
val settingsDetailKey = when {
settingsProfileSplit || isSettingsDetailRoute(currentRoute) -> "nav"
else -> "empty"
}
// Chats / contacts stay edge-to-edge; settings host wraps its
// own AppPanel so shell structure stays stable across tabs.
val detailEdgeToEdge =
pendingMainTab == MAIN_PAGE_CHATS ||
pendingMainTab == MAIN_PAGE_CONTACTS
val listPaneHazeState = rememberHazeState() val listPaneHazeState = rememberHazeState()
CompositionLocalProvider( CompositionLocalProvider(
LocalPaneHazeState provides listPaneHazeState, LocalPaneHazeState provides listPaneHazeState,
LocalDesktopMainTab provides pendingMainTab,
) { ) {
ConversationListDetailShell( ConversationListDetailShell(
showProfilePane = showProfileThirdPane,
listPaneWidth = listPaneWidth, listPaneWidth = listPaneWidth,
detailInPanel = detailInPanel, detailInPanel = false,
detailEdgeToEdge = detailEdgeToEdge, detailEdgeToEdge = detailEdgeToEdge,
listPane = { listPane = {
MainScreen( MainScreen(
@@ -909,9 +920,28 @@ fun App(
) )
}, },
detailPane = { detailPane = {
DesktopTabDetailHosts(
selectedTab = pendingMainTab,
chatsDetail = {
// SizeTransform uses Lookahead under SharedTransitionLayout;
// nesting that with always-composed tab hosts crashes with
// "Parents state is LookaheadMeasuring". Disable size morph.
AnimatedContent( AnimatedContent(
targetState = detailPaneState, targetState = chatsDetailKey,
transitionSpec = { transitionSpec = {
val navHostSwap =
(
initialState == "nav" &&
targetState.startsWith("retained:")
) ||
(
initialState.startsWith("retained:") &&
targetState == "nav"
)
val transform = if (navHostSwap) {
EnterTransition.None togetherWith
ExitTransition.None
} else {
( (
fadeIn(animationSpec = detailPaneAnim) + fadeIn(animationSpec = detailPaneAnim) +
scaleIn( scaleIn(
@@ -924,62 +954,87 @@ fun App(
easing = FastOutSlowInEasing, easing = FastOutSlowInEasing,
), ),
) )
}
transform.using(null)
}, },
label = "detailPane", label = "chatsDetailPane",
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { state -> ) { state ->
when { when {
state == "split" -> { state == "nav" -> {
val chatRouteForDetail = AppNavHost(Modifier.fillMaxSize())
when {
isConversationProfileRoute(currentRoute) ->
currentRoute
isConversationChatRoute(previousRoute) ->
previousRoute
else -> null
} }
val dmUserId = dmUserIdFromRoute(chatRouteForDetail) state.startsWith("retained:") -> {
?: dmUserIdFromRoute(currentRoute) val route =
state.removePrefix("retained:")
val dmUserId = dmUserIdFromRoute(route)
when { when {
dmUserId != null -> DmChatRoute( dmUserId != null -> DmChatRoute(
otherUserId = dmUserId, otherUserId = dmUserId,
scrollToMessageId = null, scrollToMessageId = null,
navController = navController, navController = navController,
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = null,
animatedVisibilityScope = this, animatedVisibilityScope = null,
) )
chatRouteForDetail == PublicChatNav.PROFILE_ROUTE || route == PublicChatNav.CHAT_ROUTE -> {
chatRouteForDetail == PublicChatNav.CHAT_ROUTE ||
currentRoute == PublicChatNav.PROFILE_ROUTE -> {
PublicChatChatRoute( PublicChatChatRoute(
scrollToMessageId = scrollToMessageId, scrollToMessageId =
scrollToMessageId,
navController = navController, navController = navController,
sharedTransitionScope = this@SharedTransitionLayout, sharedTransitionScope = null,
animatedVisibilityScope = this, animatedVisibilityScope = null,
) )
} }
} }
} }
state.startsWith("empty-") -> {
when (pendingMainTab) {
MAIN_PAGE_CONTACTS -> EmptyContactsPlaceholder()
MAIN_PAGE_SETTINGS -> EmptySettingsPlaceholder()
else -> EmptyConversationPlaceholder() else -> EmptyConversationPlaceholder()
} }
} }
else -> AppNavHost(Modifier.fillMaxSize()) },
settingsDetail = {
AnimatedContent(
targetState = settingsDetailKey,
transitionSpec = {
(
(
fadeIn(animationSpec = detailPaneAnim) +
scaleIn(
initialScale = 0.98f,
animationSpec = detailPaneAnim,
)
) togetherWith fadeOut(
animationSpec = tween(
durationMillis = 180,
easing = FastOutSlowInEasing,
),
)
).using(null)
},
label = "settingsDetailPane",
modifier = Modifier.fillMaxSize(),
) { state ->
if (state == "nav") {
AppPanel(
Modifier.fillMaxSize(),
shape = RoundedCornerShape(24.dp),
) {
AppNavHost(Modifier.fillMaxSize())
}
} else {
EmptySettingsPlaceholder()
} }
} }
}, },
profilePane = { contactsDetail = {
AppNavHost(Modifier.fillMaxSize()) EmptyContactsPlaceholder()
},
)
}, },
) )
} }
} else { } else {
AppNavHost(Modifier.fillMaxSize()) AppNavHost(Modifier.fillMaxSize())
} }
}
CallOverlay(Modifier.fillMaxSize()) CallOverlay(Modifier.fillMaxSize())
} }
@@ -98,6 +98,8 @@ import ru.fromchat.cd_send
import ru.fromchat.config.Settings import ru.fromchat.config.Settings
import ru.fromchat.message_corrupted_short import ru.fromchat.message_corrupted_short
import ru.fromchat.message_editing_title import ru.fromchat.message_editing_title
import ru.fromchat.ui.main.ConversationDetailContentPadding
import ru.fromchat.ui.main.LocalConversationListDetailActive
import ru.fromchat.message_placeholder import ru.fromchat.message_placeholder
import ru.fromchat.message_replying_to import ru.fromchat.message_replying_to
import ru.fromchat.suspend_chat_banner_message import ru.fromchat.suspend_chat_banner_message
@@ -322,12 +324,14 @@ fun ChatInput(
typingHandler.stopTyping() typingHandler.stopTyping()
} }
val chromeHorizontalPad =
if (LocalConversationListDetailActive.current) ConversationDetailContentPadding else 8.dp
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(Color.Transparent) .background(Color.Transparent)
.windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.navigationBars)
.padding(start = 8.dp, end = 8.dp, bottom = 8.dp), .padding(start = chromeHorizontalPad, end = chromeHorizontalPad, bottom = 12.dp),
) { ) {
val pillShape = RoundedCornerShape(28.dp) val pillShape = RoundedCornerShape(28.dp)
@@ -839,12 +839,11 @@ 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(horizontal = detailContentPad)
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp), .padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
) { ) {
Button( Button(
@@ -871,7 +870,6 @@ fun ChatScreen(
} }
} }
} else { } else {
Column(modifier = inputPad) {
ChatInput( ChatInput(
text = inputText, text = inputText,
onTextChange = { inputText = it }, onTextChange = { inputText = it },
@@ -1069,7 +1067,6 @@ fun ChatScreen(
} }
} }
} }
}
) { innerPadding -> ) { innerPadding ->
val statusBarTopDp = with(density) { val statusBarTopDp = with(density) {
WindowInsets.extraStatusBars.getTop(this).toDp() WindowInsets.extraStatusBars.getTop(this).toDp()
@@ -1153,7 +1150,7 @@ fun ChatScreen(
} }
is ChatListItem.MessageRow -> { is ChatListItem.MessageRow -> {
val message = item.message val message = item.message
var tapPositionInRoot by remember { var tapPositionInWindow by remember {
mutableStateOf(IntOffset(0, 0)) mutableStateOf(IntOffset(0, 0))
} }
val messageKey = timestampGroupKey(message) val messageKey = timestampGroupKey(message)
@@ -1291,11 +1288,11 @@ fun ChatScreen(
contextMenuState = ContextMenuState( contextMenuState = ContextMenuState(
isOpen = true, isOpen = true,
message = message, message = message,
position = tapPositionInRoot position = tapPositionInWindow,
) )
}, },
onTapPosition = { offset -> onTapPosition = { offset ->
tapPositionInRoot = tapPositionInWindow =
IntOffset(offset.x.toInt(), offset.y.toInt()) IntOffset(offset.x.toInt(), offset.y.toInt())
}, },
onUsernameClick = onUsernameClick =
@@ -393,7 +393,7 @@ fun ChatTopBar(
val layoutHeight = topBarPlaceable.height + arcExtentPx val layoutHeight = topBarPlaceable.height + arcExtentPx
val bgPlaceable = subcompose("background") { val bgPlaceable = subcompose("background") {
val hazeStyle = rememberChatSurfaceContainerHazeStyle() val hazeStyle = HazeMaterials.thin()
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -402,11 +402,15 @@ fun ChatTopBar(
BottomInsetTopBarShape(bottomCornerRadius) BottomInsetTopBarShape(bottomCornerRadius)
} }
) )
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f)) .background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = hazeState) { .hazeEffect(state = hazeState) {
blurEffect { blurEffect {
blurEnabled = hazeBlurEnabled blurEnabled = hazeBlurEnabled
style = hazeStyle style = hazeStyle
progressive = HazeProgressive.verticalGradient(
startIntensity = 1f,
endIntensity = 0f,
)
} }
}, },
) )
@@ -422,8 +426,8 @@ fun ChatTopBar(
/** /**
* Two-pane chat chrome: separate back pill + title pill. Matches the chat input bottom bar * Two-pane chat chrome: separate back pill + title pill. Matches the chat input bottom bar
* ([HazeMaterials.thin] + [androidx.compose.material3.ColorScheme.surfaceContainer] + progressive * ([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 * blur). The haze strip is full-bleed; pills sit in [ConversationDetailContentPadding] so blur
* inset content so blur breathes at the edges. * breathes at the edges (same horizontal inset as [ChatInput]).
*/ */
@Composable @Composable
private fun ChatTopBarPill( private fun ChatTopBarPill(
@@ -440,8 +444,6 @@ private fun ChatTopBarPill(
val hazeStyle = HazeMaterials.thin() val hazeStyle = HazeMaterials.thin()
val chromeColor = MaterialTheme.colorScheme.surfaceContainer val chromeColor = MaterialTheme.colorScheme.surfaceContainer
val pillShape = RoundedCornerShape(28.dp) 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.fillMaxWidth()) {
Box( Box(
@@ -471,7 +473,7 @@ private fun ChatTopBarPill(
modifier = Modifier modifier = Modifier
.size(48.dp) .size(48.dp)
.clip(CircleShape) .clip(CircleShape)
.background(pillBg), .background(chromeColor),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
@@ -485,7 +487,7 @@ private fun ChatTopBarPill(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.clip(pillShape) .clip(pillShape)
.background(pillBg) .background(chromeColor)
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp), .padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
@@ -37,17 +37,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.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize 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.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties import androidx.compose.ui.window.PopupProperties
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -147,83 +148,6 @@ fun MessageContextMenu(
if (state.isOpen && state.message != null) { if (state.isOpen && state.message != null) {
shouldShowPopup = true shouldShowPopup = true
animationProgress.floatValue = 0f animationProgress.floatValue = 0f
}
}
if (shouldShowPopup && state.message != null) {
var measuredSize by remember(state.message) { mutableStateOf(IntSize.Zero) }
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
val looseConstraints = Constraints(
minWidth = 0,
minHeight = 0,
maxWidth = screenWidthPx,
maxHeight = screenHeightPx
)
val placeables = subcompose("measure") {
ContextMenuContent(
message = state.message,
isAuthor = isAuthor,
canDelete = canDelete,
onReply = {},
onEdit = {},
onDelete = {},
onCopy = {},
onSave = {},
onCancelSend = {},
onRetrySend = {},
modifier = modifier.graphicsLayer(alpha = 0f),
animated = false,
withShadow = false,
isReadOnly = isReadOnly,
)
}.map { it.measure(looseConstraints) }
val p = placeables.firstOrNull()
if (p != null && measuredSize == IntSize.Zero) {
measuredSize = IntSize(p.width, p.height)
}
layout(0, 0) {
placeables.forEach { it.placeRelative(-10000, -10000) }
}
}
val density = LocalDensity.current
val paddingPx = with(density) { 16.dp.toPx().toInt() }
val allowOutsideWindow = supportsMouseMessageInteraction()
val rightEdge = screenWidthPx - paddingPx
val bottomEdge = screenHeightPx - paddingPx
val adjustedOffset = remember(
measuredSize,
state.position,
rightEdge,
bottomEdge,
paddingPx,
allowOutsideWindow,
) {
if (allowOutsideWindow || measuredSize == IntSize.Zero) {
state.position
} else {
var x = state.position.x
var y = state.position.y
if (x + measuredSize.width > rightEdge) x = rightEdge - measuredSize.width
if (y + measuredSize.height > bottomEdge) y = bottomEdge - measuredSize.height
if (x < paddingPx) x = paddingPx
if (y < paddingPx) y = paddingPx
IntOffset(x, y)
}
}
val sizeF = Offset(measuredSize.width.toFloat(), measuredSize.height.toFloat())
val transformOriginX = if (sizeF.x > 0f) {
((state.position.x - adjustedOffset.x) / sizeF.x).coerceIn(0f, 1f)
} else 0f
val transformOriginY = if (sizeF.y > 0f) {
((state.position.y - adjustedOffset.y) / sizeF.y).coerceIn(0f, 1f)
} else 0f
LaunchedEffect(measuredSize) {
if (measuredSize != IntSize.Zero) {
animate( animate(
initialValue = 0f, initialValue = 0f,
targetValue = 1f, targetValue = 1f,
@@ -237,19 +161,94 @@ fun MessageContextMenu(
} }
} }
if (shouldShowPopup && state.message != null) {
val density = LocalDensity.current
val paddingPx = with(density) { 16.dp.toPx().toInt() }
val allowOutsideWindow = supportsMouseMessageInteraction()
var popupSize by remember(state.message) { mutableStateOf(IntSize.Zero) }
// Clamp with popupContentSize — do not SubcomposeLayout-measure under
// SharedTransitionLayout (writes state during LookaheadMeasuring).
val positionProvider = remember(
state.position,
screenWidthPx,
screenHeightPx,
paddingPx,
allowOutsideWindow,
) {
object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
if (allowOutsideWindow) return state.position
var x = state.position.x
var y = state.position.y
val rightEdge = screenWidthPx - paddingPx
val bottomEdge = screenHeightPx - paddingPx
if (x + popupContentSize.width > rightEdge) {
x = rightEdge - popupContentSize.width
}
if (y + popupContentSize.height > bottomEdge) {
y = bottomEdge - popupContentSize.height
}
if (x < paddingPx) x = paddingPx
if (y < paddingPx) y = paddingPx
return IntOffset(x, y)
}
}
}
val adjustedOffset = remember(
popupSize,
state.position,
screenWidthPx,
screenHeightPx,
paddingPx,
allowOutsideWindow,
) {
if (allowOutsideWindow || popupSize == IntSize.Zero) {
state.position
} else {
var x = state.position.x
var y = state.position.y
val rightEdge = screenWidthPx - paddingPx
val bottomEdge = screenHeightPx - paddingPx
if (x + popupSize.width > rightEdge) x = rightEdge - popupSize.width
if (y + popupSize.height > bottomEdge) y = bottomEdge - popupSize.height
if (x < paddingPx) x = paddingPx
if (y < paddingPx) y = paddingPx
IntOffset(x, y)
}
}
val transformOriginX = if (popupSize.width > 0) {
((state.position.x - adjustedOffset.x).toFloat() / popupSize.width).coerceIn(0f, 1f)
} else {
0f
}
val transformOriginY = if (popupSize.height > 0) {
((state.position.y - adjustedOffset.y).toFloat() / popupSize.height).coerceIn(0f, 1f)
} else {
0f
}
val scale = 0.5f + 0.5f * animationProgress.floatValue val scale = 0.5f + 0.5f * animationProgress.floatValue
val alpha = animationProgress.floatValue val alpha = animationProgress.floatValue
if (measuredSize != IntSize.Zero) { // [state.position] is window coordinates (see MessageItem localToWindow).
// Alignment+offset would add the popup parents window origin again (double offset
// in listdetail panes). Place with an absolute provider instead.
Popup( Popup(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
alignment = Alignment.TopStart, popupPositionProvider = positionProvider,
offset = adjustedOffset,
properties = PopupProperties( properties = PopupProperties(
dismissOnBackPress = true, dismissOnBackPress = true,
dismissOnClickOutside = true, dismissOnClickOutside = true,
clippingEnabled = false clippingEnabled = false,
) ),
) { ) {
ContextMenuContent( ContextMenuContent(
message = state.message, message = state.message,
@@ -283,18 +282,17 @@ fun MessageContextMenu(
onRetrySend(it) onRetrySend(it)
onDismiss() onDismiss()
}, },
modifier = modifier, modifier = modifier.onSizeChanged { popupSize = it },
animated = true, animated = true,
scale = scale, scale = scale,
alpha = alpha, alpha = alpha,
transformOriginX = transformOriginX, transformOriginX = transformOriginX,
transformOriginY = transformOriginY, transformOriginY = transformOriginY,
isReadOnly = isReadOnly isReadOnly = isReadOnly,
) )
} }
} }
} }
}
@Composable @Composable
private fun ContextMenuContent( private fun ContextMenuContent(
@@ -503,7 +503,7 @@ fun MessageItem(
val openMessageMenu: (LayoutCoordinates?, Offset) -> Unit = { coords, localOffset -> val openMessageMenu: (LayoutCoordinates?, Offset) -> Unit = { coords, localOffset ->
if (coords != null && coords.isAttached) { if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset)) onTapPosition(coords.localToWindow(localOffset))
} }
isPressed = true isPressed = true
onLongPress() onLongPress()
@@ -773,7 +773,7 @@ fun MessageItem(
coords.size.height / 2f, coords.size.height / 2f,
) )
onTapPosition( onTapPosition(
coords.localToRoot(center), coords.localToWindow(center),
) )
} }
onLongPress() onLongPress()
@@ -57,8 +57,8 @@ fun DmChatRoute(
otherUserId: Int, otherUserId: Int,
scrollToMessageId: Int? = null, scrollToMessageId: Int? = null,
navController: NavController, navController: NavController,
sharedTransitionScope: SharedTransitionScope, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope, animatedVisibilityScope: AnimatedVisibilityScope? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val activeInstanceId by CacheContext.activeInstanceId.collectAsState() val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
@@ -41,8 +41,8 @@ fun NavController.navigateToPublicChat(
fun PublicChatChatRoute( fun PublicChatChatRoute(
scrollToMessageId: Int? = null, scrollToMessageId: Int? = null,
navController: NavController, navController: NavController,
sharedTransitionScope: SharedTransitionScope, sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope, animatedVisibilityScope: AnimatedVisibilityScope? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val haptic = rememberHapticFeedback() val haptic = rememberHapticFeedback()
@@ -13,11 +13,11 @@ 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.HazeState
import dev.chrisbanes.haze.hazeSource
/** /**
* When non-null, [AppPanel] is a haze source so overlays (chat context-menu blur) include * Shared [HazeState] for the list panes context-menu blur. Provide above the listdetail shell;
* panel chrome / rounded outlines. Provide above the panel (listdetail); null in compact. * [ru.fromchat.ui.main.MainScreen] applies [dev.chrisbanes.haze.hazeSource] to list content only
* so the blur layer and sharp overlay stay outside the source. Null in compact layouts.
*/ */
val LocalPaneHazeState = staticCompositionLocalOf<HazeState?> { null } val LocalPaneHazeState = staticCompositionLocalOf<HazeState?> { null }
@@ -32,15 +32,8 @@ 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.then( modifier = modifier,
if (paneHazeState != null) {
Modifier.hazeSource(paneHazeState)
} else {
Modifier
},
),
shape = shape, shape = shape,
color = color, color = color,
) { ) {
@@ -1,15 +1,8 @@
package ru.fromchat.ui.main package ru.fromchat.ui.main
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
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.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
@@ -18,193 +11,104 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.union 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.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape 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.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf
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.dp import androidx.compose.ui.unit.dp
import ru.fromchat.ui.components.AppPanel import ru.fromchat.ui.components.AppPanel
import ru.fromchat.ui.extraStatusBars import ru.fromchat.ui.extraStatusBars
/** Minimum width to show chat + profile side-by-side beside the list. */
val ConversationProfileThirdPaneMinWidth = 1100.dp
/** /**
* Horizontal inset for chat detail content (messages, top-bar pills, input) in listdetail * Horizontal inset for chat detail content (messages, top-bar pills, input) in listdetail
* so progressive chrome blur can breathe at the pane edges while outer panes stay flush. * so progressive chrome blur can breathe at the pane edges.
*/ */
val ConversationDetailContentPadding = 12.dp val ConversationDetailContentPadding = 12.dp
private val PaneCornerRadius = 24.dp
/** Round only the outer (leading) corners — adjacent edge stays square so panes sit flush. */
private val ListPaneShape: Shape = RoundedCornerShape(
topStart = PaneCornerRadius,
bottomStart = PaneCornerRadius,
)
/** Round only the outer (trailing) corners for settings / profile detail panels. */
private val DetailPaneShape: Shape = RoundedCornerShape(
topEnd = PaneCornerRadius,
bottomEnd = PaneCornerRadius,
)
/** /**
* True while the conversation listdetail shell is showing. Chat chrome uses this for * True while the conversation listdetail shell is showing. Chat chrome uses this for
* pill-shaped top bars and edge-to-edge detail layout. * pill-shaped top bars and edge-to-edge detail layout.
*/ */
val LocalConversationListDetailActive = staticCompositionLocalOf { false } val LocalConversationListDetailActive = staticCompositionLocalOf { false }
private const val ProfilePaneAnimMs = 280
/** /**
* Listdetail (optional profile) shell. The list pane always uses [AppPanel]. * Listdetail shell. The list pane always uses [AppPanel] with full rounded corners
* Settings detail uses [AppPanel] too ([detailInPanel]); chat detail is edge-to-edge * (including the edge facing the detail pane). Settings detail uses [AppPanel] too
* ([detailEdgeToEdge]) so floating chrome can blur past its inner padding toward the window. * ([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. * A small gutter separates panes so list-panel round corners stay visible.
* *
* 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
* except when [detailEdgeToEdge], where the detail column omits outer padding and keeps * except when [detailEdgeToEdge], where the detail column omits outer padding and keeps
* insets available for its own chrome. * insets available for its own chrome.
*
* Layout structure is stable regardless of [detailEdgeToEdge] / [detailInPanel] so detail
* [androidx.compose.animation.AnimatedContent] is not remounted when empty chat.
*/ */
@Composable @Composable
fun ConversationListDetailShell( fun ConversationListDetailShell(
showProfilePane: Boolean,
listPaneWidth: Dp, listPaneWidth: Dp,
listPane: @Composable () -> Unit, listPane: @Composable () -> Unit,
detailPane: @Composable () -> Unit, detailPane: @Composable () -> Unit,
profilePane: @Composable () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
detailInPanel: Boolean = false, detailInPanel: Boolean = false,
detailEdgeToEdge: Boolean = false, detailEdgeToEdge: Boolean = false,
) { ) {
BoxWithConstraints(modifier.fillMaxSize()) {
val profileVisible = showProfilePane && maxWidth >= ConversationProfileThirdPaneMinWidth
val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars) val paneInsets = WindowInsets.safeDrawing.union(WindowInsets.extraStatusBars)
CompositionLocalProvider(LocalConversationListDetailActive provides true) { CompositionLocalProvider(LocalConversationListDetailActive provides true) {
if (detailEdgeToEdge) { Row(modifier.fillMaxSize()) {
Row(Modifier.fillMaxSize()) {
AppPanel( AppPanel(
Modifier Modifier
.windowInsetsPadding(paneInsets) .windowInsetsPadding(paneInsets)
.consumeWindowInsets(paneInsets) .consumeWindowInsets(paneInsets)
.padding(start = 8.dp, top = 8.dp, bottom = 8.dp) .padding(
start = 8.dp,
top = 8.dp,
bottom = 8.dp,
)
.width(listPaneWidth) .width(listPaneWidth)
.fillMaxHeight(), .fillMaxHeight(),
shape = ListPaneShape, shape = RoundedCornerShape(24.dp),
) { ) {
listPane() listPane()
} }
Spacer(Modifier.width(8.dp))
Box( Box(
Modifier Modifier
.weight(1f) .weight(1f)
.fillMaxHeight(), .fillMaxHeight()
) { .then(
detailPane() if (detailEdgeToEdge) {
} Modifier
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 { } else {
Row(
Modifier Modifier
.fillMaxSize()
.windowInsetsPadding(paneInsets) .windowInsetsPadding(paneInsets)
.consumeWindowInsets(paneInsets) .consumeWindowInsets(paneInsets)
.padding(8.dp), .padding(
top = 8.dp,
end = 8.dp,
bottom = 8.dp,
)
},
),
) { ) {
AppPanel(
Modifier
.width(listPaneWidth)
.fillMaxHeight(),
shape = ListPaneShape,
) {
listPane()
}
if (detailInPanel) { if (detailInPanel) {
AppPanel( AppPanel(
Modifier Modifier.fillMaxSize(),
.weight(1f) shape = RoundedCornerShape(24.dp),
.fillMaxHeight(),
shape = if (profileVisible) RectangleShape else DetailPaneShape,
) { ) {
detailPane() detailPane()
} }
} else { } else {
Box(
Modifier
.weight(1f)
.fillMaxHeight(),
) {
detailPane() 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()
} }
} }
} }
@@ -0,0 +1,62 @@
package ru.fromchat.ui.main
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.zIndex
/**
* Which main bottom-nav tab owns the listdetail detail pane on large screens.
* Inactive tab hosts stay composed (alpha 0) so their detail state is retained.
*/
val LocalDesktopMainTab = compositionLocalOf { MAIN_PAGE_CHATS }
/**
* Per-tab detail pane hosts for desktop listdetail.
*
* Each tab supplies its own detail content. Only the [selectedTab] host is visible
* (alpha 1 + highest z-index); the others stay composed at alpha 0 underneath so opening
* Settings does not dispose an open Chats detail, and returning to Chats shows it again
* without clearing the chat route.
*/
@Composable
fun DesktopTabDetailHosts(
selectedTab: Int,
chatsDetail: @Composable () -> Unit,
settingsDetail: @Composable () -> Unit,
contactsDetail: @Composable () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier.fillMaxSize()) {
TabDetailHost(
active = selectedTab == MAIN_PAGE_CHATS,
content = chatsDetail,
)
TabDetailHost(
active = selectedTab == MAIN_PAGE_SETTINGS || selectedTab == MAIN_PAGE_PROFILE,
content = settingsDetail,
)
TabDetailHost(
active = selectedTab == MAIN_PAGE_CONTACTS,
content = contactsDetail,
)
}
}
@Composable
private fun TabDetailHost(
active: Boolean,
content: @Composable () -> Unit,
) {
Box(
Modifier
.fillMaxSize()
.zIndex(if (active) 1f else 0f)
.graphicsLayer { alpha = if (active) 1f else 0f },
) {
content()
}
}
@@ -2,19 +2,40 @@ package ru.fromchat.ui.main
import androidx.navigation.NavController import androidx.navigation.NavController
import androidx.navigation.NavOptionsBuilder import androidx.navigation.NavOptionsBuilder
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
/** /**
* Opens a main-hub detail destination, replacing any prior conversation/settings/profile * Opens a main-hub detail destination, replacing any prior conversation/settings/profile
* above the main `chat` root so large-screen switches do not stack previous panes. * above the main `chat` root so large-screen switches do not stack previous panes.
* Matches [ru.fromchat.ui.chat.panels.dm.navigateToDmChat] / public chat. * Matches [ru.fromchat.ui.chat.panels.dm.navigateToDmChat] / public chat.
*
* When [preserveConversationDetail] is true (desktop listdetail), keeps an open chat under
* the new settings/profile destination so tab hosts can retain the Chats detail.
*/ */
fun NavController.navigateReplacingMainDetail( fun NavController.navigateReplacingMainDetail(
route: String, route: String,
preserveConversationDetail: Boolean = false,
builder: NavOptionsBuilder.() -> Unit = {}, builder: NavOptionsBuilder.() -> Unit = {},
) { ) {
if (preserveConversationDetail) {
while (true) {
val current = currentBackStackEntry?.destination?.route ?: break
if (current == "chat" || isConversationChatRoute(current)) break
if (!popBackStack()) break
}
navigate(route) {
launchSingleTop = true
builder()
}
} else {
navigate(route) { navigate(route) {
popUpTo("chat") { saveState = true } popUpTo("chat") { saveState = true }
launchSingleTop = true launchSingleTop = true
builder() builder()
} }
} }
}
private fun isConversationChatRoute(route: String?): Boolean =
route == PublicChatNav.CHAT_ROUTE ||
(route != null && route.startsWith("dm/") && "/chat" in route)
@@ -14,7 +14,6 @@ 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
@@ -40,18 +39,9 @@ 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
@@ -82,8 +72,6 @@ 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
const val MAIN_PAGE_SETTINGS = 2 const val MAIN_PAGE_SETTINGS = 2
@@ -126,9 +114,7 @@ fun MainScreen(
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle() val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
val paneHazeState = LocalPaneHazeState.current val paneHazeState = LocalPaneHazeState.current
val contextMenuHazeState = paneHazeState ?: rememberHazeState() 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
@@ -167,15 +153,12 @@ fun MainScreen(
val settingsLabel = stringResource(Res.string.settings) val settingsLabel = stringResource(Res.string.settings)
val profileLabel = stringResource(Res.string.profile) val profileLabel = stringResource(Res.string.profile)
fun clearConversationStack() {
// Pop above chat only — inclusive pop remounts the chat destination and briefly
// drops the shell route, which remounts listdetail ↔ compact and blinks forever.
navController.popBackStack("chat", inclusive = false)
}
fun openOwnProfileInDetailPane() { fun openOwnProfileInDetailPane() {
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return
navController.navigateReplacingMainDetail("profile/$userId") navController.navigateReplacingMainDetail(
route = "profile/$userId",
preserveConversationDetail = embeddedInListDetail,
)
} }
fun selectMainPage(page: Int) { fun selectMainPage(page: Int) {
@@ -184,17 +167,10 @@ fun MainScreen(
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) } scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
openOwnProfileInDetailPane() openOwnProfileInDetailPane()
} }
page == PAGE_CHATS -> {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
if (embeddedInListDetail && selectedPage != PAGE_CHATS) {
clearConversationStack()
}
}
else -> { else -> {
// Desktop listdetail: do not pop the chat (or settings) stack — each tab
// keeps its own detail host, so switching tabs must not wipe the other tab.
scope.launch { pagerState.animateScrollToPage(page) } scope.launch { pagerState.animateScrollToPage(page) }
if (embeddedInListDetail) {
clearConversationStack()
}
} }
} }
} }
@@ -210,28 +186,12 @@ fun MainScreen(
} }
BoxWithConstraints( BoxWithConstraints(
Modifier Modifier.fillMaxSize(),
.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()
.then( .hazeSource(contextMenuHazeState),
if (paneProvidesHazeSource) {
Modifier
} else {
Modifier.hazeSource(contextMenuHazeState)
},
),
) { ) {
Scaffold( Scaffold(
snackbarHost = { snackbarHost = {
@@ -360,8 +320,6 @@ fun MainScreen(
ChatContextMenuBlurLayer( ChatContextMenuBlurLayer(
hazeState = contextMenuHazeState, hazeState = contextMenuHazeState,
blurProgress = chatMenuBlurProgress, blurProgress = chatMenuBlurProgress,
paneBoundsInWindow = contextMenuBlurPaneBoundsInWindow,
expandBeyondPane = paneProvidesHazeSource,
modifier = Modifier.zIndex(2f), modifier = Modifier.zIndex(2f),
) )
} }
@@ -381,30 +339,17 @@ 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
}
val blurModifier = Modifier.hazeEffect(state = hazeState) { // In-tree under the overlay (zIndex). Never use a Popup here: platform popups stack above
// the sharp row/menu, blur them, and intercept dismiss taps.
Box(
modifier = modifier
.fillMaxSize()
.hazeEffect(state = hazeState) {
blurEffect { blurEffect {
style = HazeBlurStyle( style = HazeBlurStyle(
blurRadius = blurRadius, blurRadius = blurRadius,
@@ -414,45 +359,6 @@ private fun ChatContextMenuBlurLayer(
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),
)
}
} }
@@ -22,6 +22,9 @@ import androidx.compose.material.icons.rounded.MarkEmailRead
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -34,6 +37,7 @@ 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
import com.pr0gramm3r101.utils.scaleOnPress import com.pr0gramm3r101.utils.scaleOnPress
import kotlinx.coroutines.channels.Channel
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.action_delete import ru.fromchat.action_delete
@@ -57,6 +61,14 @@ internal fun ChatContextMenuMeasurer(
screenHeightPx: Int, screenHeightPx: Int,
onMeasured: (IntSize) -> Unit, onMeasured: (IntSize) -> Unit,
) { ) {
val latestOnMeasured = rememberUpdatedState(onMeasured)
val measuredSizes = remember { Channel<IntSize>(Channel.CONFLATED) }
LaunchedEffect(Unit) {
for (size in measuredSizes) {
latestOnMeasured.value(size)
}
}
SubcomposeLayout(Modifier.size(0.dp)) { _ -> SubcomposeLayout(Modifier.size(0.dp)) { _ ->
val looseConstraints = Constraints( val looseConstraints = Constraints(
minWidth = 0, minWidth = 0,
@@ -84,8 +96,9 @@ internal fun ChatContextMenuMeasurer(
) )
}.map { it.measure(looseConstraints) } }.map { it.measure(looseConstraints) }
val measured = placeables.firstOrNull()?.let { IntSize(it.width, it.height) } ?: IntSize.Zero val measured = placeables.firstOrNull()?.let { IntSize(it.width, it.height) } ?: IntSize.Zero
// Do not write Compose state during measure (LookaheadMeasuring under SharedTransition).
if (measured != IntSize.Zero) { if (measured != IntSize.Zero) {
onMeasured(measured) measuredSizes.trySend(measured)
} }
layout(0, 0) { layout(0, 0) {
placeables.forEach { it.placeRelative(-10000, -10000) } placeables.forEach { it.placeRelative(-10000, -10000) }
@@ -61,6 +61,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.AwaitPointerEventScope
import androidx.compose.ui.input.pointer.PointerEvent import androidx.compose.ui.input.pointer.PointerEvent
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.input.pointer.PointerType
@@ -182,6 +183,52 @@ private fun Modifier.chatRowOpenAndSelectGestures(
} }
} }
/**
* Touch/stylus: press feedback + long-press opens the menu.
* Mouse: ignore avatar presses (row right-click handles the menu).
*/
private fun Modifier.chatRowAvatarGestures(
enabled: Boolean,
onPressStart: () -> Unit,
onPressEnd: () -> Unit,
onLongPress: (Offset) -> Unit,
): Modifier =
pointerInput(enabled) {
if (!enabled) return@pointerInput
awaitEachGesture {
handleChatRowAvatarGesture(
onPressStart = onPressStart,
onPressEnd = onPressEnd,
onLongPress = onLongPress,
)
}
}
private suspend fun AwaitPointerEventScope.handleChatRowAvatarGesture(
onPressStart: () -> Unit,
onPressEnd: () -> Unit,
onLongPress: (Offset) -> Unit,
) {
var downEvent: PointerEvent
do {
downEvent = awaitPointerEvent()
} while (!downEvent.changes.fastAll { it.changedToDown() })
val down = downEvent.changes.firstOrNull() ?: return
if (down.type == PointerType.Mouse) return
if (down.type != PointerType.Touch && down.type != PointerType.Stylus) return
onPressStart()
try {
withTimeout(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
}
} catch (_: PointerEventTimeoutCancellationException) {
onLongPress(down.position)
waitForUpOrCancellation()
} finally {
onPressEnd()
}
}
@Composable @Composable
internal fun ChatListHeadlineWithBadge( internal fun ChatListHeadlineWithBadge(
title: String, title: String,
@@ -692,32 +739,12 @@ internal fun ChatRowAvatar(
Box( Box(
modifier modifier
.size(40.dp) .size(40.dp)
.pointerInput(enabled) { .chatRowAvatarGestures(
if (!enabled) return@pointerInput enabled = enabled,
awaitEachGesture { onPressStart = onPressStart,
var downEvent: PointerEvent onPressEnd = onPressEnd,
do { onLongPress = onLongPress,
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()
}
} catch (_: PointerEventTimeoutCancellationException) {
onLongPress(down.position)
waitForUpOrCancellation()
} finally {
onPressEnd()
}
}
},
) { ) {
Avatar( Avatar(
profilePictureUrl = profilePictureUrl, profilePictureUrl = profilePictureUrl,
@@ -68,6 +68,13 @@ fun SettingsTab() {
val navController = LocalNavController.current val navController = LocalNavController.current
val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
fun openDetail(route: String) {
navController.navigateReplacingMainDetail(
route = route,
preserveConversationDetail = isTwoPane,
)
}
Scaffold( Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection), modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = Color.Transparent, containerColor = Color.Transparent,
@@ -104,7 +111,7 @@ fun SettingsTab() {
supportingText = stringResource(Res.string.settings_hub_profile_sub), supportingText = stringResource(Res.string.settings_hub_profile_sub),
onClick = { onClick = {
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return@ListItem val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return@ListItem
navController.navigateReplacingMainDetail("profile/$userId") openDetail("profile/$userId")
}, },
leadingContent = { Icon(Icons.Filled.Person, null) }, leadingContent = { Icon(Icons.Filled.Person, null) },
) )
@@ -115,7 +122,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_account), headline = stringResource(Res.string.settings_category_account),
supportingText = stringResource(Res.string.settings_category_account_d), supportingText = stringResource(Res.string.settings_category_account_d),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Account) }, onClick = { openDetail(SettingsRoutes.Account) },
leadingContent = { Icon(Icons.Filled.AccountCircle, null) }, leadingContent = { Icon(Icons.Filled.AccountCircle, null) },
divider = true divider = true
) )
@@ -123,7 +130,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_devices), headline = stringResource(Res.string.settings_category_devices),
supportingText = stringResource(Res.string.settings_category_devices_d), supportingText = stringResource(Res.string.settings_category_devices_d),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Devices) }, onClick = { openDetail(SettingsRoutes.Devices) },
leadingContent = { Icon(Icons.Filled.Devices, null) }, leadingContent = { Icon(Icons.Filled.Devices, null) },
divider = true divider = true
) )
@@ -131,7 +138,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_appearance), headline = stringResource(Res.string.settings_category_appearance),
supportingText = stringResource(Res.string.settings_category_appearance_d), supportingText = stringResource(Res.string.settings_category_appearance_d),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Appearance) }, onClick = { openDetail(SettingsRoutes.Appearance) },
leadingContent = { Icon(Icons.Filled.Palette, null) }, leadingContent = { Icon(Icons.Filled.Palette, null) },
divider = true divider = true
) )
@@ -139,7 +146,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.settings_category_notifications), headline = stringResource(Res.string.settings_category_notifications),
supportingText = stringResource(Res.string.settings_category_notifications_d), supportingText = stringResource(Res.string.settings_category_notifications_d),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Notifications) }, onClick = { openDetail(SettingsRoutes.Notifications) },
leadingContent = { Icon(Icons.Filled.Notifications, null) }, leadingContent = { Icon(Icons.Filled.Notifications, null) },
divider = true divider = true
) )
@@ -147,7 +154,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.change_server), headline = stringResource(Res.string.change_server),
supportingText = stringResource(Res.string.change_server_d), supportingText = stringResource(Res.string.change_server_d),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.ServerConfig) }, onClick = { openDetail(SettingsRoutes.ServerConfig) },
leadingContent = { Icon(Icons.Filled.Storage, null) }, leadingContent = { Icon(Icons.Filled.Storage, null) },
divider = true divider = true
) )
@@ -155,7 +162,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.about), headline = stringResource(Res.string.about),
supportingText = stringResource(Res.string.settings_hub_about_sub), supportingText = stringResource(Res.string.settings_hub_about_sub),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.About) }, onClick = { openDetail(SettingsRoutes.About) },
leadingContent = { Icon(Icons.Filled.Info, null) }, leadingContent = { Icon(Icons.Filled.Info, null) },
divider = true divider = true
) )
@@ -163,7 +170,7 @@ fun SettingsTab() {
ListItem( ListItem(
headline = stringResource(Res.string.logs_title), headline = stringResource(Res.string.logs_title),
supportingText = stringResource(Res.string.settings_hub_logs_sub), supportingText = stringResource(Res.string.settings_hub_logs_sub),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Logs) }, onClick = { openDetail(SettingsRoutes.Logs) },
leadingContent = { Icon(Icons.Outlined.BugReport, null) } leadingContent = { Icon(Icons.Outlined.BugReport, null) }
) )
} }
@@ -63,6 +63,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
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.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester import androidx.compose.foundation.relocation.BringIntoViewRequester
@@ -698,7 +699,15 @@ fun ProfileScreen(
animatedVisibilityScope != null && animatedVisibilityScope != null &&
sharedAvatarKey != null sharedAvatarKey != null
Box(modifier = Modifier.fillMaxSize()) { Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Box(
modifier = Modifier
.fillMaxSize()
.widthIn(max = 600.dp),
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -865,6 +874,8 @@ fun ProfileScreen(
} }
} }
} }
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun PublicChatProfileScreen( fun PublicChatProfileScreen(
@@ -985,7 +996,15 @@ fun PublicChatProfileScreen(
sharedAvatarKey != null sharedAvatarKey != null
ScreenSurface(modifier = modifier) { ScreenSurface(modifier = modifier) {
Box(modifier = Modifier.fillMaxSize()) { Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Box(
modifier = Modifier
.fillMaxSize()
.widthIn(max = 600.dp),
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -1074,6 +1093,7 @@ fun PublicChatProfileScreen(
} }
} }
} }
}
@Composable @Composable
private fun PublicChatProfileLoadedBody( private fun PublicChatProfileLoadedBody(
@@ -118,6 +118,7 @@ import com.pr0gramm3r101.utils.plus
import com.pr0gramm3r101.utils.right import com.pr0gramm3r101.utils.right
import com.pr0gramm3r101.utils.scaleOnPress import com.pr0gramm3r101.utils.scaleOnPress
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import tech.annexflow.constraintlayout.compose.ConstraintLayout import tech.annexflow.constraintlayout.compose.ConstraintLayout
import tech.annexflow.constraintlayout.compose.ConstraintLayoutScope import tech.annexflow.constraintlayout.compose.ConstraintLayoutScope
@@ -251,6 +252,12 @@ private fun ListItemContextMenuPopup(
} }
var measuredSize by remember { mutableStateOf(IntSize.Zero) } var measuredSize by remember { mutableStateOf(IntSize.Zero) }
val measuredSizes = remember { Channel<IntSize>(Channel.CONFLATED) }
LaunchedEffect(Unit) {
for (size in measuredSizes) {
if (measuredSize == IntSize.Zero) measuredSize = size
}
}
SubcomposeLayout(Modifier.size(0.dp)) { _ -> SubcomposeLayout(Modifier.size(0.dp)) { _ ->
val looseConstraints = Constraints( val looseConstraints = Constraints(
@@ -269,7 +276,7 @@ private fun ListItemContextMenuPopup(
}.map { it.measure(looseConstraints) } }.map { it.measure(looseConstraints) }
val p = placeables.firstOrNull() val p = placeables.firstOrNull()
if (p != null && measuredSize == IntSize.Zero) { if (p != null && measuredSize == IntSize.Zero) {
measuredSize = IntSize(p.width, p.height) measuredSizes.trySend(IntSize(p.width, p.height))
} }
layout(0, 0) { layout(0, 0) {
placeables.forEach { it.placeRelative(-10000, -10000) } placeables.forEach { it.placeRelative(-10000, -10000) }