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 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
|
# 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
|
||||||
|
|||||||
Reference in New Issue
Block a user