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