mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 19:45:04 +03:00
@@ -2,16 +2,18 @@ package ru.fromchat
|
||||
|
||||
import android.app.Application
|
||||
import com.pr0gramm3r101.utils.UtilsLibrary
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import kotlinx.coroutines.DelicateCoroutinesApi
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.local.workers.AttachmentTransferBootstrap
|
||||
import ru.fromchat.notifications.NotificationHelper
|
||||
|
||||
class App: Application() {
|
||||
@@ -37,52 +39,85 @@ class App: Application() {
|
||||
UtilsLibrary.init(this)
|
||||
|
||||
WebSocketManager.addGlobalMessageHandler { msg ->
|
||||
runCatching {
|
||||
if (msg.type == "newMessage") {
|
||||
fetchAndNotify()
|
||||
} 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
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val currentUserId = settings.getInt("current_user_id", -1)
|
||||
|
||||
for (item in updates) {
|
||||
val type = item
|
||||
.jsonObject["type"]
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
fun isOwnPublicMessage(data: JsonObject?) =
|
||||
data?.get("user_id")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId
|
||||
|
||||
if (type == "newMessage") {
|
||||
shouldFetchPublic = true
|
||||
continue
|
||||
}
|
||||
fun isOwnDmMessage(data: JsonObject?) =
|
||||
data?.get("senderId")?.jsonPrimitive?.content?.toIntOrNull() == currentUserId
|
||||
|
||||
if (type == "dmNew") {
|
||||
shouldFetchDm = true
|
||||
val envelopeId = item
|
||||
.jsonObject["data"]
|
||||
?.jsonObject
|
||||
?.get("id")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?.toIntOrNull()
|
||||
if (envelopeId != null) {
|
||||
latestDmMessageId = envelopeId.coerceAtLeast(latestDmMessageId ?: 0)
|
||||
}
|
||||
when (msg.type) {
|
||||
"newMessage" -> {
|
||||
if (!isOwnPublicMessage(msg.data?.jsonObject)) {
|
||||
fetchAndNotify()
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldFetchPublic || shouldFetchDm) {
|
||||
fetchAndNotify(
|
||||
includeDmMessages = shouldFetchDm,
|
||||
dmMessageId = latestDmMessageId
|
||||
)
|
||||
"dmNew" -> {
|
||||
if (!isOwnDmMessage(msg.data?.jsonObject)) {
|
||||
fetchAndNotify(
|
||||
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 fallbackMessageId = pushData["message_id"]?.toIntOrNull()
|
||||
?: pushData["dm_id"]?.toIntOrNull()
|
||||
val senderId = pushData["sender_id"]?.toIntOrNull()
|
||||
val sender = pushData["sender_username"] ?: remoteMessage.data["senderUsername"]
|
||||
val title = remoteMessage.notification?.title ?: pushData["title"] ?: "FromChat"
|
||||
val body = remoteMessage.notification?.body ?: pushData["body"] ?: "New message"
|
||||
@@ -33,14 +34,20 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||
ApiClient.loadPersistedData()
|
||||
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())) {
|
||||
NotificationHelper.showFallbackPushNotification(
|
||||
applicationContext,
|
||||
title,
|
||||
body,
|
||||
sender,
|
||||
fallbackMessageId,
|
||||
messageType == "dm"
|
||||
context = applicationContext,
|
||||
title = title,
|
||||
body = body,
|
||||
sender = sender,
|
||||
messageId = fallbackMessageId,
|
||||
isDirectMessage = false,
|
||||
senderId = senderId,
|
||||
)
|
||||
}
|
||||
if (isDirectMessage) {
|
||||
|
||||
@@ -27,6 +27,9 @@ import kotlinx.coroutines.launch
|
||||
import ru.fromchat.MainActivity
|
||||
import ru.fromchat.R
|
||||
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.MessagesResponse
|
||||
import ru.fromchat.api.schema.messages.dm.DmHistoryResponse
|
||||
@@ -55,6 +58,18 @@ object NotificationHelper {
|
||||
private const val PREF_LAST_NOTIFICATION_TIME = "last_notification_time"
|
||||
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
|
||||
|
||||
private fun createMessageIntent(
|
||||
@@ -145,7 +160,8 @@ object NotificationHelper {
|
||||
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
||||
.body<MessagesResponse>()
|
||||
.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()) {
|
||||
settings.putLong(PREF_LAST_NOTIFICATION_TIME, System.currentTimeMillis())
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
@@ -168,6 +184,7 @@ object NotificationHelper {
|
||||
.get("${ServerConfig.apiBaseUrl}/messages/new")
|
||||
.body<MessagesResponse>()
|
||||
.messages
|
||||
.filter { it.user_id != settings.getInt("current_user_id", -1) }
|
||||
Log.d(
|
||||
"NotificationHelper",
|
||||
"fetchAndNotify retry: fetched ${retryMessages.size} public messages"
|
||||
@@ -241,6 +258,8 @@ object NotificationHelper {
|
||||
val shownDm = settings.getStringSet(PREF_SHOWN_DM_KEY, emptySet()).toMutableSet()
|
||||
val latestMessageId = settings.getInt(PREF_LAST_DM_MESSAGE_ID, 0)
|
||||
|
||||
val previewStrings = listPreviewStrings(context)
|
||||
|
||||
dmMessages
|
||||
.filter { envelope ->
|
||||
envelope.id > 0 && envelope.senderId != currentUserId
|
||||
@@ -290,11 +309,16 @@ object NotificationHelper {
|
||||
"User ${envelope.senderId}"
|
||||
}
|
||||
val dmConversationUserId = envelope.senderId
|
||||
val notificationBody = buildChatListPreviewFromEnvelope(
|
||||
envelope = envelope,
|
||||
decryptedPlaintext = plaintext,
|
||||
strings = previewStrings,
|
||||
)?.takeIf { it.isNotBlank() } ?: plaintext
|
||||
|
||||
showFallbackPushNotification(
|
||||
context = context,
|
||||
title = "Direct message from $senderName",
|
||||
body = plaintext,
|
||||
body = notificationBody,
|
||||
sender = senderName,
|
||||
messageId = envelopeId,
|
||||
allowWhenPublicChatVisible = true,
|
||||
@@ -321,11 +345,22 @@ object NotificationHelper {
|
||||
allowWhenPublicChatVisible: Boolean = false,
|
||||
isDirectMessage: Boolean = false,
|
||||
targetDmUserId: Int? = null,
|
||||
conversationTitle: String = "Public Chat"
|
||||
conversationTitle: String = "Public Chat",
|
||||
senderId: Int? = null,
|
||||
) {
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
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) {
|
||||
Log.d("NotificationHelper", "Fallback push notification skipped: public chat is visible")
|
||||
return@launch
|
||||
@@ -429,6 +464,7 @@ object NotificationHelper {
|
||||
GlobalScope.launch {
|
||||
val shown = settings.getStringSet(PREF_SHOWN_KEY, emptySet()).toMutableSet()
|
||||
var newMessageCount = 0
|
||||
val previewStrings = listPreviewStrings(context)
|
||||
|
||||
with(NotificationManagerCompat.from(context)) {
|
||||
if (
|
||||
@@ -468,17 +504,17 @@ object NotificationHelper {
|
||||
.setStyle(
|
||||
NotificationCompat.MessagingStyle(
|
||||
Person.Builder().setName("FromChat").build()
|
||||
).setConversationTitle("Public Chat").let {
|
||||
for (msg in messages.takeLast(10)) {
|
||||
).setConversationTitle("Public Chat").let { style ->
|
||||
for (msg in newMessages.takeLast(10)) {
|
||||
val timestamp = try {
|
||||
Instant.parse(msg.timestamp).toEpochMilliseconds()
|
||||
} catch (_: Exception) {
|
||||
System.currentTimeMillis()
|
||||
}
|
||||
|
||||
it.addMessage(
|
||||
style.addMessage(
|
||||
NotificationCompat.MessagingStyle.Message(
|
||||
msg.content,
|
||||
notificationBodyForMessage(msg, previewStrings),
|
||||
timestamp,
|
||||
Person.Builder()
|
||||
.setName(msg.username)
|
||||
@@ -487,7 +523,7 @@ object NotificationHelper {
|
||||
)
|
||||
}
|
||||
|
||||
it
|
||||
style
|
||||
}
|
||||
)
|
||||
.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>
|
||||
<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>
|
||||
@@ -61,6 +61,10 @@
|
||||
<string name="public_chat">Общий чат</string>
|
||||
<string name="chat_last_mesaage">Вы: последнее сообщение</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_select">Выбрать</string>
|
||||
<string name="action_archive">В архив</string>
|
||||
|
||||
@@ -69,6 +69,10 @@
|
||||
<string name="public_chat">Main chat</string>
|
||||
<string name="chat_last_mesaage">You: last message</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_select">Select</string>
|
||||
<string name="action_archive">Archive</string>
|
||||
|
||||
@@ -7,7 +7,10 @@ import com.pr0gramm3r101.utils.settings.secureSettings
|
||||
import com.pr0gramm3r101.utils.settings.settings
|
||||
import io.ktor.client.HttpClient
|
||||
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.HttpRequestTimeoutException
|
||||
import io.ktor.client.request.forms.formData
|
||||
import io.ktor.client.request.forms.submitFormWithBinaryData
|
||||
import io.ktor.client.plugins.HttpResponseValidator
|
||||
@@ -33,6 +36,7 @@ import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -306,12 +310,39 @@ object ApiClient {
|
||||
httpProbe.get(url.trim())
|
||||
}.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 {
|
||||
httpProbe.get("${apiBaseUrl.trimEnd('/')}/check_auth") {
|
||||
bearerAuth(bearer)
|
||||
}.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() {
|
||||
if (token.isNullOrEmpty()) return
|
||||
@@ -1381,17 +1412,31 @@ object ApiClient {
|
||||
|
||||
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
|
||||
|
||||
suspend fun sendMessageViaHttp(content: String, replyToId: Int? = null) {
|
||||
if (_suspensionState.value.isSuspended) return
|
||||
suspend fun sendMessageViaHttp(
|
||||
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(
|
||||
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",
|
||||
formData = formData {
|
||||
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
|
||||
|
||||
@@ -34,6 +34,7 @@ suspend fun resolveInstanceId(
|
||||
config: ServerConfigData,
|
||||
apiBaseUrl: String,
|
||||
forceNetwork: Boolean,
|
||||
allowCachedOnFailure: Boolean = true,
|
||||
): InstanceIdResolveResult {
|
||||
val cached = InstanceRegistryStore.getActiveInstanceIdForConfig(config)?.trim().orEmpty()
|
||||
if (!forceNetwork && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||
@@ -46,7 +47,11 @@ suspend fun resolveInstanceId(
|
||||
}
|
||||
}
|
||||
if (fetchResult.isFailure) {
|
||||
return networkFailureToResolveResult(fetchResult.exceptionOrNull(), cached)
|
||||
return networkFailureToResolveResult(
|
||||
e = fetchResult.exceptionOrNull(),
|
||||
cached = cached,
|
||||
allowCachedOnFailure = allowCachedOnFailure,
|
||||
)
|
||||
}
|
||||
val fetched = fetchResult.getOrThrow().trim()
|
||||
|
||||
@@ -74,19 +79,20 @@ fun apiBaseUrlFor(config: ServerConfigData): String {
|
||||
private fun networkFailureToResolveResult(
|
||||
e: Throwable?,
|
||||
cached: String,
|
||||
allowCachedOnFailure: Boolean,
|
||||
): InstanceIdResolveResult = when (e) {
|
||||
is TimeoutCancellationException,
|
||||
is HttpRequestTimeoutException,
|
||||
is SocketTimeoutException,
|
||||
-> {
|
||||
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||
if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||
InstanceIdResolveResult.Cached(cached)
|
||||
} else {
|
||||
InstanceIdResolveResult.Timeout
|
||||
}
|
||||
}
|
||||
is ConnectTimeoutException -> {
|
||||
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||
if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||
InstanceIdResolveResult.Cached(cached)
|
||||
} else {
|
||||
InstanceIdResolveResult.Unreachable
|
||||
@@ -95,14 +101,14 @@ private fun networkFailureToResolveResult(
|
||||
is ClientRequestException -> {
|
||||
if (e.response.status.value in 400..499) {
|
||||
InstanceIdResolveResult.Unsupported
|
||||
} else if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||
} else if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||
InstanceIdResolveResult.Cached(cached)
|
||||
} else {
|
||||
InstanceIdResolveResult.Unreachable
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||
if (allowCachedOnFailure && cached.isNotEmpty() && isValidInstanceUuid(cached)) {
|
||||
InstanceIdResolveResult.Cached(cached)
|
||||
} else {
|
||||
InstanceIdResolveResult.Unreachable
|
||||
|
||||
@@ -36,12 +36,22 @@ suspend fun probeCallsReachable(config: ServerConfigData): Boolean {
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
sealed interface ApplyServerResult {
|
||||
data object Applied : ApplyServerResult
|
||||
data object ServerUnreachable : ApplyServerResult
|
||||
}
|
||||
|
||||
suspend fun probeServer(config: ServerConfigData): ServerProbeResult {
|
||||
val apiBase = apiBaseUrlFor(config)
|
||||
val mark = TimeSource.Monotonic.markNow()
|
||||
InstanceIdGuard.probeConfig = config
|
||||
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 instanceId = when (resolve) {
|
||||
is InstanceIdResolveResult.Cached -> resolve.instanceId
|
||||
@@ -78,25 +88,32 @@ suspend fun applyServerAndNavigate(
|
||||
onNavigateLogin: suspend () -> Unit,
|
||||
onNavigateChat: suspend () -> Unit,
|
||||
onLogoutOldHost: suspend () -> Unit,
|
||||
) {
|
||||
): ApplyServerResult {
|
||||
val apiBase = apiBaseUrlFor(config)
|
||||
val token = bearer.trim()
|
||||
if (token.isEmpty()) {
|
||||
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
||||
WebSocketManager.disconnect()
|
||||
onNavigateLogin()
|
||||
return
|
||||
return ApplyServerResult.Applied
|
||||
}
|
||||
val authOk = ApiClient.checkAuthAt(apiBase, token)
|
||||
if (!authOk) {
|
||||
onLogoutOldHost()
|
||||
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
||||
WebSocketManager.disconnect()
|
||||
onNavigateLogin()
|
||||
return
|
||||
when (val auth = ApiClient.checkAuthAt(apiBase, token)) {
|
||||
ApiClient.CheckAuthResult.Authenticated -> {
|
||||
applyServerConfig(config, probe.instanceId, probe.callsOk)
|
||||
WebSocketManager.disconnect()
|
||||
WebSocketManager.connect(forceRestart = true)
|
||||
onNavigateChat()
|
||||
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.local.cache.CacheContext
|
||||
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.WebSocketMessage
|
||||
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||
@@ -61,6 +62,7 @@ object WebSocketManager {
|
||||
val messages = _messages.asSharedFlow()
|
||||
|
||||
private val globalHandlers = mutableListOf<((WebSocketMessage) -> Unit)>()
|
||||
private val sessionReadyHandlers = mutableListOf<suspend () -> Unit>()
|
||||
|
||||
fun addGlobalMessageHandler(handler: ((WebSocketMessage) -> Unit)) {
|
||||
globalHandlers += handler
|
||||
@@ -70,6 +72,25 @@ object WebSocketManager {
|
||||
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 session: DefaultClientWebSocketSession? = null
|
||||
@Volatile private var connectionJob: Job? = null
|
||||
@@ -190,6 +211,8 @@ object WebSocketManager {
|
||||
}
|
||||
}
|
||||
|
||||
notifySessionReady()
|
||||
|
||||
for (frame in incoming) {
|
||||
val text = (frame as? Frame.Text)?.readText() ?: continue
|
||||
logD("Received payload: $text")
|
||||
@@ -278,8 +301,8 @@ object WebSocketManager {
|
||||
var handler: ((WebSocketMessage) -> Unit)? = null
|
||||
|
||||
return try {
|
||||
if (session == null) {
|
||||
logW("No active WebSocket session")
|
||||
if (session == null && !waitForConnection(timeoutMs)) {
|
||||
logW("No active WebSocket session after waiting")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -324,19 +347,17 @@ object WebSocketManager {
|
||||
|
||||
fun onNetworkLost() {
|
||||
logD("onNetworkLost")
|
||||
session?.cancel()
|
||||
session = null
|
||||
connecting = false
|
||||
connectionJob?.cancel()
|
||||
connectionJob = null
|
||||
disconnect()
|
||||
ConnectionStateStore.onConnecting()
|
||||
}
|
||||
|
||||
fun onNetworkAvailable() {
|
||||
if (!AppForeground.isInForeground.value) return
|
||||
|
||||
if (session != null) {
|
||||
logD("onNetworkAvailable: session active, skip")
|
||||
return
|
||||
}
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
val prev = lastOnNetworkAvailableWallMs
|
||||
if (now - prev < NETWORK_AVAILABLE_DEBOUNCE_MS) {
|
||||
@@ -344,7 +365,8 @@ object WebSocketManager {
|
||||
return
|
||||
}
|
||||
lastOnNetworkAvailableWallMs = now
|
||||
logD("onNetworkAvailable: reconnect")
|
||||
logD("onNetworkAvailable: reconnect and flush outbox")
|
||||
connect(forceRestart = true)
|
||||
OutgoingMessageCoordinator.onTransportReady()
|
||||
}
|
||||
}
|
||||
|
||||
+140
-24
@@ -3,12 +3,21 @@ package ru.fromchat.api.local.db.store
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
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.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.messages.conversationIdForDm
|
||||
import ru.fromchat.api.local.messages.conversationIdForGroup
|
||||
@@ -41,13 +50,18 @@ data class CachedConversation(
|
||||
val otherUserId: Int,
|
||||
val displayName: String,
|
||||
val lastMessagePreview: String?,
|
||||
val unreadCount: Int
|
||||
val lastMessagePendingIndicator: ChatListPreviewPendingIndicator = ChatListPreviewPendingIndicator.None,
|
||||
val lastMessageUploadProgress: Int? = null,
|
||||
val unreadCount: Int,
|
||||
)
|
||||
|
||||
object MessageCacheStore {
|
||||
private val db: MessageDatabase get() = MessageDatabaseProvider.database
|
||||
private val outboxJson = Json { ignoreUnknownKeys = true; encodeDefaults = true }
|
||||
|
||||
@Volatile
|
||||
var listPreviewStrings: ChatListPreviewStrings? = null
|
||||
|
||||
private fun instanceId(): String = CacheContext.requireActiveInstanceId()
|
||||
|
||||
private fun conversationIdForPublic(): String = conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)
|
||||
@@ -82,6 +96,26 @@ object MessageCacheStore {
|
||||
suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
|
||||
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>) {
|
||||
conversationIdForPublic().let {
|
||||
replaceMessages(
|
||||
@@ -190,6 +224,7 @@ object MessageCacheStore {
|
||||
}
|
||||
|
||||
suspend fun upsertDmMessage(otherUserId: Int, message: Message) {
|
||||
ensureDmConversationRow(otherUserId)
|
||||
upsertSingle(conversationIdForDm(otherUserId), message)
|
||||
syncDmConversationPreviewFromCache(otherUserId)
|
||||
}
|
||||
@@ -263,8 +298,9 @@ object MessageCacheStore {
|
||||
|
||||
suspend fun replaceDmConversations(
|
||||
conversations: List<DmConversation>,
|
||||
attachmentOnlyPreview: String,
|
||||
previewStrings: ChatListPreviewStrings,
|
||||
) {
|
||||
listPreviewStrings = previewStrings
|
||||
val iid = instanceId()
|
||||
val currentUserId = ApiClient.user?.id
|
||||
withContext(Dispatchers.Default) {
|
||||
@@ -280,7 +316,7 @@ object MessageCacheStore {
|
||||
lastMessagePreview = buildDmListPreview(
|
||||
conv.lastMessage,
|
||||
currentUserId,
|
||||
attachmentOnlyPreview,
|
||||
previewStrings,
|
||||
),
|
||||
unreadCount = conv.unreadCount,
|
||||
updatedAt = conv.lastMessage.timestamp,
|
||||
@@ -324,17 +360,10 @@ object MessageCacheStore {
|
||||
private suspend fun buildDmListPreview(
|
||||
envelope: DmEnvelope,
|
||||
currentUserId: Int?,
|
||||
attachmentOnlyPreview: String,
|
||||
previewStrings: ChatListPreviewStrings,
|
||||
): String? {
|
||||
val hasFiles = !envelope.files.isNullOrEmpty()
|
||||
val decrypted = runCatching { decryptEnvelope(envelope, currentUserId) }.getOrNull()
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
val previewSource = when {
|
||||
decrypted != null -> decrypted
|
||||
hasFiles -> attachmentOnlyPreview
|
||||
else -> null
|
||||
}
|
||||
val previewSource = buildChatListPreviewFromEnvelope(envelope, decrypted, previewStrings)
|
||||
return previewSource?.let { truncateDmListPreview(it) }?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
@@ -509,34 +538,120 @@ object MessageCacheStore {
|
||||
|
||||
suspend fun loadCachedDmConversations(): List<CachedConversation> =
|
||||
withContext(Dispatchers.Default) {
|
||||
val iid = instanceId()
|
||||
val previewStrings = listPreviewStrings
|
||||
val currentUserId = ApiClient.user?.id
|
||||
db.messageDatabaseQueries
|
||||
.selectActiveDmConversationsForInstance(instanceId())
|
||||
.selectActiveDmConversationsForInstance(iid)
|
||||
.executeAsList()
|
||||
.map { row: Conversation ->
|
||||
val previewState = previewStrings?.let { strings ->
|
||||
previewStateForRecentMessage(iid, row.id, strings, currentUserId)
|
||||
}
|
||||
CachedConversation(
|
||||
id = row.id,
|
||||
otherUserId = row.otherUserId?.toInt() ?: 0,
|
||||
displayName = row.displayName ?: "",
|
||||
lastMessagePreview = row.lastMessagePreview,
|
||||
unreadCount = row.unreadCount.toInt()
|
||||
lastMessagePreview = previewState?.text ?: row.lastMessagePreview,
|
||||
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) {
|
||||
val iid = instanceId()
|
||||
val convId = conversationIdForDm(otherUserId)
|
||||
withContext(Dispatchers.Default) {
|
||||
val row = db.messageDatabaseQueries
|
||||
.selectConversationsForInstance(iid)
|
||||
.executeAsList()
|
||||
.find { it.id == convId } ?: return@withContext
|
||||
var row = db.messageDatabaseQueries
|
||||
.selectConversationById(iid, convId)
|
||||
.executeAsOneOrNull()
|
||||
if (row == null) {
|
||||
ensureDmConversationRow(otherUserId)
|
||||
row = db.messageDatabaseQueries
|
||||
.selectConversationById(iid, convId)
|
||||
.executeAsOneOrNull()
|
||||
?: return@withContext
|
||||
}
|
||||
val recent = db.messageDatabaseQueries
|
||||
.selectRecentMessagesByConversation(iid, convId, 1)
|
||||
.executeAsList()
|
||||
.firstOrNull()
|
||||
val rawPreview = recent?.content.orEmpty().trim()
|
||||
val preview = rawPreview.takeIf { it.isNotEmpty() }
|
||||
val previewStrings = listPreviewStrings
|
||||
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) }
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
db.messageDatabaseQueries.upsertConversation(
|
||||
@@ -545,13 +660,14 @@ object MessageCacheStore {
|
||||
type = row.type,
|
||||
otherUserId = row.otherUserId,
|
||||
displayName = row.displayName,
|
||||
lastMessageId = row.lastMessageId,
|
||||
lastMessagePreview = preview,
|
||||
lastMessageId = recent?.id ?: row.lastMessageId,
|
||||
lastMessagePreview = preview ?: row.lastMessagePreview,
|
||||
unreadCount = row.unreadCount,
|
||||
updatedAt = row.updatedAt,
|
||||
updatedAt = recent?.timestamp ?: row.updatedAt,
|
||||
archived = row.archived,
|
||||
)
|
||||
}
|
||||
DmConversationListNotifier.notifyChanged()
|
||||
}
|
||||
|
||||
private suspend fun clearConversationMessages(conversationId: String) {
|
||||
|
||||
+15
-2
@@ -2,6 +2,8 @@ package ru.fromchat.api.local.db.store
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
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.conversationIdForDm
|
||||
import ru.fromchat.api.local.messages.conversationIdForGroup
|
||||
@@ -29,6 +31,17 @@ object MessageRepository {
|
||||
suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
|
||||
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>) =
|
||||
MessageCacheStore.replacePublicMessages(messages)
|
||||
|
||||
@@ -66,8 +79,8 @@ object MessageRepository {
|
||||
|
||||
suspend fun replaceDmConversations(
|
||||
conversations: List<DmConversation>,
|
||||
attachmentOnlyPreview: String,
|
||||
) = MessageCacheStore.replaceDmConversations(conversations, attachmentOnlyPreview)
|
||||
previewStrings: ChatListPreviewStrings,
|
||||
) = MessageCacheStore.replaceDmConversations(conversations, previewStrings)
|
||||
|
||||
suspend fun loadCachedDmConversations(): List<CachedConversation> =
|
||||
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
|
||||
}
|
||||
}
|
||||
+100
-46
@@ -39,6 +39,101 @@ object OutgoingMessageCoordinator {
|
||||
private val drainMutex = Mutex()
|
||||
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) {
|
||||
val id = instanceId.trim()
|
||||
if (id.isEmpty()) return
|
||||
@@ -253,60 +348,19 @@ object OutgoingMessageCoordinator {
|
||||
for (row in rows) {
|
||||
when (row.kind) {
|
||||
KIND_SEND_PUBLIC -> {
|
||||
val sendResult = runCatching {
|
||||
val payload = json.decodeFromString<PublicOutboxPayload>(row.payloadJson)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!handlePublicOutboxSend(id, row)) {
|
||||
allOk = false
|
||||
}
|
||||
}
|
||||
KIND_SEND_DM -> {
|
||||
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,
|
||||
)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageDatabaseProvider.database.messageDatabaseQueries
|
||||
.deleteOutboxItem(id, row.clientMessageId)
|
||||
}
|
||||
if (!handleDmOutboxSend(id, row)) {
|
||||
allOk = false
|
||||
}
|
||||
}
|
||||
KIND_SEND_DM_ATTACHMENT -> {
|
||||
if (!DmAttachmentOutboxHandler.process(row)) {
|
||||
allOk = false
|
||||
drainScope.launch {
|
||||
delay(3_000)
|
||||
drainOutboxForInstance(id)
|
||||
}
|
||||
scheduleOutboxRetry(id)
|
||||
}
|
||||
}
|
||||
KIND_SEND_DM_ATTACHMENT_AWAITING_ACK -> Unit
|
||||
|
||||
+2
-1
@@ -5,5 +5,6 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
data class SendMessageRequest(
|
||||
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.UserStatusStore
|
||||
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.types.WebSocketUpdatesData
|
||||
import ru.fromchat.config.ServerConfig
|
||||
@@ -278,10 +277,7 @@ fun App(
|
||||
MainScope().launch {
|
||||
val instanceId = CacheContext.activeInstanceId.value.trim()
|
||||
if (instanceId.isNotEmpty()) {
|
||||
scheduleOutboxProcessing(instanceId)
|
||||
kotlinx.coroutines.withContext(Dispatchers.Default) {
|
||||
OutgoingMessageCoordinator.drainOutboxForInstance(instanceId)
|
||||
}
|
||||
OutgoingMessageCoordinator.onTransportReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
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.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -12,7 +10,6 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.MaterialShapes
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -20,16 +17,11 @@ import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
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_legal_notice_and
|
||||
import ru.fromchat.auth_legal_notice_prefix
|
||||
import ru.fromchat.auth_step_profile_body
|
||||
import ru.fromchat.auth_step_profile_title
|
||||
import ru.fromchat.auth_username_taken
|
||||
@@ -38,8 +30,6 @@ import ru.fromchat.display_name_error
|
||||
import ru.fromchat.error_unexpected
|
||||
import ru.fromchat.profile_headline_bio
|
||||
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.register
|
||||
import ru.fromchat.ui.components.ActionButton
|
||||
@@ -70,7 +60,6 @@ internal fun profileStepPage(
|
||||
onSnackbar: (String) -> Unit,
|
||||
): ExpressiveStepPage {
|
||||
val scope = rememberCoroutineScope()
|
||||
val navController = LocalNavController.current
|
||||
val fieldColors = expressiveStepFieldColors()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
@@ -134,36 +123,6 @@ internal fun profileStepPage(
|
||||
)
|
||||
},
|
||||
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(
|
||||
onClick = {
|
||||
if (busy) return@ActionButton
|
||||
|
||||
@@ -204,11 +204,13 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to other user's status when DM is visible; unsubscribe on leave
|
||||
LaunchedEffect(panelState.profileUserId) {
|
||||
// Subscribe to other user's status when DM is visible; re-subscribe after reconnect
|
||||
LaunchedEffect(panelState.profileUserId, connectionStatus) {
|
||||
val userId = panelState.profileUserId
|
||||
if (userId != null) {
|
||||
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
||||
if (connectionStatus == ConnectionStatus.CONNECTED) {
|
||||
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
||||
}
|
||||
try {
|
||||
kotlinx.coroutines.awaitCancellation()
|
||||
} finally {
|
||||
@@ -302,10 +304,10 @@ fun ChatScreen(
|
||||
val lastSeen = data["lastSeen"]?.jsonPrimitive?.content
|
||||
if (userId != null) UserStatusStore.update(userId, online, lastSeen)
|
||||
}
|
||||
"newMessage", "messageEdited", "messageDeleted",
|
||||
"newMessage", "messageEdited", "messageDeleted", "sendMessage",
|
||||
"dmNew", "dmEdited", "dmDeleted",
|
||||
"typing", "stopTyping", "dmTyping", "stopDmTyping",
|
||||
"registeredUserCount" -> {
|
||||
"reactionUpdate", "registeredUserCount" -> {
|
||||
Logger.d("ChatScreen", "handleWebSocketMessage for ${update.type}")
|
||||
try {
|
||||
panel.handleWebSocketMessage(wsMessage)
|
||||
@@ -327,7 +329,8 @@ fun ChatScreen(
|
||||
if (userId != null) UserStatusStore.update(userId, online, lastSeen)
|
||||
}
|
||||
"newMessage", "messageEdited", "messageDeleted", "dmNew", "dmEdited", "dmDeleted",
|
||||
"dmTyping", "stopDmTyping", "registeredUserCount" -> {
|
||||
"dmTyping", "stopDmTyping", "typing", "stopTyping", "reactionUpdate",
|
||||
"registeredUserCount" -> {
|
||||
scope.launch {
|
||||
panel.handleWebSocketMessage(message)
|
||||
}
|
||||
|
||||
@@ -79,13 +79,7 @@ class DmPanel(
|
||||
}
|
||||
|
||||
init {
|
||||
updateState {
|
||||
it.copy(
|
||||
title = "",
|
||||
titleAvatar = null,
|
||||
profileUserId = otherUserId
|
||||
)
|
||||
}
|
||||
applyCachedPeerProfileOrReset()
|
||||
coroutineScope.launch {
|
||||
typingHandler.typingUsers.collect { users ->
|
||||
updateState { it.copy(typingUsers = users) }
|
||||
@@ -108,39 +102,77 @@ class DmPanel(
|
||||
}
|
||||
}
|
||||
coroutineScope.launch(Dispatchers.Default) {
|
||||
if (_state.title.isBlank()) {
|
||||
loadPeerTitleFromConversationCache()
|
||||
}
|
||||
runCatching {
|
||||
ApiClient.getProfileById(otherUserId)
|
||||
}.onSuccess { profile ->
|
||||
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
|
||||
ProfileCache.evictUnusableClientPreview(otherUserId)
|
||||
updateState {
|
||||
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
|
||||
if (_state.title.isBlank()) {
|
||||
updateState {
|
||||
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
|
||||
}
|
||||
}
|
||||
return@onSuccess
|
||||
}
|
||||
ProfileCache.put(profile)
|
||||
val displayName = profile.visibleDisplayName(ApiClient.user?.id).orEmpty()
|
||||
otherDisplayName = displayName
|
||||
otherProfilePicture = profile.profilePicture
|
||||
updateState {
|
||||
it.copy(
|
||||
title = displayName,
|
||||
titleAvatar = AvatarInfo(
|
||||
displayName = displayName,
|
||||
profilePictureUrl = otherProfilePicture
|
||||
),
|
||||
profileUserId = otherUserId
|
||||
)
|
||||
if (displayName.isNotBlank()) {
|
||||
applyPeerTitle(displayName, profile.profilePicture)
|
||||
}
|
||||
}.onFailure {
|
||||
ProfileCache.evictUnusableClientPreview(otherUserId)
|
||||
updateState {
|
||||
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
|
||||
if (_state.title.isBlank()) {
|
||||
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?) {
|
||||
val cid = clientMessageId?.trim().orEmpty()
|
||||
if (cid.isEmpty()) return
|
||||
@@ -313,14 +345,15 @@ class DmPanel(
|
||||
if (envelope.senderId == currentUserId) {
|
||||
mergeConfirmedOwnMessage(envelope, outcome.plaintext, outcome.isCorrupted)
|
||||
} 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) {
|
||||
val replyTo = _state.messages.find { it.id == envelope.replyToId }
|
||||
updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
MessageCacheStore.replaceDmMessages(otherUserId, _state.messages)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-42
@@ -68,13 +68,18 @@ class PublicChatPanel(
|
||||
get() = true
|
||||
|
||||
init {
|
||||
updateState {
|
||||
it.copy(
|
||||
title = "",
|
||||
titleAvatar = null,
|
||||
publicGroupMetaLoading = true,
|
||||
publicGroupMemberCount = null
|
||||
)
|
||||
val cachedProfile = PublicChatProfileCache.profile
|
||||
if (cachedProfile != null) {
|
||||
applyPublicChatProfile(cachedProfile)
|
||||
} else {
|
||||
updateState {
|
||||
it.copy(
|
||||
title = "",
|
||||
titleAvatar = null,
|
||||
publicGroupMetaLoading = true,
|
||||
publicGroupMemberCount = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
scope.launch {
|
||||
typingHandler.typingUsers.collect { users ->
|
||||
@@ -83,17 +88,13 @@ class PublicChatPanel(
|
||||
}
|
||||
}
|
||||
scope.launch(Dispatchers.Default) {
|
||||
val cached = PublicChatProfileCache.profile
|
||||
if (cached != null) {
|
||||
applyPublicChatProfile(cached)
|
||||
}
|
||||
runCatching { ApiClient.getPublicChatProfile() }
|
||||
.onSuccess { profile ->
|
||||
PublicChatProfileCache.put(profile)
|
||||
applyPublicChatProfile(profile)
|
||||
}
|
||||
.onFailure {
|
||||
if (cached == null) {
|
||||
if (cachedProfile == null) {
|
||||
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 the oldest pending optimistic row (same user, text, reply) and replace it; otherwise append.
|
||||
* Match optimistic rows via [Message.client_message_id] from the server ack (never by text).
|
||||
*/
|
||||
private suspend fun confirmIncomingOwnMessageOrAdd(newMsg: Message) {
|
||||
val uid = currentUserId
|
||||
if (uid == null) {
|
||||
addMessage(newMsg)
|
||||
return
|
||||
if (uid != null && newMsg.user_id == uid) {
|
||||
val cid = newMsg.client_message_id?.trim().orEmpty()
|
||||
if (cid.isNotEmpty()) {
|
||||
handleMessageConfirmed(cid, newMsg)
|
||||
return
|
||||
}
|
||||
if (newMsg.id > 0 && _state.messages.any { it.id == newMsg.id }) {
|
||||
return
|
||||
}
|
||||
}
|
||||
if (newMsg.user_id != uid) {
|
||||
addMessage(newMsg)
|
||||
return
|
||||
}
|
||||
if (newMsg.client_message_id != null) {
|
||||
handleMessageConfirmed(newMsg.client_message_id, newMsg)
|
||||
return
|
||||
}
|
||||
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)
|
||||
ingestIncomingPublicMessage(newMsg)
|
||||
}
|
||||
|
||||
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
|
||||
addMessage(newMsg)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.upsertPublicMessage(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?) {
|
||||
val cid = clientMessageId?.trim().orEmpty()
|
||||
if (cid.isEmpty()) return
|
||||
@@ -224,15 +223,17 @@ class PublicChatPanel(
|
||||
if (_state.isLoading) setLoading(false)
|
||||
} else {
|
||||
batchStateUpdates {
|
||||
val merged = mergeNetworkHistoryWithShown(shown, response.messages)
|
||||
clearMessages()
|
||||
addMessages(response.messages)
|
||||
addMessages(merged)
|
||||
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.replacePublicMessages(response.messages)
|
||||
val mergedForCache = mergeNetworkHistoryWithShown(_state.messages, response.messages)
|
||||
MessageCacheStore.replacePublicMessages(mergedForCache)
|
||||
}
|
||||
} else if (responseResult.isFailure) {
|
||||
val cause = responseResult.exceptionOrNull()
|
||||
|
||||
+18
@@ -10,11 +10,18 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.fromchat.api.ApiClient
|
||||
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.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.utils.PublicChatPanelCache
|
||||
import ru.fromchat.utils.NetworkConnectivity
|
||||
|
||||
@Composable
|
||||
fun PublicChatScreen(
|
||||
@@ -32,6 +39,8 @@ fun PublicChatScreen(
|
||||
}
|
||||
|
||||
val activeInstanceId by CacheContext.activeInstanceId.collectAsState()
|
||||
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
|
||||
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||
|
||||
LaunchedEffect(panel, activeInstanceId) {
|
||||
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) {
|
||||
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||
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.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
@@ -46,6 +47,8 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
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.graphics.Color
|
||||
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.UserStatus
|
||||
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.cd_chat_preview_sending
|
||||
import ru.fromchat.cd_chat_preview_uploading
|
||||
import ru.fromchat.cd_chat_selected
|
||||
import ru.fromchat.presence_online
|
||||
import ru.fromchat.ui.chat.Avatar
|
||||
import ru.fromchat.ui.chat.ExpressiveUploadIndicator
|
||||
import ru.fromchat.ui.chat.TypingIndicator
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.unread_count
|
||||
@@ -118,7 +126,7 @@ internal fun ChatConversationsList(
|
||||
listFilter: ChatListFilter,
|
||||
conversations: List<CachedConversation>,
|
||||
publicChatTitle: String?,
|
||||
publicLastMessagePreview: String?,
|
||||
publicChatPreviewState: ChatListPreviewState?,
|
||||
defaultLastMessage: String,
|
||||
statusMap: Map<Int, UserStatus>,
|
||||
listMode: ChatsListMode,
|
||||
@@ -175,7 +183,7 @@ internal fun ChatConversationsList(
|
||||
val position = listItemPositionInGroup(0, groupCount)
|
||||
PublicChatRow(
|
||||
publicChatTitle = publicChatTitle,
|
||||
publicLastMessagePreview = publicLastMessagePreview,
|
||||
publicChatPreviewState = publicChatPreviewState,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
lazyIndex = ChatListLayout.PUBLIC_CHAT_ROW,
|
||||
listMode = listMode,
|
||||
@@ -507,7 +515,7 @@ internal fun ChatRowAvatar(
|
||||
@Composable
|
||||
internal fun PublicChatRow(
|
||||
publicChatTitle: String?,
|
||||
publicLastMessagePreview: String?,
|
||||
publicChatPreviewState: ChatListPreviewState?,
|
||||
defaultLastMessage: String,
|
||||
lazyIndex: Int,
|
||||
listMode: ChatsListMode,
|
||||
@@ -569,7 +577,7 @@ internal fun PublicChatRow(
|
||||
) {
|
||||
PublicChatRowContent(
|
||||
publicChatTitle = publicChatTitle,
|
||||
publicLastMessagePreview = publicLastMessagePreview,
|
||||
publicChatPreviewState = publicChatPreviewState,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
listMode = listMode,
|
||||
selectionTransitionProgress = selectionTransitionProgress,
|
||||
@@ -591,7 +599,7 @@ internal fun PublicChatRow(
|
||||
@Composable
|
||||
internal fun PublicChatRowContent(
|
||||
publicChatTitle: String?,
|
||||
publicLastMessagePreview: String?,
|
||||
publicChatPreviewState: ChatListPreviewState?,
|
||||
defaultLastMessage: String,
|
||||
listMode: ChatsListMode,
|
||||
selectionTransitionProgress: Float,
|
||||
@@ -606,11 +614,22 @@ internal fun PublicChatRowContent(
|
||||
onBodyLongPress: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val preview = publicLastMessagePreview ?: defaultLastMessage
|
||||
val preview = publicChatPreviewState?.displayText(defaultLastMessage) ?: defaultLastMessage
|
||||
|
||||
ListItem(
|
||||
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,
|
||||
position = listItemPosition,
|
||||
groupItemCount = groupItemCount,
|
||||
@@ -783,11 +802,10 @@ internal fun DmConversationRowContent(
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
else -> Text(
|
||||
text = preview,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
else -> ChatListPreviewSupportingText(
|
||||
preview = preview,
|
||||
pendingIndicator = conversation.lastMessagePendingIndicator,
|
||||
uploadProgress = conversation.lastMessageUploadProgress,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -863,6 +881,65 @@ internal fun chatContextMenuClampedMenuX(
|
||||
}
|
||||
|
||||
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>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
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.chat_last_mesaage
|
||||
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_not_found
|
||||
import ru.fromchat.search_not_found_message
|
||||
@@ -86,8 +89,13 @@ fun ChatsSearchScreen(
|
||||
var searchText by remember { mutableStateOf("") }
|
||||
val searchListState = rememberLazyListState()
|
||||
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 attachmentOnlyPreview = stringResource(Res.string.chat_preview_attachment)
|
||||
val searchHint = stringResource(Res.string.search_hint)
|
||||
val searchBarHint = stringResource(Res.string.search_title)
|
||||
var dmConversations by remember { mutableStateOf<List<CachedConversation>>(emptyList()) }
|
||||
@@ -163,7 +171,7 @@ fun ChatsSearchScreen(
|
||||
}.onSuccess { conversations ->
|
||||
runCatching {
|
||||
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
|
||||
MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview)
|
||||
MessageRepository.replaceDmConversations(conversations, previewStrings)
|
||||
dmConversations = MessageRepository.loadCachedDmConversations()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import com.pr0gramm3r101.components.ListItemPosition
|
||||
import ru.fromchat.api.local.db.store.CachedConversation
|
||||
import ru.fromchat.api.local.messages.ChatListPreviewState
|
||||
import ru.fromchat.api.local.db.store.UserStatus
|
||||
|
||||
enum class ChatsListMode {
|
||||
@@ -83,7 +84,7 @@ data class ChatContextMenuOverlayUiState(
|
||||
val blurProgress: Float = 0f,
|
||||
val listFilter: ChatListFilter = ChatListFilter.Active,
|
||||
val publicChatTitle: String? = null,
|
||||
val publicLastMessagePreview: String? = null,
|
||||
val publicChatPreviewState: ChatListPreviewState? = null,
|
||||
val publicChatLink: String? = null,
|
||||
val defaultLastMessage: String = "",
|
||||
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.ConnectionStateStore
|
||||
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.ProfileCache
|
||||
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_last_mesaage
|
||||
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.config.ServerConfig
|
||||
import ru.fromchat.search_title
|
||||
@@ -281,7 +286,7 @@ fun ChatsTab(
|
||||
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
|
||||
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) }
|
||||
val searchBarHint = stringResource(Res.string.search_title)
|
||||
val tabListState = rememberLazyListState()
|
||||
@@ -289,9 +294,22 @@ fun ChatsTab(
|
||||
var subscribedDmUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
||||
val statusSubscriptionScope = rememberCoroutineScope()
|
||||
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)
|
||||
|
||||
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) {
|
||||
MessageCacheStore.listPreviewStrings = previewStrings
|
||||
}
|
||||
|
||||
SideEffect {
|
||||
MessageCacheStore.listPreviewStrings = previewStrings
|
||||
}
|
||||
|
||||
var listMode by remember { mutableStateOf(ChatsListMode.Normal) }
|
||||
var publicChatSelected by remember { mutableStateOf(false) }
|
||||
var selectedOtherUserIds by remember { mutableStateOf<Set<Int>>(emptySet()) }
|
||||
@@ -399,7 +417,7 @@ fun ChatsTab(
|
||||
onDispose { chatContextMenuOverlay.clear() }
|
||||
}
|
||||
|
||||
LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch) {
|
||||
LaunchedEffect(dmConversations, tabListState, isVisible, onOpenSearch, connectionStatus) {
|
||||
snapshotFlow {
|
||||
if (!isVisible) {
|
||||
emptySet()
|
||||
@@ -415,8 +433,10 @@ fun ChatsTab(
|
||||
}
|
||||
.distinctUntilChanged()
|
||||
.collect { visibleIds ->
|
||||
(visibleIds - subscribedDmUserIds).forEach { userId ->
|
||||
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
||||
if (connectionStatus == ConnectionStatus.CONNECTED) {
|
||||
visibleIds.forEach { userId ->
|
||||
runCatching { ApiClient.sendSubscribeStatus(userId) }
|
||||
}
|
||||
}
|
||||
|
||||
(subscribedDmUserIds - visibleIds).forEach { userId ->
|
||||
@@ -441,6 +461,32 @@ fun ChatsTab(
|
||||
val serverConfig by ServerConfig.serverConfig.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) {
|
||||
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||
|
||||
@@ -451,21 +497,12 @@ fun ChatsTab(
|
||||
dmConversations = conversations
|
||||
}
|
||||
|
||||
runCatching {
|
||||
publicLastMessagePreview = MessageRepository
|
||||
.loadRecentPublicMessages(1)
|
||||
.lastOrNull()
|
||||
?.content
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
}
|
||||
|
||||
runCatching {
|
||||
ApiClient.getDmConversations()
|
||||
}.onSuccess { conversations ->
|
||||
runCatching {
|
||||
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
|
||||
MessageRepository.replaceDmConversations(conversations, attachmentOnlyPreview)
|
||||
MessageRepository.replaceDmConversations(conversations, previewStrings)
|
||||
dmConversations = MessageRepository.loadCachedDmConversations()
|
||||
}
|
||||
}
|
||||
@@ -652,7 +689,7 @@ fun ChatsTab(
|
||||
listFilter = ChatListFilter.Active,
|
||||
conversations = dmConversations,
|
||||
publicChatTitle = publicChatTitle,
|
||||
publicLastMessagePreview = publicLastMessagePreview,
|
||||
publicChatPreviewState = publicChatPreviewState,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
statusMap = statusMap,
|
||||
listMode = listMode,
|
||||
@@ -813,7 +850,7 @@ fun ChatsTab(
|
||||
blurProgress = chatContextMenuOverlay.blurProgress,
|
||||
listFilter = ChatListFilter.Active,
|
||||
publicChatTitle = publicChatTitle,
|
||||
publicLastMessagePreview = publicLastMessagePreview,
|
||||
publicChatPreviewState = publicChatPreviewState,
|
||||
publicChatLink = publicChatLink,
|
||||
defaultLastMessage = defaultLastMessage,
|
||||
conversations = dmConversations,
|
||||
@@ -1238,7 +1275,7 @@ private fun ChatContextMenuOverlay(
|
||||
ChatContextMenuTarget.Public -> {
|
||||
PublicChatRowContent(
|
||||
publicChatTitle = uiState.publicChatTitle,
|
||||
publicLastMessagePreview = uiState.publicLastMessagePreview,
|
||||
publicChatPreviewState = uiState.publicChatPreviewState,
|
||||
defaultLastMessage = uiState.defaultLastMessage,
|
||||
listMode = uiState.listMode,
|
||||
selectionTransitionProgress = uiState.selectionTransitionProgress,
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
package ru.fromchat.ui.main.settings
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
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.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.HazeMaterials
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
@@ -96,6 +100,16 @@ import ru.fromchat.ui.components.HazeActionButton
|
||||
import ru.fromchat.ui.components.Text
|
||||
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) =
|
||||
listOfNotNull(
|
||||
d.deviceName,
|
||||
@@ -339,8 +353,10 @@ fun DevicesScreen(onBack: () -> Unit) {
|
||||
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val initialCache = remember { Settings.readDeviceSessionsCache() }
|
||||
var devices by remember { mutableStateOf(initialCache) }
|
||||
var loading by remember { mutableStateOf(initialCache == null) }
|
||||
var devices by remember {
|
||||
mutableStateOf(initialCache?.let(::enrichDevicesList).orEmpty())
|
||||
}
|
||||
var refreshing by remember { mutableStateOf(initialCache == null) }
|
||||
var sheetDevice by remember { mutableStateOf<DeviceSessionInfo?>(null) }
|
||||
var sheetSigningOut by remember { mutableStateOf(false) }
|
||||
var showLogoutAllConfirm by remember { mutableStateOf(false) }
|
||||
@@ -351,41 +367,27 @@ fun DevicesScreen(onBack: () -> Unit) {
|
||||
sheetSigningOut = false
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
scope.launch {
|
||||
if (devices == null) loading = true
|
||||
suspend fun fetchDevices() {
|
||||
refreshing = true
|
||||
|
||||
devices = runCatching { ApiClient.listDevices() }
|
||||
.let {
|
||||
if (it.isSuccess) {
|
||||
Settings.writeDeviceSessionsCache(it.getOrNull()!!)
|
||||
it.getOrNull()!!
|
||||
} else {
|
||||
snackbarHostState.showSnackbar(errUnexpected)
|
||||
runCatching { ApiClient.listDevices() }
|
||||
.onSuccess { list ->
|
||||
Settings.writeDeviceSessionsCache(list)
|
||||
devices = enrichDevicesList(list)
|
||||
}
|
||||
.onFailure {
|
||||
snackbarHostState.showSnackbar(errUnexpected)
|
||||
}
|
||||
|
||||
if (devices == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
devices
|
||||
}
|
||||
}
|
||||
}?.let {
|
||||
it.indexOfFirst { it.current }.let { index ->
|
||||
it
|
||||
.toMutableList()
|
||||
.also {
|
||||
it[index] = deviceSessionForCurrentDevice(it[index])
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
}
|
||||
|
||||
loading = false
|
||||
}
|
||||
refreshing = false
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
reload()
|
||||
fetchDevices()
|
||||
while (isActive) {
|
||||
delay(DEVICE_SESSIONS_POLL_INTERVAL_MS)
|
||||
fetchDevices()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
@@ -414,7 +416,7 @@ fun DevicesScreen(onBack: () -> Unit) {
|
||||
)
|
||||
},
|
||||
bottomBar = {
|
||||
if (devices?.let { it.size > 1 } == true) {
|
||||
if (devices.size > 1) {
|
||||
HazeActionButton(
|
||||
hazeState = hazeState,
|
||||
onClick = { showLogoutAllConfirm = true }
|
||||
@@ -424,114 +426,97 @@ fun DevicesScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
) { innerPadding ->
|
||||
if (loading) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.hazeSource(hazeState),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
val active_sessions = stringResource(Res.string.settings_devices_active_sessions)
|
||||
val activeSessionsTitle = stringResource(Res.string.settings_devices_active_sessions)
|
||||
val (currentDevice, sessionList) = remember(devices) {
|
||||
val mutable = devices.toMutableList()
|
||||
val current = mutable.firstOrNull { it.current }
|
||||
current?.let { mutable.remove(it) }
|
||||
current to mutable
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.padding()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||
contentPadding = innerPadding
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
ExpressiveIconFrame(
|
||||
icon = Icons.Filled.Devices,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
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 }
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.hazeSource(hazeState)
|
||||
.padding()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 24.dp),
|
||||
contentPadding = innerPadding
|
||||
) {
|
||||
item {
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
ExpressiveIconFrame(
|
||||
icon = Icons.Filled.Devices,
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
materialPolygon = MaterialShapes.VerySunny
|
||||
)
|
||||
}
|
||||
|
||||
if (currentDevice != null) {
|
||||
item {
|
||||
Category(
|
||||
title = stringResource(Res.string.settings_devices_this_device),
|
||||
margin = PaddingValues(bottom = 20.dp)
|
||||
) {
|
||||
Device(currentDevice, false)
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@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(
|
||||
margin = PaddingValues(bottom = 20.dp),
|
||||
title = active_sessions
|
||||
title = activeSessionsTitle
|
||||
) {
|
||||
sessionList.forEachIndexed { index, it ->
|
||||
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 {
|
||||
sheetState.hide()
|
||||
sheetDevice = null
|
||||
reload()
|
||||
fetchDevices()
|
||||
}.onFailure {
|
||||
snackbarHostState.showSnackbar(errUnexpected)
|
||||
}
|
||||
@@ -587,7 +589,7 @@ fun DevicesScreen(onBack: () -> Unit) {
|
||||
showLogoutAllConfirm = false
|
||||
scope.launch {
|
||||
runCatching { ApiClient.revokeAllOtherDeviceSessions() }
|
||||
.onSuccess { reload() }
|
||||
.onSuccess { fetchDevices() }
|
||||
.onFailure { snackbarHostState.showSnackbar(errUnexpected) }
|
||||
}
|
||||
}
|
||||
|
||||
+39
-37
@@ -98,6 +98,7 @@ import ru.fromchat.config.DEFAULT_CALLS_PORT
|
||||
import ru.fromchat.config.ServerConfigData
|
||||
import ru.fromchat.config.Settings
|
||||
import ru.fromchat.api.instance.ServerProbeResult
|
||||
import ru.fromchat.api.instance.ApplyServerResult
|
||||
import ru.fromchat.api.instance.applyServerAndNavigate
|
||||
import ru.fromchat.api.instance.probeServer
|
||||
import ru.fromchat.save_continue
|
||||
@@ -305,15 +306,9 @@ fun ServerConfigScreen() {
|
||||
busy = true
|
||||
|
||||
val tentative = buildTentativeConfig() ?: return@launch
|
||||
val probe = if (
|
||||
lastProbedConfig == tentative && lastProbe != null
|
||||
) {
|
||||
lastProbe!!
|
||||
} else {
|
||||
probeServer(tentative).also {
|
||||
lastProbe = it
|
||||
lastProbedConfig = tentative
|
||||
}
|
||||
val probe = probeServer(tentative).also {
|
||||
lastProbe = it
|
||||
lastProbedConfig = tentative
|
||||
}
|
||||
|
||||
when (probe) {
|
||||
@@ -332,38 +327,45 @@ fun ServerConfigScreen() {
|
||||
}
|
||||
|
||||
is ServerProbeResult.Supported -> {
|
||||
Settings.lastKnownServerInstanceId =
|
||||
probe.instanceId
|
||||
|
||||
applyServerAndNavigate(
|
||||
probe = probe,
|
||||
config = tentative,
|
||||
bearer = ApiClient.token?.trim().orEmpty(),
|
||||
onNavigateLogin = {
|
||||
withContext(Dispatchers.Main) {
|
||||
navController.navigateAndWipeBackStack("auth")
|
||||
}
|
||||
},
|
||||
onNavigateChat = {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (!navController.popBackStack()) {
|
||||
navController.navigate("chat") {
|
||||
popUpTo("welcome") {
|
||||
inclusive = true
|
||||
when (
|
||||
applyServerAndNavigate(
|
||||
probe = probe,
|
||||
config = tentative,
|
||||
bearer = ApiClient.token?.trim().orEmpty(),
|
||||
onNavigateLogin = {
|
||||
withContext(Dispatchers.Main) {
|
||||
navController.navigateAndWipeBackStack("auth")
|
||||
}
|
||||
},
|
||||
onNavigateChat = {
|
||||
withContext(Dispatchers.Main) {
|
||||
if (!navController.popBackStack()) {
|
||||
navController.navigate("chat") {
|
||||
popUpTo("welcome") {
|
||||
inclusive = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onLogoutOldHost = {
|
||||
withContext(Dispatchers.Main) {
|
||||
WebSocketManager.disconnect()
|
||||
runCatching { ApiClient.logout() }
|
||||
ApiClient.clearMemorySession()
|
||||
navController.navigateAndWipeBackStack("auth")
|
||||
}
|
||||
},
|
||||
onLogoutOldHost = {
|
||||
withContext(Dispatchers.Main) {
|
||||
WebSocketManager.disconnect()
|
||||
runCatching { ApiClient.logout() }
|
||||
ApiClient.clearMemorySession()
|
||||
navController.navigateAndWipeBackStack("auth")
|
||||
}
|
||||
},
|
||||
)
|
||||
) {
|
||||
ApplyServerResult.Applied -> {
|
||||
Settings.lastKnownServerInstanceId =
|
||||
probe.instanceId
|
||||
}
|
||||
)
|
||||
ApplyServerResult.ServerUnreachable -> {
|
||||
snackbarHostState.showSnackbar(strSnackbarApiFail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -18,6 +18,9 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.Column
|
||||
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.material3.IconButton
|
||||
import androidx.compose.material3.IconButtonDefaults
|
||||
@@ -25,17 +28,21 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.navigation.NavController
|
||||
import com.pr0gramm3r101.components.ListItemPosition
|
||||
import com.pr0gramm3r101.utils.SupportClipboardManager
|
||||
import dev.chrisbanes.haze.HazeProgressive
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
|
||||
import dev.chrisbanes.haze.materials.HazeMaterials
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
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.ContentCopy
|
||||
import androidx.compose.material.icons.rounded.Edit
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
@@ -114,6 +122,7 @@ import ru.fromchat.Logger
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.action_copy
|
||||
import ru.fromchat.action_edit
|
||||
import ru.fromchat.action_retry_send
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.calls.CallStore
|
||||
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.TypingIndicator
|
||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||
import ru.fromchat.ui.components.ShimmerBox
|
||||
import ru.fromchat.ui.components.Text
|
||||
import ru.fromchat.ui.components.showReplacingSnackbar
|
||||
import ru.fromchat.utils.RegistrationDateFormatStrings
|
||||
import ru.fromchat.utils.formatLastSeen
|
||||
import ru.fromchat.utils.formatProfileRegistrationDate
|
||||
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
||||
@@ -275,6 +286,8 @@ fun ProfileScreen(
|
||||
mutableStateOf(hasDisplayableProfile(state.profile, initialDisplayName, ownUserId))
|
||||
}
|
||||
|
||||
var reloadAttempt by remember(lookupKey) { mutableIntStateOf(0) }
|
||||
|
||||
val latestUi by rememberUpdatedState(state)
|
||||
|
||||
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(
|
||||
"ProfileScreen",
|
||||
"load start: mode=$lookupMode identifier=$lookupIdentifier cacheLookupId=$cacheLookupId ownUserId=$ownUserId"
|
||||
@@ -355,7 +372,7 @@ fun ProfileScreen(
|
||||
|
||||
val resolvedErrorMessage = when {
|
||||
err is ClientRequestException && err.response.status.value == 404 -> profileNotFound
|
||||
else -> err.message?.takeIf { it.isNotBlank() } ?: profileLoadFailed
|
||||
else -> profileLoadFailed
|
||||
}
|
||||
|
||||
if (err is ClientRequestException) {
|
||||
@@ -410,6 +427,7 @@ fun ProfileScreen(
|
||||
val headlineVerification = stringResource(Res.string.profile_headline_verification)
|
||||
val verifiedSupport = stringResource(Res.string.profile_verified_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)
|
||||
if (hasDisplayableProfile(profile, initialDisplayName, ownUserId)) {
|
||||
@@ -418,7 +436,8 @@ fun ProfileScreen(
|
||||
val statusMap by UserStatusStore.status.collectAsState()
|
||||
val lastSeenFormatStrings = rememberLastSeenFormatStrings()
|
||||
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 displayName =
|
||||
profile?.visibleDisplayName(currentProfileUserId)
|
||||
@@ -426,7 +445,7 @@ fun ProfileScreen(
|
||||
?: "?"
|
||||
val usernameForLinks = profile?.visibleUsername(currentProfileUserId)
|
||||
|
||||
val resolvedProfile = profile?.takeIf { loadError == null && !showLoadingSpinner }
|
||||
val resolvedProfile = profile?.takeIf { !showSkeleton }
|
||||
val profileLink = resolvedProfile?.let {
|
||||
usernameForLinks?.let { name -> "https://fromchat.ru/@$name" }
|
||||
?: "https://fromchat.ru/?u=${it.id}"
|
||||
@@ -539,6 +558,18 @@ fun ProfileScreen(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
when {
|
||||
showSkeleton && !hideAvatar -> {
|
||||
item {
|
||||
ShimmerBox(
|
||||
modifier = Modifier
|
||||
.padding(top = profileAvatarTop)
|
||||
.size(104.dp),
|
||||
shape = CircleShape,
|
||||
)
|
||||
}
|
||||
item { Spacer(Modifier.height(12.dp)) }
|
||||
}
|
||||
|
||||
useSharedAvatar -> {
|
||||
item {
|
||||
with(sharedTransitionScope) {
|
||||
@@ -597,271 +628,59 @@ fun ProfileScreen(
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
showLoadingSpinner -> {
|
||||
item {
|
||||
CircularProgressIndicator(modifier = Modifier.padding(top = 24.dp))
|
||||
}
|
||||
}
|
||||
|
||||
loadError != null -> {
|
||||
item {
|
||||
Text(
|
||||
text = when (loadError) {
|
||||
ProfileLoadError.Generic -> profileLoadFailed
|
||||
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)
|
||||
item {
|
||||
AnimatedContent(
|
||||
targetState = showSkeleton,
|
||||
transitionSpec = {
|
||||
fadeIn() togetherWith fadeOut()
|
||||
},
|
||||
label = "profile_body",
|
||||
) { skeleton ->
|
||||
if (skeleton) {
|
||||
ProfileSkeletonBody(
|
||||
onRetry = if (loadError != null) {
|
||||
{ reloadAttempt++ }
|
||||
} else {
|
||||
Text(
|
||||
text = statusText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (animatedState == "online") {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
item { Spacer(Modifier.height(24.dp)) }
|
||||
|
||||
item {
|
||||
ProfileActionButtonRow(
|
||||
actions = profileActions.orEmpty(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
null
|
||||
},
|
||||
retryLabel = labelRetry,
|
||||
)
|
||||
} else if (resolvedProfile != null) {
|
||||
ProfileLoadedBody(
|
||||
resolvedProfile = resolvedProfile,
|
||||
displayName = displayName,
|
||||
isOwnProfile = isOwnProfile,
|
||||
typingUsers = typingUsers,
|
||||
statusState = statusState,
|
||||
statusText = statusText,
|
||||
profileActions = profileActions.orEmpty(),
|
||||
showDetailsSection = showDetailsSection,
|
||||
showDetailsUsername = showDetailsUsername,
|
||||
showDetailsMemberSince = showDetailsMemberSince,
|
||||
showDetailsBio = showDetailsBio,
|
||||
showDetailsVerify = showDetailsVerify,
|
||||
headlineUsername = headlineUsername,
|
||||
headlineMemberSince = headlineMemberSince,
|
||||
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)
|
||||
}
|
||||
|
||||
@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
|
||||
private fun ProfileBioMarkdown(
|
||||
content: String,
|
||||
|
||||
Reference in New Issue
Block a user