Implement online & typing statuses in DMs, fix shared element transition

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-02-11 17:03:02 +03:00
Unverified
parent e10f19d1e1
commit 56054aae8c
18 changed files with 586 additions and 73 deletions
+6
View File
@@ -32,6 +32,12 @@
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="LocalVariableName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="MultiplatformPreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="MultiplatformPreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="NestedLambdaShadowedImplicitParameter" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
@@ -176,6 +176,24 @@ object ApiClient {
}
.body()
suspend fun checkSimilarity(userId: Int): SimilarityResult? =
runCatching {
http
.get("${Config.apiBaseUrl}/user/check-similarity/$userId") {
contentType(ContentType.Application.Json)
}
.body<SimilarityResult>()
}.getOrNull()
suspend fun verifyUser(userId: Int): VerifyResponse? =
runCatching {
http
.post("${Config.apiBaseUrl}/user/$userId/verify") {
contentType(ContentType.Application.Json)
}
.body<VerifyResponse>()
}.getOrNull()
suspend fun getDmConversations(): List<DmConversation> =
http
.get("${Config.apiBaseUrl}/dm/conversations") {
@@ -429,4 +447,64 @@ object ApiClient {
)
}
}
suspend fun sendDmTyping(recipientId: Int) {
runCatching {
WebSocketManager.send(
WebSocketMessage(
type = "dmTyping",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = getTokenSafely()
),
data = json.encodeToJsonElement(DmTypingData(recipientId = recipientId))
)
)
}
}
suspend fun sendStopDmTyping(recipientId: Int) {
runCatching {
WebSocketManager.send(
WebSocketMessage(
type = "stopDmTyping",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = getTokenSafely()
),
data = json.encodeToJsonElement(DmTypingData(recipientId = recipientId))
)
)
}
}
suspend fun sendSubscribeStatus(userId: Int) {
runCatching {
WebSocketManager.send(
WebSocketMessage(
type = "subscribeStatus",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = getTokenSafely()
),
data = json.encodeToJsonElement(SubscribeStatusData(userId = userId))
)
)
}
}
suspend fun sendUnsubscribeStatus(userId: Int) {
runCatching {
WebSocketManager.send(
WebSocketMessage(
type = "unsubscribeStatus",
credentials = WebSocketCredentials(
scheme = "Bearer",
credentials = getTokenSafely()
),
data = json.encodeToJsonElement(SubscribeStatusData(userId = userId))
)
)
}
}
}
@@ -269,6 +269,16 @@ data class WebSocketDeleteMessageRequest(
val message_id: Int
)
@Serializable
data class DmTypingData(
@SerialName("recipientId") val recipientId: Int
)
@Serializable
data class SubscribeStatusData(
@SerialName("userId") val userId: Int
)
@Serializable
data class BackupBlobResponse(
val blob: String?
@@ -278,3 +288,14 @@ data class BackupBlobResponse(
data class BackupBlobRequest(
val blob: String
)
@Serializable
data class SimilarityResult(
@SerialName("isSimilar") val isSimilar: Boolean,
@SerialName("similarTo") val similarTo: String? = null
)
@Serializable
data class VerifyResponse(
val verified: Boolean
)
@@ -0,0 +1,15 @@
package ru.fromchat.api
/**
* In-memory cache for user profiles. Used to show profile immediately from cache
* and reload in the background.
*/
object ProfileCache {
private val cache = mutableMapOf<Int, UserProfile>()
fun get(userId: Int): UserProfile? = cache[userId]
fun put(profile: UserProfile) {
cache[profile.id] = profile
}
}
@@ -0,0 +1,20 @@
package ru.fromchat.api
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class UserStatus(
val online: Boolean,
val lastSeen: String? = null
)
object UserStatusStore {
private val _status = MutableStateFlow<Map<Int, UserStatus>>(emptyMap())
val status: StateFlow<Map<Int, UserStatus>> = _status.asStateFlow()
fun update(userId: Int, online: Boolean, lastSeen: String?) {
_status.update { it + (userId to UserStatus(online, lastSeen)) }
}
}
@@ -1,11 +1,10 @@
package ru.fromchat.ui.chat
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -14,11 +13,15 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.drawText
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import ru.fromchat.core.config.Config
@@ -26,19 +29,17 @@ import ru.fromchat.core.config.Config
fun Avatar(
profilePictureUrl: String?,
displayName: String,
size: Dp = 32.dp,
modifier: Modifier = Modifier
) {
var imageLoadFailed by remember { mutableStateOf(false) }
val gradient = remember(displayName) { generateGradientFromName(displayName) }
val initials = remember(displayName) { getInitials(displayName) }
val textMeasurer = rememberTextMeasurer()
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(gradient),
.clip(CircleShape),
contentAlignment = Alignment.Center
) {
if (profilePictureUrl != null && !imageLoadFailed) {
@@ -52,29 +53,50 @@ fun Avatar(
model = fullUrl,
contentDescription = displayName,
modifier = Modifier
.size(size)
.fillMaxSize()
.clip(CircleShape),
contentScale = ContentScale.Crop,
onError = {
imageLoadFailed = true
},
onSuccess = {
imageLoadFailed = false
}
onError = { imageLoadFailed = true },
onSuccess = { imageLoadFailed = false }
)
}
// Fallback initials
if (imageLoadFailed || profilePictureUrl == null) {
Text(
text = initials,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White,
modifier = Modifier
Canvas(modifier = Modifier.fillMaxSize()) {
val radius = size.minDimension / 2f
// Background gradient circle
drawCircle(
brush = gradient,
radius = radius,
center = center
)
// Letters sized relative to radius
val fontPx = radius * 0.7f
val fontSp = (fontPx / density).sp
val textLayout = textMeasurer.measure(
text = initials,
style = TextStyle(
color = Color.White,
fontSize = fontSp,
fontWeight = FontWeight.SemiBold
)
)
val textWidth = textLayout.size.width.toFloat()
val textHeight = textLayout.size.height.toFloat()
val topLeft = Offset(
x = (size.width - textWidth) / 2f,
y = (size.height - textHeight) / 2f
)
drawText(
textLayoutResult = textLayout,
topLeft = topLeft
)
}
}
}
}
@@ -38,6 +38,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -63,10 +64,13 @@ import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.launch
import kotlinx.serialization.json.decodeFromJsonElement
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.Message
import ru.fromchat.api.UserStatusStore
import ru.fromchat.api.WebSocketManager
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.api.WebSocketUpdatesData
@@ -76,6 +80,7 @@ import ru.fromchat.ui.HapticFeedbackEvent
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.rememberHapticFeedback
import ru.fromchat.ui.scaleOnPress
import ru.fromchat.utils.formatLastSeen
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
@Composable
@@ -124,10 +129,24 @@ fun ChatScreen(
val hazeState = rememberHazeState(blurEnabled = true)
val currentTypingUsers = panelState.typingUsers // Directly use from panelState
val statusMap by UserStatusStore.status.collectAsState()
LaunchedEffect(currentTypingUsers) {
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
}
// Subscribe to other user's status when DM is visible; unsubscribe on leave
LaunchedEffect(panelState.profileUserId) {
val userId = panelState.profileUserId
if (userId != null) {
runCatching { ApiClient.sendSubscribeStatus(userId) }
try {
kotlinx.coroutines.awaitCancellation()
} finally {
runCatching { ApiClient.sendUnsubscribeStatus(userId) }
}
}
}
// Scroll to specific message when requested (e.g., from notification click)
LaunchedEffect(scrollToMessageId, panelState.messages) {
scrollToMessageId?.let { messageId ->
@@ -184,9 +203,15 @@ fun ChatScreen(
data = update.data
)
when (update.type) {
"statusUpdate" -> update.data?.jsonObject?.let { data ->
val userId = data["userId"]?.jsonPrimitive?.content?.toIntOrNull()
val online = data["online"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false
val lastSeen = data["lastSeen"]?.jsonPrimitive?.content
if (userId != null) UserStatusStore.update(userId, online, lastSeen)
}
"newMessage", "messageEdited", "messageDeleted",
"dmNew", "dmEdited", "dmDeleted",
"typing", "stopTyping", "statusUpdate", "suspended", "account_deleted" -> {
"typing", "stopTyping", "dmTyping", "stopDmTyping", "suspended", "account_deleted" -> {
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
scope.launch {
try {
@@ -203,7 +228,14 @@ fun ChatScreen(
e.printStackTrace()
}
}
"newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted" -> {
"statusUpdate" -> message.data?.jsonObject?.let { data ->
val userId = data["userId"]?.jsonPrimitive?.content?.toIntOrNull()
val online = data["online"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false
val lastSeen = data["lastSeen"]?.jsonPrimitive?.content
if (userId != null) UserStatusStore.update(userId, online, lastSeen)
}
"newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted",
"dmTyping", "stopDmTyping" -> {
scope.launch {
panel.handleWebSocketMessage(message)
}
@@ -264,7 +296,6 @@ fun ChatScreen(
Avatar(
profilePictureUrl = avatar?.profilePictureUrl,
displayName = displayName,
size = 36.dp,
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = sharedAvatarKey),
@@ -280,7 +311,7 @@ fun ChatScreen(
Avatar(
profilePictureUrl = avatar.profilePictureUrl,
displayName = avatar.displayName,
size = 36.dp
modifier = Modifier.size(36.dp)
)
Spacer(modifier = Modifier.width(8.dp))
}
@@ -330,6 +361,19 @@ fun ChatScreen(
typingUsers = currentTypingUsers.map { it.username },
modifier = Modifier.padding(top = 2.dp)
)
} else if (panelState.profileUserId != null) {
val userStatus = statusMap[panelState.profileUserId]
if (userStatus != null) {
val statusText = formatLastSeen(userStatus.online, userStatus.lastSeen)
if (statusText.isNotEmpty()) {
Text(
text = statusText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp)
)
}
}
}
}
}
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
@@ -83,7 +84,7 @@ fun MessageItem(
Avatar(
profilePictureUrl = message.profile_picture,
displayName = message.username,
size = 32.dp
modifier = Modifier.size(32.dp)
)
Spacer(modifier = Modifier.width(8.dp))
@@ -87,3 +87,50 @@ class PublicChatTypingHandler(
}
}
/**
* Typing handler for DM: sends dmTyping/stopDmTyping with recipientId.
*/
class DmTypingHandler(
private val scope: CoroutineScope,
private val recipientId: Int
) : TypingHandler {
private var stopTypingJob: Job? = null
private val _typingUsers = MutableStateFlow<List<TypingUser>>(emptyList())
override val typingUsers = _typingUsers.asStateFlow()
override fun sendTyping() {
scope.launch {
runCatching { ApiClient.sendDmTyping(recipientId) }
}
stopTypingJob?.cancel()
stopTypingJob = scope.launch {
delay(3.seconds)
stopTyping()
}
}
override fun stopTyping() {
stopTypingJob?.cancel()
stopTypingJob = null
scope.launch {
runCatching { ApiClient.sendStopDmTyping(recipientId) }
}
}
override fun handleTypingEvent(userId: Int, username: String) {
_typingUsers.update { currentUsers ->
if (currentUsers.none { it.userId == userId }) {
currentUsers + TypingUser(userId, username)
} else {
currentUsers
}
}
}
override fun handleStopTypingEvent(userId: Int) {
_typingUsers.update { currentUsers ->
currentUsers.filter { it.userId != userId }
}
}
}
@@ -8,10 +8,11 @@ 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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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
@@ -33,6 +34,15 @@ fun DmContainerScreen(
val panel = remember(otherUserId) {
DmPanelCache.getOrCreate(otherUserId, scope)
}
var panelState by remember(panel) { mutableStateOf(panel.getState()) }
LaunchedEffect(panel) {
panel.setOnStateChange { panelState = it }
panelState = panel.getState()
}
val initialDisplayName = panelState.titleAvatar?.displayName?.takeIf { it.isNotBlank() }
?: panelState.title.takeIf { it.isNotBlank() }
BackHandler(enabled = showProfile) {
haptic(HapticFeedbackEvent.ProfileClosed)
@@ -77,7 +87,8 @@ fun DmContainerScreen(
modifier = Modifier.fillMaxSize(),
sharedTransitionScope = this@SharedTransitionLayout,
animatedVisibilityScope = this@AnimatedContent,
sharedAvatarKey = sharedAvatarKey
sharedAvatarKey = sharedAvatarKey,
initialDisplayName = initialDisplayName
)
}
}
@@ -2,9 +2,6 @@ package ru.fromchat.ui.dm
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
@@ -14,11 +11,13 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.decodeFromJsonElement
import ru.fromchat.api.ApiClient
import ru.fromchat.api.DmEnvelope
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.Message
import ru.fromchat.api.WebSocketMessage
import ru.fromchat.core.Logger
import ru.fromchat.crypto.decryptEnvelope
import ru.fromchat.ui.chat.ChatPanel
import ru.fromchat.ui.chat.DmTypingHandler
import ru.fromchat.ui.chat.TypingHandler
import ru.fromchat.ui.chat.TypingUser
import ru.fromchat.ui.chat.AvatarInfo
@@ -32,17 +31,23 @@ class DmPanel(
currentUserId = currentUserId,
scope = coroutineScope
) {
private val typingHandler = NoopTypingHandler()
private val typingHandler = DmTypingHandler(coroutineScope, otherUserId)
private val json = Json { ignoreUnknownKeys = true }
private var otherDisplayName: String = "User $otherUserId"
private var otherProfilePicture: String? = null
init {
updateState { it.copy(title = "Direct message", profileUserId = otherUserId) }
coroutineScope.launch {
typingHandler.typingUsers.collect { users ->
updateState { it.copy(typingUsers = users) }
}
}
coroutineScope.launch(Dispatchers.Default) {
runCatching {
ApiClient.getProfileById(otherUserId)
}.onSuccess { profile ->
ProfileCache.put(profile)
val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username
otherDisplayName = displayName
otherProfilePicture = profile.profilePicture
@@ -89,12 +94,36 @@ class DmPanel(
override suspend fun handleWebSocketMessage(message: WebSocketMessage) {
when (message.type) {
"dmNew" -> message.data?.let { processEnvelope(it) }
"dmTyping" -> message.data?.let { data ->
val obj = data.jsonObject
val userId = obj["userId"]?.jsonPrimitive?.content?.toIntOrNull()
val username = obj["username"]?.jsonPrimitive?.content ?: ""
if (userId != null) typingHandler.handleTypingEvent(userId, username)
}
"stopDmTyping" -> message.data?.let { data ->
val obj = data.jsonObject
val userId = obj["userId"]?.jsonPrimitive?.content?.toIntOrNull()
if (userId != null) typingHandler.handleStopTypingEvent(userId)
}
"updates" -> {
val updates = message.data?.jsonObject?.get("updates")?.jsonArray ?: return
for (update in updates) {
val obj = update.jsonObject
if (obj["type"]?.jsonPrimitive?.content == "dmNew") {
obj["data"]?.let { processEnvelope(it) }
val type = obj["type"]?.jsonPrimitive?.content
when (type) {
"dmNew" -> obj["data"]?.let { processEnvelope(it) }
"dmTyping" -> obj["data"]?.let { data ->
val dataObj = data.jsonObject
val userId = dataObj["userId"]?.jsonPrimitive?.content?.toIntOrNull()
val username = dataObj["username"]?.jsonPrimitive?.content ?: ""
if (userId != null) typingHandler.handleTypingEvent(userId, username)
}
"stopDmTyping" -> obj["data"]?.let { data ->
val dataObj = data.jsonObject
val userId = dataObj["userId"]?.jsonPrimitive?.content?.toIntOrNull()
if (userId != null) typingHandler.handleStopTypingEvent(userId)
}
else -> {}
}
}
}
@@ -147,12 +176,3 @@ class DmPanel(
override val showUsernamesInMessages: Boolean
get() = false
}
private class NoopTypingHandler : TypingHandler {
private val _typingUsers = MutableStateFlow<List<TypingUser>>(emptyList())
override val typingUsers: StateFlow<List<TypingUser>> = _typingUsers.asStateFlow()
override fun sendTyping() {}
override fun stopTyping() {}
override fun handleTypingEvent(userId: Int, username: String) {}
override fun handleStopTypingEvent(userId: Int) {}
}
@@ -24,6 +24,7 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CalendarMonth
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
@@ -42,6 +43,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -56,7 +58,11 @@ import androidx.compose.ui.unit.dp
import com.pr0gramm3r101.components.Category
import com.pr0gramm3r101.components.CategoryDefaults
import com.pr0gramm3r101.components.ListItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
import ru.fromchat.api.ProfileCache
import ru.fromchat.api.UserProfile
import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.scaleOnPress
@@ -79,16 +85,25 @@ fun ProfileScreen(
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope? = null,
animatedVisibilityScope: AnimatedVisibilityScope? = null,
sharedAvatarKey: Any? = null
sharedAvatarKey: Any? = null,
initialDisplayName: String? = null
) {
val clipboardManager: ClipboardManager = LocalClipboardManager.current
var state by remember { mutableStateOf(ProfileUiState()) }
val targetUserId = userId.takeIf { it != null && it > 0 }
val fetchKey = targetUserId ?: 0
var state by remember(fetchKey) {
val cached = targetUserId?.let { ProfileCache.get(it) }
mutableStateOf(
ProfileUiState(
profile = cached,
isLoading = cached == null,
error = null
)
)
}
LaunchedEffect(fetchKey) {
state = state.copy(isLoading = true, error = null)
runCatching {
if (targetUserId == null) {
ApiClient.getOwnProfile()
@@ -96,9 +111,13 @@ fun ProfileScreen(
ApiClient.getProfileById(targetUserId)
}
}.onSuccess { profile ->
state = state.copy(profile = profile, isLoading = false)
ProfileCache.put(profile)
state = state.copy(profile = profile, isLoading = false, error = null)
}.onFailure {
state = state.copy(error = it.message ?: "Unable to load profile", isLoading = false)
state = state.copy(
error = if (state.profile == null) it.message ?: "Unable to load profile" else null,
isLoading = false
)
}
}
@@ -136,6 +155,7 @@ fun ProfileScreen(
val profile = state.profile
val displayName = profile?.displayName?.takeIf { it.isNotBlank() }
?: profile?.username?.takeIf { it.isNotBlank() }
?: initialDisplayName?.takeIf { it.isNotBlank() }
?: "?"
val useSharedAvatar = sharedTransitionScope != null &&
@@ -151,11 +171,10 @@ fun ProfileScreen(
) {
when {
useSharedAvatar -> {
with(sharedTransitionScope!!) {
with(sharedTransitionScope) {
Avatar(
profilePictureUrl = profile?.profilePicture,
displayName = displayName,
size = 128.dp,
modifier = Modifier
.padding(top = 16.dp)
.sharedElement(
@@ -171,8 +190,9 @@ fun ProfileScreen(
Avatar(
profilePictureUrl = profile?.profilePicture,
displayName = displayName,
size = 128.dp,
modifier = Modifier.padding(top = 16.dp)
modifier = Modifier
.padding(top = 16.dp)
.size(128.dp)
)
Spacer(modifier = Modifier.height(12.dp))
}
@@ -216,11 +236,23 @@ fun ProfileScreen(
val profileLink = profile.username.takeIf { it.isNotBlank() }
?.let { "https://fromchat.ru/@$it" }
?: "https://fromchat.ru/?u=${profile.id}"
val scope = rememberCoroutineScope()
val verificationLabel = if (profile.verified == true) "Verified account" else "Click to verify"
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = displayName,
style = MaterialTheme.typography.titleLarge
)
StatusBadge(
verified = profile.verified,
userId = profile.id
)
}
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "@${profile.username}",
@@ -377,6 +409,36 @@ fun ProfileScreen(
}
}
)
if (profile.verified == true || ApiClient.user?.id == 1) {
val onVerifyToggle: () -> Unit = {
scope.launch {
val result = withContext(Dispatchers.Default) {
runCatching { ApiClient.verifyUser(profile.id) }.getOrNull()
}
result?.verified?.let { newVerified ->
val updated = state.profile?.copy(verified = newVerified)
state = state.copy(profile = updated)
updated?.let { ProfileCache.put(it) }
}
}
}
ListItem(
headline = "Verification",
supportingText = verificationLabel,
leadingContent = {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = null
)
},
divider = true,
dividerColor = CategoryDefaults.dividerColor,
dividerThickness = CategoryDefaults.dividerThickness,
onClick = if (ApiClient.user?.id == 1) onVerifyToggle else null
)
}
}
}
}
@@ -0,0 +1,68 @@
package ru.fromchat.ui.profile
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
@Composable
fun StatusBadge(
verified: Boolean?,
userId: Int?,
modifier: Modifier = Modifier,
size: Dp = 20.dp
) {
var isSimilarToVerified by remember(userId, verified) { mutableStateOf(false) }
LaunchedEffect(verified, userId, ApiClient.token) {
if (verified == true) {
isSimilarToVerified = false
return@LaunchedEffect
}
if (userId == null || ApiClient.token == null) {
isSimilarToVerified = false
return@LaunchedEffect
}
val result = withContext(Dispatchers.Default) {
runCatching { ApiClient.checkSimilarity(userId) }.getOrNull()
}
isSimilarToVerified = result?.isSimilar == true
}
when {
verified == true -> Icon(
imageVector = Icons.Filled.Verified,
contentDescription = "Verified account",
modifier = modifier
.size(size)
.semantics { contentDescription = "Verified account" },
tint = MaterialTheme.colorScheme.primary
)
isSimilarToVerified -> Icon(
imageVector = Icons.Filled.Warning,
contentDescription = "Similar to verified account",
modifier = modifier
.size(size)
.semantics { contentDescription = "Similar to verified account" },
tint = Color(0xFFFFA000)
)
else -> {}
}
}
@@ -0,0 +1,60 @@
package ru.fromchat.ui.profile
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.foundation.layout.size
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient
@Composable
fun VerifyButton(
userId: Int,
verified: Boolean,
onVerificationChange: ((Boolean) -> Unit)? = null,
modifier: Modifier = Modifier
) {
if (ApiClient.user?.id != 1) return
var isVerifying by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Button(
onClick = {
if (ApiClient.token == null || isVerifying) return@Button
isVerifying = true
scope.launch {
val result = withContext(Dispatchers.Default) {
runCatching { ApiClient.verifyUser(userId) }.getOrNull()
}
isVerifying = false
result?.verified?.let { onVerificationChange?.invoke(it) }
}
},
modifier = modifier,
enabled = !isVerifying
) {
if (isVerifying) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp,
color = MaterialTheme.colorScheme.onPrimary
)
} else {
Text(
text = if (verified) "Unverify" else "Verify"
)
}
}
}
@@ -0,0 +1,35 @@
package ru.fromchat.utils
import kotlinx.datetime.Instant
import kotlin.time.Clock
/**
* Returns a user-friendly string for online status / last seen from an ISO-8601 timestamp.
* E.g. "Online", "Last seen just now", "Last seen 5 min ago", "Last seen yesterday".
*/
fun formatLastSeen(online: Boolean, lastSeenIso: String?): String {
if (online) return "Online"
val iso = lastSeenIso ?: return ""
val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return "Last seen $iso"
val nowSeconds = Clock.System.now().toEpochMilliseconds() / 1000L
val pastSeconds = instant.epochSeconds
val diffSeconds = nowSeconds - pastSeconds
val diffMinutes = diffSeconds / 60L
val diffHours = diffSeconds / 3600L
val diffDays = diffSeconds / 86400L
val relative = when {
diffMinutes < 1L -> "just now"
diffMinutes < 60L -> if (diffMinutes == 1L) "1 min ago" else "$diffMinutes min ago"
diffHours < 24L -> if (diffHours == 1L) "1 hour ago" else "$diffHours hours ago"
diffDays < 2L -> "yesterday"
else -> {
val parts = iso.split("T").firstOrNull()?.split("-") ?: return "Last seen $iso"
if (parts.size == 3) {
val (_, m, d) = parts
val month = listOf("", "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec").getOrNull(m.toIntOrNull() ?: 0) ?: m
"$month $d"
} else "Last seen $iso"
}
}
return "Last seen $relative"
}
@@ -5,4 +5,3 @@ import ru.fromchat.ui.App
@Suppress("unused")
fun MainViewController() = ComposeUIViewController { App() }
+1 -1
View File
@@ -1,6 +1,6 @@
#Sat Sep 06 13:57:07 MSK 2025
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
@@ -1,6 +1,8 @@
package com.pr0gramm3r101.utils
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.navigation.NavController
/**
@@ -34,10 +36,12 @@ fun NavController.navigateAndWipeBackStack(route: String, launchSingleTop: Boole
inline fun Modifier.conditional(
condition: Boolean,
`else`: Modifier.(Modifier) -> Modifier = { Modifier },
`if`: Modifier.(Modifier) -> Modifier
) = if (condition) {
crossinline `else`: @Composable Modifier.(Modifier) -> Modifier = { Modifier },
crossinline `if`: @Composable Modifier.(Modifier) -> Modifier
) = composed {
if (condition) {
this + `if`(this)
} else {
} else {
this + `else`(this)
}
}