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 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`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
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>>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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] Светлая/темная тема (читаемая)
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user