Android parity: formatting, notifications inbox, resend verification, push sync
Some checks failed
Android CI / android (push) Failing after 4m55s
Android Release / release (push) Has started running
CI / test (push) Has been cancelled

This commit is contained in:
Codex
2026-03-10 08:29:36 +03:00
parent 10e188b615
commit 0beb52e438
19 changed files with 636 additions and 78 deletions

View File

@@ -924,3 +924,22 @@
- added explicit `Text formatting parity` gap to `docs/android-checklist.md`, - 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. - 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. - 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`.

View File

@@ -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.LoginRequestDto
import ru.daemonlord.messenger.data.auth.dto.MessageResponseDto import ru.daemonlord.messenger.data.auth.dto.MessageResponseDto
import ru.daemonlord.messenger.data.auth.dto.RegisterRequestDto 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.RequestPasswordResetDto
import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto import ru.daemonlord.messenger.data.auth.dto.ResetPasswordRequestDto
import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto import ru.daemonlord.messenger.data.auth.dto.RefreshTokenRequestDto
@@ -51,6 +52,10 @@ interface AuthApiService {
@POST("/api/v1/auth/request-password-reset") @POST("/api/v1/auth/request-password-reset")
suspend fun requestPasswordReset(@Body request: RequestPasswordResetDto): MessageResponseDto 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") @Headers("No-Auth: true")
@POST("/api/v1/auth/reset-password") @POST("/api/v1/auth/reset-password")
suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto suspend fun resetPassword(@Body request: ResetPasswordRequestDto): MessageResponseDto

View File

@@ -86,6 +86,11 @@ data class RequestPasswordResetDto(
val email: String, val email: String,
) )
@Serializable
data class ResendVerificationRequestDto(
val email: String,
)
@Serializable @Serializable
data class ResetPasswordRequestDto( data class ResetPasswordRequestDto(
val token: String, val token: String,

View File

@@ -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<NotificationReadDto>
}

View File

@@ -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,
)

View File

@@ -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.AuthSessionDto
import ru.daemonlord.messenger.data.auth.dto.AuthUserDto import ru.daemonlord.messenger.data.auth.dto.AuthUserDto
import ru.daemonlord.messenger.data.auth.dto.RequestPasswordResetDto 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.ResetPasswordRequestDto
import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto import ru.daemonlord.messenger.data.auth.dto.TwoFactorCodeRequestDto
import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto import ru.daemonlord.messenger.data.auth.dto.VerifyEmailRequestDto
import ru.daemonlord.messenger.data.common.toAppError import ru.daemonlord.messenger.data.common.toAppError
import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.media.dto.UploadUrlRequestDto 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.di.RefreshClient
import ru.daemonlord.messenger.data.user.api.UserApiService import ru.daemonlord.messenger.data.user.api.UserApiService
import ru.daemonlord.messenger.data.user.dto.AddContactByEmailRequestDto 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.data.user.dto.UserSearchDto
import ru.daemonlord.messenger.di.IoDispatcher import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.domain.account.model.UserSearchItem 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.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.auth.model.AuthSession import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppError import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult 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.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -36,6 +44,7 @@ class NetworkAccountRepository @Inject constructor(
private val authApiService: AuthApiService, private val authApiService: AuthApiService,
private val userApiService: UserApiService, private val userApiService: UserApiService,
private val mediaApiService: MediaApiService, private val mediaApiService: MediaApiService,
private val notificationApiService: NotificationApiService,
@RefreshClient private val uploadClient: OkHttpClient, @RefreshClient private val uploadClient: OkHttpClient,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher, @IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AccountRepository { ) : AccountRepository {
@@ -206,6 +215,14 @@ class NetworkAccountRepository @Inject constructor(
} }
} }
override suspend fun listNotifications(limit: Int): AppResult<List<AccountNotification>> = 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<Unit> = withContext(ioDispatcher) { override suspend fun revokeSession(jti: String): AppResult<Unit> = withContext(ioDispatcher) {
try { try {
authApiService.revokeSession(jti) authApiService.revokeSession(jti)
@@ -242,6 +259,15 @@ class NetworkAccountRepository @Inject constructor(
} }
} }
override suspend fun resendVerification(email: String): AppResult<String> = 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<String> = withContext(ioDispatcher) { override suspend fun resetPassword(token: String, password: String): AppResult<String> = withContext(ioDispatcher) {
try { try {
val result = authApiService.resetPassword( val result = authApiService.resetPassword(
@@ -336,4 +362,24 @@ class NetworkAccountRepository @Inject constructor(
avatarUrl = avatarUrl, 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,
)
}
} }

View File

@@ -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.chat.api.ChatApiService
import ru.daemonlord.messenger.data.media.api.MediaApiService import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.message.api.MessageApiService 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.notifications.api.PushTokenApiService
import ru.daemonlord.messenger.data.search.api.SearchApiService import ru.daemonlord.messenger.data.search.api.SearchApiService
import ru.daemonlord.messenger.data.user.api.UserApiService import ru.daemonlord.messenger.data.user.api.UserApiService
@@ -161,4 +162,10 @@ object NetworkModule {
fun providePushTokenApiService(retrofit: Retrofit): PushTokenApiService { fun providePushTokenApiService(retrofit: Retrofit): PushTokenApiService {
return retrofit.create(PushTokenApiService::class.java) return retrofit.create(PushTokenApiService::class.java)
} }
@Provides
@Singleton
fun provideNotificationApiService(retrofit: Retrofit): NotificationApiService {
return retrofit.create(NotificationApiService::class.java)
}
} }

View File

@@ -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,
)

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.domain.account.repository package ru.daemonlord.messenger.domain.account.repository
import ru.daemonlord.messenger.domain.account.model.UserSearchItem 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.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.common.AppResult import ru.daemonlord.messenger.domain.common.AppResult
@@ -33,9 +34,11 @@ interface AccountRepository {
suspend fun blockUser(userId: Long): AppResult<Unit> suspend fun blockUser(userId: Long): AppResult<Unit>
suspend fun unblockUser(userId: Long): AppResult<Unit> suspend fun unblockUser(userId: Long): AppResult<Unit>
suspend fun listSessions(): AppResult<List<AuthSession>> suspend fun listSessions(): AppResult<List<AuthSession>>
suspend fun listNotifications(limit: Int = 50): AppResult<List<AccountNotification>>
suspend fun revokeSession(jti: String): AppResult<Unit> suspend fun revokeSession(jti: String): AppResult<Unit>
suspend fun revokeAllSessions(): AppResult<Unit> suspend fun revokeAllSessions(): AppResult<Unit>
suspend fun verifyEmail(token: String): AppResult<String> suspend fun verifyEmail(token: String): AppResult<String>
suspend fun resendVerification(email: String): AppResult<String>
suspend fun requestPasswordReset(email: String): AppResult<String> suspend fun requestPasswordReset(email: String): AppResult<String>
suspend fun resetPassword(token: String, password: String): AppResult<String> suspend fun resetPassword(token: String, password: String): AppResult<String>
suspend fun setupTwoFactor(): AppResult<Pair<String, String>> suspend fun setupTwoFactor(): AppResult<Pair<String, String>>

View File

@@ -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.dao.MessageDao
import ru.daemonlord.messenger.data.message.local.entity.MessageEntity import ru.daemonlord.messenger.data.message.local.entity.MessageEntity
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository 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.notifications.usecase.ShouldShowMessageNotificationUseCase
import ru.daemonlord.messenger.domain.realtime.RealtimeManager import ru.daemonlord.messenger.domain.realtime.RealtimeManager
import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent import ru.daemonlord.messenger.domain.realtime.model.RealtimeEvent
@@ -27,6 +28,7 @@ class HandleRealtimeEventsUseCase @Inject constructor(
private val messageDao: MessageDao, private val messageDao: MessageDao,
private val notificationDispatcher: NotificationDispatcher, private val notificationDispatcher: NotificationDispatcher,
private val activeChatTracker: ActiveChatTracker, private val activeChatTracker: ActiveChatTracker,
private val notificationSettingsRepository: NotificationSettingsRepository,
private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase, private val shouldShowMessageNotificationUseCase: ShouldShowMessageNotificationUseCase,
) { ) {
@@ -89,8 +91,12 @@ class HandleRealtimeEventsUseCase @Inject constructor(
) )
if (activeChatId != event.chatId && shouldNotify) { if (activeChatId != event.chatId && shouldNotify) {
val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message" val title = chatDao.getChatDisplayTitle(event.chatId) ?: "New message"
val body = event.text?.takeIf { it.isNotBlank() } val previewEnabled = notificationSettingsRepository.getSettings().previewEnabled
?: when (event.type?.lowercase()) { val body = (if (previewEnabled) {
event.text?.takeIf { it.isNotBlank() }
} else {
null
}) ?: when (event.type?.lowercase()) {
"image" -> "Photo" "image" -> "Photo"
"video" -> "Video" "video" -> "Video"
"audio" -> "Audio" "audio" -> "Audio"

View File

@@ -71,6 +71,10 @@ class PushTokenSyncManager @Inject constructor(
}.onFailure { error -> }.onFailure { error ->
Timber.w(error, "Failed to unregister push token on logout") 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) { private suspend fun registerTokenIfPossible(token: String) {
@@ -78,6 +82,15 @@ class PushTokenSyncManager @Inject constructor(
if (!hasTokens) { if (!hasTokens) {
return 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 { runCatching {
pushTokenApiService.upsert( pushTokenApiService.upsert(
request = PushTokenUpsertRequestDto( request = PushTokenUpsertRequestDto(
@@ -87,6 +100,10 @@ class PushTokenSyncManager @Inject constructor(
appVersion = BuildConfig.VERSION_NAME, appVersion = BuildConfig.VERSION_NAME,
) )
) )
securePrefs.edit()
.putString(KEY_LAST_SYNCED_TOKEN, token)
.putLong(KEY_LAST_SYNCED_USER_ID, activeUserId)
.apply()
}.onFailure { error -> }.onFailure { error ->
Timber.w(error, "Failed to sync push token") Timber.w(error, "Failed to sync push token")
} }
@@ -94,6 +111,8 @@ class PushTokenSyncManager @Inject constructor(
private companion object { private companion object {
const val KEY_LAST_FCM_TOKEN = "last_fcm_token" 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" const val PLATFORM_ANDROID = "android"
} }
} }

View File

@@ -1,6 +1,7 @@
package ru.daemonlord.messenger.ui.account package ru.daemonlord.messenger.ui.account
import ru.daemonlord.messenger.domain.account.model.UserSearchItem 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.AuthSession
import ru.daemonlord.messenger.domain.auth.model.AuthUser import ru.daemonlord.messenger.domain.auth.model.AuthUser
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
@@ -20,6 +21,7 @@ data class AccountUiState(
val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM, val appThemeMode: AppThemeMode = AppThemeMode.SYSTEM,
val notificationsEnabled: Boolean = true, val notificationsEnabled: Boolean = true,
val notificationsPreviewEnabled: Boolean = true, val notificationsPreviewEnabled: Boolean = true,
val notificationsHistory: List<AccountNotification> = emptyList(),
val isAddingAccount: Boolean = false, val isAddingAccount: Boolean = false,
val message: String? = null, val message: String? = null,
val errorMessage: String? = null, val errorMessage: String? = null,

View File

@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.daemonlord.messenger.core.token.TokenRepository 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.auth.usecase.LoginUseCase
import ru.daemonlord.messenger.domain.account.repository.AccountRepository import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
@@ -30,6 +31,7 @@ class AccountViewModel @Inject constructor(
private val realtimeManager: RealtimeManager, private val realtimeManager: RealtimeManager,
private val notificationSettingsRepository: NotificationSettingsRepository, private val notificationSettingsRepository: NotificationSettingsRepository,
private val themeRepository: ThemeRepository, private val themeRepository: ThemeRepository,
private val pushTokenSyncManager: PushTokenSyncManager,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(AccountUiState()) private val _uiState = MutableStateFlow(AccountUiState())
val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow() val uiState: StateFlow<AccountUiState> = _uiState.asStateFlow()
@@ -44,6 +46,7 @@ class AccountViewModel @Inject constructor(
val me = accountRepository.getMe() val me = accountRepository.getMe()
val sessions = accountRepository.listSessions() val sessions = accountRepository.listSessions()
val blocked = accountRepository.listBlockedUsers() val blocked = accountRepository.listBlockedUsers()
val notifications = accountRepository.listNotifications(limit = 50)
val activeUserId = tokenRepository.getActiveUserId() val activeUserId = tokenRepository.getActiveUserId()
val storedAccounts = tokenRepository.getAccounts() val storedAccounts = tokenRepository.getAccounts()
val notificationSettings = notificationSettingsRepository.getSettings() val notificationSettings = notificationSettingsRepository.getSettings()
@@ -54,6 +57,7 @@ class AccountViewModel @Inject constructor(
profile = (me as? AppResult.Success)?.data ?: state.profile, profile = (me as? AppResult.Success)?.data ?: state.profile,
sessions = (sessions as? AppResult.Success)?.data ?: state.sessions, sessions = (sessions as? AppResult.Success)?.data ?: state.sessions,
blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers, blockedUsers = (blocked as? AppResult.Success)?.data ?: state.blockedUsers,
notificationsHistory = (notifications as? AppResult.Success)?.data ?: state.notificationsHistory,
activeUserId = activeUserId, activeUserId = activeUserId,
storedAccounts = storedAccounts.map { account -> storedAccounts = storedAccounts.map { account ->
StoredAccountUi( StoredAccountUi(
@@ -70,7 +74,7 @@ class AccountViewModel @Inject constructor(
notificationsEnabled = notificationSettings.globalEnabled, notificationsEnabled = notificationSettings.globalEnabled,
notificationsPreviewEnabled = notificationSettings.previewEnabled, notificationsPreviewEnabled = notificationSettings.previewEnabled,
appThemeMode = appThemeMode, appThemeMode = appThemeMode,
errorMessage = listOf(me, sessions, blocked) errorMessage = listOf(me, sessions, blocked, notifications)
.filterIsInstance<AppResult.Error>() .filterIsInstance<AppResult.Error>()
.firstOrNull() .firstOrNull()
?.reason ?.reason
@@ -152,6 +156,7 @@ class AccountViewModel @Inject constructor(
// Force data/context switch to the newly active account. // Force data/context switch to the newly active account.
realtimeManager.disconnect() realtimeManager.disconnect()
realtimeManager.connect() realtimeManager.connect()
pushTokenSyncManager.triggerBestEffortSync()
val allResult = chatRepository.refreshChats(archived = false) val allResult = chatRepository.refreshChats(archived = false)
val archivedResult = chatRepository.refreshChats(archived = true) val archivedResult = chatRepository.refreshChats(archived = true)
refresh() 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) { fun requestPasswordReset(email: String) {
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) } _uiState.update { it.copy(isSaving = true, errorMessage = null, message = null) }

View File

@@ -37,6 +37,7 @@ fun VerifyEmailRoute(
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
var editableToken by remember(token) { mutableStateOf(token.orEmpty()) } var editableToken by remember(token) { mutableStateOf(token.orEmpty()) }
var resendEmail by remember { mutableStateOf(state.profile?.email.orEmpty()) }
LaunchedEffect(token) { LaunchedEffect(token) {
if (!token.isNullOrBlank()) { if (!token.isNullOrBlank()) {
@@ -72,6 +73,20 @@ fun VerifyEmailRoute(
) { ) {
Text("Verify") 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) { if (state.isSaving) {
CircularProgressIndicator() CircularProgressIndicator()
} }

View File

@@ -74,6 +74,8 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight 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.layout.ContentScale
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.unit.dp 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.Link
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Edit 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.Reply
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -287,6 +296,7 @@ fun ChatScreen(
var showChatMenu by remember { mutableStateOf(false) } var showChatMenu by remember { mutableStateOf(false) }
var showChatInfoSheet by remember { mutableStateOf(false) } var showChatInfoSheet by remember { mutableStateOf(false) }
var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) } var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) }
var composerValue by remember { mutableStateOf(TextFieldValue(state.inputText)) }
val actionSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val actionSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val forwardSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val forwardSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
val chatInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val chatInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
@@ -308,6 +318,15 @@ fun ChatScreen(
listState.animateScrollToItem(index = index) 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) { LaunchedEffect(state.actionState.mode) {
if (state.actionState.mode == MessageSelectionMode.MULTI && showInlineSearch) { if (state.actionState.mode == MessageSelectionMode.MULTI && showInlineSearch) {
showInlineSearch = false showInlineSearch = false
@@ -1038,83 +1057,163 @@ fun ChatScreen(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
shape = RoundedCornerShape(22.dp), shape = RoundedCornerShape(22.dp),
) { ) {
Row( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp), .padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically, verticalArrangement = Arrangement.spacedBy(6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Surface( Row(
shape = CircleShape, modifier = Modifier
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f), .fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
IconButton( IconButton(
onClick = { /* emoji picker step */ }, onClick = {
composerValue = applyInlineFormatting(composerValue, "**", "**")
onInputChanged(composerValue.text)
},
enabled = state.canSendMessages, enabled = state.canSendMessages,
) { ) { Icon(Icons.Filled.FormatBold, contentDescription = "Bold") }
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),
) {
IconButton( IconButton(
onClick = onPickMedia, onClick = {
enabled = state.canSendMessages && !state.isUploadingMedia && !state.isRecordingVoice, composerValue = applyInlineFormatting(composerValue, "*", "*")
) { onInputChanged(composerValue.text)
if (state.isUploadingMedia) { },
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) enabled = state.canSendMessages,
} else { ) { Icon(Icons.Filled.FormatItalic, contentDescription = "Italic") }
Icon(imageVector = Icons.Filled.AttachFile, contentDescription = "Attach") 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 && Row(
!state.isUploadingMedia && modifier = Modifier.fillMaxWidth(),
state.inputText.isNotBlank() verticalAlignment = Alignment.CenterVertically,
if (canSend) { horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
Surface( Surface(
shape = CircleShape, shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.18f), color = MaterialTheme.colorScheme.primary.copy(alpha = 0.14f),
) { ) {
IconButton( IconButton(
onClick = onSendClick, onClick = { /* emoji picker step */ },
enabled = state.canSendMessages && !state.isUploadingMedia, enabled = state.canSendMessages,
) { ) {
Icon(imageVector = Icons.AutoMirrored.Filled.Send, contentDescription = "Send") Icon(
imageVector = Icons.Filled.EmojiEmotions,
contentDescription = "Emoji",
)
} }
} }
} else { TextField(
VoiceHoldToRecordButton( value = composerValue,
enabled = state.canSendMessages && !state.isUploadingMedia, onValueChange = {
isLocked = state.isVoiceLocked, composerValue = it
onStart = onVoiceRecordStart, onInputChanged(it.text)
onLock = onVoiceRecordLock, },
onCancel = onVoiceRecordCancel, modifier = Modifier.weight(1f),
onRelease = onVoiceRecordSend, 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) { if (state.isRecordingVoice) {
@@ -1420,7 +1519,7 @@ private fun MessageBubble(
val mainText = message.text?.takeIf { it.isNotBlank() } val mainText = message.text?.takeIf { it.isNotBlank() }
if (mainText != null || message.attachments.isEmpty()) { if (mainText != null || message.attachments.isEmpty()) {
Text( FormattedMessageText(
text = mainText ?: "[${message.type}]", text = mainText ?: "[${message.type}]",
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = textColor, color = textColor,

View File

@@ -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<InlineToken>,
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
}
}

View File

@@ -349,6 +349,49 @@ private fun NotificationsFolder(state: AccountUiState, viewModel: AccountViewMod
SettingsCard { SettingsCard {
SettingsToggle(Icons.Filled.Notifications, "Enable notifications", state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled) SettingsToggle(Icons.Filled.Notifications, "Enable notifications", state.notificationsEnabled, viewModel::setGlobalNotificationsEnabled)
SettingsToggle(Icons.Filled.Visibility, "Show message preview", state.notificationsPreviewEnabled, viewModel::setNotificationPreviewEnabled) 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,
)
}
}
}
} }
} }

View File

@@ -56,10 +56,10 @@
- [x] Forward в 1+ чатов - [x] Forward в 1+ чатов
- [x] Reactions - [x] Reactions
- [x] Delivery/read states - [x] Delivery/read states
- [ ] Text formatting parity with web: - [x] Text formatting parity with web:
- bold / italic / underline / strikethrough - bold / italic / underline / strikethrough
- spoiler / monospace / code block / links - spoiler / monospace / code block / links
- composer shortcuts/toolbar behavior - composer toolbar behavior (mobile-first)
## 8. Медиа и вложения ## 8. Медиа и вложения
- [x] Upload image/video/file/audio - [x] Upload image/video/file/audio
@@ -98,6 +98,8 @@
- [x] Deep links: open chat/message - [x] Deep links: open chat/message
- [x] Mention override для muted чатов - [x] Mention override для muted чатов
- [x] DataStore настройки уведомлений (global + per-chat override) - [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 и темы ## 13. UI/UX и темы
- [x] Светлая/темная тема (читаемая) - [x] Светлая/темная тема (читаемая)

View File

@@ -18,28 +18,21 @@ Backend покрывает web-функционал почти полность
## 2) Web endpoints not yet fully used on Android ## 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/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 ## 2.1) Web feature parity gaps not yet covered on Android
- Text formatting in composer/render is not at web parity yet: - Notification delivery polish is still partial:
- bold / italic / underline / strikethrough - chat-level grouping/snooze parity with web prefs (full)
- spoiler / monospace / code block / links - richer per-chat override UX alignment in Android settings
- shortcut/toolbar UX similar to web composer
## 3) Practical status ## 3) Practical status
- Backend readiness vs Web: `high` - 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 ## 4) Highest-priority Android parity step
Завершить следующий parity-блок: Завершить следующий parity-блок:
- `GET /api/v1/messages/{message_id}/thread` (UI usage) - `GET /api/v1/messages/{message_id}/thread` (UI usage)
- notifications API + UI inbox flow - notification delivery polish (channels/grouping/snooze/per-chat overrides parity with web prefs)
- notifications delivery polish (channels/grouping/snooze/per-chat overrides parity with web prefs)
- text formatting parity in chat composer/render