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" />
|
<option name="composableFile" value="true" />
|
||||||
</inspection_tool>
|
</inspection_tool>
|
||||||
<inspection_tool class="LocalVariableName" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
<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="NestedLambdaShadowedImplicitParameter" enabled="false" level="WEAK WARNING" enabled_by_default="false" />
|
||||||
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
|
||||||
<option name="composableFile" value="true" />
|
<option name="composableFile" value="true" />
|
||||||
|
|||||||
@@ -176,6 +176,24 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
.body()
|
.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> =
|
suspend fun getDmConversations(): List<DmConversation> =
|
||||||
http
|
http
|
||||||
.get("${Config.apiBaseUrl}/dm/conversations") {
|
.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
|
val message_id: Int
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class DmTypingData(
|
||||||
|
@SerialName("recipientId") val recipientId: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class SubscribeStatusData(
|
||||||
|
@SerialName("userId") val userId: Int
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class BackupBlobResponse(
|
data class BackupBlobResponse(
|
||||||
val blob: String?
|
val blob: String?
|
||||||
@@ -277,4 +287,15 @@ data class BackupBlobResponse(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class BackupBlobRequest(
|
data class BackupBlobRequest(
|
||||||
val blob: String
|
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
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
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.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -14,11 +13,15 @@ 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.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import ru.fromchat.core.config.Config
|
import ru.fromchat.core.config.Config
|
||||||
|
|
||||||
@@ -26,19 +29,17 @@ import ru.fromchat.core.config.Config
|
|||||||
fun Avatar(
|
fun Avatar(
|
||||||
profilePictureUrl: String?,
|
profilePictureUrl: String?,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
size: Dp = 32.dp,
|
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
var imageLoadFailed by remember { mutableStateOf(false) }
|
var imageLoadFailed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val gradient = remember(displayName) { generateGradientFromName(displayName) }
|
val gradient = remember(displayName) { generateGradientFromName(displayName) }
|
||||||
val initials = remember(displayName) { getInitials(displayName) }
|
val initials = remember(displayName) { getInitials(displayName) }
|
||||||
|
val textMeasurer = rememberTextMeasurer()
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.size(size)
|
.clip(CircleShape),
|
||||||
.clip(CircleShape)
|
|
||||||
.background(gradient),
|
|
||||||
contentAlignment = Alignment.Center
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
if (profilePictureUrl != null && !imageLoadFailed) {
|
if (profilePictureUrl != null && !imageLoadFailed) {
|
||||||
@@ -47,34 +48,55 @@ fun Avatar(
|
|||||||
} else {
|
} else {
|
||||||
"${Config.apiBaseUrl}$profilePictureUrl"
|
"${Config.apiBaseUrl}$profilePictureUrl"
|
||||||
}
|
}
|
||||||
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = fullUrl,
|
model = fullUrl,
|
||||||
contentDescription = displayName,
|
contentDescription = displayName,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(size)
|
.fillMaxSize()
|
||||||
.clip(CircleShape),
|
.clip(CircleShape),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
onError = {
|
onError = { imageLoadFailed = true },
|
||||||
imageLoadFailed = true
|
onSuccess = { imageLoadFailed = false }
|
||||||
},
|
|
||||||
onSuccess = {
|
|
||||||
imageLoadFailed = false
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback initials
|
|
||||||
if (imageLoadFailed || profilePictureUrl == null) {
|
if (imageLoadFailed || profilePictureUrl == null) {
|
||||||
Text(
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
text = initials,
|
val radius = size.minDimension / 2f
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
// Background gradient circle
|
||||||
fontWeight = FontWeight.SemiBold,
|
drawCircle(
|
||||||
color = Color.White,
|
brush = gradient,
|
||||||
modifier = Modifier
|
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.material3.TopAppBarDefaults
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -63,10 +64,13 @@ import dev.chrisbanes.haze.materials.HazeMaterials
|
|||||||
import dev.chrisbanes.haze.rememberHazeState
|
import dev.chrisbanes.haze.rememberHazeState
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
|
import ru.fromchat.api.UserStatusStore
|
||||||
import ru.fromchat.api.WebSocketManager
|
import ru.fromchat.api.WebSocketManager
|
||||||
import ru.fromchat.api.WebSocketMessage
|
import ru.fromchat.api.WebSocketMessage
|
||||||
import ru.fromchat.api.WebSocketUpdatesData
|
import ru.fromchat.api.WebSocketUpdatesData
|
||||||
@@ -76,6 +80,7 @@ import ru.fromchat.ui.HapticFeedbackEvent
|
|||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.rememberHapticFeedback
|
import ru.fromchat.ui.rememberHapticFeedback
|
||||||
import ru.fromchat.ui.scaleOnPress
|
import ru.fromchat.ui.scaleOnPress
|
||||||
|
import ru.fromchat.utils.formatLastSeen
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -124,10 +129,24 @@ fun ChatScreen(
|
|||||||
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
|
||||||
|
val statusMap by UserStatusStore.status.collectAsState()
|
||||||
LaunchedEffect(currentTypingUsers) {
|
LaunchedEffect(currentTypingUsers) {
|
||||||
Logger.d("ChatScreen", "currentTypingUsers updated (from panelState): ${currentTypingUsers.map { it.username }}")
|
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)
|
// Scroll to specific message when requested (e.g., from notification click)
|
||||||
LaunchedEffect(scrollToMessageId, panelState.messages) {
|
LaunchedEffect(scrollToMessageId, panelState.messages) {
|
||||||
scrollToMessageId?.let { messageId ->
|
scrollToMessageId?.let { messageId ->
|
||||||
@@ -184,9 +203,15 @@ fun ChatScreen(
|
|||||||
data = update.data
|
data = update.data
|
||||||
)
|
)
|
||||||
when (update.type) {
|
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",
|
"newMessage", "messageEdited", "messageDeleted",
|
||||||
"dmNew", "dmEdited", "dmDeleted",
|
"dmNew", "dmEdited", "dmDeleted",
|
||||||
"typing", "stopTyping", "statusUpdate", "suspended", "account_deleted" -> {
|
"typing", "stopTyping", "dmTyping", "stopDmTyping", "suspended", "account_deleted" -> {
|
||||||
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
|
Logger.d("ChatScreen", "Launching handleWebSocketMessage for ${update.type}")
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
@@ -203,7 +228,14 @@ fun ChatScreen(
|
|||||||
e.printStackTrace()
|
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 {
|
scope.launch {
|
||||||
panel.handleWebSocketMessage(message)
|
panel.handleWebSocketMessage(message)
|
||||||
}
|
}
|
||||||
@@ -264,7 +296,6 @@ fun ChatScreen(
|
|||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = avatar?.profilePictureUrl,
|
profilePictureUrl = avatar?.profilePictureUrl,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
size = 36.dp,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.sharedElement(
|
.sharedElement(
|
||||||
rememberSharedContentState(key = sharedAvatarKey),
|
rememberSharedContentState(key = sharedAvatarKey),
|
||||||
@@ -280,7 +311,7 @@ fun ChatScreen(
|
|||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = avatar.profilePictureUrl,
|
profilePictureUrl = avatar.profilePictureUrl,
|
||||||
displayName = avatar.displayName,
|
displayName = avatar.displayName,
|
||||||
size = 36.dp
|
modifier = Modifier.size(36.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
}
|
}
|
||||||
@@ -330,6 +361,19 @@ fun ChatScreen(
|
|||||||
typingUsers = currentTypingUsers.map { it.username },
|
typingUsers = currentTypingUsers.map { it.username },
|
||||||
modifier = Modifier.padding(top = 2.dp)
|
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.Column
|
||||||
import androidx.compose.foundation.layout.IntrinsicSize
|
import androidx.compose.foundation.layout.IntrinsicSize
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@@ -83,7 +84,7 @@ fun MessageItem(
|
|||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = message.profile_picture,
|
profilePictureUrl = message.profile_picture,
|
||||||
displayName = message.username,
|
displayName = message.username,
|
||||||
size = 32.dp
|
modifier = Modifier.size(32.dp)
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(8.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.animation.togetherWith
|
||||||
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.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import ru.fromchat.ui.BackHandler
|
import ru.fromchat.ui.BackHandler
|
||||||
@@ -33,6 +34,15 @@ fun DmContainerScreen(
|
|||||||
val panel = remember(otherUserId) {
|
val panel = remember(otherUserId) {
|
||||||
DmPanelCache.getOrCreate(otherUserId, scope)
|
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) {
|
BackHandler(enabled = showProfile) {
|
||||||
haptic(HapticFeedbackEvent.ProfileClosed)
|
haptic(HapticFeedbackEvent.ProfileClosed)
|
||||||
@@ -77,7 +87,8 @@ fun DmContainerScreen(
|
|||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
sharedTransitionScope = this@SharedTransitionLayout,
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
animatedVisibilityScope = this@AnimatedContent,
|
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.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
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.coroutines.launch
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
@@ -14,11 +11,13 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.DmEnvelope
|
import ru.fromchat.api.DmEnvelope
|
||||||
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.Message
|
import ru.fromchat.api.Message
|
||||||
import ru.fromchat.api.WebSocketMessage
|
import ru.fromchat.api.WebSocketMessage
|
||||||
import ru.fromchat.core.Logger
|
import ru.fromchat.core.Logger
|
||||||
import ru.fromchat.crypto.decryptEnvelope
|
import ru.fromchat.crypto.decryptEnvelope
|
||||||
import ru.fromchat.ui.chat.ChatPanel
|
import ru.fromchat.ui.chat.ChatPanel
|
||||||
|
import ru.fromchat.ui.chat.DmTypingHandler
|
||||||
import ru.fromchat.ui.chat.TypingHandler
|
import ru.fromchat.ui.chat.TypingHandler
|
||||||
import ru.fromchat.ui.chat.TypingUser
|
import ru.fromchat.ui.chat.TypingUser
|
||||||
import ru.fromchat.ui.chat.AvatarInfo
|
import ru.fromchat.ui.chat.AvatarInfo
|
||||||
@@ -32,17 +31,23 @@ class DmPanel(
|
|||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
scope = coroutineScope
|
scope = coroutineScope
|
||||||
) {
|
) {
|
||||||
private val typingHandler = NoopTypingHandler()
|
private val typingHandler = DmTypingHandler(coroutineScope, otherUserId)
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
private var otherDisplayName: String = "User $otherUserId"
|
private var otherDisplayName: String = "User $otherUserId"
|
||||||
private var otherProfilePicture: String? = null
|
private var otherProfilePicture: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
updateState { it.copy(title = "Direct message", profileUserId = otherUserId) }
|
updateState { it.copy(title = "Direct message", profileUserId = otherUserId) }
|
||||||
|
coroutineScope.launch {
|
||||||
|
typingHandler.typingUsers.collect { users ->
|
||||||
|
updateState { it.copy(typingUsers = users) }
|
||||||
|
}
|
||||||
|
}
|
||||||
coroutineScope.launch(Dispatchers.Default) {
|
coroutineScope.launch(Dispatchers.Default) {
|
||||||
runCatching {
|
runCatching {
|
||||||
ApiClient.getProfileById(otherUserId)
|
ApiClient.getProfileById(otherUserId)
|
||||||
}.onSuccess { profile ->
|
}.onSuccess { profile ->
|
||||||
|
ProfileCache.put(profile)
|
||||||
val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username
|
val displayName = profile.displayName?.takeIf { it.isNotBlank() } ?: profile.username
|
||||||
otherDisplayName = displayName
|
otherDisplayName = displayName
|
||||||
otherProfilePicture = profile.profilePicture
|
otherProfilePicture = profile.profilePicture
|
||||||
@@ -89,12 +94,36 @@ class DmPanel(
|
|||||||
override suspend fun handleWebSocketMessage(message: WebSocketMessage) {
|
override suspend fun handleWebSocketMessage(message: WebSocketMessage) {
|
||||||
when (message.type) {
|
when (message.type) {
|
||||||
"dmNew" -> message.data?.let { processEnvelope(it) }
|
"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" -> {
|
"updates" -> {
|
||||||
val updates = message.data?.jsonObject?.get("updates")?.jsonArray ?: return
|
val updates = message.data?.jsonObject?.get("updates")?.jsonArray ?: return
|
||||||
for (update in updates) {
|
for (update in updates) {
|
||||||
val obj = update.jsonObject
|
val obj = update.jsonObject
|
||||||
if (obj["type"]?.jsonPrimitive?.content == "dmNew") {
|
val type = obj["type"]?.jsonPrimitive?.content
|
||||||
obj["data"]?.let { processEnvelope(it) }
|
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
|
override val showUsernamesInMessages: Boolean
|
||||||
get() = false
|
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.CalendarMonth
|
||||||
import androidx.compose.material.icons.filled.Info
|
import androidx.compose.material.icons.filled.Info
|
||||||
import androidx.compose.material.icons.filled.Link
|
import androidx.compose.material.icons.filled.Link
|
||||||
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
@@ -42,6 +43,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
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
|
||||||
@@ -56,7 +58,11 @@ import androidx.compose.ui.unit.dp
|
|||||||
import com.pr0gramm3r101.components.Category
|
import com.pr0gramm3r101.components.Category
|
||||||
import com.pr0gramm3r101.components.CategoryDefaults
|
import com.pr0gramm3r101.components.CategoryDefaults
|
||||||
import com.pr0gramm3r101.components.ListItem
|
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.ApiClient
|
||||||
|
import ru.fromchat.api.ProfileCache
|
||||||
import ru.fromchat.api.UserProfile
|
import ru.fromchat.api.UserProfile
|
||||||
import ru.fromchat.ui.chat.Avatar
|
import ru.fromchat.ui.chat.Avatar
|
||||||
import ru.fromchat.ui.scaleOnPress
|
import ru.fromchat.ui.scaleOnPress
|
||||||
@@ -79,16 +85,25 @@ fun ProfileScreen(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sharedTransitionScope: SharedTransitionScope? = null,
|
sharedTransitionScope: SharedTransitionScope? = null,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
sharedAvatarKey: Any? = null
|
sharedAvatarKey: Any? = null,
|
||||||
|
initialDisplayName: String? = null
|
||||||
) {
|
) {
|
||||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
||||||
var state by remember { mutableStateOf(ProfileUiState()) }
|
|
||||||
|
|
||||||
val targetUserId = userId.takeIf { it != null && it > 0 }
|
val targetUserId = userId.takeIf { it != null && it > 0 }
|
||||||
val fetchKey = targetUserId ?: 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) {
|
LaunchedEffect(fetchKey) {
|
||||||
state = state.copy(isLoading = true, error = null)
|
|
||||||
runCatching {
|
runCatching {
|
||||||
if (targetUserId == null) {
|
if (targetUserId == null) {
|
||||||
ApiClient.getOwnProfile()
|
ApiClient.getOwnProfile()
|
||||||
@@ -96,9 +111,13 @@ fun ProfileScreen(
|
|||||||
ApiClient.getProfileById(targetUserId)
|
ApiClient.getProfileById(targetUserId)
|
||||||
}
|
}
|
||||||
}.onSuccess { profile ->
|
}.onSuccess { profile ->
|
||||||
state = state.copy(profile = profile, isLoading = false)
|
ProfileCache.put(profile)
|
||||||
|
state = state.copy(profile = profile, isLoading = false, error = null)
|
||||||
}.onFailure {
|
}.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 profile = state.profile
|
||||||
val displayName = profile?.displayName?.takeIf { it.isNotBlank() }
|
val displayName = profile?.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: profile?.username?.takeIf { it.isNotBlank() }
|
?: profile?.username?.takeIf { it.isNotBlank() }
|
||||||
|
?: initialDisplayName?.takeIf { it.isNotBlank() }
|
||||||
?: "?"
|
?: "?"
|
||||||
|
|
||||||
val useSharedAvatar = sharedTransitionScope != null &&
|
val useSharedAvatar = sharedTransitionScope != null &&
|
||||||
@@ -151,11 +171,10 @@ fun ProfileScreen(
|
|||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
useSharedAvatar -> {
|
useSharedAvatar -> {
|
||||||
with(sharedTransitionScope!!) {
|
with(sharedTransitionScope) {
|
||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = profile?.profilePicture,
|
profilePictureUrl = profile?.profilePicture,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
size = 128.dp,
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = 16.dp)
|
.padding(top = 16.dp)
|
||||||
.sharedElement(
|
.sharedElement(
|
||||||
@@ -171,8 +190,9 @@ fun ProfileScreen(
|
|||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = profile?.profilePicture,
|
profilePictureUrl = profile?.profilePicture,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
size = 128.dp,
|
modifier = Modifier
|
||||||
modifier = Modifier.padding(top = 16.dp)
|
.padding(top = 16.dp)
|
||||||
|
.size(128.dp)
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
}
|
}
|
||||||
@@ -216,11 +236,23 @@ fun ProfileScreen(
|
|||||||
val profileLink = profile.username.takeIf { it.isNotBlank() }
|
val profileLink = profile.username.takeIf { it.isNotBlank() }
|
||||||
?.let { "https://fromchat.ru/@$it" }
|
?.let { "https://fromchat.ru/@$it" }
|
||||||
?: "https://fromchat.ru/?u=${profile.id}"
|
?: "https://fromchat.ru/?u=${profile.id}"
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Text(
|
val verificationLabel = if (profile.verified == true) "Verified account" else "Click to verify"
|
||||||
text = displayName,
|
|
||||||
style = MaterialTheme.typography.titleLarge
|
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))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "@${profile.username}",
|
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"
|
||||||
|
}
|
||||||
@@ -4,5 +4,4 @@ import androidx.compose.ui.window.ComposeUIViewController
|
|||||||
import ru.fromchat.ui.App
|
import ru.fromchat.ui.App
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
fun MainViewController() = ComposeUIViewController { App() }
|
fun MainViewController() = ComposeUIViewController { App() }
|
||||||
|
|
||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#Sat Sep 06 13:57:07 MSK 2025
|
#Sat Sep 06 13:57:07 MSK 2025
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.pr0gramm3r101.utils
|
package com.pr0gramm3r101.utils
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.composed
|
||||||
import androidx.navigation.NavController
|
import androidx.navigation.NavController
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,10 +36,12 @@ fun NavController.navigateAndWipeBackStack(route: String, launchSingleTop: Boole
|
|||||||
|
|
||||||
inline fun Modifier.conditional(
|
inline fun Modifier.conditional(
|
||||||
condition: Boolean,
|
condition: Boolean,
|
||||||
`else`: Modifier.(Modifier) -> Modifier = { Modifier },
|
crossinline `else`: @Composable Modifier.(Modifier) -> Modifier = { Modifier },
|
||||||
`if`: Modifier.(Modifier) -> Modifier
|
crossinline `if`: @Composable Modifier.(Modifier) -> Modifier
|
||||||
) = if (condition) {
|
) = composed {
|
||||||
this + `if`(this)
|
if (condition) {
|
||||||
} else {
|
this + `if`(this)
|
||||||
this + `else`(this)
|
} else {
|
||||||
|
this + `else`(this)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user