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>) {
// 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()) {
val appName = runBlocking { getString(Res.string.app_name) }
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.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarDuration
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.navigateToPublicChat
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.EmptyConversationPlaceholder
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_CONTACTS
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.ProfileRoutes
import ru.fromchat.ui.profile.ProfileScreen
import ru.fromchat.ui.components.AppPanel
import ru.fromchat.ui.components.LocalPaneHazeState
import ru.fromchat.ui.components.ScreenSurface
import androidx.compose.foundation.shape.RoundedCornerShape
import dev.chrisbanes.haze.rememberHazeState
import ru.fromchat.utils.NetworkConnectivity
@@ -514,49 +516,49 @@ fun App(
ScreenSurface {
Box(Modifier.fillMaxSize()) {
BoxWithConstraints(Modifier.fillMaxSize()) {
val canFitProfileThirdPane =
maxWidth >= ConversationProfileThirdPaneMinWidth
val showProfileThirdPane =
showConversationListDetail &&
canFitProfileThirdPane &&
(
isConversationProfileRoute(currentRoute) ||
(
isStandaloneProfileRoute(currentRoute) &&
isConversationChatRoute(previousRoute)
)
)
val settingsProfileSplit =
showConversationListDetail &&
isStandaloneProfileRoute(currentRoute) &&
!showProfileThirdPane &&
profileUserId != null &&
profileUserId == ownUserId
val listPaneWidth =
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
448.dp
} else {
360.dp
}
val forceSettingsListTab =
settingsProfileSplit ||
(
showConversationListDetail &&
isSettingsDetailRoute(currentRoute)
)
// Own profile from the Profile tab uses the settings detail host.
// Profile opened from a chat stays in the chats detail (push/slide).
val settingsProfileSplit =
showConversationListDetail &&
isStandaloneProfileRoute(currentRoute) &&
profileUserId != null &&
profileUserId == ownUserId &&
!isConversationChatRoute(previousRoute)
val listPaneWidth =
if (widthSizeClass == WindowWidthSizeClass.EXPANDED) {
448.dp
} else {
360.dp
}
// Only nudge the list pager when opening own profile from the
// Profile tab — do not yank the user back when they switch to Chats
// while a settings destination is still on the back stack.
val forceSettingsListTab = settingsProfileSplit
@Composable
fun AppNavHost(modifier: Modifier = Modifier) {
NavHost(
navController = navController,
startDestination = startDestination!!,
modifier = modifier,
enterTransition = { rootNavEnterTransition() },
exitTransition = { rootNavExitTransition() },
popEnterTransition = { rootNavPopEnterTransition() },
popExitTransition = { rootNavPopExitTransition() },
) {
var retainedChatsRoute by remember { mutableStateOf<String?>(null) }
LaunchedEffect(currentRoute, pendingMainTab) {
when {
isConversationChatRoute(currentRoute) -> {
retainedChatsRoute = currentRoute
}
currentRoute == "chat" &&
pendingMainTab == MAIN_PAGE_CHATS -> {
retainedChatsRoute = null
}
}
}
@Composable
fun AppNavHost(modifier: Modifier = Modifier) {
NavHost(
navController = navController,
startDestination = startDestination!!,
modifier = modifier,
enterTransition = { rootNavEnterTransition() },
exitTransition = { rootNavExitTransition() },
popEnterTransition = { rootNavPopEnterTransition() },
popExitTransition = { rootNavPopExitTransition() },
) {
composable("serverConfig") {
ServerConfigScreen()
}
@@ -872,113 +874,166 @@ fun App(
ApiClient.onAuthError = null
}
}
}
}
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>(
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 },
)
},
if (showConversationListDetail) {
val detailPaneAnim = tween<Float>(
durationMillis = 280,
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()
CompositionLocalProvider(
LocalPaneHazeState provides listPaneHazeState,
LocalDesktopMainTab provides pendingMainTab,
) {
ConversationListDetailShell(
listPaneWidth = listPaneWidth,
detailInPanel = false,
detailEdgeToEdge = detailEdgeToEdge,
listPane = {
MainScreen(
sharedTransitionScope = this@SharedTransitionLayout,
snackbarHostState = profileLookupSnackbarHostState,
embeddedInListDetail = true,
initialPage = pendingMainTab,
forceSettingsTab = forceSettingsListTab,
onPageChanged = { pendingMainTab = it },
)
},
detailPane = {
AnimatedContent(
targetState = detailPaneState,
transitionSpec = {
(
fadeIn(animationSpec = detailPaneAnim) +
scaleIn(
initialScale = 0.98f,
animationSpec = detailPaneAnim,
)
) togetherWith fadeOut(
animationSpec = tween(
durationMillis = 180,
easing = FastOutSlowInEasing,
),
)
},
label = "detailPane",
modifier = Modifier.fillMaxSize(),
) { state ->
when {
state == "split" -> {
val chatRouteForDetail =
when {
isConversationProfileRoute(currentRoute) ->
currentRoute
isConversationChatRoute(previousRoute) ->
previousRoute
else -> null
}
val dmUserId = dmUserIdFromRoute(chatRouteForDetail)
?: dmUserIdFromRoute(currentRoute)
when {
dmUserId != null -> DmChatRoute(
otherUserId = dmUserId,
scrollToMessageId = null,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
)
chatRouteForDetail == PublicChatNav.PROFILE_ROUTE ||
chatRouteForDetail == PublicChatNav.CHAT_ROUTE ||
currentRoute == PublicChatNav.PROFILE_ROUTE -> {
PublicChatChatRoute(
scrollToMessageId = scrollToMessageId,
navController = navController,
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this,
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(
targetState = chatsDetailKey,
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) +
scaleIn(
initialScale = 0.98f,
animationSpec = detailPaneAnim,
)
) togetherWith fadeOut(
animationSpec = tween(
durationMillis = 180,
easing = FastOutSlowInEasing,
),
)
}
}
}
state.startsWith("empty-") -> {
when (pendingMainTab) {
MAIN_PAGE_CONTACTS -> EmptyContactsPlaceholder()
MAIN_PAGE_SETTINGS -> EmptySettingsPlaceholder()
transform.using(null)
},
label = "chatsDetailPane",
modifier = Modifier.fillMaxSize(),
) { state ->
when {
state == "nav" -> {
AppNavHost(Modifier.fillMaxSize())
}
state.startsWith("retained:") -> {
val route =
state.removePrefix("retained:")
val dmUserId = dmUserIdFromRoute(route)
when {
dmUserId != null -> DmChatRoute(
otherUserId = dmUserId,
scrollToMessageId = null,
navController = navController,
sharedTransitionScope = null,
animatedVisibilityScope = null,
)
route == PublicChatNav.CHAT_ROUTE -> {
PublicChatChatRoute(
scrollToMessageId =
scrollToMessageId,
navController = navController,
sharedTransitionScope = null,
animatedVisibilityScope = null,
)
}
}
}
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()
}
}
},
contactsDetail = {
EmptyContactsPlaceholder()
},
)
},
profilePane = {
AppNavHost(Modifier.fillMaxSize())
},
)
}
} else {
AppNavHost(Modifier.fillMaxSize())
)
}
} else {
AppNavHost(Modifier.fillMaxSize())
}
CallOverlay(Modifier.fillMaxSize())
@@ -98,6 +98,8 @@ import ru.fromchat.cd_send
import ru.fromchat.config.Settings
import ru.fromchat.message_corrupted_short
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_replying_to
import ru.fromchat.suspend_chat_banner_message
@@ -322,12 +324,14 @@ fun ChatInput(
typingHandler.stopTyping()
}
val chromeHorizontalPad =
if (LocalConversationListDetailActive.current) ConversationDetailContentPadding else 8.dp
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Transparent)
.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)
@@ -839,12 +839,11 @@ fun ChatScreen(
}
}
) {
val inputPad = Modifier.padding(horizontal = detailContentPad)
if (peerDeleted && dmRecipientId != null) {
Box(
modifier = Modifier
.fillMaxWidth()
.then(inputPad)
.padding(horizontal = detailContentPad)
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
) {
Button(
@@ -871,7 +870,6 @@ fun ChatScreen(
}
}
} else {
Column(modifier = inputPad) {
ChatInput(
text = inputText,
onTextChange = { inputText = it },
@@ -1067,7 +1065,6 @@ fun ChatScreen(
}
)
}
}
}
}
) { innerPadding ->
@@ -1153,7 +1150,7 @@ fun ChatScreen(
}
is ChatListItem.MessageRow -> {
val message = item.message
var tapPositionInRoot by remember {
var tapPositionInWindow by remember {
mutableStateOf(IntOffset(0, 0))
}
val messageKey = timestampGroupKey(message)
@@ -1291,11 +1288,11 @@ fun ChatScreen(
contextMenuState = ContextMenuState(
isOpen = true,
message = message,
position = tapPositionInRoot
position = tapPositionInWindow,
)
},
onTapPosition = { offset ->
tapPositionInRoot =
tapPositionInWindow =
IntOffset(offset.x.toInt(), offset.y.toInt())
},
onUsernameClick =
@@ -393,7 +393,7 @@ fun ChatTopBar(
val layoutHeight = topBarPlaceable.height + arcExtentPx
val bgPlaceable = subcompose("background") {
val hazeStyle = rememberChatSurfaceContainerHazeStyle()
val hazeStyle = HazeMaterials.thin()
Box(
modifier = Modifier
.fillMaxSize()
@@ -402,11 +402,15 @@ fun ChatTopBar(
BottomInsetTopBarShape(bottomCornerRadius)
}
)
.background(MaterialTheme.colorScheme.surfaceContainer.copy(alpha = 0.91f))
.background(MaterialTheme.colorScheme.surfaceContainer)
.hazeEffect(state = hazeState) {
blurEffect {
blurEnabled = hazeBlurEnabled
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
* ([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.
* blur). The haze strip is full-bleed; pills sit in [ConversationDetailContentPadding] so blur
* breathes at the edges (same horizontal inset as [ChatInput]).
*/
@Composable
private fun ChatTopBarPill(
@@ -440,8 +444,6 @@ private fun ChatTopBarPill(
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(
@@ -471,7 +473,7 @@ private fun ChatTopBarPill(
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(pillBg),
.background(chromeColor),
contentAlignment = Alignment.Center,
) {
IconButton(onClick = onBack) {
@@ -485,7 +487,7 @@ private fun ChatTopBarPill(
modifier = Modifier
.weight(1f)
.clip(pillShape)
.background(pillBg)
.background(chromeColor)
.padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -37,17 +37,18 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
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.unit.Constraints
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
@@ -147,151 +148,148 @@ fun MessageContextMenu(
if (state.isOpen && state.message != null) {
shouldShowPopup = true
animationProgress.floatValue = 0f
animate(
initialValue = 0f,
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
)
) { value, _ ->
animationProgress.floatValue = value
}
}
}
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
var popupSize by remember(state.message) { mutableStateOf(IntSize.Zero) }
val adjustedOffset = remember(
measuredSize,
// Clamp with popupContentSize — do not SubcomposeLayout-measure under
// SharedTransitionLayout (writes state during LookaheadMeasuring).
val positionProvider = remember(
state.position,
rightEdge,
bottomEdge,
screenWidthPx,
screenHeightPx,
paddingPx,
allowOutsideWindow,
) {
if (allowOutsideWindow || measuredSize == IntSize.Zero) {
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
if (x + measuredSize.width > rightEdge) x = rightEdge - measuredSize.width
if (y + measuredSize.height > bottomEdge) y = bottomEdge - measuredSize.height
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 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(
initialValue = 0f,
targetValue = 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
)
) { value, _ ->
animationProgress.floatValue = value
}
}
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 alpha = animationProgress.floatValue
if (measuredSize != IntSize.Zero) {
Popup(
onDismissRequest = onDismiss,
alignment = Alignment.TopStart,
offset = adjustedOffset,
properties = PopupProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false
)
) {
ContextMenuContent(
message = state.message,
isAuthor = isAuthor,
canDelete = canDelete,
onReply = {
onReply(it)
onDismiss()
},
onEdit = {
onEdit(it)
onDismiss()
},
onDelete = {
onDelete(it)
onDismiss()
},
onCopy = {
onCopy(it)
onDismiss()
},
onSave = {
onSave(it)
onDismiss()
},
onCancelSend = {
onCancelSend(it)
onDismiss()
},
onRetrySend = {
onRetrySend(it)
onDismiss()
},
modifier = modifier,
animated = true,
scale = scale,
alpha = alpha,
transformOriginX = transformOriginX,
transformOriginY = transformOriginY,
isReadOnly = isReadOnly
)
}
// [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(
onDismissRequest = onDismiss,
popupPositionProvider = positionProvider,
properties = PopupProperties(
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false,
),
) {
ContextMenuContent(
message = state.message,
isAuthor = isAuthor,
canDelete = canDelete,
onReply = {
onReply(it)
onDismiss()
},
onEdit = {
onEdit(it)
onDismiss()
},
onDelete = {
onDelete(it)
onDismiss()
},
onCopy = {
onCopy(it)
onDismiss()
},
onSave = {
onSave(it)
onDismiss()
},
onCancelSend = {
onCancelSend(it)
onDismiss()
},
onRetrySend = {
onRetrySend(it)
onDismiss()
},
modifier = modifier.onSizeChanged { popupSize = it },
animated = true,
scale = scale,
alpha = alpha,
transformOriginX = transformOriginX,
transformOriginY = transformOriginY,
isReadOnly = isReadOnly,
)
}
}
}
@@ -503,7 +503,7 @@ fun MessageItem(
val openMessageMenu: (LayoutCoordinates?, Offset) -> Unit = { coords, localOffset ->
if (coords != null && coords.isAttached) {
onTapPosition(coords.localToRoot(localOffset))
onTapPosition(coords.localToWindow(localOffset))
}
isPressed = true
onLongPress()
@@ -773,7 +773,7 @@ fun MessageItem(
coords.size.height / 2f,
)
onTapPosition(
coords.localToRoot(center),
coords.localToWindow(center),
)
}
onLongPress()
@@ -57,8 +57,8 @@ fun DmChatRoute(
otherUserId: Int,
scrollToMessageId: Int? = null,
navController: NavController,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
modifier: Modifier = Modifier,
) {
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
@@ -41,8 +41,8 @@ fun NavController.navigateToPublicChat(
fun PublicChatChatRoute(
scrollToMessageId: Int? = null,
navController: NavController,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
modifier: Modifier = Modifier,
) {
val haptic = rememberHapticFeedback()
@@ -13,11 +13,11 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.dp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
/**
* When non-null, [AppPanel] is a haze source so overlays (chat context-menu blur) include
* panel chrome / rounded outlines. Provide above the panel (listdetail); null in compact.
* Shared [HazeState] for the list panes context-menu blur. Provide above the listdetail shell;
* [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 }
@@ -32,15 +32,8 @@ fun AppPanel(
shape: Shape = RoundedCornerShape(24.dp),
content: @Composable BoxScope.() -> Unit,
) {
val paneHazeState = LocalPaneHazeState.current
Surface(
modifier = modifier.then(
if (paneHazeState != null) {
Modifier.hazeSource(paneHazeState)
} else {
Modifier
},
),
modifier = modifier,
shape = shape,
color = color,
) {
@@ -1,15 +1,8 @@
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.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
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.union
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import ru.fromchat.ui.components.AppPanel
import ru.fromchat.ui.extraStatusBars
/** Minimum width to show chat + profile side-by-side beside the list. */
val ConversationProfileThirdPaneMinWidth = 1100.dp
/**
* Horizontal inset for chat detail content (messages, top-bar pills, input) in listdetail
* so progressive chrome blur can breathe at the pane edges while outer panes stay flush.
* so progressive chrome blur can breathe at the pane edges.
*/
val ConversationDetailContentPadding = 12.dp
private val PaneCornerRadius = 24.dp
/** Round only the outer (leading) corners — adjacent edge stays square so panes sit flush. */
private val ListPaneShape: Shape = RoundedCornerShape(
topStart = PaneCornerRadius,
bottomStart = PaneCornerRadius,
)
/** Round only the outer (trailing) corners for settings / profile detail panels. */
private val DetailPaneShape: Shape = RoundedCornerShape(
topEnd = PaneCornerRadius,
bottomEnd = PaneCornerRadius,
)
/**
* True while the conversation listdetail shell is showing. Chat chrome uses this for
* pill-shaped top bars and edge-to-edge detail layout.
*/
val LocalConversationListDetailActive = staticCompositionLocalOf { false }
private const val ProfilePaneAnimMs = 280
/**
* Listdetail (optional profile) shell. The list pane always uses [AppPanel].
* Settings detail uses [AppPanel] too ([detailInPanel]); chat detail is edge-to-edge
* ([detailEdgeToEdge]) so floating chrome can blur past its inner padding toward the window.
* Listdetail shell. The list pane always uses [AppPanel] with full rounded corners
* (including the edge facing the detail pane). 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.
* A small gutter separates panes so list-panel round corners stay visible.
*
* 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
* except when [detailEdgeToEdge], where the detail column omits outer padding and keeps
* 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
fun ConversationListDetailShell(
showProfilePane: Boolean,
listPaneWidth: Dp,
listPane: @Composable () -> Unit,
detailPane: @Composable () -> Unit,
profilePane: @Composable () -> Unit,
modifier: Modifier = Modifier,
detailInPanel: 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) {
if (detailEdgeToEdge) {
Row(Modifier.fillMaxSize()) {
CompositionLocalProvider(LocalConversationListDetailActive provides true) {
Row(modifier.fillMaxSize()) {
AppPanel(
Modifier
.windowInsetsPadding(paneInsets)
.consumeWindowInsets(paneInsets)
.padding(
start = 8.dp,
top = 8.dp,
bottom = 8.dp,
)
.width(listPaneWidth)
.fillMaxHeight(),
shape = RoundedCornerShape(24.dp),
) {
listPane()
}
Spacer(Modifier.width(8.dp))
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.then(
if (detailEdgeToEdge) {
Modifier
} else {
Modifier
.windowInsetsPadding(paneInsets)
.consumeWindowInsets(paneInsets)
.padding(
top = 8.dp,
end = 8.dp,
bottom = 8.dp,
)
},
),
) {
if (detailInPanel) {
AppPanel(
Modifier
.windowInsetsPadding(paneInsets)
.consumeWindowInsets(paneInsets)
.padding(start = 8.dp, top = 8.dp, bottom = 8.dp)
.width(listPaneWidth)
.fillMaxHeight(),
shape = ListPaneShape,
) {
listPane()
}
Box(
Modifier
.weight(1f)
.fillMaxHeight(),
Modifier.fillMaxSize(),
shape = RoundedCornerShape(24.dp),
) {
detailPane()
}
ProfilePaneSlot(
visible = profileVisible,
modifier = Modifier
.windowInsetsPadding(paneInsets)
.consumeWindowInsets(paneInsets)
.padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 8.dp),
content = profilePane,
)
}
} else {
Row(
Modifier
.fillMaxSize()
.windowInsetsPadding(paneInsets)
.consumeWindowInsets(paneInsets)
.padding(8.dp),
) {
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,
)
} else {
detailPane()
}
}
}
}
}
@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.NavOptionsBuilder
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
/**
* 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.
* 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(
route: String,
preserveConversationDetail: Boolean = false,
builder: NavOptionsBuilder.() -> Unit = {},
) {
navigate(route) {
popUpTo("chat") { saveState = true }
launchSingleTop = true
builder()
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) {
popUpTo("chat") { saveState = true }
launchSingleTop = true
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.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.material.icons.Icons
@@ -40,18 +39,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import androidx.compose.ui.zIndex
import com.pr0gramm3r101.utils.WindowWidthSizeClass
import com.pr0gramm3r101.utils.currentWindowAdaptiveInfo
@@ -82,8 +72,6 @@ import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
import ru.fromchat.ui.main.chats.ChatsTab
import ru.fromchat.ui.main.settings.SettingsTab
import ru.fromchat.ui.profile.ProfileScreen
import kotlin.math.roundToInt
const val MAIN_PAGE_CHATS = 0
const val MAIN_PAGE_CONTACTS = 1
const val MAIN_PAGE_SETTINGS = 2
@@ -126,9 +114,7 @@ fun MainScreen(
val navBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
val paneHazeState = LocalPaneHazeState.current
val contextMenuHazeState = paneHazeState ?: rememberHazeState()
val paneProvidesHazeSource = paneHazeState != null
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
var contextMenuBlurPaneBoundsInWindow by remember { mutableStateOf(IntRect.Zero) }
val widthClass = currentWindowAdaptiveInfo().widthSizeClass
@@ -167,15 +153,12 @@ fun MainScreen(
val settingsLabel = stringResource(Res.string.settings)
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() {
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return
navController.navigateReplacingMainDetail("profile/$userId")
navController.navigateReplacingMainDetail(
route = "profile/$userId",
preserveConversationDetail = embeddedInListDetail,
)
}
fun selectMainPage(page: Int) {
@@ -184,17 +167,10 @@ fun MainScreen(
scope.launch { pagerState.animateScrollToPage(PAGE_SETTINGS) }
openOwnProfileInDetailPane()
}
page == PAGE_CHATS -> {
scope.launch { pagerState.animateScrollToPage(PAGE_CHATS) }
if (embeddedInListDetail && selectedPage != PAGE_CHATS) {
clearConversationStack()
}
}
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) }
if (embeddedInListDetail) {
clearConversationStack()
}
}
}
}
@@ -210,28 +186,12 @@ fun MainScreen(
}
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(),
)
},
Modifier.fillMaxSize(),
) {
Box(
modifier = Modifier
.fillMaxSize()
.then(
if (paneProvidesHazeSource) {
Modifier
} else {
Modifier.hazeSource(contextMenuHazeState)
},
),
.hazeSource(contextMenuHazeState),
) {
Scaffold(
snackbarHost = {
@@ -360,8 +320,6 @@ fun MainScreen(
ChatContextMenuBlurLayer(
hazeState = contextMenuHazeState,
blurProgress = chatMenuBlurProgress,
paneBoundsInWindow = contextMenuBlurPaneBoundsInWindow,
expandBeyondPane = paneProvidesHazeSource,
modifier = Modifier.zIndex(2f),
)
}
@@ -381,78 +339,26 @@ fun MainScreen(
private fun ChatContextMenuBlurLayer(
hazeState: HazeState,
blurProgress: Float,
paneBoundsInWindow: IntRect,
expandBeyondPane: Boolean,
modifier: Modifier = Modifier,
) {
val blurRadius = 12.dp * blurProgress
if (blurRadius <= 0.dp) return
val density = LocalDensity.current
val expandPx = if (expandBeyondPane) {
with(density) { (24.dp + blurRadius).roundToPx() }
} else {
0
}
val blurBounds = if (paneBoundsInWindow.width > 0 && paneBoundsInWindow.height > 0) {
IntRect(
left = (paneBoundsInWindow.left - expandPx).coerceAtLeast(0),
top = (paneBoundsInWindow.top - expandPx).coerceAtLeast(0),
right = paneBoundsInWindow.right + expandPx,
bottom = paneBoundsInWindow.bottom + expandPx,
)
} else {
null
}
val blurModifier = Modifier.hazeEffect(state = hazeState) {
blurEffect {
style = HazeBlurStyle(
blurRadius = blurRadius,
colorEffects = emptyList(),
backgroundColor = Color.Transparent,
noiseFactor = 0f,
fallbackColorEffect = HazeColorEffect.tint(Color.Transparent),
)
}
}
if (blurBounds == null || !expandBeyondPane) {
Box(
modifier = modifier
.fillMaxSize()
.then(blurModifier),
)
return
}
// Escape AppPanel clip so blurred panel chrome / rounded outlines are covered.
Popup(
popupPositionProvider = object : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset = IntOffset(blurBounds.left, blurBounds.top)
},
properties = PopupProperties(
focusable = false,
dismissOnBackPress = false,
dismissOnClickOutside = false,
clippingEnabled = false,
),
) {
Box(
modifier = modifier
.then(
with(density) {
Modifier.size(
width = blurBounds.width.toDp(),
height = blurBounds.height.toDp(),
)
},
)
.then(blurModifier),
)
}
// 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 {
style = HazeBlurStyle(
blurRadius = blurRadius,
colorEffects = emptyList(),
backgroundColor = Color.Transparent,
noiseFactor = 0f,
fallbackColorEffect = HazeColorEffect.tint(Color.Transparent),
)
}
},
)
}
@@ -22,6 +22,9 @@ import androidx.compose.material.icons.rounded.MarkEmailRead
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
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.Modifier
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.dp
import com.pr0gramm3r101.utils.scaleOnPress
import kotlinx.coroutines.channels.Channel
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.action_delete
@@ -57,6 +61,14 @@ internal fun ChatContextMenuMeasurer(
screenHeightPx: Int,
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)) { _ ->
val looseConstraints = Constraints(
minWidth = 0,
@@ -84,8 +96,9 @@ internal fun ChatContextMenuMeasurer(
)
}.map { it.measure(looseConstraints) }
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) {
onMeasured(measured)
measuredSizes.trySend(measured)
}
layout(0, 0) {
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.TransformOrigin
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.PointerEventTimeoutCancellationException
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
internal fun ChatListHeadlineWithBadge(
title: String,
@@ -692,32 +739,12 @@ internal fun ChatRowAvatar(
Box(
modifier
.size(40.dp)
.pointerInput(enabled) {
if (!enabled) return@pointerInput
awaitEachGesture {
var downEvent: PointerEvent
do {
downEvent = awaitPointerEvent()
} while (!downEvent.changes.fastAll { it.changedToDown() })
val down = downEvent.changes.firstOrNull() ?: return@awaitEachGesture
// Mouse: avatar press/click is disabled; use row right-click for the menu.
if (down.type == PointerType.Mouse) return@awaitEachGesture
if (down.type != PointerType.Touch && down.type != PointerType.Stylus) {
return@awaitEachGesture
}
onPressStart()
try {
withTimeout(viewConfiguration.longPressTimeoutMillis) {
waitForUpOrCancellation()
}
} catch (_: PointerEventTimeoutCancellationException) {
onLongPress(down.position)
waitForUpOrCancellation()
} finally {
onPressEnd()
}
}
},
.chatRowAvatarGestures(
enabled = enabled,
onPressStart = onPressStart,
onPressEnd = onPressEnd,
onLongPress = onLongPress,
),
) {
Avatar(
profilePictureUrl = profilePictureUrl,
@@ -68,6 +68,13 @@ fun SettingsTab() {
val navController = LocalNavController.current
val isTwoPane = currentWindowAdaptiveInfo().widthSizeClass != WindowWidthSizeClass.COMPACT
fun openDetail(route: String) {
navController.navigateReplacingMainDetail(
route = route,
preserveConversationDetail = isTwoPane,
)
}
Scaffold(
modifier = Modifier.fillMaxSize().nestedScroll(scrollBehavior.nestedScrollConnection),
containerColor = Color.Transparent,
@@ -104,7 +111,7 @@ fun SettingsTab() {
supportingText = stringResource(Res.string.settings_hub_profile_sub),
onClick = {
val userId = ApiClient.user?.id?.takeIf { it > 0 } ?: return@ListItem
navController.navigateReplacingMainDetail("profile/$userId")
openDetail("profile/$userId")
},
leadingContent = { Icon(Icons.Filled.Person, null) },
)
@@ -115,7 +122,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.settings_category_account),
supportingText = stringResource(Res.string.settings_category_account_d),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Account) },
onClick = { openDetail(SettingsRoutes.Account) },
leadingContent = { Icon(Icons.Filled.AccountCircle, null) },
divider = true
)
@@ -123,7 +130,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.settings_category_devices),
supportingText = stringResource(Res.string.settings_category_devices_d),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Devices) },
onClick = { openDetail(SettingsRoutes.Devices) },
leadingContent = { Icon(Icons.Filled.Devices, null) },
divider = true
)
@@ -131,7 +138,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.settings_category_appearance),
supportingText = stringResource(Res.string.settings_category_appearance_d),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Appearance) },
onClick = { openDetail(SettingsRoutes.Appearance) },
leadingContent = { Icon(Icons.Filled.Palette, null) },
divider = true
)
@@ -139,7 +146,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.settings_category_notifications),
supportingText = stringResource(Res.string.settings_category_notifications_d),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Notifications) },
onClick = { openDetail(SettingsRoutes.Notifications) },
leadingContent = { Icon(Icons.Filled.Notifications, null) },
divider = true
)
@@ -147,7 +154,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.change_server),
supportingText = stringResource(Res.string.change_server_d),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.ServerConfig) },
onClick = { openDetail(SettingsRoutes.ServerConfig) },
leadingContent = { Icon(Icons.Filled.Storage, null) },
divider = true
)
@@ -155,7 +162,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.about),
supportingText = stringResource(Res.string.settings_hub_about_sub),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.About) },
onClick = { openDetail(SettingsRoutes.About) },
leadingContent = { Icon(Icons.Filled.Info, null) },
divider = true
)
@@ -163,7 +170,7 @@ fun SettingsTab() {
ListItem(
headline = stringResource(Res.string.logs_title),
supportingText = stringResource(Res.string.settings_hub_logs_sub),
onClick = { navController.navigateReplacingMainDetail(SettingsRoutes.Logs) },
onClick = { openDetail(SettingsRoutes.Logs) },
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.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.relocation.BringIntoViewRequester
@@ -698,17 +699,25 @@ fun ProfileScreen(
animatedVisibilityScope != null &&
sharedAvatarKey != null
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
.widthIn(max = 600.dp),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when {
showAvatarSkeleton && !hideAvatar -> {
item {
@@ -862,9 +871,11 @@ fun ProfileScreen(
.padding(bottom = 16.dp)
.fillMaxWidth(),
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PublicChatProfileScreen(
@@ -985,17 +996,25 @@ fun PublicChatProfileScreen(
sharedAvatarKey != null
ScreenSurface(modifier = modifier) {
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.TopCenter,
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
.widthIn(max = 600.dp),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.hazeSource(hazeState),
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
when {
useSharedAvatar && displayName.isNotBlank() -> {
item {
@@ -1071,6 +1090,7 @@ fun PublicChatProfileScreen(
.padding(bottom = 16.dp)
.fillMaxWidth(),
)
}
}
}
}
@@ -118,6 +118,7 @@ import com.pr0gramm3r101.utils.plus
import com.pr0gramm3r101.utils.right
import com.pr0gramm3r101.utils.scaleOnPress
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import tech.annexflow.constraintlayout.compose.ConstraintLayout
import tech.annexflow.constraintlayout.compose.ConstraintLayoutScope
@@ -251,6 +252,12 @@ private fun ListItemContextMenuPopup(
}
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)) { _ ->
val looseConstraints = Constraints(
@@ -269,7 +276,7 @@ private fun ListItemContextMenuPopup(
}.map { it.measure(looseConstraints) }
val p = placeables.firstOrNull()
if (p != null && measuredSize == IntSize.Zero) {
measuredSize = IntSize(p.width, p.height)
measuredSizes.trySend(IntSize(p.width, p.height))
}
layout(0, 0) {
placeables.forEach { it.placeRelative(-10000, -10000) }