Android parity: formatting, notifications inbox, resend verification, push sync
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,6 +86,11 @@ data class RequestPasswordResetDto(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResendVerificationRequestDto(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ResetPasswordRequestDto(
|
||||
val token: String,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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>>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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] Светлая/темная тема (читаемая)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user