Релиз 1.1.5: ускорено подключение и исправлены группы
All checks were successful
Android Kernel Build / build (push) Successful in 16h12m4s

This commit is contained in:
2026-03-09 20:20:54 +05:00
23 changed files with 1888 additions and 289 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.1.4"
val rosettaVersionCode = 16 // Increment on each release
val rosettaVersionName = "1.1.5"
val rosettaVersionCode = 17 // Increment on each release
android {
namespace = "com.rosetta.messenger"

View File

@@ -496,6 +496,7 @@ private fun EncryptedAccount.toAccountInfo(): AccountInfo {
*/
sealed class Screen {
data object Profile : Screen()
data object ProfileFromChat : Screen()
data object Requests : Screen()
data object Search : Screen()
data object GroupSetup : Screen()
@@ -600,6 +601,9 @@ fun MainScreen(
// Derived visibility — only triggers recomposition when THIS screen changes
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
val isProfileFromChatVisible by remember {
derivedStateOf { navStack.any { it is Screen.ProfileFromChat } }
}
val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } }
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } }
@@ -662,11 +666,18 @@ fun MainScreen(
navStack = navStack.dropLast(1)
}
fun openOwnProfile() {
navStack =
val filteredStack =
navStack.filterNot {
it is Screen.ChatDetail || it is Screen.OtherProfile || it is Screen.GroupInfo
}
pushScreen(Screen.Profile)
// Single state update avoids intermediate frame (chat list flash/jitter) when opening
// profile from a mention inside chat.
navStack =
if (filteredStack.lastOrNull() == Screen.Profile) {
filteredStack
} else {
filteredStack + Screen.Profile
}
}
fun popProfileAndChildren() {
navStack =
@@ -1003,8 +1014,9 @@ fun MainScreen(
onBack = { popChatAndChildren() },
onUserProfileClick = { user ->
if (isCurrentAccountUser(user)) {
// Свой профиль открываем My Profile
openOwnProfile()
// Свой профиль из чата открываем поверх текущего чата,
// чтобы возврат оставался в этот чат, а не в chat list.
pushScreen(Screen.ProfileFromChat)
} else {
// Открываем профиль другого пользователя
pushScreen(Screen.OtherProfile(user))
@@ -1028,6 +1040,39 @@ fun MainScreen(
}
}
SwipeBackContainer(
isVisible = isProfileFromChatVisible,
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } },
isDarkTheme = isDarkTheme,
layer = 1,
propagateBackgroundProgress = false
) {
ProfileScreen(
isDarkTheme = isDarkTheme,
accountName = accountName,
accountUsername = accountUsername,
accountVerified = accountVerified,
accountPublicKey = accountPublicKey,
accountPrivateKeyHash = privateKeyHash,
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } },
onSaveProfile = { name, username ->
accountName = name
accountUsername = username
mainScreenScope.launch { onAccountInfoUpdated() }
},
onLogout = onLogout,
onNavigateToTheme = { pushScreen(Screen.Theme) },
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
onNavigateToSafety = { pushScreen(Screen.Safety) },
onNavigateToLogs = { pushScreen(Screen.Logs) },
onNavigateToBiometric = { pushScreen(Screen.Biometric) },
viewModel = profileViewModel,
avatarRepository = avatarRepository,
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
backgroundBlurColorId = backgroundBlurColorId
)
}
var isGroupInfoSwipeEnabled by remember { mutableStateOf(true) }
LaunchedEffect(selectedGroup?.publicKey) {
isGroupInfoSwipeEnabled = true

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.data
import android.content.Context
import android.util.Log
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.database.GroupEntity
import com.rosetta.messenger.database.MessageEntity
@@ -23,6 +24,7 @@ import kotlin.coroutines.resume
class GroupRepository private constructor(context: Context) {
private val appContext = context.applicationContext
private val db = RosettaDatabase.getDatabase(context.applicationContext)
private val groupDao = db.groupDao()
private val messageDao = db.messageDao()
@@ -31,9 +33,11 @@ class GroupRepository private constructor(context: Context) {
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
companion object {
private const val TAG = "GroupRepository"
private const val GROUP_PREFIX = "#group:"
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
private const val GROUP_CREATED_MARKER = "\$a=Group created"
@Volatile
private var INSTANCE: GroupRepository? = null
@@ -232,7 +236,20 @@ class GroupRepository private constructor(context: Context) {
return GroupJoinResult(success = false, error = "Failed to construct invite")
}
return joinGroup(accountPublicKey, accountPrivateKey, invite)
val joinResult = joinGroup(accountPublicKey, accountPrivateKey, invite)
if (joinResult.success) {
val dialogPublicKey = joinResult.dialogPublicKey
if (!dialogPublicKey.isNullOrBlank()) {
emitGroupCreatedMarker(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
dialogPublicKey = dialogPublicKey
)
}
}
return joinResult
}
suspend fun joinGroup(
@@ -455,6 +472,23 @@ class GroupRepository private constructor(context: Context) {
)
}
private suspend fun emitGroupCreatedMarker(
accountPublicKey: String,
accountPrivateKey: String,
dialogPublicKey: String
) {
try {
val messages = MessageRepository.getInstance(appContext)
messages.initialize(accountPublicKey, accountPrivateKey)
messages.sendMessage(
toPublicKey = dialogPublicKey,
text = GROUP_CREATED_MARKER
)
} catch (e: Exception) {
Log.w(TAG, "Failed to emit group-created marker for sync visibility", e)
}
}
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey)
return "group:$encrypted"

View File

@@ -764,10 +764,13 @@ class MessageRepository private constructor(private val context: Context) {
groupKey
)
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
// 📸 Обрабатываем AVATAR attachments:
// в личке — сохраняем аватар отправителя, в группе — аватар группы (desktop parity)
val avatarOwnerKey =
if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey
processAvatarAttachments(
packet.attachments,
packet.fromPublicKey,
avatarOwnerKey,
packet.chachaKey,
privateKey,
plainKeyAndNonce,
@@ -1176,6 +1179,11 @@ class MessageRepository private constructor(private val context: Context) {
}
}
private fun toGroupDialogPublicKey(value: String): String {
val groupId = normalizeGroupId(value)
return if (groupId.isBlank()) value.trim() else "#group:$groupId"
}
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
}
@@ -1510,7 +1518,7 @@ class MessageRepository private constructor(private val context: Context) {
*/
private suspend fun processAvatarAttachments(
attachments: List<MessageAttachment>,
fromPublicKey: String,
avatarOwnerKey: String,
encryptedKey: String,
privateKey: String,
plainKeyAndNonce: ByteArray? = null,
@@ -1540,18 +1548,18 @@ class MessageRepository private constructor(private val context: Context) {
if (decryptedBlob != null) {
// 2. Сохраняем аватар в кэш
val filePath =
AvatarFileManager.saveAvatar(context, decryptedBlob, fromPublicKey)
AvatarFileManager.saveAvatar(context, decryptedBlob, avatarOwnerKey)
val entity =
AvatarCacheEntity(
publicKey = fromPublicKey,
publicKey = avatarOwnerKey,
avatar = filePath,
timestamp = System.currentTimeMillis()
)
avatarDao.insertAvatar(entity)
// 3. Очищаем старые аватары (оставляем последние 5)
avatarDao.deleteOldAvatars(fromPublicKey, 5)
avatarDao.deleteOldAvatars(avatarOwnerKey, 5)
} else {}
} catch (e: Exception) {}
}

View File

@@ -17,20 +17,17 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Профиль и группы
- Фиксированные табы в профиле и группах
- Fast-scroll с отображением даты в медиа-галерее
- Поддержка Apple Emoji в аватарах и интерфейсе
- Восстановление ключей шифрования группы по инвайт-ссылке
Подключение
- Ускорен старт соединения и handshake при входе в аккаунт
- Логика reconnect синхронизирована с desktop-поведением
- Обновлён серверный endpoint на основной production (wss)
Аватары
- Улучшено отображение аватаров: поддержка текста с эмодзи
- Улучшена логика отображения в компоненте AvatarImage
Группы
- Добавлено предзагруженное кэширование участников группы
- Убран скачок "0 members" при повторном открытии группы
Исправления
- Исправлен переход по своему тэгу в группах
- Убрана лишняя подсветка в чатах
- Корректное отображение fast-scroll при изменении размера экрана
Интерфейс
- Исправлено вертикальное выравнивание verified-галочки в списке чатов
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -42,12 +42,18 @@ interface AvatarDao {
*/
@Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC")
fun getAvatars(publicKey: String): Flow<List<AvatarCacheEntity>>
@Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC")
fun getAvatarsByKeys(publicKeys: List<String>): Flow<List<AvatarCacheEntity>>
/**
* Получить последний аватар пользователя
*/
@Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC LIMIT 1")
suspend fun getLatestAvatar(publicKey: String): AvatarCacheEntity?
@Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC LIMIT 1")
suspend fun getLatestAvatarByKeys(publicKeys: List<String>): AvatarCacheEntity?
/**
* Получить последний аватар пользователя как Flow

View File

@@ -12,7 +12,9 @@ data class FileDownloadState(
val fileName: String,
val status: FileDownloadStatus,
/** 0f..1f */
val progress: Float = 0f
val progress: Float = 0f,
val accountPublicKey: String = "",
val savedPath: String = ""
)
enum class FileDownloadStatus {
@@ -84,17 +86,27 @@ object FileDownloadManager {
downloadTag: String,
chachaKey: String,
privateKey: String,
accountPublicKey: String,
fileName: String,
savedFile: File
) {
// Уже в процессе?
if (jobs[attachmentId]?.isActive == true) return
val normalizedAccount = accountPublicKey.trim()
val savedPath = savedFile.absolutePath
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f)
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath)
jobs[attachmentId] = scope.launch {
try {
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f)
update(
attachmentId,
fileName,
FileDownloadStatus.DOWNLOADING,
0f,
normalizedAccount,
savedPath
)
// Запускаем polling прогресса из TransportManager
val progressJob = launch {
@@ -103,34 +115,87 @@ object FileDownloadManager {
if (entry != null) {
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
val p = (entry.progress / 100f) * 0.8f
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, p)
update(
attachmentId,
fileName,
FileDownloadStatus.DOWNLOADING,
p,
normalizedAccount,
savedPath
)
}
}
}
val success = withContext(Dispatchers.IO) {
if (isGroupStoredKey(chachaKey)) {
downloadGroupFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
downloadGroupFile(
attachmentId = attachmentId,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
fileName = fileName,
savedFile = savedFile,
accountPublicKey = normalizedAccount,
savedPath = savedPath
)
} else {
downloadDirectFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
downloadDirectFile(
attachmentId = attachmentId,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
fileName = fileName,
savedFile = savedFile,
accountPublicKey = normalizedAccount,
savedPath = savedPath
)
}
}
progressJob.cancel()
if (success) {
update(attachmentId, fileName, FileDownloadStatus.DONE, 1f)
update(
attachmentId,
fileName,
FileDownloadStatus.DONE,
1f,
normalizedAccount,
savedPath
)
} else {
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
e.printStackTrace()
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} catch (_: OutOfMemoryError) {
System.gc()
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} finally {
jobs.remove(attachmentId)
// Автоочистка через 5 секунд после завершения
@@ -159,25 +224,55 @@ object FileDownloadManager {
chachaKey: String,
privateKey: String,
fileName: String,
savedFile: File
savedFile: File,
accountPublicKey: String,
savedPath: String
): Boolean {
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.82f)
update(
attachmentId,
fileName,
FileDownloadStatus.DECRYPTING,
0.82f,
accountPublicKey,
savedPath
)
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) return false
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
update(
attachmentId,
fileName,
FileDownloadStatus.DECRYPTING,
0.88f,
accountPublicKey,
savedPath
)
val bytes = decrypted?.let { decodeBase64Payload(it) } ?: return false
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.93f)
update(
attachmentId,
fileName,
FileDownloadStatus.DECRYPTING,
0.93f,
accountPublicKey,
savedPath
)
withContext(Dispatchers.IO) {
savedFile.parentFile?.mkdirs()
savedFile.writeBytes(bytes)
}
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
update(
attachmentId,
fileName,
FileDownloadStatus.DECRYPTING,
0.98f,
accountPublicKey,
savedPath
)
return true
}
@@ -187,14 +282,30 @@ object FileDownloadManager {
chachaKey: String,
privateKey: String,
fileName: String,
savedFile: File
savedFile: File,
accountPublicKey: String,
savedPath: String
): Boolean {
// Streaming: скачиваем во temp file
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.83f)
update(
attachmentId,
fileName,
FileDownloadStatus.DECRYPTING,
0.83f,
accountPublicKey,
savedPath
)
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
update(
attachmentId,
fileName,
FileDownloadStatus.DECRYPTING,
0.88f,
accountPublicKey,
savedPath
)
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
withContext(Dispatchers.IO) {
@@ -208,13 +319,36 @@ object FileDownloadManager {
tempFile.delete()
}
}
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
update(
attachmentId,
fileName,
FileDownloadStatus.DECRYPTING,
0.98f,
accountPublicKey,
savedPath
)
return true
}
private fun update(id: String, fileName: String, status: FileDownloadStatus, progress: Float) {
private fun update(
id: String,
fileName: String,
status: FileDownloadStatus,
progress: Float,
accountPublicKey: String,
savedPath: String
) {
_downloads.update { map ->
map + (id to FileDownloadState(id, fileName, status, progress))
map + (
id to FileDownloadState(
attachmentId = id,
fileName = fileName,
status = status,
progress = progress,
accountPublicKey = accountPublicKey,
savedPath = savedPath
)
)
}
}
}

