mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Working shared element transition
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
|
android:enableOnBackInvokedCallback="true"
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ kotlin {
|
|||||||
|
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
// NaCl box implementation for transport encryption (Android/JVM only)
|
// NaCl box implementation for transport encryption (Android/JVM only)
|
||||||
implementation("org.purejava:tweetnacl-java:1.1.3")
|
implementation("org.purejava:tweetnacl-java:1.1.3")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package ru.fromchat.ui
|
||||||
|
|
||||||
|
import androidx.activity.compose.BackHandler as AndroidBackHandler
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
|
||||||
|
AndroidBackHandler(enabled = enabled, onBack = onBack)
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ import ru.fromchat.ui.auth.LoginScreen
|
|||||||
import ru.fromchat.ui.auth.RegisterScreen
|
import ru.fromchat.ui.auth.RegisterScreen
|
||||||
import ru.fromchat.ui.chat.PublicChatScreen
|
import ru.fromchat.ui.chat.PublicChatScreen
|
||||||
import ru.fromchat.ui.debug.DebugApiScreen
|
import ru.fromchat.ui.debug.DebugApiScreen
|
||||||
import ru.fromchat.ui.dm.DmScreen
|
import ru.fromchat.ui.dm.DmContainerScreen
|
||||||
import ru.fromchat.ui.main.MainScreen
|
import ru.fromchat.ui.main.MainScreen
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
import ru.fromchat.ui.setup.ServerConfigScreen
|
import ru.fromchat.ui.setup.ServerConfigScreen
|
||||||
@@ -177,7 +177,7 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable("profile/{userId}") { backStackEntry ->
|
composable("profile/{userId}") { backStackEntry ->
|
||||||
val userId = backStackEntry.arguments?.getString("userId")?.toIntOrNull()
|
val userId = backStackEntry.savedStateHandle.get<String>("userId")?.toIntOrNull()
|
||||||
ProfileScreen(
|
ProfileScreen(
|
||||||
userId = userId,
|
userId = userId,
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
@@ -186,9 +186,10 @@ fun App(scrollToMessageId: Int? = null, startAtPublicChat: Boolean = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
composable("dm/{otherUserId}") { backStackEntry ->
|
composable("dm/{otherUserId}") { backStackEntry ->
|
||||||
val otherUserId = backStackEntry.arguments?.getString("otherUserId")?.toIntOrNull() ?: 0
|
val otherUserId = backStackEntry.savedStateHandle.get<String>("otherUserId")?.toIntOrNull() ?: 0
|
||||||
DmScreen(
|
DmContainerScreen(
|
||||||
otherUserId = otherUserId
|
otherUserId = otherUserId,
|
||||||
|
onBack = { navController.navigateUp() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package ru.fromchat.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles system back when [enabled]. When enabled, [onBack] is invoked instead of default back.
|
||||||
|
* On Android: uses BackHandler (supports predictive back when app opts in).
|
||||||
|
* On iOS: no-op.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
expect fun BackHandler(enabled: Boolean, onBack: () -> Unit)
|
||||||
@@ -24,7 +24,8 @@ data class ChatPanelState(
|
|||||||
val hasMoreMessages: Boolean = false,
|
val hasMoreMessages: Boolean = false,
|
||||||
val isLoadingMore: Boolean = false,
|
val isLoadingMore: Boolean = false,
|
||||||
val typingUsers: List<TypingUser> = emptyList(),
|
val typingUsers: List<TypingUser> = emptyList(),
|
||||||
val titleAvatar: AvatarInfo? = null
|
val titleAvatar: AvatarInfo? = null,
|
||||||
|
val profileUserId: Int? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
|
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.interaction.PressInteraction
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -16,6 +23,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.ime
|
import androidx.compose.foundation.layout.ime
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
@@ -44,7 +52,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.TransformOrigin
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
@@ -69,7 +80,6 @@ import ru.fromchat.api.WebSocketUpdatesData
|
|||||||
import ru.fromchat.back
|
import ru.fromchat.back
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.chat.Avatar
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -77,7 +87,14 @@ fun ChatScreen(
|
|||||||
panel: ChatPanel,
|
panel: ChatPanel,
|
||||||
currentUserId: Int?,
|
currentUserId: Int?,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
scrollToMessageId: Int? = null
|
scrollToMessageId: Int? = null,
|
||||||
|
onTitleClick: (() -> Unit)? = null,
|
||||||
|
hideTitleBarAvatar: Boolean = false,
|
||||||
|
onAvatarSlotBounds: ((Rect) -> Unit)? = null,
|
||||||
|
onTitleAvatarChange: ((AvatarInfo?) -> Unit)? = null,
|
||||||
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
|
sharedAvatarKey: Any? = null
|
||||||
) {
|
) {
|
||||||
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
|
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
|
||||||
|
|
||||||
@@ -98,10 +115,15 @@ fun ChatScreen(
|
|||||||
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
|
Logger.d("ChatScreen", "Messages count changed: ${panelState.messages.size}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(panelState.titleAvatar) {
|
||||||
|
onTitleAvatarChange?.invoke(panelState.titleAvatar)
|
||||||
|
}
|
||||||
|
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
|
val profileUserId = panelState.profileUserId
|
||||||
val hazeState = rememberHazeState(blurEnabled = true)
|
val hazeState = rememberHazeState(blurEnabled = true)
|
||||||
|
|
||||||
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
|
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
|
||||||
@@ -196,12 +218,26 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll to bottom when new messages arrive
|
// Scroll to bottom when new messages arrive.
|
||||||
|
// - Initial composition: jump (no animation) to avoid jank.
|
||||||
|
// - Subsequent messages: only auto-scroll if user is already near bottom.
|
||||||
|
var didInitialScroll by remember(panel) { mutableStateOf(false) }
|
||||||
LaunchedEffect(panelState.messages.size) {
|
LaunchedEffect(panelState.messages.size) {
|
||||||
if (panelState.messages.isNotEmpty()) {
|
if (panelState.messages.isEmpty()) return@LaunchedEffect
|
||||||
scope.launch {
|
|
||||||
listState.animateScrollToItem(panelState.messages.size - 1)
|
val lastMessageIndex = panelState.messages.size // account for top spacer item at index 0
|
||||||
|
|
||||||
|
if (!didInitialScroll) {
|
||||||
|
didInitialScroll = true
|
||||||
|
listState.scrollToItem(lastMessageIndex)
|
||||||
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||||
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
|
val isNearBottom = lastVisibleIndex >= (totalItems - 3)
|
||||||
|
if (isNearBottom) {
|
||||||
|
listState.animateScrollToItem(lastMessageIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,9 +246,61 @@ fun ChatScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = {
|
title = {
|
||||||
|
val titleInteractionSource = remember { MutableInteractionSource() }
|
||||||
|
var titlePressed by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(titleInteractionSource) {
|
||||||
|
titleInteractionSource.interactions.collect { interaction ->
|
||||||
|
when (interaction) {
|
||||||
|
is PressInteraction.Press -> titlePressed = true
|
||||||
|
is PressInteraction.Release -> titlePressed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val titleScale by animateFloatAsState(
|
||||||
|
targetValue = if (titlePressed) 0.96f else 1f,
|
||||||
|
animationSpec = tween(durationMillis = 100),
|
||||||
|
label = "title_scale"
|
||||||
|
)
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.graphicsLayer(
|
||||||
|
scaleX = titleScale,
|
||||||
|
scaleY = titleScale,
|
||||||
|
transformOrigin = TransformOrigin.Center
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
if (profileUserId != null && onTitleClick != null) {
|
||||||
|
Modifier.clickable(
|
||||||
|
interactionSource = titleInteractionSource,
|
||||||
|
indication = null
|
||||||
|
) { onTitleClick() }
|
||||||
|
} else Modifier
|
||||||
|
),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
|
when {
|
||||||
|
sharedAvatarKey != null && sharedTransitionScope != null && animatedVisibilityScope != null -> {
|
||||||
|
val avatar = panelState.titleAvatar
|
||||||
|
val displayName = avatar?.displayName?.takeIf { it.isNotBlank() }
|
||||||
|
?: panelState.title.takeIf { it.isNotBlank() }
|
||||||
|
?: "?"
|
||||||
|
with(sharedTransitionScope) {
|
||||||
|
Avatar(
|
||||||
|
profilePictureUrl = avatar?.profilePictureUrl,
|
||||||
|
displayName = displayName,
|
||||||
|
size = 36.dp,
|
||||||
|
modifier = Modifier
|
||||||
|
.sharedElement(
|
||||||
|
rememberSharedContentState(key = sharedAvatarKey),
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope
|
||||||
|
)
|
||||||
|
.size(36.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
!hideTitleBarAvatar -> {
|
||||||
panelState.titleAvatar?.let { avatar ->
|
panelState.titleAvatar?.let { avatar ->
|
||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = avatar.profilePictureUrl,
|
profilePictureUrl = avatar.profilePictureUrl,
|
||||||
@@ -221,6 +309,33 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
onAvatarSlotBounds != null -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(36.dp)
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
val pos = coords.positionInRoot()
|
||||||
|
val sz = coords.size
|
||||||
|
onAvatarSlotBounds(
|
||||||
|
Rect(
|
||||||
|
pos.x,
|
||||||
|
pos.y,
|
||||||
|
pos.x + sz.width.toFloat(),
|
||||||
|
pos.y + sz.height.toFloat()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
panelState.titleAvatar?.let {
|
||||||
|
Spacer(modifier = Modifier.width(36.dp))
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxWidth()) {
|
Column(Modifier.fillMaxWidth()) {
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package ru.fromchat.ui.dm
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedContent
|
||||||
|
import androidx.compose.animation.SharedTransitionLayout
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.togetherWith
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import ru.fromchat.ui.BackHandler
|
||||||
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
|
|
||||||
|
private const val TRANSITION_MS = 320
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DmContainerScreen(
|
||||||
|
otherUserId: Int,
|
||||||
|
onBack: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var showProfile by remember { mutableStateOf(false) }
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val panel = remember(otherUserId) {
|
||||||
|
DmPanelCache.getOrCreate(otherUserId, scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
BackHandler(enabled = showProfile) {
|
||||||
|
showProfile = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// One stable key per DM to match the same avatar in chat + profile.
|
||||||
|
val sharedAvatarKey = remember(otherUserId) { "dm-avatar-$otherUserId" }
|
||||||
|
|
||||||
|
SharedTransitionLayout(modifier = modifier.fillMaxSize()) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = showProfile,
|
||||||
|
transitionSpec = {
|
||||||
|
fadeIn(animationSpec = tween(TRANSITION_MS)) togetherWith
|
||||||
|
fadeOut(animationSpec = tween(TRANSITION_MS))
|
||||||
|
},
|
||||||
|
label = "dm_profile"
|
||||||
|
) { targetState ->
|
||||||
|
if (!targetState) {
|
||||||
|
DmScreen(
|
||||||
|
panel = panel,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onTitleClick = { showProfile = true },
|
||||||
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
|
animatedVisibilityScope = this@AnimatedContent,
|
||||||
|
sharedAvatarKey = sharedAvatarKey
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ProfileScreen(
|
||||||
|
userId = otherUserId,
|
||||||
|
onBack = { showProfile = false },
|
||||||
|
onChat = { _ -> showProfile = false },
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
|
animatedVisibilityScope = this@AnimatedContent,
|
||||||
|
sharedAvatarKey = sharedAvatarKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,8 +38,8 @@ class DmPanel(
|
|||||||
private var otherProfilePicture: String? = null
|
private var otherProfilePicture: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
updateState { it.copy(title = "Direct message") }
|
updateState { it.copy(title = "Direct message", profileUserId = otherUserId) }
|
||||||
coroutineScope.launch(Dispatchers.IO) {
|
coroutineScope.launch(Dispatchers.Default) {
|
||||||
runCatching {
|
runCatching {
|
||||||
ApiClient.getProfileById(otherUserId)
|
ApiClient.getProfileById(otherUserId)
|
||||||
}.onSuccess { profile ->
|
}.onSuccess { profile ->
|
||||||
@@ -52,7 +52,8 @@ class DmPanel(
|
|||||||
titleAvatar = AvatarInfo(
|
titleAvatar = AvatarInfo(
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
profilePictureUrl = otherProfilePicture
|
profilePictureUrl = otherProfilePicture
|
||||||
)
|
),
|
||||||
|
profileUserId = otherUserId
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -105,7 +106,7 @@ class DmPanel(
|
|||||||
json.decodeFromJsonElement(DmEnvelope.serializer(), element)
|
json.decodeFromJsonElement(DmEnvelope.serializer(), element)
|
||||||
}.getOrNull() ?: return
|
}.getOrNull() ?: return
|
||||||
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
|
if (envelope.senderId != otherUserId && envelope.recipientId != otherUserId) return
|
||||||
scope.launch(Dispatchers.IO) {
|
scope.launch(Dispatchers.Default) {
|
||||||
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
|
val plaintext = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
|
||||||
if (plaintext != null) {
|
if (plaintext != null) {
|
||||||
addMessage(createMessage(envelope, plaintext))
|
addMessage(createMessage(envelope, plaintext))
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package ru.fromchat.ui.dm
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache of DM panels by otherUserId so that when navigating back from profile
|
||||||
|
* we reuse the same panel and don't reload messages.
|
||||||
|
*/
|
||||||
|
object DmPanelCache {
|
||||||
|
private val panels = mutableMapOf<Int, DmPanel>()
|
||||||
|
|
||||||
|
fun getOrCreate(
|
||||||
|
otherUserId: Int,
|
||||||
|
scope: CoroutineScope
|
||||||
|
): DmPanel {
|
||||||
|
return panels.getOrPut(otherUserId) {
|
||||||
|
DmPanel(
|
||||||
|
otherUserId = otherUserId,
|
||||||
|
coroutineScope = scope,
|
||||||
|
currentUserId = ApiClient.user?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(otherUserId: Int) {
|
||||||
|
panels.remove(otherUserId)?.destroy()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,32 +1,31 @@
|
|||||||
package ru.fromchat.ui.dm
|
package ru.fromchat.ui.dm
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import kotlinx.coroutines.launch
|
import androidx.compose.ui.geometry.Rect
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.ui.chat.ChatScreen
|
import ru.fromchat.ui.chat.ChatScreen
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DmScreen(
|
fun DmScreen(
|
||||||
otherUserId: Int,
|
panel: DmPanel,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
onTitleClick: (() -> Unit)? = null,
|
||||||
|
hideTitleBarAvatar: Boolean = false,
|
||||||
|
onAvatarSlotBounds: ((Rect) -> Unit)? = null,
|
||||||
|
onTitleAvatarChange: ((ru.fromchat.ui.chat.AvatarInfo?) -> Unit)? = null,
|
||||||
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
|
sharedAvatarKey: Any? = null
|
||||||
) {
|
) {
|
||||||
val coroutineScope = rememberCoroutineScope()
|
|
||||||
val currentUserId = ApiClient.user?.id
|
val currentUserId = ApiClient.user?.id
|
||||||
val panel = remember(otherUserId) {
|
|
||||||
DmPanel(
|
|
||||||
otherUserId = otherUserId,
|
|
||||||
coroutineScope = coroutineScope,
|
|
||||||
currentUserId = currentUserId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(otherUserId) {
|
LaunchedEffect(panel) {
|
||||||
coroutineScope.launch {
|
if (panel.getState().messages.isEmpty()) {
|
||||||
panel.loadMessages()
|
panel.loadMessages()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -34,6 +33,13 @@ fun DmScreen(
|
|||||||
ChatScreen(
|
ChatScreen(
|
||||||
panel = panel,
|
panel = panel,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
modifier = modifier.fillMaxSize()
|
modifier = modifier.fillMaxSize(),
|
||||||
|
onTitleClick = onTitleClick,
|
||||||
|
hideTitleBarAvatar = hideTitleBarAvatar,
|
||||||
|
onAvatarSlotBounds = onAvatarSlotBounds,
|
||||||
|
onTitleAvatarChange = onTitleAvatarChange,
|
||||||
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
|
sharedAvatarKey = sharedAvatarKey
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package ru.fromchat.ui.profile
|
package ru.fromchat.ui.profile
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -10,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
@@ -22,7 +23,6 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -33,7 +33,10 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.ClipboardManager
|
import androidx.compose.ui.platform.ClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
@@ -41,6 +44,7 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.UserProfile
|
import ru.fromchat.api.UserProfile
|
||||||
|
import ru.fromchat.ui.chat.Avatar
|
||||||
|
|
||||||
private data class ProfileUiState(
|
private data class ProfileUiState(
|
||||||
val profile: UserProfile? = null,
|
val profile: UserProfile? = null,
|
||||||
@@ -54,7 +58,13 @@ private data class ProfileUiState(
|
|||||||
fun ProfileScreen(
|
fun ProfileScreen(
|
||||||
userId: Int?,
|
userId: Int?,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onChat: (Int) -> Unit
|
onChat: (Int) -> Unit,
|
||||||
|
hideAvatar: Boolean = false,
|
||||||
|
onAvatarSlotBounds: ((Rect) -> Unit)? = null,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
|
sharedAvatarKey: Any? = null
|
||||||
) {
|
) {
|
||||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||||
var state by remember { mutableStateOf(ProfileUiState()) }
|
var state by remember { mutableStateOf(ProfileUiState()) }
|
||||||
@@ -78,6 +88,7 @@ fun ProfileScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(text = "Profile") },
|
title = { Text(text = "Profile") },
|
||||||
@@ -96,49 +107,88 @@ fun ProfileScreen(
|
|||||||
.padding(16.dp)
|
.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
val errorMessage = state.error
|
val errorMessage = state.error
|
||||||
when {
|
val profile = state.profile
|
||||||
state.isLoading -> {
|
val displayName = profile?.displayName?.takeIf { it.isNotBlank() }
|
||||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
?: profile?.username?.takeIf { it.isNotBlank() }
|
||||||
}
|
?: "?"
|
||||||
errorMessage != null -> {
|
|
||||||
Text(
|
val useSharedAvatar = sharedTransitionScope != null &&
|
||||||
text = errorMessage,
|
animatedVisibilityScope != null &&
|
||||||
color = MaterialTheme.colorScheme.error,
|
sharedAvatarKey != null
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
state.profile != null -> {
|
|
||||||
val profile = state.profile!!
|
|
||||||
val profileLink = profile.username.takeIf { it.isNotBlank() }
|
|
||||||
?.let { "https://fromchat.ru/@$it" }
|
|
||||||
?: "https://fromchat.ru/?u=${profile.id}"
|
|
||||||
val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username
|
|
||||||
val initials = profile.displayName?.firstOrNull()
|
|
||||||
?: profile.username.firstOrNull()
|
|
||||||
?: '?'
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.Top
|
verticalArrangement = Arrangement.Top
|
||||||
) {
|
) {
|
||||||
Surface(
|
when {
|
||||||
|
useSharedAvatar -> {
|
||||||
|
with(sharedTransitionScope!!) {
|
||||||
|
Avatar(
|
||||||
|
profilePictureUrl = profile?.profilePicture,
|
||||||
|
displayName = displayName,
|
||||||
|
size = 128.dp,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(128.dp)
|
.padding(top = 16.dp)
|
||||||
.padding(top = 16.dp),
|
.sharedElement(
|
||||||
shape = CircleShape,
|
rememberSharedContentState(key = sharedAvatarKey!!),
|
||||||
color = MaterialTheme.colorScheme.primaryContainer
|
animatedVisibilityScope = animatedVisibilityScope!!
|
||||||
) {
|
|
||||||
Box(contentAlignment = Alignment.Center) {
|
|
||||||
Text(
|
|
||||||
text = initials.toString(),
|
|
||||||
style = MaterialTheme.typography.headlineLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
)
|
)
|
||||||
|
.size(128.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
!hideAvatar -> {
|
||||||
|
Avatar(
|
||||||
|
profilePictureUrl = profile?.profilePicture,
|
||||||
|
displayName = displayName,
|
||||||
|
size = 128.dp,
|
||||||
|
modifier = Modifier.padding(top = 16.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
onAvatarSlotBounds != null -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(top = 16.dp)
|
||||||
|
.size(128.dp)
|
||||||
|
.onGloballyPositioned { coords ->
|
||||||
|
val pos = coords.positionInRoot()
|
||||||
|
val sz = coords.size
|
||||||
|
onAvatarSlotBounds(
|
||||||
|
Rect(
|
||||||
|
pos.x,
|
||||||
|
pos.y,
|
||||||
|
pos.x + sz.width.toFloat(),
|
||||||
|
pos.y + sz.height.toFloat()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Spacer(modifier = Modifier.height(16.dp + 128.dp + 12.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
when {
|
||||||
|
state.isLoading -> {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
|
||||||
|
}
|
||||||
|
errorMessage != null -> {
|
||||||
|
Text(
|
||||||
|
text = errorMessage,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.padding(top = 24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
profile != null -> {
|
||||||
|
val profileLink = profile.username.takeIf { it.isNotBlank() }
|
||||||
|
?.let { "https://fromchat.ru/@$it" }
|
||||||
|
?: "https://fromchat.ru/?u=${profile.id}"
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = displayName,
|
text = displayName,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package ru.fromchat.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun BackHandler(enabled: Boolean, onBack: () -> Unit) {
|
||||||
|
// iOS has no system back button; back is handled by navigation.
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user