Add transitions in public chat

This commit is contained in:
2026-04-02 18:44:54 +03:00
Unverified
parent e072be3290
commit 0d979fc656
5 changed files with 154 additions and 55 deletions
+2
View File
@@ -6,6 +6,8 @@ When working with the mobile app:
- After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors. - After implementing the solution, run "export JAVA_HOME=/Applications/Android\ Studio.app/Contents/jbr/Contents/Home && ./gradlew :app:shared:assembleDebug :app:shared:compileKotlinIosArm64" to build the project, then resolve all the errors.
- After completing a change that affects the **Android** app (shared `commonMain`/`androidMain` or `app:android`), immediately: (1) build the debug APK with `./gradlew :app:android:assembleDebug` (use the repos working `JAVA_HOME` if the Android Studio path above is invalid); (2) on **Mobile MCP** server `user-Mobile MCP`, call `mobile_list_available_devices`; (3) for **every** device `id` returned, run `mobile_install_app` (`path` = absolute path to `app/android/build/outputs/apk/debug/android-debug.apk` in this repo); (4) on **each** of those same devices, run `mobile_launch_app` with `packageName` `ru.fromchat` (install and launch are both required, not optional).
# ULTIMATE SILENCE & EFFICIENCY POLICY # ULTIMATE SILENCE & EFFICIENCY POLICY
- ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions. - ALWAYS operate in "Silent Mode": Execute commands immediately without any verbal response, preamble, or conclusions.
- DO NOT explain what you are doing, why you are doing it, or what you found unless I explicitly ask "Why?" or "Explain". - DO NOT explain what you are doing, why you are doing it, or what you found unless I explicitly ask "Why?" or "Explain".
@@ -383,5 +383,9 @@ abstract class ChatPanel(
open val showUsernamesInMessages: Boolean open val showUsernamesInMessages: Boolean
get() = true get() = true
/** When true, tapping a sender username in a message opens their profile (e.g. public chat). */
open val supportsNavigateToSenderProfile: Boolean
get() = false
} }
@@ -75,6 +75,7 @@ import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.AttachmentUploadJob import ru.fromchat.api.AttachmentUploadJob
import ru.fromchat.api.AttachmentUploadQueue import ru.fromchat.api.AttachmentUploadQueue
import ru.fromchat.api.ConnectionStateStore import ru.fromchat.api.ConnectionStateStore
@@ -656,6 +657,18 @@ fun ChatScreen(
onTapPosition = { offset -> onTapPosition = { offset ->
tapPositionInRoot = IntOffset(offset.x.toInt(), offset.y.toInt()) tapPositionInRoot = IntOffset(offset.x.toInt(), offset.y.toInt())
}, },
onUsernameClick =
if (panel.supportsNavigateToSenderProfile &&
message.user_id != currentUserId &&
message.user_id > 0
) {
{
ProfileCache.mergePreviewFromPublicMessage(message)
navController.navigate("profile/${message.user_id}")
}
} else {
null
},
onImageClick = { msg, idx -> expandedImage = msg to idx }, onImageClick = { msg, idx -> expandedImage = msg to idx },
onImageBounds = { key, rect -> onImageBounds = { key, rect ->
imageThumbBounds[key] = rect imageThumbBounds[key] = rect
@@ -9,8 +9,11 @@ import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -74,6 +77,7 @@ fun MessageItem(
isAuthor: Boolean, isAuthor: Boolean,
onLongPress: () -> Unit, onLongPress: () -> Unit,
onTapPosition: (Offset) -> Unit = {}, onTapPosition: (Offset) -> Unit = {},
onUsernameClick: (() -> Unit)? = null,
onImageClick: ((Message, Int) -> Unit)? = null, onImageClick: ((Message, Int) -> Unit)? = null,
onImageBounds: ((String, Rect) -> Unit)? = null, onImageBounds: ((String, Rect) -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@@ -97,8 +101,10 @@ fun MessageItem(
modifier = modifier modifier = modifier
) { ) {
var isPressed by remember { mutableStateOf(false) } var isPressed by remember { mutableStateOf(false) }
var rowPositionInRoot by remember { mutableStateOf(Offset.Zero) } var avatarPressed by remember(message.id) { mutableStateOf(false) }
var bubbleBodyPositionInRoot by remember { mutableStateOf(Offset.Zero) }
val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f val scaleTarget = if (isPressed && !isContextMenuForThisMessage && !isContextMenuOpen) 0.96f else 1f
val avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = scaleTarget, targetValue = scaleTarget,
animationSpec = spring( animationSpec = spring(
@@ -108,42 +114,51 @@ fun MessageItem(
visibilityThreshold = 0.001f, visibilityThreshold = 0.001f,
label = "messageBubbleScale" label = "messageBubbleScale"
) )
val avatarScale by animateFloatAsState(
targetValue = avatarScaleTarget,
animationSpec = spring(
dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow
),
visibilityThreshold = 0.001f,
label = "messageAvatarScale"
)
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp) .padding(horizontal = 8.dp, vertical = 4.dp),
.onGloballyPositioned { coordinates ->
rowPositionInRoot = coordinates.positionInRoot()
}
.then(
if (isContextMenuOpen) Modifier
else Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onLongPress = { offset ->
onTapPosition(rowPositionInRoot + offset)
onLongPress()
}
)
}
),
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start, horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
verticalAlignment = Alignment.Bottom verticalAlignment = Alignment.Bottom
) { ) {
if (!isAuthor && showUsername) { if (!isAuthor && showUsername) {
Box(
modifier = Modifier
.graphicsLayer(
scaleX = avatarScale,
scaleY = avatarScale,
transformOrigin = TransformOrigin.Center
)
.pointerInput(onUsernameClick, isContextMenuOpen) {
detectTapGestures(
onPress = {
if (!isContextMenuOpen) avatarPressed = true
try {
awaitRelease()
} finally {
if (!isContextMenuOpen) avatarPressed = false
}
},
onTap = { onUsernameClick?.invoke() }
)
}
) {
Avatar( Avatar(
profilePictureUrl = message.profile_picture, profilePictureUrl = message.profile_picture,
displayName = message.username, displayName = message.username,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) )
}
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
} }
@@ -166,26 +181,42 @@ fun MessageItem(
) )
else -> false else -> false
} }
val firstContentIsImage = (!showUsername || isAuthor) && val firstContentIsImage = (
message.reply_to == null && !showUsername || isAuthor
(pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true) ) && message.reply_to == null && (
Box( pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
modifier = Modifier
.graphicsLayer(
scaleX = scale,
scaleY = scale,
transformOrigin = TransformOrigin.Center
) )
// Max width: 70% of row, min width: content-driven
.widthIn(max = maxBubbleWidth) val bubbleShape = RoundedCornerShape(
.clip(
RoundedCornerShape(
topStart = 20.dp, topStart = 20.dp,
topEnd = 20.dp, topEnd = 20.dp,
bottomStart = if (isAuthor) 20.dp else 8.dp, bottomStart = if (isAuthor) 20.dp else 8.dp,
bottomEnd = if (isAuthor) 8.dp else 20.dp bottomEnd = if (isAuthor) 8.dp else 20.dp
) )
val bubbleBodyGestures =
if (isContextMenuOpen) Modifier
else Modifier.pointerInput(Unit) {
detectTapGestures(
onPress = {
isPressed = true
try {
awaitRelease()
} finally {
isPressed = false
}
},
onLongPress = { offset ->
onTapPosition(bubbleBodyPositionInRoot + offset)
onLongPress()
}
) )
}
Box(
modifier = Modifier
.widthIn(max = maxBubbleWidth)
.clip(bubbleShape)
.conditional( .conditional(
isAuthor, isAuthor,
`if` = { `if` = {
@@ -206,20 +237,64 @@ fun MessageItem(
background(MaterialTheme.colorScheme.surfaceContainerHighest) background(MaterialTheme.colorScheme.surfaceContainerHighest)
} }
) )
.graphicsLayer(
scaleX = scale,
scaleY = scale,
transformOrigin = TransformOrigin.Center
)
.padding(top = if (firstContentIsImage) 0.dp else 6.dp) .padding(top = if (firstContentIsImage) 0.dp else 6.dp)
) { ) {
Column { val bubbleColumnModifier =
// Username inside bubble (for received messages) if (showUsername && !isAuthor) Modifier.width(IntrinsicSize.Max)
else Modifier
Column(modifier = bubbleColumnModifier) {
if (showUsername && !isAuthor) { if (showUsername && !isAuthor) {
val usernameShape = RoundedCornerShape(6.dp)
val usernameOutset = Modifier.padding(start = 8.dp, end = 8.dp, bottom = 4.dp)
val usernameInset = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
if (onUsernameClick != null) {
val usernameInteraction = remember(message.id) { MutableInteractionSource() }
Box(
modifier = usernameOutset
.clip(usernameShape)
.clickable(
interactionSource = usernameInteraction,
indication = LocalIndication.current,
onClick = onUsernameClick
)
) {
Text( Text(
text = message.username, text = message.username,
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp) modifier = usernameInset
) )
} }
} else {
Box(modifier = usernameOutset) {
Text(
text = message.username,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.primary,
modifier = usernameInset
)
}
}
}
val gestureWidthModifier =
if (showUsername && !isAuthor) Modifier.fillMaxWidth()
else Modifier
Box(
modifier = gestureWidthModifier
.onGloballyPositioned { coordinates ->
bubbleBodyPositionInRoot = coordinates.positionInRoot()
}
.then(bubbleBodyGestures)
) {
Column {
// Reply preview // Reply preview
message.reply_to?.let { replyTo -> message.reply_to?.let { replyTo ->
Box( Box(
@@ -444,6 +519,8 @@ fun MessageItem(
} }
} }
} }
}
}
@ExperimentalTime @ExperimentalTime
private fun formatTime(timestamp: String): String { private fun formatTime(timestamp: String): String {
@@ -24,6 +24,9 @@ class PublicChatPanel(
private val typingHandler = PublicChatTypingHandler(scope) private val typingHandler = PublicChatTypingHandler(scope)
private var messagesLoaded = false private var messagesLoaded = false
override val supportsNavigateToSenderProfile: Boolean
get() = true
init { init {
updateState { it.copy(title = chatName) } updateState { it.copy(title = chatName) }
// Observe typing users from the handler and update panel state // Observe typing users from the handler and update panel state