Релиз 1.1.5: ускорено подключение и исправлены группы
All checks were successful
Android Kernel Build / build (push) Successful in 16h12m4s
All checks were successful
Android Kernel Build / build (push) Successful in 16h12m4s
This commit is contained in:
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.1.4"
|
||||
val rosettaVersionCode = 16 // Increment on each release
|
||||
val rosettaVersionName = "1.1.5"
|
||||
val rosettaVersionCode = 17 // Increment on each release
|
||||
|
||||
android {
|
||||
namespace = "com.rosetta.messenger"
|
||||
|
||||
@@ -496,6 +496,7 @@ private fun EncryptedAccount.toAccountInfo(): AccountInfo {
|
||||
*/
|
||||
sealed class Screen {
|
||||
data object Profile : Screen()
|
||||
data object ProfileFromChat : Screen()
|
||||
data object Requests : Screen()
|
||||
data object Search : Screen()
|
||||
data object GroupSetup : Screen()
|
||||
@@ -600,6 +601,9 @@ fun MainScreen(
|
||||
|
||||
// Derived visibility — only triggers recomposition when THIS screen changes
|
||||
val isProfileVisible by remember { derivedStateOf { navStack.any { it is Screen.Profile } } }
|
||||
val isProfileFromChatVisible by remember {
|
||||
derivedStateOf { navStack.any { it is Screen.ProfileFromChat } }
|
||||
}
|
||||
val isRequestsVisible by remember { derivedStateOf { navStack.any { it is Screen.Requests } } }
|
||||
val isSearchVisible by remember { derivedStateOf { navStack.any { it is Screen.Search } } }
|
||||
val isGroupSetupVisible by remember { derivedStateOf { navStack.any { it is Screen.GroupSetup } } }
|
||||
@@ -662,11 +666,18 @@ fun MainScreen(
|
||||
navStack = navStack.dropLast(1)
|
||||
}
|
||||
fun openOwnProfile() {
|
||||
navStack =
|
||||
val filteredStack =
|
||||
navStack.filterNot {
|
||||
it is Screen.ChatDetail || it is Screen.OtherProfile || it is Screen.GroupInfo
|
||||
}
|
||||
pushScreen(Screen.Profile)
|
||||
// Single state update avoids intermediate frame (chat list flash/jitter) when opening
|
||||
// profile from a mention inside chat.
|
||||
navStack =
|
||||
if (filteredStack.lastOrNull() == Screen.Profile) {
|
||||
filteredStack
|
||||
} else {
|
||||
filteredStack + Screen.Profile
|
||||
}
|
||||
}
|
||||
fun popProfileAndChildren() {
|
||||
navStack =
|
||||
@@ -1003,8 +1014,9 @@ fun MainScreen(
|
||||
onBack = { popChatAndChildren() },
|
||||
onUserProfileClick = { user ->
|
||||
if (isCurrentAccountUser(user)) {
|
||||
// Свой профиль — открываем My Profile
|
||||
openOwnProfile()
|
||||
// Свой профиль из чата открываем поверх текущего чата,
|
||||
// чтобы возврат оставался в этот чат, а не в chat list.
|
||||
pushScreen(Screen.ProfileFromChat)
|
||||
} else {
|
||||
// Открываем профиль другого пользователя
|
||||
pushScreen(Screen.OtherProfile(user))
|
||||
@@ -1028,6 +1040,39 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
SwipeBackContainer(
|
||||
isVisible = isProfileFromChatVisible,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } },
|
||||
isDarkTheme = isDarkTheme,
|
||||
layer = 1,
|
||||
propagateBackgroundProgress = false
|
||||
) {
|
||||
ProfileScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
accountName = accountName,
|
||||
accountUsername = accountUsername,
|
||||
accountVerified = accountVerified,
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKeyHash = privateKeyHash,
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.ProfileFromChat } },
|
||||
onSaveProfile = { name, username ->
|
||||
accountName = name
|
||||
accountUsername = username
|
||||
mainScreenScope.launch { onAccountInfoUpdated() }
|
||||
},
|
||||
onLogout = onLogout,
|
||||
onNavigateToTheme = { pushScreen(Screen.Theme) },
|
||||
onNavigateToAppearance = { pushScreen(Screen.Appearance) },
|
||||
onNavigateToSafety = { pushScreen(Screen.Safety) },
|
||||
onNavigateToLogs = { pushScreen(Screen.Logs) },
|
||||
onNavigateToBiometric = { pushScreen(Screen.Biometric) },
|
||||
viewModel = profileViewModel,
|
||||
avatarRepository = avatarRepository,
|
||||
dialogDao = RosettaDatabase.getDatabase(context).dialogDao(),
|
||||
backgroundBlurColorId = backgroundBlurColorId
|
||||
)
|
||||
}
|
||||
|
||||
var isGroupInfoSwipeEnabled by remember { mutableStateOf(true) }
|
||||
LaunchedEffect(selectedGroup?.publicKey) {
|
||||
isGroupInfoSwipeEnabled = true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.rosetta.messenger.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.database.GroupEntity
|
||||
import com.rosetta.messenger.database.MessageEntity
|
||||
@@ -23,6 +24,7 @@ import kotlin.coroutines.resume
|
||||
|
||||
class GroupRepository private constructor(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val db = RosettaDatabase.getDatabase(context.applicationContext)
|
||||
private val groupDao = db.groupDao()
|
||||
private val messageDao = db.messageDao()
|
||||
@@ -31,9 +33,11 @@ class GroupRepository private constructor(context: Context) {
|
||||
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GroupRepository"
|
||||
private const val GROUP_PREFIX = "#group:"
|
||||
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
|
||||
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
|
||||
private const val GROUP_CREATED_MARKER = "\$a=Group created"
|
||||
|
||||
@Volatile
|
||||
private var INSTANCE: GroupRepository? = null
|
||||
@@ -232,7 +236,20 @@ class GroupRepository private constructor(context: Context) {
|
||||
return GroupJoinResult(success = false, error = "Failed to construct invite")
|
||||
}
|
||||
|
||||
return joinGroup(accountPublicKey, accountPrivateKey, invite)
|
||||
val joinResult = joinGroup(accountPublicKey, accountPrivateKey, invite)
|
||||
|
||||
if (joinResult.success) {
|
||||
val dialogPublicKey = joinResult.dialogPublicKey
|
||||
if (!dialogPublicKey.isNullOrBlank()) {
|
||||
emitGroupCreatedMarker(
|
||||
accountPublicKey = accountPublicKey,
|
||||
accountPrivateKey = accountPrivateKey,
|
||||
dialogPublicKey = dialogPublicKey
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return joinResult
|
||||
}
|
||||
|
||||
suspend fun joinGroup(
|
||||
@@ -455,6 +472,23 @@ class GroupRepository private constructor(context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitGroupCreatedMarker(
|
||||
accountPublicKey: String,
|
||||
accountPrivateKey: String,
|
||||
dialogPublicKey: String
|
||||
) {
|
||||
try {
|
||||
val messages = MessageRepository.getInstance(appContext)
|
||||
messages.initialize(accountPublicKey, accountPrivateKey)
|
||||
messages.sendMessage(
|
||||
toPublicKey = dialogPublicKey,
|
||||
text = GROUP_CREATED_MARKER
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to emit group-created marker for sync visibility", e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
|
||||
val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey)
|
||||
return "group:$encrypted"
|
||||
|
||||
@@ -764,10 +764,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
groupKey
|
||||
)
|
||||
|
||||
// 📸 Обрабатываем AVATAR attachments - сохраняем аватар отправителя
|
||||
// 📸 Обрабатываем AVATAR attachments:
|
||||
// в личке — сохраняем аватар отправителя, в группе — аватар группы (desktop parity)
|
||||
val avatarOwnerKey =
|
||||
if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey
|
||||
processAvatarAttachments(
|
||||
packet.attachments,
|
||||
packet.fromPublicKey,
|
||||
avatarOwnerKey,
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce,
|
||||
@@ -1176,6 +1179,11 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun toGroupDialogPublicKey(value: String): String {
|
||||
val groupId = normalizeGroupId(value)
|
||||
return if (groupId.isBlank()) value.trim() else "#group:$groupId"
|
||||
}
|
||||
|
||||
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
|
||||
return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
|
||||
}
|
||||
@@ -1510,7 +1518,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
*/
|
||||
private suspend fun processAvatarAttachments(
|
||||
attachments: List<MessageAttachment>,
|
||||
fromPublicKey: String,
|
||||
avatarOwnerKey: String,
|
||||
encryptedKey: String,
|
||||
privateKey: String,
|
||||
plainKeyAndNonce: ByteArray? = null,
|
||||
@@ -1540,18 +1548,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
if (decryptedBlob != null) {
|
||||
// 2. Сохраняем аватар в кэш
|
||||
val filePath =
|
||||
AvatarFileManager.saveAvatar(context, decryptedBlob, fromPublicKey)
|
||||
AvatarFileManager.saveAvatar(context, decryptedBlob, avatarOwnerKey)
|
||||
|
||||
val entity =
|
||||
AvatarCacheEntity(
|
||||
publicKey = fromPublicKey,
|
||||
publicKey = avatarOwnerKey,
|
||||
avatar = filePath,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
avatarDao.insertAvatar(entity)
|
||||
|
||||
// 3. Очищаем старые аватары (оставляем последние 5)
|
||||
avatarDao.deleteOldAvatars(fromPublicKey, 5)
|
||||
avatarDao.deleteOldAvatars(avatarOwnerKey, 5)
|
||||
} else {}
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
@@ -17,20 +17,17 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Профиль и группы
|
||||
- Фиксированные табы в профиле и группах
|
||||
- Fast-scroll с отображением даты в медиа-галерее
|
||||
- Поддержка Apple Emoji в аватарах и интерфейсе
|
||||
- Восстановление ключей шифрования группы по инвайт-ссылке
|
||||
Подключение
|
||||
- Ускорен старт соединения и handshake при входе в аккаунт
|
||||
- Логика reconnect синхронизирована с desktop-поведением
|
||||
- Обновлён серверный endpoint на основной production (wss)
|
||||
|
||||
Аватары
|
||||
- Улучшено отображение аватаров: поддержка текста с эмодзи
|
||||
- Улучшена логика отображения в компоненте AvatarImage
|
||||
Группы
|
||||
- Добавлено предзагруженное кэширование участников группы
|
||||
- Убран скачок "0 members" при повторном открытии группы
|
||||
|
||||
Исправления
|
||||
- Исправлен переход по своему тэгу в группах
|
||||
- Убрана лишняя подсветка в чатах
|
||||
- Корректное отображение fast-scroll при изменении размера экрана
|
||||
Интерфейс
|
||||
- Исправлено вертикальное выравнивание verified-галочки в списке чатов
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -42,12 +42,18 @@ interface AvatarDao {
|
||||
*/
|
||||
@Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC")
|
||||
fun getAvatars(publicKey: String): Flow<List<AvatarCacheEntity>>
|
||||
|
||||
@Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC")
|
||||
fun getAvatarsByKeys(publicKeys: List<String>): Flow<List<AvatarCacheEntity>>
|
||||
|
||||
/**
|
||||
* Получить последний аватар пользователя
|
||||
*/
|
||||
@Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC LIMIT 1")
|
||||
suspend fun getLatestAvatar(publicKey: String): AvatarCacheEntity?
|
||||
|
||||
@Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC LIMIT 1")
|
||||
suspend fun getLatestAvatarByKeys(publicKeys: List<String>): AvatarCacheEntity?
|
||||
|
||||
/**
|
||||
* Получить последний аватар пользователя как Flow
|
||||
|
||||
@@ -12,7 +12,9 @@ data class FileDownloadState(
|
||||
val fileName: String,
|
||||
val status: FileDownloadStatus,
|
||||
/** 0f..1f */
|
||||
val progress: Float = 0f
|
||||
val progress: Float = 0f,
|
||||
val accountPublicKey: String = "",
|
||||
val savedPath: String = ""
|
||||
)
|
||||
|
||||
enum class FileDownloadStatus {
|
||||
@@ -84,17 +86,27 @@ object FileDownloadManager {
|
||||
downloadTag: String,
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
accountPublicKey: String,
|
||||
fileName: String,
|
||||
savedFile: File
|
||||
) {
|
||||
// Уже в процессе?
|
||||
if (jobs[attachmentId]?.isActive == true) return
|
||||
val normalizedAccount = accountPublicKey.trim()
|
||||
val savedPath = savedFile.absolutePath
|
||||
|
||||
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f)
|
||||
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath)
|
||||
|
||||
jobs[attachmentId] = scope.launch {
|
||||
try {
|
||||
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DOWNLOADING,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
|
||||
// Запускаем polling прогресса из TransportManager
|
||||
val progressJob = launch {
|
||||
@@ -103,34 +115,87 @@ object FileDownloadManager {
|
||||
if (entry != null) {
|
||||
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
|
||||
val p = (entry.progress / 100f) * 0.8f
|
||||
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, p)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DOWNLOADING,
|
||||
p,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
if (isGroupStoredKey(chachaKey)) {
|
||||
downloadGroupFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
|
||||
downloadGroupFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
fileName = fileName,
|
||||
savedFile = savedFile,
|
||||
accountPublicKey = normalizedAccount,
|
||||
savedPath = savedPath
|
||||
)
|
||||
} else {
|
||||
downloadDirectFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
|
||||
downloadDirectFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
fileName = fileName,
|
||||
savedFile = savedFile,
|
||||
accountPublicKey = normalizedAccount,
|
||||
savedPath = savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
progressJob.cancel()
|
||||
|
||||
if (success) {
|
||||
update(attachmentId, fileName, FileDownloadStatus.DONE, 1f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DONE,
|
||||
1f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
} else {
|
||||
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
} catch (_: OutOfMemoryError) {
|
||||
System.gc()
|
||||
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
} finally {
|
||||
jobs.remove(attachmentId)
|
||||
// Автоочистка через 5 секунд после завершения
|
||||
@@ -159,25 +224,55 @@ object FileDownloadManager {
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
fileName: String,
|
||||
savedFile: File
|
||||
savedFile: File,
|
||||
accountPublicKey: String,
|
||||
savedPath: String
|
||||
): Boolean {
|
||||
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
|
||||
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.82f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DECRYPTING,
|
||||
0.82f,
|
||||
accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
|
||||
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
|
||||
if (groupPassword.isNullOrBlank()) return false
|
||||
|
||||
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
|
||||
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DECRYPTING,
|
||||
0.88f,
|
||||
accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
|
||||
val bytes = decrypted?.let { decodeBase64Payload(it) } ?: return false
|
||||
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.93f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DECRYPTING,
|
||||
0.93f,
|
||||
accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
savedFile.parentFile?.mkdirs()
|
||||
savedFile.writeBytes(bytes)
|
||||
}
|
||||
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DECRYPTING,
|
||||
0.98f,
|
||||
accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -187,14 +282,30 @@ object FileDownloadManager {
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
fileName: String,
|
||||
savedFile: File
|
||||
savedFile: File,
|
||||
accountPublicKey: String,
|
||||
savedPath: String
|
||||
): Boolean {
|
||||
// Streaming: скачиваем во temp file
|
||||
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
|
||||
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.83f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DECRYPTING,
|
||||
0.83f,
|
||||
accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
|
||||
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DECRYPTING,
|
||||
0.88f,
|
||||
accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
|
||||
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -208,13 +319,36 @@ object FileDownloadManager {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DECRYPTING,
|
||||
0.98f,
|
||||
accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun update(id: String, fileName: String, status: FileDownloadStatus, progress: Float) {
|
||||
private fun update(
|
||||
id: String,
|
||||
fileName: String,
|
||||
status: FileDownloadStatus,
|
||||
progress: Float,
|
||||
accountPublicKey: String,
|
||||
savedPath: String
|
||||
) {
|
||||
_downloads.update { map ->
|
||||
map + (id to FileDownloadState(id, fileName, status, progress))
|
||||
map + (
|
||||
id to FileDownloadState(
|
||||
attachmentId = id,
|
||||
fileName = fileName,
|
||||
status = status,
|
||||
progress = progress,
|
||||
accountPublicKey = accountPublicKey,
|
||||
savedPath = savedPath
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,9 +29,10 @@ object ProtocolManager {
|
||||
private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L
|
||||
private const val MAX_DEBUG_LOGS = 600
|
||||
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
||||
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
|
||||
|
||||
// Server address - same as React Native version
|
||||
private const val SERVER_ADDRESS = "ws://46.28.71.12:3000"
|
||||
// Desktop parity: use the same primary WebSocket endpoint as desktop client.
|
||||
private const val SERVER_ADDRESS = "wss://wss.rosetta.im"
|
||||
private const val DEVICE_PREFS = "rosetta_protocol"
|
||||
private const val DEVICE_ID_KEY = "device_id"
|
||||
private const val DEVICE_ID_LENGTH = 128
|
||||
@@ -59,6 +60,9 @@ object ProtocolManager {
|
||||
// Typing status
|
||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
||||
private val typingStateLock = Any()
|
||||
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
|
||||
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
// Connected devices and pending verification requests
|
||||
private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList())
|
||||
@@ -200,6 +204,7 @@ object ProtocolManager {
|
||||
*/
|
||||
fun initializeAccount(publicKey: String, privateKey: String) {
|
||||
setSyncInProgress(false)
|
||||
clearTypingState()
|
||||
messageRepository?.initialize(publicKey, privateKey)
|
||||
if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) {
|
||||
resyncRequiredAfterAccountInit = false
|
||||
@@ -369,13 +374,26 @@ object ProtocolManager {
|
||||
// Обработчик typing (0x0B)
|
||||
waitPacket(0x0B) { packet ->
|
||||
val typingPacket = packet as PacketTyping
|
||||
|
||||
// Добавляем в set и удаляем через 3 секунды
|
||||
_typingUsers.value = _typingUsers.value + typingPacket.fromPublicKey
|
||||
scope.launch {
|
||||
delay(3000)
|
||||
_typingUsers.value = _typingUsers.value - typingPacket.fromPublicKey
|
||||
val fromPublicKey = typingPacket.fromPublicKey.trim()
|
||||
val toPublicKey = typingPacket.toPublicKey.trim()
|
||||
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return@waitPacket
|
||||
|
||||
val ownPublicKey =
|
||||
getProtocol().getPublicKey()?.trim().orEmpty().ifBlank {
|
||||
messageRepository?.getCurrentAccountKey()?.trim().orEmpty()
|
||||
}
|
||||
if (ownPublicKey.isNotBlank() && fromPublicKey.equals(ownPublicKey, ignoreCase = true)) {
|
||||
return@waitPacket
|
||||
}
|
||||
|
||||
val dialogKey =
|
||||
resolveTypingDialogKey(
|
||||
fromPublicKey = fromPublicKey,
|
||||
toPublicKey = toPublicKey,
|
||||
ownPublicKey = ownPublicKey
|
||||
) ?: return@waitPacket
|
||||
|
||||
rememberTypingEvent(dialogKey, fromPublicKey)
|
||||
}
|
||||
|
||||
// 📱 Обработчик списка устройств (0x17)
|
||||
@@ -508,6 +526,71 @@ object ProtocolManager {
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun normalizeGroupDialogKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
val normalized = trimmed.lowercase(Locale.ROOT)
|
||||
return when {
|
||||
normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}"
|
||||
normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}"
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveTypingDialogKey(
|
||||
fromPublicKey: String,
|
||||
toPublicKey: String,
|
||||
ownPublicKey: String
|
||||
): String? {
|
||||
return when {
|
||||
isGroupDialogKey(toPublicKey) -> normalizeGroupDialogKey(toPublicKey)
|
||||
ownPublicKey.isNotBlank() && toPublicKey.equals(ownPublicKey, ignoreCase = true) ->
|
||||
fromPublicKey.trim()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeTypingTimeoutKey(dialogKey: String, fromPublicKey: String): String {
|
||||
return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
|
||||
private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) {
|
||||
val normalizedDialogKey =
|
||||
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
|
||||
val normalizedFrom = fromPublicKey.trim()
|
||||
if (normalizedDialogKey.isBlank() || normalizedFrom.isBlank()) return
|
||||
|
||||
synchronized(typingStateLock) {
|
||||
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
|
||||
users.add(normalizedFrom)
|
||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||
}
|
||||
|
||||
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
|
||||
typingTimeoutJobs.remove(timeoutKey)?.cancel()
|
||||
typingTimeoutJobs[timeoutKey] =
|
||||
scope.launch {
|
||||
delay(TYPING_INDICATOR_TIMEOUT_MS)
|
||||
synchronized(typingStateLock) {
|
||||
val users = typingUsersByDialog[normalizedDialogKey]
|
||||
users?.remove(normalizedFrom)
|
||||
if (users.isNullOrEmpty()) {
|
||||
typingUsersByDialog.remove(normalizedDialogKey)
|
||||
}
|
||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||
}
|
||||
typingTimeoutJobs.remove(timeoutKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearTypingState() {
|
||||
typingTimeoutJobs.values.forEach { it.cancel() }
|
||||
typingTimeoutJobs.clear()
|
||||
synchronized(typingStateLock) {
|
||||
typingUsersByDialog.clear()
|
||||
_typingUsers.value = emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
private fun onAuthenticated() {
|
||||
setSyncInProgress(false)
|
||||
TransportManager.requestTransportServer()
|
||||
@@ -1021,6 +1104,7 @@ object ProtocolManager {
|
||||
protocol?.disconnect()
|
||||
protocol?.clearCredentials()
|
||||
messageRepository?.clearInitialization()
|
||||
clearTypingState()
|
||||
_devices.value = emptyList()
|
||||
_pendingDeviceVerification.value = null
|
||||
syncRequestInFlight = false
|
||||
@@ -1036,6 +1120,7 @@ object ProtocolManager {
|
||||
protocol?.destroy()
|
||||
protocol = null
|
||||
messageRepository?.clearInitialization()
|
||||
clearTypingState()
|
||||
_devices.value = emptyList()
|
||||
_pendingDeviceVerification.value = null
|
||||
syncRequestInFlight = false
|
||||
|
||||
@@ -163,11 +163,8 @@ class AuthStateManager(
|
||||
|
||||
// Step 8: Connect and authenticate with protocol
|
||||
ProtocolManager.connect()
|
||||
|
||||
// Give WebSocket time to connect before authenticating
|
||||
kotlinx.coroutines.delay(500)
|
||||
|
||||
ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash)
|
||||
ProtocolManager.reconnectNowIfNeeded("auth_state_create")
|
||||
|
||||
Result.success(decryptedAccount)
|
||||
} catch (e: Exception) {
|
||||
@@ -210,11 +207,8 @@ class AuthStateManager(
|
||||
|
||||
// Connect and authenticate with protocol
|
||||
ProtocolManager.connect()
|
||||
|
||||
// Give WebSocket time to connect before authenticating
|
||||
kotlinx.coroutines.delay(500)
|
||||
|
||||
ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash)
|
||||
ProtocolManager.reconnectNowIfNeeded("auth_state_unlock")
|
||||
|
||||
Result.success(decryptedAccount)
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -53,6 +53,36 @@ class AvatarRepository(
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeOwnerKey(publicKey: String): String {
|
||||
val trimmed = publicKey.trim()
|
||||
if (trimmed.isBlank()) return trimmed
|
||||
return when {
|
||||
trimmed.startsWith("#group:") -> {
|
||||
val groupId = trimmed.removePrefix("#group:").trim()
|
||||
if (groupId.isBlank()) trimmed else "#group:$groupId"
|
||||
}
|
||||
trimmed.startsWith("group:", ignoreCase = true) -> {
|
||||
val groupId = trimmed.substringAfter(':').trim()
|
||||
if (groupId.isBlank()) trimmed else "#group:$groupId"
|
||||
}
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun lookupKeys(publicKey: String): List<String> {
|
||||
val normalized = normalizeOwnerKey(publicKey)
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
val keys = linkedSetOf(normalized)
|
||||
if (normalized.startsWith("#group:")) {
|
||||
keys.add(normalized.removePrefix("#group:"))
|
||||
}
|
||||
val trimmed = publicKey.trim()
|
||||
if (trimmed.isNotBlank()) {
|
||||
keys.add(trimmed)
|
||||
}
|
||||
return keys.toList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить аватары пользователя
|
||||
@@ -60,14 +90,20 @@ class AvatarRepository(
|
||||
* @param allDecode true = вся история, false = только последний (для списков)
|
||||
*/
|
||||
fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow<List<AvatarInfo>> {
|
||||
val normalizedKey = normalizeOwnerKey(publicKey)
|
||||
val keys = lookupKeys(publicKey)
|
||||
if (normalizedKey.isBlank() || keys.isEmpty()) {
|
||||
return MutableStateFlow(emptyList<AvatarInfo>()).asStateFlow()
|
||||
}
|
||||
|
||||
// Проверяем LRU cache (accessOrder=true обновляет позицию при get)
|
||||
memoryCache[publicKey]?.let { return it.flow.asStateFlow() }
|
||||
memoryCache[normalizedKey]?.let { return it.flow.asStateFlow() }
|
||||
|
||||
// Создаем новый flow для этого пользователя
|
||||
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
|
||||
|
||||
// Подписываемся на изменения в БД
|
||||
val job = avatarDao.getAvatars(publicKey)
|
||||
val job = avatarDao.getAvatarsByKeys(keys)
|
||||
.onEach { entities ->
|
||||
val avatars = if (allDecode) {
|
||||
// Параллельная загрузка всей истории
|
||||
@@ -86,7 +122,7 @@ class AvatarRepository(
|
||||
}
|
||||
.launchIn(repositoryScope)
|
||||
|
||||
memoryCache[publicKey] = CacheEntry(flow, job)
|
||||
memoryCache[normalizedKey] = CacheEntry(flow, job)
|
||||
return flow.asStateFlow()
|
||||
}
|
||||
|
||||
@@ -94,7 +130,9 @@ class AvatarRepository(
|
||||
* Получить последний аватар пользователя (suspend версия)
|
||||
*/
|
||||
suspend fun getLatestAvatar(publicKey: String): AvatarInfo? {
|
||||
val entity = avatarDao.getLatestAvatar(publicKey) ?: return null
|
||||
val keys = lookupKeys(publicKey)
|
||||
if (keys.isEmpty()) return null
|
||||
val entity = avatarDao.getLatestAvatarByKeys(keys) ?: return null
|
||||
return loadAndDecryptAvatar(entity)
|
||||
}
|
||||
|
||||
@@ -108,22 +146,24 @@ class AvatarRepository(
|
||||
suspend fun saveAvatar(fromPublicKey: String, base64Image: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val ownerKey = normalizeOwnerKey(fromPublicKey)
|
||||
if (ownerKey.isBlank()) return@withContext
|
||||
// Сохраняем файл
|
||||
val filePath = AvatarFileManager.saveAvatar(context, base64Image, fromPublicKey)
|
||||
val filePath = AvatarFileManager.saveAvatar(context, base64Image, ownerKey)
|
||||
|
||||
// Сохраняем в БД
|
||||
val entity = AvatarCacheEntity(
|
||||
publicKey = fromPublicKey,
|
||||
publicKey = ownerKey,
|
||||
avatar = filePath,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
avatarDao.insertAvatar(entity)
|
||||
|
||||
// Очищаем старые аватары (оставляем только последние N)
|
||||
avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY)
|
||||
avatarDao.deleteOldAvatars(ownerKey, MAX_AVATAR_HISTORY)
|
||||
|
||||
// 🔄 Обновляем memory cache если он существует
|
||||
val cached = memoryCache[fromPublicKey]
|
||||
val cached = memoryCache[ownerKey]
|
||||
if (cached != null) {
|
||||
val avatarInfo = loadAndDecryptAvatar(entity)
|
||||
if (avatarInfo != null) {
|
||||
|
||||
@@ -2,20 +2,24 @@ package com.rosetta.messenger.ui.auth
|
||||
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
internal fun startAuthHandshakeFast(publicKey: String, privateKeyHash: String) {
|
||||
// Desktop parity: start connection+handshake immediately, without artificial waits.
|
||||
ProtocolManager.connect()
|
||||
ProtocolManager.authenticate(publicKey, privateKeyHash)
|
||||
ProtocolManager.reconnectNowIfNeeded("auth_fast_start")
|
||||
}
|
||||
|
||||
internal suspend fun awaitAuthHandshakeState(
|
||||
publicKey: String,
|
||||
privateKeyHash: String,
|
||||
attempts: Int = 2,
|
||||
timeoutMs: Long = 25_000L
|
||||
): ProtocolState? {
|
||||
repeat(attempts) {
|
||||
ProtocolManager.disconnect()
|
||||
delay(200)
|
||||
ProtocolManager.authenticate(publicKey, privateKeyHash)
|
||||
repeat(attempts) { attempt ->
|
||||
startAuthHandshakeFast(publicKey, privateKeyHash)
|
||||
|
||||
val state = withTimeoutOrNull(timeoutMs) {
|
||||
ProtocolManager.state.first {
|
||||
@@ -26,6 +30,7 @@ internal suspend fun awaitAuthHandshakeState(
|
||||
if (state != null) {
|
||||
return state
|
||||
}
|
||||
ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -527,17 +527,10 @@ fun SetPasswordScreen(
|
||||
keyPair.privateKey
|
||||
)
|
||||
|
||||
val handshakeState =
|
||||
awaitAuthHandshakeState(
|
||||
keyPair.publicKey,
|
||||
privateKeyHash
|
||||
)
|
||||
if (handshakeState == null) {
|
||||
error =
|
||||
"Failed to connect to server. Please try again."
|
||||
isCreating = false
|
||||
return@launch
|
||||
}
|
||||
startAuthHandshakeFast(
|
||||
keyPair.publicKey,
|
||||
privateKeyHash
|
||||
)
|
||||
|
||||
accountManager.setCurrentAccount(keyPair.publicKey)
|
||||
|
||||
|
||||
@@ -116,12 +116,7 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
||||
name = selectedAccount.name
|
||||
)
|
||||
|
||||
val handshakeState = awaitAuthHandshakeState(account.publicKey, privateKeyHash)
|
||||
if (handshakeState == null) {
|
||||
onError("Failed to connect to server")
|
||||
onUnlocking(false)
|
||||
return
|
||||
}
|
||||
startAuthHandshakeFast(account.publicKey, privateKeyHash)
|
||||
|
||||
accountManager.setCurrentAccount(account.publicKey)
|
||||
onSuccess(decryptedAccount)
|
||||
|
||||
@@ -2,12 +2,19 @@ package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Shader
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.net.Uri
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
@@ -55,6 +62,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalLifecycleOwner
|
||||
@@ -63,8 +71,10 @@ import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -107,6 +117,26 @@ import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private data class IncomingRunAvatarAccumulator(
|
||||
val senderPublicKey: String,
|
||||
val senderDisplayName: String,
|
||||
var minTopPx: Float,
|
||||
var maxBottomPx: Float
|
||||
)
|
||||
|
||||
private data class IncomingRunAvatarOverlay(
|
||||
val runHeadIndex: Int,
|
||||
val senderPublicKey: String,
|
||||
val senderDisplayName: String,
|
||||
val topPx: Float
|
||||
)
|
||||
|
||||
private data class IncomingRunAvatarUiState(
|
||||
val showOnRunHeads: Set<Int>,
|
||||
val showOnRunTails: Set<Int>,
|
||||
val overlays: List<IncomingRunAvatarOverlay>
|
||||
)
|
||||
|
||||
@OptIn(
|
||||
ExperimentalMaterial3Api::class,
|
||||
androidx.compose.foundation.ExperimentalFoundationApi::class,
|
||||
@@ -153,6 +183,7 @@ fun ChatDetailScreen(
|
||||
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
|
||||
val dateHeaderTextColor = if (isDarkTheme) Color.White else secondaryTextColor
|
||||
val headerIconColor = Color.White
|
||||
|
||||
// 🔥 Keyboard & Emoji Coordinator
|
||||
@@ -186,6 +217,10 @@ fun ChatDetailScreen(
|
||||
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
|
||||
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val isSelectionMode = selectedMessages.isNotEmpty()
|
||||
// После long press AndroidView текста может прислать tap на отпускание.
|
||||
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
|
||||
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
|
||||
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
|
||||
|
||||
// 💬 MESSAGE CONTEXT MENU STATE
|
||||
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
||||
@@ -210,13 +245,22 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
|
||||
val hideKeyboardAndBack: () -> Unit = {
|
||||
// Используем нативный InputMethodManager для МГНОВЕННОГО закрытия
|
||||
val hideInputOverlays: () -> Unit = {
|
||||
val imm =
|
||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
window?.let { win ->
|
||||
androidx.core.view.WindowCompat.getInsetsController(win, view)
|
||||
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
keyboardController?.hide()
|
||||
focusManager.clearFocus(force = true)
|
||||
showEmojiPicker = false
|
||||
}
|
||||
|
||||
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
|
||||
val hideKeyboardAndBack: () -> Unit = {
|
||||
hideInputOverlays()
|
||||
onBack()
|
||||
}
|
||||
|
||||
@@ -228,10 +272,7 @@ fun ChatDetailScreen(
|
||||
else user.title.ifEmpty { user.publicKey.take(10) }
|
||||
|
||||
val openDialogInfo: () -> Unit = {
|
||||
val imm =
|
||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
hideInputOverlays()
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
if (isGroupChat) {
|
||||
@@ -424,6 +465,9 @@ fun ChatDetailScreen(
|
||||
var groupAdminKeys by remember(user.publicKey, currentUserPublicKey) {
|
||||
mutableStateOf<Set<String>>(emptySet())
|
||||
}
|
||||
var groupMembersCount by remember(user.publicKey, currentUserPublicKey) {
|
||||
mutableStateOf<Int?>(null)
|
||||
}
|
||||
var mentionCandidates by remember(user.publicKey, currentUserPublicKey) {
|
||||
mutableStateOf<List<MentionCandidate>>(emptyList())
|
||||
}
|
||||
@@ -438,6 +482,7 @@ fun ChatDetailScreen(
|
||||
LaunchedEffect(isGroupChat, user.publicKey, currentUserPublicKey) {
|
||||
if (!isGroupChat || user.publicKey.isBlank() || currentUserPublicKey.isBlank()) {
|
||||
groupAdminKeys = emptySet()
|
||||
groupMembersCount = null
|
||||
mentionCandidates = emptyList()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
@@ -445,15 +490,20 @@ fun ChatDetailScreen(
|
||||
val members = withContext(Dispatchers.IO) {
|
||||
groupRepository.requestGroupMembers(user.publicKey).orEmpty()
|
||||
}
|
||||
val adminKey = members.firstOrNull().orEmpty()
|
||||
val normalizedMembers =
|
||||
members.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
groupMembersCount = normalizedMembers.size
|
||||
|
||||
val adminKey = normalizedMembers.firstOrNull().orEmpty()
|
||||
groupAdminKeys =
|
||||
if (adminKey.isBlank()) emptySet() else setOf(adminKey)
|
||||
|
||||
mentionCandidates =
|
||||
withContext(Dispatchers.IO) {
|
||||
members.map { it.trim() }
|
||||
.filter { it.isNotBlank() && !it.equals(currentUserPublicKey.trim(), ignoreCase = true) }
|
||||
.distinct()
|
||||
normalizedMembers
|
||||
.filter { !it.equals(currentUserPublicKey.trim(), ignoreCase = true) }
|
||||
.mapNotNull { memberKey ->
|
||||
val resolvedUser = viewModel.resolveUserForProfile(memberKey) ?: return@mapNotNull null
|
||||
val normalizedUsername = resolvedUser.username.trim().trimStart('@')
|
||||
@@ -522,6 +572,31 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Long press должен только включать selection для сообщения (идемпотентно),
|
||||
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
|
||||
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
|
||||
{ messageId, canSelect ->
|
||||
if (canSelect && !selectedMessages.contains(messageId)) {
|
||||
selectedMessages = selectedMessages + messageId
|
||||
}
|
||||
}
|
||||
val suppressTapAfterLongPress: (messageId: String) -> Unit =
|
||||
{ messageId ->
|
||||
longPressSuppressedMessageId = messageId
|
||||
longPressSuppressUntilMs = System.currentTimeMillis() + 350L
|
||||
}
|
||||
val shouldIgnoreTapAfterLongPress: (messageId: String) -> Boolean =
|
||||
{ messageId ->
|
||||
val now = System.currentTimeMillis()
|
||||
val isSuppressed =
|
||||
longPressSuppressedMessageId == messageId &&
|
||||
now <= longPressSuppressUntilMs
|
||||
if (isSuppressed || now > longPressSuppressUntilMs) {
|
||||
longPressSuppressedMessageId = null
|
||||
}
|
||||
isSuppressed
|
||||
}
|
||||
|
||||
// 🔥 ПАГИНАЦИЯ: Загружаем старые сообщения при прокрутке вверх
|
||||
// NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true
|
||||
// автоматически сохраняет позицию благодаря стабильным ключам (key = message.id)
|
||||
@@ -595,7 +670,250 @@ fun ChatDetailScreen(
|
||||
// <20>🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
|
||||
// (dedup + sort + date headers off the main thread)
|
||||
val messagesWithDates by viewModel.messagesWithDates.collectAsState()
|
||||
val resolveSenderPublicKey: (ChatMessage?) -> String =
|
||||
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
|
||||
{ msg ->
|
||||
when {
|
||||
msg == null -> ""
|
||||
msg.senderPublicKey.isNotBlank() -> msg.senderPublicKey.trim()
|
||||
msg.isOutgoing -> currentUserPublicKey.trim()
|
||||
isGroupChat -> ""
|
||||
else -> user.publicKey.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
val resolveSenderIdentity: (ChatMessage?) -> String =
|
||||
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
|
||||
{ msg ->
|
||||
when {
|
||||
msg == null -> ""
|
||||
msg.isOutgoing ->
|
||||
"out:${currentUserPublicKey.trim().lowercase(Locale.ROOT)}"
|
||||
msg.senderPublicKey.isNotBlank() ->
|
||||
"in:${msg.senderPublicKey.trim().lowercase(Locale.ROOT)}"
|
||||
isGroupChat && msg.senderName.isNotBlank() ->
|
||||
"name:${msg.senderName.trim().lowercase(Locale.ROOT)}"
|
||||
!isGroupChat ->
|
||||
"in:${user.publicKey.trim().lowercase(Locale.ROOT)}"
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
}
|
||||
val isMessageBoundary: (ChatMessage, ChatMessage?) -> Boolean =
|
||||
remember(isGroupChat, currentUserPublicKey, user.publicKey) {
|
||||
{ currentMessage, adjacentMessage ->
|
||||
if (adjacentMessage == null) {
|
||||
true
|
||||
} else if (adjacentMessage.isOutgoing != currentMessage.isOutgoing) {
|
||||
true
|
||||
} else if (
|
||||
kotlin.math.abs(
|
||||
currentMessage.timestamp.time -
|
||||
adjacentMessage.timestamp.time
|
||||
) > 60_000L
|
||||
) {
|
||||
true
|
||||
} else if (
|
||||
isGroupChat &&
|
||||
!currentMessage.isOutgoing &&
|
||||
!adjacentMessage.isOutgoing
|
||||
) {
|
||||
val currentSenderIdentity =
|
||||
resolveSenderIdentity(currentMessage)
|
||||
val adjacentSenderIdentity =
|
||||
resolveSenderIdentity(adjacentMessage)
|
||||
if (
|
||||
currentSenderIdentity.isBlank() ||
|
||||
adjacentSenderIdentity.isBlank()
|
||||
) {
|
||||
true
|
||||
} else {
|
||||
!currentSenderIdentity.equals(
|
||||
adjacentSenderIdentity,
|
||||
ignoreCase = true
|
||||
)
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
val messageRunNewestIndex =
|
||||
remember(messagesWithDates, isGroupChat, currentUserPublicKey, user.publicKey) {
|
||||
IntArray(messagesWithDates.size).also { runHeadByIndex ->
|
||||
messagesWithDates.indices.forEach { messageIndex ->
|
||||
if (messageIndex == 0) {
|
||||
runHeadByIndex[messageIndex] = messageIndex
|
||||
} else {
|
||||
val currentMessage = messagesWithDates[messageIndex].first
|
||||
val newerMessage = messagesWithDates[messageIndex - 1].first
|
||||
runHeadByIndex[messageIndex] =
|
||||
if (isMessageBoundary(currentMessage, newerMessage)) {
|
||||
messageIndex
|
||||
} else {
|
||||
runHeadByIndex[messageIndex - 1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val messageRunOldestIndexByHead =
|
||||
remember(messageRunNewestIndex) {
|
||||
IntArray(messageRunNewestIndex.size) { it }.also { runTailByHead ->
|
||||
messageRunNewestIndex.indices.forEach { messageIndex ->
|
||||
val runHeadIndex = messageRunNewestIndex[messageIndex]
|
||||
runTailByHead[runHeadIndex] = messageIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
val incomingRunAvatarSize = 42.dp
|
||||
val incomingRunAvatarInsetStart = 6.dp
|
||||
val incomingRunAvatarTopGuard = 4.dp
|
||||
val incomingRunAvatarUiState by remember(
|
||||
isGroupChat,
|
||||
messageRunNewestIndex,
|
||||
messageRunOldestIndexByHead,
|
||||
messagesWithDates,
|
||||
listState,
|
||||
density
|
||||
) {
|
||||
derivedStateOf {
|
||||
if (!isGroupChat || messagesWithDates.isEmpty()) {
|
||||
return@derivedStateOf IncomingRunAvatarUiState(
|
||||
showOnRunHeads = emptySet(),
|
||||
showOnRunTails = emptySet(),
|
||||
overlays = emptyList()
|
||||
)
|
||||
}
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
if (visibleItems.isEmpty()) {
|
||||
return@derivedStateOf IncomingRunAvatarUiState(
|
||||
showOnRunHeads = emptySet(),
|
||||
showOnRunTails = emptySet(),
|
||||
overlays = emptyList()
|
||||
)
|
||||
}
|
||||
|
||||
val avatarSizePx = with(density) { incomingRunAvatarSize.toPx() }
|
||||
val topGuardPx = with(density) { incomingRunAvatarTopGuard.toPx() }
|
||||
val viewportStart = layoutInfo.viewportStartOffset.toFloat()
|
||||
val viewportEnd = layoutInfo.viewportEndOffset.toFloat()
|
||||
val maxAvatarTop = viewportEnd - avatarSizePx
|
||||
val visibleIndexSet = visibleItems.map { it.index }.toHashSet()
|
||||
val visibleRuns = linkedMapOf<Int, IncomingRunAvatarAccumulator>()
|
||||
|
||||
visibleItems.forEach { itemInfo ->
|
||||
val visibleIndex = itemInfo.index
|
||||
if (visibleIndex !in messagesWithDates.indices) {
|
||||
return@forEach
|
||||
}
|
||||
val message = messagesWithDates[visibleIndex].first
|
||||
if (message.isOutgoing) {
|
||||
return@forEach
|
||||
}
|
||||
val senderPublicKey = resolveSenderPublicKey(message).trim()
|
||||
if (senderPublicKey.isBlank()) {
|
||||
return@forEach
|
||||
}
|
||||
val runHeadIndex =
|
||||
messageRunNewestIndex.getOrNull(visibleIndex)
|
||||
?: visibleIndex
|
||||
val itemTopPx = itemInfo.offset.toFloat()
|
||||
val itemBottomPx = (itemInfo.offset + itemInfo.size).toFloat()
|
||||
val currentRun = visibleRuns[runHeadIndex]
|
||||
if (currentRun == null) {
|
||||
visibleRuns[runHeadIndex] =
|
||||
IncomingRunAvatarAccumulator(
|
||||
senderPublicKey = senderPublicKey,
|
||||
senderDisplayName =
|
||||
message.senderName.ifBlank {
|
||||
senderPublicKey
|
||||
},
|
||||
minTopPx = itemTopPx,
|
||||
maxBottomPx = itemBottomPx
|
||||
)
|
||||
} else {
|
||||
if (itemTopPx < currentRun.minTopPx) {
|
||||
currentRun.minTopPx = itemTopPx
|
||||
}
|
||||
if (itemBottomPx > currentRun.maxBottomPx) {
|
||||
currentRun.maxBottomPx = itemBottomPx
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val showOnRunHeads = hashSetOf<Int>()
|
||||
val showOnRunTails = hashSetOf<Int>()
|
||||
val overlays = arrayListOf<IncomingRunAvatarOverlay>()
|
||||
|
||||
visibleRuns.forEach { (runHeadIndex, runData) ->
|
||||
val runTailIndex =
|
||||
messageRunOldestIndexByHead.getOrNull(runHeadIndex)
|
||||
?: runHeadIndex
|
||||
val isRunHeadVisible = visibleIndexSet.contains(runHeadIndex)
|
||||
val isRunTailVisible = visibleIndexSet.contains(runTailIndex)
|
||||
|
||||
when {
|
||||
isRunHeadVisible -> {
|
||||
// Start/default phase: keep avatar on the lower (newest) bubble.
|
||||
showOnRunHeads.add(runHeadIndex)
|
||||
}
|
||||
isRunTailVisible -> {
|
||||
// End phase: keep avatar attached to the last bubble in run.
|
||||
showOnRunTails.add(runHeadIndex)
|
||||
}
|
||||
else -> {
|
||||
// Middle phase: floating avatar while scrolling through run.
|
||||
var avatarTopPx =
|
||||
kotlin.math.min(
|
||||
runData.maxBottomPx - avatarSizePx,
|
||||
maxAvatarTop
|
||||
)
|
||||
val topClampPx = runData.minTopPx + topGuardPx
|
||||
if (avatarTopPx < topClampPx) {
|
||||
avatarTopPx = topClampPx
|
||||
}
|
||||
if (
|
||||
avatarTopPx + avatarSizePx > viewportStart &&
|
||||
avatarTopPx < viewportEnd
|
||||
) {
|
||||
overlays +=
|
||||
IncomingRunAvatarOverlay(
|
||||
runHeadIndex = runHeadIndex,
|
||||
senderPublicKey =
|
||||
runData.senderPublicKey,
|
||||
senderDisplayName =
|
||||
runData.senderDisplayName,
|
||||
topPx = avatarTopPx
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IncomingRunAvatarUiState(
|
||||
showOnRunHeads = showOnRunHeads,
|
||||
showOnRunTails = showOnRunTails,
|
||||
overlays = overlays.sortedBy { it.topPx }
|
||||
)
|
||||
}
|
||||
}
|
||||
val openProfileByPublicKey: (String) -> Unit = { rawPublicKey ->
|
||||
val normalizedPublicKey = rawPublicKey.trim()
|
||||
if (normalizedPublicKey.isNotBlank()) {
|
||||
scope.launch {
|
||||
val resolvedUser =
|
||||
viewModel.resolveUserForProfile(normalizedPublicKey)
|
||||
if (resolvedUser != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
onUserProfileClick(resolvedUser)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 🔥 Функция для скролла к сообщению с подсветкой
|
||||
val scrollToMessage: (String) -> Unit = { messageId ->
|
||||
scope.launch {
|
||||
@@ -635,10 +953,13 @@ fun ChatDetailScreen(
|
||||
val isRosettaOfficial = user.title.equals("Rosetta", ignoreCase = true) ||
|
||||
user.username.equals("rosetta", ignoreCase = true) ||
|
||||
isSystemAccount
|
||||
val groupMembersSubtitleCount = groupMembersCount ?: 0
|
||||
val groupMembersSubtitle =
|
||||
"$groupMembersSubtitleCount member${if (groupMembersSubtitleCount == 1) "" else "s"}"
|
||||
val chatSubtitle =
|
||||
when {
|
||||
isSavedMessages -> "Notes"
|
||||
isGroupChat -> "group"
|
||||
isGroupChat -> groupMembersSubtitle
|
||||
isTyping -> "" // Пустая строка, используем компонент TypingIndicator
|
||||
isOnline -> "online"
|
||||
isSystemAccount -> "official account"
|
||||
@@ -721,6 +1042,15 @@ fun ChatDetailScreen(
|
||||
.maxWithOrNull(compareBy<ChatMessage>({ it.timestamp.time }, { it.id }))
|
||||
?.id
|
||||
var lastNewestMessageId by remember { mutableStateOf<String?>(null) }
|
||||
val isAtBottom by remember(listState) {
|
||||
derivedStateOf {
|
||||
listState.firstVisibleItemIndex == 0 &&
|
||||
listState.firstVisibleItemScrollOffset <= 12
|
||||
}
|
||||
}
|
||||
val showScrollToBottomButton by remember(messagesWithDates, isAtBottom, isSendingMessage) {
|
||||
derivedStateOf { messagesWithDates.isNotEmpty() && !isAtBottom && !isSendingMessage }
|
||||
}
|
||||
|
||||
// Telegram-style: Прокрутка ТОЛЬКО при новых сообщениях (не при пагинации)
|
||||
// 🔥 Скроллим только если изменился ID самого нового сообщения
|
||||
@@ -730,10 +1060,15 @@ fun ChatDetailScreen(
|
||||
lastNewestMessageId != null &&
|
||||
newestMessageId != lastNewestMessageId
|
||||
) {
|
||||
// Новое сообщение пришло - скроллим вниз
|
||||
delay(50) // Debounce - ждём стабилизации
|
||||
listState.animateScrollToItem(0)
|
||||
wasManualScroll = false
|
||||
val newestMessage = messages.firstOrNull { it.id == newestMessageId }
|
||||
val isOwnOutgoingMessage = newestMessage?.isOutgoing == true
|
||||
val shouldAutoScroll = isAtBottom || isOwnOutgoingMessage
|
||||
|
||||
if (shouldAutoScroll) {
|
||||
delay(50) // Debounce - ждём стабилизации
|
||||
listState.animateScrollToItem(0)
|
||||
wasManualScroll = false
|
||||
}
|
||||
}
|
||||
lastNewestMessageId = newestMessageId
|
||||
}
|
||||
@@ -1141,7 +1476,8 @@ fun ChatDetailScreen(
|
||||
color = Color.White,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = false
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1.1f
|
||||
)
|
||||
if (!isSavedMessages &&
|
||||
!isGroupChat &&
|
||||
@@ -1226,12 +1562,8 @@ fun ChatDetailScreen(
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Закрываем
|
||||
// клавиатуру перед открытием меню
|
||||
keyboardController
|
||||
?.hide()
|
||||
focusManager
|
||||
.clearFocus()
|
||||
// Закрываем клавиатуру/emoji перед открытием меню
|
||||
hideInputOverlays()
|
||||
showMenu =
|
||||
true
|
||||
},
|
||||
@@ -1277,6 +1609,7 @@ fun ChatDetailScreen(
|
||||
onGroupInfoClick = {
|
||||
showMenu =
|
||||
false
|
||||
hideInputOverlays()
|
||||
onGroupInfoClick(
|
||||
user
|
||||
)
|
||||
@@ -1284,6 +1617,7 @@ fun ChatDetailScreen(
|
||||
onSearchMembersClick = {
|
||||
showMenu =
|
||||
false
|
||||
hideInputOverlays()
|
||||
onGroupInfoClick(
|
||||
user
|
||||
)
|
||||
@@ -1847,11 +2181,10 @@ fun ChatDetailScreen(
|
||||
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
|
||||
// when content paddings (bottom bar/IME) change.
|
||||
if (chatWallpaperResId != null) {
|
||||
Image(
|
||||
painter = painterResource(id = chatWallpaperResId),
|
||||
contentDescription = "Chat wallpaper",
|
||||
TiledChatWallpaper(
|
||||
wallpaperResId = chatWallpaperResId,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
tileScale = 0.9f
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
@@ -1998,7 +2331,7 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
// Есть сообщения
|
||||
else ->
|
||||
else -> {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier =
|
||||
@@ -2050,36 +2383,6 @@ fun ChatDetailScreen(
|
||||
index,
|
||||
(message, showDate)
|
||||
->
|
||||
// Определяем,
|
||||
// показывать ли
|
||||
// хвостик
|
||||
// (последнее
|
||||
// сообщение в
|
||||
// группе)
|
||||
val nextMessage =
|
||||
messagesWithDates
|
||||
.getOrNull(
|
||||
index +
|
||||
1
|
||||
)
|
||||
?.first
|
||||
val showTail =
|
||||
nextMessage ==
|
||||
null ||
|
||||
nextMessage
|
||||
.isOutgoing !=
|
||||
message.isOutgoing ||
|
||||
(message.timestamp
|
||||
.time -
|
||||
nextMessage
|
||||
.timestamp
|
||||
.time) >
|
||||
60_000
|
||||
|
||||
// Определяем начало
|
||||
// новой
|
||||
// группы (для
|
||||
// отступов)
|
||||
val prevMessage =
|
||||
messagesWithDates
|
||||
.getOrNull(
|
||||
@@ -2087,18 +2390,59 @@ fun ChatDetailScreen(
|
||||
1
|
||||
)
|
||||
?.first
|
||||
val nextMessage =
|
||||
messagesWithDates
|
||||
.getOrNull(
|
||||
index +
|
||||
1
|
||||
)
|
||||
?.first
|
||||
val senderPublicKeyForMessage =
|
||||
resolveSenderPublicKey(
|
||||
message
|
||||
)
|
||||
// Для reverseLayout + DESC списка:
|
||||
// prev = более новое сообщение,
|
||||
// next = более старое.
|
||||
val showTail =
|
||||
isMessageBoundary(message, prevMessage)
|
||||
val isGroupStart =
|
||||
prevMessage !=
|
||||
null &&
|
||||
(prevMessage
|
||||
.isOutgoing !=
|
||||
message.isOutgoing ||
|
||||
(prevMessage
|
||||
.timestamp
|
||||
.time -
|
||||
message.timestamp
|
||||
.time) >
|
||||
60_000)
|
||||
isMessageBoundary(message, nextMessage)
|
||||
val runHeadIndex =
|
||||
messageRunNewestIndex.getOrNull(
|
||||
index
|
||||
) ?: index
|
||||
val runTailIndex =
|
||||
messageRunOldestIndexByHead
|
||||
.getOrNull(
|
||||
runHeadIndex
|
||||
)
|
||||
?: runHeadIndex
|
||||
val isHeadPhase =
|
||||
incomingRunAvatarUiState
|
||||
.showOnRunHeads
|
||||
.contains(
|
||||
runHeadIndex
|
||||
)
|
||||
val isTailPhase =
|
||||
incomingRunAvatarUiState
|
||||
.showOnRunTails
|
||||
.contains(
|
||||
runHeadIndex
|
||||
)
|
||||
val showIncomingGroupAvatar =
|
||||
isGroupChat &&
|
||||
!message.isOutgoing &&
|
||||
senderPublicKeyForMessage
|
||||
.isNotBlank() &&
|
||||
((index ==
|
||||
runHeadIndex &&
|
||||
isHeadPhase &&
|
||||
showTail) ||
|
||||
(index ==
|
||||
runTailIndex &&
|
||||
isTailPhase &&
|
||||
isGroupStart))
|
||||
|
||||
Column {
|
||||
if (showDate
|
||||
@@ -2110,19 +2454,11 @@ fun ChatDetailScreen(
|
||||
.time
|
||||
),
|
||||
secondaryTextColor =
|
||||
secondaryTextColor
|
||||
dateHeaderTextColor
|
||||
)
|
||||
}
|
||||
val selectionKey =
|
||||
message.id
|
||||
val senderPublicKeyForMessage =
|
||||
if (message.senderPublicKey.isNotBlank()) {
|
||||
message.senderPublicKey
|
||||
} else if (message.isOutgoing) {
|
||||
currentUserPublicKey
|
||||
} else {
|
||||
user.publicKey
|
||||
}
|
||||
MessageBubble(
|
||||
message =
|
||||
message,
|
||||
@@ -2134,6 +2470,8 @@ fun ChatDetailScreen(
|
||||
isSelectionMode,
|
||||
showTail =
|
||||
showTail,
|
||||
showIncomingGroupAvatar =
|
||||
showIncomingGroupAvatar,
|
||||
isGroupStart =
|
||||
isGroupStart,
|
||||
isSelected =
|
||||
@@ -2154,9 +2492,12 @@ fun ChatDetailScreen(
|
||||
message.senderName,
|
||||
isGroupChat =
|
||||
isGroupChat,
|
||||
dialogPublicKey =
|
||||
user.publicKey,
|
||||
showGroupSenderLabel =
|
||||
isGroupChat &&
|
||||
!message.isOutgoing,
|
||||
!message.isOutgoing &&
|
||||
isGroupStart,
|
||||
isGroupSenderAdmin =
|
||||
isGroupChat &&
|
||||
senderPublicKeyForMessage
|
||||
@@ -2203,12 +2544,21 @@ fun ChatDetailScreen(
|
||||
.clearFocus()
|
||||
showEmojiPicker =
|
||||
false
|
||||
toggleMessageSelection(
|
||||
selectMessageOnLongPress(
|
||||
selectionKey,
|
||||
true
|
||||
)
|
||||
suppressTapAfterLongPress(
|
||||
selectionKey
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (shouldIgnoreTapAfterLongPress(
|
||||
selectionKey
|
||||
)
|
||||
) {
|
||||
return@MessageBubble
|
||||
}
|
||||
val hasAvatar =
|
||||
message.attachments
|
||||
.any {
|
||||
@@ -2306,6 +2656,12 @@ fun ChatDetailScreen(
|
||||
true
|
||||
)
|
||||
},
|
||||
onAvatarClick = {
|
||||
avatarOwnerPublicKey ->
|
||||
openProfileByPublicKey(
|
||||
avatarOwnerPublicKey
|
||||
)
|
||||
},
|
||||
onForwardedSenderClick = { senderPublicKey ->
|
||||
// Open profile of the forwarded message sender
|
||||
scope.launch {
|
||||
@@ -2461,10 +2817,109 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
if (incomingRunAvatarUiState.overlays.isNotEmpty()) {
|
||||
val avatarInsetPx =
|
||||
with(density) {
|
||||
incomingRunAvatarInsetStart
|
||||
.roundToPx()
|
||||
}
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.matchParentSize()
|
||||
.graphicsLayer {
|
||||
clip = true
|
||||
}
|
||||
) {
|
||||
incomingRunAvatarUiState.overlays.forEach { overlay ->
|
||||
key(
|
||||
overlay.runHeadIndex,
|
||||
overlay.senderPublicKey
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.offset {
|
||||
IntOffset(
|
||||
avatarInsetPx,
|
||||
overlay.topPx
|
||||
.toInt()
|
||||
)
|
||||
}
|
||||
) {
|
||||
AvatarImage(
|
||||
publicKey =
|
||||
overlay.senderPublicKey,
|
||||
avatarRepository =
|
||||
avatarRepository,
|
||||
size =
|
||||
incomingRunAvatarSize,
|
||||
isDarkTheme =
|
||||
isDarkTheme,
|
||||
onClick =
|
||||
if (isSelectionMode) null
|
||||
else {
|
||||
{
|
||||
openProfileByPublicKey(
|
||||
overlay.senderPublicKey
|
||||
)
|
||||
}
|
||||
},
|
||||
displayName =
|
||||
overlay.senderDisplayName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Конец Column внутри Scaffold content
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showScrollToBottomButton && !isLoading && !isSelectionMode,
|
||||
enter = fadeIn(animationSpec = tween(140)) + expandVertically(expandFrom = Alignment.Bottom),
|
||||
exit = fadeOut(animationSpec = tween(120)) + shrinkVertically(shrinkTowards = Alignment.Bottom),
|
||||
modifier =
|
||||
Modifier.align(Alignment.BottomEnd)
|
||||
.padding(
|
||||
end = 14.dp,
|
||||
bottom = if (isSystemAccount) 24.dp else 16.dp
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(38.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF2D2E31)
|
||||
else Color.White
|
||||
)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource =
|
||||
remember {
|
||||
MutableInteractionSource()
|
||||
}
|
||||
) {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(0)
|
||||
wasManualScroll = false
|
||||
}
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.ChevronDown,
|
||||
contentDescription = "Scroll to bottom",
|
||||
tint =
|
||||
if (isDarkTheme) Color(0xFFF2F2F3)
|
||||
else Color(0xFF2D3138),
|
||||
modifier = Modifier.size(21.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 📎 Media Picker — new tab-based ChatAttachAlert (Telegram-style)
|
||||
// Feature flag: set USE_NEW_ATTACH_ALERT to false to use old MediaPickerBottomSheet
|
||||
val USE_NEW_ATTACH_ALERT = true
|
||||
@@ -2944,3 +3399,62 @@ fun ChatDetailScreen(
|
||||
|
||||
} // Закрытие outer Box
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TiledChatWallpaper(
|
||||
wallpaperResId: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
tileScale: Float = 0.9f
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val wallpaperDrawable =
|
||||
remember(wallpaperResId, tileScale, context) {
|
||||
val decoded = BitmapFactory.decodeResource(context.resources, wallpaperResId)
|
||||
val normalizedScale = tileScale.coerceIn(0.2f, 2f)
|
||||
|
||||
val scaledBitmap =
|
||||
decoded?.let { original ->
|
||||
if (normalizedScale == 1f) {
|
||||
original
|
||||
} else {
|
||||
val width =
|
||||
(original.width * normalizedScale)
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
val height =
|
||||
(original.height * normalizedScale)
|
||||
.toInt()
|
||||
.coerceAtLeast(1)
|
||||
val scaled =
|
||||
Bitmap.createScaledBitmap(
|
||||
original,
|
||||
width,
|
||||
height,
|
||||
true
|
||||
)
|
||||
if (scaled != original) {
|
||||
original.recycle()
|
||||
}
|
||||
scaled
|
||||
}
|
||||
}
|
||||
|
||||
val safeBitmap =
|
||||
scaledBitmap
|
||||
?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
|
||||
.apply {
|
||||
eraseColor(android.graphics.Color.TRANSPARENT)
|
||||
}
|
||||
|
||||
BitmapDrawable(context.resources, safeBitmap).apply {
|
||||
setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
|
||||
gravity = Gravity.TOP or Gravity.START
|
||||
}
|
||||
}
|
||||
|
||||
AndroidView(
|
||||
modifier = modifier,
|
||||
factory = { ctx -> View(ctx).apply { background = wallpaperDrawable } },
|
||||
update = { view -> view.background = wallpaperDrawable }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -242,9 +242,32 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
// 🔥 Сохраняем ссылки на обработчики для очистки в onCleared()
|
||||
// ВАЖНО: Должны быть определены ДО init блока!
|
||||
private val typingPacketHandler: (Packet) -> Unit = { packet ->
|
||||
private val typingPacketHandler: (Packet) -> Unit = typingPacketHandler@{ packet ->
|
||||
val typingPacket = packet as PacketTyping
|
||||
if (typingPacket.fromPublicKey == opponentKey) {
|
||||
val currentDialog = opponentKey?.trim().orEmpty()
|
||||
val currentAccount = myPublicKey?.trim().orEmpty()
|
||||
if (currentDialog.isBlank() || currentAccount.isBlank()) {
|
||||
return@typingPacketHandler
|
||||
}
|
||||
|
||||
val fromPublicKey = typingPacket.fromPublicKey.trim()
|
||||
val toPublicKey = typingPacket.toPublicKey.trim()
|
||||
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) {
|
||||
return@typingPacketHandler
|
||||
}
|
||||
if (fromPublicKey.equals(currentAccount, ignoreCase = true)) {
|
||||
return@typingPacketHandler
|
||||
}
|
||||
|
||||
val shouldShowTyping =
|
||||
if (isGroupDialogKey(currentDialog)) {
|
||||
normalizeGroupId(toPublicKey).equals(normalizeGroupId(currentDialog), ignoreCase = true)
|
||||
} else {
|
||||
fromPublicKey.equals(currentDialog, ignoreCase = true) &&
|
||||
toPublicKey.equals(currentAccount, ignoreCase = true)
|
||||
}
|
||||
|
||||
if (shouldShowTyping) {
|
||||
showTypingIndicator()
|
||||
}
|
||||
}
|
||||
@@ -4163,7 +4186,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
// 📁 Для Saved Messages - не отправляем typing indicator
|
||||
if (opponent == sender || isGroupDialogKey(opponent)) {
|
||||
if (opponent.equals(sender, ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,28 @@ private fun isGroupDialogKey(value: String): Boolean {
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun normalizeGroupDialogKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
val normalized = trimmed.lowercase(Locale.ROOT)
|
||||
return when {
|
||||
normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}"
|
||||
normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}"
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTypingForDialog(dialogKey: String, typingDialogs: Set<String>): Boolean {
|
||||
if (typingDialogs.isEmpty()) return false
|
||||
if (isGroupDialogKey(dialogKey)) {
|
||||
val normalizedDialogKey = normalizeGroupDialogKey(dialogKey)
|
||||
return typingDialogs.any {
|
||||
isGroupDialogKey(it) &&
|
||||
normalizeGroupDialogKey(it).equals(normalizedDialogKey, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
return typingDialogs.any { it.equals(dialogKey.trim(), ignoreCase = true) }
|
||||
}
|
||||
|
||||
private val TELEGRAM_DIALOG_AVATAR_START = 10.dp
|
||||
private val TELEGRAM_DIALOG_TEXT_START = 72.dp
|
||||
private val TELEGRAM_DIALOG_AVATAR_SIZE = 54.dp
|
||||
@@ -442,9 +464,29 @@ fun ChatsListScreen(
|
||||
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
|
||||
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
|
||||
|
||||
// <EFBFBD> Active downloads tracking (for header indicator)
|
||||
val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState()
|
||||
val hasActiveDownloads = activeDownloads.isNotEmpty()
|
||||
// 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads)
|
||||
val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() }
|
||||
val allFileDownloads by
|
||||
com.rosetta.messenger.network.FileDownloadManager.downloads.collectAsState()
|
||||
val accountFileDownloads = remember(allFileDownloads, currentAccountKey) {
|
||||
allFileDownloads.values
|
||||
.filter {
|
||||
it.accountPublicKey.equals(currentAccountKey, ignoreCase = true)
|
||||
}
|
||||
.sortedByDescending { it.progress }
|
||||
}
|
||||
val activeFileDownloads = remember(accountFileDownloads) {
|
||||
accountFileDownloads.filter {
|
||||
it.status == com.rosetta.messenger.network.FileDownloadStatus.QUEUED ||
|
||||
it.status ==
|
||||
com.rosetta.messenger.network.FileDownloadStatus
|
||||
.DOWNLOADING ||
|
||||
it.status ==
|
||||
com.rosetta.messenger.network.FileDownloadStatus
|
||||
.DECRYPTING
|
||||
}
|
||||
}
|
||||
val hasActiveDownloads = activeFileDownloads.isNotEmpty()
|
||||
|
||||
// <20>🔥 Пользователи, которые сейчас печатают
|
||||
val typingUsers by ProtocolManager.typingUsers.collectAsState()
|
||||
@@ -473,6 +515,7 @@ fun ChatsListScreen(
|
||||
|
||||
// 📬 Requests screen state
|
||||
var showRequestsScreen by remember { mutableStateOf(false) }
|
||||
var showDownloadsScreen by remember { mutableStateOf(false) }
|
||||
var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) }
|
||||
var isRequestsRouteTapLocked by remember { mutableStateOf(false) }
|
||||
val inlineRequestsTransitionLockMs = 340L
|
||||
@@ -498,6 +541,10 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentAccountKey) {
|
||||
showDownloadsScreen = false
|
||||
}
|
||||
|
||||
// 📂 Accounts section expanded state (arrow toggle)
|
||||
var accountsSectionExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -535,8 +582,10 @@ fun ChatsListScreen(
|
||||
|
||||
// Back: drawer → закрыть, selection → сбросить
|
||||
// Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно
|
||||
BackHandler(enabled = isSelectionMode || drawerState.isOpen) {
|
||||
if (isSelectionMode) {
|
||||
BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) {
|
||||
if (showDownloadsScreen) {
|
||||
showDownloadsScreen = false
|
||||
} else if (isSelectionMode) {
|
||||
selectedChatKeys = emptySet()
|
||||
} else if (drawerState.isOpen) {
|
||||
scope.launch { drawerState.close() }
|
||||
@@ -709,7 +758,7 @@ fun ChatsListScreen(
|
||||
) {
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
gesturesEnabled = !showRequestsScreen,
|
||||
gesturesEnabled = !showRequestsScreen && !showDownloadsScreen,
|
||||
drawerContent = {
|
||||
ModalDrawerSheet(
|
||||
drawerContainerColor = Color.Transparent,
|
||||
@@ -1335,7 +1384,12 @@ fun ChatsListScreen(
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
key(isDarkTheme, showRequestsScreen, isSelectionMode) {
|
||||
key(
|
||||
isDarkTheme,
|
||||
showRequestsScreen,
|
||||
showDownloadsScreen,
|
||||
isSelectionMode
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = isSelectionMode,
|
||||
animationSpec = tween(200),
|
||||
@@ -1473,12 +1527,16 @@ fun ChatsListScreen(
|
||||
// ═══ NORMAL HEADER ═══
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (showRequestsScreen) {
|
||||
if (showRequestsScreen || showDownloadsScreen) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
setInlineRequestsVisible(
|
||||
false
|
||||
)
|
||||
if (showDownloadsScreen) {
|
||||
showDownloadsScreen = false
|
||||
} else {
|
||||
setInlineRequestsVisible(
|
||||
false
|
||||
)
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
@@ -1557,7 +1615,14 @@ fun ChatsListScreen(
|
||||
}
|
||||
},
|
||||
title = {
|
||||
if (showRequestsScreen) {
|
||||
if (showDownloadsScreen) {
|
||||
Text(
|
||||
"Downloads",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = Color.White
|
||||
)
|
||||
} else if (showRequestsScreen) {
|
||||
Text(
|
||||
"Requests",
|
||||
fontWeight =
|
||||
@@ -1596,10 +1661,18 @@ fun ChatsListScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
if (!showRequestsScreen) {
|
||||
if (!showRequestsScreen && !showDownloadsScreen) {
|
||||
// 📥 Animated download indicator (Telegram-style)
|
||||
Box(
|
||||
modifier = androidx.compose.ui.Modifier.size(48.dp),
|
||||
modifier =
|
||||
androidx.compose.ui.Modifier
|
||||
.size(48.dp)
|
||||
.clickable(
|
||||
enabled = hasActiveDownloads,
|
||||
onClick = {
|
||||
showDownloadsScreen = true
|
||||
}
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
|
||||
@@ -1709,39 +1782,105 @@ fun ChatsListScreen(
|
||||
|
||||
val showSkeleton = isLoading
|
||||
|
||||
// 🎬 Animated content transition between main list and
|
||||
// requests
|
||||
AnimatedContent(
|
||||
targetState = showRequestsScreen,
|
||||
targetState = showDownloadsScreen,
|
||||
transitionSpec = {
|
||||
if (targetState) {
|
||||
// Opening requests: slide in from right
|
||||
// Opening downloads: slide from right with fade
|
||||
slideInHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> fullWidth } + fadeIn(
|
||||
animationSpec =
|
||||
tween(
|
||||
280,
|
||||
easing =
|
||||
FastOutSlowInEasing
|
||||
)
|
||||
) { fullWidth ->
|
||||
fullWidth
|
||||
} + fadeIn(
|
||||
animationSpec = tween(200)
|
||||
) togetherWith
|
||||
slideOutHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> -fullWidth / 4 } + fadeOut(
|
||||
animationSpec =
|
||||
tween(
|
||||
280,
|
||||
easing =
|
||||
FastOutSlowInEasing
|
||||
)
|
||||
) { fullWidth ->
|
||||
-fullWidth / 4
|
||||
} + fadeOut(
|
||||
animationSpec = tween(150)
|
||||
)
|
||||
} else {
|
||||
// Closing requests: slide out to right
|
||||
// Closing downloads: slide back to right
|
||||
slideInHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> -fullWidth / 4 } + fadeIn(
|
||||
animationSpec =
|
||||
tween(
|
||||
280,
|
||||
easing =
|
||||
FastOutSlowInEasing
|
||||
)
|
||||
) { fullWidth ->
|
||||
-fullWidth / 4
|
||||
} + fadeIn(
|
||||
animationSpec = tween(200)
|
||||
) togetherWith
|
||||
slideOutHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> fullWidth } + fadeOut(
|
||||
animationSpec =
|
||||
tween(
|
||||
280,
|
||||
easing =
|
||||
FastOutSlowInEasing
|
||||
)
|
||||
) { fullWidth ->
|
||||
fullWidth
|
||||
} + fadeOut(
|
||||
animationSpec = tween(150)
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "RequestsTransition"
|
||||
) { isRequestsScreen ->
|
||||
label = "DownloadsTransition"
|
||||
) { isDownloadsScreen ->
|
||||
if (isDownloadsScreen) {
|
||||
FileDownloadsScreen(
|
||||
downloads = activeFileDownloads,
|
||||
isDarkTheme = isDarkTheme,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
// 🎬 Animated content transition between main list and
|
||||
// requests
|
||||
AnimatedContent(
|
||||
targetState = showRequestsScreen,
|
||||
transitionSpec = {
|
||||
if (targetState) {
|
||||
// Opening requests: slide in from right
|
||||
slideInHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> fullWidth } + fadeIn(
|
||||
animationSpec = tween(200)
|
||||
) togetherWith
|
||||
slideOutHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> -fullWidth / 4 } + fadeOut(
|
||||
animationSpec = tween(150)
|
||||
)
|
||||
} else {
|
||||
// Closing requests: slide out to right
|
||||
slideInHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> -fullWidth / 4 } + fadeIn(
|
||||
animationSpec = tween(200)
|
||||
) togetherWith
|
||||
slideOutHorizontally(
|
||||
animationSpec = tween(280, easing = FastOutSlowInEasing)
|
||||
) { fullWidth -> fullWidth } + fadeOut(
|
||||
animationSpec = tween(150)
|
||||
)
|
||||
}
|
||||
},
|
||||
label = "RequestsTransition"
|
||||
) { isRequestsScreen ->
|
||||
if (isRequestsScreen) {
|
||||
// 📬 Show Requests Screen with swipe-back
|
||||
Box(
|
||||
@@ -2054,13 +2193,14 @@ fun ChatsListScreen(
|
||||
)
|
||||
val isTyping by
|
||||
remember(
|
||||
dialog.opponentKey
|
||||
dialog.opponentKey,
|
||||
typingUsers
|
||||
) {
|
||||
derivedStateOf {
|
||||
typingUsers
|
||||
.contains(
|
||||
dialog.opponentKey
|
||||
)
|
||||
isTypingForDialog(
|
||||
dialog.opponentKey,
|
||||
typingUsers
|
||||
)
|
||||
}
|
||||
}
|
||||
val isSelectedDialog =
|
||||
@@ -2241,7 +2381,9 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
} // Close AnimatedContent
|
||||
} // Close AnimatedContent
|
||||
} // Close downloads/main content switch
|
||||
} // Close Downloads AnimatedContent
|
||||
|
||||
// Console button removed
|
||||
}
|
||||
@@ -3746,7 +3888,11 @@ fun DialogItemContent(
|
||||
MessageRepository.isSystemAccount(dialog.opponentKey)
|
||||
if (dialog.verified > 0 || isRosettaOfficial) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(verified = if (dialog.verified > 0) dialog.verified else 1, size = 16)
|
||||
VerifiedBadge(
|
||||
verified = if (dialog.verified > 0) dialog.verified else 1,
|
||||
size = 16,
|
||||
modifier = Modifier.offset(y = (-2).dp)
|
||||
)
|
||||
}
|
||||
// 🔒 Красная иконка замочка для заблокированных пользователей
|
||||
if (isBlocked) {
|
||||
@@ -4561,6 +4707,136 @@ fun RequestsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FileDownloadsScreen(
|
||||
downloads: List<com.rosetta.messenger.network.FileDownloadState>,
|
||||
isDarkTheme: Boolean,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val background = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
val card = if (isDarkTheme) Color(0xFF212123) else Color.White
|
||||
val primaryText = if (isDarkTheme) Color.White else Color(0xFF111111)
|
||||
val secondaryText = if (isDarkTheme) Color(0xFF9B9B9F) else Color(0xFF6E6E73)
|
||||
val divider = if (isDarkTheme) Color(0xFF2F2F31) else Color(0xFFE7E7EC)
|
||||
|
||||
if (downloads.isEmpty()) {
|
||||
Box(
|
||||
modifier = modifier.background(background),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.Download,
|
||||
contentDescription = null,
|
||||
tint = secondaryText,
|
||||
modifier = Modifier.size(34.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "No active file downloads",
|
||||
color = primaryText,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "New file downloads will appear here.",
|
||||
color = secondaryText,
|
||||
fontSize = 13.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = modifier.background(background),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
items(items = downloads, key = { it.attachmentId }) { item ->
|
||||
Surface(
|
||||
color = card,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(34.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isDarkTheme) Color(0xFF2B4E6E)
|
||||
else Color(0xFFDCEEFF)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.Download,
|
||||
contentDescription = null,
|
||||
tint = if (isDarkTheme) Color(0xFF8FC6FF) else Color(0xFF228BE6),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.fileName.ifBlank { "Unknown file" },
|
||||
color = primaryText,
|
||||
fontSize = 15.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = formatDownloadStatusText(item),
|
||||
color = secondaryText,
|
||||
fontSize = 12.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
LinearProgressIndicator(
|
||||
progress = item.progress.coerceIn(0f, 1f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(4.dp)
|
||||
.clip(RoundedCornerShape(50)),
|
||||
color = if (isDarkTheme) Color(0xFF4DA6FF) else Color(0xFF228BE6),
|
||||
trackColor = if (isDarkTheme) Color(0xFF3A3A3D) else Color(0xFFD8D8DE)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Divider(color = divider, thickness = 0.5.dp)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = item.savedPath.ifBlank { "Storage path unavailable" },
|
||||
color = secondaryText,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDownloadStatusText(
|
||||
item: com.rosetta.messenger.network.FileDownloadState
|
||||
): String {
|
||||
val percent = (item.progress.coerceIn(0f, 1f) * 100).toInt().coerceIn(0, 100)
|
||||
return when (item.status) {
|
||||
com.rosetta.messenger.network.FileDownloadStatus.QUEUED -> "Queued"
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> "Downloading $percent%"
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> "Decrypting"
|
||||
com.rosetta.messenger.network.FileDownloadStatus.DONE -> "Completed"
|
||||
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> "Download failed"
|
||||
}
|
||||
}
|
||||
|
||||
/** 🎨 Enhanced Drawer Menu Item - пункт меню в стиле Telegram */
|
||||
@Composable
|
||||
fun DrawerMenuItemEnhanced(
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@@ -88,6 +92,7 @@ import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
@@ -131,13 +136,20 @@ import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||
import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer
|
||||
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import com.rosetta.messenger.utils.ImageCropHelper
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.core.view.WindowCompat
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
@@ -203,6 +215,75 @@ private object GroupMembersMemoryCache {
|
||||
}
|
||||
}
|
||||
|
||||
private data class GroupMembersDiskCacheEntry(
|
||||
val members: List<String>,
|
||||
val updatedAtMs: Long
|
||||
)
|
||||
|
||||
private object GroupMembersDiskCache {
|
||||
private const val PREFS_NAME = "group_members_cache"
|
||||
private const val KEY_PREFIX = "entry_"
|
||||
private const val TTL_MS = 12 * 60 * 60 * 1000L
|
||||
|
||||
fun getAny(context: Context, key: String): GroupMembersDiskCacheEntry? = read(context, key)
|
||||
|
||||
fun getFresh(context: Context, key: String): GroupMembersDiskCacheEntry? {
|
||||
val entry = read(context, key) ?: return null
|
||||
return if (System.currentTimeMillis() - entry.updatedAtMs <= TTL_MS) entry else null
|
||||
}
|
||||
|
||||
fun put(context: Context, key: String, members: List<String>) {
|
||||
if (key.isBlank()) return
|
||||
val normalizedMembers = members.map { it.trim() }.filter { it.isNotBlank() }.distinct()
|
||||
if (normalizedMembers.isEmpty()) return
|
||||
val payload =
|
||||
JSONObject().apply {
|
||||
put("updatedAtMs", System.currentTimeMillis())
|
||||
put("members", JSONArray().apply { normalizedMembers.forEach { put(it) } })
|
||||
}.toString()
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_PREFIX + key, payload)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun remove(context: Context, key: String) {
|
||||
if (key.isBlank()) return
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.remove(KEY_PREFIX + key)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun read(context: Context, key: String): GroupMembersDiskCacheEntry? {
|
||||
if (key.isBlank()) return null
|
||||
val raw =
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.getString(KEY_PREFIX + key, null) ?: return null
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val membersArray = json.optJSONArray("members") ?: JSONArray()
|
||||
val membersList =
|
||||
buildList {
|
||||
repeat(membersArray.length()) { index ->
|
||||
val value = membersArray.optString(index).trim()
|
||||
if (value.isNotBlank()) add(value)
|
||||
}
|
||||
}
|
||||
.distinct()
|
||||
if (membersList.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
GroupMembersDiskCacheEntry(
|
||||
members = membersList,
|
||||
updatedAtMs = json.optLong("updatedAtMs", 0L)
|
||||
)
|
||||
}
|
||||
}
|
||||
.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private data class GroupMediaItem(
|
||||
val key: String,
|
||||
val attachment: MessageAttachment,
|
||||
@@ -238,6 +319,7 @@ fun GroupInfoScreen(
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val view = LocalView.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -253,6 +335,20 @@ fun GroupInfoScreen(
|
||||
val secondaryText = Color(0xFF8E8E93)
|
||||
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
||||
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
|
||||
val groupActionButtonBlue = if (isDarkTheme) Color(0xFF285683) else Color(0xFF2478C2)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
repeat(3) {
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
(context as? Activity)?.window?.let { window ->
|
||||
WindowCompat.getInsetsController(window, view)
|
||||
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
focusManager.clearFocus(force = true)
|
||||
delay(16)
|
||||
}
|
||||
}
|
||||
|
||||
// Keep status bar unified with group header color.
|
||||
DisposableEffect(topSurfaceColor, view) {
|
||||
@@ -299,19 +395,40 @@ fun GroupInfoScreen(
|
||||
var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
||||
var encryptionKeyLoading by remember { mutableStateOf(false) }
|
||||
var membersLoading by remember { mutableStateOf(false) }
|
||||
var membersLoading by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||
var isMuted by remember { mutableStateOf(false) }
|
||||
var showGroupAvatarPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||
var showGroupAvatarViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||
var groupAvatarViewerTimestamp by rememberSaveable(dialogPublicKey) { mutableStateOf(0L) }
|
||||
var groupAvatarViewerBitmap by remember(dialogPublicKey) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
||||
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||
|
||||
var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) }
|
||||
val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf<String, SearchUser>() }
|
||||
// Real online status from PacketOnlineState (0x05), NOT from SearchUser.online
|
||||
val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf<String, Boolean>() }
|
||||
val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) {
|
||||
"${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}"
|
||||
}
|
||||
val initialMemoryMembersCache = remember(membersCacheKey) {
|
||||
GroupMembersMemoryCache.getAny(membersCacheKey)
|
||||
}
|
||||
val initialDiskMembersCache = remember(membersCacheKey) {
|
||||
GroupMembersDiskCache.getAny(context, membersCacheKey)
|
||||
}
|
||||
val hasInitialMembersCache = remember(initialMemoryMembersCache, initialDiskMembersCache) {
|
||||
initialMemoryMembersCache != null || initialDiskMembersCache != null
|
||||
}
|
||||
val initialMembers = remember(initialMemoryMembersCache, initialDiskMembersCache) {
|
||||
initialMemoryMembersCache?.members ?: initialDiskMembersCache?.members.orEmpty()
|
||||
}
|
||||
var members by remember(dialogPublicKey, membersCacheKey) { mutableStateOf(initialMembers) }
|
||||
val memberInfoByKey =
|
||||
remember(dialogPublicKey, membersCacheKey) {
|
||||
mutableStateMapOf<String, SearchUser>().apply {
|
||||
initialMemoryMembersCache?.memberInfoByKey?.let { putAll(it) }
|
||||
}
|
||||
}
|
||||
// Real online status from PacketOnlineState (0x05), NOT from SearchUser.online
|
||||
val memberOnlineStatus = remember(dialogPublicKey, membersCacheKey) { mutableStateMapOf<String, Boolean>() }
|
||||
|
||||
val groupEntity by produceState<com.rosetta.messenger.database.GroupEntity?>(
|
||||
initialValue = null,
|
||||
@@ -390,6 +507,49 @@ fun GroupInfoScreen(
|
||||
groupEntity?.description?.trim().orEmpty()
|
||||
}
|
||||
|
||||
val cropLauncher =
|
||||
rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.StartActivityForResult()
|
||||
) { result ->
|
||||
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
|
||||
val cropError = ImageCropHelper.getCropError(result)
|
||||
if (croppedUri != null) {
|
||||
scope.launch {
|
||||
val preparedBase64 =
|
||||
withContext(Dispatchers.IO) {
|
||||
val imageBytes =
|
||||
runCatching {
|
||||
context.contentResolver.openInputStream(croppedUri)?.use { stream ->
|
||||
stream.readBytes()
|
||||
}
|
||||
}.getOrNull()
|
||||
imageBytes?.let { bytes ->
|
||||
val prepared = AvatarFileManager.imagePrepareForNetworkTransfer(context, bytes)
|
||||
if (prepared.isBlank()) null else "data:image/png;base64,$prepared"
|
||||
}
|
||||
}
|
||||
|
||||
val repository = avatarRepository
|
||||
val saved =
|
||||
preparedBase64 != null &&
|
||||
repository != null &&
|
||||
runCatching {
|
||||
withContext(Dispatchers.IO) {
|
||||
repository.saveAvatar(dialogPublicKey, preparedBase64)
|
||||
}
|
||||
}.isSuccess
|
||||
|
||||
Toast.makeText(
|
||||
context,
|
||||
if (saved) "Group avatar updated" else "Failed to update group avatar",
|
||||
Toast.LENGTH_SHORT
|
||||
).show()
|
||||
}
|
||||
} else if (cropError != null) {
|
||||
Toast.makeText(context, "Failed to crop photo", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentUserPublicKey, dialogPublicKey) {
|
||||
if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) {
|
||||
isMuted = preferencesManager.isChatMuted(currentUserPublicKey, dialogPublicKey)
|
||||
@@ -417,7 +577,9 @@ fun GroupInfoScreen(
|
||||
}
|
||||
}
|
||||
|
||||
val hasAnyCache = GroupMembersMemoryCache.getAny(membersCacheKey) != null
|
||||
val hasAnyCache =
|
||||
GroupMembersMemoryCache.getAny(membersCacheKey) != null ||
|
||||
GroupMembersDiskCache.getAny(context, membersCacheKey) != null
|
||||
val shouldShowLoader = showLoader && members.isEmpty() && !hasAnyCache
|
||||
if (shouldShowLoader) membersLoading = true
|
||||
isRefreshingMembers = true
|
||||
@@ -455,6 +617,7 @@ fun GroupInfoScreen(
|
||||
members = members,
|
||||
memberInfoByKey = memberInfoByKey.toMap()
|
||||
)
|
||||
GroupMembersDiskCache.put(context, membersCacheKey, members)
|
||||
} finally {
|
||||
if (shouldShowLoader) membersLoading = false
|
||||
isRefreshingMembers = false
|
||||
@@ -463,15 +626,44 @@ fun GroupInfoScreen(
|
||||
}
|
||||
|
||||
LaunchedEffect(membersCacheKey) {
|
||||
val cachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey)
|
||||
cachedEntry?.let { cached ->
|
||||
val memoryCachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey)
|
||||
val diskCachedEntry = GroupMembersDiskCache.getAny(context, membersCacheKey)
|
||||
|
||||
memoryCachedEntry?.let { cached ->
|
||||
members = cached.members
|
||||
memberInfoByKey.clear()
|
||||
memberInfoByKey.putAll(cached.memberInfoByKey)
|
||||
} ?: diskCachedEntry?.let { cached ->
|
||||
members = cached.members
|
||||
if (memberInfoByKey.isEmpty()) {
|
||||
val resolvedUsers = withContext(Dispatchers.IO) {
|
||||
val resolvedMap = LinkedHashMap<String, SearchUser>()
|
||||
cached.members.forEach { memberKey ->
|
||||
ProtocolManager.getCachedUserInfo(memberKey)?.let { resolved ->
|
||||
resolvedMap[memberKey] = resolved
|
||||
}
|
||||
}
|
||||
resolvedMap
|
||||
}
|
||||
if (resolvedUsers.isNotEmpty()) {
|
||||
memberInfoByKey.putAll(resolvedUsers)
|
||||
}
|
||||
}
|
||||
GroupMembersMemoryCache.put(
|
||||
key = membersCacheKey,
|
||||
members = members,
|
||||
memberInfoByKey = memberInfoByKey.toMap()
|
||||
)
|
||||
}
|
||||
|
||||
if (GroupMembersMemoryCache.getFresh(membersCacheKey) == null) {
|
||||
refreshMembers(force = true, showLoader = cachedEntry == null)
|
||||
val hasFreshCache =
|
||||
GroupMembersMemoryCache.getFresh(membersCacheKey) != null ||
|
||||
GroupMembersDiskCache.getFresh(context, membersCacheKey) != null
|
||||
if (!hasFreshCache) {
|
||||
refreshMembers(
|
||||
force = true,
|
||||
showLoader = memoryCachedEntry == null && diskCachedEntry == null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -581,6 +773,20 @@ fun GroupInfoScreen(
|
||||
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
|
||||
fun openGroupAvatarViewer() {
|
||||
val repository = avatarRepository ?: return
|
||||
scope.launch {
|
||||
val latestAvatar = repository.getAvatars(dialogPublicKey, allDecode = false).first().firstOrNull()
|
||||
?: return@launch
|
||||
groupAvatarViewerTimestamp = latestAvatar.timestamp / 1000L
|
||||
groupAvatarViewerBitmap =
|
||||
withContext(Dispatchers.IO) {
|
||||
AvatarFileManager.base64ToBitmap(latestAvatar.base64Data)
|
||||
}
|
||||
showGroupAvatarViewer = true
|
||||
}
|
||||
}
|
||||
var swipedMemberKey by remember(dialogPublicKey) { mutableStateOf<String?>(null) }
|
||||
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
|
||||
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||
@@ -700,6 +906,7 @@ fun GroupInfoScreen(
|
||||
isLeaving = false
|
||||
if (left) {
|
||||
GroupMembersMemoryCache.remove(membersCacheKey)
|
||||
GroupMembersDiskCache.remove(context, membersCacheKey)
|
||||
onGroupLeft()
|
||||
} else {
|
||||
Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show()
|
||||
@@ -744,6 +951,7 @@ fun GroupInfoScreen(
|
||||
members = members,
|
||||
memberInfoByKey = memberInfoByKey.toMap()
|
||||
)
|
||||
GroupMembersDiskCache.put(context, membersCacheKey, members)
|
||||
refreshMembers(force = true, showLoader = false)
|
||||
Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show()
|
||||
} else {
|
||||
@@ -777,12 +985,12 @@ fun GroupInfoScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(backgroundColor)
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSurfaceColor)
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 14.dp, vertical = 10.dp)
|
||||
) {
|
||||
IconButton(onClick = onBack, modifier = Modifier.align(Alignment.TopStart)) {
|
||||
@@ -861,6 +1069,13 @@ fun GroupInfoScreen(
|
||||
avatarRepository = avatarRepository,
|
||||
size = 86.dp,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onClick = {
|
||||
if (currentUserIsAdmin) {
|
||||
showGroupAvatarPicker = true
|
||||
} else {
|
||||
openGroupAvatarViewer()
|
||||
}
|
||||
},
|
||||
displayName = groupTitle
|
||||
)
|
||||
|
||||
@@ -877,7 +1092,7 @@ fun GroupInfoScreen(
|
||||
)
|
||||
|
||||
Text(
|
||||
text = if (membersLoading) {
|
||||
text = if (membersLoading || (members.isEmpty() && !hasInitialMembersCache)) {
|
||||
"Loading members..."
|
||||
} else {
|
||||
"${members.size} members, $onlineCount online"
|
||||
@@ -896,16 +1111,18 @@ fun GroupInfoScreen(
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = Icons.Default.Message,
|
||||
label = "Message",
|
||||
backgroundColor = cardColor,
|
||||
contentColor = actionContentColor,
|
||||
backgroundColor = groupActionButtonBlue,
|
||||
contentColor = Color.White,
|
||||
iconColor = Color.White,
|
||||
onClick = onBack
|
||||
)
|
||||
GroupActionButton(
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = if (isMuted) Icons.Default.Notifications else Icons.Default.NotificationsOff,
|
||||
label = if (isMuted) "Unmute" else "Mute",
|
||||
backgroundColor = cardColor,
|
||||
contentColor = actionContentColor,
|
||||
backgroundColor = groupActionButtonBlue,
|
||||
contentColor = Color.White,
|
||||
iconColor = Color.White,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
val newMutedState = !isMuted
|
||||
@@ -922,8 +1139,9 @@ fun GroupInfoScreen(
|
||||
modifier = Modifier.weight(1f),
|
||||
icon = Icons.Default.ExitToApp,
|
||||
label = "Leave",
|
||||
backgroundColor = cardColor,
|
||||
contentColor = Color(0xFFFF7A7A),
|
||||
backgroundColor = groupActionButtonBlue,
|
||||
contentColor = Color.White,
|
||||
iconColor = Color.White,
|
||||
onClick = { showLeaveConfirm = true }
|
||||
)
|
||||
}
|
||||
@@ -1335,11 +1553,7 @@ fun GroupInfoScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
topSurfaceColor = topSurfaceColor,
|
||||
backgroundColor = backgroundColor,
|
||||
onBack = { showEncryptionPage = false },
|
||||
onCopy = {
|
||||
clipboardManager.setText(AnnotatedString(encryptionKey))
|
||||
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
onBack = { showEncryptionPage = false }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1408,6 +1622,27 @@ fun GroupInfoScreen(
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
ProfilePhotoPicker(
|
||||
isVisible = showGroupAvatarPicker,
|
||||
onDismiss = { showGroupAvatarPicker = false },
|
||||
onPhotoSelected = { uri ->
|
||||
showGroupAvatarPicker = false
|
||||
val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme)
|
||||
cropLauncher.launch(cropIntent)
|
||||
},
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
|
||||
FullScreenAvatarViewer(
|
||||
isVisible = showGroupAvatarViewer,
|
||||
onDismiss = { showGroupAvatarViewer = false },
|
||||
displayName = groupTitle.ifBlank { shortPublicKey(dialogPublicKey) },
|
||||
avatarTimestamp = groupAvatarViewerTimestamp,
|
||||
avatarBitmap = groupAvatarViewerBitmap,
|
||||
publicKey = dialogPublicKey,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -1456,8 +1691,7 @@ private fun GroupEncryptionKeyPage(
|
||||
isDarkTheme: Boolean,
|
||||
topSurfaceColor: Color,
|
||||
backgroundColor: Color,
|
||||
onBack: () -> Unit,
|
||||
onCopy: () -> Unit
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val safePeerTitle = peerTitle.ifBlank { "this group" }
|
||||
@@ -1477,13 +1711,13 @@ private fun GroupEncryptionKeyPage(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
// Top bar
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(topSurfaceColor)
|
||||
.statusBarsPadding()
|
||||
.padding(horizontal = 4.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
@@ -1500,14 +1734,6 @@ private fun GroupEncryptionKeyPage(
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onCopy) {
|
||||
Text(
|
||||
text = "Copy",
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Two-half layout like Telegram
|
||||
@@ -1595,6 +1821,7 @@ private fun GroupActionButton(
|
||||
label: String,
|
||||
backgroundColor: Color,
|
||||
contentColor: Color,
|
||||
iconColor: Color = contentColor,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Surface(
|
||||
@@ -1613,7 +1840,7 @@ private fun GroupActionButton(
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
tint = iconColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
@@ -68,6 +68,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import com.rosetta.messenger.R
|
||||
@@ -149,6 +150,10 @@ fun GroupSetupScreen(
|
||||
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
|
||||
val secondaryTextColor = Color(0xFF8E8E93)
|
||||
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
||||
val groupAvatarCameraButtonColor =
|
||||
if (isDarkTheme) sectionColor else Color(0xFF8CC9F6)
|
||||
val groupAvatarCameraIconColor =
|
||||
if (isDarkTheme) accentColor else Color.White
|
||||
|
||||
androidx.compose.runtime.DisposableEffect(topSurfaceColor, view) {
|
||||
val window = (view.context as? Activity)?.window
|
||||
@@ -208,12 +213,28 @@ fun GroupSetupScreen(
|
||||
}
|
||||
}
|
||||
|
||||
fun dismissInputUi() {
|
||||
if (showEmojiKeyboard || coordinator.isEmojiVisible || coordinator.isEmojiBoxVisible) {
|
||||
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
|
||||
} else {
|
||||
showEmojiKeyboard = false
|
||||
}
|
||||
focusManager.clearFocus(force = true)
|
||||
keyboardController?.hide()
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
(context as? Activity)?.window?.let { window ->
|
||||
WindowCompat.getInsetsController(window, view).hide(WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
}
|
||||
|
||||
fun handleBack() {
|
||||
if (isLoading) return
|
||||
errorText = null
|
||||
if (step == GroupSetupStep.DESCRIPTION) {
|
||||
step = GroupSetupStep.DETAILS
|
||||
} else {
|
||||
dismissInputUi()
|
||||
onBack()
|
||||
}
|
||||
}
|
||||
@@ -402,7 +423,7 @@ fun GroupSetupScreen(
|
||||
Modifier
|
||||
.size(64.dp)
|
||||
.clip(CircleShape)
|
||||
.background(sectionColor)
|
||||
.background(groupAvatarCameraButtonColor)
|
||||
.clickable(enabled = !isLoading) {
|
||||
showPhotoPicker = true
|
||||
},
|
||||
@@ -423,7 +444,7 @@ fun GroupSetupScreen(
|
||||
Icon(
|
||||
painter = TelegramIcons.Camera,
|
||||
contentDescription = "Set group avatar",
|
||||
tint = accentColor,
|
||||
tint = groupAvatarCameraIconColor,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
@@ -713,6 +734,7 @@ fun GroupSetupScreen(
|
||||
avatarUriString = selectedAvatarUri
|
||||
)
|
||||
}
|
||||
dismissInputUi()
|
||||
openGroup(
|
||||
dialogPublicKey = result.dialogPublicKey,
|
||||
groupTitle = result.title.ifBlank { title.trim() }
|
||||
|
||||
@@ -32,9 +32,11 @@ import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
@@ -446,9 +448,22 @@ private fun ChatsTabContent(
|
||||
val dividerColor = remember(isDarkTheme) {
|
||||
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||
}
|
||||
val suggestionsPrefs = remember(currentUserPublicKey) {
|
||||
context.getSharedPreferences("search_suggestions", Context.MODE_PRIVATE)
|
||||
}
|
||||
val hiddenSuggestionsKey = remember(currentUserPublicKey) {
|
||||
"hidden_frequent_${currentUserPublicKey}"
|
||||
}
|
||||
|
||||
// ─── Загрузка частых контактов из БД (top dialogs) ───
|
||||
var frequentContacts by remember { mutableStateOf<List<FrequentContact>>(emptyList()) }
|
||||
var hiddenSuggestionKeys by remember(currentUserPublicKey) { mutableStateOf<Set<String>>(emptySet()) }
|
||||
var frequentSuggestionToRemove by remember { mutableStateOf<FrequentContact?>(null) }
|
||||
|
||||
LaunchedEffect(currentUserPublicKey) {
|
||||
hiddenSuggestionKeys =
|
||||
suggestionsPrefs.getStringSet(hiddenSuggestionsKey, emptySet())?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
LaunchedEffect(currentUserPublicKey) {
|
||||
if (currentUserPublicKey.isBlank()) return@LaunchedEffect
|
||||
@@ -478,6 +493,14 @@ private fun ChatsTabContent(
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
val visibleFrequentContacts = remember(frequentContacts, hiddenSuggestionKeys) {
|
||||
frequentContacts.filterNot { hiddenSuggestionKeys.contains(it.publicKey) }
|
||||
}
|
||||
fun removeFrequentSuggestion(publicKey: String) {
|
||||
val updated = (hiddenSuggestionKeys + publicKey).toSet()
|
||||
hiddenSuggestionKeys = updated
|
||||
suggestionsPrefs.edit().putStringSet(hiddenSuggestionsKey, updated).apply()
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (searchQuery.isEmpty()) {
|
||||
@@ -486,10 +509,10 @@ private fun ChatsTabContent(
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
// ─── Горизонтальный ряд частых контактов (как в Telegram) ───
|
||||
if (frequentContacts.isNotEmpty()) {
|
||||
if (visibleFrequentContacts.isNotEmpty()) {
|
||||
item {
|
||||
FrequentContactsRow(
|
||||
contacts = frequentContacts,
|
||||
contacts = visibleFrequentContacts,
|
||||
avatarRepository = avatarRepository,
|
||||
isDarkTheme = isDarkTheme,
|
||||
textColor = textColor,
|
||||
@@ -504,6 +527,9 @@ private fun ChatsTabContent(
|
||||
)
|
||||
RecentSearchesManager.addUser(user)
|
||||
onUserSelect(user)
|
||||
},
|
||||
onLongPress = { contact ->
|
||||
frequentSuggestionToRemove = contact
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -653,6 +679,69 @@ private fun ChatsTabContent(
|
||||
}
|
||||
)
|
||||
}
|
||||
frequentSuggestionToRemove?.let { contact ->
|
||||
val scrimColor = if (isDarkTheme) Color.Black.copy(alpha = 0.42f) else Color.Black.copy(alpha = 0.24f)
|
||||
val popupColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
|
||||
val popupSecondaryText = if (isDarkTheme) Color(0xFFAEAEB2) else Color(0xFF6D6D72)
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.matchParentSize()
|
||||
.background(scrimColor)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) { frequentSuggestionToRemove = null }
|
||||
)
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.imePadding()
|
||||
.padding(start = 20.dp, end = 20.dp, bottom = 14.dp),
|
||||
color = popupColor,
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
tonalElevation = 0.dp,
|
||||
shadowElevation = 0.dp
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Remove suggestion",
|
||||
color = textColor,
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Are you sure you want to remove ${contact.name} from suggestions?",
|
||||
color = popupSecondaryText,
|
||||
fontSize = 16.sp,
|
||||
lineHeight = 22.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End
|
||||
) {
|
||||
TextButton(onClick = { frequentSuggestionToRemove = null }) {
|
||||
Text("Cancel", color = AppPrimaryBlue, fontSize = 17.sp)
|
||||
}
|
||||
TextButton(
|
||||
onClick = {
|
||||
removeFrequentSuggestion(contact.publicKey)
|
||||
frequentSuggestionToRemove = null
|
||||
}
|
||||
) {
|
||||
Text("Remove", color = Color(0xFFFF5A5F), fontSize = 17.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -669,13 +758,16 @@ private data class FrequentContact(
|
||||
)
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
private fun FrequentContactsRow(
|
||||
contacts: List<FrequentContact>,
|
||||
avatarRepository: AvatarRepository?,
|
||||
isDarkTheme: Boolean,
|
||||
textColor: Color,
|
||||
onClick: (FrequentContact) -> Unit
|
||||
onClick: (FrequentContact) -> Unit,
|
||||
onLongPress: (FrequentContact) -> Unit
|
||||
) {
|
||||
val haptic = LocalHapticFeedback.current
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -687,10 +779,15 @@ private fun FrequentContactsRow(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(72.dp)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
) { onClick(contact) }
|
||||
.combinedClickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = { onClick(contact) },
|
||||
onLongClick = {
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onLongPress(contact)
|
||||
}
|
||||
)
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
|
||||
@@ -80,6 +80,19 @@ private val whitespaceRegex = "\\s+".toRegex()
|
||||
|
||||
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
|
||||
|
||||
private fun canonicalGroupDialogKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) return ""
|
||||
val groupId =
|
||||
when {
|
||||
trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim()
|
||||
trimmed.startsWith("group:", ignoreCase = true) ->
|
||||
trimmed.substringAfter(':').trim()
|
||||
else -> return trimmed
|
||||
}
|
||||
return if (groupId.isBlank()) "" else "#group:$groupId"
|
||||
}
|
||||
|
||||
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
|
||||
if (!isGroupStoredKey(storedKey)) return null
|
||||
val encoded = storedKey.removePrefix("group:")
|
||||
@@ -332,6 +345,8 @@ fun MessageAttachments(
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
senderPublicKey: String,
|
||||
dialogPublicKey: String = "",
|
||||
isGroupChat: Boolean = false,
|
||||
timestamp: java.util.Date,
|
||||
messageStatus: MessageStatus = MessageStatus.READ,
|
||||
avatarRepository: AvatarRepository? = null,
|
||||
@@ -380,6 +395,7 @@ fun MessageAttachments(
|
||||
attachment = attachment,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
@@ -392,6 +408,8 @@ fun MessageAttachments(
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
senderPublicKey = senderPublicKey,
|
||||
dialogPublicKey = dialogPublicKey,
|
||||
isGroupChat = isGroupChat,
|
||||
avatarRepository = avatarRepository,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
isOutgoing = isOutgoing,
|
||||
@@ -1427,6 +1445,7 @@ fun FileAttachment(
|
||||
attachment: MessageAttachment,
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
currentUserPublicKey: String = "",
|
||||
isOutgoing: Boolean,
|
||||
isDarkTheme: Boolean,
|
||||
timestamp: java.util.Date,
|
||||
@@ -1540,6 +1559,7 @@ fun FileAttachment(
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
accountPublicKey = currentUserPublicKey,
|
||||
fileName = fileName,
|
||||
savedFile = savedFile
|
||||
)
|
||||
@@ -1775,6 +1795,8 @@ fun AvatarAttachment(
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
senderPublicKey: String,
|
||||
dialogPublicKey: String = "",
|
||||
isGroupChat: Boolean = false,
|
||||
avatarRepository: AvatarRepository?,
|
||||
currentUserPublicKey: String = "",
|
||||
isOutgoing: Boolean,
|
||||
@@ -1924,11 +1946,16 @@ fun AvatarAttachment(
|
||||
// Сохраняем аватар в репозиторий (для UI обновления)
|
||||
// Если это исходящее сообщение с аватаром, сохраняем для текущего
|
||||
// пользователя
|
||||
val normalizedDialogKey = dialogPublicKey.trim()
|
||||
val canonicalDialogKey = canonicalGroupDialogKey(normalizedDialogKey)
|
||||
val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey)
|
||||
val targetPublicKey =
|
||||
if (isOutgoing && currentUserPublicKey.isNotEmpty()) {
|
||||
currentUserPublicKey
|
||||
} else {
|
||||
senderPublicKey
|
||||
when {
|
||||
isGroupAvatarAttachment && canonicalDialogKey.isNotEmpty() ->
|
||||
canonicalDialogKey
|
||||
isOutgoing && currentUserPublicKey.isNotEmpty() ->
|
||||
currentUserPublicKey
|
||||
else -> senderPublicKey
|
||||
}
|
||||
|
||||
// ВАЖНО: ждем завершения сохранения в репозиторий
|
||||
|
||||
@@ -71,6 +71,7 @@ import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.models.*
|
||||
import com.rosetta.messenger.ui.chats.utils.*
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.components.AvatarImage
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
@@ -289,6 +290,7 @@ fun MessageBubble(
|
||||
isSystemSafeChat: Boolean = false,
|
||||
isSelectionMode: Boolean = false,
|
||||
showTail: Boolean = true,
|
||||
showIncomingGroupAvatar: Boolean? = null,
|
||||
isGroupStart: Boolean = false,
|
||||
isSelected: Boolean = false,
|
||||
isHighlighted: Boolean = false,
|
||||
@@ -297,6 +299,7 @@ fun MessageBubble(
|
||||
senderPublicKey: String = "",
|
||||
senderName: String = "",
|
||||
isGroupChat: Boolean = false,
|
||||
dialogPublicKey: String = "",
|
||||
showGroupSenderLabel: Boolean = false,
|
||||
isGroupSenderAdmin: Boolean = false,
|
||||
currentUserPublicKey: String = "",
|
||||
@@ -309,6 +312,7 @@ fun MessageBubble(
|
||||
onRetry: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||
onAvatarClick: (senderPublicKey: String) -> Unit = {},
|
||||
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
||||
onMentionClick: (username: String) -> Unit = {},
|
||||
onGroupInviteOpen: (SearchUser) -> Unit = {},
|
||||
@@ -531,6 +535,16 @@ fun MessageBubble(
|
||||
val combinedBackgroundColor =
|
||||
if (isSelected) selectionBackgroundColor else highlightBackgroundColor
|
||||
|
||||
val telegramIncomingAvatarSize = 42.dp
|
||||
val telegramIncomingAvatarLane = 48.dp
|
||||
val telegramIncomingAvatarInset = 6.dp
|
||||
val shouldShowIncomingGroupAvatar =
|
||||
showIncomingGroupAvatar
|
||||
?: (isGroupChat &&
|
||||
!message.isOutgoing &&
|
||||
showTail &&
|
||||
senderPublicKey.isNotBlank())
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth()
|
||||
@@ -542,7 +556,7 @@ fun MessageBubble(
|
||||
) {
|
||||
// Selection checkmark
|
||||
AnimatedVisibility(
|
||||
visible = isSelected,
|
||||
visible = isSelected && message.isOutgoing,
|
||||
enter =
|
||||
fadeIn(tween(150)) +
|
||||
scaleIn(
|
||||
@@ -568,16 +582,63 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !isSelected,
|
||||
AnimatedVisibility(
|
||||
visible = !isSelected || !message.isOutgoing,
|
||||
enter = fadeIn(tween(100)),
|
||||
exit = fadeOut(tween(100))
|
||||
) { Spacer(modifier = Modifier.width(12.dp)) }
|
||||
) {
|
||||
val leadingSpacerWidth =
|
||||
if (!message.isOutgoing && isGroupChat) 0.dp else 12.dp
|
||||
Spacer(modifier = Modifier.width(leadingSpacerWidth))
|
||||
}
|
||||
|
||||
if (message.isOutgoing) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
|
||||
if (!message.isOutgoing && isGroupChat) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.width(telegramIncomingAvatarLane)
|
||||
.height(telegramIncomingAvatarSize)
|
||||
.align(Alignment.Bottom),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
if (shouldShowIncomingGroupAvatar) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.padding(
|
||||
start =
|
||||
telegramIncomingAvatarInset
|
||||
),
|
||||
contentAlignment = Alignment.BottomStart
|
||||
) {
|
||||
val avatarClickHandler: (() -> Unit)? =
|
||||
if (!isSelectionMode &&
|
||||
senderPublicKey
|
||||
.isNotBlank()
|
||||
) {
|
||||
{ onAvatarClick(senderPublicKey) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
AvatarImage(
|
||||
publicKey = senderPublicKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = telegramIncomingAvatarSize,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onClick = avatarClickHandler,
|
||||
displayName =
|
||||
senderName.ifBlank {
|
||||
senderPublicKey
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Проверяем - есть ли только фотки без текста
|
||||
val hasOnlyMedia =
|
||||
message.attachments.isNotEmpty() &&
|
||||
@@ -707,7 +768,8 @@ fun MessageBubble(
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(end = 12.dp)
|
||||
Modifier.align(Alignment.Bottom)
|
||||
.padding(end = 12.dp)
|
||||
.then(bubbleWidthModifier)
|
||||
.graphicsLayer {
|
||||
this.alpha = selectionAlpha
|
||||
@@ -846,6 +908,8 @@ fun MessageBubble(
|
||||
isOutgoing = message.isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
senderPublicKey = senderPublicKey,
|
||||
dialogPublicKey = dialogPublicKey,
|
||||
isGroupChat = isGroupChat,
|
||||
timestamp = message.timestamp,
|
||||
messageStatus = attachmentDisplayStatus,
|
||||
avatarRepository = avatarRepository,
|
||||
@@ -1170,6 +1234,37 @@ fun MessageBubble(
|
||||
// 💬 Context menu anchor (DropdownMenu positions relative to this Box)
|
||||
contextMenuContent()
|
||||
}
|
||||
|
||||
if (!message.isOutgoing) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
AnimatedVisibility(
|
||||
visible = isSelected,
|
||||
enter =
|
||||
fadeIn(tween(150)) +
|
||||
scaleIn(
|
||||
initialScale = 0.3f,
|
||||
animationSpec =
|
||||
spring(dampingRatio = 0.6f)
|
||||
),
|
||||
exit = fadeOut(tween(100)) + scaleOut(targetScale = 0.3f)
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.padding(start = 4.dp, end = 12.dp)
|
||||
.size(24.dp)
|
||||
.clip(CircleShape)
|
||||
.background(PrimaryBlue),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = "Selected",
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1212,27 +1307,8 @@ private fun isGroupInviteCode(text: String): Boolean {
|
||||
}
|
||||
|
||||
private fun groupSenderLabelColor(publicKey: String, isDarkTheme: Boolean): Color {
|
||||
val paletteDark =
|
||||
listOf(
|
||||
Color(0xFF7ED957),
|
||||
Color(0xFF6EC1FF),
|
||||
Color(0xFFFF9F68),
|
||||
Color(0xFFC38AFF),
|
||||
Color(0xFFFF7AA2),
|
||||
Color(0xFF4DD7C8)
|
||||
)
|
||||
val paletteLight =
|
||||
listOf(
|
||||
Color(0xFF2E7D32),
|
||||
Color(0xFF1565C0),
|
||||
Color(0xFFEF6C00),
|
||||
Color(0xFF6A1B9A),
|
||||
Color(0xFFC2185B),
|
||||
Color(0xFF00695C)
|
||||
)
|
||||
val palette = if (isDarkTheme) paletteDark else paletteLight
|
||||
val index = kotlin.math.abs(publicKey.hashCode()) % palette.size
|
||||
return palette[index]
|
||||
// Match nickname color with avatar initials color.
|
||||
return com.rosetta.messenger.ui.chats.getAvatarColor(publicKey, isDarkTheme).textColor
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -358,13 +358,14 @@ fun AppleEmojiText(
|
||||
enableMentions: Boolean = false,
|
||||
onMentionClick: ((String) -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
||||
onLongClick: (() -> Unit)? = null // 🔥 Callback для long press (selection в MessageBubble)
|
||||
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
|
||||
minHeightMultiplier: Float = 1.5f
|
||||
) {
|
||||
val fontSizeValue = if (fontSize == androidx.compose.ui.unit.TextUnit.Unspecified) 15f
|
||||
else fontSize.value
|
||||
|
||||
// Минимальная высота для корректного отображения emoji
|
||||
val minHeight = (fontSizeValue * 1.5).toInt()
|
||||
val minHeight = (fontSizeValue * minHeightMultiplier).toInt()
|
||||
|
||||
// Преобразуем FontWeight в Android typeface style
|
||||
val typefaceStyle = when (fontWeight) {
|
||||
|
||||
@@ -575,7 +575,7 @@ private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) {
|
||||
painter = painterResource(id = wallpaperResId),
|
||||
contentDescription = "Chat wallpaper preview",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
contentScale = ContentScale.FillBounds
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user