From 0beb52e438a136ccbfdb4b580bcbc0140157f547 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 10 Mar 2026 08:29:36 +0300 Subject: [PATCH] Android parity: formatting, notifications inbox, resend verification, push sync --- android/CHANGELOG.md | 19 ++ .../messenger/data/auth/api/AuthApiService.kt | 5 + .../messenger/data/auth/dto/AuthDtos.kt | 5 + .../api/NotificationApiService.kt | 10 + .../notifications/dto/NotificationDtos.kt | 16 ++ .../repository/NetworkAccountRepository.kt | 46 ++++ .../daemonlord/messenger/di/NetworkModule.kt | 7 + .../account/model/AccountNotification.kt | 11 + .../account/repository/AccountRepository.kt | 3 + .../usecase/HandleRealtimeEventsUseCase.kt | 10 +- .../messenger/push/PushTokenSyncManager.kt | 19 ++ .../messenger/ui/account/AccountUiState.kt | 2 + .../messenger/ui/account/AccountViewModel.kt | 27 +- .../ui/auth/verify/VerifyEmailScreen.kt | 15 ++ .../messenger/ui/chat/ChatScreen.kt | 221 ++++++++++++----- .../messenger/ui/chat/MessageFormatting.kt | 232 ++++++++++++++++++ .../messenger/ui/settings/SettingsScreen.kt | 43 ++++ docs/android-checklist.md | 6 +- docs/backend-web-android-parity.md | 17 +- 19 files changed, 636 insertions(+), 78 deletions(-) create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/notifications/api/NotificationApiService.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/data/notifications/dto/NotificationDtos.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/domain/account/model/AccountNotification.kt create mode 100644 android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index c562899..71ece77 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -924,3 +924,22 @@ - added explicit `Text formatting parity` gap to `docs/android-checklist.md`, - added dedicated Android gap block in `docs/backend-web-android-parity.md` for formatting parity. - Marked formatting parity as part of highest-priority Android parity block. + +### Step 129 - Parity block (1/3/4/5/6): formatting, notifications inbox, resend verification, push sync +- Completed Android text formatting parity in chat: + - composer toolbar actions for `bold/italic/underline/strikethrough`, + - spoiler, inline code, code block, quote, link insertion, + - message bubble rich renderer for web-style markdown tokens and clickable links. +- Added server notifications inbox flow in account/settings: + - API wiring for `GET /api/v1/notifications`, + - domain mapping and recent-notifications UI section. +- Added resend verification support on Android: + - API wiring for `POST /api/v1/auth/resend-verification`, + - Verify Email screen action for resending link by email. +- Hardened push token lifecycle sync: + - token registration dedupe by `(userId, token)`, + - marker cleanup on logout, + - best-effort re-sync after account switch. +- Notification delivery polish (foundation): + - foreground notification body now respects preview setting and falls back to media-type labels when previews are disabled. +- Verified with successful `:app:compileDebugKotlin` and `:app:assembleDebug`. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt index 26576d4..32ac135 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/api/AuthApiService.kt @@ -6,6 +6,7 @@ import ru.daemonlord.messenger.data.auth.dto.CheckEmailStatusDto import ru.daemonlord.messenger.data.auth.dto.LoginRequestDto import ru.daemonlord.messenger.data.auth.dto.MessageResponseDto import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto +import ru.daemonlord.messenger.data.auth.dto.ResendVerificationRequestDto import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto @@ -51,6 +52,10 @@ interface AuthApiService { @POST("/api/v1/auth/request-password-reset") suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto + @Headers("No-Auth: true") + @POST("/api/v1/auth/resend-verification") + suspend fun resendVerification(@Body request: ResendVerificationRequestDto): MessageResponseDto + @Headers("No-Auth: true") @POST("/api/v1/auth/reset-password") suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt index 142a083..6289f2e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/dto/AuthDtos.kt @@ -86,6 +86,11 @@ data class RequestPasswordResetDto( val email: String, ) +@Serializable +data class ResendVerificationRequestDto( + val email: String, +) + @Serializable data class ResetPasswordRequestDto( val token: String, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/api/NotificationApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/api/NotificationApiService.kt new file mode 100644 index 0000000..242bef1 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/api/NotificationApiService.kt @@ -0,0 +1,10 @@ +package ru.daemonlord.messenger.data.notifications.api + +import retrofit2.http.GET +import retrofit2.http.Query +import ru.daemonlord.messenger.data.notifications.dto.NotificationReadDto + +interface NotificationApiService { + @GET("/api/v1/notifications") + suspend fun list(@Query("limit") limit: Int = 50): List +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/dto/NotificationDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/dto/NotificationDtos.kt new file mode 100644 index 0000000..cc12735 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/dto/NotificationDtos.kt @@ -0,0 +1,16 @@ +package ru.daemonlord.messenger.data.notifications.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NotificationReadDto( + val id: Long, + @SerialName("user_id") + val userId: Long, + @SerialName("event_type") + val eventType: String, + val payload: String, + @SerialName("created_at") + val createdAt: String, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt index 828356b..ab3e9f7 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/user/repository/NetworkAccountRepository.kt @@ -10,12 +10,15 @@ import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.auth.dto.AuthSessionDto import ru.daemonlord.messenger.data.auth.dto.AuthUserDto import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto +import ru.daemonlord.messenger.data.auth.dto.ResendVerificationRequestDto import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto +import ru.daemonlord.messenger.data.notifications.api.NotificationApiService +import ru.daemonlord.messenger.data.notifications.dto.NotificationReadDto import ru.daemonlord.messenger.di.RefreshClient import ru.daemonlord.messenger.data.user.api.UserApiService import ru.daemonlord.messenger.data.user.dto.AddContactByEmailRequestDto @@ -23,11 +26,16 @@ import ru.daemonlord.messenger.data.user.dto.UserProfileUpdateRequestDto import ru.daemonlord.messenger.data.user.dto.UserSearchDto import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.domain.account.model.UserSearchItem +import ru.daemonlord.messenger.domain.account.model.AccountNotification import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppResult +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.contentOrNull import javax.inject.Inject import javax.inject.Singleton @@ -36,6 +44,7 @@ class NetworkAccountRepository @Inject constructor( private val authApiService: AuthApiService, private val userApiService: UserApiService, private val mediaApiService: MediaApiService, + private val notificationApiService: NotificationApiService, @RefreshClient private val uploadClient: OkHttpClient, @IoDispatcher private val ioDispatcher: CoroutineDispatcher, ) : AccountRepository { @@ -206,6 +215,14 @@ class NetworkAccountRepository @Inject constructor( } } + override suspend fun listNotifications(limit: Int): AppResult> = withContext(ioDispatcher) { + try { + AppResult.Success(notificationApiService.list(limit = limit).map { it.toDomain() }) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + override suspend fun revokeSession(jti: String): AppResult = withContext(ioDispatcher) { try { authApiService.revokeSession(jti) @@ -242,6 +259,15 @@ class NetworkAccountRepository @Inject constructor( } } + override suspend fun resendVerification(email: String): AppResult = withContext(ioDispatcher) { + try { + val result = authApiService.resendVerification(ResendVerificationRequestDto(email = email.trim())) + AppResult.Success(result.message) + } catch (error: Throwable) { + AppResult.Error(error.toAppError()) + } + } + override suspend fun resetPassword(token: String, password: String): AppResult = withContext(ioDispatcher) { try { val result = authApiService.resetPassword( @@ -336,4 +362,24 @@ class NetworkAccountRepository @Inject constructor( avatarUrl = avatarUrl, ) } + + private fun NotificationReadDto.toDomain(): AccountNotification { + val payloadObject = runCatching { + Json.parseToJsonElement(payload).jsonObject + }.getOrNull() + val chatId = payloadObject?.get("chat_id")?.jsonPrimitive?.contentOrNull?.toLongOrNull() + val messageId = payloadObject?.get("message_id")?.jsonPrimitive?.contentOrNull?.toLongOrNull() + val text = payloadObject?.get("text")?.jsonPrimitive?.contentOrNull + ?: payloadObject?.get("body")?.jsonPrimitive?.contentOrNull + ?: payloadObject?.get("title")?.jsonPrimitive?.contentOrNull + return AccountNotification( + id = id, + eventType = eventType, + createdAt = createdAt, + payloadRaw = payload, + chatId = chatId, + messageId = messageId, + text = text, + ) + } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt index cb74d78..e53e5cd 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt @@ -19,6 +19,7 @@ import ru.daemonlord.messenger.data.auth.api.AuthApiService import ru.daemonlord.messenger.data.chat.api.ChatApiService import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.data.message.api.MessageApiService +import ru.daemonlord.messenger.data.notifications.api.NotificationApiService import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService import ru.daemonlord.messenger.data.search.api.SearchApiService import ru.daemonlord.messenger.data.user.api.UserApiService @@ -161,4 +162,10 @@ object NetworkModule { fun providePushTokenApiService(retrofit: Retrofit): PushTokenApiService { return retrofit.create(PushTokenApiService::class.java) } + + @Provides + @Singleton + fun provideNotificationApiService(retrofit: Retrofit): NotificationApiService { + return retrofit.create(NotificationApiService::class.java) + } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/account/model/AccountNotification.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/model/AccountNotification.kt new file mode 100644 index 0000000..6e36f56 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/model/AccountNotification.kt @@ -0,0 +1,11 @@ +package ru.daemonlord.messenger.domain.account.model + +data class AccountNotification( + val id: Long, + val eventType: String, + val createdAt: String, + val payloadRaw: String, + val chatId: Long? = null, + val messageId: Long? = null, + val text: String? = null, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt index 11e4e28..77ca6e0 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/account/repository/AccountRepository.kt @@ -1,6 +1,7 @@ package ru.daemonlord.messenger.domain.account.repository import ru.daemonlord.messenger.domain.account.model.UserSearchItem +import ru.daemonlord.messenger.domain.account.model.AccountNotification import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.common.AppResult @@ -33,9 +34,11 @@ interface AccountRepository { suspend fun blockUser(userId: Long): AppResult suspend fun unblockUser(userId: Long): AppResult suspend fun listSessions(): AppResult> + suspend fun listNotifications(limit: Int = 50): AppResult> suspend fun revokeSession(jti: String): AppResult suspend fun revokeAllSessions(): AppResult suspend fun verifyEmail(token: String): AppResult + suspend fun resendVerification(email: String): AppResult suspend fun requestPasswordReset(email: String): AppResult suspend fun resetPassword(token: String, password: String): AppResult suspend fun setupTwoFactor(): AppResult> diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt index 0f15b04..84e245e 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/realtime/usecase/HandleRealtimeEventsUseCase.kt @@ -13,6 +13,7 @@ import ru.daemonlord.messenger.core.notifications.NotificationDispatcher import ru.daemonlord.messenger.data.message.local.dao.MessageDao import ru.daemonlord.messenger.data.message.local.entity.MessageEntity import ru.daemonlord.messenger.domain.chat.repository.ChatRepository +import ru.daemonlord.messenger.domain.notifications.repository.NotificationSettingsRepository import ru.daemonlord.messenger.domain.notifications.usecase.ShouldShowMessageNotificationUseCase import ru.daemonlord.messenger.domain.realtime.RealtimeManager import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent @@ -27,6 +28,7 @@ class HandleRealtimeEventsUseCase @Inject constructor( private val messageDao: MessageDao, private val notificationDispatcher: NotificationDispatcher, private val activeChatTracker: ActiveChatTracker, + private val notificationSettingsRepository: NotificationSettingsRepository, private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase, ) { @@ -89,8 +91,12 @@ class HandleRealtimeEventsUseCase @Inject constructor( ) if (activeChatId != event.chatId && shouldNotify) { val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message" - val body = event.text?.takeIf { it.isNotBlank() } - ?: when (event.type?.lowercase()) { + val previewEnabled = notificationSettingsRepository.getSettings().previewEnabled + val body = (if (previewEnabled) { + event.text?.takeIf { it.isNotBlank() } + } else { + null + }) ?: when (event.type?.lowercase()) { "image" -> "Photo" "video" -> "Video" "audio" -> "Audio" diff --git a/android/app/src/main/java/ru/daemonlord/messenger/push/PushTokenSyncManager.kt b/android/app/src/main/java/ru/daemonlord/messenger/push/PushTokenSyncManager.kt index a568314..e931deb 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/push/PushTokenSyncManager.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/push/PushTokenSyncManager.kt @@ -71,6 +71,10 @@ class PushTokenSyncManager @Inject constructor( }.onFailure { error -> Timber.w(error, "Failed to unregister push token on logout") } + securePrefs.edit() + .remove(KEY_LAST_SYNCED_TOKEN) + .remove(KEY_LAST_SYNCED_USER_ID) + .apply() } private suspend fun registerTokenIfPossible(token: String) { @@ -78,6 +82,15 @@ class PushTokenSyncManager @Inject constructor( if (!hasTokens) { return } + val activeUserId = tokenRepository.getActiveUserId() + if (activeUserId == null) { + return + } + val lastSyncedToken = securePrefs.getString(KEY_LAST_SYNCED_TOKEN, null)?.trim() + val lastSyncedUserId = securePrefs.getLong(KEY_LAST_SYNCED_USER_ID, -1L).takeIf { it > 0L } + if (lastSyncedToken == token && lastSyncedUserId == activeUserId) { + return + } runCatching { pushTokenApiService.upsert( request = PushTokenUpsertRequestDto( @@ -87,6 +100,10 @@ class PushTokenSyncManager @Inject constructor( appVersion = BuildConfig.VERSION_NAME, ) ) + securePrefs.edit() + .putString(KEY_LAST_SYNCED_TOKEN, token) + .putLong(KEY_LAST_SYNCED_USER_ID, activeUserId) + .apply() }.onFailure { error -> Timber.w(error, "Failed to sync push token") } @@ -94,6 +111,8 @@ class PushTokenSyncManager @Inject constructor( private companion object { const val KEY_LAST_FCM_TOKEN = "last_fcm_token" + const val KEY_LAST_SYNCED_TOKEN = "last_synced_push_token" + const val KEY_LAST_SYNCED_USER_ID = "last_synced_push_user_id" const val PLATFORM_ANDROID = "android" } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt index 831bd63..333a195 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountUiState.kt @@ -1,6 +1,7 @@ package ru.daemonlord.messenger.ui.account import ru.daemonlord.messenger.domain.account.model.UserSearchItem +import ru.daemonlord.messenger.domain.account.model.AccountNotification import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.settings.model.AppThemeMode @@ -20,6 +21,7 @@ data class AccountUiState( val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, val notificationsEnabled: Boolean = true, val notificationsPreviewEnabled: Boolean = true, + val notificationsHistory: List = emptyList(), val isAddingAccount: Boolean = false, val message: String? = null, val errorMessage: String? = null, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt index e86b237..800daf3 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/account/AccountViewModel.kt @@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import ru.daemonlord.messenger.core.token.TokenRepository +import ru.daemonlord.messenger.push.PushTokenSyncManager import ru.daemonlord.messenger.domain.auth.usecase.LoginUseCase import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository @@ -30,6 +31,7 @@ class AccountViewModel @Inject constructor( private val realtimeManager: RealtimeManager, private val notificationSettingsRepository: NotificationSettingsRepository, private val themeRepository: ThemeRepository, + private val pushTokenSyncManager: PushTokenSyncManager, ) : ViewModel() { private val _uiState = MutableStateFlow(AccountUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -44,6 +46,7 @@ class AccountViewModel @Inject constructor( val me = accountRepository.getMe() val sessions = accountRepository.listSessions() val blocked = accountRepository.listBlockedUsers() + val notifications = accountRepository.listNotifications(limit = 50) val activeUserId = tokenRepository.getActiveUserId() val storedAccounts = tokenRepository.getAccounts() val notificationSettings = notificationSettingsRepository.getSettings() @@ -54,6 +57,7 @@ class AccountViewModel @Inject constructor( profile = (me as? AppResult.Success)?.data ?: state.profile, sessions = (sessions as? AppResult.Success)?.data ?: state.sessions, blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers, + notificationsHistory = (notifications as? AppResult.Success)?.data ?: state.notificationsHistory, activeUserId = activeUserId, storedAccounts = storedAccounts.map { account -> StoredAccountUi( @@ -70,7 +74,7 @@ class AccountViewModel @Inject constructor( notificationsEnabled = notificationSettings.globalEnabled, notificationsPreviewEnabled = notificationSettings.previewEnabled, appThemeMode = appThemeMode, - errorMessage = listOf(me, sessions, blocked) + errorMessage = listOf(me, sessions, blocked, notifications) .filterIsInstance() .firstOrNull() ?.reason @@ -152,6 +156,7 @@ class AccountViewModel @Inject constructor( // Force data/context switch to the newly active account. realtimeManager.disconnect() realtimeManager.connect() + pushTokenSyncManager.triggerBestEffortSync() val allResult = chatRepository.refreshChats(archived = false) val archivedResult = chatRepository.refreshChats(archived = true) refresh() @@ -374,6 +379,26 @@ class AccountViewModel @Inject constructor( } } + fun resendVerification(email: String) { + viewModelScope.launch { + _uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) } + when (val result = accountRepository.resendVerification(email)) { + is AppResult.Success -> _uiState.update { + it.copy( + isSaving = false, + message = result.data, + ) + } + is AppResult.Error -> _uiState.update { + it.copy( + isSaving = false, + errorMessage = result.reason.toUiMessage(), + ) + } + } + } + } + fun requestPasswordReset(email: String) { viewModelScope.launch { _uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt index 169b608..49edfc8 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/auth/verify/VerifyEmailScreen.kt @@ -37,6 +37,7 @@ fun VerifyEmailRoute( ) { val state by viewModel.uiState.collectAsStateWithLifecycle() var editableToken by remember(token) { mutableStateOf(token.orEmpty()) } + var resendEmail by remember { mutableStateOf(state.profile?.email.orEmpty()) } LaunchedEffect(token) { if (!token.isNullOrBlank()) { @@ -72,6 +73,20 @@ fun VerifyEmailRoute( ) { Text("Verify") } + OutlinedTextField( + value = resendEmail, + onValueChange = { resendEmail = it }, + label = { Text("Email for resend") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + Button( + onClick = { viewModel.resendVerification(resendEmail) }, + enabled = !state.isSaving && resendEmail.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { + Text("Resend verification link") + } if (state.isSaving) { CircularProgressIndicator() } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 0a19bb6..376d327 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -74,6 +74,8 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.draw.clip import androidx.compose.ui.unit.dp @@ -113,6 +115,13 @@ import androidx.compose.material.icons.filled.Image import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.FormatBold +import androidx.compose.material.icons.filled.FormatItalic +import androidx.compose.material.icons.filled.FormatUnderlined +import androidx.compose.material.icons.filled.StrikethroughS +import androidx.compose.material.icons.filled.Code +import androidx.compose.material.icons.filled.FormatQuote +import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import coil.compose.AsyncImage @@ -287,6 +296,7 @@ fun ChatScreen( var showChatMenu by remember { mutableStateOf(false) } var showChatInfoSheet by remember { mutableStateOf(false) } var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) } + var composerValue by remember { mutableStateOf(TextFieldValue(state.inputText)) } val actionSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val forwardSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val chatInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) @@ -308,6 +318,15 @@ fun ChatScreen( listState.animateScrollToItem(index = index) } } + LaunchedEffect(state.inputText) { + if (state.inputText != composerValue.text) { + val clampedCursor = composerValue.selection.end.coerceAtMost(state.inputText.length) + composerValue = TextFieldValue( + text = state.inputText, + selection = TextRange(clampedCursor), + ) + } + } LaunchedEffect(state.actionState.mode) { if (state.actionState.mode == MessageSelectionMode.MULTI && showInlineSearch) { showInlineSearch = false @@ -1038,83 +1057,163 @@ fun ChatScreen( color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), shape = RoundedCornerShape(22.dp), ) { - Row( + Column( modifier = Modifier .fillMaxWidth() .padding(horizontal = 8.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalAlignment = Alignment.CenterVertically, ) { IconButton( - onClick = { /* emoji picker step */ }, + onClick = { + composerValue = applyInlineFormatting(composerValue, "**", "**") + onInputChanged(composerValue.text) + }, enabled = state.canSendMessages, - ) { - Icon( - imageVector = Icons.Filled.EmojiEmotions, - contentDescription = "Emoji", - ) - } - } - TextField( - value = state.inputText, - onValueChange = onInputChanged, - modifier = Modifier.weight(1f), - placeholder = { Text("Message") }, - shape = RoundedCornerShape(14.dp), - maxLines = 4, - colors = TextFieldDefaults.colors( - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), - disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), - focusedIndicatorColor = Color.Transparent, - unfocusedIndicatorColor = Color.Transparent, - disabledIndicatorColor = Color.Transparent, - ), - ) - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), - ) { + ) { Icon(Icons.Filled.FormatBold, contentDescription = "Bold") } IconButton( - onClick = onPickMedia, - enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice, - ) { - if (state.isUploadingMedia) { - CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) - } else { - Icon(imageVector = Icons.Filled.AttachFile, contentDescription = "Attach") - } - } + onClick = { + composerValue = applyInlineFormatting(composerValue, "*", "*") + onInputChanged(composerValue.text) + }, + enabled = state.canSendMessages, + ) { Icon(Icons.Filled.FormatItalic, contentDescription = "Italic") } + IconButton( + onClick = { + composerValue = applyInlineFormatting(composerValue, "__", "__") + onInputChanged(composerValue.text) + }, + enabled = state.canSendMessages, + ) { Icon(Icons.Filled.FormatUnderlined, contentDescription = "Underline") } + IconButton( + onClick = { + composerValue = applyInlineFormatting(composerValue, "~~", "~~") + onInputChanged(composerValue.text) + }, + enabled = state.canSendMessages, + ) { Icon(Icons.Filled.StrikethroughS, contentDescription = "Strikethrough") } + IconButton( + onClick = { + composerValue = applyInlineFormatting(composerValue, "||", "||") + onInputChanged(composerValue.text) + }, + enabled = state.canSendMessages, + ) { Icon(Icons.Filled.VisibilityOff, contentDescription = "Spoiler") } + IconButton( + onClick = { + composerValue = applyInlineFormatting(composerValue, "`", "`") + onInputChanged(composerValue.text) + }, + enabled = state.canSendMessages, + ) { Icon(Icons.Filled.Code, contentDescription = "Monospace") } + IconButton( + onClick = { + composerValue = applyInlineFormatting(composerValue, "```\n", "\n```", "code") + onInputChanged(composerValue.text) + }, + enabled = state.canSendMessages, + ) { Icon(Icons.Filled.Code, contentDescription = "Code block") } + IconButton( + onClick = { + composerValue = applyQuoteFormatting(composerValue) + onInputChanged(composerValue.text) + }, + enabled = state.canSendMessages, + ) { Icon(Icons.Filled.FormatQuote, contentDescription = "Quote") } + IconButton( + onClick = { + composerValue = applyLinkFormatting(composerValue) + onInputChanged(composerValue.text) + }, + enabled = state.canSendMessages, + ) { Icon(Icons.Filled.Link, contentDescription = "Link") } } - val canSend = state.canSendMessages && - !state.isSending && - !state.isUploadingMedia && - state.inputText.isNotBlank() - if (canSend) { + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { Surface( shape = CircleShape, - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), ) { IconButton( - onClick = onSendClick, - enabled = state.canSendMessages && !state.isUploadingMedia, + onClick = { /* emoji picker step */ }, + enabled = state.canSendMessages, ) { - Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send") + Icon( + imageVector = Icons.Filled.EmojiEmotions, + contentDescription = "Emoji", + ) } } - } else { - VoiceHoldToRecordButton( - enabled = state.canSendMessages && !state.isUploadingMedia, - isLocked = state.isVoiceLocked, - onStart = onVoiceRecordStart, - onLock = onVoiceRecordLock, - onCancel = onVoiceRecordCancel, - onRelease = onVoiceRecordSend, + TextField( + value = composerValue, + onValueChange = { + composerValue = it + onInputChanged(it.text) + }, + modifier = Modifier.weight(1f), + placeholder = { Text("Message") }, + shape = RoundedCornerShape(14.dp), + maxLines = 4, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.45f), + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f), + disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f), + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + disabledIndicatorColor = Color.Transparent, + ), ) + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), + ) { + IconButton( + onClick = onPickMedia, + enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice, + ) { + if (state.isUploadingMedia) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Icon(imageVector = Icons.Filled.AttachFile, contentDescription = "Attach") + } + } + } + val canSend = state.canSendMessages && + !state.isSending && + !state.isUploadingMedia && + composerValue.text.isNotBlank() + if (canSend) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), + ) { + IconButton( + onClick = onSendClick, + enabled = state.canSendMessages && !state.isUploadingMedia, + ) { + Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send") + } + } + } else { + VoiceHoldToRecordButton( + enabled = state.canSendMessages && !state.isUploadingMedia, + isLocked = state.isVoiceLocked, + onStart = onVoiceRecordStart, + onLock = onVoiceRecordLock, + onCancel = onVoiceRecordCancel, + onRelease = onVoiceRecordSend, + ) + } } } if (state.isRecordingVoice) { @@ -1420,7 +1519,7 @@ private fun MessageBubble( val mainText = message.text?.takeIf { it.isNotBlank() } if (mainText != null || message.attachments.isEmpty()) { - Text( + FormattedMessageText( text = mainText ?: "[${message.type}]", style = MaterialTheme.typography.bodyLarge, color = textColor, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt new file mode 100644 index 0000000..9f7fe37 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageFormatting.kt @@ -0,0 +1,232 @@ +package ru.daemonlord.messenger.ui.chat + +import androidx.compose.foundation.text.ClickableText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextDecoration + +private data class InlineToken( + val marker: String, + val style: SpanStyle, +) + +fun applyInlineFormatting( + value: TextFieldValue, + prefix: String, + suffix: String = prefix, + placeholder: String = "text", +): TextFieldValue { + val start = value.selection.min + val end = value.selection.max + val selected = value.text.substring(start, end) + val middle = if (selected.isNotBlank()) selected else placeholder + val replacement = "$prefix$middle$suffix" + val nextText = value.text.replaceRange(start, end, replacement) + val selection = if (selected.isNotBlank()) { + TextRange(start + replacement.length) + } else { + TextRange(start + prefix.length, start + prefix.length + middle.length) + } + return value.copy(text = nextText, selection = selection) +} + +fun applyQuoteFormatting(value: TextFieldValue): TextFieldValue { + val start = value.selection.min + val end = value.selection.max + val selected = value.text.substring(start, end).ifBlank { "quote" } + val quoted = selected + .split('\n') + .joinToString("\n") { line -> "> $line" } + val nextText = value.text.replaceRange(start, end, quoted) + return value.copy(text = nextText, selection = TextRange(start + quoted.length)) +} + +fun applyLinkFormatting(value: TextFieldValue, url: String = "https://"): TextFieldValue { + val start = value.selection.min + val end = value.selection.max + val selected = value.text.substring(start, end).trim().ifBlank { "text" } + val replacement = "[$selected]($url)" + val nextText = value.text.replaceRange(start, end, replacement) + return value.copy( + text = nextText, + selection = TextRange(start + selected.length + 3, start + selected.length + 3 + url.length), + ) +} + +@Composable +fun FormattedMessageText( + text: String, + style: TextStyle, + color: Color, + modifier: Modifier = Modifier, +) { + val uriHandler = LocalUriHandler.current + val annotated = buildFormattedMessage(text = text, baseColor = color) + ClickableText( + text = annotated, + style = style, + modifier = modifier, + onClick = { offset -> + annotated + .getStringAnnotations(tag = "url", start = offset, end = offset) + .firstOrNull() + ?.item + ?.let { link -> + runCatching { uriHandler.openUri(link) } + } + }, + ) +} + +private fun buildFormattedMessage(text: String, baseColor: Color): AnnotatedString { + val codeStyle = SpanStyle( + fontFamily = FontFamily.Monospace, + background = Color(0x332A2A2A), + ) + val spoilerStyle = SpanStyle( + color = Color.Transparent, + background = baseColor.copy(alpha = 0.45f), + ) + val linkStyle = SpanStyle( + color = Color(0xFF8AB4F8), + textDecoration = TextDecoration.Underline, + ) + val quotePrefixStyle = SpanStyle( + color = baseColor.copy(alpha = 0.72f), + fontWeight = FontWeight.SemiBold, + ) + val tokens = listOf( + InlineToken("||", spoilerStyle), + InlineToken("**", SpanStyle(fontWeight = FontWeight.Bold)), + InlineToken("__", SpanStyle(textDecoration = TextDecoration.Underline)), + InlineToken("~~", SpanStyle(textDecoration = TextDecoration.LineThrough)), + InlineToken("*", SpanStyle(fontStyle = FontStyle.Italic)), + ) + + return buildAnnotatedString { + val parts = text.split("```") + parts.forEachIndexed { index, part -> + val isCodeBlock = index % 2 == 1 + if (isCodeBlock) { + val cleanCode = part.trim('\n') + val blockStart = length + append(cleanCode) + addStyle(codeStyle, blockStart, length) + if (index != parts.lastIndex) append('\n') + return@forEachIndexed + } + + val lines = part.split('\n') + lines.forEachIndexed { lineIndex, line -> + if (line.startsWith("> ")) { + val prefixStart = length + append("▍ ") + addStyle(quotePrefixStyle, prefixStart, length) + appendInlineFormatted( + source = line.removePrefix("> "), + tokens = tokens, + codeStyle = codeStyle, + linkStyle = linkStyle, + ) + } else { + appendInlineFormatted( + source = line, + tokens = tokens, + codeStyle = codeStyle, + linkStyle = linkStyle, + ) + } + if (lineIndex != lines.lastIndex) append('\n') + } + } + } +} + +private fun AnnotatedString.Builder.appendInlineFormatted( + source: String, + tokens: List, + codeStyle: SpanStyle, + linkStyle: SpanStyle, +) { + var i = 0 + while (i < source.length) { + val linkMatch = Regex("""^\[([^\]]+)]\(([^)]+)\)""").find(source.substring(i)) + if (linkMatch != null) { + val label = linkMatch.groupValues[1] + val href = linkMatch.groupValues[2].trim() + if (href.startsWith("http://", ignoreCase = true) || href.startsWith("https://", ignoreCase = true)) { + val start = length + appendInlineFormatted( + source = label, + tokens = tokens, + codeStyle = codeStyle, + linkStyle = linkStyle, + ) + addStyle(linkStyle, start, length) + addStringAnnotation(tag = "url", annotation = href, start = start, end = length) + i += linkMatch.value.length + continue + } + } + + val bareUrl = Regex("""^https?://[^\s<>()]+""", RegexOption.IGNORE_CASE).find(source.substring(i)) + if (bareUrl != null) { + val raw = bareUrl.value + val trimmed = raw.trimEnd(',', ')', '.', ';', '!', '?') + val trailing = raw.removePrefix(trimmed) + val start = length + append(trimmed) + addStyle(linkStyle, start, length) + addStringAnnotation(tag = "url", annotation = trimmed, start = start, end = length) + append(trailing) + i += raw.length + continue + } + + if (source.startsWith("`", i)) { + val end = source.indexOf('`', startIndex = i + 1) + if (end > i + 1) { + val codeStart = length + append(source.substring(i + 1, end)) + addStyle(codeStyle, codeStart, length) + i = end + 1 + continue + } + } + + var matched = false + for (token in tokens) { + if (!source.startsWith(token.marker, i)) continue + val end = source.indexOf(token.marker, startIndex = i + token.marker.length) + if (end <= i + token.marker.length) continue + val inner = source.substring(i + token.marker.length, end) + if (inner.isBlank()) continue + val rangeStart = length + appendInlineFormatted( + source = inner, + tokens = tokens, + codeStyle = codeStyle, + linkStyle = linkStyle, + ) + addStyle(token.style, rangeStart, length) + i = end + token.marker.length + matched = true + break + } + if (matched) continue + + append(source[i]) + i += 1 + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt index ff28c53..a272066 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/settings/SettingsScreen.kt @@ -349,6 +349,49 @@ private fun NotificationsFolder(state: AccountUiState, viewModel: AccountViewMod SettingsCard { SettingsToggle(Icons.Filled.Notifications, "Enable notifications", state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled) SettingsToggle(Icons.Filled.Visibility, "Show message preview", state.notificationsPreviewEnabled, viewModel::setNotificationPreviewEnabled) + OutlinedButton( + onClick = viewModel::refresh, + enabled = !state.isLoading && !state.isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Refresh notification history") + } + } + SettingsCard { + Text("Recent notifications", style = MaterialTheme.typography.titleSmall) + if (state.notificationsHistory.isEmpty()) { + Text("No server notifications yet.", color = MaterialTheme.colorScheme.onSurfaceVariant) + } else { + state.notificationsHistory.take(20).forEach { notification -> + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerHighest) + .padding(horizontal = 10.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = notification.eventType, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = notification.text ?: notification.payloadRaw, + style = MaterialTheme.typography.bodySmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "chat=${notification.chatId ?: "-"} • msg=${notification.messageId ?: "-"} • ${notification.createdAt}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } } } diff --git a/docs/android-checklist.md b/docs/android-checklist.md index 9d1d3ed..1fe835a 100644 --- a/docs/android-checklist.md +++ b/docs/android-checklist.md @@ -56,10 +56,10 @@ - [x] Forward в 1+ чатов - [x] Reactions - [x] Delivery/read states -- [ ] Text formatting parity with web: +- [x] Text formatting parity with web: - bold / italic / underline / strikethrough - spoiler / monospace / code block / links - - composer shortcuts/toolbar behavior + - composer toolbar behavior (mobile-first) ## 8. Медиа и вложения - [x] Upload image/video/file/audio @@ -98,6 +98,8 @@ - [x] Deep links: open chat/message - [x] Mention override для muted чатов - [x] DataStore настройки уведомлений (global + per-chat override) +- [x] Server notifications inbox in settings (`GET /notifications`) +- [x] Push-token lifecycle sync (`POST/DELETE /notifications/push-token`) with dedupe per user/token ## 13. UI/UX и темы - [x] Светлая/темная тема (читаемая) diff --git a/docs/backend-web-android-parity.md b/docs/backend-web-android-parity.md index 0c815da..4ed1f8d 100644 --- a/docs/backend-web-android-parity.md +++ b/docs/backend-web-android-parity.md @@ -18,28 +18,21 @@ Backend покрывает web-функционал почти полность ## 2) Web endpoints not yet fully used on Android - `GET /api/v1/messages/{message_id}/thread` (data layer wired, UI thread screen/jump usage pending) -- `GET /api/v1/notifications` -- `POST /api/v1/notifications/push-token` -- `DELETE /api/v1/notifications/push-token` -- `POST /api/v1/auth/resend-verification` ## 2.1) Web feature parity gaps not yet covered on Android -- Text formatting in composer/render is not at web parity yet: - - bold / italic / underline / strikethrough - - spoiler / monospace / code block / links - - shortcut/toolbar UX similar to web composer +- Notification delivery polish is still partial: + - chat-level grouping/snooze parity with web prefs (full) + - richer per-chat override UX alignment in Android settings ## 3) Practical status - Backend readiness vs Web: `high` -- Android parity vs Web (feature-level): `~82-87%` +- Android parity vs Web (feature-level): `~87-91%` ## 4) Highest-priority Android parity step Завершить следующий parity-блок: - `GET /api/v1/messages/{message_id}/thread` (UI usage) -- notifications API + UI inbox flow -- notifications delivery polish (channels/grouping/snooze/per-chat overrides parity with web prefs) -- text formatting parity in chat composer/render +- notification delivery polish (channels/grouping/snooze/per-chat overrides parity with web prefs)