mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
Fix deleted account issues
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
@@ -3,16 +3,10 @@ package ru.fromchat.api
|
|||||||
import com.google.android.gms.tasks.Task
|
import com.google.android.gms.tasks.Task
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import com.pr0gramm3r101.utils.settings.settings
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.request.header
|
|
||||||
import io.ktor.client.request.post
|
|
||||||
import io.ktor.client.request.setBody
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.schema.core.SimpleStatusResponse
|
|
||||||
import ru.fromchat.config.ServerConfig
|
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
import kotlin.coroutines.resumeWithException
|
import kotlin.coroutines.resumeWithException
|
||||||
|
|
||||||
@@ -34,14 +28,8 @@ private suspend fun fetchCurrentFcmToken(): String? = suspendCancellableCoroutin
|
|||||||
|
|
||||||
private suspend fun postFcmToken(token: String): Boolean {
|
private suspend fun postFcmToken(token: String): Boolean {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val suffix = token.takeLast(8)
|
ApiClient.registerFcmToken(token)
|
||||||
ApiClient.http
|
Logger.i("FcmReg", "Uploaded FCM token to server: ...${token.takeLast(8)}")
|
||||||
.post("${ServerConfig.apiBaseUrl}/push/register") {
|
|
||||||
header("Content-Type", "application/json")
|
|
||||||
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
|
|
||||||
}
|
|
||||||
.body<SimpleStatusResponse>()
|
|
||||||
Logger.i("FcmReg", "Uploaded FCM token to server: ...$suffix")
|
|
||||||
true
|
true
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
Logger.e("FcmReg", "Failed to upload FCM token: ${e.message}", e)
|
Logger.e("FcmReg", "Failed to upload FCM token: ${e.message}", e)
|
||||||
@@ -53,8 +41,11 @@ actual suspend fun uploadPendingFcmTokenIfAvailable() = withContext(Dispatchers.
|
|||||||
try {
|
try {
|
||||||
val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "")
|
val pending = settings.getString(PENDING_FCM_TOKEN_KEY, "")
|
||||||
|
|
||||||
if (ApiClient.token.isNullOrEmpty() || pending.isBlank()) {
|
if (ApiClient.token.isNullOrEmpty()) {
|
||||||
Logger.d("FcmReg", "Auth token missing or no FCM token; deferring FCM token upload")
|
Logger.d("FcmReg", "Auth token missing; deferring FCM token upload")
|
||||||
|
return@withContext
|
||||||
|
}
|
||||||
|
if (pending.isBlank()) {
|
||||||
return@withContext
|
return@withContext
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,22 +93,21 @@ actual suspend fun unregisterFcmTokenFromServer(): Boolean = withContext(Dispatc
|
|||||||
return@withContext false
|
return@withContext false
|
||||||
}
|
}
|
||||||
|
|
||||||
val token = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim()
|
val storedToken = settings.getString(CURRENT_FCM_TOKEN_KEY, "").trim()
|
||||||
|
val token = storedToken.ifBlank {
|
||||||
|
runCatching { fetchCurrentFcmToken()?.trim().orEmpty() }.getOrDefault("")
|
||||||
|
}
|
||||||
Logger.i("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}")
|
Logger.i("FcmReg", "unregisterFcmTokenFromServer requested with token=...${token.takeLast(8)}")
|
||||||
return@withContext runCatching {
|
return@withContext runCatching {
|
||||||
ApiClient.http.post("${ServerConfig.apiBaseUrl}/push/unregister") {
|
ApiClient.unregisterFcmToken(token.takeIf { it.isNotBlank() })
|
||||||
header("Content-Type", "application/json")
|
|
||||||
if (token.isNotEmpty()) {
|
|
||||||
setBody(ApiClient.json.encodeToString(mapOf("token" to token)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
settings.remove(PENDING_FCM_TOKEN_KEY)
|
settings.remove(PENDING_FCM_TOKEN_KEY)
|
||||||
if (token.isNotBlank()) {
|
|
||||||
settings.remove(CURRENT_FCM_TOKEN_KEY)
|
settings.remove(CURRENT_FCM_TOKEN_KEY)
|
||||||
}
|
|
||||||
true
|
true
|
||||||
}.getOrElse { e ->
|
}.getOrElse { e ->
|
||||||
Logger.e("FcmReg", "Failed to unregister FCM token: ${e.message}")
|
Logger.e("FcmReg", "Failed to unregister FCM token: ${e.message}")
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun isFcmPushRegisteredLocally(): Boolean =
|
||||||
|
settings.getString(CURRENT_FCM_TOKEN_KEY, "").isNotBlank()
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ import ru.fromchat.notif_call_ongoing_title
|
|||||||
import ru.fromchat.notif_screenshare_text
|
import ru.fromchat.notif_screenshare_text
|
||||||
import ru.fromchat.notif_screenshare_title
|
import ru.fromchat.notif_screenshare_title
|
||||||
import ru.fromchat.ui.chat.Avatar
|
import ru.fromchat.ui.chat.Avatar
|
||||||
|
import ru.fromchat.ui.profile.avatarLabelForInitials
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
@@ -731,7 +732,8 @@ private fun SoloCallParticipantVideos(
|
|||||||
val lcRef = localCamRefs.firstOrNull()
|
val lcRef = localCamRefs.firstOrNull()
|
||||||
val self = ApiClient.user
|
val self = ApiClient.user
|
||||||
val selfPic = self?.id?.let { ProfileCache.get(it)?.profilePicture } ?: self?.profile_picture
|
val selfPic = self?.id?.let { ProfileCache.get(it)?.profilePicture } ?: self?.profile_picture
|
||||||
val selfName = self?.displayName?.takeIf { !it.isNullOrBlank() } ?: self?.username.orEmpty()
|
val selfAvatarLabel = self?.displayName?.trim().orEmpty()
|
||||||
|
val peerAvatarLabel = ProfileCache.get(session.peerUserId)?.avatarLabelForInitials(self?.id).orEmpty()
|
||||||
val peerPic = ProfileCache.get(session.peerUserId)?.profilePicture
|
val peerPic = ProfileCache.get(session.peerUserId)?.profilePicture
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -753,7 +755,7 @@ private fun SoloCallParticipantVideos(
|
|||||||
}
|
}
|
||||||
lcRef != null && !localCamOn -> {
|
lcRef != null && !localCamOn -> {
|
||||||
RemoteVideoOffPlaceholder(
|
RemoteVideoOffPlaceholder(
|
||||||
displayName = selfName,
|
displayName = selfAvatarLabel,
|
||||||
profilePictureUrl = selfPic,
|
profilePictureUrl = selfPic,
|
||||||
audioLevel = localLevel,
|
audioLevel = localLevel,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -761,7 +763,7 @@ private fun SoloCallParticipantVideos(
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
RemoteVideoOffPlaceholder(
|
RemoteVideoOffPlaceholder(
|
||||||
displayName = session.peerDisplayName,
|
displayName = peerAvatarLabel,
|
||||||
profilePictureUrl = peerPic,
|
profilePictureUrl = peerPic,
|
||||||
audioLevel = 0f,
|
audioLevel = 0f,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
@@ -884,7 +886,8 @@ private fun DuoCallParticipantVideos(
|
|||||||
val selfId = self?.id
|
val selfId = self?.id
|
||||||
val selfPic = selfId?.let { ProfileCache.get(it)?.profilePicture }
|
val selfPic = selfId?.let { ProfileCache.get(it)?.profilePicture }
|
||||||
?: self?.profile_picture
|
?: self?.profile_picture
|
||||||
val selfName = self?.displayName?.takeIf { !it.isNullOrBlank() } ?: self?.username.orEmpty()
|
val selfAvatarLabel = self?.displayName?.trim().orEmpty()
|
||||||
|
val peerAvatarLabel = ProfileCache.get(session.peerUserId)?.avatarLabelForInitials(selfId).orEmpty()
|
||||||
val youLabel = stringResource(Res.string.message_sender_you)
|
val youLabel = stringResource(Res.string.message_sender_you)
|
||||||
val controlsReserve = if (showInCallControls) 168.dp else 0.dp
|
val controlsReserve = if (showInCallControls) 168.dp else 0.dp
|
||||||
val screenShareMainBottomPad = controlsReserve
|
val screenShareMainBottomPad = controlsReserve
|
||||||
@@ -893,7 +896,7 @@ private fun DuoCallParticipantVideos(
|
|||||||
VideoSlot.RemoteScreen, VideoSlot.RemoteCam ->
|
VideoSlot.RemoteScreen, VideoSlot.RemoteCam ->
|
||||||
CallOwnerUi(
|
CallOwnerUi(
|
||||||
name = session.peerDisplayName,
|
name = session.peerDisplayName,
|
||||||
avatarName = session.peerDisplayName,
|
avatarName = peerAvatarLabel,
|
||||||
pictureUrl = peerPic,
|
pictureUrl = peerPic,
|
||||||
level = remoteLevel,
|
level = remoteLevel,
|
||||||
isSelf = false,
|
isSelf = false,
|
||||||
@@ -901,7 +904,7 @@ private fun DuoCallParticipantVideos(
|
|||||||
VideoSlot.LocalCam, VideoSlot.LocalScreen ->
|
VideoSlot.LocalCam, VideoSlot.LocalScreen ->
|
||||||
CallOwnerUi(
|
CallOwnerUi(
|
||||||
name = youLabel,
|
name = youLabel,
|
||||||
avatarName = selfName,
|
avatarName = selfAvatarLabel,
|
||||||
pictureUrl = selfPic,
|
pictureUrl = selfPic,
|
||||||
level = localLevel,
|
level = localLevel,
|
||||||
isSelf = true,
|
isSelf = true,
|
||||||
@@ -950,7 +953,11 @@ private fun DuoCallParticipantVideos(
|
|||||||
}
|
}
|
||||||
slot != VideoSlot.None && mainRef != null && !isScreen(slot) && !camEnabled(slot) -> {
|
slot != VideoSlot.None && mainRef != null && !isScreen(slot) && !camEnabled(slot) -> {
|
||||||
RemoteVideoOffPlaceholder(
|
RemoteVideoOffPlaceholder(
|
||||||
displayName = session.peerDisplayName,
|
displayName = if (slot == VideoSlot.RemoteCam) {
|
||||||
|
peerAvatarLabel
|
||||||
|
} else {
|
||||||
|
selfAvatarLabel
|
||||||
|
},
|
||||||
profilePictureUrl = if (slot == VideoSlot.RemoteCam) peerPic else selfPic,
|
profilePictureUrl = if (slot == VideoSlot.RemoteCam) peerPic else selfPic,
|
||||||
audioLevel = if (slot == VideoSlot.RemoteCam) remoteLevel else localLevel,
|
audioLevel = if (slot == VideoSlot.RemoteCam) remoteLevel else localLevel,
|
||||||
modifier = baseModifier,
|
modifier = baseModifier,
|
||||||
@@ -958,7 +965,7 @@ private fun DuoCallParticipantVideos(
|
|||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
RemoteVideoOffPlaceholder(
|
RemoteVideoOffPlaceholder(
|
||||||
displayName = session.peerDisplayName,
|
displayName = peerAvatarLabel,
|
||||||
profilePictureUrl = peerPic,
|
profilePictureUrl = peerPic,
|
||||||
audioLevel = remoteLevel,
|
audioLevel = remoteLevel,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
|||||||
@@ -110,6 +110,9 @@
|
|||||||
<string name="notif_media_upload_text">Загрузка продолжается в фоне</string>
|
<string name="notif_media_upload_text">Загрузка продолжается в фоне</string>
|
||||||
<string name="message_sender_you">Вы</string>
|
<string name="message_sender_you">Вы</string>
|
||||||
<string name="user_fallback">Человек %1$d</string>
|
<string name="user_fallback">Человек %1$d</string>
|
||||||
|
<string name="deleted_account">Удалённый аккаунт</string>
|
||||||
|
<string name="account_suspended">Аккаунт заблокирован</string>
|
||||||
|
<string name="action_delete_chat">Удалить чат</string>
|
||||||
<string name="message_corrupted">Это сообщение не удалось показать.</string>
|
<string name="message_corrupted">Это сообщение не удалось показать.</string>
|
||||||
<string name="message_edited_suffix">(изменено)</string>
|
<string name="message_edited_suffix">(изменено)</string>
|
||||||
<string name="message_replying_to">Ответ %1$s</string>
|
<string name="message_replying_to">Ответ %1$s</string>
|
||||||
@@ -178,9 +181,11 @@
|
|||||||
<string name="verify">Сделать официальным</string>
|
<string name="verify">Сделать официальным</string>
|
||||||
<string name="unverify">Снять официальный статус</string>
|
<string name="unverify">Снять официальный статус</string>
|
||||||
<string name="cd_verified_account">Официальный аккаунт</string>
|
<string name="cd_verified_account">Официальный аккаунт</string>
|
||||||
|
<string name="cd_account_blocked">Аккаунт заблокирован</string>
|
||||||
<string name="cd_similar_verified">Похож на официальный аккаунт</string>
|
<string name="cd_similar_verified">Похож на официальный аккаунт</string>
|
||||||
<string name="presence_online">В сети</string>
|
<string name="presence_online">В сети</string>
|
||||||
<string name="presence_recently">Недавно заходил</string>
|
<string name="presence_recently">Недавно заходил</string>
|
||||||
|
<string name="presence_long_ago">был(а) давно</string>
|
||||||
<string name="presence_today_at">Сегодня в %1$s</string>
|
<string name="presence_today_at">Сегодня в %1$s</string>
|
||||||
<string name="presence_yesterday_at">Вчера в %1$s</string>
|
<string name="presence_yesterday_at">Вчера в %1$s</string>
|
||||||
<string name="presence_weekday_at">%1$s в %2$s</string>
|
<string name="presence_weekday_at">%1$s в %2$s</string>
|
||||||
@@ -383,6 +388,8 @@
|
|||||||
<string name="logs_file_size_kb">%1$d КБ</string>
|
<string name="logs_file_size_kb">%1$d КБ</string>
|
||||||
<string name="logs_file_size_mb">%1$s МБ</string>
|
<string name="logs_file_size_mb">%1$s МБ</string>
|
||||||
<string name="logs_scroll_to_bottom_cd">Прокрутить к последним записям</string>
|
<string name="logs_scroll_to_bottom_cd">Прокрутить к последним записям</string>
|
||||||
|
<string name="logs_search">Поиск</string>
|
||||||
|
<string name="logs_search_hint">Поиск по записям журнала</string>
|
||||||
|
|
||||||
<string name="settings_account_title">Аккаунт</string>
|
<string name="settings_account_title">Аккаунт</string>
|
||||||
<string name="settings_account_logout_confirm_title">Выйти?</string>
|
<string name="settings_account_logout_confirm_title">Выйти?</string>
|
||||||
@@ -413,6 +420,7 @@
|
|||||||
<string name="error_connection">Не удалось подключиться. Проверьте интернет.</string>
|
<string name="error_connection">Не удалось подключиться. Проверьте интернет.</string>
|
||||||
<string name="error_unknown">Что-то пошло не так. Попробуйте ещё раз.</string>
|
<string name="error_unknown">Что-то пошло не так. Попробуйте ещё раз.</string>
|
||||||
<string name="typing_single">%1$s печатает…</string>
|
<string name="typing_single">%1$s печатает…</string>
|
||||||
|
<string name="typing_alone">печатает…</string>
|
||||||
<string name="typing_two">%1$s и %2$s печатают…</string>
|
<string name="typing_two">%1$s и %2$s печатают…</string>
|
||||||
<string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string>
|
<string name="typing_many">%1$s, %2$s и ещё %3$d печатают…</string>
|
||||||
<string name="more">Ещё</string>
|
<string name="more">Ещё</string>
|
||||||
|
|||||||
@@ -122,6 +122,9 @@
|
|||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<string name="message_sender_you">You</string>
|
<string name="message_sender_you">You</string>
|
||||||
<string name="user_fallback">Person %1$d</string>
|
<string name="user_fallback">Person %1$d</string>
|
||||||
|
<string name="deleted_account">Deleted account</string>
|
||||||
|
<string name="account_suspended">Account suspended</string>
|
||||||
|
<string name="action_delete_chat">Delete chat</string>
|
||||||
<string name="message_corrupted">This message could not be shown.</string>
|
<string name="message_corrupted">This message could not be shown.</string>
|
||||||
<string name="message_edited_suffix">(edited)</string>
|
<string name="message_edited_suffix">(edited)</string>
|
||||||
<string name="message_replying_to">Reply to %1$s</string>
|
<string name="message_replying_to">Reply to %1$s</string>
|
||||||
@@ -200,11 +203,13 @@
|
|||||||
|
|
||||||
<!-- Status badge -->
|
<!-- Status badge -->
|
||||||
<string name="cd_verified_account">Verified account</string>
|
<string name="cd_verified_account">Verified account</string>
|
||||||
|
<string name="cd_account_blocked">Account blocked</string>
|
||||||
<string name="cd_similar_verified">May be a verified account</string>
|
<string name="cd_similar_verified">May be a verified account</string>
|
||||||
|
|
||||||
<!-- Presence / last seen -->
|
<!-- Presence / last seen -->
|
||||||
<string name="presence_online">Online</string>
|
<string name="presence_online">Online</string>
|
||||||
<string name="presence_recently">Active recently</string>
|
<string name="presence_recently">Active recently</string>
|
||||||
|
<string name="presence_long_ago">last seen a long time ago</string>
|
||||||
<string name="presence_today_at">Today at %1$s</string>
|
<string name="presence_today_at">Today at %1$s</string>
|
||||||
<string name="presence_yesterday_at">Yesterday at %1$s</string>
|
<string name="presence_yesterday_at">Yesterday at %1$s</string>
|
||||||
<string name="presence_weekday_at">%1$s at %2$s</string>
|
<string name="presence_weekday_at">%1$s at %2$s</string>
|
||||||
@@ -409,6 +414,8 @@
|
|||||||
<string name="logs_file_size_kb">%1$d KB</string>
|
<string name="logs_file_size_kb">%1$d KB</string>
|
||||||
<string name="logs_file_size_mb">%1$s MB</string>
|
<string name="logs_file_size_mb">%1$s MB</string>
|
||||||
<string name="logs_scroll_to_bottom_cd">Scroll to latest logs</string>
|
<string name="logs_scroll_to_bottom_cd">Scroll to latest logs</string>
|
||||||
|
<string name="logs_search">Search</string>
|
||||||
|
<string name="logs_search_hint">Search log entries</string>
|
||||||
|
|
||||||
<string name="settings_account_title">Account</string>
|
<string name="settings_account_title">Account</string>
|
||||||
<string name="settings_account_logout_confirm_title">Log out?</string>
|
<string name="settings_account_logout_confirm_title">Log out?</string>
|
||||||
@@ -443,6 +450,7 @@
|
|||||||
|
|
||||||
<!-- Typing Indicators -->
|
<!-- Typing Indicators -->
|
||||||
<string name="typing_single">%1$s is typing…</string>
|
<string name="typing_single">%1$s is typing…</string>
|
||||||
|
<string name="typing_alone">typing…</string>
|
||||||
<string name="typing_two">%1$s and %2$s are typing…</string>
|
<string name="typing_two">%1$s and %2$s are typing…</string>
|
||||||
<string name="typing_many">%1$s, %2$s and %3$d more are typing…</string>
|
<string name="typing_many">%1$s, %2$s and %3$d more are typing…</string>
|
||||||
|
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ object ApiClient {
|
|||||||
if (response.status.value == 401) {
|
if (response.status.value == 401) {
|
||||||
val path = response.call.request.url.encodedPath
|
val path = response.call.request.url.encodedPath
|
||||||
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
|
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
|
||||||
if (!isCredentialCheck) {
|
if (!isCredentialCheck && !logoutInProgress) {
|
||||||
MainScope().launch {
|
MainScope().launch {
|
||||||
runCatching { WebSocketManager.disconnect() }
|
runCatching { WebSocketManager.disconnect() }
|
||||||
runCatching { clearLocalSession() }
|
runCatching { clearLocalSession() }
|
||||||
@@ -408,6 +408,9 @@ object ApiClient {
|
|||||||
@Volatile
|
@Volatile
|
||||||
var user: User? = null
|
var user: User? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var logoutInProgress = false
|
||||||
|
|
||||||
var onAuthError: (() -> Unit)? = null
|
var onAuthError: (() -> Unit)? = null
|
||||||
|
|
||||||
private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? =
|
private fun getSuspensionReasonFromForbiddenResponse(response: HttpResponse): String? =
|
||||||
@@ -1406,13 +1409,20 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun logout() {
|
suspend fun logout() {
|
||||||
|
if (logoutInProgress) return
|
||||||
|
logoutInProgress = true
|
||||||
|
try {
|
||||||
|
runCatching { WebSocketManager.disconnect() }
|
||||||
|
runCatching { unregisterFcmTokenFromServer() }
|
||||||
runCatching {
|
runCatching {
|
||||||
http.get("${ServerConfig.apiBaseUrl}/logout")
|
http.get("${ServerConfig.apiBaseUrl}/logout")
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
ru.fromchat.Logger.e("ApiClient", "Server logout failed", e)
|
ru.fromchat.Logger.e("ApiClient", "Server logout failed", e)
|
||||||
}
|
}
|
||||||
runCatching { unregisterFcmTokenFromServer() }
|
|
||||||
clearLocalSession()
|
clearLocalSession()
|
||||||
|
} finally {
|
||||||
|
logoutInProgress = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
|
fun getTokenSafely() = token ?: throw IllegalStateException("Not authenticated")
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package ru.fromchat.api
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
|
import ru.fromchat.api.local.db.store.MessageCacheStore
|
||||||
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.messages.parseMessageTimestampMillis
|
||||||
|
import ru.fromchat.api.schema.messages.Message
|
||||||
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
|
import ru.fromchat.api.schema.websocket.types.WebSocketUpdatesData
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps the chats tab list in sync: DM conversations from the server and the latest public-chat
|
||||||
|
* message for list previews (without opening each chat first).
|
||||||
|
*/
|
||||||
|
object ChatListSync {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private var started = false
|
||||||
|
|
||||||
|
fun ensureStarted() {
|
||||||
|
if (started) return
|
||||||
|
started = true
|
||||||
|
|
||||||
|
WebSocketManager.addSessionReadyHandler {
|
||||||
|
scope.launch { syncFromNetwork() }
|
||||||
|
}
|
||||||
|
|
||||||
|
WebSocketManager.addGlobalMessageHandler(::handleWebSocketMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetOnLogout() {
|
||||||
|
started = false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun syncFromNetwork() {
|
||||||
|
if (!canSync()) return
|
||||||
|
syncDmConversations()
|
||||||
|
syncPublicChatPreview()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun canSync(): Boolean {
|
||||||
|
if (ApiClient.token.isNullOrEmpty()) return false
|
||||||
|
if (CacheContext.activeInstanceId.value.trim().isEmpty()) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncDmConversations() {
|
||||||
|
val previewStrings = MessageCacheStore.listPreviewStrings ?: return
|
||||||
|
runCatching {
|
||||||
|
val conversations = ApiClient.getDmConversations()
|
||||||
|
conversations.forEach { ProfileCache.mergeFromDmUser(it.user) }
|
||||||
|
MessageRepository.replaceDmConversations(conversations, previewStrings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun syncPublicChatPreview() {
|
||||||
|
runCatching {
|
||||||
|
val response = ApiClient.getMessages(limit = 1)
|
||||||
|
val latest = response.messages.maxByOrNull { message ->
|
||||||
|
parseMessageTimestampMillis(message.timestamp) ?: Long.MIN_VALUE
|
||||||
|
} ?: return@runCatching
|
||||||
|
MessageRepository.upsertPublicMessage(latest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleWebSocketMessage(message: WebSocketMessage) {
|
||||||
|
when (message.type) {
|
||||||
|
"updates" -> {
|
||||||
|
val data = message.data ?: return
|
||||||
|
val updates = runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(WebSocketUpdatesData.serializer(), data)
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
updates.updates.forEach { update ->
|
||||||
|
handleWebSocketMessage(WebSocketMessage(type = update.type, data = update.data))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"newMessage" -> message.data?.let { element ->
|
||||||
|
scope.launch { ingestPublicMessage(element) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun ingestPublicMessage(element: JsonElement) {
|
||||||
|
val newMsg = runCatching {
|
||||||
|
ApiClient.json.decodeFromJsonElement(Message.serializer(), element)
|
||||||
|
}.getOrNull() ?: return
|
||||||
|
ProfileCache.mergePreviewFromPublicMessage(newMsg)
|
||||||
|
MessageRepository.upsertPublicMessage(newMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,3 +13,6 @@ expect suspend fun ensureFcmTokenRegistered(): Boolean
|
|||||||
* Returns true when the unregister request succeeds.
|
* Returns true when the unregister request succeeds.
|
||||||
*/
|
*/
|
||||||
expect suspend fun unregisterFcmTokenFromServer(): Boolean
|
expect suspend fun unregisterFcmTokenFromServer(): Boolean
|
||||||
|
|
||||||
|
/** Whether this device has an FCM token registered with the server for the current session. */
|
||||||
|
expect suspend fun isFcmPushRegisteredLocally(): Boolean
|
||||||
@@ -7,6 +7,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.ChatListSync
|
||||||
import ru.fromchat.api.PublicChatProfileSync
|
import ru.fromchat.api.PublicChatProfileSync
|
||||||
import ru.fromchat.api.local.db.store.InstanceRegistryStore
|
import ru.fromchat.api.local.db.store.InstanceRegistryStore
|
||||||
import ru.fromchat.api.local.db.store.MessageRepository
|
import ru.fromchat.api.local.db.store.MessageRepository
|
||||||
@@ -38,6 +39,7 @@ private suspend fun activateInstance(instanceId: String) {
|
|||||||
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
|
CacheContext.setActiveInstance(instanceId, ApiClient.user?.id)
|
||||||
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) }
|
runCatching { PublicChatProfileCache.hydrateFromDiskImmediate(instanceId) }
|
||||||
PublicChatProfileSync.ensureStarted()
|
PublicChatProfileSync.ensureStarted()
|
||||||
|
ChatListSync.ensureStarted()
|
||||||
scheduleOutboxProcessing(instanceId)
|
scheduleOutboxProcessing(instanceId)
|
||||||
scheduleAttachmentResumeAfterSession()
|
scheduleAttachmentResumeAfterSession()
|
||||||
ApiClient.user?.id?.let { userId ->
|
ApiClient.user?.id?.let { userId ->
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ru.fromchat.api.local.db
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.ChatListSync
|
||||||
import ru.fromchat.api.PublicChatProfileSync
|
import ru.fromchat.api.PublicChatProfileSync
|
||||||
import ru.fromchat.api.local.cache.CacheContext
|
import ru.fromchat.api.local.cache.CacheContext
|
||||||
import ru.fromchat.api.local.cache.DecryptedFileCache
|
import ru.fromchat.api.local.cache.DecryptedFileCache
|
||||||
@@ -48,6 +49,7 @@ suspend fun wipeLocalCacheOnDisk() {
|
|||||||
suspend fun clearAccountCacheOnLogout(instanceId: String) {
|
suspend fun clearAccountCacheOnLogout(instanceId: String) {
|
||||||
val id = instanceId.trim()
|
val id = instanceId.trim()
|
||||||
PublicChatProfileSync.resetOnLogout()
|
PublicChatProfileSync.resetOnLogout()
|
||||||
|
ChatListSync.resetOnLogout()
|
||||||
if (id.isNotEmpty()) {
|
if (id.isNotEmpty()) {
|
||||||
cancelOutboxProcessing(id)
|
cancelOutboxProcessing(id)
|
||||||
runCatching { MessageRepository.purgeAllPendingForInstance() }
|
runCatching { MessageRepository.purgeAllPendingForInstance() }
|
||||||
|
|||||||
+26
-10
@@ -99,11 +99,11 @@ object MessageCacheStore {
|
|||||||
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
|
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
|
||||||
if (instanceId.isBlank()) return emptyList()
|
if (instanceId.isBlank()) return emptyList()
|
||||||
val convId = conversationIdForPublic()
|
val convId = conversationIdForPublic()
|
||||||
val raw = db.messageDatabaseQueries
|
val raw = hydrateReplyReferences(
|
||||||
|
db.messageDatabaseQueries
|
||||||
.selectRecentMessagesByConversation(instanceId, convId, limit)
|
.selectRecentMessagesByConversation(instanceId, convId, limit)
|
||||||
.executeAsList()
|
.executeAsList(),
|
||||||
.map { row: DbMessage -> row.toAppMessage() }
|
).reversed()
|
||||||
.reversed()
|
|
||||||
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
||||||
return ProfileCache.enrichPublicMessagesForDisplay(
|
return ProfileCache.enrichPublicMessagesForDisplay(
|
||||||
sortMessagesForChatDisplay(
|
sortMessagesForChatDisplay(
|
||||||
@@ -345,8 +345,11 @@ object MessageCacheStore {
|
|||||||
withContext(Dispatchers.Default) {
|
withContext(Dispatchers.Default) {
|
||||||
val upserts = conversations.map { conv ->
|
val upserts = conversations.map { conv ->
|
||||||
val conversationId = conversationIdForDm(conv.user.id)
|
val conversationId = conversationIdForDm(conv.user.id)
|
||||||
val displayLabel = conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
val displayLabel = when {
|
||||||
|
conv.user.deleted == true -> ""
|
||||||
|
else -> conv.user.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
?: conv.user.username.trim()
|
?: conv.user.username.trim()
|
||||||
|
}
|
||||||
val localUnread = db.messageDatabaseQueries
|
val localUnread = db.messageDatabaseQueries
|
||||||
.countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong())
|
.countUnreadInboundDmMessages(iid, conversationId, conv.user.id.toLong())
|
||||||
.executeAsOne()
|
.executeAsOne()
|
||||||
@@ -388,6 +391,7 @@ object MessageCacheStore {
|
|||||||
pruneEmptyConversationsLocked(iid)
|
pruneEmptyConversationsLocked(iid)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DmConversationListNotifier.notifyChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
private data class UpsertDmConversationRow(
|
private data class UpsertDmConversationRow(
|
||||||
@@ -853,10 +857,10 @@ object MessageCacheStore {
|
|||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(iid)
|
OutgoingMessageCoordinator.pruneStaleAttachmentOutboxForInstance(iid)
|
||||||
return withContext(Dispatchers.Default) {
|
return withContext(Dispatchers.Default) {
|
||||||
val raw = db.messageDatabaseQueries
|
val rows = db.messageDatabaseQueries
|
||||||
.selectMessagesByConversation(iid, conversationId)
|
.selectMessagesByConversation(iid, conversationId)
|
||||||
.executeAsList()
|
.executeAsList()
|
||||||
.map { row: DbMessage -> row.toAppMessage() }
|
val raw = rows.map { it.toAppMessage() }
|
||||||
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
||||||
purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded)
|
purgeSupersededPendingRows(iid, conversationId, raw, withoutSuperseded)
|
||||||
sortMessagesForChatDisplay(
|
sortMessagesForChatDisplay(
|
||||||
@@ -873,11 +877,10 @@ object MessageCacheStore {
|
|||||||
private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> {
|
private suspend fun loadRecentMessages(conversationId: String, limit: Long): List<Message> {
|
||||||
val iid = instanceId()
|
val iid = instanceId()
|
||||||
return withContext(Dispatchers.Default) {
|
return withContext(Dispatchers.Default) {
|
||||||
db.messageDatabaseQueries
|
val rows = db.messageDatabaseQueries
|
||||||
.selectRecentMessagesByConversation(iid, conversationId, limit)
|
.selectRecentMessagesByConversation(iid, conversationId, limit)
|
||||||
.executeAsList()
|
.executeAsList()
|
||||||
.map { row: DbMessage -> row.toAppMessage() }
|
rows.map { it.toAppMessage() }.reversed()
|
||||||
.reversed()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,6 +986,19 @@ object MessageCacheStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun hydrateReplyReferences(rows: List<DbMessage>): List<Message> {
|
||||||
|
val messages = rows.map { it.toAppMessage() }
|
||||||
|
val byId = messages.associateBy { it.id }
|
||||||
|
return rows.zip(messages).map { (row, message) ->
|
||||||
|
val replyId = row.replyToId?.toInt()
|
||||||
|
if (replyId != null) {
|
||||||
|
message.copy(reply_to = byId[replyId])
|
||||||
|
} else {
|
||||||
|
message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun DbMessage.toAppMessage(): Message {
|
private fun DbMessage.toAppMessage(): Message {
|
||||||
val uid = userId.toInt()
|
val uid = userId.toInt()
|
||||||
val self = ApiClient.user
|
val self = ApiClient.user
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import kotlin.concurrent.Volatile
|
|||||||
* to the UI (for suspended or deleted users), except for the current user.
|
* to the UI (for suspended or deleted users), except for the current user.
|
||||||
*/
|
*/
|
||||||
fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean =
|
fun UserProfile.shouldHideUsername(currentUserId: Int? = null): Boolean =
|
||||||
id != currentUserId && (deleted == true || suspended == true)
|
id != currentUserId && (deleted == true || isDeletedPlaceholderUsername(username))
|
||||||
|
|
||||||
|
private fun isDeletedPlaceholderUsername(username: String?): Boolean =
|
||||||
|
username?.startsWith("#deleted") == true
|
||||||
|
|
||||||
fun UserProfile.visibleUsername(currentUserId: Int? = null): String? =
|
fun UserProfile.visibleUsername(currentUserId: Int? = null): String? =
|
||||||
if (shouldHideUsername(currentUserId)) {
|
if (shouldHideUsername(currentUserId)) {
|
||||||
@@ -71,18 +74,23 @@ object ProfileCache {
|
|||||||
|
|
||||||
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
|
val incomingUsername = username?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
|
?: existing?.username?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
val incomingDisplayName = displayName?.trim()?.takeIf { it.isNotEmpty() }
|
val isDeleted = existing?.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
|
||||||
|
val incomingDisplayName = if (isDeleted) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
?: existing?.displayName?.takeIf { it.isNotBlank() }
|
?: existing?.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: incomingUsername
|
?: incomingUsername
|
||||||
|
}
|
||||||
|
|
||||||
if (incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return
|
if (!isDeleted && incomingUsername.isNullOrEmpty() && incomingDisplayName.isNullOrBlank()) return
|
||||||
|
|
||||||
put(
|
put(
|
||||||
UserProfile(
|
UserProfile(
|
||||||
id = id,
|
id = id,
|
||||||
username = incomingUsername.orEmpty(),
|
username = incomingUsername.orEmpty(),
|
||||||
displayName = incomingDisplayName,
|
displayName = incomingDisplayName,
|
||||||
profilePicture = profilePicture?.takeIf { it.isNotBlank() }
|
profilePicture = if (isDeleted) null else profilePicture?.takeIf { it.isNotBlank() }
|
||||||
?: existing?.profilePicture,
|
?: existing?.profilePicture,
|
||||||
bio = existing?.bio,
|
bio = existing?.bio,
|
||||||
online = existing?.online ?: false,
|
online = existing?.online ?: false,
|
||||||
@@ -92,7 +100,7 @@ object ProfileCache {
|
|||||||
verificationStatus = verificationStatus ?: existing?.verificationStatus,
|
verificationStatus = verificationStatus ?: existing?.verificationStatus,
|
||||||
suspended = existing?.suspended,
|
suspended = existing?.suspended,
|
||||||
suspensionReason = existing?.suspensionReason,
|
suspensionReason = existing?.suspensionReason,
|
||||||
deleted = existing?.deleted,
|
deleted = isDeleted,
|
||||||
isClientPreviewOnly = true,
|
isClientPreviewOnly = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -152,16 +160,20 @@ object ProfileCache {
|
|||||||
val incomingUsername = user.username.trim()
|
val incomingUsername = user.username.trim()
|
||||||
if (incomingUsername.isEmpty()) return
|
if (incomingUsername.isEmpty()) return
|
||||||
|
|
||||||
val incomingDisplayName =
|
val isDeleted = user.deleted == true || isDeletedPlaceholderUsername(incomingUsername)
|
||||||
|
val incomingDisplayName = if (isDeleted) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername
|
user.displayName?.trim()?.takeIf { it.isNotEmpty() } ?: incomingUsername
|
||||||
|
}
|
||||||
|
|
||||||
put(
|
put(
|
||||||
UserProfile(
|
UserProfile(
|
||||||
id = user.id,
|
id = user.id,
|
||||||
username = incomingUsername,
|
username = incomingUsername,
|
||||||
displayName = existing?.displayName?.takeIf { it.isNotBlank() }
|
displayName = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: incomingDisplayName,
|
?: incomingDisplayName,
|
||||||
profilePicture = user.profile_picture?.takeIf { it.isNotBlank() }
|
profilePicture = if (isDeleted) null else user.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
?: existing?.profilePicture,
|
?: existing?.profilePicture,
|
||||||
bio = existing?.bio,
|
bio = existing?.bio,
|
||||||
online = user.online,
|
online = user.online,
|
||||||
@@ -169,9 +181,9 @@ object ProfileCache {
|
|||||||
createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt,
|
createdAt = user.created_at.takeIf { it.isNotBlank() } ?: existing?.createdAt,
|
||||||
verified = user.verified ?: existing?.verified,
|
verified = user.verified ?: existing?.verified,
|
||||||
verificationStatus = user.verificationStatus ?: existing?.verificationStatus,
|
verificationStatus = user.verificationStatus ?: existing?.verificationStatus,
|
||||||
suspended = existing?.suspended,
|
suspended = user.suspended ?: existing?.suspended,
|
||||||
suspensionReason = existing?.suspensionReason,
|
suspensionReason = user.suspensionReason ?: existing?.suspensionReason,
|
||||||
deleted = existing?.deleted,
|
deleted = isDeleted,
|
||||||
isClientPreviewOnly = true,
|
isClientPreviewOnly = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -185,8 +197,10 @@ object ProfileCache {
|
|||||||
|
|
||||||
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
||||||
if (uname.isBlank()) return
|
if (uname.isBlank()) return
|
||||||
val display = existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
|
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
|
||||||
val pic = message.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture
|
val display = if (isDeleted) null else existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
|
||||||
|
val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
|
?: existing?.profilePicture
|
||||||
|
|
||||||
put(
|
put(
|
||||||
UserProfile(
|
UserProfile(
|
||||||
@@ -202,7 +216,7 @@ object ProfileCache {
|
|||||||
verificationStatus = message.verificationStatus ?: existing?.verificationStatus,
|
verificationStatus = message.verificationStatus ?: existing?.verificationStatus,
|
||||||
suspended = existing?.suspended,
|
suspended = existing?.suspended,
|
||||||
suspensionReason = existing?.suspensionReason,
|
suspensionReason = existing?.suspensionReason,
|
||||||
deleted = existing?.deleted,
|
deleted = isDeleted,
|
||||||
isClientPreviewOnly = true,
|
isClientPreviewOnly = true,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,6 +30,23 @@ internal fun parseMessageInstant(timestamp: String): Instant? {
|
|||||||
internal fun parseMessageTimestampMillis(timestamp: String): Long? =
|
internal fun parseMessageTimestampMillis(timestamp: String): Long? =
|
||||||
parseMessageInstant(timestamp)?.toEpochMilliseconds()
|
parseMessageInstant(timestamp)?.toEpochMilliseconds()
|
||||||
|
|
||||||
|
/** HH:mm today; date + time when the message is from another day (bubble footer). */
|
||||||
|
internal fun formatMessageBubbleTimeLocal(timestamp: String): String {
|
||||||
|
val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return ""
|
||||||
|
val now = Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
|
||||||
|
val hour = local.hour.toString().padStart(2, '0')
|
||||||
|
val minute = local.minute.toString().padStart(2, '0')
|
||||||
|
val time = "$hour:$minute"
|
||||||
|
if (local.date == now.date) return time
|
||||||
|
val day = local.day.toString().padStart(2, '0')
|
||||||
|
val month = local.month.number.toString().padStart(2, '0')
|
||||||
|
return if (local.year == now.year) {
|
||||||
|
"$day.$month $time"
|
||||||
|
} else {
|
||||||
|
"$day.$month.${local.year} $time"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** HH:mm in the device time zone (bubble footer). */
|
/** HH:mm in the device time zone (bubble footer). */
|
||||||
internal fun formatMessageTimeLocal(timestamp: String): String {
|
internal fun formatMessageTimeLocal(timestamp: String): String {
|
||||||
val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return ""
|
val local = parseMessageInstant(timestamp)?.toDeviceLocal() ?: return ""
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ data class User(
|
|||||||
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
|
@SerialName("verification_status") val verificationStatus: VerificationStatus? = null,
|
||||||
val suspended: Boolean? = null,
|
val suspended: Boolean? = null,
|
||||||
@SerialName("suspension_reason") val suspensionReason: String? = null,
|
@SerialName("suspension_reason") val suspensionReason: String? = null,
|
||||||
|
val deleted: Boolean? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+3
@@ -13,6 +13,9 @@ enum class VerificationStatus {
|
|||||||
|
|
||||||
@SerialName("none")
|
@SerialName("none")
|
||||||
None,
|
None,
|
||||||
|
|
||||||
|
@SerialName("blocked")
|
||||||
|
Blocked,
|
||||||
}
|
}
|
||||||
|
|
||||||
fun VerificationStatus?.orFromLegacyVerified(verified: Boolean?): VerificationStatus =
|
fun VerificationStatus?.orFromLegacyVerified(verified: Boolean?): VerificationStatus =
|
||||||
|
|||||||
@@ -165,7 +165,6 @@ private fun handleAccountLifecycleEvent(message: WebSocketMessage) {
|
|||||||
MainScope().launch {
|
MainScope().launch {
|
||||||
ApiClient.logout()
|
ApiClient.logout()
|
||||||
}
|
}
|
||||||
WebSocketManager.disconnect()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import ru.fromchat.api.ApiClient
|
|||||||
import ru.fromchat.api.calls.CallStore
|
import ru.fromchat.api.calls.CallStore
|
||||||
import ru.fromchat.api.calls.CallUiState
|
import ru.fromchat.api.calls.CallUiState
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
import ru.fromchat.ui.profile.displayNameForUi
|
||||||
import ru.fromchat.ui.profile.DisplayName
|
import ru.fromchat.ui.profile.DisplayName
|
||||||
import ru.fromchat.ui.profile.effectiveVerificationStatus
|
import ru.fromchat.ui.profile.effectiveVerificationStatus
|
||||||
import ru.fromchat.ui.profile.resolveVerificationStatus
|
import ru.fromchat.ui.profile.resolveVerificationStatus
|
||||||
@@ -94,7 +94,7 @@ fun CallOverlay(modifier: Modifier = Modifier) {
|
|||||||
val me = ApiClient.user?.id
|
val me = ApiClient.user?.id
|
||||||
val cached = ProfileCache.get(s.fromUserId)
|
val cached = ProfileCache.get(s.fromUserId)
|
||||||
val title =
|
val title =
|
||||||
cached?.visibleDisplayName(me)?.takeIf { it.isNotBlank() }
|
cached?.displayNameForUi(me)?.takeIf { it.isNotBlank() }
|
||||||
?: cached?.username?.takeIf { it.isNotBlank() }
|
?: cached?.username?.takeIf { it.isNotBlank() }
|
||||||
?: stringResource(Res.string.user_fallback, s.fromUserId)
|
?: stringResource(Res.string.user_fallback, s.fromUserId)
|
||||||
Box(
|
Box(
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.sizeIn
|
import androidx.compose.foundation.layout.sizeIn
|
||||||
@@ -108,6 +109,7 @@ import ru.fromchat.ui.components.Text
|
|||||||
import com.pr0gramm3r101.utils.scaleOnPress
|
import com.pr0gramm3r101.utils.scaleOnPress
|
||||||
|
|
||||||
private val IMAGE_SIZE = 160.dp
|
private val IMAGE_SIZE = 160.dp
|
||||||
|
private val IMAGE_MAX_HEIGHT = 240.dp
|
||||||
private const val BLUR_FADE_MS = 450
|
private const val BLUR_FADE_MS = 450
|
||||||
|
|
||||||
internal fun isImageFilename(name: String): Boolean =
|
internal fun isImageFilename(name: String): Boolean =
|
||||||
@@ -210,11 +212,12 @@ fun AttachmentPreview(
|
|||||||
fileAspectRatio != null && fileAspectRatio > 0f,
|
fileAspectRatio != null && fileAspectRatio > 0f,
|
||||||
`if` = {
|
`if` = {
|
||||||
Modifier
|
Modifier
|
||||||
.aspectRatio(fileAspectRatio!!)
|
.heightIn(max = IMAGE_MAX_HEIGHT)
|
||||||
.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_SIZE)
|
.widthIn(max = IMAGE_SIZE)
|
||||||
|
.aspectRatio(fileAspectRatio!!, matchHeightConstraintsFirst = true)
|
||||||
},
|
},
|
||||||
`else` = {
|
`else` = {
|
||||||
Modifier.size(IMAGE_SIZE)
|
Modifier.sizeIn(maxWidth = IMAGE_SIZE, maxHeight = IMAGE_MAX_HEIGHT)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.clip(attachmentImageCornerShape(isAuthor))
|
.clip(attachmentImageCornerShape(isAuthor))
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import androidx.compose.foundation.Canvas
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.outlined.PersonOff
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
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
|
||||||
@@ -29,8 +32,35 @@ import ru.fromchat.ui.chat.components.getInitials
|
|||||||
fun Avatar(
|
fun Avatar(
|
||||||
profilePictureUrl: String?,
|
profilePictureUrl: String?,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier,
|
||||||
|
isDeletedUser: Boolean = false,
|
||||||
|
userId: Int? = null,
|
||||||
) {
|
) {
|
||||||
|
if (isDeletedUser) {
|
||||||
|
val gradientSeed = userId?.toString() ?: displayName
|
||||||
|
val gradient = remember(gradientSeed) { generateGradientFromName(gradientSeed) }
|
||||||
|
Box(
|
||||||
|
modifier = modifier.clip(CircleShape),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val radius = size.minDimension / 2f
|
||||||
|
drawCircle(
|
||||||
|
brush = gradient,
|
||||||
|
radius = radius,
|
||||||
|
center = center,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.PersonOff,
|
||||||
|
contentDescription = displayName,
|
||||||
|
modifier = Modifier.fillMaxSize(0.55f),
|
||||||
|
tint = Color.White,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var imageLoadFailed by remember { mutableStateOf(false) }
|
var imageLoadFailed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val gradient = remember(displayName) { generateGradientFromName(displayName) }
|
val gradient = remember(displayName) { generateGradientFromName(displayName) }
|
||||||
@@ -99,4 +129,3 @@ fun Avatar(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -321,25 +321,35 @@ abstract class ChatPanel(
|
|||||||
pending?.first?.cancel()
|
pending?.first?.cancel()
|
||||||
|
|
||||||
updateState { currentState ->
|
updateState { currentState ->
|
||||||
val withoutDupReal = if (confirmedMessage.id > 0) {
|
val optimistic = currentState.messages.find { it.client_message_id == tempId }
|
||||||
currentState.messages.filter { it.id != confirmedMessage.id }
|
val resolvedConfirmed = if (confirmedMessage.reply_to == null) {
|
||||||
|
val reply = optimistic?.reply_to
|
||||||
|
if (reply != null) confirmedMessage.copy(reply_to = reply) else confirmedMessage
|
||||||
|
} else {
|
||||||
|
confirmedMessage
|
||||||
|
}
|
||||||
|
val withoutDupReal = if (resolvedConfirmed.id > 0) {
|
||||||
|
currentState.messages.filter { it.id != resolvedConfirmed.id }
|
||||||
} else {
|
} else {
|
||||||
currentState.messages
|
currentState.messages
|
||||||
}
|
}
|
||||||
val hadTemp = withoutDupReal.any { it.client_message_id == tempId }
|
val hadTemp = withoutDupReal.any { it.client_message_id == tempId }
|
||||||
val mapped = withoutDupReal.map { msg ->
|
val mapped = withoutDupReal.map { msg ->
|
||||||
if (msg.client_message_id == tempId) confirmedMessage else msg
|
if (msg.client_message_id == tempId) resolvedConfirmed else msg
|
||||||
}
|
}
|
||||||
val messages = when {
|
val messages = when {
|
||||||
hadTemp -> mapped
|
hadTemp -> mapped
|
||||||
confirmedMessage.id > 0 && mapped.none { it.id == confirmedMessage.id } ->
|
resolvedConfirmed.id > 0 && mapped.none { it.id == resolvedConfirmed.id } ->
|
||||||
mapped + confirmedMessage
|
mapped + resolvedConfirmed
|
||||||
else -> mapped
|
else -> mapped
|
||||||
}
|
}
|
||||||
currentState.copy(messages = sortMessagesForChatDisplay(messages))
|
currentState.copy(messages = sortMessagesForChatDisplay(messages))
|
||||||
}
|
}
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
runCatching { onOptimisticMessageConfirmed(tempId, confirmedMessage) }
|
val resolved = _state.messages.find { it.client_message_id == tempId }
|
||||||
|
?: _state.messages.find { it.id == confirmedMessage.id }
|
||||||
|
val toPersist = resolved ?: confirmedMessage
|
||||||
|
runCatching { onOptimisticMessageConfirmed(tempId, toPersist) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
@@ -69,6 +72,7 @@ import ru.fromchat.api.calls.CallStore
|
|||||||
import ru.fromchat.api.local.WebSocketManager
|
import ru.fromchat.api.local.WebSocketManager
|
||||||
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.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.UserStatusStore
|
import ru.fromchat.api.local.db.store.UserStatusStore
|
||||||
import ru.fromchat.api.local.download.SavableMessageImage
|
import ru.fromchat.api.local.download.SavableMessageImage
|
||||||
@@ -94,6 +98,8 @@ import ru.fromchat.api.schema.messages.Message
|
|||||||
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.back
|
import ru.fromchat.back
|
||||||
|
import ru.fromchat.action_delete_chat
|
||||||
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
import ru.fromchat.cd_call
|
import ru.fromchat.cd_call
|
||||||
import ru.fromchat.chat_group_label
|
import ru.fromchat.chat_group_label
|
||||||
import ru.fromchat.status_connecting
|
import ru.fromchat.status_connecting
|
||||||
@@ -104,6 +110,7 @@ import ru.fromchat.ui.chat.utils.getImageAspectRatio
|
|||||||
import ru.fromchat.ui.chat.utils.getImageDimensions
|
import ru.fromchat.ui.chat.utils.getImageDimensions
|
||||||
import ru.fromchat.ui.chat.utils.imageAttachmentKey
|
import ru.fromchat.ui.chat.utils.imageAttachmentKey
|
||||||
import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
|
import ru.fromchat.ui.chat.utils.visibleMessageIdsInChatList
|
||||||
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
||||||
import ru.fromchat.utils.NetworkConnectivity
|
import ru.fromchat.utils.NetworkConnectivity
|
||||||
import ru.fromchat.utils.formatLastSeen
|
import ru.fromchat.utils.formatLastSeen
|
||||||
@@ -167,6 +174,24 @@ fun ChatScreen(
|
|||||||
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
|
val online by NetworkConnectivity.isOnline.collectAsState(initial = true)
|
||||||
val suspensionState by ApiClient.suspensionState.collectAsState()
|
val suspensionState by ApiClient.suspensionState.collectAsState()
|
||||||
val isReadOnly = suspensionState.isSuspended
|
val isReadOnly = suspensionState.isSuspended
|
||||||
|
val dmRecipientId = panel.getRecipientId()
|
||||||
|
var peerDeleted by remember(dmRecipientId) { mutableStateOf(false) }
|
||||||
|
val deleteChatLabel = stringResource(Res.string.action_delete_chat)
|
||||||
|
LaunchedEffect(dmRecipientId) {
|
||||||
|
val userId = dmRecipientId ?: return@LaunchedEffect
|
||||||
|
peerDeleted = peerIsDeleted(userId = userId, currentUserId = currentUserId)
|
||||||
|
if (!peerDeleted) {
|
||||||
|
runCatching { ApiClient.getProfileById(userId) }.onSuccess { profile ->
|
||||||
|
ProfileCache.put(profile)
|
||||||
|
peerDeleted = peerIsDeleted(
|
||||||
|
userId = userId,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
deleted = profile.deleted,
|
||||||
|
username = profile.username,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val lastSeenFormat = rememberLastSeenFormatStrings()
|
val lastSeenFormat = rememberLastSeenFormatStrings()
|
||||||
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
|
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
|
||||||
val statusConnecting = stringResource(Res.string.status_connecting)
|
val statusConnecting = stringResource(Res.string.status_connecting)
|
||||||
@@ -507,6 +532,36 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
if (peerDeleted && dmRecipientId != null) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val messages = runCatching {
|
||||||
|
MessageRepository.loadDmMessages(dmRecipientId)
|
||||||
|
}.getOrDefault(emptyList()).filter { it.id > 0 }
|
||||||
|
messages.forEach { msg ->
|
||||||
|
runCatching { ApiClient.deleteDm(msg.id, dmRecipientId) }
|
||||||
|
}
|
||||||
|
runCatching { MessageRepository.deleteDmConversation(dmRecipientId) }
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 12.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.error,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Text(deleteChatLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
ChatInput(
|
ChatInput(
|
||||||
text = inputText,
|
text = inputText,
|
||||||
onTextChange = { inputText = it },
|
onTextChange = { inputText = it },
|
||||||
@@ -634,6 +689,7 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
val statusBarTopDp = with(density) { WindowInsets.statusBars.getTop(this).toDp() }
|
||||||
@@ -775,6 +831,7 @@ fun ChatScreen(
|
|||||||
sharedAvatarKey = sharedAvatarKey,
|
sharedAvatarKey = sharedAvatarKey,
|
||||||
subtitleKey = subtitleKey,
|
subtitleKey = subtitleKey,
|
||||||
currentTypingUsers = currentTypingUsers,
|
currentTypingUsers = currentTypingUsers,
|
||||||
|
typingShowsUsernames = panel.usesPublicGroupSubtitle,
|
||||||
statusConnecting = statusConnecting,
|
statusConnecting = statusConnecting,
|
||||||
statusUpdating = statusUpdating,
|
statusUpdating = statusUpdating,
|
||||||
chatGroupLabel = chatGroupLabel,
|
chatGroupLabel = chatGroupLabel,
|
||||||
|
|||||||
@@ -65,7 +65,10 @@ import ru.fromchat.ui.chat.utils.TypingUser
|
|||||||
import ru.fromchat.ui.components.ConnectingEllipsis
|
import ru.fromchat.ui.components.ConnectingEllipsis
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.ui.profile.StatusBadge
|
import ru.fromchat.ui.profile.StatusBadge
|
||||||
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
import ru.fromchat.ui.profile.resolveVerificationStatus
|
import ru.fromchat.ui.profile.resolveVerificationStatus
|
||||||
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
import com.pr0gramm3r101.utils.scaleOnPress
|
import com.pr0gramm3r101.utils.scaleOnPress
|
||||||
import kotlin.math.PI
|
import kotlin.math.PI
|
||||||
import kotlin.math.tan
|
import kotlin.math.tan
|
||||||
@@ -85,11 +88,19 @@ fun ChatTopBarInner(
|
|||||||
sharedAvatarKey: Any?,
|
sharedAvatarKey: Any?,
|
||||||
subtitleKey: String,
|
subtitleKey: String,
|
||||||
currentTypingUsers: List<TypingUser>,
|
currentTypingUsers: List<TypingUser>,
|
||||||
|
typingShowsUsernames: Boolean = true,
|
||||||
statusConnecting: String,
|
statusConnecting: String,
|
||||||
statusUpdating: String,
|
statusUpdating: String,
|
||||||
chatGroupLabel: String,
|
chatGroupLabel: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val isDeletedPeer = profileUserId?.let { userId ->
|
||||||
|
peerIsDeleted(
|
||||||
|
userId = userId,
|
||||||
|
currentUserId = ApiClient.user?.id,
|
||||||
|
username = titleAvatar?.displayName ?: title,
|
||||||
|
)
|
||||||
|
} == true
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.Center,
|
horizontalArrangement = Arrangement.Center,
|
||||||
@@ -118,6 +129,8 @@ fun ChatTopBarInner(
|
|||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
)
|
)
|
||||||
.size(40.dp),
|
.size(40.dp),
|
||||||
|
isDeletedUser = isDeletedPeer,
|
||||||
|
userId = profileUserId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +143,8 @@ fun ChatTopBarInner(
|
|||||||
profilePictureUrl = avatar.profilePictureUrl,
|
profilePictureUrl = avatar.profilePictureUrl,
|
||||||
displayName = avatar.displayName,
|
displayName = avatar.displayName,
|
||||||
modifier = Modifier.size(40.dp),
|
modifier = Modifier.size(40.dp),
|
||||||
|
isDeletedUser = isDeletedPeer,
|
||||||
|
userId = profileUserId,
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
@@ -244,6 +259,7 @@ fun ChatTopBarInner(
|
|||||||
key == "typing" -> {
|
key == "typing" -> {
|
||||||
TypingIndicator(
|
TypingIndicator(
|
||||||
typingUsers = currentTypingUsers.map { it.username },
|
typingUsers = currentTypingUsers.map { it.username },
|
||||||
|
showUsernames = typingShowsUsernames,
|
||||||
modifier = Modifier.padding(top = 2.dp),
|
modifier = Modifier.padding(top = 2.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,37 +6,41 @@ import ru.fromchat.Res
|
|||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
import ru.fromchat.api.local.db.store.visibleUsername
|
|
||||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||||
|
import ru.fromchat.ui.profile.avatarLabelForInitials
|
||||||
import ru.fromchat.message_sender_you
|
import ru.fromchat.message_sender_you
|
||||||
import ru.fromchat.user_fallback
|
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
|
||||||
|
import ru.fromchat.ui.profile.isDeletedAccount
|
||||||
|
import ru.fromchat.ui.profile.isDeletedAccountUsername
|
||||||
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
|
|
||||||
private val userIdUsernamePattern = Regex("^User (\\d+)$")
|
private val userIdUsernamePattern = Regex("^User (\\d+)$")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves [Message.username] for display: localized «Вы», «Пользователь N», or server-provided name.
|
* Resolves [Message.username] for display: localized «Вы», deleted user label, or server-provided name.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
|
fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
|
||||||
if (currentUserId != null && message.user_id == currentUserId) {
|
if (currentUserId != null && message.user_id == currentUserId) {
|
||||||
return stringResource(Res.string.message_sender_you)
|
return stringResource(Res.string.message_sender_you)
|
||||||
}
|
}
|
||||||
val cachedProfile = ProfileCache.get(message.user_id)
|
ProfileCache.get(message.user_id)?.let { profile ->
|
||||||
val isCachedUserHidden = cachedProfile?.let {
|
if (profile.isDeletedAccount(currentUserId) || isDeletedAccountUsername(profile.username)) {
|
||||||
it.id != currentUserId && (it.deleted == true || it.suspended == true)
|
return deletedUserDisplayNameForUi()
|
||||||
} == true
|
|
||||||
if (isCachedUserHidden) {
|
|
||||||
return stringResource(Res.string.user_fallback, message.user_id)
|
|
||||||
}
|
}
|
||||||
val cachedUsername = cachedProfile?.visibleUsername(currentUserId)
|
}
|
||||||
|
if (isDeletedAccountUsername(message.username)) {
|
||||||
|
return deletedUserDisplayNameForUi()
|
||||||
|
}
|
||||||
|
val cachedUsername = ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId)
|
||||||
if (cachedUsername != null) return cachedUsername
|
if (cachedUsername != null) return cachedUsername
|
||||||
if (message.username.equals("deleted", ignoreCase = true)) {
|
if (message.username.equals("deleted", ignoreCase = true)) {
|
||||||
return stringResource(Res.string.user_fallback, message.user_id)
|
return deletedUserDisplayNameForUi()
|
||||||
}
|
}
|
||||||
val m = userIdUsernamePattern.matchEntire(message.username)
|
val m = userIdUsernamePattern.matchEntire(message.username)
|
||||||
if (m != null) {
|
if (m != null) {
|
||||||
val id = m.groupValues[1].toIntOrNull()
|
val id = m.groupValues[1].toIntOrNull()
|
||||||
if (id != null) return stringResource(Res.string.user_fallback, id)
|
if (id != null) return deletedUserDisplayNameForUi()
|
||||||
}
|
}
|
||||||
return message.username
|
return message.username
|
||||||
}
|
}
|
||||||
@@ -45,6 +49,9 @@ fun messageSenderProfilePicture(
|
|||||||
message: Message,
|
message: Message,
|
||||||
currentUserId: Int? = ApiClient.user?.id,
|
currentUserId: Int? = ApiClient.user?.id,
|
||||||
): String? {
|
): String? {
|
||||||
|
if (ProfileCache.get(message.user_id)?.isDeletedAccount(currentUserId) == true) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
if (currentUserId != null && message.user_id == currentUserId) {
|
if (currentUserId != null && message.user_id == currentUserId) {
|
||||||
return message.profile_picture?.takeIf { it.isNotBlank() }
|
return message.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
?: ApiClient.user?.profile_picture?.takeIf { it.isNotBlank() }
|
?: ApiClient.user?.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
@@ -53,14 +60,24 @@ fun messageSenderProfilePicture(
|
|||||||
?: ProfileCache.get(message.user_id)?.profilePicture?.takeIf { it.isNotBlank() }
|
?: ProfileCache.get(message.user_id)?.profilePicture?.takeIf { it.isNotBlank() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun messageSenderIsDeleted(message: Message, currentUserId: Int? = ApiClient.user?.id): Boolean =
|
||||||
|
peerIsDeleted(
|
||||||
|
userId = message.user_id,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
username = message.username,
|
||||||
|
)
|
||||||
|
|
||||||
fun messageSenderAvatarLabel(
|
fun messageSenderAvatarLabel(
|
||||||
message: Message,
|
message: Message,
|
||||||
currentUserId: Int? = ApiClient.user?.id,
|
currentUserId: Int? = ApiClient.user?.id,
|
||||||
): String {
|
): String {
|
||||||
|
if (messageSenderIsDeleted(message, currentUserId)) return ""
|
||||||
|
ProfileCache.get(message.user_id)
|
||||||
|
?.avatarLabelForInitials(currentUserId)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { return it }
|
||||||
if (currentUserId != null && message.user_id == currentUserId) {
|
if (currentUserId != null && message.user_id == currentUserId) {
|
||||||
return ApiClient.user?.username?.takeIf { it.isNotBlank() }.orEmpty()
|
return ApiClient.user?.displayName?.trim()?.takeIf { it.isNotBlank() }.orEmpty()
|
||||||
}
|
|
||||||
return message.username.trim().ifBlank {
|
|
||||||
ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId).orEmpty()
|
|
||||||
}
|
}
|
||||||
|
return message.username.trim()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
@@ -57,8 +56,8 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.api.local.cache.DecryptedImageCache
|
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||||
import ru.fromchat.api.local.db.store.ProfileCache
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
import ru.fromchat.api.local.db.store.visibleUsername
|
import ru.fromchat.ui.chat.messageSenderAvatarLabel
|
||||||
import ru.fromchat.api.local.messages.formatMessageTimeLocal
|
import ru.fromchat.api.local.messages.formatMessageBubbleTimeLocal
|
||||||
import ru.fromchat.api.local.messages.isQueuedOutbound
|
import ru.fromchat.api.local.messages.isQueuedOutbound
|
||||||
import ru.fromchat.api.schema.messages.Message
|
import ru.fromchat.api.schema.messages.Message
|
||||||
import ru.fromchat.message_corrupted
|
import ru.fromchat.message_corrupted
|
||||||
@@ -69,6 +68,7 @@ import ru.fromchat.ui.chat.components.getReplyMessageGradient
|
|||||||
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
|
import ru.fromchat.ui.chat.utils.imageAspectRatioForMessage
|
||||||
import ru.fromchat.ui.chat.utils.imageAttachmentKey
|
import ru.fromchat.ui.chat.utils.imageAttachmentKey
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.ui.isAppInDarkTheme
|
||||||
import ru.fromchat.ui.profile.StatusBadge
|
import ru.fromchat.ui.profile.StatusBadge
|
||||||
import ru.fromchat.ui.profile.resolveVerificationStatus
|
import ru.fromchat.ui.profile.resolveVerificationStatus
|
||||||
|
|
||||||
@@ -119,7 +119,7 @@ fun MessageItem(
|
|||||||
isMessageCorrupted(message)
|
isMessageCorrupted(message)
|
||||||
}
|
}
|
||||||
val formattedTime = remember(message.timestamp) {
|
val formattedTime = remember(message.timestamp) {
|
||||||
formatMessageTimeLocal(message.timestamp)
|
formatMessageBubbleTimeLocal(message.timestamp)
|
||||||
}
|
}
|
||||||
val corruptedBody = stringResource(Res.string.message_corrupted)
|
val corruptedBody = stringResource(Res.string.message_corrupted)
|
||||||
val editedSuffix = stringResource(Res.string.message_edited_suffix)
|
val editedSuffix = stringResource(Res.string.message_edited_suffix)
|
||||||
@@ -128,9 +128,9 @@ fun MessageItem(
|
|||||||
val senderProfile = ProfileCache.get(message.user_id)
|
val senderProfile = ProfileCache.get(message.user_id)
|
||||||
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
|
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
|
||||||
?: message.profile_picture
|
?: message.profile_picture
|
||||||
val avatarDisplayName = senderProfile?.visibleUsername(currentUserId)?.takeIf { it.isNotBlank() }
|
val avatarDisplayName = messageSenderAvatarLabel(message, currentUserId)
|
||||||
?: message.username
|
|
||||||
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
|
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
|
||||||
|
val isDeletedSender = messageSenderIsDeleted(message, currentUserId)
|
||||||
val replyRef = message.reply_to
|
val replyRef = message.reply_to
|
||||||
|
|
||||||
// No AnimatedVisibility here: visible=true still ran enter transitions for every item on first
|
// No AnimatedVisibility here: visible=true still ran enter transitions for every item on first
|
||||||
@@ -193,7 +193,9 @@ fun MessageItem(
|
|||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = avatarPictureUrl,
|
profilePictureUrl = avatarPictureUrl,
|
||||||
displayName = avatarDisplayName,
|
displayName = avatarDisplayName,
|
||||||
modifier = Modifier.size(32.dp)
|
modifier = Modifier.size(32.dp),
|
||||||
|
isDeletedUser = isDeletedSender,
|
||||||
|
userId = message.user_id,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,7 +212,7 @@ fun MessageItem(
|
|||||||
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
horizontalAlignment = if (isAuthor) Alignment.End else Alignment.Start
|
||||||
) {
|
) {
|
||||||
// Message bubble
|
// Message bubble
|
||||||
val isDark = isSystemInDarkTheme()
|
val isDark = isAppInDarkTheme()
|
||||||
val pendingIsImage = when {
|
val pendingIsImage = when {
|
||||||
message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename)
|
message.pendingFilename?.isNotBlank() == true -> isImageFilename(message.pendingFilename)
|
||||||
message.pendingFileUri != null -> isImageFilename(
|
message.pendingFileUri != null -> isImageFilename(
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.typing_alone
|
||||||
import ru.fromchat.typing_many
|
import ru.fromchat.typing_many
|
||||||
import ru.fromchat.typing_single
|
import ru.fromchat.typing_single
|
||||||
import ru.fromchat.typing_two
|
import ru.fromchat.typing_two
|
||||||
@@ -29,6 +30,7 @@ import ru.fromchat.typing_two
|
|||||||
@Composable
|
@Composable
|
||||||
fun TypingIndicator(
|
fun TypingIndicator(
|
||||||
typingUsers: List<String>,
|
typingUsers: List<String>,
|
||||||
|
showUsernames: Boolean = true,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
if (typingUsers.isEmpty()) return
|
if (typingUsers.isEmpty()) return
|
||||||
@@ -40,7 +42,7 @@ fun TypingIndicator(
|
|||||||
TypingDots()
|
TypingDots()
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text(
|
Text(
|
||||||
text = formatTypingText(typingUsers),
|
text = formatTypingText(typingUsers, showUsernames),
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary
|
color = MaterialTheme.colorScheme.primary
|
||||||
)
|
)
|
||||||
@@ -48,11 +50,13 @@ fun TypingIndicator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun formatTypingText(typingUsers: List<String>): String {
|
private fun formatTypingText(typingUsers: List<String>, showUsernames: Boolean): String {
|
||||||
return when (typingUsers.size) {
|
return when {
|
||||||
0 -> ""
|
typingUsers.isEmpty() -> ""
|
||||||
1 -> stringResource(Res.string.typing_single, typingUsers[0])
|
!showUsernames && typingUsers.size == 1 ->
|
||||||
2 -> stringResource(Res.string.typing_two, typingUsers[0], typingUsers[1])
|
stringResource(Res.string.typing_alone)
|
||||||
|
typingUsers.size == 1 -> stringResource(Res.string.typing_single, typingUsers[0])
|
||||||
|
typingUsers.size == 2 -> stringResource(Res.string.typing_two, typingUsers[0], typingUsers[1])
|
||||||
else -> stringResource(
|
else -> stringResource(
|
||||||
Res.string.typing_many,
|
Res.string.typing_many,
|
||||||
typingUsers[0],
|
typingUsers[0],
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ import ru.fromchat.api.schema.messages.Message
|
|||||||
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
import ru.fromchat.api.schema.messages.dm.DmEnvelope
|
||||||
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
import ru.fromchat.api.schema.websocket.WebSocketMessage
|
||||||
import ru.fromchat.api.schema.websocket.types.DmDeletedData
|
import ru.fromchat.api.schema.websocket.types.DmDeletedData
|
||||||
import ru.fromchat.api.local.db.store.visibleDisplayName
|
import ru.fromchat.ui.profile.displayNameText
|
||||||
|
import ru.fromchat.ui.profile.isDeletedAccount
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.config.ServerConfig
|
import ru.fromchat.config.ServerConfig
|
||||||
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
|
import ru.fromchat.api.crypto.CorruptedDmMessagePlaceholder
|
||||||
@@ -106,26 +107,34 @@ class DmPanel(
|
|||||||
if (_state.title.isBlank()) {
|
if (_state.title.isBlank()) {
|
||||||
loadPeerTitleFromConversationCache()
|
loadPeerTitleFromConversationCache()
|
||||||
}
|
}
|
||||||
runCatching {
|
try {
|
||||||
ApiClient.getProfileById(otherUserId)
|
val profile = ApiClient.getProfileById(otherUserId)
|
||||||
}.onSuccess { profile ->
|
if (
|
||||||
if (profile.username.isBlank() && profile.displayName.isNullOrBlank()) {
|
!profile.isDeletedAccount(ApiClient.user?.id) &&
|
||||||
|
profile.username.isBlank() &&
|
||||||
|
profile.displayName.isNullOrBlank()
|
||||||
|
) {
|
||||||
ProfileCache.evictUnusableClientPreview(otherUserId)
|
ProfileCache.evictUnusableClientPreview(otherUserId)
|
||||||
if (_state.title.isBlank()) {
|
if (_state.title.isBlank()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
|
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return@onSuccess
|
}
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
ProfileCache.put(profile)
|
ProfileCache.put(profile)
|
||||||
val displayName = profile.visibleDisplayName(ApiClient.user?.id).orEmpty()
|
val displayName = profile.displayNameText(ApiClient.user?.id)
|
||||||
if (displayName.isNotBlank()) {
|
if (displayName.isNotBlank()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
applyPeerTitle(displayName, profile.profilePicture)
|
applyPeerTitle(displayName, profile.profilePicture)
|
||||||
}
|
}
|
||||||
}.onFailure {
|
}
|
||||||
|
} catch (_: Throwable) {
|
||||||
ProfileCache.evictUnusableClientPreview(otherUserId)
|
ProfileCache.evictUnusableClientPreview(otherUserId)
|
||||||
if (_state.title.isBlank()) {
|
if (_state.title.isBlank()) {
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
updateState {
|
updateState {
|
||||||
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
|
it.copy(title = "", titleAvatar = null, profileUserId = otherUserId)
|
||||||
}
|
}
|
||||||
@@ -133,10 +142,13 @@ class DmPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyCachedPeerProfileOrReset() {
|
private fun applyCachedPeerProfileOrReset() {
|
||||||
|
scope.launch(Dispatchers.Default) {
|
||||||
val cached = ProfileCache.get(otherUserId)
|
val cached = ProfileCache.get(otherUserId)
|
||||||
val displayName = cached?.visibleDisplayName(ApiClient.user?.id).orEmpty()
|
val displayName = cached?.displayNameText(ApiClient.user?.id).orEmpty()
|
||||||
|
withContext(Dispatchers.Main) {
|
||||||
if (displayName.isNotBlank()) {
|
if (displayName.isNotBlank()) {
|
||||||
applyPeerTitle(displayName, cached?.profilePicture)
|
applyPeerTitle(displayName, cached?.profilePicture)
|
||||||
} else {
|
} else {
|
||||||
@@ -145,6 +157,8 @@ class DmPanel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyPeerTitle(displayName: String, profilePicture: String?) {
|
private fun applyPeerTitle(displayName: String, profilePicture: String?) {
|
||||||
otherDisplayName = displayName
|
otherDisplayName = displayName
|
||||||
@@ -423,6 +437,9 @@ class DmPanel(
|
|||||||
pendingFileAspectRatio = aspect,
|
pendingFileAspectRatio = aspect,
|
||||||
fileAspectRatios = confirmed.fileAspectRatios ?: aspect?.let { listOf(it) },
|
fileAspectRatios = confirmed.fileAspectRatios ?: aspect?.let { listOf(it) },
|
||||||
fileDimensions = confirmed.fileDimensions ?: stateSourceBeforeMerge?.fileDimensions,
|
fileDimensions = confirmed.fileDimensions ?: stateSourceBeforeMerge?.fileDimensions,
|
||||||
|
reply_to = envelope.replyToId?.let { replyId ->
|
||||||
|
_state.messages.find { it.id == replyId }
|
||||||
|
} ?: stateSourceBeforeMerge?.reply_to,
|
||||||
)
|
)
|
||||||
val mergedForPersistence = merged.copy(pendingFilename = null)
|
val mergedForPersistence = merged.copy(pendingFilename = null)
|
||||||
AttachmentMediaLog.persist(
|
AttachmentMediaLog.persist(
|
||||||
@@ -461,10 +478,6 @@ class DmPanel(
|
|||||||
val deduped = dedupeMessagesByClientId(newMessages)
|
val deduped = dedupeMessagesByClientId(newMessages)
|
||||||
currentState.copy(messages = deduped)
|
currentState.copy(messages = deduped)
|
||||||
}
|
}
|
||||||
if (envelope.replyToId != null) {
|
|
||||||
val replyTo = _state.messages.find { it.id == envelope.replyToId }
|
|
||||||
updateMessage(envelope.id) { it.copy(reply_to = replyTo) }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cid.isNotEmpty()) {
|
if (cid.isNotEmpty()) {
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ import ru.fromchat.ui.chat.TypingIndicator
|
|||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.unread_count_badge
|
import ru.fromchat.unread_count_badge
|
||||||
import ru.fromchat.unread_count_overflow
|
import ru.fromchat.unread_count_overflow
|
||||||
|
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
|
||||||
|
import ru.fromchat.ui.profile.displayNameForUi
|
||||||
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
import ru.fromchat.user_fallback
|
import ru.fromchat.user_fallback
|
||||||
|
|
||||||
internal object ChatListLayout {
|
internal object ChatListLayout {
|
||||||
@@ -394,12 +397,33 @@ internal fun SearchConversationsList(
|
|||||||
resultIndex++
|
resultIndex++
|
||||||
item {
|
item {
|
||||||
val cached = ProfileCache.get(user.id)
|
val cached = ProfileCache.get(user.id)
|
||||||
val avatarUrl = cached?.profilePicture ?: user.profile_picture
|
val isPeerDeleted = peerIsDeleted(
|
||||||
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
|
userId = user.id,
|
||||||
|
currentUserId = ApiClient.user?.id,
|
||||||
|
deleted = user.deleted ?: cached?.deleted,
|
||||||
|
username = user.username,
|
||||||
|
)
|
||||||
|
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture ?: user.profile_picture
|
||||||
|
val peerTitle = if (isPeerDeleted) {
|
||||||
|
deletedUserDisplayNameForUi()
|
||||||
|
} else {
|
||||||
|
cached?.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: user.displayName?.takeIf { it.isNotBlank() }
|
?: user.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: cached?.visibleUsername(ApiClient.user?.id)
|
?: cached?.visibleUsername(ApiClient.user?.id)
|
||||||
?: user.username
|
?: user.username
|
||||||
val username = cached?.visibleUsername(ApiClient.user?.id) ?: user.username
|
}
|
||||||
|
val avatarInitialsLabel = if (isPeerDeleted) {
|
||||||
|
deletedUserDisplayNameForUi()
|
||||||
|
} else {
|
||||||
|
cached?.displayName?.takeIf { it.isNotBlank() }
|
||||||
|
?: user.displayName?.takeIf { it.isNotBlank() }
|
||||||
|
?: ""
|
||||||
|
}
|
||||||
|
val username = if (isPeerDeleted) {
|
||||||
|
deletedUserDisplayNameForUi()
|
||||||
|
} else {
|
||||||
|
cached?.visibleUsername(ApiClient.user?.id) ?: user.username
|
||||||
|
}
|
||||||
|
|
||||||
ChatRowScaleContainer(
|
ChatRowScaleContainer(
|
||||||
listItemPosition = position,
|
listItemPosition = position,
|
||||||
@@ -418,11 +442,13 @@ internal fun SearchConversationsList(
|
|||||||
leadingContent = {
|
leadingContent = {
|
||||||
ChatRowAvatar(
|
ChatRowAvatar(
|
||||||
profilePictureUrl = avatarUrl,
|
profilePictureUrl = avatarUrl,
|
||||||
displayNameForInitials = peerTitle,
|
displayNameForInitials = avatarInitialsLabel,
|
||||||
enabled = false,
|
enabled = false,
|
||||||
onPressStart = {},
|
onPressStart = {},
|
||||||
onPressEnd = {},
|
onPressEnd = {},
|
||||||
onLongPress = {},
|
onLongPress = {},
|
||||||
|
isDeletedUser = isPeerDeleted,
|
||||||
|
userId = user.id,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -503,6 +529,8 @@ internal fun ChatRowAvatar(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
showOnlineIndicator: Boolean = false,
|
showOnlineIndicator: Boolean = false,
|
||||||
onlineIndicatorBorderColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
|
onlineIndicatorBorderColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
|
||||||
|
isDeletedUser: Boolean = false,
|
||||||
|
userId: Int? = null,
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier
|
modifier
|
||||||
@@ -526,6 +554,8 @@ internal fun ChatRowAvatar(
|
|||||||
profilePictureUrl = profilePictureUrl,
|
profilePictureUrl = profilePictureUrl,
|
||||||
displayName = displayNameForInitials,
|
displayName = displayNameForInitials,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
isDeletedUser = isDeletedUser,
|
||||||
|
userId = userId,
|
||||||
)
|
)
|
||||||
if (showOnlineIndicator) {
|
if (showOnlineIndicator) {
|
||||||
Box(
|
Box(
|
||||||
@@ -830,12 +860,26 @@ internal fun DmConversationRowContent(
|
|||||||
onBodyLongPress: () -> Unit,
|
onBodyLongPress: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
|
val currentUserId = ApiClient.user?.id
|
||||||
val cached = ProfileCache.get(conversation.otherUserId)
|
val cached = ProfileCache.get(conversation.otherUserId)
|
||||||
val avatarUrl = cached?.profilePicture
|
val isPeerDeleted = peerIsDeleted(
|
||||||
val peerTitle = cached?.displayName?.takeIf { it.isNotBlank() }
|
userId = conversation.otherUserId,
|
||||||
?: cached?.visibleUsername(ApiClient.user?.id)
|
currentUserId = currentUserId,
|
||||||
?: conversation.displayName.ifBlank {
|
deleted = cached?.deleted,
|
||||||
stringResource(Res.string.user_fallback, conversation.otherUserId)
|
username = cached?.username ?: conversation.displayName.takeIf { it.isNotBlank() },
|
||||||
|
)
|
||||||
|
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
|
||||||
|
val peerTitle = when {
|
||||||
|
isPeerDeleted -> deletedUserDisplayNameForUi()
|
||||||
|
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
|
||||||
|
conversation.displayName.isNotBlank() -> conversation.displayName
|
||||||
|
else -> stringResource(Res.string.user_fallback, conversation.otherUserId)
|
||||||
|
}
|
||||||
|
val avatarInitialsLabel = when {
|
||||||
|
isPeerDeleted -> deletedUserDisplayNameForUi()
|
||||||
|
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
|
||||||
|
conversation.displayName.isNotBlank() -> conversation.displayName
|
||||||
|
else -> ""
|
||||||
}
|
}
|
||||||
val preview = conversation.lastMessagePreview?.trim().orEmpty().ifEmpty { defaultLastMessage }
|
val preview = conversation.lastMessagePreview?.trim().orEmpty().ifEmpty { defaultLastMessage }
|
||||||
val status = statusMap[conversation.otherUserId]
|
val status = statusMap[conversation.otherUserId]
|
||||||
@@ -869,13 +913,15 @@ internal fun DmConversationRowContent(
|
|||||||
)
|
)
|
||||||
ChatRowAvatar(
|
ChatRowAvatar(
|
||||||
profilePictureUrl = avatarUrl,
|
profilePictureUrl = avatarUrl,
|
||||||
displayNameForInitials = peerTitle,
|
displayNameForInitials = avatarInitialsLabel,
|
||||||
enabled = avatarEnabled,
|
enabled = avatarEnabled,
|
||||||
onPressStart = onAvatarPressStart,
|
onPressStart = onAvatarPressStart,
|
||||||
onPressEnd = onAvatarPressEnd,
|
onPressEnd = onAvatarPressEnd,
|
||||||
onLongPress = onAvatarLongPress,
|
onLongPress = onAvatarLongPress,
|
||||||
showOnlineIndicator = isOnline,
|
showOnlineIndicator = isOnline,
|
||||||
onlineIndicatorBorderColor = listSurfaceColor,
|
onlineIndicatorBorderColor = listSurfaceColor,
|
||||||
|
isDeletedUser = isPeerDeleted,
|
||||||
|
userId = conversation.otherUserId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
|
import androidx.compose.material.icons.rounded.Block
|
||||||
|
import com.pr0gramm3r101.components.ListItem
|
||||||
|
import com.pr0gramm3r101.components.ListItemPosition
|
||||||
import androidx.compose.material.icons.filled.Search
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.rounded.Delete
|
import androidx.compose.material.icons.rounded.Delete
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -112,8 +115,7 @@ import ru.fromchat.public_chat
|
|||||||
import ru.fromchat.search_title
|
import ru.fromchat.search_title
|
||||||
import ru.fromchat.status_connecting
|
import ru.fromchat.status_connecting
|
||||||
import ru.fromchat.status_updating
|
import ru.fromchat.status_updating
|
||||||
import ru.fromchat.suspend_chat_banner_message
|
import ru.fromchat.account_suspended
|
||||||
import ru.fromchat.suspended_default_reason
|
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.chat.panels.dm.DmNav
|
import ru.fromchat.ui.chat.panels.dm.DmNav
|
||||||
import ru.fromchat.ui.components.BackHandler
|
import ru.fromchat.ui.components.BackHandler
|
||||||
@@ -122,8 +124,7 @@ import ru.fromchat.ui.components.ConnectingEllipsis
|
|||||||
import ru.fromchat.ui.components.PredictiveBackHandler
|
import ru.fromchat.ui.components.PredictiveBackHandler
|
||||||
import ru.fromchat.ui.components.SearchBar
|
import ru.fromchat.ui.components.SearchBar
|
||||||
import ru.fromchat.ui.components.SearchBarSharedElement
|
import ru.fromchat.ui.components.SearchBarSharedElement
|
||||||
import ru.fromchat.ui.components.SuspendedAccountBannerStyle
|
import ru.fromchat.ui.components.SuspendedAccountSupportSheet
|
||||||
import ru.fromchat.ui.components.SuspendedAccountNoticeHost
|
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.utils.NetworkConnectivity
|
import ru.fromchat.utils.NetworkConnectivity
|
||||||
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
import ru.fromchat.utils.haptic.HapticFeedbackEvent
|
||||||
@@ -250,7 +251,7 @@ private fun ChatsSelectionTopBar(
|
|||||||
Box {
|
Box {
|
||||||
IconButton(onClick = { overflowOpen = true }) {
|
IconButton(onClick = { overflowOpen = true }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.MoreVert,
|
imageVector = Icons.Filled.MoreVert,
|
||||||
contentDescription = moreActionsCd,
|
contentDescription = moreActionsCd,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -330,6 +331,7 @@ 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()
|
||||||
|
var showSuspendedSupportSheet by remember { mutableStateOf(false) }
|
||||||
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
|
val defaultLastMessage = stringResource(Res.string.chat_last_mesaage)
|
||||||
|
|
||||||
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) {
|
LaunchedEffect(previewStrings.imageOnly, previewStrings.attachmentOnly) {
|
||||||
@@ -519,7 +521,7 @@ fun ChatsTab(
|
|||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) {
|
LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) {
|
||||||
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
if (activeInstanceId.isBlank() || connectionStatus != ConnectionStatus.CONNECTED) return@LaunchedEffect
|
||||||
|
|
||||||
runCatching {
|
runCatching {
|
||||||
ApiClient.getDmConversations()
|
ApiClient.getDmConversations()
|
||||||
@@ -546,8 +548,7 @@ fun ChatsTab(
|
|||||||
val updatingTitle = stringResource(Res.string.status_updating)
|
val updatingTitle = stringResource(Res.string.status_updating)
|
||||||
val selectedCount = selectedOtherUserIds.size + if (publicChatSelected) 1 else 0
|
val selectedCount = selectedOtherUserIds.size + if (publicChatSelected) 1 else 0
|
||||||
val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount)
|
val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount)
|
||||||
val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message)
|
val accountSuspendedTitle = stringResource(Res.string.account_suspended)
|
||||||
val suspendDefaultReason = stringResource(Res.string.suspended_default_reason)
|
|
||||||
val publicChatFallbackTitle = stringResource(Res.string.public_chat)
|
val publicChatFallbackTitle = stringResource(Res.string.public_chat)
|
||||||
val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() }
|
val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() }
|
||||||
?: publicChatFallbackTitle.takeIf { activeInstanceId.isNotBlank() }
|
?: publicChatFallbackTitle.takeIf { activeInstanceId.isNotBlank() }
|
||||||
@@ -696,15 +697,25 @@ fun ChatsTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
SuspendedAccountNoticeHost(
|
if (suspensionState.isSuspended) {
|
||||||
isSuspended = suspensionState.isSuspended,
|
ListItem(
|
||||||
reason = suspensionState.reason,
|
headline = accountSuspendedTitle,
|
||||||
fallbackReason = suspendDefaultReason,
|
position = ListItemPosition.START,
|
||||||
bannerTitle = suspendBannerTitle,
|
groupItemCount = 1,
|
||||||
style = SuspendedAccountBannerStyle.Tabs,
|
divider = false,
|
||||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
|
leadingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Rounded.Block,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
onClick = { showSuspendedSupportSheet = true },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
ChatConversationsList(
|
ChatConversationsList(
|
||||||
listState = tabListState,
|
listState = tabListState,
|
||||||
listFilter = ChatListFilter.Active,
|
listFilter = ChatListFilter.Active,
|
||||||
@@ -799,6 +810,7 @@ fun ChatsTab(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
chatContextMenuOverlay.onStateChange = { contextMenuState = it }
|
chatContextMenuOverlay.onStateChange = { contextMenuState = it }
|
||||||
chatContextMenuOverlay.onDismiss = {
|
chatContextMenuOverlay.onDismiss = {
|
||||||
@@ -903,6 +915,11 @@ fun ChatsTab(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SuspendedAccountSupportSheet(
|
||||||
|
isVisible = showSuspendedSupportSheet,
|
||||||
|
onDismissRequest = { showSuspendedSupportSheet = false },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -37,6 +38,9 @@ import androidx.compose.foundation.lazy.LazyListState
|
|||||||
import androidx.compose.foundation.lazy.itemsIndexed
|
import androidx.compose.foundation.lazy.itemsIndexed
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
import androidx.compose.foundation.text.selection.DisableSelection
|
import androidx.compose.foundation.text.selection.DisableSelection
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -54,6 +58,7 @@ import androidx.compose.material.icons.filled.Info
|
|||||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||||
import androidx.compose.material.icons.filled.MoreVert
|
import androidx.compose.material.icons.filled.MoreVert
|
||||||
import androidx.compose.material.icons.filled.Report
|
import androidx.compose.material.icons.filled.Report
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material.icons.automirrored.filled.Subject
|
import androidx.compose.material.icons.automirrored.filled.Subject
|
||||||
import androidx.compose.material.icons.filled.Sync
|
import androidx.compose.material.icons.filled.Sync
|
||||||
@@ -98,7 +103,10 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||||
@@ -108,12 +116,17 @@ import androidx.compose.ui.input.pointer.pointerInput
|
|||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
|
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.input.ImeAction
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import com.pr0gramm3r101.components.Category
|
import com.pr0gramm3r101.components.Category
|
||||||
|
import com.pr0gramm3r101.utils.ToggleNavScrimEffect
|
||||||
import com.pr0gramm3r101.components.ListItem
|
import com.pr0gramm3r101.components.ListItem
|
||||||
|
import com.pr0gramm3r101.utils.resetFocus
|
||||||
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
|
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
@@ -158,6 +171,8 @@ import ru.fromchat.logs_rotate
|
|||||||
import ru.fromchat.logs_rotate_confirm_body
|
import ru.fromchat.logs_rotate_confirm_body
|
||||||
import ru.fromchat.logs_rotate_confirm_title
|
import ru.fromchat.logs_rotate_confirm_title
|
||||||
import ru.fromchat.logs_scroll_to_bottom_cd
|
import ru.fromchat.logs_scroll_to_bottom_cd
|
||||||
|
import ru.fromchat.logs_search
|
||||||
|
import ru.fromchat.logs_search_hint
|
||||||
import ru.fromchat.logs_selected_count
|
import ru.fromchat.logs_selected_count
|
||||||
import ru.fromchat.logs_share
|
import ru.fromchat.logs_share
|
||||||
import ru.fromchat.logs_share_compressed
|
import ru.fromchat.logs_share_compressed
|
||||||
@@ -167,6 +182,7 @@ import ru.fromchat.logs_share_uncompressed
|
|||||||
import ru.fromchat.logs_share_uncompressed_desc
|
import ru.fromchat.logs_share_uncompressed_desc
|
||||||
import ru.fromchat.logs_title
|
import ru.fromchat.logs_title
|
||||||
import ru.fromchat.more
|
import ru.fromchat.more
|
||||||
|
import ru.fromchat.search_not_found
|
||||||
import ru.fromchat.logging.AppLogEntry
|
import ru.fromchat.logging.AppLogEntry
|
||||||
import ru.fromchat.logging.AppLogLevel
|
import ru.fromchat.logging.AppLogLevel
|
||||||
import ru.fromchat.logging.AppLogStore
|
import ru.fromchat.logging.AppLogStore
|
||||||
@@ -218,6 +234,8 @@ fun LogsScreen() {
|
|||||||
val clipboard = supportClipboardManagerImpl
|
val clipboard = supportClipboardManagerImpl
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
val keyboardController = LocalSoftwareKeyboardController.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
val topAppBarState = rememberTopAppBarState()
|
val topAppBarState = rememberTopAppBarState()
|
||||||
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState)
|
val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState)
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
@@ -246,7 +264,21 @@ fun LogsScreen() {
|
|||||||
|
|
||||||
var listMode by remember { mutableStateOf(LogsListMode.Normal) }
|
var listMode by remember { mutableStateOf(LogsListMode.Normal) }
|
||||||
var selectedEntryIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
|
var selectedEntryIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
|
||||||
|
var searchMode by remember { mutableStateOf(false) }
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
val listEntries = remember(displayEntries, searchMode, searchQuery) {
|
||||||
|
if (!searchMode || searchQuery.isBlank()) {
|
||||||
|
displayEntries
|
||||||
|
} else {
|
||||||
|
val needle = searchQuery.lowercase()
|
||||||
|
displayEntries.filter { entry ->
|
||||||
|
entry.displayText().lowercase().contains(needle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
val selectionTransitionProgress = remember { Animatable(0f) }
|
val selectionTransitionProgress = remember { Animatable(0f) }
|
||||||
|
val searchTransitionProgress = remember { Animatable(0f) }
|
||||||
|
val searchFocusRequester = remember { FocusRequester() }
|
||||||
val gestureState = rememberLogsListGestureState()
|
val gestureState = rememberLogsListGestureState()
|
||||||
var dragAnchorIndex by remember { mutableIntStateOf(-1) }
|
var dragAnchorIndex by remember { mutableIntStateOf(-1) }
|
||||||
var dragLastY by remember { mutableFloatStateOf(0f) }
|
var dragLastY by remember { mutableFloatStateOf(0f) }
|
||||||
@@ -273,13 +305,22 @@ fun LogsScreen() {
|
|||||||
val shareTitle = stringResource(Res.string.logs_title)
|
val shareTitle = stringResource(Res.string.logs_title)
|
||||||
val selectionMode = listMode == LogsListMode.Selecting
|
val selectionMode = listMode == LogsListMode.Selecting
|
||||||
val selectionProgress = selectionTransitionProgress.value
|
val selectionProgress = selectionTransitionProgress.value
|
||||||
val showBrowseFab = AppLogStore.hasFilesBesidesCurrent() && !selectionMode
|
val searchProgress = searchTransitionProgress.value
|
||||||
|
val showBrowseFab = AppLogStore.hasFilesBesidesCurrent() && !selectionMode && !searchMode
|
||||||
val showScrollToBottomFab = isViewingCurrent &&
|
val showScrollToBottomFab = isViewingCurrent &&
|
||||||
!selectionMode &&
|
!selectionMode &&
|
||||||
|
!searchMode &&
|
||||||
displayEntries.isNotEmpty() &&
|
displayEntries.isNotEmpty() &&
|
||||||
!isAtBottom &&
|
!isAtBottom &&
|
||||||
!isProgrammaticScroll
|
!isProgrammaticScroll
|
||||||
val scrollToBottomCd = stringResource(Res.string.logs_scroll_to_bottom_cd)
|
val scrollToBottomCd = stringResource(Res.string.logs_scroll_to_bottom_cd)
|
||||||
|
val logsSearchLabel = stringResource(Res.string.logs_search)
|
||||||
|
val logsSearchHint = stringResource(Res.string.logs_search_hint)
|
||||||
|
val searchNotFoundLabel = stringResource(Res.string.search_not_found)
|
||||||
|
|
||||||
|
val hideIme: () -> Unit = {
|
||||||
|
resetFocus(keyboardController, focusManager)
|
||||||
|
}
|
||||||
|
|
||||||
val selectedCountTitle = stringResource(Res.string.logs_selected_count, selectedEntryIds.size)
|
val selectedCountTitle = stringResource(Res.string.logs_selected_count, selectedEntryIds.size)
|
||||||
val closeSelectionCd = stringResource(Res.string.cd_close_selection)
|
val closeSelectionCd = stringResource(Res.string.cd_close_selection)
|
||||||
@@ -287,6 +328,20 @@ fun LogsScreen() {
|
|||||||
val shareLabel = stringResource(Res.string.logs_share)
|
val shareLabel = stringResource(Res.string.logs_share)
|
||||||
val deleteLabel = stringResource(Res.string.action_delete)
|
val deleteLabel = stringResource(Res.string.action_delete)
|
||||||
|
|
||||||
|
fun exitSearchMode() {
|
||||||
|
searchQuery = ""
|
||||||
|
searchMode = false
|
||||||
|
hideIme()
|
||||||
|
scope.launch { searchTransitionProgress.snapTo(0f) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun requestExitSearchMode() {
|
||||||
|
scope.launch {
|
||||||
|
searchTransitionProgress.animateTo(0f, ChatSelectionTransitionSpring)
|
||||||
|
exitSearchMode()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun scrollToLatestLogs() {
|
fun scrollToLatestLogs() {
|
||||||
if (displayEntries.isEmpty()) return
|
if (displayEntries.isEmpty()) return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -339,6 +394,14 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun enterSearchMode() {
|
||||||
|
if (selectionMode) {
|
||||||
|
exitEntrySelection()
|
||||||
|
}
|
||||||
|
scope.launch { searchTransitionProgress.snapTo(0f) }
|
||||||
|
searchMode = true
|
||||||
|
}
|
||||||
|
|
||||||
fun performShare(compression: LogShareCompression) {
|
fun performShare(compression: LogShareCompression) {
|
||||||
val request = pendingShareRequest ?: return
|
val request = pendingShareRequest ?: return
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -361,19 +424,20 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun applyDragSelectionRange(toIndex: Int) {
|
fun applyDragSelectionRange(toIndex: Int) {
|
||||||
|
if (searchMode) return
|
||||||
val anchor = dragAnchorIndex
|
val anchor = dragAnchorIndex
|
||||||
if (anchor < 0 || toIndex < 0) return
|
if (anchor < 0 || toIndex < 0) return
|
||||||
val start = minOf(anchor, toIndex)
|
val start = minOf(anchor, toIndex)
|
||||||
val end = maxOf(anchor, toIndex)
|
val end = maxOf(anchor, toIndex)
|
||||||
selectedEntryIds = displayEntries.subList(start, end + 1).map { it.id }.toSet()
|
selectedEntryIds = listEntries.subList(start, end + 1).map { it.id }.toSet()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun beginDragSelection(index: Int) {
|
fun beginDragSelection(index: Int) {
|
||||||
if (index !in displayEntries.indices) return
|
if (searchMode || index !in listEntries.indices) return
|
||||||
gestureState.onDragSelectionStart()
|
gestureState.onDragSelectionStart()
|
||||||
dragAnchorIndex = index
|
dragAnchorIndex = index
|
||||||
if (!selectionMode) {
|
if (!selectionMode) {
|
||||||
enterEntrySelection(displayEntries[index].id)
|
enterEntrySelection(listEntries[index].id)
|
||||||
} else {
|
} else {
|
||||||
applyDragSelectionRange(index)
|
applyDragSelectionRange(index)
|
||||||
}
|
}
|
||||||
@@ -414,11 +478,12 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(displayEntries.size, isViewingCurrent, followLatest, selectionMode) {
|
LaunchedEffect(displayEntries.size, isViewingCurrent, followLatest, selectionMode, searchMode) {
|
||||||
if (
|
if (
|
||||||
isViewingCurrent &&
|
isViewingCurrent &&
|
||||||
displayEntries.isNotEmpty() &&
|
displayEntries.isNotEmpty() &&
|
||||||
!selectionMode &&
|
!selectionMode &&
|
||||||
|
!searchMode &&
|
||||||
followLatest
|
followLatest
|
||||||
) {
|
) {
|
||||||
isProgrammaticScroll = true
|
isProgrammaticScroll = true
|
||||||
@@ -462,8 +527,8 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectionMode) {
|
LaunchedEffect(selectionMode, searchMode) {
|
||||||
if (selectionMode) {
|
if (selectionMode || searchMode) {
|
||||||
followLatest = false
|
followLatest = false
|
||||||
} else if (isAtBottom) {
|
} else if (isAtBottom) {
|
||||||
followLatest = true
|
followLatest = true
|
||||||
@@ -476,6 +541,13 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(searchMode) {
|
||||||
|
if (searchMode) {
|
||||||
|
searchTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
|
||||||
|
searchFocusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(selectedEntryIds, listMode) {
|
LaunchedEffect(selectedEntryIds, listMode) {
|
||||||
if (listMode == LogsListMode.Selecting && selectedEntryIds.isEmpty()) {
|
if (listMode == LogsListMode.Selecting && selectedEntryIds.isEmpty()) {
|
||||||
requestExitEntrySelection()
|
requestExitEntrySelection()
|
||||||
@@ -483,7 +555,10 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose { exitEntrySelection() }
|
onDispose {
|
||||||
|
exitEntrySelection()
|
||||||
|
exitSearchMode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showDecompressDialog) {
|
if (showDecompressDialog) {
|
||||||
@@ -606,6 +681,7 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BackHandler(enabled = selectionMode) { requestExitEntrySelection() }
|
BackHandler(enabled = selectionMode) { requestExitEntrySelection() }
|
||||||
|
BackHandler(enabled = searchMode && !selectionMode) { requestExitSearchMode() }
|
||||||
PredictiveBackHandler(
|
PredictiveBackHandler(
|
||||||
enabled = selectionMode,
|
enabled = selectionMode,
|
||||||
onProgress = { backProgress ->
|
onProgress = { backProgress ->
|
||||||
@@ -622,7 +698,26 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
PredictiveBackHandler(
|
||||||
|
enabled = searchMode && !selectionMode,
|
||||||
|
onProgress = { backProgress ->
|
||||||
|
scope.launch {
|
||||||
|
searchTransitionProgress.snapTo((1f - backProgress).coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onCommit = { requestExitSearchMode() },
|
||||||
|
onCancel = {
|
||||||
|
if (searchMode) {
|
||||||
|
scope.launch {
|
||||||
|
searchTransitionProgress.animateTo(1f, ChatSelectionTransitionSpring)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
if (searchMode) {
|
||||||
|
ToggleNavScrimEffect()
|
||||||
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -632,7 +727,7 @@ fun LogsScreen() {
|
|||||||
contentWindowInsets = WindowInsets.navigationBars,
|
contentWindowInsets = WindowInsets.navigationBars,
|
||||||
floatingActionButtonPosition = FabPosition.End,
|
floatingActionButtonPosition = FabPosition.End,
|
||||||
floatingActionButton = {
|
floatingActionButton = {
|
||||||
val fabReveal = (1f - selectionProgress).coerceIn(0f, 1f)
|
val fabReveal = ((1f - selectionProgress) * (1f - searchProgress)).coerceIn(0f, 1f)
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.End,
|
horizontalAlignment = Alignment.End,
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
@@ -657,11 +752,13 @@ fun LogsScreen() {
|
|||||||
topBar = {
|
topBar = {
|
||||||
Box {
|
Box {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress },
|
modifier = Modifier.graphicsLayer {
|
||||||
|
alpha = (1f - selectionProgress) * (1f - searchProgress)
|
||||||
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { navController.navigateUp() },
|
onClick = { navController.navigateUp() },
|
||||||
enabled = selectionProgress < 1f,
|
enabled = selectionProgress < 1f && searchProgress < 1f,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
@@ -682,7 +779,7 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
if (isViewingCurrent && !selectionMode) {
|
if (isViewingCurrent && !selectionMode && searchProgress < 1f) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
pendingShareRequest = LogsShareRequest(
|
pendingShareRequest = LogsShareRequest(
|
||||||
@@ -699,7 +796,10 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box {
|
Box {
|
||||||
IconButton(onClick = { menuExpanded = true }) {
|
IconButton(
|
||||||
|
onClick = { menuExpanded = true },
|
||||||
|
enabled = searchProgress < 1f,
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.MoreVert,
|
imageVector = Icons.Default.MoreVert,
|
||||||
contentDescription = stringResource(Res.string.more),
|
contentDescription = stringResource(Res.string.more),
|
||||||
@@ -709,6 +809,16 @@ fun LogsScreen() {
|
|||||||
expanded = menuExpanded,
|
expanded = menuExpanded,
|
||||||
onDismissRequest = { menuExpanded = false },
|
onDismissRequest = { menuExpanded = false },
|
||||||
) {
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(logsSearchLabel) },
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(Icons.Default.Search, contentDescription = null)
|
||||||
|
},
|
||||||
|
onClick = {
|
||||||
|
menuExpanded = false
|
||||||
|
enterSearchMode()
|
||||||
|
},
|
||||||
|
)
|
||||||
if (isViewingCurrent) {
|
if (isViewingCurrent) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = { Text(stringResource(Res.string.logs_rotate)) },
|
text = { Text(stringResource(Res.string.logs_rotate)) },
|
||||||
@@ -750,6 +860,66 @@ fun LogsScreen() {
|
|||||||
},
|
},
|
||||||
scrollBehavior = scrollBehavior,
|
scrollBehavior = scrollBehavior,
|
||||||
)
|
)
|
||||||
|
if (searchMode || searchProgress > 0f) {
|
||||||
|
TopAppBar(
|
||||||
|
modifier = Modifier.graphicsLayer { alpha = searchProgress },
|
||||||
|
colors = logsTransparentTopAppBarColors(),
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(
|
||||||
|
onClick = { requestExitSearchMode() },
|
||||||
|
enabled = searchProgress > 0f,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = stringResource(Res.string.back),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
title = {
|
||||||
|
BasicTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = { searchQuery = it },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = MaterialTheme.typography.titleLarge.copy(
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
|
||||||
|
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||||
|
keyboardActions = KeyboardActions(onSearch = { hideIme() }),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.focusRequester(searchFocusRequester),
|
||||||
|
decorationBox = { innerTextField ->
|
||||||
|
Box(contentAlignment = Alignment.CenterStart) {
|
||||||
|
if (searchQuery.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = logsSearchHint,
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
innerTextField()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
if (searchQuery.isNotBlank()) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { searchQuery = "" },
|
||||||
|
enabled = searchProgress > 0f,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = stringResource(Res.string.cancel),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
if (selectionMode || selectionProgress > 0f) {
|
if (selectionMode || selectionProgress > 0f) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
modifier = Modifier.graphicsLayer { alpha = selectionProgress },
|
modifier = Modifier.graphicsLayer { alpha = selectionProgress },
|
||||||
@@ -806,11 +976,13 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
if (displayEntries.isEmpty()) {
|
when {
|
||||||
|
displayEntries.isEmpty() -> {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding)
|
||||||
|
.then(if (searchMode) Modifier.imePadding() else Modifier),
|
||||||
verticalArrangement = Arrangement.Center,
|
verticalArrangement = Arrangement.Center,
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
@@ -820,7 +992,26 @@ fun LogsScreen() {
|
|||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
|
||||||
|
searchMode && searchQuery.isNotBlank() && listEntries.isEmpty() -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
|
.imePadding(),
|
||||||
|
verticalArrangement = Arrangement.Center,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = searchNotFoundLabel,
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
val listContent: @Composable () -> Unit = {
|
val listContent: @Composable () -> Unit = {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
@@ -837,7 +1028,7 @@ fun LogsScreen() {
|
|||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
itemsIndexed(
|
itemsIndexed(
|
||||||
items = displayEntries,
|
items = listEntries,
|
||||||
key = { _, entry -> entry.id },
|
key = { _, entry -> entry.id },
|
||||||
) { index, entry ->
|
) { index, entry ->
|
||||||
LogEntryRow(
|
LogEntryRow(
|
||||||
@@ -861,14 +1052,23 @@ fun LogsScreen() {
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding),
|
.padding(innerPadding)
|
||||||
|
.then(if (searchMode) Modifier.imePadding() else Modifier),
|
||||||
) {
|
) {
|
||||||
listContent()
|
listContent()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun logsTransparentTopAppBarColors() = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
scrolledContainerColor = Color.Transparent,
|
||||||
|
)
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun LogsAnimatedFab(
|
internal fun LogsAnimatedFab(
|
||||||
visible: Boolean,
|
visible: Boolean,
|
||||||
|
|||||||
+15
-6
@@ -18,6 +18,7 @@ import androidx.compose.material3.SnackbarHostState
|
|||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -33,6 +34,7 @@ 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.api.ensureFcmTokenRegistered
|
import ru.fromchat.api.ensureFcmTokenRegistered
|
||||||
|
import ru.fromchat.api.isFcmPushRegisteredLocally
|
||||||
import ru.fromchat.api.unregisterFcmTokenFromServer
|
import ru.fromchat.api.unregisterFcmTokenFromServer
|
||||||
import ru.fromchat.back
|
import ru.fromchat.back
|
||||||
import ru.fromchat.error_unexpected
|
import ru.fromchat.error_unexpected
|
||||||
@@ -51,7 +53,10 @@ fun NotificationsScreen(onBack: () -> Unit) {
|
|||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
var notificationsEnabled by remember { mutableStateOf(areAppNotificationsEnabled()) }
|
var notificationsEnabled by remember { mutableStateOf(false) }
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
notificationsEnabled = areAppNotificationsEnabled() && isFcmPushRegisteredLocally()
|
||||||
|
}
|
||||||
val notificationsPermissionText = stringResource(Res.string.settings_notifications_permission_required)
|
val notificationsPermissionText = stringResource(Res.string.settings_notifications_permission_required)
|
||||||
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
|
val unexpectedErrorText = stringResource(Res.string.error_unexpected)
|
||||||
|
|
||||||
@@ -86,8 +91,9 @@ fun NotificationsScreen(onBack: () -> Unit) {
|
|||||||
Icon(Icons.Filled.Notifications, null)
|
Icon(Icons.Filled.Notifications, null)
|
||||||
},
|
},
|
||||||
checked = notificationsEnabled,
|
checked = notificationsEnabled,
|
||||||
onCheckedChange = {
|
onCheckedChange = { enabled ->
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
if (enabled) {
|
||||||
if (!areAppNotificationsEnabled()) {
|
if (!areAppNotificationsEnabled()) {
|
||||||
if (!openAppNotificationSettings()) {
|
if (!openAppNotificationSettings()) {
|
||||||
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
||||||
@@ -97,13 +103,16 @@ fun NotificationsScreen(onBack: () -> Unit) {
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
if (notificationsEnabled) {
|
val registered = ensureFcmTokenRegistered()
|
||||||
unregisterFcmTokenFromServer()
|
if (registered) {
|
||||||
notificationsEnabled = !notificationsEnabled
|
notificationsEnabled = true
|
||||||
} else {
|
} else {
|
||||||
ensureFcmTokenRegistered()
|
|
||||||
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
snackbarHostState.showSnackbar(message = unexpectedErrorText)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
unregisterFcmTokenFromServer()
|
||||||
|
notificationsEnabled = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
divider = true
|
divider = true
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ 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.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.WebSocketManager
|
|
||||||
import ru.fromchat.back
|
import ru.fromchat.back
|
||||||
import ru.fromchat.cancel
|
import ru.fromchat.cancel
|
||||||
import ru.fromchat.logout
|
import ru.fromchat.logout
|
||||||
@@ -139,7 +138,6 @@ fun AccountScreen(
|
|||||||
showLogoutConfirm = false
|
showLogoutConfirm = false
|
||||||
scope.launch {
|
scope.launch {
|
||||||
runCatching { ApiClient.logout() }
|
runCatching { ApiClient.logout() }
|
||||||
WebSocketManager.disconnect()
|
|
||||||
onLogout()
|
onLogout()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ fun EditProfileScreen(
|
|||||||
|
|
||||||
val canSave = loaded && hasChanges && !hasValidationErrors && !busy
|
val canSave = loaded && hasChanges && !hasValidationErrors && !busy
|
||||||
|
|
||||||
val avatarDisplayName = trimmedDisplayName.ifBlank { trimmedUsername }.ifBlank { "?" }
|
val avatarDisplayName = trimmedDisplayName.ifBlank { "?" }
|
||||||
|
|
||||||
fun showSnack(text: String) {
|
fun showSnack(text: String) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@@ -150,7 +150,12 @@ import ru.fromchat.profile_load_failed
|
|||||||
import ru.fromchat.profile_not_found
|
import ru.fromchat.profile_not_found
|
||||||
import ru.fromchat.profile_verified_support
|
import ru.fromchat.profile_verified_support
|
||||||
import ru.fromchat.profile_verify_prompt_support
|
import ru.fromchat.profile_verify_prompt_support
|
||||||
|
import ru.fromchat.ui.profile.deletedUserDisplayNameForUi
|
||||||
|
import ru.fromchat.ui.profile.avatarLabelForInitials
|
||||||
|
import ru.fromchat.ui.profile.displayNameForUi
|
||||||
import ru.fromchat.ui.profile.effectiveVerificationStatus
|
import ru.fromchat.ui.profile.effectiveVerificationStatus
|
||||||
|
import ru.fromchat.ui.profile.isDeletedAccount
|
||||||
|
import ru.fromchat.ui.profile.peerIsDeleted
|
||||||
import ru.fromchat.ui.LocalNavController
|
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
|
||||||
@@ -196,6 +201,7 @@ private fun hasDisplayableProfile(
|
|||||||
currentUserId: Int?,
|
currentUserId: Int?,
|
||||||
) = !initialDisplayName.isNullOrBlank() ||
|
) = !initialDisplayName.isNullOrBlank() ||
|
||||||
(profile != null && profile.id > 0 && (
|
(profile != null && profile.id > 0 && (
|
||||||
|
profile.isDeletedAccount(currentUserId) ||
|
||||||
!profile.visibleDisplayName(currentUserId).isNullOrBlank() ||
|
!profile.visibleDisplayName(currentUserId).isNullOrBlank() ||
|
||||||
profile.username.isNotBlank()
|
profile.username.isNotBlank()
|
||||||
))
|
))
|
||||||
@@ -461,12 +467,27 @@ fun ProfileScreen(
|
|||||||
val showBodySkeleton = resolvedProfile == null
|
val showBodySkeleton = resolvedProfile == null
|
||||||
val showAvatarSkeleton = showBodySkeleton && !hasDisplayable
|
val showAvatarSkeleton = showBodySkeleton && !hasDisplayable
|
||||||
val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id
|
val currentProfileUserId = targetUserId ?: ownUserId ?: profile?.id
|
||||||
val displayName =
|
val viewerUserId = ownUserId
|
||||||
profile?.visibleDisplayName(currentProfileUserId)
|
val resolvedUserId = resolvedProfile?.id ?: targetUserId
|
||||||
|
val isDeletedProfile = resolvedUserId != null && peerIsDeleted(
|
||||||
|
userId = resolvedUserId,
|
||||||
|
currentUserId = viewerUserId,
|
||||||
|
deleted = resolvedProfile?.deleted,
|
||||||
|
username = resolvedProfile?.username,
|
||||||
|
)
|
||||||
|
val displayName = when {
|
||||||
|
isDeletedProfile -> deletedUserDisplayNameForUi()
|
||||||
|
else -> profile?.displayNameForUi(viewerUserId)?.takeIf { it.isNotBlank() }
|
||||||
?: initialDisplayName?.takeIf { it.isNotBlank() }
|
?: initialDisplayName?.takeIf { it.isNotBlank() }
|
||||||
?: profile?.username?.takeIf { it.isNotBlank() }
|
|
||||||
?: ""
|
?: ""
|
||||||
val usernameForLinks = profile?.visibleUsername(currentProfileUserId)
|
}
|
||||||
|
val avatarLabel = when {
|
||||||
|
isDeletedProfile -> displayName
|
||||||
|
else -> profile?.avatarLabelForInitials(viewerUserId)?.takeIf { it.isNotBlank() }
|
||||||
|
?: initialDisplayName?.takeIf { it.isNotBlank() }
|
||||||
|
?: ""
|
||||||
|
}
|
||||||
|
val usernameForLinks = if (isDeletedProfile) null else profile?.visibleUsername(viewerUserId)
|
||||||
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}"
|
||||||
@@ -507,6 +528,28 @@ fun ProfileScreen(
|
|||||||
onClick = onOpenSettings,
|
onClick = onOpenSettings,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
} else if (p.isDeletedAccount(viewerUserId)) {
|
||||||
|
listOf(
|
||||||
|
ProfileAction(
|
||||||
|
label = labelChat,
|
||||||
|
icon = Icons.AutoMirrored.Filled.Chat,
|
||||||
|
holdsExpansionOnNavigate = true,
|
||||||
|
onClick = { onChat(p.id) },
|
||||||
|
),
|
||||||
|
ProfileAction(
|
||||||
|
label = labelSearch,
|
||||||
|
icon = Icons.Filled.Search,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showReplacingSnackbar(
|
||||||
|
message = notImplementedMessage,
|
||||||
|
withDismissAction = false,
|
||||||
|
duration = SnackbarDuration.Short,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
buildList {
|
buildList {
|
||||||
add(
|
add(
|
||||||
@@ -554,8 +597,10 @@ fun ProfileScreen(
|
|||||||
val showDetailsUsername = usernameForLinks != null
|
val showDetailsUsername = usernameForLinks != null
|
||||||
val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank()
|
val showDetailsMemberSince = !resolvedProfile?.createdAt.isNullOrBlank()
|
||||||
val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank()
|
val showDetailsBio = !resolvedProfile?.bio.isNullOrBlank()
|
||||||
val showDetailsVerify = resolvedProfile?.verified == true || ApiClient.user?.id == 1
|
val showDetailsVerify = !isDeletedProfile && (
|
||||||
val showDetailsSection = resolvedProfile != null && (
|
resolvedProfile?.verified == true || ApiClient.user?.id == 1
|
||||||
|
)
|
||||||
|
val showDetailsSection = resolvedProfile != null && !isDeletedProfile && (
|
||||||
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
|
showDetailsUsername || showDetailsMemberSince || showDetailsBio || showDetailsVerify
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -596,14 +641,16 @@ fun ProfileScreen(
|
|||||||
with(sharedTransitionScope) {
|
with(sharedTransitionScope) {
|
||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = profile?.profilePicture,
|
profilePictureUrl = profile?.profilePicture,
|
||||||
displayName = displayName,
|
displayName = avatarLabel,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = profileAvatarTop)
|
.padding(top = profileAvatarTop)
|
||||||
.sharedElement(
|
.sharedElement(
|
||||||
rememberSharedContentState(key = sharedAvatarKey),
|
rememberSharedContentState(key = sharedAvatarKey),
|
||||||
animatedVisibilityScope = animatedVisibilityScope
|
animatedVisibilityScope = animatedVisibilityScope
|
||||||
)
|
)
|
||||||
.size(104.dp)
|
.size(104.dp),
|
||||||
|
isDeletedUser = isDeletedProfile,
|
||||||
|
userId = resolvedUserId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -614,10 +661,12 @@ fun ProfileScreen(
|
|||||||
item {
|
item {
|
||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = profile?.profilePicture,
|
profilePictureUrl = profile?.profilePicture,
|
||||||
displayName = displayName,
|
displayName = avatarLabel,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = profileAvatarTop)
|
.padding(top = profileAvatarTop)
|
||||||
.size(104.dp)
|
.size(104.dp),
|
||||||
|
isDeletedUser = isDeletedProfile,
|
||||||
|
userId = resolvedUserId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
item { Spacer(Modifier.height(12.dp)) }
|
item { Spacer(Modifier.height(12.dp)) }
|
||||||
@@ -665,6 +714,7 @@ fun ProfileScreen(
|
|||||||
resolvedProfile = resolvedProfile!!,
|
resolvedProfile = resolvedProfile!!,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
isOwnProfile = isOwnProfile,
|
isOwnProfile = isOwnProfile,
|
||||||
|
isDeletedProfile = isDeletedProfile,
|
||||||
typingUsers = typingUsers,
|
typingUsers = typingUsers,
|
||||||
statusState = statusState,
|
statusState = statusState,
|
||||||
statusText = statusText,
|
statusText = statusText,
|
||||||
@@ -1145,6 +1195,7 @@ private fun ProfileLoadedBody(
|
|||||||
resolvedProfile: UserProfile,
|
resolvedProfile: UserProfile,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
isOwnProfile: Boolean,
|
isOwnProfile: Boolean,
|
||||||
|
isDeletedProfile: Boolean,
|
||||||
typingUsers: List<String>,
|
typingUsers: List<String>,
|
||||||
statusState: UserStatus?,
|
statusState: UserStatus?,
|
||||||
statusText: String,
|
statusText: String,
|
||||||
@@ -1204,11 +1255,14 @@ private fun ProfileLoadedBody(
|
|||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (!isDeletedProfile) {
|
||||||
StatusBadge(
|
StatusBadge(
|
||||||
verificationStatus = resolvedProfile.effectiveVerificationStatus(),
|
verificationStatus = resolvedProfile.effectiveVerificationStatus(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDeletedProfile) {
|
||||||
Spacer(Modifier.height(4.dp))
|
Spacer(Modifier.height(4.dp))
|
||||||
|
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
@@ -1237,6 +1291,9 @@ private fun ProfileLoadedBody(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Verified
|
import androidx.compose.material.icons.filled.Verified
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material.icons.rounded.Block
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -16,6 +17,7 @@ import ru.fromchat.Res
|
|||||||
import ru.fromchat.api.schema.user.profile.VerificationStatus
|
import ru.fromchat.api.schema.user.profile.VerificationStatus
|
||||||
import ru.fromchat.cd_similar_verified
|
import ru.fromchat.cd_similar_verified
|
||||||
import ru.fromchat.cd_verified_account
|
import ru.fromchat.cd_verified_account
|
||||||
|
import ru.fromchat.cd_account_blocked
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun StatusBadge(
|
fun StatusBadge(
|
||||||
@@ -38,6 +40,13 @@ fun StatusBadge(
|
|||||||
tint = Color(0xFFFFA000),
|
tint = Color(0xFFFFA000),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
VerificationStatus.Blocked -> Icon(
|
||||||
|
imageVector = Icons.Rounded.Block,
|
||||||
|
contentDescription = stringResource(Res.string.cd_account_blocked),
|
||||||
|
modifier = modifier.size(size),
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
)
|
||||||
|
|
||||||
VerificationStatus.None, null -> Unit
|
VerificationStatus.None, null -> Unit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package ru.fromchat.ui.profile
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.local.db.store.ProfileCache
|
||||||
|
import ru.fromchat.api.local.db.store.shouldHideUsername
|
||||||
|
import ru.fromchat.api.local.db.store.visibleDisplayName
|
||||||
|
import ru.fromchat.api.schema.user.profile.UserProfile
|
||||||
|
import ru.fromchat.deleted_account
|
||||||
|
|
||||||
|
fun UserProfile.isDeletedAccount(currentUserId: Int? = null): Boolean =
|
||||||
|
deleted == true && id != currentUserId
|
||||||
|
|
||||||
|
fun UserProfile.isSuspendedAccount(currentUserId: Int? = null): Boolean =
|
||||||
|
suspended == true && deleted != true && id != currentUserId
|
||||||
|
|
||||||
|
fun isDeletedAccountUsername(username: String?): Boolean =
|
||||||
|
username?.startsWith("#deleted") == true
|
||||||
|
|
||||||
|
/** True when [userId] should be shown as a deleted account to [currentUserId]. */
|
||||||
|
fun peerIsDeleted(
|
||||||
|
userId: Int,
|
||||||
|
currentUserId: Int? = null,
|
||||||
|
deleted: Boolean? = null,
|
||||||
|
username: String? = null,
|
||||||
|
): Boolean {
|
||||||
|
if (userId <= 0 || userId == currentUserId) return false
|
||||||
|
if (deleted == true) return true
|
||||||
|
if (isDeletedAccountUsername(username)) return true
|
||||||
|
ProfileCache.get(userId)?.let { profile ->
|
||||||
|
if (profile.isDeletedAccount(currentUserId)) return true
|
||||||
|
if (isDeletedAccountUsername(profile.username)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun deletedUserDisplayName(): String =
|
||||||
|
getString(Res.string.deleted_account)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun deletedUserDisplayNameForUi(): String =
|
||||||
|
stringResource(Res.string.deleted_account)
|
||||||
|
|
||||||
|
suspend fun UserProfile.displayNameText(currentUserId: Int? = null): String {
|
||||||
|
if (isDeletedAccount(currentUserId)) {
|
||||||
|
return deletedUserDisplayName()
|
||||||
|
}
|
||||||
|
return visibleDisplayName(currentUserId).orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun UserProfile.displayNameForUi(currentUserId: Int? = null): String =
|
||||||
|
if (isDeletedAccount(currentUserId)) {
|
||||||
|
deletedUserDisplayNameForUi()
|
||||||
|
} else {
|
||||||
|
visibleDisplayName(currentUserId).orEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display name for avatar initials/gradient only; never falls back to username. */
|
||||||
|
fun UserProfile.avatarLabelForInitials(currentUserId: Int? = null): String =
|
||||||
|
if (isDeletedAccount(currentUserId)) {
|
||||||
|
""
|
||||||
|
} else {
|
||||||
|
displayName?.trim().orEmpty()
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import ru.fromchat.month_sep
|
|||||||
import ru.fromchat.presence_date_full
|
import ru.fromchat.presence_date_full
|
||||||
import ru.fromchat.presence_date_this_year
|
import ru.fromchat.presence_date_this_year
|
||||||
import ru.fromchat.presence_online
|
import ru.fromchat.presence_online
|
||||||
|
import ru.fromchat.presence_long_ago
|
||||||
import ru.fromchat.presence_recently
|
import ru.fromchat.presence_recently
|
||||||
import ru.fromchat.presence_today_at
|
import ru.fromchat.presence_today_at
|
||||||
import ru.fromchat.presence_weekday_at
|
import ru.fromchat.presence_weekday_at
|
||||||
@@ -53,6 +54,7 @@ private fun formatFromXmlTemplate(template: String, vararg args: Any): String {
|
|||||||
data class LastSeenFormatStrings(
|
data class LastSeenFormatStrings(
|
||||||
val online: String,
|
val online: String,
|
||||||
val recently: String,
|
val recently: String,
|
||||||
|
val longAgo: String,
|
||||||
val todayAt: String,
|
val todayAt: String,
|
||||||
val yesterdayAt: String,
|
val yesterdayAt: String,
|
||||||
val weekdayAt: String,
|
val weekdayAt: String,
|
||||||
@@ -66,6 +68,7 @@ data class LastSeenFormatStrings(
|
|||||||
fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
|
fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
|
||||||
val online = stringResource(Res.string.presence_online)
|
val online = stringResource(Res.string.presence_online)
|
||||||
val recently = stringResource(Res.string.presence_recently)
|
val recently = stringResource(Res.string.presence_recently)
|
||||||
|
val longAgo = stringResource(Res.string.presence_long_ago)
|
||||||
val todayAt = stringResource(Res.string.presence_today_at)
|
val todayAt = stringResource(Res.string.presence_today_at)
|
||||||
val yesterdayAt = stringResource(Res.string.presence_yesterday_at)
|
val yesterdayAt = stringResource(Res.string.presence_yesterday_at)
|
||||||
val weekdayAt = stringResource(Res.string.presence_weekday_at)
|
val weekdayAt = stringResource(Res.string.presence_weekday_at)
|
||||||
@@ -91,7 +94,7 @@ fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
|
|||||||
val mNov = stringResource(Res.string.month_nov)
|
val mNov = stringResource(Res.string.month_nov)
|
||||||
val mDec = stringResource(Res.string.month_dec)
|
val mDec = stringResource(Res.string.month_dec)
|
||||||
return remember(
|
return remember(
|
||||||
online, recently, todayAt, yesterdayAt, weekdayAt, dateThisYear, dateFull,
|
online, recently, longAgo, todayAt, yesterdayAt, weekdayAt, dateThisYear, dateFull,
|
||||||
mon, tue, wed, thu, fri, sat, sun,
|
mon, tue, wed, thu, fri, sat, sun,
|
||||||
mJan, mFeb, mMar, mApr, mMay, mJun, mJul, mAug, mSep, mOct, mNov, mDec
|
mJan, mFeb, mMar, mApr, mMay, mJun, mJul, mAug, mSep, mOct, mNov, mDec
|
||||||
) {
|
) {
|
||||||
@@ -99,6 +102,7 @@ fun rememberLastSeenFormatStrings(): LastSeenFormatStrings {
|
|||||||
LastSeenFormatStrings(
|
LastSeenFormatStrings(
|
||||||
online = online,
|
online = online,
|
||||||
recently = recently,
|
recently = recently,
|
||||||
|
longAgo = longAgo,
|
||||||
todayAt = todayAt,
|
todayAt = todayAt,
|
||||||
yesterdayAt = yesterdayAt,
|
yesterdayAt = yesterdayAt,
|
||||||
weekdayAt = weekdayAt,
|
weekdayAt = weekdayAt,
|
||||||
@@ -128,6 +132,7 @@ fun formatLastSeen(online: Boolean, lastSeenIso: String?, s: LastSeenFormatStrin
|
|||||||
if (online) return s.online
|
if (online) return s.online
|
||||||
val iso = lastSeenIso ?: return ""
|
val iso = lastSeenIso ?: return ""
|
||||||
val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return s.recently
|
val instant = runCatching { Instant.parse(iso) }.getOrNull() ?: return s.recently
|
||||||
|
if (instant.toEpochMilliseconds() <= 0L) return s.longAgo
|
||||||
|
|
||||||
val timeZone = TimeZone.currentSystemDefault()
|
val timeZone = TimeZone.currentSystemDefault()
|
||||||
val lastLocal = instant.toLocalDateTime(timeZone)
|
val lastLocal = instant.toLocalDateTime(timeZone)
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ actual suspend fun unregisterFcmTokenFromServer(): Boolean {
|
|||||||
// iOS does not use FCM token management in this app build.
|
// iOS does not use FCM token management in this app build.
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual suspend fun isFcmPushRegisteredLocally(): Boolean = false
|
||||||
|
|||||||
Reference in New Issue
Block a user