diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 621cbfd..17b9834 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -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". diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt index 9b843d2..36df280 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatPanel.kt @@ -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 } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt index b72068d..2ba0530 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/ChatScreen.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt index b107349..e34e56e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/MessageItem.kt @@ -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( ) } } + } + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt index 46cca24..24c3e5e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/PublicChatPanel.kt @@ -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