Compare commits

...

21 Commits

Author SHA1 Message Date
982dfc5dff Релиз 1.1.5: ускорено подключение и исправлены группы
All checks were successful
Android Kernel Build / build (push) Successful in 16h12m4s
2026-03-09 20:20:54 +05:00
c2e27cf543 Ускорено подключение, исправлен кэш участников групп и обновлена версия до 1.1.5 2026-03-09 20:20:27 +05:00
41faa98130 Добавлен кэширование участников групп в памяти и на диске 2026-03-09 19:23:28 +05:00
1b3c4c8cea Изменён цвет текста заголовка даты в зависимости от темы 2026-03-09 19:09:20 +05:00
f35596f18d Обновлён стиль кнопок на экране группы 2026-03-09 16:52:46 +05:00
ce376d340f Доработаны GroupSetup и Search: клавиатура, кнопка камеры и попап подсказок 2026-03-09 16:06:49 +05:00
5e908a6d0c Исправлено поведение аватарок серий в группах и выделение сообщений 2026-03-09 03:53:12 +05:00
b0a41b2831 Исправлены typing и загрузки файлов, улучшен UI чатов
- Добавлена привязка загрузок файлов к аккаунту и путь сохранения в состоянии загрузки
- Реализован экран Downloads с прогрессом и путём файла, плюс анимация открытия/закрытия
- Исправлена логика typing-индикаторов в группах и ЛС (без ложных срабатываний)
- Доработаны пузырьки групповых сообщений: Telegram-style аватар 42dp и отступы
- Исправлено поведение кнопки прокрутки вниз в чате (без мигания при отправке, ближе к инпуту)
- Убран Copy на экране Encryption Key группы
2026-03-08 19:07:56 +05:00
c7d5c47dd0 Исправлен переход в профиль по своему тэгу из группового чата 2026-03-08 02:55:44 +05:00
85bddb798c Исправлены синхронизация групп, выделение сообщений и фон чата 2026-03-07 23:43:09 +05:00
364b166581 Добавлено отслеживание количества участников группы в ChatDetailScreen 2026-03-07 20:42:21 +05:00
56ba0bd42a Слил master в dev 2026-03-07 19:03:54 +05:00
59499a8f85 Релиз v1.1.4: профиль, группы, аватары и исправления
- Фиксированные табы в профиле и группах
- Fast-scroll с датой в медиа-галерее
- Apple Emoji в аватарах и интерфейсе
- Восстановление ключей группы по инвайт-ссылке
- Улучшено отображение аватаров с эмодзи
- Исправлен переход по своему тэгу в группах
- Убрана лишняя подсветка, исправлен fast-scroll overlay
- Версия 1.1.3 → 1.1.4, versionCode 15 → 16

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 18:24:19 +05:00
896d54df4d Релиз 1.1.3: обновлена версия и release notes
- Поднят versionName до 1.1.3 и versionCode до 15.
- Обновлены release notes под текущий релиз.
- В notes добавлены изменения по reply/mentions в группах, индикаторам чтения и UX-исправлениям.
2026-03-07 18:17:48 +05:00
c674a1ea99 Добавлено сохранение аватаров для групповых сообщений, улучшена логика обработки ключей диалога в компонентах сообщений. 2026-03-07 18:08:30 +05:00
c5737e51b0 Добавлено автоматическое прокручивание к новым сообщениям и кнопка "Прокрутить вниз" в ChatDetailScreen 2026-03-07 17:34:36 +05:00
b62ff7d7c4 Добавлено автоматическое прокручивание к новым сообщениям и кнопка "Прокрутить вниз" в ChatDetailScreen 2026-03-07 17:15:25 +05:00
429025537f Исправил переход по своему тэгу в группах и убрал лишнюю подсветку
- Клик по своему упоминанию теперь сразу открывает My Profile без экрана OtherProfile и kebab-меню\n- Нормализовал сравнение аккаунта по publicKey/username (trim + ignoreCase)\n- Убрал жёлтую подсветку сообщений с упоминанием в группах\n- Подровнял положение бейджа верификации рядом с именем
2026-03-06 20:03:50 +05:00
6a269f93db Улучшение отображения аватаров: добавлена поддержка текста с эмодзи и улучшена логика отображения в AvatarImage. Обновлен SharedMediaFastScrollOverlay для корректного отображения при изменении размера. Исправлено сообщение подсказки в строках. 2026-03-06 19:19:01 +05:00
8bce15cc19 Профиль и группы: фиксированные табы, fast-scroll с датой и Apple Emoji 2026-03-06 18:05:17 +05:00
e9944b3c67 Группы: восстановление ключей по инвайту и Apple Emoji
- Добавлено восстановление локального ключа группы из инвайта при повторном нажатии, даже если на сервере статус уже JOINED.
- В карточке приглашения сначала восстанавливается ключ, затем открывается группа.
- Включено отображение Apple Emoji для названия/описания группы в GroupInfo и в заголовке группы в чате.
- Обновлён превью-заголовок в GroupSetup на Apple Emoji рендер.
2026-03-06 17:23:11 +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 // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.1.4" val rosettaVersionName = "1.1.5"
val rosettaVersionCode = 16 // Increment on each release val rosettaVersionCode = 17 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