View File

@@ -29,9 +29,10 @@ object ProtocolManager {
private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L
private const val MAX_DEBUG_LOGS = 600
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
// Server address - same as React Native version
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
// Desktop parity: use the same primary WebSocket endpoint as desktop client.
private const val SERVER_ADDRESS = "wss://wss.rosetta.im"
private const val DEVICE_PREFS = "rosetta_protocol"
private const val DEVICE_ID_KEY = "device_id"
private const val DEVICE_ID_LENGTH = 128
@@ -59,6 +60,9 @@ object ProtocolManager {
// Typing status
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
private val typingStateLock = Any()
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
// Connected devices and pending verification requests
private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList())
@@ -200,6 +204,7 @@ object ProtocolManager {
*/
fun initializeAccount(publicKey: String, privateKey: String) {
setSyncInProgress(false)
clearTypingState()
messageRepository?.initialize(publicKey, privateKey)
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
resyncRequiredAfterAccountInit = false
@@ -369,13 +374,26 @@ object ProtocolManager {
// Обработчик typing (0x0B)
waitPacket(0x0B) { packet ->
val typingPacket = packet as PacketTyping
// Добавляем в set и удаляем через 3 секунды
_typingUsers.value = _typingUsers.value + typingPacket.fromPublicKey
scope.launch {
delay(3000)
_typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey
val fromPublicKey = typingPacket.fromPublicKey.trim()
val toPublicKey = typingPacket.toPublicKey.trim()
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return@waitPacket
val ownPublicKey =
getProtocol().getPublicKey()?.trim().orEmpty().ifBlank {
messageRepository?.getCurrentAccountKey()?.trim().orEmpty()
}
if (ownPublicKey.isNotBlank() && fromPublicKey.equals(ownPublicKey, ignoreCase = true)) {
return@waitPacket
}
val dialogKey =
resolveTypingDialogKey(
fromPublicKey = fromPublicKey,
toPublicKey = toPublicKey,
ownPublicKey = ownPublicKey
) ?: return@waitPacket
rememberTypingEvent(dialogKey, fromPublicKey)
}
// 📱 Обработчик списка устройств (0x17)
@@ -508,6 +526,71 @@ object ProtocolManager {
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private fun normalizeGroupDialogKey(value: String): String {
val trimmed = value.trim()
val normalized = trimmed.lowercase(Locale.ROOT)
return when {
normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}"
normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}"
else -> trimmed
}
}
private fun resolveTypingDialogKey(
fromPublicKey: String,
toPublicKey: String,
ownPublicKey: String
): String? {
return when {
isGroupDialogKey(toPublicKey) -> normalizeGroupDialogKey(toPublicKey)
ownPublicKey.isNotBlank() && toPublicKey.equals(ownPublicKey, ignoreCase = true) ->
fromPublicKey.trim()
else -> null
}
}
private fun makeTypingTimeoutKey(dialogKey: String, fromPublicKey: String): String {
return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}"
}
private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) {
val normalizedDialogKey =
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
val normalizedFrom = fromPublicKey.trim()
if (normalizedDialogKey.isBlank() || normalizedFrom.isBlank()) return
synchronized(typingStateLock) {
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
users.add(normalizedFrom)
_typingUsers.value = typingUsersByDialog.keys.toSet()
}
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
typingTimeoutJobs.remove(timeoutKey)?.cancel()
typingTimeoutJobs[timeoutKey] =
scope.launch {
delay(TYPING_INDICATOR_TIMEOUT_MS)
synchronized(typingStateLock) {
val users = typingUsersByDialog[normalizedDialogKey]
users?.remove(normalizedFrom)
if (users.isNullOrEmpty()) {
typingUsersByDialog.remove(normalizedDialogKey)
}
_typingUsers.value = typingUsersByDialog.keys.toSet()
}
typingTimeoutJobs.remove(timeoutKey)
}
}
private fun clearTypingState() {
typingTimeoutJobs.values.forEach { it.cancel() }
typingTimeoutJobs.clear()
synchronized(typingStateLock) {
typingUsersByDialog.clear()
_typingUsers.value = emptySet()
}
}
private fun onAuthenticated() {
setSyncInProgress(false)
TransportManager.requestTransportServer()
@@ -1021,6 +1104,7 @@ object ProtocolManager {
protocol?.disconnect()
protocol?.clearCredentials()
messageRepository?.clearInitialization()
clearTypingState()
_devices.value = emptyList()
_pendingDeviceVerification.value = null
syncRequestInFlight = false
@@ -1036,6 +1120,7 @@ object ProtocolManager {
protocol?.destroy()
protocol = null
messageRepository?.clearInitialization()
clearTypingState()
_devices.value = emptyList()
_pendingDeviceVerification.value = null
syncRequestInFlight = false

View File

@@ -163,11 +163,8 @@ class AuthStateManager(
// Step 8: Connect and authenticate with protocol
ProtocolManager.connect()
// Give WebSocket time to connect before authenticating
kotlinx.coroutines.delay(500)
ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash)
ProtocolManager.reconnectNowIfNeeded("auth_state_create")
Result.success(decryptedAccount)
} catch (e: Exception) {
@@ -210,11 +207,8 @@ class AuthStateManager(
// Connect and authenticate with protocol
ProtocolManager.connect()
// Give WebSocket time to connect before authenticating
kotlinx.coroutines.delay(500)
ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash)
ProtocolManager.reconnectNowIfNeeded("auth_state_unlock")
Result.success(decryptedAccount)
} catch (e: Exception) {

View File

@@ -53,6 +53,36 @@ class AvatarRepository(
return false
}
}
private fun normalizeOwnerKey(publicKey: String): String {
val trimmed = publicKey.trim()
if (trimmed.isBlank()) return trimmed
return when {
trimmed.startsWith("#group:") -> {
val groupId = trimmed.removePrefix("#group:").trim()
if (groupId.isBlank()) trimmed else "#group:$groupId"
}
trimmed.startsWith("group:", ignoreCase = true) -> {
val groupId = trimmed.substringAfter(':').trim()
if (groupId.isBlank()) trimmed else "#group:$groupId"
}
else -> trimmed
}
}
private fun lookupKeys(publicKey: String): List<String> {
val normalized = normalizeOwnerKey(publicKey)
if (normalized.isBlank()) return emptyList()
val keys = linkedSetOf(normalized)
if (normalized.startsWith("#group:")) {
keys.add(normalized.removePrefix("#group:"))
}
val trimmed = publicKey.trim()
if (trimmed.isNotBlank()) {
keys.add(trimmed)
}
return keys.toList()
}
/**
* Получить аватары пользователя
@@ -60,14 +90,20 @@ class AvatarRepository(
* @param allDecode true = вся история, false = только последний (для списков)
*/
fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow<List<AvatarInfo>> {
val normalizedKey = normalizeOwnerKey(publicKey)
val keys = lookupKeys(publicKey)
if (normalizedKey.isBlank() || keys.isEmpty()) {
return MutableStateFlow(emptyList<AvatarInfo>()).asStateFlow()
}
// Проверяем LRU cache (accessOrder=true обновляет позицию при get)
memoryCache[publicKey]?.let { return it.flow.asStateFlow() }
memoryCache[normalizedKey]?.let { return it.flow.asStateFlow() }
// Создаем новый flow для этого пользователя
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
// Подписываемся на изменения в БД
val job = avatarDao.getAvatars(publicKey)
val job = avatarDao.getAvatarsByKeys(keys)
.onEach { entities ->
val avatars = if (allDecode) {
// Параллельная загрузка всей истории
@@ -86,7 +122,7 @@ class AvatarRepository(
}
.launchIn(repositoryScope)
memoryCache[publicKey] = CacheEntry(flow, job)
memoryCache[normalizedKey] = CacheEntry(flow, job)
return flow.asStateFlow()
}
@@ -94,7 +130,9 @@ class AvatarRepository(
* Получить последний аватар пользователя (suspend версия)
*/
suspend fun getLatestAvatar(publicKey: String): AvatarInfo? {
val entity = avatarDao.getLatestAvatar(publicKey) ?: return null
val keys = lookupKeys(publicKey)
if (keys.isEmpty()) return null
val entity = avatarDao.getLatestAvatarByKeys(keys) ?: return null
return loadAndDecryptAvatar(entity)
}
@@ -108,22 +146,24 @@ class AvatarRepository(
suspend fun saveAvatar(fromPublicKey: String, base64Image: String) {
withContext(Dispatchers.IO) {
try {
val ownerKey = normalizeOwnerKey(fromPublicKey)
if (ownerKey.isBlank()) return@withContext
// Сохраняем файл
val filePath = AvatarFileManager.saveAvatar(context, base64Image, fromPublicKey)
val filePath = AvatarFileManager.saveAvatar(context, base64Image, ownerKey)
// Сохраняем в БД
val entity = AvatarCacheEntity(
publicKey = fromPublicKey,
publicKey = ownerKey,
avatar = filePath,
timestamp = System.currentTimeMillis()
)
avatarDao.insertAvatar(entity)
// Очищаем старые аватары (оставляем только последние N)
avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY)
avatarDao.deleteOldAvatars(ownerKey, MAX_AVATAR_HISTORY)
// 🔄 Обновляем memory cache если он существует
val cached = memoryCache[fromPublicKey]
val cached = memoryCache[ownerKey]
if (cached != null) {
val avatarInfo = loadAndDecryptAvatar(entity)
if (avatarInfo != null) {

View File

@@ -2,20 +2,24 @@ package com.rosetta.messenger.ui.auth
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
internal fun startAuthHandshakeFast(publicKey: String, privateKeyHash: String) {
// Desktop parity: start connection+handshake immediately, without artificial waits.
ProtocolManager.connect()
ProtocolManager.authenticate(publicKey, privateKeyHash)
ProtocolManager.reconnectNowIfNeeded("auth_fast_start")
}
internal suspend fun awaitAuthHandshakeState(
publicKey: String,
privateKeyHash: String,
attempts: Int = 2,
timeoutMs: Long = 25_000L
): ProtocolState? {
repeat(attempts) {
ProtocolManager.disconnect()
delay(200)
ProtocolManager.authenticate(publicKey, privateKeyHash)
repeat(attempts) { attempt ->
startAuthHandshakeFast(publicKey, privateKeyHash)
val state = withTimeoutOrNull(timeoutMs) {
ProtocolManager.state.first {
@@ -26,6 +30,7 @@ internal suspend fun awaitAuthHandshakeState(
if (state != null) {
return state
}
ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
}
return null
}

View File

@@ -527,17 +527,10 @@ fun SetPasswordScreen(
keyPair.privateKey
)
val handshakeState =
awaitAuthHandshakeState(
keyPair.publicKey,
privateKeyHash
)
if (handshakeState == null) {
error =
"Failed to connect to server. Please try again."
isCreating = false
return@launch
}
startAuthHandshakeFast(
keyPair.publicKey,
privateKeyHash
)
accountManager.setCurrentAccount(keyPair.publicKey)

View File

@@ -116,12 +116,7 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
name = selectedAccount.name
)
val handshakeState = awaitAuthHandshakeState(account.publicKey, privateKeyHash)
if (handshakeState == null) {
onError("Failed to connect to server")
onUnlocking(false)
return
}
startAuthHandshakeFast(account.publicKey, privateKeyHash)
accountManager.setCurrentAccount(account.publicKey)
onSuccess(decryptedAccount)

View File

@@ -2,12 +2,19 @@ package com.rosetta.messenger.ui.chats
import android.app.Activity
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Shader
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.view.Gravity
import android.view.View
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
@@ -55,6 +62,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner
@@ -63,8 +71,10 @@ import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -107,6 +117,26 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private data class IncomingRunAvatarAccumulator(
val senderPublicKey: String,
val senderDisplayName: String,
var minTopPx: Float,
var maxBottomPx: Float
)
private data class IncomingRunAvatarOverlay(
val runHeadIndex: Int,
val senderPublicKey: String,
val senderDisplayName: String,
val topPx: Float
)
private data class IncomingRunAvatarUiState(
val showOnRunHeads: Set<Int>,
val showOnRunTails: Set<Int>,
val overlays: List<IncomingRunAvatarOverlay>
)
@OptIn(
ExperimentalMaterial3Api::class,
androidx.compose.foundation.ExperimentalFoundationApi::class,
@@ -153,6 +183,7 @@ fun ChatDetailScreen(
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val dateHeaderTextColor = if (isDarkTheme) Color.White else secondaryTextColor
val headerIconColor = Color.White
// 🔥 Keyboard & Emoji Coordinator
@@ -186,6 +217,10 @@ fun ChatDetailScreen(
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty()
// После long press AndroidView текста может прислать tap на отпускание.
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
// 💬 MESSAGE CONTEXT MENU STATE
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
@@ -210,13 +245,22 @@ fun ChatDetailScreen(
}
}
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
val hideKeyboardAndBack: () -> Unit = {
// Используем нативный InputMethodManager для МГНОВЕННОГО закрытия
val hideInputOverlays: () -> Unit = {
val imm =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
window?.let { win ->
androidx.core.view.WindowCompat.getInsetsController(win, view)
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
}
keyboardController?.hide()
focusManager.clearFocus(force = true)
showEmojiPicker = false
}
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
val hideKeyboardAndBack: () -> Unit = {
hideInputOverlays()
onBack()
}
@@ -228,10 +272,7 @@ fun ChatDetailScreen(
else user.title.ifEmpty { user.publicKey.take(10) }
val openDialogInfo: () -> Unit = {
val imm =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
hideInputOverlays()
showContextMenu = false
contextMenuMessage = null
if (isGroupChat) {
@@ -424,6 +465,9 @@ fun ChatDetailScreen(
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet())
}
var groupMembersCount by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Int?>(null)
}
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<List<MentionCandidate>>(emptyList())
}
@@ -438,6 +482,7 @@ fun ChatDetailScreen(
LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) {
if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) {
groupAdminKeys = emptySet()
groupMembersCount = null
mentionCandidates = emptyList()
return@LaunchedEffect
}
@@ -445,15 +490,20 @@ fun ChatDetailScreen(
val members = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(user.publicKey).orEmpty()
}
val adminKey = members.firstOrNull().orEmpty()
val normalizedMembers =
members.map { it.trim() }
.filter { it.isNotBlank() }
.distinct()
groupMembersCount = normalizedMembers.size
val adminKey = normalizedMembers.firstOrNull().orEmpty()
groupAdminKeys =
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
mentionCandidates =
withContext(Dispatchers.IO) {
members.map { it.trim() }
.filter { it.isNotBlank() && !it.equals(currentUserPublicKey.trim(), ignoreCase = true) }
.distinct()
normalizedMembers
.filter { !it.equals(currentUserPublicKey.trim(), ignoreCase = true) }
.mapNotNull { memberKey ->
val resolvedUser = viewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null
val normalizedUsername = resolvedUser.username.trim().trimStart('@')
@@ -522,6 +572,31 @@ fun ChatDetailScreen(
}
}
// Long press должен только включать selection для сообщения (идемпотентно),
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
{ messageId, canSelect ->
if (canSelect && !selectedMessages.contains(messageId)) {
selectedMessages = selectedMessages + messageId
}
}
val suppressTapAfterLongPress: (messageId: String) -> Unit =
{ messageId ->
longPressSuppressedMessageId = messageId
longPressSuppressUntilMs = System.currentTimeMillis() + 350L
}
val shouldIgnoreTapAfterLongPress: (messageId: String) -> Boolean =
{ messageId ->
val now = System.currentTimeMillis()
val isSuppressed =
longPressSuppressedMessageId == messageId &&
now <= longPressSuppressUntilMs
if (isSuppressed || now > longPressSuppressUntilMs) {
longPressSuppressedMessageId = null
}
isSuppressed
}
// 🔥 ПАГИНАЦИЯ: Загружаем старые сообщения при прокрутке вверх
// NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true
// автоматически сохраняет позицию благодаря стабильным ключам (key = message.id)
@@ -595,7 +670,250 @@ fun ChatDetailScreen(
// <20>🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
// (dedup + sort + date headers off the main thread)
val messagesWithDates by viewModel.messagesWithDates.collectAsState()
val resolveSenderPublicKey: (ChatMessage?) -> String =
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
{ msg ->
when {
msg == null -> ""
msg.senderPublicKey.isNotBlank() -> msg.senderPublicKey.trim()
msg.isOutgoing -> currentUserPublicKey.trim()
isGroupChat -> ""
else -> user.publicKey.trim()
}
}
}
val resolveSenderIdentity: (ChatMessage?) -> String =
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
{ msg ->
when {
msg == null -> ""
msg.isOutgoing ->
"out:${currentUserPublicKey.trim().lowercase(Locale.ROOT)}"
msg.senderPublicKey.isNotBlank() ->
"in:${msg.senderPublicKey.trim().lowercase(Locale.ROOT)}"
isGroupChat && msg.senderName.isNotBlank() ->
"name:${msg.senderName.trim().lowercase(Locale.ROOT)}"
!isGroupChat ->
"in:${user.publicKey.trim().lowercase(Locale.ROOT)}"
else -> ""
}
}
}
val isMessageBoundary: (ChatMessage, ChatMessage?) -> Boolean =
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
{ currentMessage, adjacentMessage ->
if (adjacentMessage == null) {
true
} else if (adjacentMessage.isOutgoing != currentMessage.isOutgoing) {
true
} else if (
kotlin.math.abs(
currentMessage.timestamp.time -
adjacentMessage.timestamp.time
) > 60_000L
) {
true
} else if (
isGroupChat &&
!currentMessage.isOutgoing &&
!adjacentMessage.isOutgoing
) {
val currentSenderIdentity =
resolveSenderIdentity(currentMessage)
val adjacentSenderIdentity =
resolveSenderIdentity(adjacentMessage)
if (
currentSenderIdentity.isBlank() ||
adjacentSenderIdentity.isBlank()
) {
true
} else {
!currentSenderIdentity.equals(
adjacentSenderIdentity,
ignoreCase = true
)
}
} else {
false
}
}
}
val messageRunNewestIndex =
remember(messagesWithDates, isGroupChat, currentUserPublicKey, user.publicKey) {
IntArray(messagesWithDates.size).also { runHeadByIndex ->
messagesWithDates.indices.forEach { messageIndex ->
if (messageIndex == 0) {
runHeadByIndex[messageIndex] = messageIndex
} else {
val currentMessage = messagesWithDates[messageIndex].first
val newerMessage = messagesWithDates[messageIndex - 1].first
runHeadByIndex[messageIndex] =
if (isMessageBoundary(currentMessage, newerMessage)) {
messageIndex
} else {
runHeadByIndex[messageIndex - 1]
}
}
}
}
}
val messageRunOldestIndexByHead =
remember(messageRunNewestIndex) {
IntArray(messageRunNewestIndex.size) { it }.also { runTailByHead ->
messageRunNewestIndex.indices.forEach { messageIndex ->
val runHeadIndex = messageRunNewestIndex[messageIndex]
runTailByHead[runHeadIndex] = messageIndex
}
}
}
val density = LocalDensity.current
val incomingRunAvatarSize = 42.dp
val incomingRunAvatarInsetStart = 6.dp
val incomingRunAvatarTopGuard = 4.dp
val incomingRunAvatarUiState by remember(
isGroupChat,
messageRunNewestIndex,
messageRunOldestIndexByHead,
messagesWithDates,
listState,
density
) {
derivedStateOf {
if (!isGroupChat || messagesWithDates.isEmpty()) {
return@derivedStateOf IncomingRunAvatarUiState(
showOnRunHeads = emptySet(),
showOnRunTails = emptySet(),
overlays = emptyList()
)
}
val layoutInfo = listState.layoutInfo
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) {
return@derivedStateOf IncomingRunAvatarUiState(
showOnRunHeads = emptySet(),
showOnRunTails = emptySet(),
overlays = emptyList()
)
}
val avatarSizePx = with(density) { incomingRunAvatarSize.toPx() }
val topGuardPx = with(density) { incomingRunAvatarTopGuard.toPx() }
val viewportStart = layoutInfo.viewportStartOffset.toFloat()
val viewportEnd = layoutInfo.viewportEndOffset.toFloat()
val maxAvatarTop = viewportEnd - avatarSizePx
val visibleIndexSet = visibleItems.map { it.index }.toHashSet()
val visibleRuns = linkedMapOf<Int, IncomingRunAvatarAccumulator>()
visibleItems.forEach { itemInfo ->
val visibleIndex = itemInfo.index
if (visibleIndex !in messagesWithDates.indices) {
return@forEach
}
val message = messagesWithDates[visibleIndex].first
if (message.isOutgoing) {
return@forEach
}
val senderPublicKey = resolveSenderPublicKey(message).trim()
if (senderPublicKey.isBlank()) {
return@forEach
}
val runHeadIndex =
messageRunNewestIndex.getOrNull(visibleIndex)
?: visibleIndex
val itemTopPx = itemInfo.offset.toFloat()
val itemBottomPx = (itemInfo.offset + itemInfo.size).toFloat()
val currentRun = visibleRuns[runHeadIndex]
if (currentRun == null) {
visibleRuns[runHeadIndex] =
IncomingRunAvatarAccumulator(
senderPublicKey = senderPublicKey,
senderDisplayName =
message.senderName.ifBlank {
senderPublicKey
},
minTopPx = itemTopPx,
maxBottomPx = itemBottomPx
)
} else {
if (itemTopPx < currentRun.minTopPx) {
currentRun.minTopPx = itemTopPx
}
if (itemBottomPx > currentRun.maxBottomPx) {
currentRun.maxBottomPx = itemBottomPx
}
}
}
val showOnRunHeads = hashSetOf<Int>()
val showOnRunTails = hashSetOf<Int>()
val overlays = arrayListOf<IncomingRunAvatarOverlay>()
visibleRuns.forEach { (runHeadIndex, runData) ->
val runTailIndex =
messageRunOldestIndexByHead.getOrNull(runHeadIndex)
?: runHeadIndex
val isRunHeadVisible = visibleIndexSet.contains(runHeadIndex)
val isRunTailVisible = visibleIndexSet.contains(runTailIndex)
when {
isRunHeadVisible -> {
// Start/default phase: keep avatar on the lower (newest) bubble.
showOnRunHeads.add(runHeadIndex)
}
isRunTailVisible -> {
// End phase: keep avatar attached to the last bubble in run.
showOnRunTails.add(runHeadIndex)
}
else -> {
// Middle phase: floating avatar while scrolling through run.
var avatarTopPx =
kotlin.math.min(
runData.maxBottomPx - avatarSizePx,
maxAvatarTop
)
val topClampPx = runData.minTopPx + topGuardPx
if (avatarTopPx < topClampPx) {
avatarTopPx = topClampPx
}
if (
avatarTopPx + avatarSizePx > viewportStart &&
avatarTopPx < viewportEnd
) {
overlays +=
IncomingRunAvatarOverlay(
runHeadIndex = runHeadIndex,
senderPublicKey =
runData.senderPublicKey,
senderDisplayName =
runData.senderDisplayName,
topPx = avatarTopPx
)
}
}
}
}
IncomingRunAvatarUiState(
showOnRunHeads = showOnRunHeads,
showOnRunTails = showOnRunTails,
overlays = overlays.sortedBy { it.topPx }
)
}
}
val openProfileByPublicKey: (String) -> Unit = { rawPublicKey ->
val normalizedPublicKey = rawPublicKey.trim()
if (normalizedPublicKey.isNotBlank()) {
scope.launch {
val resolvedUser =
viewModel.resolveUserForProfile(normalizedPublicKey)
if (resolvedUser != null) {
showContextMenu = false
contextMenuMessage = null
onUserProfileClick(resolvedUser)
}
}
}
}
// 🔥 Функция для скролла к сообщению с подсветкой
val scrollToMessage: (String) -> Unit = { messageId ->
scope.launch {
@@ -635,10 +953,13 @@ fun ChatDetailScreen(
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
user.username.equals("rosetta", ignoreCase = true) ||
isSystemAccount
val groupMembersSubtitleCount = groupMembersCount ?: 0
val groupMembersSubtitle =
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}"
val chatSubtitle =
when {
isSavedMessages -> "Notes"
isGroupChat -> "group"
isGroupChat -> groupMembersSubtitle
isTyping -> "" // Пустая строка, используем компонент TypingIndicator
isOnline -> "online"
isSystemAccount -> "official account"
@@ -721,6 +1042,15 @@ fun ChatDetailScreen(
.maxWithOrNull(compareBy<ChatMessage>({ it.timestamp.time }, { it.id }))
?.id
var lastNewestMessageId by remember { mutableStateOf<String?>(null) }
val isAtBottom by remember(listState) {
derivedStateOf {
listState.firstVisibleItemIndex == 0 &&
listState.firstVisibleItemScrollOffset <= 12
}
}
val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) {
derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage }
}
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
// 🔥 Скроллим только если изменился ID самого нового сообщения
@@ -730,10 +1060,15 @@ fun ChatDetailScreen(
lastNewestMessageId != null &&
newestMessageId != lastNewestMessageId
) {
// Новое сообщение пришло - скроллим вниз
delay(50) // Debounce - ждём стабилизации
listState.animateScrollToItem(0)
wasManualScroll = false
val newestMessage = messages.firstOrNull { it.id == newestMessageId }
val isOwnOutgoingMessage = newestMessage?.isOutgoing == true
val shouldAutoScroll = isAtBottom || isOwnOutgoingMessage
if (shouldAutoScroll) {
delay(50) // Debounce - ждём стабилизации
listState.animateScrollToItem(0)
wasManualScroll = false
}
}
lastNewestMessageId = newestMessageId
}
@@ -1141,7 +1476,8 @@ fun ChatDetailScreen(
color = Color.White,
maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false
enableLinks = false,
minHeightMultiplier = 1.1f
)
if (!isSavedMessages &&
!isGroupChat &&
@@ -1226,12 +1562,8 @@ fun ChatDetailScreen(
Box {
IconButton(
onClick = {
// Закрываем
// клавиатуру перед открытием меню
keyboardController
?.hide()
focusManager
.clearFocus()
// Закрываем клавиатуру/emoji перед открытием меню
hideInputOverlays()
showMenu =
true
},
@@ -1277,6 +1609,7 @@ fun ChatDetailScreen(
onGroupInfoClick = {
showMenu =
false
hideInputOverlays()
onGroupInfoClick(
user
)
@@ -1284,6 +1617,7 @@ fun ChatDetailScreen(
onSearchMembersClick = {
showMenu =
false
hideInputOverlays()
onGroupInfoClick(
user
)
@@ -1847,11 +2181,10 @@ fun ChatDetailScreen(
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
// when content paddings (bottom bar/IME) change.
if (chatWallpaperResId != null) {
Image(
painter = painterResource(id = chatWallpaperResId),
contentDescription = "Chat wallpaper",
TiledChatWallpaper(
wallpaperResId = chatWallpaperResId,
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop
tileScale = 0.9f
)
} else {
Box(
@@ -1998,7 +2331,7 @@ fun ChatDetailScreen(
}
}
// Есть сообщения
else ->
else -> {
LazyColumn(
state = listState,
modifier =
@@ -2050,36 +2383,6 @@ fun ChatDetailScreen(
index,
(message, showDate)
->
// Определяем,
// показывать ли
// хвостик
// (последнее
// сообщение в
// группе)
val nextMessage =
messagesWithDates
.getOrNull(
index +
1
)
?.first
val showTail =
nextMessage ==
null ||
nextMessage
.isOutgoing !=
message.isOutgoing ||
(message.timestamp
.time -
nextMessage
.timestamp
.time) >
60_000
// Определяем начало
// новой
// группы (для
// отступов)
val prevMessage =
messagesWithDates
.getOrNull(
@@ -2087,18 +2390,59 @@ fun ChatDetailScreen(
1
)
?.first
val nextMessage =
messagesWithDates
.getOrNull(
index +
1
)
?.first
val senderPublicKeyForMessage =
resolveSenderPublicKey(
message
)
// Для reverseLayout + DESC списка:
// prev = более новое сообщение,
// next = более старое.
val showTail =
isMessageBoundary(message, prevMessage)
val isGroupStart =
prevMessage !=
null &&
(prevMessage
.isOutgoing !=
message.isOutgoing ||
(prevMessage
.timestamp
.time -
message.timestamp
.time) >
60_000)
isMessageBoundary(message, nextMessage)
val runHeadIndex =
messageRunNewestIndex.getOrNull(
index
) ?: index
val runTailIndex =
messageRunOldestIndexByHead
.getOrNull(
runHeadIndex
)
?: runHeadIndex
val isHeadPhase =
incomingRunAvatarUiState
.showOnRunHeads
.contains(
runHeadIndex
)
val isTailPhase =
incomingRunAvatarUiState
.showOnRunTails
.contains(
runHeadIndex
)
val showIncomingGroupAvatar =
isGroupChat &&
!message.isOutgoing &&
senderPublicKeyForMessage
.isNotBlank() &&
((index ==
runHeadIndex &&
isHeadPhase &&
showTail) ||
(index ==
runTailIndex &&
isTailPhase &&
isGroupStart))
Column {
if (showDate
@@ -2110,19 +2454,11 @@ fun ChatDetailScreen(
.time
),
secondaryTextColor =
secondaryTextColor
dateHeaderTextColor
)
}
val selectionKey =
message.id
val senderPublicKeyForMessage =
if (message.senderPublicKey.isNotBlank()) {
message.senderPublicKey
} else if (message.isOutgoing) {
currentUserPublicKey
} else {
user.publicKey
}
MessageBubble(
message =
message,
@@ -2134,6 +2470,8 @@ fun ChatDetailScreen(
isSelectionMode,
showTail =
showTail,
showIncomingGroupAvatar =
showIncomingGroupAvatar,
isGroupStart =
isGroupStart,
isSelected =
@@ -2154,9 +2492,12 @@ fun ChatDetailScreen(
message.senderName,
isGroupChat =
isGroupChat,
dialogPublicKey =
user.publicKey,
showGroupSenderLabel =
isGroupChat &&
!message.isOutgoing,
!message.isOutgoing &&
isGroupStart,
isGroupSenderAdmin =
isGroupChat &&
senderPublicKeyForMessage
@@ -2203,12 +2544,21 @@ fun ChatDetailScreen(
.clearFocus()
showEmojiPicker =
false
toggleMessageSelection(
selectMessageOnLongPress(
selectionKey,
true
)
suppressTapAfterLongPress(
selectionKey
)
},
onClick = {
if (shouldIgnoreTapAfterLongPress(
selectionKey
)
) {
return@MessageBubble
}
val hasAvatar =
message.attachments
.any {
@@ -2306,6 +2656,12 @@ fun ChatDetailScreen(
true
)
},
onAvatarClick = {
avatarOwnerPublicKey ->
openProfileByPublicKey(
avatarOwnerPublicKey
)
},
onForwardedSenderClick = { senderPublicKey ->
// Open profile of the forwarded message sender
scope.launch {
@@ -2461,10 +2817,109 @@ fun ChatDetailScreen(
}
}
}
if (incomingRunAvatarUiState.overlays.isNotEmpty()) {
val avatarInsetPx =
with(density) {
incomingRunAvatarInsetStart
.roundToPx()
}
Box(
modifier =
Modifier.matchParentSize()
.graphicsLayer {
clip = true
}
) {
incomingRunAvatarUiState.overlays.forEach { overlay ->
key(
overlay.runHeadIndex,
overlay.senderPublicKey
) {
Box(
modifier =
Modifier.offset {
IntOffset(
avatarInsetPx,
overlay.topPx
.toInt()
)
}
) {
AvatarImage(
publicKey =
overlay.senderPublicKey,
avatarRepository =
avatarRepository,
size =
incomingRunAvatarSize,
isDarkTheme =
isDarkTheme,
onClick =
if (isSelectionMode) null
else {
{
openProfileByPublicKey(
overlay.senderPublicKey
)
}
},
displayName =
overlay.senderDisplayName
)
}
}
}
}
}
}
}
}
} // Конец Column внутри Scaffold content
AnimatedVisibility(
visible = showScrollToBottomButton && !isLoading && !isSelectionMode,
enter = fadeIn(animationSpec = tween(140)) + expandVertically(expandFrom = Alignment.Bottom),
exit = fadeOut(animationSpec = tween(120)) + shrinkVertically(shrinkTowards = Alignment.Bottom),
modifier =
Modifier.align(Alignment.BottomEnd)
.padding(
end = 14.dp,
bottom = if (isSystemAccount) 24.dp else 16.dp
)
) {
Box(
modifier =
Modifier.size(38.dp)
.clip(CircleShape)
.background(
if (isDarkTheme) Color(0xFF2D2E31)
else Color.White
)
.clickable(
indication = null,
interactionSource =
remember {
MutableInteractionSource()
}
) {
scope.launch {
listState.animateScrollToItem(0)
wasManualScroll = false
}
},
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.ChevronDown,
contentDescription = "Scroll to bottom",
tint =
if (isDarkTheme) Color(0xFFF2F2F3)
else Color(0xFF2D3138),
modifier = Modifier.size(21.dp)
)
}
}
// 📎 Media Picker — new tab-based ChatAttachAlert (Telegram-style)
// Feature flag: set USE_NEW_ATTACH_ALERT to false to use old MediaPickerBottomSheet
val USE_NEW_ATTACH_ALERT = true
@@ -2944,3 +3399,62 @@ fun ChatDetailScreen(
} // Закрытие outer Box
}
@Composable
private fun TiledChatWallpaper(
wallpaperResId: Int,
modifier: Modifier = Modifier,
tileScale: Float = 0.9f
) {
val context = LocalContext.current
val wallpaperDrawable =
remember(wallpaperResId, tileScale, context) {
val decoded = BitmapFactory.decodeResource(context.resources, wallpaperResId)
val normalizedScale = tileScale.coerceIn(0.2f, 2f)
val scaledBitmap =
decoded?.let { original ->
if (normalizedScale == 1f) {
original
} else {
val width =
(original.width * normalizedScale)
.toInt()
.coerceAtLeast(1)
val height =
(original.height * normalizedScale)
.toInt()
.coerceAtLeast(1)
val scaled =
Bitmap.createScaledBitmap(
original,
width,
height,
true
)
if (scaled != original) {
original.recycle()
}
scaled
}
}
val safeBitmap =
scaledBitmap
?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
.apply {
eraseColor(android.graphics.Color.TRANSPARENT)
}
BitmapDrawable(context.resources, safeBitmap).apply {
setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
gravity = Gravity.TOP or Gravity.START
}
}
AndroidView(
modifier = modifier,
factory = { ctx -> View(ctx).apply { background = wallpaperDrawable } },
update = { view -> view.background = wallpaperDrawable }
)
}

View File

@@ -242,9 +242,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔥 Сохраняем ссылки на обработчики для очистки в onCleared()
// ВАЖНО: Должны быть определены ДО init блока!
private val typingPacketHandler: (Packet) -> Unit = { packet ->
private val typingPacketHandler: (Packet) -> Unit = typingPacketHandler@{ packet ->
val typingPacket = packet as PacketTyping
if (typingPacket.fromPublicKey == opponentKey) {
val currentDialog = opponentKey?.trim().orEmpty()
val currentAccount = myPublicKey?.trim().orEmpty()
if (currentDialog.isBlank() || currentAccount.isBlank()) {
return@typingPacketHandler
}
val fromPublicKey = typingPacket.fromPublicKey.trim()
val toPublicKey = typingPacket.toPublicKey.trim()
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) {
return@typingPacketHandler
}
if (fromPublicKey.equals(currentAccount, ignoreCase = true)) {
return@typingPacketHandler
}
val shouldShowTyping =
if (isGroupDialogKey(currentDialog)) {
normalizeGroupId(toPublicKey).equals(normalizeGroupId(currentDialog), ignoreCase = true)
} else {
fromPublicKey.equals(currentDialog, ignoreCase = true) &&
toPublicKey.equals(currentAccount, ignoreCase = true)
}
if (shouldShowTyping) {
showTypingIndicator()
}
}
@@ -4163,7 +4186,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
// 📁 Для Saved Messages - не отправляем typing indicator
if (opponent == sender || isGroupDialogKey(opponent)) {
if (opponent.equals(sender, ignoreCase = true)) {
return
}

View File

@@ -192,6 +192,28 @@ private fun isGroupDialogKey(value: String): Boolean {
return normalized.startsWith("#group:") || normalized.startsWith("group:")
}
private fun normalizeGroupDialogKey(value: String): String {
val trimmed = value.trim()
val normalized = trimmed.lowercase(Locale.ROOT)
return when {
normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}"
normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}"
else -> trimmed
}
}
private fun isTypingForDialog(dialogKey: String, typingDialogs: Set<String>): Boolean {
if (typingDialogs.isEmpty()) return false
if (isGroupDialogKey(dialogKey)) {
val normalizedDialogKey = normalizeGroupDialogKey(dialogKey)
return typingDialogs.any {
isGroupDialogKey(it) &&
normalizeGroupDialogKey(it).equals(normalizedDialogKey, ignoreCase = true)
}
}
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
}
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -442,9 +464,29 @@ fun ChatsListScreen(
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// <EFBFBD> Active downloads tracking (for header indicator)
val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState()
val hasActiveDownloads = activeDownloads.isNotEmpty()
// 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads)
val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() }
val allFileDownloads by
com.rosetta.messenger.network.FileDownloadManager.downloads.collectAsState()
val accountFileDownloads = remember(allFileDownloads, currentAccountKey) {
allFileDownloads.values
.filter {
it.accountPublicKey.equals(currentAccountKey, ignoreCase = true)
}
.sortedByDescending { it.progress }
}
val activeFileDownloads = remember(accountFileDownloads) {
accountFileDownloads.filter {
it.status == com.rosetta.messenger.network.FileDownloadStatus.QUEUED ||
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.DOWNLOADING ||
it.status ==
com.rosetta.messenger.network.FileDownloadStatus
.DECRYPTING
}
}
val hasActiveDownloads = activeFileDownloads.isNotEmpty()
// <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState()
@@ -473,6 +515,7 @@ fun ChatsListScreen(
// 📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) }
var showDownloadsScreen by remember { mutableStateOf(false) }
var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) }
var isRequestsRouteTapLocked by remember { mutableStateOf(false) }
val inlineRequestsTransitionLockMs = 340L
@@ -498,6 +541,10 @@ fun ChatsListScreen(
}
}
LaunchedEffect(currentAccountKey) {
showDownloadsScreen = false
}
// 📂 Accounts section expanded state (arrow toggle)
var accountsSectionExpanded by remember { mutableStateOf(false) }
@@ -535,8 +582,10 @@ fun ChatsListScreen(
// Back: drawer → закрыть, selection → сбросить
// Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно
BackHandler(enabled = isSelectionMode || drawerState.isOpen) {
if (isSelectionMode) {
BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) {
if (showDownloadsScreen) {
showDownloadsScreen = false
} else if (isSelectionMode) {
selectedChatKeys = emptySet()
} else if (drawerState.isOpen) {
scope.launch { drawerState.close() }
@@ -709,7 +758,7 @@ fun ChatsListScreen(
) {
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = !showRequestsScreen,
gesturesEnabled = !showRequestsScreen && !showDownloadsScreen,
drawerContent = {
ModalDrawerSheet(
drawerContainerColor = Color.Transparent,
@@ -1335,7 +1384,12 @@ fun ChatsListScreen(
) {
Scaffold(
topBar = {
key(isDarkTheme, showRequestsScreen, isSelectionMode) {
key(
isDarkTheme,
showRequestsScreen,
showDownloadsScreen,
isSelectionMode
) {
Crossfade(
targetState = isSelectionMode,
animationSpec = tween(200),
@@ -1473,12 +1527,16 @@ fun ChatsListScreen(
// ═══ NORMAL HEADER ═══
TopAppBar(
navigationIcon = {
if (showRequestsScreen) {
if (showRequestsScreen || showDownloadsScreen) {
IconButton(
onClick = {
setInlineRequestsVisible(
false
)
if (showDownloadsScreen) {
showDownloadsScreen = false
} else {
setInlineRequestsVisible(
false
)
}
}
) {
Icon(
@@ -1557,7 +1615,14 @@ fun ChatsListScreen(
}
},
title = {
if (showRequestsScreen) {
if (showDownloadsScreen) {
Text(
"Downloads",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = Color.White
)
} else if (showRequestsScreen) {
Text(
"Requests",
fontWeight =
@@ -1596,10 +1661,18 @@ fun ChatsListScreen(
}
},
actions = {
if (!showRequestsScreen) {
if (!showRequestsScreen && !showDownloadsScreen) {
// 📥 Animated download indicator (Telegram-style)
Box(
modifier = androidx.compose.ui.Modifier.size(48.dp),
modifier =
androidx.compose.ui.Modifier
.size(48.dp)
.clickable(
enabled = hasActiveDownloads,
onClick = {
showDownloadsScreen = true
}
),
contentAlignment = Alignment.Center
) {
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
@@ -1709,39 +1782,105 @@ fun ChatsListScreen(
val showSkeleton = isLoading
// 🎬 Animated content transition between main list and
// requests
AnimatedContent(
targetState = showRequestsScreen,
targetState = showDownloadsScreen,
transitionSpec = {
if (targetState) {
// Opening requests: slide in from right
// Opening downloads: slide from right with fade
slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> fullWidth } + fadeIn(
animationSpec =
tween(
280,
easing =
FastOutSlowInEasing
)
) { fullWidth ->
fullWidth
} + fadeIn(
animationSpec = tween(200)
) togetherWith
slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> -fullWidth / 4 } + fadeOut(
animationSpec =
tween(
280,
easing =
FastOutSlowInEasing
)
) { fullWidth ->
-fullWidth / 4
} + fadeOut(
animationSpec = tween(150)
)
} else {
// Closing requests: slide out to right
// Closing downloads: slide back to right
slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> -fullWidth / 4 } + fadeIn(
animationSpec =
tween(
280,
easing =
FastOutSlowInEasing
)
) { fullWidth ->
-fullWidth / 4
} + fadeIn(
animationSpec = tween(200)
) togetherWith
slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> fullWidth } + fadeOut(
animationSpec =
tween(
280,
easing =
FastOutSlowInEasing
)
) { fullWidth ->
fullWidth
} + fadeOut(
animationSpec = tween(150)
)
}
},
label = "RequestsTransition"
) { isRequestsScreen ->
label = "DownloadsTransition"
) { isDownloadsScreen ->
if (isDownloadsScreen) {
FileDownloadsScreen(
downloads = activeFileDownloads,
isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxSize()
)
} else {
// 🎬 Animated content transition between main list and
// requests
AnimatedContent(
targetState = showRequestsScreen,
transitionSpec = {
if (targetState) {
// Opening requests: slide in from right
slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> fullWidth } + fadeIn(
animationSpec = tween(200)
) togetherWith
slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> -fullWidth / 4 } + fadeOut(
animationSpec = tween(150)
)
} else {
// Closing requests: slide out to right
slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> -fullWidth / 4 } + fadeIn(
animationSpec = tween(200)
) togetherWith
slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> fullWidth } + fadeOut(
animationSpec = tween(150)
)
}
},
label = "RequestsTransition"
) { isRequestsScreen ->
if (isRequestsScreen) {
// 📬 Show Requests Screen with swipe-back
Box(
@@ -2054,13 +2193,14 @@ fun ChatsListScreen(
)
val isTyping by
remember(
dialog.opponentKey
dialog.opponentKey,
typingUsers
) {
derivedStateOf {
typingUsers
.contains(
dialog.opponentKey
)
isTypingForDialog(
dialog.opponentKey,
typingUsers
)
}
}
val isSelectedDialog =
@@ -2241,7 +2381,9 @@ fun ChatsListScreen(
}
}
}
} // Close AnimatedContent
} // Close AnimatedContent
} // Close downloads/main content switch
} // Close Downloads AnimatedContent
// Console button removed
}
@@ -3746,7 +3888,11 @@ fun DialogItemContent(
MessageRepository.isSystemAccount(dialog.opponentKey)
if (dialog.verified > 0 || isRosettaOfficial) {
Spacer(modifier = Modifier.width(4.dp))
VerifiedBadge(verified = if (dialog.verified > 0) dialog.verified else 1, size = 16)
VerifiedBadge(
verified = if (dialog.verified > 0) dialog.verified else 1,
size = 16,
modifier = Modifier.offset(y = (-2).dp)
)
}
// 🔒 Красная иконка замочка для заблокированных пользователей
if (isBlocked) {
@@ -4561,6 +4707,136 @@ fun RequestsScreen(
}
}
@Composable
private fun FileDownloadsScreen(
downloads: List<com.rosetta.messenger.network.FileDownloadState>,
isDarkTheme: Boolean,
modifier: Modifier = Modifier
) {
val background = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val card = if (isDarkTheme) Color(0xFF212123) else Color.White
val primaryText = if (isDarkTheme) Color.White else Color(0xFF111111)
val secondaryText = if (isDarkTheme) Color(0xFF9B9B9F) else Color(0xFF6E6E73)
val divider = if (isDarkTheme) Color(0xFF2F2F31) else Color(0xFFE7E7EC)
if (downloads.isEmpty()) {
Box(
modifier = modifier.background(background),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = TablerIcons.Download,
contentDescription = null,
tint = secondaryText,
modifier = Modifier.size(34.dp)
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "No active file downloads",
color = primaryText,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "New file downloads will appear here.",
color = secondaryText,
fontSize = 13.sp
)
}
}
return
}
LazyColumn(
modifier = modifier.background(background),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(items = downloads, key = { it.attachmentId }) { item ->
Surface(
color = card,
shape = RoundedCornerShape(14.dp),
tonalElevation = 0.dp
) {
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(34.dp)
.clip(CircleShape)
.background(
if (isDarkTheme) Color(0xFF2B4E6E)
else Color(0xFFDCEEFF)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = TablerIcons.Download,
contentDescription = null,
tint = if (isDarkTheme) Color(0xFF8FC6FF) else Color(0xFF228BE6),
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.fileName.ifBlank { "Unknown file" },
color = primaryText,
fontSize = 15.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = formatDownloadStatusText(item),
color = secondaryText,
fontSize = 12.sp
)
}
}
Spacer(modifier = Modifier.height(10.dp))
LinearProgressIndicator(
progress = item.progress.coerceIn(0f, 1f),
modifier = Modifier
.fillMaxWidth()
.height(4.dp)
.clip(RoundedCornerShape(50)),
color = if (isDarkTheme) Color(0xFF4DA6FF) else Color(0xFF228BE6),
trackColor = if (isDarkTheme) Color(0xFF3A3A3D) else Color(0xFFD8D8DE)
)
Spacer(modifier = Modifier.height(8.dp))
Divider(color = divider, thickness = 0.5.dp)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = item.savedPath.ifBlank { "Storage path unavailable" },
color = secondaryText,
fontSize = 11.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
private fun formatDownloadStatusText(
item: com.rosetta.messenger.network.FileDownloadState
): String {
val percent = (item.progress.coerceIn(0f, 1f) * 100).toInt().coerceIn(0, 100)
return when (item.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%"
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting"
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed"
}
}
/** 🎨 Enhanced Drawer Menu Item - пункт меню в стиле Telegram */
@Composable
fun DrawerMenuItemEnhanced(

View File

@@ -1,8 +1,12 @@
package com.rosetta.messenger.ui.chats
import android.app.Activity
import android.content.Context
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
@@ -88,6 +92,7 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalView
@@ -131,13 +136,20 @@ import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.ImageCropHelper
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import androidx.core.view.WindowCompat
import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@@ -203,6 +215,75 @@ private object GroupMembersMemoryCache {
}
}
private data class GroupMembersDiskCacheEntry(
val members: List<String>,
val updatedAtMs: Long
)
private object GroupMembersDiskCache {
private const val PREFS_NAME = "group_members_cache"
private const val KEY_PREFIX = "entry_"
private const val TTL_MS = 12 * 60 * 60 * 1000L
fun getAny(context: Context, key: String): GroupMembersDiskCacheEntry? = read(context, key)
fun getFresh(context: Context, key: String): GroupMembersDiskCacheEntry? {
val entry = read(context, key) ?: return null
return if (System.currentTimeMillis() - entry.updatedAtMs <= TTL_MS) entry else null
}
fun put(context: Context, key: String, members: List<String>) {
if (key.isBlank()) return
val normalizedMembers = members.map { it.trim() }.filter { it.isNotBlank() }.distinct()
if (normalizedMembers.isEmpty()) return
val payload =
JSONObject().apply {
put("updatedAtMs", System.currentTimeMillis())
put("members", JSONArray().apply { normalizedMembers.forEach { put(it) } })
}.toString()
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_PREFIX + key, payload)
.apply()
}
fun remove(context: Context, key: String) {
if (key.isBlank()) return
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.remove(KEY_PREFIX + key)
.apply()
}
private fun read(context: Context, key: String): GroupMembersDiskCacheEntry? {
if (key.isBlank()) return null
val raw =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(KEY_PREFIX + key, null) ?: return null
return runCatching {
val json = JSONObject(raw)
val membersArray = json.optJSONArray("members") ?: JSONArray()
val membersList =
buildList {
repeat(membersArray.length()) { index ->
val value = membersArray.optString(index).trim()
if (value.isNotBlank()) add(value)
}
}
.distinct()
if (membersList.isEmpty()) {
null
} else {
GroupMembersDiskCacheEntry(
members = membersList,
updatedAtMs = json.optLong("updatedAtMs", 0L)
)
}
}
.getOrNull()
}
}
private data class GroupMediaItem(
val key: String,
val attachment: MessageAttachment,
@@ -238,6 +319,7 @@ fun GroupInfoScreen(
) {
val context = androidx.compose.ui.platform.LocalContext.current
val view = LocalView.current
val focusManager = LocalFocusManager.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
val hapticFeedback = LocalHapticFeedback.current
val scope = rememberCoroutineScope()
@@ -253,6 +335,20 @@ fun GroupInfoScreen(
val secondaryText = Color(0xFF8E8E93)
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
val groupActionButtonBlue = if (isDarkTheme) Color(0xFF285683) else Color(0xFF2478C2)
LaunchedEffect(Unit) {
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
repeat(3) {
imm.hideSoftInputFromWindow(view.windowToken, 0)
(context as? Activity)?.window?.let { window ->
WindowCompat.getInsetsController(window, view)
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
}
focusManager.clearFocus(force = true)
delay(16)
}
}
// Keep status bar unified with group header color.
DisposableEffect(topSurfaceColor, view) {
@@ -299,19 +395,40 @@ fun GroupInfoScreen(
var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var encryptionKeyLoading by remember { mutableStateOf(false) }
var membersLoading by remember { mutableStateOf(false) }
var membersLoading by remember(dialogPublicKey) { mutableStateOf(false) }
var isMuted by remember { mutableStateOf(false) }
var showGroupAvatarPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var showGroupAvatarViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var groupAvatarViewerTimestamp by rememberSaveable(dialogPublicKey) { mutableStateOf(0L) }
var groupAvatarViewerBitmap by remember(dialogPublicKey) { mutableStateOf<android.graphics.Bitmap?>(null) }
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) }
val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf<String, SearchUser>() }
// Real online status from PacketOnlineState (0x05), NOT from SearchUser.online
val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf<String, Boolean>() }
val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) {
"${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}"
}
val initialMemoryMembersCache = remember(membersCacheKey) {
GroupMembersMemoryCache.getAny(membersCacheKey)
}
val initialDiskMembersCache = remember(membersCacheKey) {
GroupMembersDiskCache.getAny(context, membersCacheKey)
}
val hasInitialMembersCache = remember(initialMemoryMembersCache, initialDiskMembersCache) {
initialMemoryMembersCache != null || initialDiskMembersCache != null
}
val initialMembers = remember(initialMemoryMembersCache, initialDiskMembersCache) {
initialMemoryMembersCache?.members ?: initialDiskMembersCache?.members.orEmpty()
}
var members by remember(dialogPublicKey, membersCacheKey) { mutableStateOf(initialMembers) }
val memberInfoByKey =
remember(dialogPublicKey, membersCacheKey) {
mutableStateMapOf<String, SearchUser>().apply {
initialMemoryMembersCache?.memberInfoByKey?.let { putAll(it) }
}
}
// Real online status from PacketOnlineState (0x05), NOT from SearchUser.online
val memberOnlineStatus = remember(dialogPublicKey, membersCacheKey) { mutableStateMapOf<String, Boolean>() }
val groupEntity by produceState<com.rosetta.messenger.database.GroupEntity?>(
initialValue = null,
@@ -390,6 +507,49 @@ fun GroupInfoScreen(
groupEntity?.description?.trim().orEmpty()
}
val cropLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
val cropError = ImageCropHelper.getCropError(result)
if (croppedUri != null) {
scope.launch {
val preparedBase64 =
withContext(Dispatchers.IO) {
val imageBytes =
runCatching {
context.contentResolver.openInputStream(croppedUri)?.use { stream ->
stream.readBytes()
}
}.getOrNull()
imageBytes?.let { bytes ->
val prepared = AvatarFileManager.imagePrepareForNetworkTransfer(context, bytes)
if (prepared.isBlank()) null else "data:image/png;base64,$prepared"
}
}
val repository = avatarRepository
val saved =
preparedBase64 != null &&
repository != null &&
runCatching {
withContext(Dispatchers.IO) {
repository.saveAvatar(dialogPublicKey, preparedBase64)
}
}.isSuccess
Toast.makeText(
context,
if (saved) "Group avatar updated" else "Failed to update group avatar",
Toast.LENGTH_SHORT
).show()
}
} else if (cropError != null) {
Toast.makeText(context, "Failed to crop photo", Toast.LENGTH_SHORT).show()
}
}
LaunchedEffect(currentUserPublicKey, dialogPublicKey) {
if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) {
isMuted = preferencesManager.isChatMuted(currentUserPublicKey, dialogPublicKey)
@@ -417,7 +577,9 @@ fun GroupInfoScreen(
}
}
val hasAnyCache = GroupMembersMemoryCache.getAny(membersCacheKey) != null
val hasAnyCache =
GroupMembersMemoryCache.getAny(membersCacheKey) != null ||
GroupMembersDiskCache.getAny(context, membersCacheKey) != null
val shouldShowLoader = showLoader && members.isEmpty() && !hasAnyCache
if (shouldShowLoader) membersLoading = true
isRefreshingMembers = true
@@ -455,6 +617,7 @@ fun GroupInfoScreen(
members = members,
memberInfoByKey = memberInfoByKey.toMap()
)
GroupMembersDiskCache.put(context, membersCacheKey, members)
} finally {
if (shouldShowLoader) membersLoading = false
isRefreshingMembers = false
@@ -463,15 +626,44 @@ fun GroupInfoScreen(
}
LaunchedEffect(membersCacheKey) {
val cachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey)
cachedEntry?.let { cached ->
val memoryCachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey)
val diskCachedEntry = GroupMembersDiskCache.getAny(context, membersCacheKey)
memoryCachedEntry?.let { cached ->
members = cached.members
memberInfoByKey.clear()
memberInfoByKey.putAll(cached.memberInfoByKey)
} ?: diskCachedEntry?.let { cached ->
members = cached.members
if (memberInfoByKey.isEmpty()) {
val resolvedUsers = withContext(Dispatchers.IO) {
val resolvedMap = LinkedHashMap<String, SearchUser>()
cached.members.forEach { memberKey ->
ProtocolManager.getCachedUserInfo(memberKey)?.let { resolved ->
resolvedMap[memberKey] = resolved
}
}
resolvedMap
}
if (resolvedUsers.isNotEmpty()) {
memberInfoByKey.putAll(resolvedUsers)
}
}
GroupMembersMemoryCache.put(
key = membersCacheKey,
members = members,
memberInfoByKey = memberInfoByKey.toMap()
)
}
if (GroupMembersMemoryCache.getFresh(membersCacheKey) == null) {
refreshMembers(force = true, showLoader = cachedEntry == null)
val hasFreshCache =
GroupMembersMemoryCache.getFresh(membersCacheKey) != null ||
GroupMembersDiskCache.getFresh(context, membersCacheKey) != null
if (!hasFreshCache) {
refreshMembers(
force = true,
showLoader = memoryCachedEntry == null && diskCachedEntry == null
)
}
}
@@ -581,6 +773,20 @@ fun GroupInfoScreen(
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
}
}
fun openGroupAvatarViewer() {
val repository = avatarRepository ?: return
scope.launch {
val latestAvatar = repository.getAvatars(dialogPublicKey, allDecode = false).first().firstOrNull()
?: return@launch
groupAvatarViewerTimestamp = latestAvatar.timestamp / 1000L
groupAvatarViewerBitmap =
withContext(Dispatchers.IO) {
AvatarFileManager.base64ToBitmap(latestAvatar.base64Data)
}
showGroupAvatarViewer = true
}
}
var swipedMemberKey by remember(dialogPublicKey) { mutableStateOf<String?>(null) }
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
@@ -700,6 +906,7 @@ fun GroupInfoScreen(
isLeaving = false
if (left) {
GroupMembersMemoryCache.remove(membersCacheKey)
GroupMembersDiskCache.remove(context, membersCacheKey)
onGroupLeft()
} else {
Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show()
@@ -744,6 +951,7 @@ fun GroupInfoScreen(
members = members,
memberInfoByKey = memberInfoByKey.toMap()
)
GroupMembersDiskCache.put(context, membersCacheKey, members)
refreshMembers(force = true, showLoader = false)
Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show()
} else {
@@ -777,12 +985,12 @@ fun GroupInfoScreen(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
.statusBarsPadding()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(topSurfaceColor)
.statusBarsPadding()
.padding(horizontal = 14.dp, vertical = 10.dp)
) {
IconButton(onClick = onBack, modifier = Modifier.align(Alignment.TopStart)) {
@@ -861,6 +1069,13 @@ fun GroupInfoScreen(
avatarRepository = avatarRepository,
size = 86.dp,
isDarkTheme = isDarkTheme,
onClick = {
if (currentUserIsAdmin) {
showGroupAvatarPicker = true
} else {
openGroupAvatarViewer()
}
},
displayName = groupTitle
)
@@ -877,7 +1092,7 @@ fun GroupInfoScreen(
)
Text(
text = if (membersLoading) {
text = if (membersLoading || (members.isEmpty() && !hasInitialMembersCache)) {
"Loading members..."
} else {
"${members.size} members, $onlineCount online"
@@ -896,16 +1111,18 @@ fun GroupInfoScreen(
modifier = Modifier.weight(1f),
icon = Icons.Default.Message,
label = "Message",
backgroundColor = cardColor,
contentColor = actionContentColor,
backgroundColor = groupActionButtonBlue,
contentColor = Color.White,
iconColor = Color.White,
onClick = onBack
)
GroupActionButton(
modifier = Modifier.weight(1f),
icon = if (isMuted) Icons.Default.Notifications else Icons.Default.NotificationsOff,
label = if (isMuted) "Unmute" else "Mute",
backgroundColor = cardColor,
contentColor = actionContentColor,
backgroundColor = groupActionButtonBlue,
contentColor = Color.White,
iconColor = Color.White,
onClick = {
scope.launch {
val newMutedState = !isMuted
@@ -922,8 +1139,9 @@ fun GroupInfoScreen(
modifier = Modifier.weight(1f),
icon = Icons.Default.ExitToApp,
label = "Leave",
backgroundColor = cardColor,
contentColor = Color(0xFFFF7A7A),
backgroundColor = groupActionButtonBlue,
contentColor = Color.White,
iconColor = Color.White,
onClick = { showLeaveConfirm = true }
)
}
@@ -1335,11 +1553,7 @@ fun GroupInfoScreen(
isDarkTheme = isDarkTheme,
topSurfaceColor = topSurfaceColor,
backgroundColor = backgroundColor,
onBack = { showEncryptionPage = false },
onCopy = {
clipboardManager.setText(AnnotatedString(encryptionKey))
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
}
onBack = { showEncryptionPage = false }
)
}
@@ -1408,6 +1622,27 @@ fun GroupInfoScreen(
}
)
}
ProfilePhotoPicker(
isVisible = showGroupAvatarPicker,
onDismiss = { showGroupAvatarPicker = false },
onPhotoSelected = { uri ->
showGroupAvatarPicker = false
val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme)
cropLauncher.launch(cropIntent)
},
isDarkTheme = isDarkTheme
)
FullScreenAvatarViewer(
isVisible = showGroupAvatarViewer,
onDismiss = { showGroupAvatarViewer = false },
displayName = groupTitle.ifBlank { shortPublicKey(dialogPublicKey) },
avatarTimestamp = groupAvatarViewerTimestamp,
avatarBitmap = groupAvatarViewerBitmap,
publicKey = dialogPublicKey,
isDarkTheme = isDarkTheme
)
}
@Composable
@@ -1456,8 +1691,7 @@ private fun GroupEncryptionKeyPage(
isDarkTheme: Boolean,
topSurfaceColor: Color,
backgroundColor: Color,
onBack: () -> Unit,
onCopy: () -> Unit
onBack: () -> Unit
) {
val uriHandler = LocalUriHandler.current
val safePeerTitle = peerTitle.ifBlank { "this group" }
@@ -1477,13 +1711,13 @@ private fun GroupEncryptionKeyPage(
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
) {
// Top bar
Row(
modifier = Modifier
.fillMaxWidth()
.background(topSurfaceColor)
.statusBarsPadding()
.padding(horizontal = 4.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
@@ -1500,14 +1734,6 @@ private fun GroupEncryptionKeyPage(
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onCopy) {
Text(
text = "Copy",
color = Color.White.copy(alpha = 0.9f),
fontWeight = FontWeight.Medium
)
}
}
// Two-half layout like Telegram
@@ -1595,6 +1821,7 @@ private fun GroupActionButton(
label: String,
backgroundColor: Color,
contentColor: Color,
iconColor: Color = contentColor,
onClick: () -> Unit
) {
Surface(
@@ -1613,7 +1840,7 @@ private fun GroupActionButton(
Icon(
imageVector = icon,
contentDescription = null,
tint = contentColor,
tint = iconColor,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.height(4.dp))

View File

@@ -68,6 +68,7 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.R
@@ -149,6 +150,10 @@ fun GroupSetupScreen(
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
val groupAvatarCameraButtonColor =
if (isDarkTheme) sectionColor else Color(0xFF8CC9F6)
val groupAvatarCameraIconColor =
if (isDarkTheme) accentColor else Color.White
androidx.compose.runtime.DisposableEffect(topSurfaceColor, view) {
val window = (view.context as? Activity)?.window
@@ -208,12 +213,28 @@ fun GroupSetupScreen(
}
}
fun dismissInputUi() {
if (showEmojiKeyboard || coordinator.isEmojiVisible || coordinator.isEmojiBoxVisible) {
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
} else {
showEmojiKeyboard = false
}
focusManager.clearFocus(force = true)
keyboardController?.hide()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
(context as? Activity)?.window?.let { window ->
WindowCompat.getInsetsController(window, view).hide(WindowInsetsCompat.Type.ime())
}
}
fun handleBack() {
if (isLoading) return
errorText = null
if (step == GroupSetupStep.DESCRIPTION) {
step = GroupSetupStep.DETAILS
} else {
dismissInputUi()
onBack()
}
}
@@ -402,7 +423,7 @@ fun GroupSetupScreen(
Modifier
.size(64.dp)
.clip(CircleShape)
.background(sectionColor)
.background(groupAvatarCameraButtonColor)
.clickable(enabled = !isLoading) {
showPhotoPicker = true
},
@@ -423,7 +444,7 @@ fun GroupSetupScreen(
Icon(
painter = TelegramIcons.Camera,
contentDescription = "Set group avatar",
tint = accentColor,
tint = groupAvatarCameraIconColor,
modifier = Modifier.size(24.dp)
)
}
@@ -713,6 +734,7 @@ fun GroupSetupScreen(
avatarUriString = selectedAvatarUri
)
}
dismissInputUi()
openGroup(
dialogPublicKey = result.dialogPublicKey,
groupTitle = result.title.ifBlank { title.trim() }

View File

@@ -32,9 +32,11 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import com.rosetta.messenger.repository.AvatarRepository
@@ -446,9 +448,22 @@ private fun ChatsTabContent(
val dividerColor = remember(isDarkTheme) {
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
}
val suggestionsPrefs = remember(currentUserPublicKey) {
context.getSharedPreferences("search_suggestions", Context.MODE_PRIVATE)
}
val hiddenSuggestionsKey = remember(currentUserPublicKey) {
"hidden_frequent_${currentUserPublicKey}"
}
// ─── Загрузка частых контактов из БД (top dialogs) ───
var frequentContacts by remember { mutableStateOf<List<FrequentContact>>(emptyList()) }
var hiddenSuggestionKeys by remember(currentUserPublicKey) { mutableStateOf<Set<String>>(emptySet()) }
var frequentSuggestionToRemove by remember { mutableStateOf<FrequentContact?>(null) }
LaunchedEffect(currentUserPublicKey) {
hiddenSuggestionKeys =
suggestionsPrefs.getStringSet(hiddenSuggestionsKey, emptySet())?.toSet() ?: emptySet()
}
LaunchedEffect(currentUserPublicKey) {
if (currentUserPublicKey.isBlank()) return@LaunchedEffect
@@ -478,6 +493,14 @@ private fun ChatsTabContent(
} catch (_: Exception) { }
}
}
val visibleFrequentContacts = remember(frequentContacts, hiddenSuggestionKeys) {
frequentContacts.filterNot { hiddenSuggestionKeys.contains(it.publicKey) }
}
fun removeFrequentSuggestion(publicKey: String) {
val updated = (hiddenSuggestionKeys + publicKey).toSet()
hiddenSuggestionKeys = updated
suggestionsPrefs.edit().putStringSet(hiddenSuggestionsKey, updated).apply()
}
Box(modifier = Modifier.fillMaxSize()) {
if (searchQuery.isEmpty()) {
@@ -486,10 +509,10 @@ private fun ChatsTabContent(
modifier = Modifier.fillMaxSize()
) {
// ─── Горизонтальный ряд частых контактов (как в Telegram) ───
if (frequentContacts.isNotEmpty()) {
if (visibleFrequentContacts.isNotEmpty()) {
item {
FrequentContactsRow(
contacts = frequentContacts,
contacts = visibleFrequentContacts,
avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme,
textColor = textColor,
@@ -504,6 +527,9 @@ private fun ChatsTabContent(
)
RecentSearchesManager.addUser(user)
onUserSelect(user)
},
onLongPress = { contact ->
frequentSuggestionToRemove = contact
}
)
}
@@ -653,6 +679,69 @@ private fun ChatsTabContent(
}
)
}
frequentSuggestionToRemove?.let { contact ->
val scrimColor = if (isDarkTheme) Color.Black.copy(alpha = 0.42f) else Color.Black.copy(alpha = 0.24f)
val popupColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val popupSecondaryText = if (isDarkTheme) Color(0xFFAEAEB2) else Color(0xFF6D6D72)
Box(
modifier =
Modifier
.matchParentSize()
.background(scrimColor)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { frequentSuggestionToRemove = null }
)
Surface(
modifier =
Modifier
.align(Alignment.BottomCenter)
.imePadding()
.padding(start = 20.dp, end = 20.dp, bottom = 14.dp),
color = popupColor,
shape = RoundedCornerShape(18.dp),
tonalElevation = 0.dp,
shadowElevation = 0.dp
) {
Column(
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp)
) {
Text(
text = "Remove suggestion",
color = textColor,
fontWeight = FontWeight.Bold,
fontSize = 17.sp
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Are you sure you want to remove ${contact.name} from suggestions?",
color = popupSecondaryText,
fontSize = 16.sp,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = { frequentSuggestionToRemove = null }) {
Text("Cancel", color = AppPrimaryBlue, fontSize = 17.sp)
}
TextButton(
onClick = {
removeFrequentSuggestion(contact.publicKey)
frequentSuggestionToRemove = null
}
) {
Text("Remove", color = Color(0xFFFF5A5F), fontSize = 17.sp)
}
}
}
}
}
}
}
@@ -669,13 +758,16 @@ private data class FrequentContact(
)
@Composable
@OptIn(ExperimentalFoundationApi::class)
private fun FrequentContactsRow(
contacts: List<FrequentContact>,
avatarRepository: AvatarRepository?,
isDarkTheme: Boolean,
textColor: Color,
onClick: (FrequentContact) -> Unit
onClick: (FrequentContact) -> Unit,
onLongPress: (FrequentContact) -> Unit
) {
val haptic = LocalHapticFeedback.current
LazyRow(
modifier = Modifier
.fillMaxWidth()
@@ -687,10 +779,15 @@ private fun FrequentContactsRow(
Column(
modifier = Modifier
.width(72.dp)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) { onClick(contact) }
.combinedClickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = { onClick(contact) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongPress(contact)
}
)
.padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {

View File

@@ -80,6 +80,19 @@ private val whitespaceRegex = "\\s+".toRegex()
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
private fun canonicalGroupDialogKey(value: String): String {
val trimmed = value.trim()
if (trimmed.isBlank()) return ""
val groupId =
when {
trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim()
trimmed.startsWith("group:", ignoreCase = true) ->
trimmed.substringAfter(':').trim()
else -> return trimmed
}
return if (groupId.isBlank()) "" else "#group:$groupId"
}
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
if (!isGroupStoredKey(storedKey)) return null
val encoded = storedKey.removePrefix("group:")
@@ -332,6 +345,8 @@ fun MessageAttachments(
isOutgoing: Boolean,
isDarkTheme: Boolean,
senderPublicKey: String,
dialogPublicKey: String = "",
isGroupChat: Boolean = false,
timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ,
avatarRepository: AvatarRepository? = null,
@@ -380,6 +395,7 @@ fun MessageAttachments(
attachment = attachment,
chachaKey = chachaKey,
privateKey = privateKey,
currentUserPublicKey = currentUserPublicKey,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
timestamp = timestamp,
@@ -392,6 +408,8 @@ fun MessageAttachments(
chachaKey = chachaKey,
privateKey = privateKey,
senderPublicKey = senderPublicKey,
dialogPublicKey = dialogPublicKey,
isGroupChat = isGroupChat,
avatarRepository = avatarRepository,
currentUserPublicKey = currentUserPublicKey,
isOutgoing = isOutgoing,
@@ -1427,6 +1445,7 @@ fun FileAttachment(
attachment: MessageAttachment,
chachaKey: String,
privateKey: String,
currentUserPublicKey: String = "",
isOutgoing: Boolean,
isDarkTheme: Boolean,
timestamp: java.util.Date,
@@ -1540,6 +1559,7 @@ fun FileAttachment(
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
accountPublicKey = currentUserPublicKey,
fileName = fileName,
savedFile = savedFile
)
@@ -1775,6 +1795,8 @@ fun AvatarAttachment(
chachaKey: String,
privateKey: String,
senderPublicKey: String,
dialogPublicKey: String = "",
isGroupChat: Boolean = false,
avatarRepository: AvatarRepository?,
currentUserPublicKey: String = "",
isOutgoing: Boolean,
@@ -1924,11 +1946,16 @@ fun AvatarAttachment(
// Сохраняем аватар в репозиторий (для UI обновления)
// Если это исходящее сообщение с аватаром, сохраняем для текущего
// пользователя
val normalizedDialogKey = dialogPublicKey.trim()
val canonicalDialogKey = canonicalGroupDialogKey(normalizedDialogKey)
val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey)
val targetPublicKey =
if (isOutgoing && currentUserPublicKey.isNotEmpty()) {
currentUserPublicKey
} else {
senderPublicKey
when {
isGroupAvatarAttachment && canonicalDialogKey.isNotEmpty() ->
canonicalDialogKey
isOutgoing && currentUserPublicKey.isNotEmpty() ->
currentUserPublicKey
else -> senderPublicKey
}
// ВАЖНО: ждем завершения сохранения в репозиторий

View File

@@ -71,6 +71,7 @@ import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.models.*
import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.components.AppleEmojiText
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager
import com.vanniktech.blurhash.BlurHash
@@ -289,6 +290,7 @@ fun MessageBubble(
isSystemSafeChat: Boolean = false,
isSelectionMode: Boolean = false,
showTail: Boolean = true,
showIncomingGroupAvatar: Boolean? = null,
isGroupStart: Boolean = false,
isSelected: Boolean = false,
isHighlighted: Boolean = false,
@@ -297,6 +299,7 @@ fun MessageBubble(
senderPublicKey: String = "",
senderName: String = "",
isGroupChat: Boolean = false,
dialogPublicKey: String = "",
showGroupSenderLabel: Boolean = false,
isGroupSenderAdmin: Boolean = false,
currentUserPublicKey: String = "",
@@ -309,6 +312,7 @@ fun MessageBubble(
onRetry: () -> Unit = {},
onDelete: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onAvatarClick: (senderPublicKey: String) -> Unit = {},
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onMentionClick: (username: String) -> Unit = {},
onGroupInviteOpen: (SearchUser) -> Unit = {},
@@ -531,6 +535,16 @@ fun MessageBubble(
val combinedBackgroundColor =
if (isSelected) selectionBackgroundColor else highlightBackgroundColor
val telegramIncomingAvatarSize = 42.dp
val telegramIncomingAvatarLane = 48.dp
val telegramIncomingAvatarInset = 6.dp
val shouldShowIncomingGroupAvatar =
showIncomingGroupAvatar
?: (isGroupChat &&
!message.isOutgoing &&
showTail &&
senderPublicKey.isNotBlank())
Row(
modifier =
Modifier.fillMaxWidth()
@@ -542,7 +556,7 @@ fun MessageBubble(
) {
// Selection checkmark
AnimatedVisibility(
visible = isSelected,
visible = isSelected && message.isOutgoing,
enter =
fadeIn(tween(150)) +
scaleIn(
@@ -568,16 +582,63 @@ fun MessageBubble(
}
}
AnimatedVisibility(
visible = !isSelected,
AnimatedVisibility(
visible = !isSelected || !message.isOutgoing,
enter = fadeIn(tween(100)),
exit = fadeOut(tween(100))
) { Spacer(modifier = Modifier.width(12.dp)) }
) {
val leadingSpacerWidth =
if (!message.isOutgoing && isGroupChat) 0.dp else 12.dp
Spacer(modifier = Modifier.width(leadingSpacerWidth))
}
if (message.isOutgoing) {
Spacer(modifier = Modifier.weight(1f))
}
if (!message.isOutgoing && isGroupChat) {
Box(
modifier =
Modifier.width(telegramIncomingAvatarLane)
.height(telegramIncomingAvatarSize)
.align(Alignment.Bottom),
contentAlignment = Alignment.BottomStart
) {
if (shouldShowIncomingGroupAvatar) {
Box(
modifier =
Modifier.fillMaxSize()
.padding(
start =
telegramIncomingAvatarInset
),
contentAlignment = Alignment.BottomStart
) {
val avatarClickHandler: (() -> Unit)? =
if (!isSelectionMode &&
senderPublicKey
.isNotBlank()
) {
{ onAvatarClick(senderPublicKey) }
} else {
null
}
AvatarImage(
publicKey = senderPublicKey,
avatarRepository = avatarRepository,
size = telegramIncomingAvatarSize,
isDarkTheme = isDarkTheme,
onClick = avatarClickHandler,
displayName =
senderName.ifBlank {
senderPublicKey
}
)
}
}
}
}
// Проверяем - есть ли только фотки без текста
val hasOnlyMedia =
message.attachments.isNotEmpty() &&
@@ -707,7 +768,8 @@ fun MessageBubble(
Box(
modifier =
Modifier.padding(end = 12.dp)
Modifier.align(Alignment.Bottom)
.padding(end = 12.dp)
.then(bubbleWidthModifier)
.graphicsLayer {
this.alpha = selectionAlpha
@@ -846,6 +908,8 @@ fun MessageBubble(
isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme,
senderPublicKey = senderPublicKey,
dialogPublicKey = dialogPublicKey,
isGroupChat = isGroupChat,
timestamp = message.timestamp,
messageStatus = attachmentDisplayStatus,
avatarRepository = avatarRepository,
@@ -1170,6 +1234,37 @@ fun MessageBubble(
// 💬 Context menu anchor (DropdownMenu positions relative to this Box)
contextMenuContent()
}
if (!message.isOutgoing) {
Spacer(modifier = Modifier.weight(1f))
AnimatedVisibility(
visible = isSelected,
enter =
fadeIn(tween(150)) +
scaleIn(
initialScale = 0.3f,
animationSpec =
spring(dampingRatio = 0.6f)
),
exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f)
) {
Box(
modifier =
Modifier.padding(start = 4.dp, end = 12.dp)
.size(24.dp)
.clip(CircleShape)
.background(PrimaryBlue),
contentAlignment = Alignment.Center
) {
Icon(
painter = TelegramIcons.Done,
contentDescription = "Selected",
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
}
@@ -1212,27 +1307,8 @@ private fun isGroupInviteCode(text: String): Boolean {
}
private fun groupSenderLabelColor(publicKey: String, isDarkTheme: Boolean): Color {
val paletteDark =
listOf(
Color(0xFF7ED957),
Color(0xFF6EC1FF),
Color(0xFFFF9F68),
Color(0xFFC38AFF),
Color(0xFFFF7AA2),
Color(0xFF4DD7C8)
)
val paletteLight =
listOf(
Color(0xFF2E7D32),
Color(0xFF1565C0),
Color(0xFFEF6C00),
Color(0xFF6A1B9A),
Color(0xFFC2185B),
Color(0xFF00695C)
)
val palette = if (isDarkTheme) paletteDark else paletteLight
val index = kotlin.math.abs(publicKey.hashCode()) % palette.size
return palette[index]
// Match nickname color with avatar initials color.
return com.rosetta.messenger.ui.chats.getAvatarColor(publicKey, isDarkTheme).textColor
}
@Composable

View File

@@ -358,13 +358,14 @@ fun AppleEmojiText(
enableMentions: Boolean = false,
onMentionClick: ((String) -> Unit)? = null,
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble)
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
minHeightMultiplier: Float = 1.5f
) {
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
else fontSize.value
// Минимальная высота для корректного отображения emoji
val minHeight = (fontSizeValue * 1.5).toInt()
val minHeight = (fontSizeValue * minHeightMultiplier).toInt()
// Преобразуем FontWeight в Android typeface style
val typefaceStyle = when (fontWeight) {

View File

@@ -575,7 +575,7 @@ private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) {
painter = painterResource(id = wallpaperResId),
contentDescription = "Chat wallpaper preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
contentScale = ContentScale.FillBounds
)
}