Compare commits
21 Commits
9625763b0c
...
982dfc5dff
| Author | SHA1 | Date | |
|---|---|---|---|
| 982dfc5dff | |||
| c2e27cf543 | |||
| 41faa98130 | |||
| 1b3c4c8cea | |||
| f35596f18d | |||
| ce376d340f | |||
| 5e908a6d0c | |||
| b0a41b2831 | |||
| c7d5c47dd0 | |||
| 85bddb798c | |||
| 364b166581 | |||
| 56ba0bd42a | |||
| 59499a8f85 | |||
| 896d54df4d | |||
| c674a1ea99 | |||
| c5737e51b0 | |||
| b62ff7d7c4 | |||
| 429025537f | |||
| 6a269f93db | |||
| 8bce15cc19 | |||
| e9944b3c67 |
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
|||||||
@@ -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
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ВАЖНО: ждем завершения сохранения в репозиторий
|
// ВАЖНО: ждем завершения сохранения в репозиторий
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user