@@ -496,6 +496,7 @@ private fun EncryptedAccount.toAccountInfo(): AccountInfo {
*/ */
sealed class Screen { sealed class Screen {
data object Profile : Screen() data object Profile : Screen()
data object ProfileFromChat : Screen()
data object Requests : Screen() data object Requests : Screen()
data object Search : Screen() data object Search : Screen()
data object GroupSetup : Screen() data object GroupSetup : Screen()
@@ -600,6 +601,9 @@ fun MainScreen(
// Derived visibility — only triggers recomposition when THIS screen changes // Derived visibility — only triggers recomposition when THIS screen changes
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } } 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 isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } }
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } } val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } } val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } }
@@ -662,11 +666,18 @@ fun MainScreen(
navStack = navStack.dropLast(1) navStack = navStack.dropLast(1)
} }
fun openOwnProfile() { fun openOwnProfile() {
navStack = val filteredStack =
navStack.filterNot { navStack.filterNot {
it is Screen.ChatDetail || it is Screen.OtherProfile || it is Screen.GroupInfo 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() { fun popProfileAndChildren() {
navStack = navStack =
@@ -1003,8 +1014,9 @@ fun MainScreen(
onBack = { popChatAndChildren() }, onBack = { popChatAndChildren() },
onUserProfileClick = { user -> onUserProfileClick = { user ->
if (isCurrentAccountUser(user)) { if (isCurrentAccountUser(user)) {
// Свой профиль открываем My Profile // Свой профиль из чата открываем поверх текущего чата,
openOwnProfile() // чтобы возврат оставался в этот чат, а не в chat list.
pushScreen(Screen.ProfileFromChat)
} else { } else {
// Открываем профиль другого пользователя // Открываем профиль другого пользователя
pushScreen(Screen.OtherProfile(user)) 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) } var isGroupInfoSwipeEnabled by remember { mutableStateOf(true) }
LaunchedEffect(selectedGroup?.publicKey) { LaunchedEffect(selectedGroup?.publicKey) {
isGroupInfoSwipeEnabled = true isGroupInfoSwipeEnabled = true

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.data package com.rosetta.messenger.data
import android.content.Context import android.content.Context
import android.util.Log
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.database.GroupEntity import com.rosetta.messenger.database.GroupEntity
import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.MessageEntity
@@ -23,6 +24,7 @@ import kotlin.coroutines.resume
class GroupRepository private constructor(context: Context) { class GroupRepository private constructor(context: Context) {
private val appContext = context.applicationContext
private val db = RosettaDatabase.getDatabase(context.applicationContext) private val db = RosettaDatabase.getDatabase(context.applicationContext)
private val groupDao = db.groupDao() private val groupDao = db.groupDao()
private val messageDao = db.messageDao() private val messageDao = db.messageDao()
@@ -31,9 +33,11 @@ class GroupRepository private constructor(context: Context) {
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>() private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
companion object { companion object {
private const val TAG = "GroupRepository"
private const val GROUP_PREFIX = "#group:" private const val GROUP_PREFIX = "#group:"
private const val GROUP_INVITE_PASSWORD = "rosetta_group" private const val GROUP_INVITE_PASSWORD = "rosetta_group"
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
private const val GROUP_CREATED_MARKER = "\$a=Group created"
@Volatile @Volatile
private var INSTANCE: GroupRepository? = null 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 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( 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 { private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey) val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey)
return "group:$encrypted" return "group:$encrypted"

View File

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

View File

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

View File

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

View File

@@ -12,7 +12,9 @@ data class FileDownloadState(
val fileName: String, val fileName: String,
val status: FileDownloadStatus, val status: FileDownloadStatus,
/** 0f..1f */ /** 0f..1f */
val progress: Float = 0f val progress: Float = 0f,
val accountPublicKey: String = "",
val savedPath: String = ""
) )
enum class FileDownloadStatus { enum class FileDownloadStatus {
@@ -84,17 +86,27 @@ object FileDownloadManager {
downloadTag: String, downloadTag: String,
chachaKey: String, chachaKey: String,
privateKey: String, privateKey: String,
accountPublicKey: String,
fileName: String, fileName: String,
savedFile: File savedFile: File
) { ) {
// Уже в процессе? // Уже в процессе?
if (jobs[attachmentId]?.isActive == true) return 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 { jobs[attachmentId] = scope.launch {
try { try {
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f) update(
attachmentId,
fileName,
FileDownloadStatus.DOWNLOADING,
0f,
normalizedAccount,
savedPath
)
// Запускаем polling прогресса из TransportManager // Запускаем polling прогресса из TransportManager
val progressJob = launch { val progressJob = launch {
@@ -103,34 +115,87 @@ object FileDownloadManager {
if (entry != null) { if (entry != null) {
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание) // CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
val p = (entry.progress / 100f) * 0.8f 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) { val success = withContext(Dispatchers.IO) {
if (isGroupStoredKey(chachaKey)) { 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 { } 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() progressJob.cancel()
if (success) { if (success) {
update(attachmentId, fileName, FileDownloadStatus.DONE, 1f) update(
attachmentId,
fileName,
FileDownloadStatus.DONE,
1f,
normalizedAccount,
savedPath
)
} else { } else {
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
e.printStackTrace() e.printStackTrace()
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} catch (_: OutOfMemoryError) { } catch (_: OutOfMemoryError) {
System.gc() System.gc()
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f) update(
attachmentId,
fileName,
FileDownloadStatus.ERROR,
0f,
normalizedAccount,
savedPath
)
} finally { } finally {
jobs.remove(attachmentId) jobs.remove(attachmentId)
// Автоочистка через 5 секунд после завершения // Автоочистка через 5 секунд после завершения
@@ -159,25 +224,55 @@ object FileDownloadManager {
chachaKey: String, chachaKey: String,
privateKey: String, privateKey: String,
fileName: String, fileName: String,
savedFile: File savedFile: File,
accountPublicKey: String,
savedPath: String
): Boolean { ): Boolean {
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag) 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) val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) return false if (groupPassword.isNullOrBlank()) return false
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword) 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 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) { withContext(Dispatchers.IO) {
savedFile.parentFile?.mkdirs() savedFile.parentFile?.mkdirs()
savedFile.writeBytes(bytes) savedFile.writeBytes(bytes)
} }
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f) update(
attachmentId,
fileName,
FileDownloadStatus.DECRYPTING,
0.98f,
accountPublicKey,
savedPath
)
return true return true
} }
@@ -187,14 +282,30 @@ object FileDownloadManager {
chachaKey: String, chachaKey: String,
privateKey: String, privateKey: String,
fileName: String, fileName: String,
savedFile: File savedFile: File,
accountPublicKey: String,
savedPath: String
): Boolean { ): Boolean {
// Streaming: скачиваем во temp file // Streaming: скачиваем во temp file
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag) 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) 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 // Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -208,13 +319,36 @@ object FileDownloadManager {
tempFile.delete() tempFile.delete()
} }
} }
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f) update(
attachmentId,
fileName,
FileDownloadStatus.DECRYPTING,
0.98f,
accountPublicKey,
savedPath
)
return true 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 -> _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 MANUAL_SYNC_BACKTRACK_MS = 120_000L
private const val MAX_DEBUG_LOGS = 600 private const val MAX_DEBUG_LOGS = 600
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L 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 // Desktop parity: use the same primary WebSocket endpoint as desktop client.
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000" private const val SERVER_ADDRESS = "wss://wss.rosetta.im"
private const val DEVICE_PREFS = "rosetta_protocol" private const val DEVICE_PREFS = "rosetta_protocol"
private const val DEVICE_ID_KEY = "device_id" private const val DEVICE_ID_KEY = "device_id"
private const val DEVICE_ID_LENGTH = 128 private const val DEVICE_ID_LENGTH = 128
@@ -59,6 +60,9 @@ object ProtocolManager {
// Typing status // Typing status
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet()) private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow() 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 // Connected devices and pending verification requests
private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList()) private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList())
@@ -200,6 +204,7 @@ object ProtocolManager {
*/ */
fun initializeAccount(publicKey: String, privateKey: String) { fun initializeAccount(publicKey: String, privateKey: String) {
setSyncInProgress(false) setSyncInProgress(false)
clearTypingState()
messageRepository?.initialize(publicKey, privateKey) messageRepository?.initialize(publicKey, privateKey)
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) { if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
resyncRequiredAfterAccountInit = false resyncRequiredAfterAccountInit = false
@@ -369,13 +374,26 @@ object ProtocolManager {
// Обработчик typing (0x0B) // Обработчик typing (0x0B)
waitPacket(0x0B) { packet -> waitPacket(0x0B) { packet ->
val typingPacket = packet as PacketTyping val typingPacket = packet as PacketTyping
val fromPublicKey = typingPacket.fromPublicKey.trim()
val toPublicKey = typingPacket.toPublicKey.trim()
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return@waitPacket
// Добавляем в set и удаляем через 3 секунды val ownPublicKey =
_typingUsers.value = _typingUsers.value + typingPacket.fromPublicKey getProtocol().getPublicKey()?.trim().orEmpty().ifBlank {
scope.launch { messageRepository?.getCurrentAccountKey()?.trim().orEmpty()
delay(3000) }
_typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey 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) // 📱 Обработчик списка устройств (0x17)
@@ -508,6 +526,71 @@ object ProtocolManager {
return normalized.startsWith("#group:") || normalized.startsWith("group:") 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() { private fun onAuthenticated() {
setSyncInProgress(false) setSyncInProgress(false)
TransportManager.requestTransportServer() TransportManager.requestTransportServer()
@@ -1021,6 +1104,7 @@ object ProtocolManager {
protocol?.disconnect() protocol?.disconnect()
protocol?.clearCredentials() protocol?.clearCredentials()
messageRepository?.clearInitialization() messageRepository?.clearInitialization()
clearTypingState()
_devices.value = emptyList() _devices.value = emptyList()
_pendingDeviceVerification.value = null _pendingDeviceVerification.value = null
syncRequestInFlight = false syncRequestInFlight = false
@@ -1036,6 +1120,7 @@ object ProtocolManager {
protocol?.destroy() protocol?.destroy()
protocol = null protocol = null
messageRepository?.clearInitialization() messageRepository?.clearInitialization()
clearTypingState()
_devices.value = emptyList() _devices.value = emptyList()
_pendingDeviceVerification.value = null _pendingDeviceVerification.value = null
syncRequestInFlight = false syncRequestInFlight = false

View File

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

View File

@@ -54,20 +54,56 @@ class AvatarRepository(
} }
} }
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()
}
/** /**
* Получить аватары пользователя * Получить аватары пользователя
* @param publicKey Публичный ключ пользователя * @param publicKey Публичный ключ пользователя
* @param allDecode true = вся история, false = только последний (для списков) * @param allDecode true = вся история, false = только последний (для списков)
*/ */
fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow<List<AvatarInfo>> { 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) // Проверяем LRU cache (accessOrder=true обновляет позицию при get)
memoryCache[publicKey]?.let { return it.flow.asStateFlow() } memoryCache[normalizedKey]?.let { return it.flow.asStateFlow() }
// Создаем новый flow для этого пользователя // Создаем новый flow для этого пользователя
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList()) val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
// Подписываемся на изменения в БД // Подписываемся на изменения в БД
val job = avatarDao.getAvatars(publicKey) val job = avatarDao.getAvatarsByKeys(keys)
.onEach { entities -> .onEach { entities ->
val avatars = if (allDecode) { val avatars = if (allDecode) {
// Параллельная загрузка всей истории // Параллельная загрузка всей истории
@@ -86,7 +122,7 @@ class AvatarRepository(
} }
.launchIn(repositoryScope) .launchIn(repositoryScope)
memoryCache[publicKey] = CacheEntry(flow, job) memoryCache[normalizedKey] = CacheEntry(flow, job)
return flow.asStateFlow() return flow.asStateFlow()
} }
@@ -94,7 +130,9 @@ class AvatarRepository(
* Получить последний аватар пользователя (suspend версия) * Получить последний аватар пользователя (suspend версия)
*/ */
suspend fun getLatestAvatar(publicKey: String): AvatarInfo? { 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) return loadAndDecryptAvatar(entity)
} }
@@ -108,22 +146,24 @@ class AvatarRepository(
suspend fun saveAvatar(fromPublicKey: String, base64Image: String) { suspend fun saveAvatar(fromPublicKey: String, base64Image: String) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
try { 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( val entity = AvatarCacheEntity(
publicKey = fromPublicKey, publicKey = ownerKey,
avatar = filePath, avatar = filePath,
timestamp = System.currentTimeMillis() timestamp = System.currentTimeMillis()
) )
avatarDao.insertAvatar(entity) avatarDao.insertAvatar(entity)
// Очищаем старые аватары (оставляем только последние N) // Очищаем старые аватары (оставляем только последние N)
avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY) avatarDao.deleteOldAvatars(ownerKey, MAX_AVATAR_HISTORY)
// 🔄 Обновляем memory cache если он существует // 🔄 Обновляем memory cache если он существует
val cached = memoryCache[fromPublicKey] val cached = memoryCache[ownerKey]
if (cached != null) { if (cached != null) {
val avatarInfo = loadAndDecryptAvatar(entity) val avatarInfo = loadAndDecryptAvatar(entity)
if (avatarInfo != null) { 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.ProtocolManager
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull 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( internal suspend fun awaitAuthHandshakeState(
publicKey: String, publicKey: String,
privateKeyHash: String, privateKeyHash: String,
attempts: Int = 2, attempts: Int = 2,
timeoutMs: Long = 25_000L timeoutMs: Long = 25_000L
): ProtocolState? { ): ProtocolState? {
repeat(attempts) { repeat(attempts) { attempt ->
ProtocolManager.disconnect() startAuthHandshakeFast(publicKey, privateKeyHash)
delay(200)
ProtocolManager.authenticate(publicKey, privateKeyHash)
val state = withTimeoutOrNull(timeoutMs) { val state = withTimeoutOrNull(timeoutMs) {
ProtocolManager.state.first { ProtocolManager.state.first {
@@ -26,6 +30,7 @@ internal suspend fun awaitAuthHandshakeState(
if (state != null) { if (state != null) {
return state return state
} }
ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
} }
return null return null
} }

View File

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

View File

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

View File

@@ -2,12 +2,19 @@ package com.rosetta.messenger.ui.chats
import android.app.Activity import android.app.Activity
import android.content.Context 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.net.Uri
import android.view.Gravity
import android.view.View
import android.view.inputmethod.InputMethodManager import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade import androidx.compose.animation.Crossfade
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState 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.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalLifecycleOwner 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.res.painterResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -107,6 +117,26 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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( @OptIn(
ExperimentalMaterial3Api::class, ExperimentalMaterial3Api::class,
androidx.compose.foundation.ExperimentalFoundationApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class,
@@ -153,6 +183,7 @@ fun ChatDetailScreen(
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) } val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
val textColor = if (isDarkTheme) Color.White else Color.Black val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val dateHeaderTextColor = if (isDarkTheme) Color.White else secondaryTextColor
val headerIconColor = Color.White val headerIconColor = Color.White
// 🔥 Keyboard & Emoji Coordinator // 🔥 Keyboard & Emoji Coordinator
@@ -186,6 +217,10 @@ fun ChatDetailScreen(
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward // 🔥 MESSAGE SELECTION STATE - для Reply/Forward
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) } var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty() 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 // 💬 MESSAGE CONTEXT MENU STATE
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) } var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
@@ -210,13 +245,22 @@ fun ChatDetailScreen(
} }
} }
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager val hideInputOverlays: () -> Unit = {
val hideKeyboardAndBack: () -> Unit = {
// Используем нативный InputMethodManager для МГНОВЕННОГО закрытия
val imm = val imm =
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0) 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() onBack()
} }
@@ -228,10 +272,7 @@ fun ChatDetailScreen(
else user.title.ifEmpty { user.publicKey.take(10) } else user.title.ifEmpty { user.publicKey.take(10) }
val openDialogInfo: () -> Unit = { val openDialogInfo: () -> Unit = {
val imm = hideInputOverlays()
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.hideSoftInputFromWindow(view.windowToken, 0)
focusManager.clearFocus()
showContextMenu = false showContextMenu = false
contextMenuMessage = null contextMenuMessage = null
if (isGroupChat) { if (isGroupChat) {
@@ -424,6 +465,9 @@ fun ChatDetailScreen(
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) { var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Set<String>>(emptySet()) mutableStateOf<Set<String>>(emptySet())
} }
var groupMembersCount by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<Int?>(null)
}
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) { var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
mutableStateOf<List<MentionCandidate>>(emptyList()) mutableStateOf<List<MentionCandidate>>(emptyList())
} }
@@ -438,6 +482,7 @@ fun ChatDetailScreen(
LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) { LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) {
if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) { if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) {
groupAdminKeys = emptySet() groupAdminKeys = emptySet()
groupMembersCount = null
mentionCandidates = emptyList() mentionCandidates = emptyList()
return@LaunchedEffect return@LaunchedEffect
} }
@@ -445,15 +490,20 @@ fun ChatDetailScreen(
val members = withContext(Dispatchers.IO) { val members = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(user.publicKey).orEmpty() 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 = groupAdminKeys =
if (adminKey.isBlank()) emptySet() else setOf(adminKey) if (adminKey.isBlank()) emptySet() else setOf(adminKey)
mentionCandidates = mentionCandidates =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
members.map { it.trim() } normalizedMembers
.filter { it.isNotBlank() && !it.equals(currentUserPublicKey.trim(), ignoreCase = true) } .filter { !it.equals(currentUserPublicKey.trim(), ignoreCase = true) }
.distinct()
.mapNotNull { memberKey -> .mapNotNull { memberKey ->
val resolvedUser = viewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null val resolvedUser = viewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null
val normalizedUsername = resolvedUser.username.trim().trimStart('@') 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 // NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true
// автоматически сохраняет позицию благодаря стабильным ключам (key = message.id) // автоматически сохраняет позицию благодаря стабильным ключам (key = message.id)
@@ -595,7 +670,250 @@ fun ChatDetailScreen(
// <20>🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default // <20>🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
// (dedup + sort + date headers off the main thread) // (dedup + sort + date headers off the main thread)
val messagesWithDates by viewModel.messagesWithDates.collectAsState() 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 -> val scrollToMessage: (String) -> Unit = { messageId ->
scope.launch { scope.launch {
@@ -635,10 +953,13 @@ fun ChatDetailScreen(
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) || val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
user.username.equals("rosetta", ignoreCase = true) || user.username.equals("rosetta", ignoreCase = true) ||
isSystemAccount isSystemAccount
val groupMembersSubtitleCount = groupMembersCount ?: 0
val groupMembersSubtitle =
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}"
val chatSubtitle = val chatSubtitle =
when { when {
isSavedMessages -> "Notes" isSavedMessages -> "Notes"
isGroupChat -> "group" isGroupChat -> groupMembersSubtitle
isTyping -> "" // Пустая строка, используем компонент TypingIndicator isTyping -> "" // Пустая строка, используем компонент TypingIndicator
isOnline -> "online" isOnline -> "online"
isSystemAccount -> "official account" isSystemAccount -> "official account"
@@ -721,6 +1042,15 @@ fun ChatDetailScreen(
.maxWithOrNull(compareBy<ChatMessage>({ it.timestamp.time }, { it.id })) .maxWithOrNull(compareBy<ChatMessage>({ it.timestamp.time }, { it.id }))
?.id ?.id
var lastNewestMessageId by remember { mutableStateOf<String?>(null) } 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: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации) // Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
// 🔥 Скроллим только если изменился ID самого нового сообщения // 🔥 Скроллим только если изменился ID самого нового сообщения
@@ -730,10 +1060,15 @@ fun ChatDetailScreen(
lastNewestMessageId != null && lastNewestMessageId != null &&
newestMessageId != lastNewestMessageId newestMessageId != lastNewestMessageId
) { ) {
// Новое сообщение пришло - скроллим вниз val newestMessage = messages.firstOrNull { it.id == newestMessageId }
delay(50) // Debounce - ждём стабилизации val isOwnOutgoingMessage = newestMessage?.isOutgoing == true
listState.animateScrollToItem(0) val shouldAutoScroll = isAtBottom || isOwnOutgoingMessage
wasManualScroll = false
if (shouldAutoScroll) {
delay(50) // Debounce - ждём стабилизации
listState.animateScrollToItem(0)
wasManualScroll = false
}
} }
lastNewestMessageId = newestMessageId lastNewestMessageId = newestMessageId
} }
@@ -1141,7 +1476,8 @@ fun ChatDetailScreen(
color = Color.White, color = Color.White,
maxLines = 1, maxLines = 1,
overflow = android.text.TextUtils.TruncateAt.END, overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = false enableLinks = false,
minHeightMultiplier = 1.1f
) )
if (!isSavedMessages && if (!isSavedMessages &&
!isGroupChat && !isGroupChat &&
@@ -1226,12 +1562,8 @@ fun ChatDetailScreen(
Box { Box {
IconButton( IconButton(
onClick = { onClick = {
// Закрываем // Закрываем клавиатуру/emoji перед открытием меню
// клавиатуру перед открытием меню hideInputOverlays()
keyboardController
?.hide()
focusManager
.clearFocus()
showMenu = showMenu =
true true
}, },
@@ -1277,6 +1609,7 @@ fun ChatDetailScreen(
onGroupInfoClick = { onGroupInfoClick = {
showMenu = showMenu =
false false
hideInputOverlays()
onGroupInfoClick( onGroupInfoClick(
user user
) )
@@ -1284,6 +1617,7 @@ fun ChatDetailScreen(
onSearchMembersClick = { onSearchMembersClick = {
showMenu = showMenu =
false false
hideInputOverlays()
onGroupInfoClick( onGroupInfoClick(
user user
) )
@@ -1847,11 +2181,10 @@ fun ChatDetailScreen(
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale // Keep wallpaper on a fixed full-screen layer so it doesn't rescale
// when content paddings (bottom bar/IME) change. // when content paddings (bottom bar/IME) change.
if (chatWallpaperResId != null) { if (chatWallpaperResId != null) {
Image( TiledChatWallpaper(
painter = painterResource(id = chatWallpaperResId), wallpaperResId = chatWallpaperResId,
contentDescription = "Chat wallpaper",
modifier = Modifier.matchParentSize(), modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop tileScale = 0.9f
) )
} else { } else {
Box( Box(
@@ -1998,7 +2331,7 @@ fun ChatDetailScreen(
} }
} }
// Есть сообщения // Есть сообщения
else -> else -> {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = modifier =
@@ -2050,36 +2383,6 @@ fun ChatDetailScreen(
index, index,
(message, showDate) (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 = val prevMessage =
messagesWithDates messagesWithDates
.getOrNull( .getOrNull(
@@ -2087,18 +2390,59 @@ fun ChatDetailScreen(
1 1
) )
?.first ?.first
val nextMessage =
messagesWithDates
.getOrNull(
index +
1
)
?.first
val senderPublicKeyForMessage =
resolveSenderPublicKey(
message
)
// Для reverseLayout + DESC списка:
// prev = более новое сообщение,
// next = более старое.
val showTail =
isMessageBoundary(message, prevMessage)
val isGroupStart = val isGroupStart =
prevMessage != isMessageBoundary(message, nextMessage)
null && val runHeadIndex =
(prevMessage messageRunNewestIndex.getOrNull(
.isOutgoing != index
message.isOutgoing || ) ?: index
(prevMessage val runTailIndex =
.timestamp messageRunOldestIndexByHead
.time - .getOrNull(
message.timestamp runHeadIndex
.time) > )
60_000) ?: 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 { Column {
if (showDate if (showDate
@@ -2110,19 +2454,11 @@ fun ChatDetailScreen(
.time .time
), ),
secondaryTextColor = secondaryTextColor =
secondaryTextColor dateHeaderTextColor
) )
} }
val selectionKey = val selectionKey =
message.id message.id
val senderPublicKeyForMessage =
if (message.senderPublicKey.isNotBlank()) {
message.senderPublicKey
} else if (message.isOutgoing) {
currentUserPublicKey
} else {
user.publicKey
}
MessageBubble( MessageBubble(
message = message =
message, message,
@@ -2134,6 +2470,8 @@ fun ChatDetailScreen(
isSelectionMode, isSelectionMode,
showTail = showTail =
showTail, showTail,
showIncomingGroupAvatar =
showIncomingGroupAvatar,
isGroupStart = isGroupStart =
isGroupStart, isGroupStart,
isSelected = isSelected =
@@ -2154,9 +2492,12 @@ fun ChatDetailScreen(
message.senderName, message.senderName,
isGroupChat = isGroupChat =
isGroupChat, isGroupChat,
dialogPublicKey =
user.publicKey,
showGroupSenderLabel = showGroupSenderLabel =
isGroupChat && isGroupChat &&
!message.isOutgoing, !message.isOutgoing &&
isGroupStart,
isGroupSenderAdmin = isGroupSenderAdmin =
isGroupChat && isGroupChat &&
senderPublicKeyForMessage senderPublicKeyForMessage
@@ -2203,12 +2544,21 @@ fun ChatDetailScreen(
.clearFocus() .clearFocus()
showEmojiPicker = showEmojiPicker =
false false
toggleMessageSelection( selectMessageOnLongPress(
selectionKey, selectionKey,
true true
) )
suppressTapAfterLongPress(
selectionKey
)
}, },
onClick = { onClick = {
if (shouldIgnoreTapAfterLongPress(
selectionKey
)
) {
return@MessageBubble
}
val hasAvatar = val hasAvatar =
message.attachments message.attachments
.any { .any {
@@ -2306,6 +2656,12 @@ fun ChatDetailScreen(
true true
) )
}, },
onAvatarClick = {
avatarOwnerPublicKey ->
openProfileByPublicKey(
avatarOwnerPublicKey
)
},
onForwardedSenderClick = { senderPublicKey -> onForwardedSenderClick = { senderPublicKey ->
// Open profile of the forwarded message sender // Open profile of the forwarded message sender
scope.launch { 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 } // Конец 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) // 📎 Media Picker — new tab-based ChatAttachAlert (Telegram-style)
// Feature flag: set USE_NEW_ATTACH_ALERT to false to use old MediaPickerBottomSheet // Feature flag: set USE_NEW_ATTACH_ALERT to false to use old MediaPickerBottomSheet
val USE_NEW_ATTACH_ALERT = true val USE_NEW_ATTACH_ALERT = true
@@ -2944,3 +3399,62 @@ fun ChatDetailScreen(
} // Закрытие outer Box } // Закрытие 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() // 🔥 Сохраняем ссылки на обработчики для очистки в onCleared()
// ВАЖНО: Должны быть определены ДО init блока! // ВАЖНО: Должны быть определены ДО init блока!
private val typingPacketHandler: (Packet) -> Unit = { packet -> private val typingPacketHandler: (Packet) -> Unit = typingPacketHandler@{ packet ->
val typingPacket = packet as PacketTyping 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() showTypingIndicator()
} }
} }
@@ -4163,7 +4186,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
// 📁 Для Saved Messages - не отправляем typing indicator // 📁 Для Saved Messages - не отправляем typing indicator
if (opponent == sender || isGroupDialogKey(opponent)) { if (opponent.equals(sender, ignoreCase = true)) {
return return
} }

View File

@@ -192,6 +192,28 @@ private fun isGroupDialogKey(value: String): Boolean {
return normalized.startsWith("#group:") || normalized.startsWith("group:") 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_AVATAR_START = 10.dp
private val TELEGRAM_DIALOG_TEXT_START = 72.dp private val TELEGRAM_DIALOG_TEXT_START = 72.dp
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
@@ -442,9 +464,29 @@ fun ChatsListScreen(
val syncInProgress by ProtocolManager.syncInProgress.collectAsState() val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// <EFBFBD> Active downloads tracking (for header indicator) // 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads)
val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState() val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() }
val hasActiveDownloads = activeDownloads.isNotEmpty() 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>🔥 Пользователи, которые сейчас печатают // <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState() val typingUsers by ProtocolManager.typingUsers.collectAsState()
@@ -473,6 +515,7 @@ fun ChatsListScreen(
// 📬 Requests screen state // 📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) } var showRequestsScreen by remember { mutableStateOf(false) }
var showDownloadsScreen by remember { mutableStateOf(false) }
var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) } var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) }
var isRequestsRouteTapLocked by remember { mutableStateOf(false) } var isRequestsRouteTapLocked by remember { mutableStateOf(false) }
val inlineRequestsTransitionLockMs = 340L val inlineRequestsTransitionLockMs = 340L
@@ -498,6 +541,10 @@ fun ChatsListScreen(
} }
} }
LaunchedEffect(currentAccountKey) {
showDownloadsScreen = false
}
// 📂 Accounts section expanded state (arrow toggle) // 📂 Accounts section expanded state (arrow toggle)
var accountsSectionExpanded by remember { mutableStateOf(false) } var accountsSectionExpanded by remember { mutableStateOf(false) }
@@ -535,8 +582,10 @@ fun ChatsListScreen(
// Back: drawer → закрыть, selection → сбросить // Back: drawer → закрыть, selection → сбросить
// Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно // Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно
BackHandler(enabled = isSelectionMode || drawerState.isOpen) { BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) {
if (isSelectionMode) { if (showDownloadsScreen) {
showDownloadsScreen = false
} else if (isSelectionMode) {
selectedChatKeys = emptySet() selectedChatKeys = emptySet()
} else if (drawerState.isOpen) { } else if (drawerState.isOpen) {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@@ -709,7 +758,7 @@ fun ChatsListScreen(
) { ) {
ModalNavigationDrawer( ModalNavigationDrawer(
drawerState = drawerState, drawerState = drawerState,
gesturesEnabled = !showRequestsScreen, gesturesEnabled = !showRequestsScreen && !showDownloadsScreen,
drawerContent = { drawerContent = {
ModalDrawerSheet( ModalDrawerSheet(
drawerContainerColor = Color.Transparent, drawerContainerColor = Color.Transparent,
@@ -1335,7 +1384,12 @@ fun ChatsListScreen(
) { ) {
Scaffold( Scaffold(
topBar = { topBar = {
key(isDarkTheme, showRequestsScreen, isSelectionMode) { key(
isDarkTheme,
showRequestsScreen,
showDownloadsScreen,
isSelectionMode
) {
Crossfade( Crossfade(
targetState = isSelectionMode, targetState = isSelectionMode,
animationSpec = tween(200), animationSpec = tween(200),
@@ -1473,12 +1527,16 @@ fun ChatsListScreen(
// ═══ NORMAL HEADER ═══ // ═══ NORMAL HEADER ═══
TopAppBar( TopAppBar(
navigationIcon = { navigationIcon = {
if (showRequestsScreen) { if (showRequestsScreen || showDownloadsScreen) {
IconButton( IconButton(
onClick = { onClick = {
setInlineRequestsVisible( if (showDownloadsScreen) {
false showDownloadsScreen = false
) } else {
setInlineRequestsVisible(
false
)
}
} }
) { ) {
Icon( Icon(
@@ -1557,7 +1615,14 @@ fun ChatsListScreen(
} }
}, },
title = { title = {
if (showRequestsScreen) { if (showDownloadsScreen) {
Text(
"Downloads",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = Color.White
)
} else if (showRequestsScreen) {
Text( Text(
"Requests", "Requests",
fontWeight = fontWeight =
@@ -1596,10 +1661,18 @@ fun ChatsListScreen(
} }
}, },
actions = { actions = {
if (!showRequestsScreen) { if (!showRequestsScreen && !showDownloadsScreen) {
// 📥 Animated download indicator (Telegram-style) // 📥 Animated download indicator (Telegram-style)
Box( 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 contentAlignment = Alignment.Center
) { ) {
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator( com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
@@ -1709,39 +1782,105 @@ fun ChatsListScreen(
val showSkeleton = isLoading val showSkeleton = isLoading
// 🎬 Animated content transition between main list and
// requests
AnimatedContent( AnimatedContent(
targetState = showRequestsScreen, targetState = showDownloadsScreen,
transitionSpec = { transitionSpec = {
if (targetState) { if (targetState) {
// Opening requests: slide in from right // Opening downloads: slide from right with fade
slideInHorizontally( slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing) animationSpec =
) { fullWidth -> fullWidth } + fadeIn( tween(
280,
easing =
FastOutSlowInEasing
)
) { fullWidth ->
fullWidth
} + fadeIn(
animationSpec = tween(200) animationSpec = tween(200)
) togetherWith ) togetherWith
slideOutHorizontally( slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing) animationSpec =
) { fullWidth -> -fullWidth / 4 } + fadeOut( tween(
280,
easing =
FastOutSlowInEasing
)
) { fullWidth ->
-fullWidth / 4
} + fadeOut(
animationSpec = tween(150) animationSpec = tween(150)
) )
} else { } else {
// Closing requests: slide out to right // Closing downloads: slide back to right
slideInHorizontally( slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing) animationSpec =
) { fullWidth -> -fullWidth / 4 } + fadeIn( tween(
280,
easing =
FastOutSlowInEasing
)
) { fullWidth ->
-fullWidth / 4
} + fadeIn(
animationSpec = tween(200) animationSpec = tween(200)
) togetherWith ) togetherWith
slideOutHorizontally( slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing) animationSpec =
) { fullWidth -> fullWidth } + fadeOut( tween(
280,
easing =
FastOutSlowInEasing
)
) { fullWidth ->
fullWidth
} + fadeOut(
animationSpec = tween(150) animationSpec = tween(150)
) )
} }
}, },
label = "RequestsTransition" label = "DownloadsTransition"
) { isRequestsScreen -> ) { 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) { if (isRequestsScreen) {
// 📬 Show Requests Screen with swipe-back // 📬 Show Requests Screen with swipe-back
Box( Box(
@@ -2054,13 +2193,14 @@ fun ChatsListScreen(
) )
val isTyping by val isTyping by
remember( remember(
dialog.opponentKey dialog.opponentKey,
typingUsers
) { ) {
derivedStateOf { derivedStateOf {
typingUsers isTypingForDialog(
.contains( dialog.opponentKey,
dialog.opponentKey typingUsers
) )
} }
} }
val isSelectedDialog = val isSelectedDialog =
@@ -2241,7 +2381,9 @@ fun ChatsListScreen(
} }
} }
} }
} // Close AnimatedContent } // Close AnimatedContent
} // Close downloads/main content switch
} // Close Downloads AnimatedContent
// Console button removed // Console button removed
} }
@@ -3746,7 +3888,11 @@ fun DialogItemContent(
MessageRepository.isSystemAccount(dialog.opponentKey) MessageRepository.isSystemAccount(dialog.opponentKey)
if (dialog.verified > 0 || isRosettaOfficial) { if (dialog.verified > 0 || isRosettaOfficial) {
Spacer(modifier = Modifier.width(4.dp)) 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) { 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 */ /** 🎨 Enhanced Drawer Menu Item - пункт меню в стиле Telegram */
@Composable @Composable
fun DrawerMenuItemEnhanced( fun DrawerMenuItemEnhanced(

View File

@@ -1,8 +1,12 @@
package com.rosetta.messenger.ui.chats package com.rosetta.messenger.ui.chats
import android.app.Activity import android.app.Activity
import android.content.Context
import android.view.inputmethod.InputMethodManager
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.BackHandler 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.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.geometry.Size
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalView 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.SharedMediaFastScrollOverlay
import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.icons.TelegramIcons 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 androidx.lifecycle.viewmodel.compose.viewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale 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( private data class GroupMediaItem(
val key: String, val key: String,
val attachment: MessageAttachment, val attachment: MessageAttachment,
@@ -238,6 +319,7 @@ fun GroupInfoScreen(
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current val context = androidx.compose.ui.platform.LocalContext.current
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -253,6 +335,20 @@ fun GroupInfoScreen(
val secondaryText = Color(0xFF8E8E93) val secondaryText = Color(0xFF8E8E93)
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) 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. // Keep status bar unified with group header color.
DisposableEffect(topSurfaceColor, view) { DisposableEffect(topSurfaceColor, view) {
@@ -299,19 +395,40 @@ fun GroupInfoScreen(
var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var encryptionKeyLoading by remember { mutableStateOf(false) } 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 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 showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) } 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) { val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) {
"${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}" "${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?>( val groupEntity by produceState<com.rosetta.messenger.database.GroupEntity?>(
initialValue = null, initialValue = null,
@@ -390,6 +507,49 @@ fun GroupInfoScreen(
groupEntity?.description?.trim().orEmpty() 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) { LaunchedEffect(currentUserPublicKey, dialogPublicKey) {
if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) { if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) {
isMuted = preferencesManager.isChatMuted(currentUserPublicKey, dialogPublicKey) 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 val shouldShowLoader = showLoader && members.isEmpty() && !hasAnyCache
if (shouldShowLoader) membersLoading = true if (shouldShowLoader) membersLoading = true
isRefreshingMembers = true isRefreshingMembers = true
@@ -455,6 +617,7 @@ fun GroupInfoScreen(
members = members, members = members,
memberInfoByKey = memberInfoByKey.toMap() memberInfoByKey = memberInfoByKey.toMap()
) )
GroupMembersDiskCache.put(context, membersCacheKey, members)
} finally { } finally {
if (shouldShowLoader) membersLoading = false if (shouldShowLoader) membersLoading = false
isRefreshingMembers = false isRefreshingMembers = false
@@ -463,15 +626,44 @@ fun GroupInfoScreen(
} }
LaunchedEffect(membersCacheKey) { LaunchedEffect(membersCacheKey) {
val cachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey) val memoryCachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey)
cachedEntry?.let { cached -> val diskCachedEntry = GroupMembersDiskCache.getAny(context, membersCacheKey)
memoryCachedEntry?.let { cached ->
members = cached.members members = cached.members
memberInfoByKey.clear() memberInfoByKey.clear()
memberInfoByKey.putAll(cached.memberInfoByKey) 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) { val hasFreshCache =
refreshMembers(force = true, showLoader = cachedEntry == null) 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 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 swipedMemberKey by remember(dialogPublicKey) { mutableStateOf<String?>(null) }
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) } var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) } var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
@@ -700,6 +906,7 @@ fun GroupInfoScreen(
isLeaving = false isLeaving = false
if (left) { if (left) {
GroupMembersMemoryCache.remove(membersCacheKey) GroupMembersMemoryCache.remove(membersCacheKey)
GroupMembersDiskCache.remove(context, membersCacheKey)
onGroupLeft() onGroupLeft()
} else { } else {
Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show()
@@ -744,6 +951,7 @@ fun GroupInfoScreen(
members = members, members = members,
memberInfoByKey = memberInfoByKey.toMap() memberInfoByKey = memberInfoByKey.toMap()
) )
GroupMembersDiskCache.put(context, membersCacheKey, members)
refreshMembers(force = true, showLoader = false) refreshMembers(force = true, showLoader = false)
Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show()
} else { } else {
@@ -777,12 +985,12 @@ fun GroupInfoScreen(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(backgroundColor) .background(backgroundColor)
.statusBarsPadding()
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(topSurfaceColor) .background(topSurfaceColor)
.statusBarsPadding()
.padding(horizontal = 14.dp, vertical = 10.dp) .padding(horizontal = 14.dp, vertical = 10.dp)
) { ) {
IconButton(onClick = onBack, modifier = Modifier.align(Alignment.TopStart)) { IconButton(onClick = onBack, modifier = Modifier.align(Alignment.TopStart)) {
@@ -861,6 +1069,13 @@ fun GroupInfoScreen(
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
size = 86.dp, size = 86.dp,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
onClick = {
if (currentUserIsAdmin) {
showGroupAvatarPicker = true
} else {
openGroupAvatarViewer()
}
},
displayName = groupTitle displayName = groupTitle
) )
@@ -877,7 +1092,7 @@ fun GroupInfoScreen(
) )
Text( Text(
text = if (membersLoading) { text = if (membersLoading || (members.isEmpty() && !hasInitialMembersCache)) {
"Loading members..." "Loading members..."
} else { } else {
"${members.size} members, $onlineCount online" "${members.size} members, $onlineCount online"
@@ -896,16 +1111,18 @@ fun GroupInfoScreen(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
icon = Icons.Default.Message, icon = Icons.Default.Message,
label = "Message", label = "Message",
backgroundColor = cardColor, backgroundColor = groupActionButtonBlue,
contentColor = actionContentColor, contentColor = Color.White,
iconColor = Color.White,
onClick = onBack onClick = onBack
) )
GroupActionButton( GroupActionButton(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
icon = if (isMuted) Icons.Default.Notifications else Icons.Default.NotificationsOff, icon = if (isMuted) Icons.Default.Notifications else Icons.Default.NotificationsOff,
label = if (isMuted) "Unmute" else "Mute", label = if (isMuted) "Unmute" else "Mute",
backgroundColor = cardColor, backgroundColor = groupActionButtonBlue,
contentColor = actionContentColor, contentColor = Color.White,
iconColor = Color.White,
onClick = { onClick = {
scope.launch { scope.launch {
val newMutedState = !isMuted val newMutedState = !isMuted
@@ -922,8 +1139,9 @@ fun GroupInfoScreen(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
icon = Icons.Default.ExitToApp, icon = Icons.Default.ExitToApp,
label = "Leave", label = "Leave",
backgroundColor = cardColor, backgroundColor = groupActionButtonBlue,
contentColor = Color(0xFFFF7A7A), contentColor = Color.White,
iconColor = Color.White,
onClick = { showLeaveConfirm = true } onClick = { showLeaveConfirm = true }
) )
} }
@@ -1335,11 +1553,7 @@ fun GroupInfoScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
topSurfaceColor = topSurfaceColor, topSurfaceColor = topSurfaceColor,
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
onBack = { showEncryptionPage = false }, onBack = { showEncryptionPage = false }
onCopy = {
clipboardManager.setText(AnnotatedString(encryptionKey))
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
}
) )
} }
@@ -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 @Composable
@@ -1456,8 +1691,7 @@ private fun GroupEncryptionKeyPage(
isDarkTheme: Boolean, isDarkTheme: Boolean,
topSurfaceColor: Color, topSurfaceColor: Color,
backgroundColor: Color, backgroundColor: Color,
onBack: () -> Unit, onBack: () -> Unit
onCopy: () -> Unit
) { ) {
val uriHandler = LocalUriHandler.current val uriHandler = LocalUriHandler.current
val safePeerTitle = peerTitle.ifBlank { "this group" } val safePeerTitle = peerTitle.ifBlank { "this group" }
@@ -1477,13 +1711,13 @@ private fun GroupEncryptionKeyPage(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.statusBarsPadding()
) { ) {
// Top bar // Top bar
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(topSurfaceColor) .background(topSurfaceColor)
.statusBarsPadding()
.padding(horizontal = 4.dp, vertical = 6.dp), .padding(horizontal = 4.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
@@ -1500,14 +1734,6 @@ private fun GroupEncryptionKeyPage(
fontSize = 20.sp, fontSize = 20.sp,
fontWeight = FontWeight.SemiBold 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 // Two-half layout like Telegram
@@ -1595,6 +1821,7 @@ private fun GroupActionButton(
label: String, label: String,
backgroundColor: Color, backgroundColor: Color,
contentColor: Color, contentColor: Color,
iconColor: Color = contentColor,
onClick: () -> Unit onClick: () -> Unit
) { ) {
Surface( Surface(
@@ -1613,7 +1840,7 @@ private fun GroupActionButton(
Icon( Icon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = contentColor, tint = iconColor,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Spacer(modifier = Modifier.height(4.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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.rosetta.messenger.R import com.rosetta.messenger.R
@@ -149,6 +150,10 @@ fun GroupSetupScreen(
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93) val secondaryTextColor = Color(0xFF8E8E93)
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) 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) { androidx.compose.runtime.DisposableEffect(topSurfaceColor, view) {
val window = (view.context as? Activity)?.window 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() { fun handleBack() {
if (isLoading) return if (isLoading) return
errorText = null errorText = null
if (step == GroupSetupStep.DESCRIPTION) { if (step == GroupSetupStep.DESCRIPTION) {
step = GroupSetupStep.DETAILS step = GroupSetupStep.DETAILS
} else { } else {
dismissInputUi()
onBack() onBack()
} }
} }
@@ -402,7 +423,7 @@ fun GroupSetupScreen(
Modifier Modifier
.size(64.dp) .size(64.dp)
.clip(CircleShape) .clip(CircleShape)
.background(sectionColor) .background(groupAvatarCameraButtonColor)
.clickable(enabled = !isLoading) { .clickable(enabled = !isLoading) {
showPhotoPicker = true showPhotoPicker = true
}, },
@@ -423,7 +444,7 @@ fun GroupSetupScreen(
Icon( Icon(
painter = TelegramIcons.Camera, painter = TelegramIcons.Camera,
contentDescription = "Set group avatar", contentDescription = "Set group avatar",
tint = accentColor, tint = groupAvatarCameraIconColor,
modifier = Modifier.size(24.dp) modifier = Modifier.size(24.dp)
) )
} }
@@ -713,6 +734,7 @@ fun GroupSetupScreen(
avatarUriString = selectedAvatarUri avatarUriString = selectedAvatarUri
) )
} }
dismissInputUi()
openGroup( openGroup(
dialogPublicKey = result.dialogPublicKey, dialogPublicKey = result.dialogPublicKey,
groupTitle = result.title.ifBlank { title.trim() } 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.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
@@ -446,9 +448,22 @@ private fun ChatsTabContent(
val dividerColor = remember(isDarkTheme) { val dividerColor = remember(isDarkTheme) {
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) 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) ─── // ─── Загрузка частых контактов из БД (top dialogs) ───
var frequentContacts by remember { mutableStateOf<List<FrequentContact>>(emptyList()) } 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) { LaunchedEffect(currentUserPublicKey) {
if (currentUserPublicKey.isBlank()) return@LaunchedEffect if (currentUserPublicKey.isBlank()) return@LaunchedEffect
@@ -478,6 +493,14 @@ private fun ChatsTabContent(
} catch (_: Exception) { } } 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()) { Box(modifier = Modifier.fillMaxSize()) {
if (searchQuery.isEmpty()) { if (searchQuery.isEmpty()) {
@@ -486,10 +509,10 @@ private fun ChatsTabContent(
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
// ─── Горизонтальный ряд частых контактов (как в Telegram) ─── // ─── Горизонтальный ряд частых контактов (как в Telegram) ───
if (frequentContacts.isNotEmpty()) { if (visibleFrequentContacts.isNotEmpty()) {
item { item {
FrequentContactsRow( FrequentContactsRow(
contacts = frequentContacts, contacts = visibleFrequentContacts,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
textColor = textColor, textColor = textColor,
@@ -504,6 +527,9 @@ private fun ChatsTabContent(
) )
RecentSearchesManager.addUser(user) RecentSearchesManager.addUser(user)
onUserSelect(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 @Composable
@OptIn(ExperimentalFoundationApi::class)
private fun FrequentContactsRow( private fun FrequentContactsRow(
contacts: List<FrequentContact>, contacts: List<FrequentContact>,
avatarRepository: AvatarRepository?, avatarRepository: AvatarRepository?,
isDarkTheme: Boolean, isDarkTheme: Boolean,
textColor: Color, textColor: Color,
onClick: (FrequentContact) -> Unit onClick: (FrequentContact) -> Unit,
onLongPress: (FrequentContact) -> Unit
) { ) {
val haptic = LocalHapticFeedback.current
LazyRow( LazyRow(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -687,10 +779,15 @@ private fun FrequentContactsRow(
Column( Column(
modifier = Modifier modifier = Modifier
.width(72.dp) .width(72.dp)
.clickable( .combinedClickable(
indication = null, indication = null,
interactionSource = remember { MutableInteractionSource() } interactionSource = remember { MutableInteractionSource() },
) { onClick(contact) } onClick = { onClick(contact) },
onLongClick = {
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
onLongPress(contact)
}
)
.padding(vertical = 4.dp), .padding(vertical = 4.dp),
horizontalAlignment = Alignment.CenterHorizontally 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 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? { private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
if (!isGroupStoredKey(storedKey)) return null if (!isGroupStoredKey(storedKey)) return null
val encoded = storedKey.removePrefix("group:") val encoded = storedKey.removePrefix("group:")
@@ -332,6 +345,8 @@ fun MessageAttachments(
isOutgoing: Boolean, isOutgoing: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean,
senderPublicKey: String, senderPublicKey: String,
dialogPublicKey: String = "",
isGroupChat: Boolean = false,
timestamp: java.util.Date, timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ, messageStatus: MessageStatus = MessageStatus.READ,
avatarRepository: AvatarRepository? = null, avatarRepository: AvatarRepository? = null,
@@ -380,6 +395,7 @@ fun MessageAttachments(
attachment = attachment, attachment = attachment,
chachaKey = chachaKey, chachaKey = chachaKey,
privateKey = privateKey, privateKey = privateKey,
currentUserPublicKey = currentUserPublicKey,
isOutgoing = isOutgoing, isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
timestamp = timestamp, timestamp = timestamp,
@@ -392,6 +408,8 @@ fun MessageAttachments(
chachaKey = chachaKey, chachaKey = chachaKey,
privateKey = privateKey, privateKey = privateKey,
senderPublicKey = senderPublicKey, senderPublicKey = senderPublicKey,
dialogPublicKey = dialogPublicKey,
isGroupChat = isGroupChat,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
currentUserPublicKey = currentUserPublicKey, currentUserPublicKey = currentUserPublicKey,
isOutgoing = isOutgoing, isOutgoing = isOutgoing,
@@ -1427,6 +1445,7 @@ fun FileAttachment(
attachment: MessageAttachment, attachment: MessageAttachment,
chachaKey: String, chachaKey: String,
privateKey: String, privateKey: String,
currentUserPublicKey: String = "",
isOutgoing: Boolean, isOutgoing: Boolean,
isDarkTheme: Boolean, isDarkTheme: Boolean,
timestamp: java.util.Date, timestamp: java.util.Date,
@@ -1540,6 +1559,7 @@ fun FileAttachment(
downloadTag = downloadTag, downloadTag = downloadTag,
chachaKey = chachaKey, chachaKey = chachaKey,
privateKey = privateKey, privateKey = privateKey,
accountPublicKey = currentUserPublicKey,
fileName = fileName, fileName = fileName,
savedFile = savedFile savedFile = savedFile
) )
@@ -1775,6 +1795,8 @@ fun AvatarAttachment(
chachaKey: String, chachaKey: String,
privateKey: String, privateKey: String,
senderPublicKey: String, senderPublicKey: String,
dialogPublicKey: String = "",
isGroupChat: Boolean = false,
avatarRepository: AvatarRepository?, avatarRepository: AvatarRepository?,
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
isOutgoing: Boolean, isOutgoing: Boolean,
@@ -1924,11 +1946,16 @@ fun AvatarAttachment(
// Сохраняем аватар в репозиторий (для UI обновления) // Сохраняем аватар в репозиторий (для UI обновления)
// Если это исходящее сообщение с аватаром, сохраняем для текущего // Если это исходящее сообщение с аватаром, сохраняем для текущего
// пользователя // пользователя
val normalizedDialogKey = dialogPublicKey.trim()
val canonicalDialogKey = canonicalGroupDialogKey(normalizedDialogKey)
val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey)
val targetPublicKey = val targetPublicKey =
if (isOutgoing && currentUserPublicKey.isNotEmpty()) { when {
currentUserPublicKey isGroupAvatarAttachment && canonicalDialogKey.isNotEmpty() ->
} else { canonicalDialogKey
senderPublicKey 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.models.*
import com.rosetta.messenger.ui.chats.utils.* import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.components.AppleEmojiText 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.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AttachmentFileManager
import com.vanniktech.blurhash.BlurHash import com.vanniktech.blurhash.BlurHash
@@ -289,6 +290,7 @@ fun MessageBubble(
isSystemSafeChat: Boolean = false, isSystemSafeChat: Boolean = false,
isSelectionMode: Boolean = false, isSelectionMode: Boolean = false,
showTail: Boolean = true, showTail: Boolean = true,
showIncomingGroupAvatar: Boolean? = null,
isGroupStart: Boolean = false, isGroupStart: Boolean = false,
isSelected: Boolean = false, isSelected: Boolean = false,
isHighlighted: Boolean = false, isHighlighted: Boolean = false,
@@ -297,6 +299,7 @@ fun MessageBubble(
senderPublicKey: String = "", senderPublicKey: String = "",
senderName: String = "", senderName: String = "",
isGroupChat: Boolean = false, isGroupChat: Boolean = false,
dialogPublicKey: String = "",
showGroupSenderLabel: Boolean = false, showGroupSenderLabel: Boolean = false,
isGroupSenderAdmin: Boolean = false, isGroupSenderAdmin: Boolean = false,
currentUserPublicKey: String = "", currentUserPublicKey: String = "",
@@ -309,6 +312,7 @@ fun MessageBubble(
onRetry: () -> Unit = {}, onRetry: () -> Unit = {},
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onAvatarClick: (senderPublicKey: String) -> Unit = {},
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onMentionClick: (username: String) -> Unit = {}, onMentionClick: (username: String) -> Unit = {},
onGroupInviteOpen: (SearchUser) -> Unit = {}, onGroupInviteOpen: (SearchUser) -> Unit = {},
@@ -531,6 +535,16 @@ fun MessageBubble(
val combinedBackgroundColor = val combinedBackgroundColor =
if (isSelected) selectionBackgroundColor else highlightBackgroundColor 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( Row(
modifier = modifier =
Modifier.fillMaxWidth() Modifier.fillMaxWidth()
@@ -542,7 +556,7 @@ fun MessageBubble(
) { ) {
// Selection checkmark // Selection checkmark
AnimatedVisibility( AnimatedVisibility(
visible = isSelected, visible = isSelected && message.isOutgoing,
enter = enter =
fadeIn(tween(150)) + fadeIn(tween(150)) +
scaleIn( scaleIn(
@@ -568,16 +582,63 @@ fun MessageBubble(
} }
} }
AnimatedVisibility( AnimatedVisibility(
visible = !isSelected, visible = !isSelected || !message.isOutgoing,
enter = fadeIn(tween(100)), enter = fadeIn(tween(100)),
exit = fadeOut(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) { if (message.isOutgoing) {
Spacer(modifier = Modifier.weight(1f)) 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 = val hasOnlyMedia =
message.attachments.isNotEmpty() && message.attachments.isNotEmpty() &&
@@ -707,7 +768,8 @@ fun MessageBubble(
Box( Box(
modifier = modifier =
Modifier.padding(end = 12.dp) Modifier.align(Alignment.Bottom)
.padding(end = 12.dp)
.then(bubbleWidthModifier) .then(bubbleWidthModifier)
.graphicsLayer { .graphicsLayer {
this.alpha = selectionAlpha this.alpha = selectionAlpha
@@ -846,6 +908,8 @@ fun MessageBubble(
isOutgoing = message.isOutgoing, isOutgoing = message.isOutgoing,
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
senderPublicKey = senderPublicKey, senderPublicKey = senderPublicKey,
dialogPublicKey = dialogPublicKey,
isGroupChat = isGroupChat,
timestamp = message.timestamp, timestamp = message.timestamp,
messageStatus = attachmentDisplayStatus, messageStatus = attachmentDisplayStatus,
avatarRepository = avatarRepository, avatarRepository = avatarRepository,
@@ -1170,6 +1234,37 @@ fun MessageBubble(
// 💬 Context menu anchor (DropdownMenu positions relative to this Box) // 💬 Context menu anchor (DropdownMenu positions relative to this Box)
contextMenuContent() 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 { private fun groupSenderLabelColor(publicKey: String, isDarkTheme: Boolean): Color {
val paletteDark = // Match nickname color with avatar initials color.
listOf( return com.rosetta.messenger.ui.chats.getAvatarColor(publicKey, isDarkTheme).textColor
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]
} }
@Composable @Composable

View File

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

View File

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