mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Add transitions in public chat
This commit is contained in:
@@ -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 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 repo’s 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
|
||||
- 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".
|
||||
|
||||
@@ -383,5 +383,9 @@ abstract class ChatPanel(
|
||||
|
||||
open val showUsernamesInMessages: Boolean
|
||||
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 ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.ProfileCache
|
||||
import ru.fromchat.api.AttachmentUploadJob
|
||||
import ru.fromchat.api.AttachmentUploadQueue
|
||||
import ru.fromchat.api.ConnectionStateStore
|
||||
@@ -656,6 +657,18 @@ fun ChatScreen(
|
||||
onTapPosition = { offset ->
|
||||
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 },
|
||||
onImageBounds = { key, rect ->
|
||||
imageThumbBounds[key] = rect
|
||||
|
||||
@@ -9,8 +9,11 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.LocalIndication
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -74,6 +77,7 @@ fun MessageItem(
|
||||
isAuthor: Boolean,
|
||||
onLongPress: () -> Unit,
|
||||
onTapPosition: (Offset) -> Unit = {},
|
||||
onUsernameClick: (() -> Unit)? = null,
|
||||
onImageClick: ((Message, Int) -> Unit)? = null,
|
||||
onImageBounds: ((String, Rect) -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
@@ -97,8 +101,10 @@ fun MessageItem(
|
||||
modifier = modifier
|
||||
) {
|
||||
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 avatarScaleTarget = if (avatarPressed && !isContextMenuOpen) 0.96f else 1f
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = scaleTarget,
|
||||
animationSpec = spring(
|
||||
@@ -108,42 +114,51 @@ fun MessageItem(
|
||||
visibilityThreshold = 0.001f,
|
||||
label = "messageBubbleScale"
|
||||
)
|
||||
val avatarScale by animateFloatAsState(
|
||||
targetValue = avatarScaleTarget,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = Spring.StiffnessMediumLow
|
||||
),
|
||||
visibilityThreshold = 0.001f,
|
||||
label = "messageAvatarScale"
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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()
|
||||
}
|
||||
)
|
||||
}
|
||||
),
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = if (isAuthor) Arrangement.End else Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
if (!isAuthor && showUsername) {
|
||||
Avatar(
|
||||
profilePictureUrl = message.profile_picture,
|
||||
displayName = message.username,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
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(
|
||||
profilePictureUrl = message.profile_picture,
|
||||
displayName = message.username,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
@@ -166,26 +181,42 @@ fun MessageItem(
|
||||
)
|
||||
else -> false
|
||||
}
|
||||
val firstContentIsImage = (!showUsername || isAuthor) &&
|
||||
message.reply_to == null &&
|
||||
(pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true)
|
||||
val firstContentIsImage = (
|
||||
!showUsername || isAuthor
|
||||
) && message.reply_to == null && (
|
||||
pendingIsImage || message.files?.firstOrNull()?.let { isImageFilename(it.name) } == true
|
||||
)
|
||||
|
||||
val bubbleShape = RoundedCornerShape(
|
||||
topStart = 20.dp,
|
||||
topEnd = 20.dp,
|
||||
bottomStart = if (isAuthor) 20.dp else 8.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
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
transformOrigin = TransformOrigin.Center
|
||||
)
|
||||
// Max width: 70% of row, min width: content-driven
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
.clip(
|
||||
RoundedCornerShape(
|
||||
topStart = 20.dp,
|
||||
topEnd = 20.dp,
|
||||
bottomStart = if (isAuthor) 20.dp else 8.dp,
|
||||
bottomEnd = if (isAuthor) 8.dp else 20.dp
|
||||
)
|
||||
)
|
||||
.clip(bubbleShape)
|
||||
.conditional(
|
||||
isAuthor,
|
||||
`if` = {
|
||||
@@ -206,20 +237,64 @@ fun MessageItem(
|
||||
background(MaterialTheme.colorScheme.surfaceContainerHighest)
|
||||
}
|
||||
)
|
||||
.graphicsLayer(
|
||||
scaleX = scale,
|
||||
scaleY = scale,
|
||||
transformOrigin = TransformOrigin.Center
|
||||
)
|
||||
.padding(top = if (firstContentIsImage) 0.dp else 6.dp)
|
||||
) {
|
||||
Column {
|
||||
// Username inside bubble (for received messages)
|
||||
val bubbleColumnModifier =
|
||||
if (showUsername && !isAuthor) Modifier.width(IntrinsicSize.Max)
|
||||
else Modifier
|
||||
Column(modifier = bubbleColumnModifier) {
|
||||
if (showUsername && !isAuthor) {
|
||||
Text(
|
||||
text = message.username,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 4.dp)
|
||||
)
|
||||
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 = message.username,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
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
|
||||
message.reply_to?.let { replyTo ->
|
||||
Box(
|
||||
@@ -437,6 +512,8 @@ fun MessageItem(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ class PublicChatPanel(
|
||||
private val typingHandler = PublicChatTypingHandler(scope)
|
||||
private var messagesLoaded = false
|
||||
|
||||
override val supportsNavigateToSenderProfile: Boolean
|
||||
get() = true
|
||||
|
||||
init {
|
||||
updateState { it.copy(title = chatName) }
|
||||
// Observe typing users from the handler and update panel state
|
||||
|
||||
Reference in New Issue
Block a user