Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-04 20:07:43 +03:00
Unverified
parent 7b82bc58f9
commit 139de3393b
30 changed files with 1676 additions and 756 deletions
+76 -41
View File
@@ -2,16 +2,18 @@ package ru.fromchat
import android.app.Application import android.app.Application
import com.pr0gramm3r101.utils.UtilsLibrary import com.pr0gramm3r101.utils.UtilsLibrary
import com.pr0gramm3r101.utils.settings.settings
import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
import ru.fromchat.notifications.NotificationHelper import ru.fromchat.notifications.NotificationHelper
class App: Application() { class App: Application() {
@@ -37,52 +39,85 @@ class App: Application() {
UtilsLibrary.init(this) UtilsLibrary.init(this)
WebSocketManager.addGlobalMessageHandler { msg -> WebSocketManager.addGlobalMessageHandler { msg ->
runCatching { GlobalScope.launch(Dispatchers.IO) {
if (msg.type == "newMessage") { runCatching {
fetchAndNotify() val currentUserId = settings.getInt("current_user_id", -1)
} else if (msg.type == "dmNew") {
val dmMessageId = msg.data?.jsonObject?.get("id")?.jsonPrimitive?.content?.toIntOrNull()
fetchAndNotify(
includeDmMessages = true,
dmMessageId = dmMessageId
)
} else if (msg.type == "updates") {
msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates ->
var shouldFetchPublic = false
var shouldFetchDm = false
var latestDmMessageId: Int? = null
for (item in updates) { fun isOwnPublicMessage(data: JsonObject?) =
val type = item data?.get("user_id")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId
.jsonObject["type"]
?.jsonPrimitive
?.content
if (type == "newMessage") { fun isOwnDmMessage(data: JsonObject?) =
shouldFetchPublic = true data?.get("senderId")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId
continue
}
if (type == "dmNew") { when (msg.type) {
shouldFetchDm = true "newMessage" -> {
val envelopeId = item if (!isOwnPublicMessage(msg.data?.jsonObject)) {
.jsonObject["data"] fetchAndNotify()
?.jsonObject
?.get("id")
?.jsonPrimitive
?.content
?.toIntOrNull()
if (envelopeId != null) {
latestDmMessageId = envelopeId.coerceAtLeast(latestDmMessageId ?: 0)
}
} }
} }
if (shouldFetchPublic || shouldFetchDm) { "dmNew" -> {
fetchAndNotify( if (!isOwnDmMessage(msg.data?.jsonObject)) {
includeDmMessages = shouldFetchDm, fetchAndNotify(
dmMessageId = latestDmMessageId includeDmMessages = true,
) dmMessageId = msg
.data
?.jsonObject
?.get("id")
?.jsonPrimitive
?.content
?.toIntOrNull()
)
}
}
"updates" -> {
msg.data?.jsonObject?.get("updates")?.jsonArray?.let { updates ->
var shouldFetchPublic = false
var shouldFetchDm = false
var latestDmMessageId: Int? = null
for (item in updates) {
val (type, data) = item.jsonObject.let {
Pair(
it["type"]?.jsonPrimitive?.content,
it["data"]?.jsonObject
)
}
when (type) {
"newMessage" -> {
if (!isOwnPublicMessage(data)) {
shouldFetchPublic = true
}
}
"dmNew" -> {
if (!isOwnDmMessage(data)) {
shouldFetchDm = true
data
?.get("id")
?.jsonPrimitive
?.content
?.toIntOrNull()
?.let {
latestDmMessageId = it.coerceAtLeast(
latestDmMessageId ?: 0
)
}
}
}
}
}
if (shouldFetchPublic || shouldFetchDm) {
fetchAndNotify(
includeDmMessages = shouldFetchDm,
dmMessageId = latestDmMessageId
)
}
}
} }
} }
} }
@@ -23,6 +23,7 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
val pushData = remoteMessage.data val pushData = remoteMessage.data
val fallbackMessageId = pushData["message_id"]?.toIntOrNull() val fallbackMessageId = pushData["message_id"]?.toIntOrNull()
?: pushData["dm_id"]?.toIntOrNull() ?: pushData["dm_id"]?.toIntOrNull()
val senderId = pushData["sender_id"]?.toIntOrNull()
val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"] val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"]
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat" val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat"
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message" val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message"
@@ -33,14 +34,20 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
ApiClient.loadPersistedData() ApiClient.loadPersistedData()
Log.d("FromChatFCM", "Token loaded from storage for push sync: hasToken=${ApiClient.token?.isNotBlank() ?: false}") Log.d("FromChatFCM", "Token loaded from storage for push sync: hasToken=${ApiClient.token?.isNotBlank() ?: false}")
} }
val currentUserId = settings.getInt("current_user_id", -1)
if (senderId != null && senderId == currentUserId) {
Log.d("FromChatFCM", "Skipping push for own message senderId=$senderId")
return@launch
}
if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) { if (!isDirectMessage && (title.isNotBlank() || body.isNotBlank())) {
NotificationHelper.showFallbackPushNotification( NotificationHelper.showFallbackPushNotification(
applicationContext, context = applicationContext,
title, title = title,
body, body = body,
sender, sender = sender,
fallbackMessageId, messageId = fallbackMessageId,
messageType == "dm" isDirectMessage = false,
senderId = senderId,
) )
} }
if (isDirectMessage) { if (isDirectMessage) {
@@ -27,6 +27,9 @@ import kotlinx.coroutines.launch
import ru.fromchat.MainActivity import ru.fromchat.MainActivity
import ru.fromchat.R import ru.fromchat.R
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.messages.buildChatListPreview
import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.MessagesResponse import ru.fromchat.api.schema.messages.MessagesResponse
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
@@ -55,6 +58,18 @@ object NotificationHelper {
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time" private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
const val KEY_TEXT_REPLY = "key_text_reply" const val KEY_TEXT_REPLY = "key_text_reply"
private fun listPreviewStrings(context: Context): ChatListPreviewStrings {
val emoji = context.getString(R.string.chat_preview_image_emoji)
return ChatListPreviewStrings(
imageEmoji = emoji,
imageOnly = context.getString(R.string.chat_preview_image, emoji),
attachmentOnly = context.getString(R.string.chat_preview_attachment),
)
}
private fun notificationBodyForMessage(message: Message, strings: ChatListPreviewStrings): String =
buildChatListPreview(message, strings)?.takeIf { it.isNotBlank() } ?: message.content
fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID fun summaryNotificationId(): Int = SUMMARY_NOTIFICATION_ID
private fun createMessageIntent( private fun createMessageIntent(
@@ -145,7 +160,8 @@ object NotificationHelper {
.get("${ServerConfig.apiBaseUrl}/messages/new") .get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>() .body<MessagesResponse>()
.messages .messages
Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages") .filter { it.user_id != currentUserId }
Log.d("NotificationHelper", "fetchAndNotify: fetched ${messages.size} public messages (excluding self)")
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis()) settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
@@ -168,6 +184,7 @@ object NotificationHelper {
.get("${ServerConfig.apiBaseUrl}/messages/new") .get("${ServerConfig.apiBaseUrl}/messages/new")
.body<MessagesResponse>() .body<MessagesResponse>()
.messages .messages
.filter { it.user_id != settings.getInt("current_user_id", -1) }
Log.d( Log.d(
"NotificationHelper", "NotificationHelper",
"fetchAndNotify retry: fetched ${retryMessages.size} public messages" "fetchAndNotify retry: fetched ${retryMessages.size} public messages"
@@ -241,6 +258,8 @@ object NotificationHelper {
val shownDm = settings.getStringSet(PREF_SHOWN_DM_KEY, emptySet()).toMutableSet() val shownDm = settings.getStringSet(PREF_SHOWN_DM_KEY, emptySet()).toMutableSet()
val latestMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0) val latestMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
val previewStrings = listPreviewStrings(context)
dmMessages dmMessages
.filter { envelope -> .filter { envelope ->
envelope.id > 0 && envelope.senderId != currentUserId envelope.id > 0 && envelope.senderId != currentUserId
@@ -290,11 +309,16 @@ object NotificationHelper {
"User ${envelope.senderId}" "User ${envelope.senderId}"
} }
val dmConversationUserId = envelope.senderId val dmConversationUserId = envelope.senderId
val notificationBody = buildChatListPreviewFromEnvelope(
envelope = envelope,
decryptedPlaintext = plaintext,
strings = previewStrings,
)?.takeIf { it.isNotBlank() } ?: plaintext
showFallbackPushNotification( showFallbackPushNotification(
context = context, context = context,
title = "Direct message from $senderName", title = "Direct message from $senderName",
body = plaintext, body = notificationBody,
sender = senderName, sender = senderName,
messageId = envelopeId, messageId = envelopeId,
allowWhenPublicChatVisible = true, allowWhenPublicChatVisible = true,
@@ -321,11 +345,22 @@ object NotificationHelper {
allowWhenPublicChatVisible: Boolean = false, allowWhenPublicChatVisible: Boolean = false,
isDirectMessage: Boolean = false, isDirectMessage: Boolean = false,
targetDmUserId: Int? = null, targetDmUserId: Int? = null,
conversationTitle: String = "Public Chat" conversationTitle: String = "Public Chat",
senderId: Int? = null,
) { ) {
CoroutineScope(Dispatchers.Main).launch { CoroutineScope(Dispatchers.Main).launch {
createChannel(context) createChannel(context)
val currentUserId = settings.getInt("current_user_id", -1)
if (!isDirectMessage && senderId != null && senderId == currentUserId) {
Log.d("NotificationHelper", "Fallback push skipped: own public message senderId=$senderId")
return@launch
}
if (isDirectMessage && targetDmUserId != null && targetDmUserId == currentUserId) {
Log.d("NotificationHelper", "Fallback push skipped: own DM targetDmUserId=$targetDmUserId")
return@launch
}
if (isPublicChatVisible && !allowWhenPublicChatVisible) { if (isPublicChatVisible && !allowWhenPublicChatVisible) {
Log.d("NotificationHelper", "Fallback push notification skipped: public chat is visible") Log.d("NotificationHelper", "Fallback push notification skipped: public chat is visible")
return@launch return@launch
@@ -429,6 +464,7 @@ object NotificationHelper {
GlobalScope.launch { GlobalScope.launch {
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet() val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
var newMessageCount = 0 var newMessageCount = 0
val previewStrings = listPreviewStrings(context)
with(NotificationManagerCompat.from(context)) { with(NotificationManagerCompat.from(context)) {
if ( if (
@@ -468,17 +504,17 @@ object NotificationHelper {
.setStyle( .setStyle(
NotificationCompat.MessagingStyle( NotificationCompat.MessagingStyle(
Person.Builder().setName("FromChat").build() Person.Builder().setName("FromChat").build()
).setConversationTitle("Public Chat").let { ).setConversationTitle("Public Chat").let { style ->
for (msg in messages.takeLast(10)) { for (msg in newMessages.takeLast(10)) {
val timestamp = try { val timestamp = try {
Instant.parse(msg.timestamp).toEpochMilliseconds() Instant.parse(msg.timestamp).toEpochMilliseconds()
} catch (_: Exception) { } catch (_: Exception) {
System.currentTimeMillis() System.currentTimeMillis()
} }
it.addMessage( style.addMessage(
NotificationCompat.MessagingStyle.Message( NotificationCompat.MessagingStyle.Message(
msg.content, notificationBodyForMessage(msg, previewStrings),
timestamp, timestamp,
Person.Builder() Person.Builder()
.setName(msg.username) .setName(msg.username)
@@ -487,7 +523,7 @@ object NotificationHelper {
) )
} }
it style
} }
) )
.setPriority(NotificationCompat.PRIORITY_HIGH) .setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -0,0 +1,5 @@
<resources>
<string name="chat_preview_attachment">Вложение</string>
<string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 фото</string>
</resources>
@@ -1,3 +1,6 @@
<resources> <resources>
<string name="app_name" translatable="false">FromChat</string> <string name="app_name" translatable="false">FromChat</string>
<string name="chat_preview_attachment">Attachment</string>
<string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 photo</string>
</resources> </resources>
@@ -61,6 +61,10 @@
<string name="public_chat">Общий чат</string> <string name="public_chat">Общий чат</string>
<string name="chat_last_mesaage">Вы: последнее сообщение</string> <string name="chat_last_mesaage">Вы: последнее сообщение</string>
<string name="chat_preview_attachment">Вложение</string> <string name="chat_preview_attachment">Вложение</string>
<string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 фото</string>
<string name="cd_chat_preview_sending">Отправка сообщения</string>
<string name="cd_chat_preview_uploading">Загрузка файла</string>
<string name="action_mark_read">Прочитано</string> <string name="action_mark_read">Прочитано</string>
<string name="action_select">Выбрать</string> <string name="action_select">Выбрать</string>
<string name="action_archive">В архив</string> <string name="action_archive">В архив</string>
@@ -69,6 +69,10 @@
<string name="public_chat">Main chat</string> <string name="public_chat">Main chat</string>
<string name="chat_last_mesaage">You: last message</string> <string name="chat_last_mesaage">You: last message</string>
<string name="chat_preview_attachment">Attachment</string> <string name="chat_preview_attachment">Attachment</string>
<string name="chat_preview_image_emoji">📷</string>
<string name="chat_preview_image">%1$s 1 photo</string>
<string name="cd_chat_preview_sending">Sending message</string>
<string name="cd_chat_preview_uploading">Uploading file</string>
<string name="action_mark_read">Mark as read</string> <string name="action_mark_read">Mark as read</string>
<string name="action_select">Select</string> <string name="action_select">Select</string>
<string name="action_archive">Archive</string> <string name="action_archive">Archive</string>
@@ -7,7 +7,10 @@ import com.pr0gramm3r101.utils.settings.secureSettings
import com.pr0gramm3r101.utils.settings.settings import com.pr0gramm3r101.utils.settings.settings
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.network.sockets.ConnectTimeoutException
import io.ktor.client.network.sockets.SocketTimeoutException
import io.ktor.client.plugins.ClientRequestException import io.ktor.client.plugins.ClientRequestException
import io.ktor.client.plugins.HttpRequestTimeoutException
import io.ktor.client.request.forms.formData import io.ktor.client.request.forms.formData
import io.ktor.client.request.forms.submitFormWithBinaryData import io.ktor.client.request.forms.submitFormWithBinaryData
import io.ktor.client.plugins.HttpResponseValidator import io.ktor.client.plugins.HttpResponseValidator
@@ -33,6 +36,7 @@ import io.ktor.http.ContentType
import io.ktor.http.contentType import io.ktor.http.contentType
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@@ -306,12 +310,39 @@ object ApiClient {
httpProbe.get(url.trim()) httpProbe.get(url.trim())
}.isSuccess }.isSuccess
suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): Boolean = sealed interface CheckAuthResult {
data object Authenticated : CheckAuthResult
data object NotAuthenticated : CheckAuthResult
data object Unreachable : CheckAuthResult
}
suspend fun checkAuthAt(apiBaseUrl: String, bearer: String): CheckAuthResult =
runCatching { runCatching {
httpProbe.get("${apiBaseUrl.trimEnd('/')}/check_auth") { httpProbe.get("${apiBaseUrl.trimEnd('/')}/check_auth") {
bearerAuth(bearer) bearerAuth(bearer)
}.body<CheckAuthResponse>().authenticated }.body<CheckAuthResponse>().authenticated
}.getOrDefault(false) }.fold(
onSuccess = { authenticated ->
if (authenticated) CheckAuthResult.Authenticated else CheckAuthResult.NotAuthenticated
},
onFailure = { e ->
when (e) {
is TimeoutCancellationException,
is HttpRequestTimeoutException,
is SocketTimeoutException,
is ConnectTimeoutException,
-> CheckAuthResult.Unreachable
is ClientRequestException -> {
if (e.response.status.value in 400..499) {
CheckAuthResult.NotAuthenticated
} else {
CheckAuthResult.Unreachable
}
}
else -> CheckAuthResult.Unreachable
}
},
)
suspend fun refreshServerInstanceFingerprint() { suspend fun refreshServerInstanceFingerprint() {
if (token.isNullOrEmpty()) return if (token.isNullOrEmpty()) return
@@ -1381,17 +1412,31 @@ object ApiClient {
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated") fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) { suspend fun sendMessageViaHttp(
if (_suspensionState.value.isSuspended) return content: String,
replyToId: Int? = null,
clientMessageId: String? = null,
): ru.fromchat.api.schema.messages.Message {
if (_suspensionState.value.isSuspended) {
throw IllegalStateException("Account suspended")
}
val payloadJson = json.encodeToString( val payloadJson = json.encodeToString(
SendMessageRequest(content = content.trim(), reply_to_id = replyToId), SendMessageRequest(
content = content.trim(),
reply_to_id = replyToId,
client_message_id = clientMessageId?.trim()?.takeIf { it.isNotEmpty() },
),
) )
http.submitFormWithBinaryData( val response = http.submitFormWithBinaryData(
url = "${ServerConfig.apiBaseUrl}/send_message", url = "${ServerConfig.apiBaseUrl}/send_message",
formData = formData { formData = formData {
append("payload", payloadJson) append("payload", payloadJson)
}, },
) ).body<ru.fromchat.api.schema.messages.publicchat.SendMessageResponse>()
if (!response.status.equals("success", ignoreCase = true)) {
throw IllegalStateException("send_message failed: ${response.status}")
}
return response.message
} }
// WebSocket send helpers // WebSocket send helpers
@@ -34,6 +34,7 @@ suspend fun resolveInstanceId(
config: ServerConfigData, config: ServerConfigData,
apiBaseUrl: String, apiBaseUrl: String,
forceNetwork: Boolean, forceNetwork: Boolean,
allowCachedOnFailure: Boolean = true,
): InstanceIdResolveResult { ): InstanceIdResolveResult {
val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty() val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty()
if (!forceNetwork && cached.isNotEmpty() && isValidInstanceUuid(cached)) { if (!forceNetwork && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
@@ -46,7 +47,11 @@ suspend fun resolveInstanceId(
} }
} }
if (fetchResult.isFailure) { if (fetchResult.isFailure) {
return networkFailureToResolveResult(fetchResult.exceptionOrNull(), cached) return networkFailureToResolveResult(
e = fetchResult.exceptionOrNull(),
cached = cached,
allowCachedOnFailure = allowCachedOnFailure,
)
} }
val fetched = fetchResult.getOrThrow().trim() val fetched = fetchResult.getOrThrow().trim()
@@ -74,19 +79,20 @@ fun apiBaseUrlFor(config: ServerConfigData): String {
private fun networkFailureToResolveResult( private fun networkFailureToResolveResult(
e: Throwable?, e: Throwable?,
cached: String, cached: String,
allowCachedOnFailure: Boolean,
): InstanceIdResolveResult = when (e) { ): InstanceIdResolveResult = when (e) {
is TimeoutCancellationException, is TimeoutCancellationException,
is HttpRequestTimeoutException, is HttpRequestTimeoutException,
is SocketTimeoutException, is SocketTimeoutException,
-> { -> {
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
InstanceIdResolveResult.Cached(cached) InstanceIdResolveResult.Cached(cached)
} else { } else {
InstanceIdResolveResult.Timeout InstanceIdResolveResult.Timeout
} }
} }
is ConnectTimeoutException -> { is ConnectTimeoutException -> {
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
InstanceIdResolveResult.Cached(cached) InstanceIdResolveResult.Cached(cached)
} else { } else {
InstanceIdResolveResult.Unreachable InstanceIdResolveResult.Unreachable
@@ -95,14 +101,14 @@ private fun networkFailureToResolveResult(
is ClientRequestException -> { is ClientRequestException -> {
if (e.response.status.value in 400..499) { if (e.response.status.value in 400..499) {
InstanceIdResolveResult.Unsupported InstanceIdResolveResult.Unsupported
} else if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { } else if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
InstanceIdResolveResult.Cached(cached) InstanceIdResolveResult.Cached(cached)
} else { } else {
InstanceIdResolveResult.Unreachable InstanceIdResolveResult.Unreachable
} }
} }
else -> { else -> {
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) { if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
InstanceIdResolveResult.Cached(cached) InstanceIdResolveResult.Cached(cached)
} else { } else {
InstanceIdResolveResult.Unreachable InstanceIdResolveResult.Unreachable
@@ -36,12 +36,22 @@ suspend fun probeCallsReachable(config: ServerConfigData): Boolean {
}.getOrDefault(false) }.getOrDefault(false)
} }
sealed interface ApplyServerResult {
data object Applied : ApplyServerResult
data object ServerUnreachable : ApplyServerResult
}
suspend fun probeServer(config: ServerConfigData): ServerProbeResult { suspend fun probeServer(config: ServerConfigData): ServerProbeResult {
val apiBase = apiBaseUrlFor(config) val apiBase = apiBaseUrlFor(config)
val mark = TimeSource.Monotonic.markNow() val mark = TimeSource.Monotonic.markNow()
InstanceIdGuard.probeConfig = config InstanceIdGuard.probeConfig = config
try { try {
val resolve = resolveInstanceId(config, apiBase, forceNetwork = true) val resolve = resolveInstanceId(
config = config,
apiBaseUrl = apiBase,
forceNetwork = true,
allowCachedOnFailure = false,
)
val pingMs = mark.elapsedNow().inWholeMilliseconds.toInt().coerceAtLeast(0) val pingMs = mark.elapsedNow().inWholeMilliseconds.toInt().coerceAtLeast(0)
val instanceId = when (resolve) { val instanceId = when (resolve) {
is InstanceIdResolveResult.Cached -> resolve.instanceId is InstanceIdResolveResult.Cached -> resolve.instanceId
@@ -78,25 +88,32 @@ suspend fun applyServerAndNavigate(
onNavigateLogin: suspend () -> Unit, onNavigateLogin: suspend () -> Unit,
onNavigateChat: suspend () -> Unit, onNavigateChat: suspend () -> Unit,
onLogoutOldHost: suspend () -> Unit, onLogoutOldHost: suspend () -> Unit,
) { ): ApplyServerResult {
val apiBase = apiBaseUrlFor(config) val apiBase = apiBaseUrlFor(config)
val token = bearer.trim() val token = bearer.trim()
if (token.isEmpty()) { if (token.isEmpty()) {
applyServerConfig(config, probe.instanceId, probe.callsOk) applyServerConfig(config, probe.instanceId, probe.callsOk)
WebSocketManager.disconnect() WebSocketManager.disconnect()
onNavigateLogin() onNavigateLogin()
return return ApplyServerResult.Applied
} }
val authOk = ApiClient.checkAuthAt(apiBase, token) when (val auth = ApiClient.checkAuthAt(apiBase, token)) {
if (!authOk) { ApiClient.CheckAuthResult.Authenticated -> {
onLogoutOldHost() applyServerConfig(config, probe.instanceId, probe.callsOk)
applyServerConfig(config, probe.instanceId, probe.callsOk) WebSocketManager.disconnect()
WebSocketManager.disconnect() WebSocketManager.connect(forceRestart = true)
onNavigateLogin() onNavigateChat()
return return ApplyServerResult.Applied
}
ApiClient.CheckAuthResult.Unreachable -> {
return ApplyServerResult.ServerUnreachable
}
ApiClient.CheckAuthResult.NotAuthenticated -> {
onLogoutOldHost()
applyServerConfig(config, probe.instanceId, probe.callsOk)
WebSocketManager.disconnect()
onNavigateLogin()
return ApplyServerResult.Applied
}
} }
applyServerConfig(config, probe.instanceId, probe.callsOk)
WebSocketManager.disconnect()
WebSocketManager.connect(forceRestart = true)
onNavigateChat()
} }
@@ -34,6 +34,7 @@ import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.instance.InstanceIdGuard import ru.fromchat.api.instance.InstanceIdGuard
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.schema.websocket.WebSocketCredentials import ru.fromchat.api.schema.websocket.WebSocketCredentials
import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
@@ -61,6 +62,7 @@ object WebSocketManager {
val messages = _messages.asSharedFlow() val messages = _messages.asSharedFlow()
private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>() private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>()
private val sessionReadyHandlers = mutableListOf<suspend () -> Unit>()
fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) { fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
globalHandlers += handler globalHandlers += handler
@@ -70,6 +72,25 @@ object WebSocketManager {
globalHandlers -= handler globalHandlers -= handler
} }
fun addSessionReadyHandler(handler: suspend () -> Unit) {
sessionReadyHandlers += handler
}
fun removeSessionReadyHandler(handler: suspend () -> Unit) {
sessionReadyHandlers -= handler
}
private fun notifySessionReady() {
OutgoingMessageCoordinator.onTransportReady()
scope.launch {
sessionReadyHandlers.forEach { handler ->
runCatching { handler() }.onFailure {
logW("Session-ready handler failed: ${it.message}", it)
}
}
}
}
@Volatile private var connecting = false @Volatile private var connecting = false
@Volatile private var session: DefaultClientWebSocketSession? = null @Volatile private var session: DefaultClientWebSocketSession? = null
@Volatile private var connectionJob: Job? = null @Volatile private var connectionJob: Job? = null
@@ -190,6 +211,8 @@ object WebSocketManager {
} }
} }
notifySessionReady()
for (frame in incoming) { for (frame in incoming) {
val text = (frame as? Frame.Text)?.readText() ?: continue val text = (frame as? Frame.Text)?.readText() ?: continue
logD("Received payload: $text") logD("Received payload: $text")
@@ -278,8 +301,8 @@ object WebSocketManager {
var handler: ((WebSocketMessage) -> Unit)? = null var handler: ((WebSocketMessage) -> Unit)? = null
return try { return try {
if (session == null) { if (session == null && !waitForConnection(timeoutMs)) {
logW("No active WebSocket session") logW("No active WebSocket session after waiting")
return null return null
} }
@@ -324,19 +347,17 @@ object WebSocketManager {
fun onNetworkLost() { fun onNetworkLost() {
logD("onNetworkLost") logD("onNetworkLost")
session?.cancel()
session = null
connecting = false
connectionJob?.cancel() connectionJob?.cancel()
connectionJob = null connectionJob = null
disconnect()
ConnectionStateStore.onConnecting() ConnectionStateStore.onConnecting()
} }
fun onNetworkAvailable() { fun onNetworkAvailable() {
if (!AppForeground.isInForeground.value) return if (!AppForeground.isInForeground.value) return
if (session != null) {
logD("onNetworkAvailable: session active, skip")
return
}
val now = Clock.System.now().toEpochMilliseconds() val now = Clock.System.now().toEpochMilliseconds()
val prev = lastOnNetworkAvailableWallMs val prev = lastOnNetworkAvailableWallMs
if (now - prev < NETWORK_AVAILABLE_DEBOUNCE_MS) { if (now - prev < NETWORK_AVAILABLE_DEBOUNCE_MS) {
@@ -344,7 +365,8 @@ object WebSocketManager {
return return
} }
lastOnNetworkAvailableWallMs = now lastOnNetworkAvailableWallMs = now
logD("onNetworkAvailable: reconnect") logD("onNetworkAvailable: reconnect and flush outbox")
connect(forceRestart = true) connect(forceRestart = true)
OutgoingMessageCoordinator.onTransportReady()
} }
} }
@@ -3,12 +3,21 @@ package ru.fromchat.api.local.db.store
import app.cash.sqldelight.coroutines.asFlow import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList import app.cash.sqldelight.coroutines.mapToList
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator
import ru.fromchat.api.local.messages.ChatListPreviewState
import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.messages.buildChatListPreview
import ru.fromchat.api.local.messages.buildChatListPreviewFromEnvelope
import ru.fromchat.api.local.messages.buildChatListPreviewState
import ru.fromchat.api.local.db.aspectRatioFromDimensionPair import ru.fromchat.api.local.db.aspectRatioFromDimensionPair
import ru.fromchat.api.local.messages.conversationIdForDm import ru.fromchat.api.local.messages.conversationIdForDm
import ru.fromchat.api.local.messages.conversationIdForGroup import ru.fromchat.api.local.messages.conversationIdForGroup
@@ -41,13 +50,18 @@ data class CachedConversation(
val otherUserId: Int, val otherUserId: Int,
val displayName: String, val displayName: String,
val lastMessagePreview: String?, val lastMessagePreview: String?,
val unreadCount: Int val lastMessagePendingIndicator: ChatListPreviewPendingIndicator = ChatListPreviewPendingIndicator.None,
val lastMessageUploadProgress: Int? = null,
val unreadCount: Int,
) )
object MessageCacheStore { object MessageCacheStore {
private val db: MessageDatabase get() = MessageDatabaseProvider.database private val db: MessageDatabase get() = MessageDatabaseProvider.database
private val outboxJson = Json { ignoreUnknownKeys = true; encodeDefaults = true } private val outboxJson = Json { ignoreUnknownKeys = true; encodeDefaults = true }
@Volatile
var listPreviewStrings: ChatListPreviewStrings? = null
private fun instanceId(): String = CacheContext.requireActiveInstanceId() private fun instanceId(): String = CacheContext.requireActiveInstanceId()
private fun conversationIdForPublic(): String = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID) private fun conversationIdForPublic(): String = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
@@ -82,6 +96,26 @@ object MessageCacheStore {
suspend fun loadRecentPublicMessages(limit: Long): List<Message> = suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
loadRecentMessages(conversationIdForPublic(), limit) loadRecentMessages(conversationIdForPublic(), limit)
suspend fun loadRecentPublicChatPreviewState(
strings: ChatListPreviewStrings,
limit: Long = 1,
): ChatListPreviewState? = withContext(Dispatchers.Default) {
val convId = conversationIdForPublic()
val iid = instanceId()
val recent = db.messageDatabaseQueries
.selectRecentMessagesByConversation(iid, convId, limit)
.executeAsList()
.firstOrNull() ?: return@withContext null
val message = enrichQueuedOutboundUi(listOf(recent.toAppMessage()), convId).firstOrNull()
?: return@withContext null
buildChatListPreviewState(message, strings, ApiClient.user?.id)
.let { state ->
state.copy(
text = state.text?.trim()?.takeIf { it.isNotEmpty() },
)
}
}
suspend fun replacePublicMessages(messages: List<Message>) { suspend fun replacePublicMessages(messages: List<Message>) {
conversationIdForPublic().let { conversationIdForPublic().let {
replaceMessages( replaceMessages(
@@ -190,6 +224,7 @@ object MessageCacheStore {
} }
suspend fun upsertDmMessage(otherUserId: Int, message: Message) { suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
ensureDmConversationRow(otherUserId)
upsertSingle(conversationIdForDm(otherUserId), message) upsertSingle(conversationIdForDm(otherUserId), message)
syncDmConversationPreviewFromCache(otherUserId) syncDmConversationPreviewFromCache(otherUserId)
} }
@@ -263,8 +298,9 @@ object MessageCacheStore {
suspend fun replaceDmConversations( suspend fun replaceDmConversations(
conversations: List<DmConversation>, conversations: List<DmConversation>,
attachmentOnlyPreview: String, previewStrings: ChatListPreviewStrings,
) { ) {
listPreviewStrings = previewStrings
val iid = instanceId() val iid = instanceId()
val currentUserId = ApiClient.user?.id val currentUserId = ApiClient.user?.id
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
@@ -280,7 +316,7 @@ object MessageCacheStore {
lastMessagePreview = buildDmListPreview( lastMessagePreview = buildDmListPreview(
conv.lastMessage, conv.lastMessage,
currentUserId, currentUserId,
attachmentOnlyPreview, previewStrings,
), ),
unreadCount = conv.unreadCount, unreadCount = conv.unreadCount,
updatedAt = conv.lastMessage.timestamp, updatedAt = conv.lastMessage.timestamp,
@@ -324,17 +360,10 @@ object MessageCacheStore {
private suspend fun buildDmListPreview( private suspend fun buildDmListPreview(
envelope: DmEnvelope, envelope: DmEnvelope,
currentUserId: Int?, currentUserId: Int?,
attachmentOnlyPreview: String, previewStrings: ChatListPreviewStrings,
): String? { ): String? {
val hasFiles = !envelope.files.isNullOrEmpty()
val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull() val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
?.trim() val previewSource = buildChatListPreviewFromEnvelope(envelope, decrypted, previewStrings)
?.takeIf { it.isNotEmpty() }
val previewSource = when {
decrypted != null -> decrypted
hasFiles -> attachmentOnlyPreview
else -> null
}
return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() } return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() }
} }
@@ -509,34 +538,120 @@ object MessageCacheStore {
suspend fun loadCachedDmConversations(): List<CachedConversation> = suspend fun loadCachedDmConversations(): List<CachedConversation> =
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val iid = instanceId()
val previewStrings = listPreviewStrings
val currentUserId = ApiClient.user?.id
db.messageDatabaseQueries db.messageDatabaseQueries
.selectActiveDmConversationsForInstance(instanceId()) .selectActiveDmConversationsForInstance(iid)
.executeAsList() .executeAsList()
.map { row: Conversation -> .map { row: Conversation ->
val previewState = previewStrings?.let { strings ->
previewStateForRecentMessage(iid, row.id, strings, currentUserId)
}
CachedConversation( CachedConversation(
id = row.id, id = row.id,
otherUserId = row.otherUserId?.toInt() ?: 0, otherUserId = row.otherUserId?.toInt() ?: 0,
displayName = row.displayName ?: "", displayName = row.displayName ?: "",
lastMessagePreview = row.lastMessagePreview, lastMessagePreview = previewState?.text ?: row.lastMessagePreview,
unreadCount = row.unreadCount.toInt() lastMessagePendingIndicator = previewState?.pendingIndicator
?: ChatListPreviewPendingIndicator.None,
lastMessageUploadProgress = previewState?.uploadProgress,
unreadCount = row.unreadCount.toInt(),
) )
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
fun observeActiveDmConversations(instanceId: String): Flow<List<CachedConversation>> {
val conversationsFlow = db.messageDatabaseQueries
.selectActiveDmConversationsForInstance(instanceId)
.asFlow()
.mapToList(Dispatchers.Default)
val pendingFlow = db.messageDatabaseQueries
.selectAllPendingMessagesForInstance(instanceId)
.asFlow()
.mapToList(Dispatchers.Default)
val outboxFlow = db.messageDatabaseQueries
.selectPendingOutboxForInstance(instanceId)
.asFlow()
.mapToList(Dispatchers.Default)
return merge(conversationsFlow, pendingFlow, outboxFlow)
.mapLatest { loadCachedDmConversations() }
}
@OptIn(ExperimentalCoroutinesApi::class)
fun observePublicChatPreviewState(
instanceId: String,
strings: ChatListPreviewStrings,
): Flow<ChatListPreviewState?> {
val convId = conversationIdForPublic()
val messagesFlow = db.messageDatabaseQueries
.selectMessagesByConversation(instanceId, convId)
.asFlow()
.mapToList(Dispatchers.Default)
val pendingFlow = db.messageDatabaseQueries
.selectAllPendingMessagesForInstance(instanceId)
.asFlow()
.mapToList(Dispatchers.Default)
val outboxFlow = db.messageDatabaseQueries
.selectPendingOutboxForInstance(instanceId)
.asFlow()
.mapToList(Dispatchers.Default)
return merge(messagesFlow, pendingFlow, outboxFlow)
.mapLatest { loadRecentPublicChatPreviewState(strings) }
}
private fun previewStateForRecentMessage(
instanceId: String,
conversationId: String,
strings: ChatListPreviewStrings,
currentUserId: Int?,
): ChatListPreviewState? {
val recent = db.messageDatabaseQueries
.selectRecentMessagesByConversation(instanceId, conversationId, 1)
.executeAsList()
.firstOrNull() ?: return null
val message = enrichQueuedOutboundUi(
listOf(recent.toAppMessage()),
conversationId,
).firstOrNull() ?: return null
return buildChatListPreviewState(message, strings, currentUserId)
.let { state ->
state.copy(
text = state.text
?.let { truncateDmListPreview(it) }
?.takeIf { it.isNotEmpty() },
)
}
}
private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) { private suspend fun syncDmConversationPreviewFromCache(otherUserId: Int) {
val iid = instanceId() val iid = instanceId()
val convId = conversationIdForDm(otherUserId) val convId = conversationIdForDm(otherUserId)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
val row = db.messageDatabaseQueries var row = db.messageDatabaseQueries
.selectConversationsForInstance(iid) .selectConversationById(iid, convId)
.executeAsList() .executeAsOneOrNull()
.find { it.id == convId } ?: return@withContext if (row == null) {
ensureDmConversationRow(otherUserId)
row = db.messageDatabaseQueries
.selectConversationById(iid, convId)
.executeAsOneOrNull()
?: return@withContext
}
val recent = db.messageDatabaseQueries val recent = db.messageDatabaseQueries
.selectRecentMessagesByConversation(iid, convId, 1) .selectRecentMessagesByConversation(iid, convId, 1)
.executeAsList() .executeAsList()
.firstOrNull() .firstOrNull()
val rawPreview = recent?.content.orEmpty().trim() val previewStrings = listPreviewStrings
val preview = rawPreview.takeIf { it.isNotEmpty() } val preview = previewStrings?.let { strings ->
recent?.toAppMessage()?.let { message ->
val enriched = enrichQueuedOutboundUi(listOf(message), convId).firstOrNull()
enriched?.let {
buildChatListPreviewState(it, strings, ApiClient.user?.id).text
}
}
}
?.let { truncateDmListPreview(it) } ?.let { truncateDmListPreview(it) }
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
db.messageDatabaseQueries.upsertConversation( db.messageDatabaseQueries.upsertConversation(
@@ -545,13 +660,14 @@ object MessageCacheStore {
type = row.type, type = row.type,
otherUserId = row.otherUserId, otherUserId = row.otherUserId,
displayName = row.displayName, displayName = row.displayName,
lastMessageId = row.lastMessageId, lastMessageId = recent?.id ?: row.lastMessageId,
lastMessagePreview = preview, lastMessagePreview = preview ?: row.lastMessagePreview,
unreadCount = row.unreadCount, unreadCount = row.unreadCount,
updatedAt = row.updatedAt, updatedAt = recent?.timestamp ?: row.updatedAt,
archived = row.archived, archived = row.archived,
) )
} }
DmConversationListNotifier.notifyChanged()
} }
private suspend fun clearConversationMessages(conversationId: String) { private suspend fun clearConversationMessages(conversationId: String) {
@@ -2,6 +2,8 @@ package ru.fromchat.api.local.db.store
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.messages.ChatListPreviewState
import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
import ru.fromchat.api.local.messages.conversationIdForDm import ru.fromchat.api.local.messages.conversationIdForDm
import ru.fromchat.api.local.messages.conversationIdForGroup import ru.fromchat.api.local.messages.conversationIdForGroup
@@ -29,6 +31,17 @@ object MessageRepository {
suspend fun loadRecentPublicMessages(limit: Long): List<Message> = suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
MessageCacheStore.loadRecentPublicMessages(limit) MessageCacheStore.loadRecentPublicMessages(limit)
suspend fun loadRecentPublicChatPreviewState(
strings: ChatListPreviewStrings,
limit: Long = 1,
): ChatListPreviewState? = MessageCacheStore.loadRecentPublicChatPreviewState(strings, limit)
fun observePublicChatPreviewState(strings: ChatListPreviewStrings): Flow<ChatListPreviewState?> =
MessageCacheStore.observePublicChatPreviewState(activeInstance(), strings)
fun observeActiveDmConversations(): Flow<List<CachedConversation>> =
MessageCacheStore.observeActiveDmConversations(activeInstance())
suspend fun replacePublicMessages(messages: List<Message>) = suspend fun replacePublicMessages(messages: List<Message>) =
MessageCacheStore.replacePublicMessages(messages) MessageCacheStore.replacePublicMessages(messages)
@@ -66,8 +79,8 @@ object MessageRepository {
suspend fun replaceDmConversations( suspend fun replaceDmConversations(
conversations: List<DmConversation>, conversations: List<DmConversation>,
attachmentOnlyPreview: String, previewStrings: ChatListPreviewStrings,
) = MessageCacheStore.replaceDmConversations(conversations, attachmentOnlyPreview) ) = MessageCacheStore.replaceDmConversations(conversations, previewStrings)
suspend fun loadCachedDmConversations(): List<CachedConversation> = suspend fun loadCachedDmConversations(): List<CachedConversation> =
MessageCacheStore.loadCachedDmConversations() MessageCacheStore.loadCachedDmConversations()
@@ -0,0 +1,157 @@
package ru.fromchat.api.local.messages
import ru.fromchat.api.local.db.parseDmMessageContent
import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.messages.dm.DmEnvelope
import ru.fromchat.api.schema.messages.dm.DmFile
import ru.fromchat.ui.chat.isFilenameOnlyMessageCaption
import ru.fromchat.ui.chat.isImageFilename
enum class ChatListPreviewPendingIndicator {
None,
SendingText,
UploadingFile,
}
data class ChatListPreviewState(
val text: String?,
val pendingIndicator: ChatListPreviewPendingIndicator = ChatListPreviewPendingIndicator.None,
val uploadProgress: Int? = null,
) {
fun displayText(default: String): String =
text?.trim()?.takeIf { it.isNotEmpty() } ?: default
}
data class ChatListPreviewStrings(
val imageEmoji: String,
val imageOnly: String,
val attachmentOnly: String,
) {
fun imageWithCaption(caption: String): String = "$imageEmoji $caption"
}
/** Detects image attachments from structured message/envelope fields (never from JSON substring heuristics). */
fun messageHasImageAttachment(message: Message): Boolean {
message.files.orEmpty().forEach { file ->
if (isImageFilename(file.name)) return true
}
if (message.pendingFileUri != null) {
val pendingName = message.pendingFilename?.trim()?.takeIf { it.isNotEmpty() }
?: message.pendingFileUri.substringAfterLast('/').substringBefore('?')
if (isImageFilename(pendingName)) return true
if (message.pendingFileAspectRatio != null) return true
}
if (!parseDmMessageContent(message.content).fileThumbnails.isNullOrEmpty()) return true
return false
}
fun messageHasNonImageFileAttachment(message: Message): Boolean {
message.files.orEmpty().forEach { file ->
if (!isImageFilename(file.name)) return true
}
return message.pendingFileUri != null && !messageHasImageAttachment(message)
}
fun envelopeHasImageAttachment(envelope: DmEnvelope): Boolean =
envelope.files.orEmpty().any { isImageFilename(it.name) }
fun envelopeHasFileAttachment(envelope: DmEnvelope): Boolean =
!envelope.files.isNullOrEmpty()
fun messagePreviewCaption(message: Message): String? =
captionFromParsedContent(
content = message.content,
files = message.files,
pendingFilename = message.pendingFilename,
)
private fun captionFromParsedContent(
content: String,
files: List<DmFile>? = null,
pendingFilename: String? = null,
): String? {
val text = parseDmMessageContent(content).text.trim().takeIf { it.isNotEmpty() } ?: return null
val probe = Message(
id = 0,
user_id = 0,
content = text,
timestamp = "",
is_read = true,
is_edited = false,
username = "",
files = files,
pendingFilename = pendingFilename,
)
if (isFilenameOnlyMessageCaption(probe)) return null
return text
}
fun buildChatListPreview(message: Message, strings: ChatListPreviewStrings): String? =
when {
messageHasImageAttachment(message) -> {
val caption = messagePreviewCaption(message)
if (caption != null) strings.imageWithCaption(caption) else strings.imageOnly
}
else -> {
val caption = messagePreviewCaption(message)
when {
caption != null -> caption
messageHasNonImageFileAttachment(message) -> strings.attachmentOnly
else -> null
}
}
}
fun resolveChatListPreviewPendingIndicator(
message: Message,
currentUserId: Int?,
): Pair<ChatListPreviewPendingIndicator, Int?> {
if (currentUserId == null || message.user_id != currentUserId) {
return ChatListPreviewPendingIndicator.None to null
}
if (!message.isQueuedOutbound() || !message.uploadError.isNullOrBlank()) {
return ChatListPreviewPendingIndicator.None to null
}
val hasFileUpload = !message.pendingFileUri.isNullOrBlank() ||
!message.uploadJobId.isNullOrBlank()
return when {
hasFileUpload ->
ChatListPreviewPendingIndicator.UploadingFile to message.uploadProgress
message.files.isNullOrEmpty() ->
ChatListPreviewPendingIndicator.SendingText to null
else -> ChatListPreviewPendingIndicator.None to null
}
}
fun buildChatListPreviewState(
message: Message,
strings: ChatListPreviewStrings,
currentUserId: Int?,
): ChatListPreviewState {
val (pendingIndicator, uploadProgress) = resolveChatListPreviewPendingIndicator(message, currentUserId)
return ChatListPreviewState(
text = buildChatListPreview(message, strings),
pendingIndicator = pendingIndicator,
uploadProgress = uploadProgress,
)
}
fun buildChatListPreviewFromEnvelope(
envelope: DmEnvelope,
decryptedPlaintext: String?,
strings: ChatListPreviewStrings,
): String? {
val hasImages = envelopeHasImageAttachment(envelope)
val hasFiles = envelopeHasFileAttachment(envelope)
val caption = decryptedPlaintext?.let { plaintext ->
captionFromParsedContent(plaintext, envelope.files)
}
return when {
hasImages -> {
if (caption != null) strings.imageWithCaption(caption) else strings.imageOnly
}
caption != null -> caption
hasFiles -> strings.attachmentOnly
else -> null
}
}
@@ -39,6 +39,101 @@ object OutgoingMessageCoordinator {
private val drainMutex = Mutex() private val drainMutex = Mutex()
private val drainScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val drainScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/** Called when network or WebSocket transport is ready; drains pending outbox rows. */
fun onTransportReady() {
kickOutboxDrain(CacheContext.activeInstanceId.value.trim())
}
private fun scheduleOutboxRetry(instanceId: String) {
drainScope.launch {
delay(3_000)
drainOutboxForInstance(instanceId)
}
}
private suspend fun handlePublicOutboxSend(
instanceId: String,
row: ru.fromchat.db.Outbox,
): Boolean {
val sendResult = runCatching {
val payload = json.decodeFromString<PublicOutboxPayload>(row.payloadJson)
ApiClient.sendMessageViaHttp(
content = payload.content,
replyToId = payload.replyToId,
clientMessageId = row.clientMessageId,
)
}
var ok = true
sendResult.onSuccess { confirmed ->
withContext(Dispatchers.Default) {
MessageCacheStore.confirmPublicMessage(
row.clientMessageId,
confirmed.copy(client_message_id = row.clientMessageId),
)
MessageDatabaseProvider.database.messageDatabaseQueries
.deleteOutboxItem(instanceId, row.clientMessageId)
}
}.onFailure { error ->
when {
error.isOutboundPermanentFailure() -> {
val errorKey = outboundFailureErrorKey(error)
withContext(Dispatchers.Default) {
MessageCacheStore.markSendFailed(row.conversationId, row.clientMessageId)
}
OutboundSendNotifier.emit(
OutboundSendProgress.Failed(row.clientMessageId, errorKey),
)
}
error.isOutboundTransientFailure() -> {
ok = false
scheduleOutboxRetry(instanceId)
}
}
}
return ok
}
private suspend fun handleDmOutboxSend(
instanceId: String,
row: ru.fromchat.db.Outbox,
): Boolean {
val sendResult = runCatching {
val payload = json.decodeFromString<DmOutboxPayload>(row.payloadJson)
ApiClient.sendDm(
recipientId = payload.recipientId,
plaintext = payload.plaintext,
clientMessageId = payload.clientMessageId,
replyToId = payload.replyToId,
transportFiles = payload.transportFiles,
uploadedFileIds = payload.uploadedFileIds,
)
}
var ok = true
sendResult.onSuccess {
withContext(Dispatchers.Default) {
MessageDatabaseProvider.database.messageDatabaseQueries
.deleteOutboxItem(instanceId, row.clientMessageId)
}
}.onFailure { error ->
when {
error.isOutboundPermanentFailure() -> {
val errorKey = outboundFailureErrorKey(error)
withContext(Dispatchers.Default) {
MessageCacheStore.markSendFailed(row.conversationId, row.clientMessageId)
}
OutboundSendNotifier.emit(
OutboundSendProgress.Failed(row.clientMessageId, errorKey),
)
}
error.isOutboundTransientFailure() -> {
ok = false
scheduleOutboxRetry(instanceId)
}
}
}
return ok
}
private fun kickOutboxDrain(instanceId: String) { private fun kickOutboxDrain(instanceId: String) {
val id = instanceId.trim() val id = instanceId.trim()
if (id.isEmpty()) return if (id.isEmpty()) return
@@ -253,60 +348,19 @@ object OutgoingMessageCoordinator {
for (row in rows) { for (row in rows) {
when (row.kind) { when (row.kind) {
KIND_SEND_PUBLIC -> { KIND_SEND_PUBLIC -> {
val sendResult = runCatching { if (!handlePublicOutboxSend(id, row)) {
val payload = json.decodeFromString<PublicOutboxPayload>(row.payloadJson) allOk = false
ApiClient.sendMessageViaHttp(payload.content, payload.replyToId)
}
sendResult.onSuccess {
withContext(Dispatchers.Default) {
MessageDatabaseProvider.database.messageDatabaseQueries
.deleteOutboxItem(id, row.clientMessageId)
}
}.onFailure { error ->
when {
error.isOutboundPermanentFailure() -> {
val errorKey = outboundFailureErrorKey(error)
withContext(Dispatchers.Default) {
MessageCacheStore.markSendFailed(row.conversationId, row.clientMessageId)
}
OutboundSendNotifier.emit(
OutboundSendProgress.Failed(row.clientMessageId, errorKey),
)
}
error.isOutboundTransientFailure() -> {
allOk = false
drainScope.launch {
delay(3_000)
drainOutboxForInstance(id)
}
}
}
} }
} }
KIND_SEND_DM -> { KIND_SEND_DM -> {
runCatching { if (!handleDmOutboxSend(id, row)) {
val payload = json.decodeFromString<DmOutboxPayload>(row.payloadJson) allOk = false
ApiClient.sendDm(
recipientId = payload.recipientId,
plaintext = payload.plaintext,
clientMessageId = payload.clientMessageId,
replyToId = payload.replyToId,
transportFiles = payload.transportFiles,
uploadedFileIds = payload.uploadedFileIds,
)
withContext(Dispatchers.Default) {
MessageDatabaseProvider.database.messageDatabaseQueries
.deleteOutboxItem(id, row.clientMessageId)
}
} }
} }
KIND_SEND_DM_ATTACHMENT -> { KIND_SEND_DM_ATTACHMENT -> {
if (!DmAttachmentOutboxHandler.process(row)) { if (!DmAttachmentOutboxHandler.process(row)) {
allOk = false allOk = false
drainScope.launch { scheduleOutboxRetry(id)
delay(3_000)
drainOutboxForInstance(id)
}
} }
} }
KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> Unit KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> Unit
@@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class SendMessageRequest( data class SendMessageRequest(
val content: String, val content: String,
val reply_to_id: Int? = null val reply_to_id: Int? = null,
val client_message_id: String? = null,
) )
@@ -66,7 +66,6 @@ import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.UserStatusStore import ru.fromchat.api.local.db.store.UserStatusStore
import ru.fromchat.api.local.send.OutgoingMessageCoordinator import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.schema.websocket.WebSocketMessage import ru.fromchat.api.schema.websocket.WebSocketMessage
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
@@ -278,10 +277,7 @@ fun App(
MainScope().launch { MainScope().launch {
val instanceId = CacheContext.activeInstanceId.value.trim() val instanceId = CacheContext.activeInstanceId.value.trim()
if (instanceId.isNotEmpty()) { if (instanceId.isNotEmpty()) {
scheduleOutboxProcessing(instanceId) OutgoingMessageCoordinator.onTransportReady()
kotlinx.coroutines.withContext(Dispatchers.Default) {
OutgoingMessageCoordinator.drainOutboxForInstance(instanceId)
}
} }
} }
} }
@@ -1,7 +1,5 @@
package ru.fromchat.ui.auth.register package ru.fromchat.ui.auth.register
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -12,7 +10,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.TextButton
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
@@ -20,16 +17,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.about_link_privacy
import ru.fromchat.about_link_terms
import ru.fromchat.auth_char_count import ru.fromchat.auth_char_count
import ru.fromchat.auth_legal_notice_and
import ru.fromchat.auth_legal_notice_prefix
import ru.fromchat.auth_step_profile_body import ru.fromchat.auth_step_profile_body
import ru.fromchat.auth_step_profile_title import ru.fromchat.auth_step_profile_title
import ru.fromchat.auth_username_taken import ru.fromchat.auth_username_taken
@@ -38,8 +30,6 @@ import ru.fromchat.display_name_error
import ru.fromchat.error_unexpected import ru.fromchat.error_unexpected
import ru.fromchat.profile_headline_bio import ru.fromchat.profile_headline_bio
import ru.fromchat.register_button import ru.fromchat.register_button
import ru.fromchat.legal.DocumentType
import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.auth.RegisterResult import ru.fromchat.ui.auth.RegisterResult
import ru.fromchat.ui.auth.register import ru.fromchat.ui.auth.register
import ru.fromchat.ui.components.ActionButton import ru.fromchat.ui.components.ActionButton
@@ -70,7 +60,6 @@ internal fun profileStepPage(
onSnackbar: (String) -> Unit, onSnackbar: (String) -> Unit,
): ExpressiveStepPage { ): ExpressiveStepPage {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val navController = LocalNavController.current
val fieldColors = expressiveStepFieldColors() val fieldColors = expressiveStepFieldColors()
val colorScheme = MaterialTheme.colorScheme val colorScheme = MaterialTheme.colorScheme
@@ -134,36 +123,6 @@ internal fun profileStepPage(
) )
}, },
button = { button = {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = SettingsStepHorizontalPadding, vertical = 8.dp),
horizontalArrangement = Arrangement.Center,
) {
Text(
text = stringResource(Res.string.auth_legal_notice_prefix),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
TextButton(onClick = { navController.navigate(DocumentType.route(DocumentType.Terms)) }) {
Text(
text = stringResource(Res.string.about_link_terms),
style = MaterialTheme.typography.bodySmall,
)
}
Text(
text = stringResource(Res.string.auth_legal_notice_and),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
TextButton(onClick = { navController.navigate(DocumentType.route(DocumentType.Privacy)) }) {
Text(
text = stringResource(Res.string.about_link_privacy),
style = MaterialTheme.typography.bodySmall,
)
}
}
ActionButton( ActionButton(
onClick = { onClick = {
if (busy) return@ActionButton if (busy) return@ActionButton
@@ -204,11 +204,13 @@ fun ChatScreen(
} }
} }
// Subscribe to other user's status when DM is visible; unsubscribe on leave // Subscribe to other user's status when DM is visible; re-subscribe after reconnect
LaunchedEffect(panelState.profileUserId) { LaunchedEffect(panelState.profileUserId, connectionStatus) {
val userId = panelState.profileUserId val userId = panelState.profileUserId
if (userId != null) { if (userId != null) {
runCatching { ApiClient.sendSubscribeStatus(userId) } if (connectionStatus == ConnectionStatus.CONNECTED) {
runCatching { ApiClient.sendSubscribeStatus(userId) }
}
try { try {
kotlinx.coroutines.awaitCancellation() kotlinx.coroutines.awaitCancellation()
} finally { } finally {
@@ -302,10 +304,10 @@ fun ChatScreen(
val lastSeen = data["lastSeen"]?.jsonPrimitive?.content val lastSeen = data["lastSeen"]?.jsonPrimitive?.content
if (userId != null) UserStatusStore.update(userId, online, lastSeen) if (userId != null) UserStatusStore.update(userId, online, lastSeen)
} }
"newMessage", "messageEdited", "messageDeleted", "newMessage", "messageEdited", "messageDeleted", "sendMessage",
"dmNew", "dmEdited", "dmDeleted", "dmNew", "dmEdited", "dmDeleted",
"typing", "stopTyping", "dmTyping", "stopDmTyping", "typing", "stopTyping", "dmTyping", "stopDmTyping",
"registeredUserCount" -> { "reactionUpdate", "registeredUserCount" -> {
Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}") Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}")
try { try {
panel.handleWebSocketMessage(wsMessage) panel.handleWebSocketMessage(wsMessage)
@@ -327,7 +329,8 @@ fun ChatScreen(
if (userId != null) UserStatusStore.update(userId, online, lastSeen) if (userId != null) UserStatusStore.update(userId, online, lastSeen)
} }
"newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted", "newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted",
"dmTyping", "stopDmTyping", "registeredUserCount" -> { "dmTyping", "stopDmTyping", "typing", "stopTyping", "reactionUpdate",
"registeredUserCount" -> {
scope.launch { scope.launch {
panel.handleWebSocketMessage(message) panel.handleWebSocketMessage(message)
} }
@@ -79,13 +79,7 @@ class DmPanel(
} }
init { init {
updateState { applyCachedPeerProfileOrReset()
it.copy(
title = "",
titleAvatar = null,
profileUserId = otherUserId
)
}
coroutineScope.launch { coroutineScope.launch {
typingHandler.typingUsers.collect { users -> typingHandler.typingUsers.collect { users ->
updateState { it.copy(typingUsers = users) } updateState { it.copy(typingUsers = users) }
@@ -108,39 +102,77 @@ class DmPanel(
} }
} }
coroutineScope.launch(Dispatchers.Default) { coroutineScope.launch(Dispatchers.Default) {
if (_state.title.isBlank()) {
loadPeerTitleFromConversationCache()
}
runCatching { runCatching {
ApiClient.getProfileById(otherUserId) ApiClient.getProfileById(otherUserId)
}.onSuccess { profile -> }.onSuccess { profile ->
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) { if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
ProfileCache.evictUnusableClientPreview(otherUserId) ProfileCache.evictUnusableClientPreview(otherUserId)
updateState { if (_state.title.isBlank()) {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) updateState {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
}
} }
return@onSuccess return@onSuccess
} }
ProfileCache.put(profile) ProfileCache.put(profile)
val displayName = profile.visibleDisplayName(ApiClient.user?.id).orEmpty() val displayName = profile.visibleDisplayName(ApiClient.user?.id).orEmpty()
otherDisplayName = displayName if (displayName.isNotBlank()) {
otherProfilePicture = profile.profilePicture applyPeerTitle(displayName, profile.profilePicture)
updateState {
it.copy(
title = displayName,
titleAvatar = AvatarInfo(
displayName = displayName,
profilePictureUrl = otherProfilePicture
),
profileUserId = otherUserId
)
} }
}.onFailure { }.onFailure {
ProfileCache.evictUnusableClientPreview(otherUserId) ProfileCache.evictUnusableClientPreview(otherUserId)
updateState { if (_state.title.isBlank()) {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId) updateState {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
}
} }
} }
} }
} }
private fun applyCachedPeerProfileOrReset() {
val cached = ProfileCache.get(otherUserId)
val displayName = cached?.visibleDisplayName(ApiClient.user?.id).orEmpty()
if (displayName.isNotBlank()) {
applyPeerTitle(displayName, cached?.profilePicture)
} else {
updateState {
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
}
}
}
private fun applyPeerTitle(displayName: String, profilePicture: String?) {
otherDisplayName = displayName
otherProfilePicture = profilePicture
updateState {
it.copy(
title = displayName,
titleAvatar = AvatarInfo(
displayName = displayName,
profilePictureUrl = profilePicture,
),
profileUserId = otherUserId,
)
}
}
private suspend fun loadPeerTitleFromConversationCache() {
val conversation = runCatching {
MessageCacheStore.loadCachedDmConversations()
}.getOrNull()?.find { it.otherUserId == otherUserId } ?: return
val displayName = conversation.displayName.takeIf { it.isNotBlank() } ?: return
ProfileCache.mergeFromCachedConversation(conversation)
withContext(Dispatchers.Main) {
if (_state.title.isBlank()) {
applyPeerTitle(displayName, ProfileCache.get(otherUserId)?.profilePicture)
}
}
}
override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) {
val cid = clientMessageId?.trim().orEmpty() val cid = clientMessageId?.trim().orEmpty()
if (cid.isEmpty()) return if (cid.isEmpty()) return
@@ -313,14 +345,15 @@ class DmPanel(
if (envelope.senderId == currentUserId) { if (envelope.senderId == currentUserId) {
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted) mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
} else { } else {
addMessage(createMessage(envelope, outcome.plaintext, outcome.isCorrupted)) val incoming = createMessage(envelope, outcome.plaintext, outcome.isCorrupted)
withContext(Dispatchers.Default) {
MessageCacheStore.upsertDmMessage(otherUserId, incoming)
}
addMessage(incoming)
if (envelope.replyToId != null) { if (envelope.replyToId != null) {
val replyTo = _state.messages.find { it.id == envelope.replyToId } val replyTo = _state.messages.find { it.id == envelope.replyToId }
updateMessage(envelope.id) { it.copy(reply_to = replyTo) } updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
} }
scope.launch(Dispatchers.Default) {
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
}
} }
} }
} }
@@ -68,13 +68,18 @@ class PublicChatPanel(
get() = true get() = true
init { init {
updateState { val cachedProfile = PublicChatProfileCache.profile
it.copy( if (cachedProfile != null) {
title = "", applyPublicChatProfile(cachedProfile)
titleAvatar = null, } else {
publicGroupMetaLoading = true, updateState {
publicGroupMemberCount = null it.copy(
) title = "",
titleAvatar = null,
publicGroupMetaLoading = true,
publicGroupMemberCount = null,
)
}
} }
scope.launch { scope.launch {
typingHandler.typingUsers.collect { users -> typingHandler.typingUsers.collect { users ->
@@ -83,17 +88,13 @@ class PublicChatPanel(
} }
} }
scope.launch(Dispatchers.Default) { scope.launch(Dispatchers.Default) {
val cached = PublicChatProfileCache.profile
if (cached != null) {
applyPublicChatProfile(cached)
}
runCatching { ApiClient.getPublicChatProfile() } runCatching { ApiClient.getPublicChatProfile() }
.onSuccess { profile -> .onSuccess { profile ->
PublicChatProfileCache.put(profile) PublicChatProfileCache.put(profile)
applyPublicChatProfile(profile) applyPublicChatProfile(profile)
} }
.onFailure { .onFailure {
if (cached == null) { if (cachedProfile == null) {
updateState { s -> s.copy(publicGroupMetaLoading = false) } updateState { s -> s.copy(publicGroupMetaLoading = false) }
} }
} }
@@ -118,41 +119,39 @@ class PublicChatPanel(
} }
/** /**
* Server often omits [Message.client_message_id] on broadcast [newMessage] / [SendMessageResponse.message]. * Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
* Match the oldest pending optimistic row (same user, text, reply) and replace it; otherwise append.
*/ */
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) { private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
val uid = currentUserId val uid = currentUserId
if (uid == null) { if (uid != null && newMsg.user_id == uid) {
addMessage(newMsg) val cid = newMsg.client_message_id?.trim().orEmpty()
return if (cid.isNotEmpty()) {
handleMessageConfirmed(cid, newMsg)
return
}
if (newMsg.id > 0 && _state.messages.any { it.id == newMsg.id }) {
return
}
} }
if (newMsg.user_id != uid) { ingestIncomingPublicMessage(newMsg)
addMessage(newMsg) }
return
} private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
if (newMsg.client_message_id != null) { addMessage(newMsg)
handleMessageConfirmed(newMsg.client_message_id, newMsg) withContext(Dispatchers.Default) {
return MessageCacheStore.upsertPublicMessage(newMsg)
}
if (newMsg.id <= 0) {
addMessage(newMsg)
return
}
val pending = _state.messages.firstOrNull { msg ->
msg.id < 0 &&
msg.user_id == uid &&
msg.client_message_id != null &&
msg.content == newMsg.content &&
msg.reply_to?.id == newMsg.reply_to?.id
}
if (pending?.client_message_id != null) {
handleMessageConfirmed(pending.client_message_id, newMsg)
} else {
addMessage(newMsg)
} }
} }
private fun mergeNetworkHistoryWithShown(shown: List<Message>, fromNetwork: List<Message>): List<Message> {
val networkIds = fromNetwork.map { it.id }.toSet()
val ahead = shown.filter { it.id > 0 && it.id !in networkIds }
if (ahead.isEmpty()) return fromNetwork
return ru.fromchat.api.local.messages.sortMessagesForChatDisplay(
ru.fromchat.ui.chat.utils.dedupeMessagesByClientId(fromNetwork + ahead),
)
}
override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) { override suspend fun sendMessage(content: String, replyToId: Int?, clientMessageId: String?) {
val cid = clientMessageId?.trim().orEmpty() val cid = clientMessageId?.trim().orEmpty()
if (cid.isEmpty()) return if (cid.isEmpty()) return
@@ -224,15 +223,17 @@ class PublicChatPanel(
if (_state.isLoading) setLoading(false) if (_state.isLoading) setLoading(false)
} else { } else {
batchStateUpdates { batchStateUpdates {
val merged = mergeNetworkHistoryWithShown(shown, response.messages)
clearMessages() clearMessages()
addMessages(response.messages) addMessages(merged)
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false) setLoading(false)
} }
} }
} }
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.replacePublicMessages(response.messages) val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, response.messages)
MessageCacheStore.replacePublicMessages(mergedForCache)
} }
} else if (responseResult.isFailure) { } else if (responseResult.isFailure) {
val cause = responseResult.exceptionOrNull() val cause = responseResult.exceptionOrNull()
@@ -10,11 +10,18 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ConnectionStatus
import ru.fromchat.api.local.send.OutgoingMessageCoordinator
import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.ui.chat.ChatScreen import ru.fromchat.ui.chat.ChatScreen
import ru.fromchat.ui.chat.utils.PublicChatPanelCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.utils.NetworkConnectivity
@Composable @Composable
fun PublicChatScreen( fun PublicChatScreen(
@@ -32,6 +39,8 @@ fun PublicChatScreen(
} }
val activeInstanceId by CacheContext.activeInstanceId.collectAsState() val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
val connectionStatus by ConnectionStateStore.status.collectAsState()
LaunchedEffect(panel, activeInstanceId) { LaunchedEffect(panel, activeInstanceId) {
if (activeInstanceId.isBlank()) return@LaunchedEffect if (activeInstanceId.isBlank()) return@LaunchedEffect
@@ -40,6 +49,15 @@ fun PublicChatScreen(
} }
} }
LaunchedEffect(activeInstanceId, online, connectionStatus) {
val instanceId = activeInstanceId.trim()
if (instanceId.isBlank() || !online) return@LaunchedEffect
scheduleOutboxProcessing(instanceId)
withContext(Dispatchers.Default) {
OutgoingMessageCoordinator.drainOutboxForInstance(instanceId)
}
}
LaunchedEffect(panel, activeInstanceId) { LaunchedEffect(panel, activeInstanceId) {
if (activeInstanceId.isBlank()) return@LaunchedEffect if (activeInstanceId.isBlank()) return@LaunchedEffect
MessageRepository.observePublicMessages().collect { rows -> MessageRepository.observePublicMessages().collect { rows ->
@@ -0,0 +1,54 @@
package ru.fromchat.ui.components
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
@Composable
fun shimmerBrush(
baseColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f),
highlightColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.16f),
): Brush {
val transition = rememberInfiniteTransition(label = "shimmer")
val translate by transition.animateFloat(
initialValue = -400f,
targetValue = 400f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "shimmer_translate",
)
return Brush.linearGradient(
colors = listOf(baseColor, highlightColor, baseColor),
start = Offset(translate, translate),
end = Offset(translate + 400f, translate + 400f),
)
}
@Composable
fun ShimmerBox(
modifier: Modifier = Modifier,
shape: Shape = CircleShape,
) {
Box(
modifier = modifier
.clip(shape)
.background(shimmerBrush()),
)
}
@@ -32,6 +32,7 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.RadioButtonUnchecked
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -46,6 +47,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin import androidx.compose.ui.graphics.TransformOrigin
@@ -68,10 +71,15 @@ import ru.fromchat.api.local.db.store.CachedConversation
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.UserStatus import ru.fromchat.api.local.db.store.UserStatus
import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.local.messages.ChatListPreviewPendingIndicator
import ru.fromchat.api.local.messages.ChatListPreviewState
import ru.fromchat.api.schema.user.User import ru.fromchat.api.schema.user.User
import ru.fromchat.cd_chat_preview_sending
import ru.fromchat.cd_chat_preview_uploading
import ru.fromchat.cd_chat_selected import ru.fromchat.cd_chat_selected
import ru.fromchat.presence_online import ru.fromchat.presence_online
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.ExpressiveUploadIndicator
import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.chat.TypingIndicator
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.unread_count import ru.fromchat.unread_count
@@ -118,7 +126,7 @@ internal fun ChatConversationsList(
listFilter: ChatListFilter, listFilter: ChatListFilter,
conversations: List<CachedConversation>, conversations: List<CachedConversation>,
publicChatTitle: String?, publicChatTitle: String?,
publicLastMessagePreview: String?, publicChatPreviewState: ChatListPreviewState?,
defaultLastMessage: String, defaultLastMessage: String,
statusMap: Map<Int, UserStatus>, statusMap: Map<Int, UserStatus>,
listMode: ChatsListMode, listMode: ChatsListMode,
@@ -175,7 +183,7 @@ internal fun ChatConversationsList(
val position = listItemPositionInGroup(0, groupCount) val position = listItemPositionInGroup(0, groupCount)
PublicChatRow( PublicChatRow(
publicChatTitle = publicChatTitle, publicChatTitle = publicChatTitle,
publicLastMessagePreview = publicLastMessagePreview, publicChatPreviewState = publicChatPreviewState,
defaultLastMessage = defaultLastMessage, defaultLastMessage = defaultLastMessage,
lazyIndex = ChatListLayout.PUBLIC_CHAT_ROW, lazyIndex = ChatListLayout.PUBLIC_CHAT_ROW,
listMode = listMode, listMode = listMode,
@@ -507,7 +515,7 @@ internal fun ChatRowAvatar(
@Composable @Composable
internal fun PublicChatRow( internal fun PublicChatRow(
publicChatTitle: String?, publicChatTitle: String?,
publicLastMessagePreview: String?, publicChatPreviewState: ChatListPreviewState?,
defaultLastMessage: String, defaultLastMessage: String,
lazyIndex: Int, lazyIndex: Int,
listMode: ChatsListMode, listMode: ChatsListMode,
@@ -569,7 +577,7 @@ internal fun PublicChatRow(
) { ) {
PublicChatRowContent( PublicChatRowContent(
publicChatTitle = publicChatTitle, publicChatTitle = publicChatTitle,
publicLastMessagePreview = publicLastMessagePreview, publicChatPreviewState = publicChatPreviewState,
defaultLastMessage = defaultLastMessage, defaultLastMessage = defaultLastMessage,
listMode = listMode, listMode = listMode,
selectionTransitionProgress = selectionTransitionProgress, selectionTransitionProgress = selectionTransitionProgress,
@@ -591,7 +599,7 @@ internal fun PublicChatRow(
@Composable @Composable
internal fun PublicChatRowContent( internal fun PublicChatRowContent(
publicChatTitle: String?, publicChatTitle: String?,
publicLastMessagePreview: String?, publicChatPreviewState: ChatListPreviewState?,
defaultLastMessage: String, defaultLastMessage: String,
listMode: ChatsListMode, listMode: ChatsListMode,
selectionTransitionProgress: Float, selectionTransitionProgress: Float,
@@ -606,11 +614,22 @@ internal fun PublicChatRowContent(
onBodyLongPress: () -> Unit, onBodyLongPress: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val preview = publicLastMessagePreview ?: defaultLastMessage val preview = publicChatPreviewState?.displayText(defaultLastMessage) ?: defaultLastMessage
ListItem( ListItem(
headline = publicChatTitle.orEmpty(), headline = publicChatTitle.orEmpty(),
supportingText = if (publicChatTitle != null) preview else null, supportingSlot = if (publicChatTitle != null) {
{
ChatListPreviewSupportingText(
preview = preview,
pendingIndicator = publicChatPreviewState?.pendingIndicator
?: ChatListPreviewPendingIndicator.None,
uploadProgress = publicChatPreviewState?.uploadProgress,
)
}
} else {
null
},
containerColor = Color.Transparent, containerColor = Color.Transparent,
position = listItemPosition, position = listItemPosition,
groupItemCount = groupItemCount, groupItemCount = groupItemCount,
@@ -783,11 +802,10 @@ internal fun DmConversationRowContent(
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
else -> Text( else -> ChatListPreviewSupportingText(
text = preview, preview = preview,
maxLines = 2, pendingIndicator = conversation.lastMessagePendingIndicator,
overflow = TextOverflow.Ellipsis, uploadProgress = conversation.lastMessageUploadProgress,
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
} }
@@ -863,6 +881,65 @@ internal fun chatContextMenuClampedMenuX(
} }
internal const val ChatRowContextMenuPressScale = 0.96f internal const val ChatRowContextMenuPressScale = 0.96f
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
internal fun ChatListPreviewSupportingText(
preview: String,
pendingIndicator: ChatListPreviewPendingIndicator,
uploadProgress: Int?,
modifier: Modifier = Modifier,
) {
val previewColor = MaterialTheme.colorScheme.onSurfaceVariant
val indicatorColor = previewColor.copy(alpha = 0.7f)
val sendingCd = stringResource(Res.string.cd_chat_preview_sending)
val uploadingCd = stringResource(Res.string.cd_chat_preview_uploading)
Row(
modifier = modifier,
verticalAlignment = Alignment.CenterVertically,
) {
when (pendingIndicator) {
ChatListPreviewPendingIndicator.SendingText -> {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.5.dp,
color = indicatorColor,
)
Spacer(modifier = Modifier.width(6.dp))
}
ChatListPreviewPendingIndicator.UploadingFile -> {
Box(
modifier = Modifier.size(12.dp),
contentAlignment = Alignment.Center,
) {
ExpressiveUploadIndicator(
uploadProgress = uploadProgress,
modifier = Modifier.fillMaxSize(),
indicatorColor = indicatorColor,
trackColorOverride = indicatorColor.copy(alpha = 0.35f),
)
}
Spacer(modifier = Modifier.width(6.dp))
}
ChatListPreviewPendingIndicator.None -> Unit
}
Text(
text = preview,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = previewColor,
modifier = when (pendingIndicator) {
ChatListPreviewPendingIndicator.SendingText ->
Modifier.semantics { contentDescription = "$sendingCd. $preview" }
ChatListPreviewPendingIndicator.UploadingFile ->
Modifier.semantics { contentDescription = "$uploadingCd. $preview" }
ChatListPreviewPendingIndicator.None -> Modifier
},
)
}
}
internal val ChatRowPressSpring = spring<Float>( internal val ChatRowPressSpring = spring<Float>(
dampingRatio = Spring.DampingRatioNoBouncy, dampingRatio = Spring.DampingRatioNoBouncy,
stiffness = Spring.StiffnessMediumLow, stiffness = Spring.StiffnessMediumLow,
@@ -66,6 +66,9 @@ import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.schema.user.User import ru.fromchat.api.schema.user.User
import ru.fromchat.chat_last_mesaage import ru.fromchat.chat_last_mesaage
import ru.fromchat.chat_preview_attachment import ru.fromchat.chat_preview_attachment
import ru.fromchat.chat_preview_image
import ru.fromchat.chat_preview_image_emoji
import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.search_hint import ru.fromchat.search_hint
import ru.fromchat.search_not_found import ru.fromchat.search_not_found
import ru.fromchat.search_not_found_message import ru.fromchat.search_not_found_message
@@ -86,8 +89,13 @@ fun ChatsSearchScreen(
var searchText by remember { mutableStateOf("") } var searchText by remember { mutableStateOf("") }
val searchListState = rememberLazyListState() val searchListState = rememberLazyListState()
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
val imageEmoji = stringResource(Res.string.chat_preview_image_emoji)
val previewStrings = ChatListPreviewStrings(
imageEmoji = imageEmoji,
imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji),
attachmentOnly = stringResource(Res.string.chat_preview_attachment),
)
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
val attachmentOnlyPreview = stringResource(Res.string.chat_preview_attachment)
val searchHint = stringResource(Res.string.search_hint) val searchHint = stringResource(Res.string.search_hint)
val searchBarHint = stringResource(Res.string.search_title) val searchBarHint = stringResource(Res.string.search_title)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) } var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
@@ -163,7 +171,7 @@ fun ChatsSearchScreen(
}.onSuccess { conversations -> }.onSuccess { conversations ->
runCatching { runCatching {
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview) MessageRepository.replaceDmConversations(conversations, previewStrings)
dmConversations = MessageRepository.loadCachedDmConversations() dmConversations = MessageRepository.loadCachedDmConversations()
} }
} }
@@ -8,6 +8,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import com.pr0gramm3r101.components.ListItemPosition import com.pr0gramm3r101.components.ListItemPosition
import ru.fromchat.api.local.db.store.CachedConversation import ru.fromchat.api.local.db.store.CachedConversation
import ru.fromchat.api.local.messages.ChatListPreviewState
import ru.fromchat.api.local.db.store.UserStatus import ru.fromchat.api.local.db.store.UserStatus
enum class ChatsListMode { enum class ChatsListMode {
@@ -83,7 +84,7 @@ data class ChatContextMenuOverlayUiState(
val blurProgress: Float = 0f, val blurProgress: Float = 0f,
val listFilter: ChatListFilter = ChatListFilter.Active, val listFilter: ChatListFilter = ChatListFilter.Active,
val publicChatTitle: String? = null, val publicChatTitle: String? = null,
val publicLastMessagePreview: String? = null, val publicChatPreviewState: ChatListPreviewState? = null,
val publicChatLink: String? = null, val publicChatLink: String? = null,
val defaultLastMessage: String = "", val defaultLastMessage: String = "",
val conversations: List<CachedConversation> = emptyList(), val conversations: List<CachedConversation> = emptyList(),
@@ -88,6 +88,9 @@ import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.store.CachedConversation import ru.fromchat.api.local.db.store.CachedConversation
import ru.fromchat.api.local.db.store.ConnectionStateStore import ru.fromchat.api.local.db.store.ConnectionStateStore
import ru.fromchat.api.local.db.store.ConnectionStatus import ru.fromchat.api.local.db.store.ConnectionStatus
import ru.fromchat.api.local.messages.ChatListPreviewState
import ru.fromchat.api.local.messages.ChatListPreviewStrings
import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache
@@ -100,6 +103,8 @@ import ru.fromchat.chat_delete_confirm_body
import ru.fromchat.chat_delete_confirm_title import ru.fromchat.chat_delete_confirm_title
import ru.fromchat.chat_last_mesaage import ru.fromchat.chat_last_mesaage
import ru.fromchat.chat_preview_attachment import ru.fromchat.chat_preview_attachment
import ru.fromchat.chat_preview_image
import ru.fromchat.chat_preview_image_emoji
import ru.fromchat.chats_selected_count import ru.fromchat.chats_selected_count
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import ru.fromchat.search_title import ru.fromchat.search_title
@@ -281,7 +286,7 @@ fun ChatsTab(
val connectionStatus by ConnectionStateStore.status.collectAsState() val connectionStatus by ConnectionStateStore.status.collectAsState()
val online by NetworkConnectivity.isOnline.collectAsState(initial = true) val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) } var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
var publicLastMessagePreview by remember { mutableStateOf<String?>(null) } var publicChatPreviewState by remember { mutableStateOf<ChatListPreviewState?>(null) }
var publicChatProfile by remember { mutableStateOf(PublicChatProfileCache.profile) } var publicChatProfile by remember { mutableStateOf(PublicChatProfileCache.profile) }
val searchBarHint = stringResource(Res.string.search_title) val searchBarHint = stringResource(Res.string.search_title)
val tabListState = rememberLazyListState() val tabListState = rememberLazyListState()
@@ -289,9 +294,22 @@ fun ChatsTab(
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) } var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
val statusSubscriptionScope = rememberCoroutineScope() val statusSubscriptionScope = rememberCoroutineScope()
val suspensionState by ApiClient.suspensionState.collectAsState() val suspensionState by ApiClient.suspensionState.collectAsState()
val attachmentOnlyPreview = stringResource(Res.string.chat_preview_attachment) val imageEmoji = stringResource(Res.string.chat_preview_image_emoji)
val previewStrings = ChatListPreviewStrings(
imageEmoji = imageEmoji,
imageOnly = stringResource(Res.string.chat_preview_image, imageEmoji),
attachmentOnly = stringResource(Res.string.chat_preview_attachment),
)
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage) val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) {
MessageCacheStore.listPreviewStrings = previewStrings
}
SideEffect {
MessageCacheStore.listPreviewStrings = previewStrings
}
var listMode by remember { mutableStateOf(ChatsListMode.Normal) } var listMode by remember { mutableStateOf(ChatsListMode.Normal) }
var publicChatSelected by remember { mutableStateOf(false) } var publicChatSelected by remember { mutableStateOf(false) }
var selectedOtherUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) } var selectedOtherUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
@@ -399,7 +417,7 @@ fun ChatsTab(
onDispose { chatContextMenuOverlay.clear() } onDispose { chatContextMenuOverlay.clear() }
} }
LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) { LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch, connectionStatus) {
snapshotFlow { snapshotFlow {
if (!isVisible) { if (!isVisible) {
emptySet() emptySet()
@@ -415,8 +433,10 @@ fun ChatsTab(
} }
.distinctUntilChanged() .distinctUntilChanged()
.collect { visibleIds -> .collect { visibleIds ->
(visibleIds - subscribedDmUserIds).forEach { userId -> if (connectionStatus == ConnectionStatus.CONNECTED) {
runCatching { ApiClient.sendSubscribeStatus(userId) } visibleIds.forEach { userId ->
runCatching { ApiClient.sendSubscribeStatus(userId) }
}
} }
(subscribedDmUserIds - visibleIds).forEach { userId -> (subscribedDmUserIds - visibleIds).forEach { userId ->
@@ -441,6 +461,32 @@ fun ChatsTab(
val serverConfig by ServerConfig.serverConfig.collectAsState() val serverConfig by ServerConfig.serverConfig.collectAsState()
val activeInstanceId by CacheContext.activeInstanceId.collectAsState() val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
LaunchedEffect(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) {
if (activeInstanceId.isBlank()) {
publicChatPreviewState = null
return@LaunchedEffect
}
publicChatProfile = PublicChatProfileCache.profile
runCatching {
publicChatPreviewState = MessageRepository.loadRecentPublicChatPreviewState(previewStrings)
}
}
LaunchedEffect(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) {
if (activeInstanceId.isBlank()) return@LaunchedEffect
MessageRepository.observeActiveDmConversations().collect { conversations ->
conversations.forEach { ProfileCache.mergeFromCachedConversation(it) }
dmConversations = conversations
}
}
LaunchedEffect(activeInstanceId, previewStrings.imageOnly, previewStrings.attachmentOnly) {
if (activeInstanceId.isBlank()) return@LaunchedEffect
MessageRepository.observePublicChatPreviewState(previewStrings).collect { state ->
publicChatPreviewState = state
}
}
LaunchedEffect(serverConfig, activeInstanceId) { LaunchedEffect(serverConfig, activeInstanceId) {
if (activeInstanceId.isBlank()) return@LaunchedEffect if (activeInstanceId.isBlank()) return@LaunchedEffect
@@ -451,21 +497,12 @@ fun ChatsTab(
dmConversations = conversations dmConversations = conversations
} }
runCatching {
publicLastMessagePreview = MessageRepository
.loadRecentPublicMessages(1)
.lastOrNull()
?.content
?.trim()
?.takeIf { it.isNotEmpty() }
}
runCatching { runCatching {
ApiClient.getDmConversations() ApiClient.getDmConversations()
}.onSuccess { conversations -> }.onSuccess { conversations ->
runCatching { runCatching {
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) } conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview) MessageRepository.replaceDmConversations(conversations, previewStrings)
dmConversations = MessageRepository.loadCachedDmConversations() dmConversations = MessageRepository.loadCachedDmConversations()
} }
} }
@@ -652,7 +689,7 @@ fun ChatsTab(
listFilter = ChatListFilter.Active, listFilter = ChatListFilter.Active,
conversations = dmConversations, conversations = dmConversations,
publicChatTitle = publicChatTitle, publicChatTitle = publicChatTitle,
publicLastMessagePreview = publicLastMessagePreview, publicChatPreviewState = publicChatPreviewState,
defaultLastMessage = defaultLastMessage, defaultLastMessage = defaultLastMessage,
statusMap = statusMap, statusMap = statusMap,
listMode = listMode, listMode = listMode,
@@ -813,7 +850,7 @@ fun ChatsTab(
blurProgress = chatContextMenuOverlay.blurProgress, blurProgress = chatContextMenuOverlay.blurProgress,
listFilter = ChatListFilter.Active, listFilter = ChatListFilter.Active,
publicChatTitle = publicChatTitle, publicChatTitle = publicChatTitle,
publicLastMessagePreview = publicLastMessagePreview, publicChatPreviewState = publicChatPreviewState,
publicChatLink = publicChatLink, publicChatLink = publicChatLink,
defaultLastMessage = defaultLastMessage, defaultLastMessage = defaultLastMessage,
conversations = dmConversations, conversations = dmConversations,
@@ -1238,7 +1275,7 @@ private fun ChatContextMenuOverlay(
ChatContextMenuTarget.Public -> { ChatContextMenuTarget.Public -> {
PublicChatRowContent( PublicChatRowContent(
publicChatTitle = uiState.publicChatTitle, publicChatTitle = uiState.publicChatTitle,
publicLastMessagePreview = uiState.publicLastMessagePreview, publicChatPreviewState = uiState.publicChatPreviewState,
defaultLastMessage = uiState.defaultLastMessage, defaultLastMessage = uiState.defaultLastMessage,
listMode = uiState.listMode, listMode = uiState.listMode,
selectionTransitionProgress = uiState.selectionTransitionProgress, selectionTransitionProgress = uiState.selectionTransitionProgress,
@@ -2,12 +2,14 @@
package ru.fromchat.ui.main.settings package ru.fromchat.ui.main.settings
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.layout.Arrangement import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
@@ -66,6 +68,8 @@ import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState import dev.chrisbanes.haze.rememberHazeState
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
@@ -96,6 +100,16 @@ import ru.fromchat.ui.components.HazeActionButton
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.unknown import ru.fromchat.unknown
private const val DEVICE_SESSIONS_POLL_INTERVAL_MS = 15_000L
private fun enrichDevicesList(list: List<DeviceSessionInfo>): List<DeviceSessionInfo> {
val currentIndex = list.indexOfFirst { it.current }
if (currentIndex < 0) return list
return list
.toMutableList()
.also { it[currentIndex] = deviceSessionForCurrentDevice(it[currentIndex]) }
}
private fun formatDeviceLine(d: DeviceSessionInfo, fallbackLabel: String) = private fun formatDeviceLine(d: DeviceSessionInfo, fallbackLabel: String) =
listOfNotNull( listOfNotNull(
d.deviceName, d.deviceName,
@@ -339,8 +353,10 @@ fun DevicesScreen(onBack: () -> Unit) {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val initialCache = remember { Settings.readDeviceSessionsCache() } val initialCache = remember { Settings.readDeviceSessionsCache() }
var devices by remember { mutableStateOf(initialCache) } var devices by remember {
var loading by remember { mutableStateOf(initialCache == null) } mutableStateOf(initialCache?.let(::enrichDevicesList).orEmpty())
}
var refreshing by remember { mutableStateOf(initialCache == null) }
var sheetDevice by remember { mutableStateOf<DeviceSessionInfo?>(null) } var sheetDevice by remember { mutableStateOf<DeviceSessionInfo?>(null) }
var sheetSigningOut by remember { mutableStateOf(false) } var sheetSigningOut by remember { mutableStateOf(false) }
var showLogoutAllConfirm by remember { mutableStateOf(false) } var showLogoutAllConfirm by remember { mutableStateOf(false) }
@@ -351,41 +367,27 @@ fun DevicesScreen(onBack: () -> Unit) {
sheetSigningOut = false sheetSigningOut = false
} }
fun reload() { suspend fun fetchDevices() {
scope.launch { refreshing = true
if (devices == null) loading = true
devices = runCatching { ApiClient.listDevices() } runCatching { ApiClient.listDevices() }
.let { .onSuccess { list ->
if (it.isSuccess) { Settings.writeDeviceSessionsCache(list)
Settings.writeDeviceSessionsCache(it.getOrNull()!!) devices = enrichDevicesList(list)
it.getOrNull()!! }
} else { .onFailure {
snackbarHostState.showSnackbar(errUnexpected) snackbarHostState.showSnackbar(errUnexpected)
}
if (devices == null) { refreshing = false
emptyList()
} else {
devices
}
}
}?.let {
it.indexOfFirst { it.current }.let { index ->
it
.toMutableList()
.also {
it[index] = deviceSessionForCurrentDevice(it[index])
}
.toList()
}
}
loading = false
}
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
reload() fetchDevices()
while (isActive) {
delay(DEVICE_SESSIONS_POLL_INTERVAL_MS)
fetchDevices()
}
} }
Scaffold( Scaffold(
@@ -414,7 +416,7 @@ fun DevicesScreen(onBack: () -> Unit) {
) )
}, },
bottomBar = { bottomBar = {
if (devices?.let { it.size > 1 } == true) { if (devices.size > 1) {
HazeActionButton( HazeActionButton(
hazeState = hazeState, hazeState = hazeState,
onClick = { showLogoutAllConfirm = true } onClick = { showLogoutAllConfirm = true }
@@ -424,114 +426,97 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
} }
) { innerPadding -> ) { innerPadding ->
if (loading) { val activeSessionsTitle = stringResource(Res.string.settings_devices_active_sessions)
Column( val (currentDevice, sessionList) = remember(devices) {
modifier = Modifier val mutable = devices.toMutableList()
.fillMaxSize() val current = mutable.firstOrNull { it.current }
.padding(innerPadding) current?.let { mutable.remove(it) }
.hazeSource(hazeState), current to mutable
horizontalAlignment = Alignment.CenterHorizontally, }
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
}
} else {
val active_sessions = stringResource(Res.string.settings_devices_active_sessions)
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.hazeSource(hazeState) .hazeSource(hazeState)
.padding() .padding()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp), .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
contentPadding = innerPadding contentPadding = innerPadding
) { ) {
item { item {
Column(Modifier.fillMaxWidth()) { Column(Modifier.fillMaxWidth()) {
ExpressiveIconFrame( ExpressiveIconFrame(
icon = Icons.Filled.Devices, icon = Icons.Filled.Devices,
modifier = Modifier.align(Alignment.CenterHorizontally), modifier = Modifier.align(Alignment.CenterHorizontally),
materialPolygon = MaterialShapes.VerySunny materialPolygon = MaterialShapes.VerySunny
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(Res.string.settings_devices_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
}
}
var currentDevice: DeviceSessionInfo? = null
val sessionList = devices!!
.toMutableList()
.also { mutable ->
currentDevice = mutable.first { it.current }.also {
mutable.remove(it)
}
}
.toList()
@Composable
fun Device(
device: DeviceSessionInfo,
divider: Boolean
) {
val osLogo = remember(device) { deviceSessionLogoResource(device) }
ListItem(
headline = remember(device) {
deviceHeadline(device, unknownDeviceLabel)
},
supportingText = stringResource(
Res.string.settings_devices_last_active,
formatDeviceLastSeen(device.lastSeen)
),
leadingContent = {
if (osLogo != null) {
AsyncImage(
model = Res.getUri(osLogo),
contentDescription = null,
modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface)
)
} else {
Icon(
imageVector = remember(device) {
deviceSessionIcon(
device
)
},
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
divider = divider,
onClick = { sheetDevice = device }
) )
}
if (currentDevice != null) { Spacer(Modifier.height(8.dp))
item {
Category( Text(
title = stringResource(Res.string.settings_devices_this_device), text = stringResource(Res.string.settings_devices_title),
margin = PaddingValues(bottom = 20.dp) style = MaterialTheme.typography.headlineMedium,
) { textAlign = TextAlign.Center,
Device(currentDevice, false) modifier = Modifier.fillMaxWidth()
)
Spacer(Modifier.height(16.dp))
}
}
@Composable
fun Device(
device: DeviceSessionInfo,
divider: Boolean
) {
val osLogo = remember(device) { deviceSessionLogoResource(device) }
ListItem(
headline = remember(device) {
deviceHeadline(device, unknownDeviceLabel)
},
supportingText = stringResource(
Res.string.settings_devices_last_active,
formatDeviceLastSeen(device.lastSeen)
),
leadingContent = {
if (osLogo != null) {
AsyncImage(
model = Res.getUri(osLogo),
contentDescription = null,
modifier = Modifier.size(24.dp),
contentScale = ContentScale.Fit,
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface)
)
} else {
Icon(
imageVector = remember(device) {
deviceSessionIcon(device)
},
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
},
divider = divider,
onClick = { sheetDevice = device }
)
}
if (currentDevice != null) {
item {
Category(
title = stringResource(Res.string.settings_devices_this_device),
margin = PaddingValues(bottom = 20.dp)
) {
Device(currentDevice, false)
} }
} }
}
if (sessionList.isNotEmpty()) {
Category( Category(
margin = PaddingValues(bottom = 20.dp), margin = PaddingValues(bottom = 20.dp),
title = active_sessions title = activeSessionsTitle
) { ) {
sessionList.forEachIndexed { index, it -> sessionList.forEachIndexed { index, it ->
item { item {
@@ -540,6 +525,23 @@ fun DevicesScreen(onBack: () -> Unit) {
} }
} }
} }
item {
AnimatedVisibility(
visible = refreshing,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 16.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
}
} }
} }
@@ -564,7 +566,7 @@ fun DevicesScreen(onBack: () -> Unit) {
}.onSuccess { }.onSuccess {
sheetState.hide() sheetState.hide()
sheetDevice = null sheetDevice = null
reload() fetchDevices()
}.onFailure { }.onFailure {
snackbarHostState.showSnackbar(errUnexpected) snackbarHostState.showSnackbar(errUnexpected)
} }
@@ -587,7 +589,7 @@ fun DevicesScreen(onBack: () -> Unit) {
showLogoutAllConfirm = false showLogoutAllConfirm = false
scope.launch { scope.launch {
runCatching { ApiClient.revokeAllOtherDeviceSessions() } runCatching { ApiClient.revokeAllOtherDeviceSessions() }
.onSuccess { reload() } .onSuccess { fetchDevices() }
.onFailure { snackbarHostState.showSnackbar(errUnexpected) } .onFailure { snackbarHostState.showSnackbar(errUnexpected) }
} }
} }
@@ -98,6 +98,7 @@ import ru.fromchat.config.DEFAULT_CALLS_PORT
import ru.fromchat.config.ServerConfigData import ru.fromchat.config.ServerConfigData
import ru.fromchat.config.Settings import ru.fromchat.config.Settings
import ru.fromchat.api.instance.ServerProbeResult import ru.fromchat.api.instance.ServerProbeResult
import ru.fromchat.api.instance.ApplyServerResult
import ru.fromchat.api.instance.applyServerAndNavigate import ru.fromchat.api.instance.applyServerAndNavigate
import ru.fromchat.api.instance.probeServer import ru.fromchat.api.instance.probeServer
import ru.fromchat.save_continue import ru.fromchat.save_continue
@@ -305,15 +306,9 @@ fun ServerConfigScreen() {
busy = true busy = true
val tentative = buildTentativeConfig() ?: return@launch val tentative = buildTentativeConfig() ?: return@launch
val probe = if ( val probe = probeServer(tentative).also {
lastProbedConfig == tentative && lastProbe != null lastProbe = it
) { lastProbedConfig = tentative
lastProbe!!
} else {
probeServer(tentative).also {
lastProbe = it
lastProbedConfig = tentative
}
} }
when (probe) { when (probe) {
@@ -332,38 +327,45 @@ fun ServerConfigScreen() {
} }
is ServerProbeResult.Supported -> { is ServerProbeResult.Supported -> {
Settings.lastKnownServerInstanceId = when (
probe.instanceId applyServerAndNavigate(
probe = probe,
applyServerAndNavigate( config = tentative,
probe = probe, bearer = ApiClient.token?.trim().orEmpty(),
config = tentative, onNavigateLogin = {
bearer = ApiClient.token?.trim().orEmpty(), withContext(Dispatchers.Main) {
onNavigateLogin = { navController.navigateAndWipeBackStack("auth")
withContext(Dispatchers.Main) { }
navController.navigateAndWipeBackStack("auth") },
} onNavigateChat = {
}, withContext(Dispatchers.Main) {
onNavigateChat = { if (!navController.popBackStack()) {
withContext(Dispatchers.Main) { navController.navigate("chat") {
if (!navController.popBackStack()) { popUpTo("welcome") {
navController.navigate("chat") { inclusive = true
popUpTo("welcome") { }
inclusive = true
} }
} }
} }
} },
}, onLogoutOldHost = {
onLogoutOldHost = { withContext(Dispatchers.Main) {
withContext(Dispatchers.Main) { WebSocketManager.disconnect()
WebSocketManager.disconnect() runCatching { ApiClient.logout() }
runCatching { ApiClient.logout() } ApiClient.clearMemorySession()
ApiClient.clearMemorySession() navController.navigateAndWipeBackStack("auth")
navController.navigateAndWipeBackStack("auth") }
} },
)
) {
ApplyServerResult.Applied -> {
Settings.lastKnownServerInstanceId =
probe.instanceId
} }
) ApplyServerResult.ServerUnreachable -> {
snackbarHostState.showSnackbar(strSnackbarApiFail)
}
}
} }
} }
} finally { } finally {
@@ -18,6 +18,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.IconButtonDefaults
@@ -25,17 +28,21 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.navigation.NavController
import com.pr0gramm3r101.components.ListItemPosition import com.pr0gramm3r101.components.ListItemPosition
import com.pr0gramm3r101.utils.SupportClipboardManager
import dev.chrisbanes.haze.HazeProgressive import dev.chrisbanes.haze.HazeProgressive
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials import dev.chrisbanes.haze.materials.HazeMaterials
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile import ru.fromchat.api.schema.chats.publicchat.PublicChatProfile
@@ -73,8 +80,9 @@ import androidx.compose.material.icons.filled.Verified
import androidx.compose.material.icons.rounded.Call import androidx.compose.material.icons.rounded.Call
import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.ContentCopy
import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.TextButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarDuration
@@ -114,6 +122,7 @@ import ru.fromchat.Logger
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.action_copy import ru.fromchat.action_copy
import ru.fromchat.action_edit import ru.fromchat.action_edit
import ru.fromchat.action_retry_send
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
@@ -143,8 +152,10 @@ import ru.fromchat.ui.LocalNavController
import ru.fromchat.ui.chat.Avatar import ru.fromchat.ui.chat.Avatar
import ru.fromchat.ui.chat.TypingIndicator import ru.fromchat.ui.chat.TypingIndicator
import ru.fromchat.ui.components.FromChatSnackbarHost import ru.fromchat.ui.components.FromChatSnackbarHost
import ru.fromchat.ui.components.ShimmerBox
import ru.fromchat.ui.components.Text import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.showReplacingSnackbar import ru.fromchat.ui.components.showReplacingSnackbar
import ru.fromchat.utils.RegistrationDateFormatStrings
import ru.fromchat.utils.formatLastSeen import ru.fromchat.utils.formatLastSeen
import ru.fromchat.utils.formatProfileRegistrationDate import ru.fromchat.utils.formatProfileRegistrationDate
import ru.fromchat.utils.haptic.HapticFeedbackEvent import ru.fromchat.utils.haptic.HapticFeedbackEvent
@@ -275,6 +286,8 @@ fun ProfileScreen(
mutableStateOf(hasDisplayableProfile(state.profile, initialDisplayName, ownUserId)) mutableStateOf(hasDisplayableProfile(state.profile, initialDisplayName, ownUserId))
} }
var reloadAttempt by remember(lookupKey) { mutableIntStateOf(0) }
val latestUi by rememberUpdatedState(state) val latestUi by rememberUpdatedState(state)
val backStackEntry = navController.currentBackStackEntry val backStackEntry = navController.currentBackStackEntry
@@ -296,7 +309,11 @@ fun ProfileScreen(
} }
} }
LaunchedEffect(lookupMode, lookupIdentifier) { LaunchedEffect(lookupMode, lookupIdentifier, reloadAttempt) {
if (reloadAttempt > 0) {
state = latestUi.copy(isLoading = true, error = null)
}
Logger.d( Logger.d(
"ProfileScreen", "ProfileScreen",
"load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId" "load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId"
@@ -355,7 +372,7 @@ fun ProfileScreen(
val resolvedErrorMessage = when { val resolvedErrorMessage = when {
err is ClientRequestException && err.response.status.value == 404 -> profileNotFound err is ClientRequestException && err.response.status.value == 404 -> profileNotFound
else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed else -> profileLoadFailed
} }
if (err is ClientRequestException) { if (err is ClientRequestException) {
@@ -410,6 +427,7 @@ fun ProfileScreen(
val headlineVerification = stringResource(Res.string.profile_headline_verification) val headlineVerification = stringResource(Res.string.profile_headline_verification)
val verifiedSupport = stringResource(Res.string.profile_verified_support) val verifiedSupport = stringResource(Res.string.profile_verified_support)
val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support) val verifyPromptSupport = stringResource(Res.string.profile_verify_prompt_support)
val labelRetry = stringResource(Res.string.action_retry_send)
val profile = state.profile ?: resolveCachedProfile(targetUserId, targetUsername, ownUserId) val profile = state.profile ?: resolveCachedProfile(targetUserId, targetUsername, ownUserId)
if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) { if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) {
@@ -418,7 +436,8 @@ fun ProfileScreen(
val statusMap by UserStatusStore.status.collectAsState() val statusMap by UserStatusStore.status.collectAsState()
val lastSeenFormatStrings = rememberLastSeenFormatStrings() val lastSeenFormatStrings = rememberLastSeenFormatStrings()
val loadError = state.error val loadError = state.error
val showLoadingSpinner = state.isLoading && !hasShownContent.value val hasDisplayable = hasDisplayableProfile(profile, initialDisplayName, ownUserId)
val showSkeleton = !hasDisplayable && (state.isLoading || loadError != null)
val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id
val displayName = val displayName =
profile?.visibleDisplayName(currentProfileUserId) profile?.visibleDisplayName(currentProfileUserId)
@@ -426,7 +445,7 @@ fun ProfileScreen(
?: "?" ?: "?"
val usernameForLinks = profile?.visibleUsername(currentProfileUserId) val usernameForLinks = profile?.visibleUsername(currentProfileUserId)
val resolvedProfile = profile?.takeIf { loadError == null && !showLoadingSpinner } val resolvedProfile = profile?.takeIf { !showSkeleton }
val profileLink = resolvedProfile?.let { val profileLink = resolvedProfile?.let {
usernameForLinks?.let { name -> "https://fromchat.ru/@$name" } usernameForLinks?.let { name -> "https://fromchat.ru/@$name" }
?: "https://fromchat.ru/?u=${it.id}" ?: "https://fromchat.ru/?u=${it.id}"
@@ -539,6 +558,18 @@ fun ProfileScreen(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
when { when {
showSkeleton && !hideAvatar -> {
item {
ShimmerBox(
modifier = Modifier
.padding(top = profileAvatarTop)
.size(104.dp),
shape = CircleShape,
)
}
item { Spacer(Modifier.height(12.dp)) }
}
useSharedAvatar -> { useSharedAvatar -> {
item { item {
with(sharedTransitionScope) { with(sharedTransitionScope) {
@@ -597,271 +628,59 @@ fun ProfileScreen(
} }
} }
when { item {
showLoadingSpinner -> { AnimatedContent(
item { targetState = showSkeleton,
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp)) transitionSpec = {
} fadeIn() togetherWith fadeOut()
} },
label = "profile_body",
loadError != null -> { ) { skeleton ->
item { if (skeleton) {
Text( ProfileSkeletonBody(
text = when (loadError) { onRetry = if (loadError != null) {
ProfileLoadError.Generic -> profileLoadFailed { reloadAttempt++ }
is ProfileLoadError.Message -> loadError.text
},
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 24.dp)
)
}
}
resolvedProfile != null -> {
item {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ContextMenuPressable(
pressScale = ProfileDisplayNamePressScale,
onContextMenuOpen = openContextMenuHaptic,
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(AnnotatedString(displayName))
}
if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) {
navController.navigate(
ProfileRoutes.editRoute(EditProfileFocusField.DisplayName),
)
}
}
},
) {
Text(
text = displayName,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
StatusBadge(
verified = resolvedProfile.verified,
userId = resolvedProfile.id,
)
}
}
item { Spacer(Modifier.height(4.dp)) }
item {
AnimatedContent(
targetState = when {
typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}"
statusState?.online == true -> "online"
else -> "offline"
},
transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut())
},
label = "profile_status_${resolvedProfile.id}"
) { animatedState ->
if (animatedState.startsWith("typing:")) {
TypingIndicator(typingUsers = typingUsers)
} else { } else {
Text( null
text = statusText, },
style = MaterialTheme.typography.bodyMedium, retryLabel = labelRetry,
color = if (animatedState == "online") { )
MaterialTheme.colorScheme.primary } else if (resolvedProfile != null) {
} else { ProfileLoadedBody(
MaterialTheme.colorScheme.onSurfaceVariant resolvedProfile = resolvedProfile,
} displayName = displayName,
) isOwnProfile = isOwnProfile,
} typingUsers = typingUsers,
} statusState = statusState,
} statusText = statusText,
profileActions = profileActions.orEmpty(),
item { Spacer(Modifier.height(24.dp)) } showDetailsSection = showDetailsSection,
showDetailsUsername = showDetailsUsername,
item { showDetailsMemberSince = showDetailsMemberSince,
ProfileActionButtonRow( showDetailsBio = showDetailsBio,
actions = profileActions.orEmpty(), showDetailsVerify = showDetailsVerify,
modifier = Modifier headlineUsername = headlineUsername,
.fillMaxWidth() headlineMemberSince = headlineMemberSince,
.padding(horizontal = 16.dp), headlineBio = headlineBio,
headlineVerification = headlineVerification,
usernameForLinks = usernameForLinks,
verifiedSupport = verifiedSupport,
verifyPromptSupport = verifyPromptSupport,
registrationDateStrings = registrationDateStrings,
listItemIconTint = listItemIconTint,
labelCopy = labelCopy,
labelEdit = labelEdit,
detailsBringIntoView = detailsBringIntoView,
clipboardManager = clipboardManager,
clipboard = clipboard,
navController = navController,
scope = scope,
openContextMenuHaptic = openContextMenuHaptic,
onProfileUpdated = { updated ->
state = state.copy(profile = updated)
ProfileCache.put(updated)
},
) )
}
if (showDetailsSection) {
val detailCount = listOf(
showDetailsUsername,
showDetailsMemberSince,
showDetailsBio,
showDetailsVerify,
).count { it }
var detailIndex = 0
Category(
modifier = Modifier.bringIntoViewRequester(detailsBringIntoView),
margin = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 28.dp,
bottom = 20.dp,
),
roundedCorners = false,
) {
if (showDetailsUsername) {
val position = listItemPositionInGroup(detailIndex, detailCount)
detailIndex++
item {
ListItem(
headline = headlineUsername,
supportingText = usernameForLinks.orEmpty(),
divider = true,
position = position,
groupItemCount = detailCount,
onContextMenuOpen = openContextMenuHaptic,
leadingContent = {
Icon(
imageVector = Icons.Filled.AlternateEmail,
contentDescription = null,
tint = listItemIconTint,
)
},
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(
AnnotatedString(usernameForLinks.orEmpty()),
)
}
if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) {
navController.navigate(
ProfileRoutes.editRoute(EditProfileFocusField.Username),
)
}
}
},
)
}
}
if (showDetailsMemberSince) {
val memberSinceText = formatProfileRegistrationDate(
resolvedProfile.createdAt,
registrationDateStrings,
).orEmpty()
val position = listItemPositionInGroup(detailIndex, detailCount)
detailIndex++
item {
ListItem(
headline = headlineMemberSince,
supportingText = memberSinceText,
divider = true,
position = position,
groupItemCount = detailCount,
onContextMenuOpen = openContextMenuHaptic,
leadingContent = {
Icon(
imageVector = Icons.Filled.CalendarMonth,
contentDescription = null,
tint = listItemIconTint,
)
},
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(AnnotatedString(memberSinceText))
}
},
)
}
}
if (showDetailsBio) {
val position = listItemPositionInGroup(detailIndex, detailCount)
detailIndex++
item {
ListItem(
headline = headlineBio,
supportingSlot = {
ProfileBioMarkdown(
content = resolvedProfile.bio.orEmpty(),
)
},
divider = true,
position = position,
groupItemCount = detailCount,
onContextMenuOpen = openContextMenuHaptic,
leadingContent = {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
tint = listItemIconTint,
)
},
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
scope.launch {
clipboard.setText(resolvedProfile.bio.orEmpty())
}
}
if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) {
navController.navigate(
ProfileRoutes.editRoute(EditProfileFocusField.Bio),
)
}
}
},
)
}
}
if (showDetailsVerify) {
val position = listItemPositionInGroup(detailIndex, detailCount)
detailIndex++
item {
ListItem(
headline = headlineVerification,
supportingText = if (resolvedProfile.verified == true) {
verifiedSupport
} else {
verifyPromptSupport
},
divider = true,
position = position,
groupItemCount = detailCount,
leadingContent = {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = null,
tint = listItemIconTint,
)
},
onClick = if (ApiClient.user?.id == 1) {
{
scope.launch {
val result = withContext(Dispatchers.Default) {
runCatching {
ApiClient.verifyUser(resolvedProfile.id)
}.getOrNull()
}
result?.verified?.let { newVerified ->
val updated =
state.profile?.copy(verified = newVerified)
state = state.copy(profile = updated)
updated?.let { ProfileCache.put(it) }
}
}
}
} else null,
)
}
}
}
} }
} }
} }
@@ -1211,6 +1030,391 @@ private fun targetWeightForPress(
return (count * ProfileActionDefaultWeight - ProfileActionPressExpansionRatio) / (count - 1) return (count * ProfileActionDefaultWeight - ProfileActionPressExpansionRatio) / (count - 1)
} }
@Composable
private fun ProfileSkeletonBody(
onRetry: (() -> Unit)?,
retryLabel: String,
modifier: Modifier = Modifier,
) {
val barShape = RoundedCornerShape(8.dp)
val pillShape = MaterialTheme.shapes.extraLarge
val listBarShape = RoundedCornerShape(4.dp)
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
ShimmerBox(
modifier = Modifier
.width(168.dp)
.height(28.dp),
shape = barShape,
)
Spacer(Modifier.height(8.dp))
ShimmerBox(
modifier = Modifier
.width(112.dp)
.height(16.dp),
shape = barShape,
)
Spacer(Modifier.height(24.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
repeat(3) {
ShimmerBox(
modifier = Modifier
.weight(1f)
.height(52.dp),
shape = pillShape,
)
}
}
Category(
margin = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 28.dp,
bottom = 20.dp,
),
roundedCorners = false,
) {
ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = true)
ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = true)
ProfileSkeletonListRow(listBarShape = listBarShape, showDivider = false)
}
if (onRetry != null) {
Spacer(Modifier.height(8.dp))
TextButton(onClick = onRetry) {
Text(
text = retryLabel,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun ProfileSkeletonListRow(
listBarShape: RoundedCornerShape,
showDivider: Boolean,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
ShimmerBox(
modifier = Modifier.size(24.dp),
shape = CircleShape,
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ShimmerBox(
modifier = Modifier
.fillMaxWidth(0.38f)
.height(14.dp),
shape = listBarShape,
)
ShimmerBox(
modifier = Modifier
.fillMaxWidth(0.62f)
.height(12.dp),
shape = listBarShape,
)
}
}
if (showDivider) {
Spacer(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.height(1.dp)
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
)
}
}
@Composable
private fun ProfileLoadedBody(
resolvedProfile: UserProfile,
displayName: String,
isOwnProfile: Boolean,
typingUsers: List<String>,
statusState: UserStatus?,
statusText: String,
profileActions: List<ProfileAction>,
showDetailsSection: Boolean,
showDetailsUsername: Boolean,
showDetailsMemberSince: Boolean,
showDetailsBio: Boolean,
showDetailsVerify: Boolean,
headlineUsername: String,
headlineMemberSince: String,
headlineBio: String,
headlineVerification: String,
usernameForLinks: String?,
verifiedSupport: String,
verifyPromptSupport: String,
registrationDateStrings: RegistrationDateFormatStrings,
listItemIconTint: Color,
labelCopy: String,
labelEdit: String,
detailsBringIntoView: BringIntoViewRequester,
clipboardManager: ClipboardManager,
clipboard: SupportClipboardManager,
navController: NavController,
scope: CoroutineScope,
openContextMenuHaptic: () -> Unit,
onProfileUpdated: (UserProfile) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
ContextMenuPressable(
pressScale = ProfileDisplayNamePressScale,
onContextMenuOpen = openContextMenuHaptic,
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(AnnotatedString(displayName))
}
if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) {
navController.navigate(
ProfileRoutes.editRoute(EditProfileFocusField.DisplayName),
)
}
}
},
) {
Text(
text = displayName,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
}
StatusBadge(
verified = resolvedProfile.verified,
userId = resolvedProfile.id,
)
}
Spacer(Modifier.height(4.dp))
AnimatedContent(
targetState = when {
typingUsers.isNotEmpty() -> "typing:${typingUsers.joinToString("|")}"
statusState?.online == true -> "online"
else -> "offline"
},
transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut())
},
label = "profile_status_${resolvedProfile.id}",
) { animatedState ->
if (animatedState.startsWith("typing:")) {
TypingIndicator(typingUsers = typingUsers)
} else {
Text(
text = statusText,
style = MaterialTheme.typography.bodyMedium,
color = if (animatedState == "online") {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
Spacer(Modifier.height(24.dp))
ProfileActionButtonRow(
actions = profileActions,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
)
if (showDetailsSection) {
val detailCount = listOf(
showDetailsUsername,
showDetailsMemberSince,
showDetailsBio,
showDetailsVerify,
).count { it }
var detailIndex = 0
Category(
modifier = Modifier.bringIntoViewRequester(detailsBringIntoView),
margin = PaddingValues(
start = 16.dp,
end = 16.dp,
top = 28.dp,
bottom = 20.dp,
),
roundedCorners = false,
) {
if (showDetailsUsername) {
val position = listItemPositionInGroup(detailIndex, detailCount)
detailIndex++
ListItem(
headline = headlineUsername,
supportingText = usernameForLinks.orEmpty(),
divider = true,
position = position,
groupItemCount = detailCount,
onContextMenuOpen = openContextMenuHaptic,
leadingContent = {
Icon(
imageVector = Icons.Filled.AlternateEmail,
contentDescription = null,
tint = listItemIconTint,
)
},
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(
AnnotatedString(usernameForLinks.orEmpty()),
)
}
if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) {
navController.navigate(
ProfileRoutes.editRoute(EditProfileFocusField.Username),
)
}
}
},
)
}
if (showDetailsMemberSince) {
val memberSinceText = formatProfileRegistrationDate(
resolvedProfile.createdAt,
registrationDateStrings,
).orEmpty()
val position = listItemPositionInGroup(detailIndex, detailCount)
detailIndex++
ListItem(
headline = headlineMemberSince,
supportingText = memberSinceText,
divider = true,
position = position,
groupItemCount = detailCount,
onContextMenuOpen = openContextMenuHaptic,
leadingContent = {
Icon(
imageVector = Icons.Filled.CalendarMonth,
contentDescription = null,
tint = listItemIconTint,
)
},
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
clipboardManager.setText(AnnotatedString(memberSinceText))
}
},
)
}
if (showDetailsBio) {
val position = listItemPositionInGroup(detailIndex, detailCount)
detailIndex++
ListItem(
headline = headlineBio,
supportingSlot = {
ProfileBioMarkdown(
content = resolvedProfile.bio.orEmpty(),
)
},
divider = true,
position = position,
groupItemCount = detailCount,
onContextMenuOpen = openContextMenuHaptic,
leadingContent = {
Icon(
imageVector = Icons.Filled.Info,
contentDescription = null,
tint = listItemIconTint,
)
},
contextMenu = {
item(Icons.Rounded.ContentCopy, labelCopy) {
scope.launch {
clipboard.setText(resolvedProfile.bio.orEmpty())
}
}
if (isOwnProfile) {
item(Icons.Rounded.Edit, labelEdit) {
navController.navigate(
ProfileRoutes.editRoute(EditProfileFocusField.Bio),
)
}
}
},
)
}
if (showDetailsVerify) {
val position = listItemPositionInGroup(detailIndex, detailCount)
detailIndex++
ListItem(
headline = headlineVerification,
supportingText = if (resolvedProfile.verified == true) {
verifiedSupport
} else {
verifyPromptSupport
},
divider = true,
position = position,
groupItemCount = detailCount,
leadingContent = {
Icon(
imageVector = Icons.Filled.Verified,
contentDescription = null,
tint = listItemIconTint,
)
},
onClick = if (ApiClient.user?.id == 1) {
{
scope.launch {
val result = withContext(Dispatchers.Default) {
runCatching {
ApiClient.verifyUser(resolvedProfile.id)
}.getOrNull()
}
result?.verified?.let { newVerified ->
onProfileUpdated(
resolvedProfile.copy(verified = newVerified),
)
}
}
}
} else null,
)
}
}
}
}
}
@Composable @Composable
private fun ProfileBioMarkdown( private fun ProfileBioMarkdown(
content: String, content: String,