mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Implement online & typing statuses in DMs, fix shared element transition
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
+6
@@ -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(
|
||||
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 = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
style = TextStyle(
|
||||
color = Color.White,
|
||||
modifier = Modifier
|
||||
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
@@ -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 {
|
||||
this + `else`(this)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user