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 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`.

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.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

View File

@@ -86,6 +86,11 @@ data class RequestPasswordResetDto(
val email: String,
)
@Serializable
data class ResendVerificationRequestDto(
val email: String,
)
@Serializable
data class ResetPasswordRequestDto(
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.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<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) {
try {
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) {
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,
)
}
}

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.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)
}
}

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
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<Unit>
suspend fun unblockUser(userId: Long): AppResult<Unit>
suspend fun listSessions(): AppResult<List<AuthSession>>
suspend fun listNotifications(limit: Int = 50): AppResult<List<AccountNotification>>
suspend fun revokeSession(jti: String): AppResult<Unit>
suspend fun revokeAllSessions(): AppResult<Unit>
suspend fun verifyEmail(token: String): AppResult<String>
suspend fun resendVerification(email: String): AppResult<String>
suspend fun requestPasswordReset(email: String): AppResult<String>
suspend fun resetPassword(token: String, password: String): AppResult<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.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"

View File

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

View File

@@ -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<AccountNotification> = emptyList(),
val isAddingAccount: Boolean = false,
val message: 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.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<AccountUiState> = _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<AppResult.Error>()
.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) }

View File

@@ -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()
}

View File

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

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 {
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,
)
}
}
}
}
}

View File

@@ -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] Светлая/темная тема (читаемая)

View File

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