From ba7182abe63bd533d3903d3bb4d12250754a5f96 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sun, 22 Feb 2026 12:32:19 +0500 Subject: [PATCH] Refactor image blurring to use RenderScript for improved performance and quality - Replaced custom fast blur implementation with RenderScript-based Gaussian blur in BlurredAvatarBackground and AppearanceScreen. - Updated image processing logic to scale down bitmaps before applying blur for efficiency. - Simplified blur logic by removing unnecessary pixel manipulation methods. - Enhanced media preview handling in OtherProfileScreen to utilize new Gaussian blur function. - Improved code readability and maintainability by consolidating blur functionality. --- .../com/rosetta/messenger/MainActivity.kt | 52 +- .../messenger/data/MessageRepository.kt | 32 +- .../rosetta/messenger/data/ReleaseNotes.kt | 4 + .../messenger/database/MessageEntities.kt | 64 ++ .../messenger/database/RosettaDatabase.kt | 35 +- .../messenger/ui/chats/ChatDetailScreen.kt | 211 ++++++- .../messenger/ui/chats/ChatViewModel.kt | 84 ++- .../messenger/ui/chats/ChatsListScreen.kt | 199 +++--- .../chats/components/AttachmentComponents.kt | 43 +- .../chats/components/ChatDetailComponents.kt | 534 +++++++++++++++- .../ui/components/BlurredAvatarBackground.kt | 147 ++--- .../messenger/ui/settings/AppearanceScreen.kt | 95 +-- .../ui/settings/OtherProfileScreen.kt | 575 ++++++++---------- 13 files changed, 1378 insertions(+), 697 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index ab7cb87..656a3ff 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -28,6 +28,7 @@ import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.PreferencesManager +import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.database.RosettaDatabase @@ -1097,6 +1098,7 @@ fun MainScreen( val biometricPrefs = remember { com.rosetta.messenger.biometric.BiometricPreferences(context) } + val biometricAccountManager = remember { AccountManager(context) } val activity = context as? FragmentActivity BiometricEnableScreen( @@ -1108,24 +1110,40 @@ fun MainScreen( return@BiometricEnableScreen } - biometricManager.encryptPassword( - activity = activity, - password = password, - onSuccess = { encryptedPassword -> - mainScreenScope.launch { - biometricPrefs.saveEncryptedPassword( - accountPublicKey, - encryptedPassword - ) - biometricPrefs.enableBiometric() - onSuccess() + // Verify password against the real account before saving + mainScreenScope.launch { + val account = biometricAccountManager.getAccount(accountPublicKey) + if (account == null) { + onError("Account not found") + return@launch + } + val decryptedKey = try { + CryptoManager.decryptWithPassword(account.encryptedPrivateKey, password) + } catch (_: Exception) { null } + if (decryptedKey == null) { + onError("Incorrect password") + return@launch + } + + biometricManager.encryptPassword( + activity = activity, + password = password, + onSuccess = { encryptedPassword -> + mainScreenScope.launch { + biometricPrefs.saveEncryptedPassword( + accountPublicKey, + encryptedPassword + ) + biometricPrefs.enableBiometric() + onSuccess() + } + }, + onError = { error -> onError(error) }, + onCancel = { + navStack = navStack.filterNot { it is Screen.Biometric } } - }, - onError = { error -> onError(error) }, - onCancel = { - navStack = navStack.filterNot { it is Screen.Biometric } - } - ) + ) + } } ) } diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 04b5f34..9839ecb 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -230,16 +230,16 @@ class MessageRepository private constructor(private val context: Context) { _newMessageEvents.tryEmit(dialogKey) } - /** Send a system message from "Rosetta Updates" account */ - suspend fun addUpdateSystemMessage(messageText: String) { - val account = currentAccount ?: return - val privateKey = currentPrivateKey ?: return + /** Send a system message from "Rosetta Updates" account. Returns messageId or null. */ + suspend fun addUpdateSystemMessage(messageText: String): String? { + val account = currentAccount ?: return null + val privateKey = currentPrivateKey ?: return null val encryptedPlainMessage = try { CryptoManager.encryptWithPassword(messageText, privateKey) } catch (_: Exception) { - return + return null } val messageId = UUID.randomUUID().toString().replace("-", "").take(32) @@ -265,7 +265,7 @@ class MessageRepository private constructor(private val context: Context) { ) ) - if (inserted == -1L) return + if (inserted == -1L) return null val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY) dialogDao.insertDialog( @@ -286,6 +286,7 @@ class MessageRepository private constructor(private val context: Context) { dialogDao.updateDialogFromMessages(account, SYSTEM_UPDATES_PUBLIC_KEY) _newMessageEvents.tryEmit(dialogKey) + return messageId } /** @@ -295,12 +296,23 @@ class MessageRepository private constructor(private val context: Context) { suspend fun checkAndSendVersionUpdateMessage() { val account = currentAccount ?: return val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE) - val lastNoticeVersion = prefs.getString("lastNoticeVersion", "") ?: "" + val lastNoticeKey = prefs.getString("lastNoticeKey", "") ?: "" val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME + val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}" - if (lastNoticeVersion != currentVersion) { - addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion)) - prefs.edit().putString("lastNoticeVersion", currentVersion).apply() + if (lastNoticeKey != currentKey) { + // Delete the previous message for this version (if any) + val prevMessageId = prefs.getString("lastNoticeMessageId_$currentVersion", null) + if (prevMessageId != null) { + messageDao.deleteMessage(account, prevMessageId) + dialogDao.updateDialogFromMessages(account, SYSTEM_UPDATES_PUBLIC_KEY) + } + + val messageId = addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion)) + prefs.edit() + .putString("lastNoticeKey", currentKey) + .putString("lastNoticeMessageId_$currentVersion", messageId) + .apply() } } diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index b159acf..49b0eb6 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -28,4 +28,8 @@ object ReleaseNotes { fun getNotice(version: String): String = RELEASE_NOTICE.replace(VERSION_PLACEHOLDER, version) + + /** Hash of current notice text — used to re-send if text changed within the same version */ + val noticeHash: String + get() = RELEASE_NOTICE.hashCode().toString(16) } diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index 79fc441..964ea2c 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -3,6 +3,70 @@ package com.rosetta.messenger.database import androidx.room.* import kotlinx.coroutines.flow.Flow +// ═══════════════════════════════════════════════════════════ +// 📌 PINNED MESSAGES +// ═══════════════════════════════════════════════════════════ + +/** Entity для закреплённых сообщений в чате (Telegram-style pinned messages) */ +@Entity( + tableName = "pinned_messages", + indices = + [ + Index( + value = ["account", "dialog_key", "message_id"], + unique = true + ), + Index(value = ["account", "dialog_key", "pinned_at"])] +) +data class PinnedMessageEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(name = "account") val account: String, + @ColumnInfo(name = "dialog_key") val dialogKey: String, + @ColumnInfo(name = "message_id") val messageId: String, + @ColumnInfo(name = "pinned_at") val pinnedAt: Long = System.currentTimeMillis() +) + +/** DAO для работы с закреплёнными сообщениями */ +@Dao +interface PinnedMessageDao { + + /** Закрепить сообщение (IGNORE если уже закреплено) */ + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertPin(pin: PinnedMessageEntity): Long + + /** Открепить конкретное сообщение */ + @Query( + "DELETE FROM pinned_messages WHERE account = :account AND dialog_key = :dialogKey AND message_id = :messageId" + ) + suspend fun removePin(account: String, dialogKey: String, messageId: String) + + /** Получить все закреплённые сообщения диалога (Flow для реактивных обновлений) */ + @Query( + """ + SELECT * FROM pinned_messages + WHERE account = :account AND dialog_key = :dialogKey + ORDER BY pinned_at DESC + """ + ) + fun getPinnedMessages(account: String, dialogKey: String): Flow> + + /** Проверить, закреплено ли сообщение */ + @Query( + "SELECT EXISTS(SELECT 1 FROM pinned_messages WHERE account = :account AND dialog_key = :dialogKey AND message_id = :messageId)" + ) + suspend fun isPinned(account: String, dialogKey: String, messageId: String): Boolean + + /** Открепить все сообщения диалога */ + @Query("DELETE FROM pinned_messages WHERE account = :account AND dialog_key = :dialogKey") + suspend fun unpinAll(account: String, dialogKey: String): Int + + /** Количество закреплённых сообщений в диалоге */ + @Query( + "SELECT COUNT(*) FROM pinned_messages WHERE account = :account AND dialog_key = :dialogKey" + ) + suspend fun getPinnedCount(account: String, dialogKey: String): Int +} + /** 🔥 Data class для статуса последнего сообщения */ data class LastMessageStatus( @ColumnInfo(name = "from_me") val fromMe: Int, diff --git a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt index 451b1d1..5374c3c 100644 --- a/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt +++ b/app/src/main/java/com/rosetta/messenger/database/RosettaDatabase.kt @@ -15,8 +15,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase DialogEntity::class, BlacklistEntity::class, AvatarCacheEntity::class, - AccountSyncTimeEntity::class], - version = 12, + AccountSyncTimeEntity::class, + PinnedMessageEntity::class], + version = 13, exportSchema = false ) abstract class RosettaDatabase : RoomDatabase() { @@ -26,6 +27,7 @@ abstract class RosettaDatabase : RoomDatabase() { abstract fun blacklistDao(): BlacklistDao abstract fun avatarDao(): AvatarDao abstract fun syncTimeDao(): SyncTimeDao + abstract fun pinnedMessageDao(): PinnedMessageDao companion object { @Volatile private var INSTANCE: RosettaDatabase? = null @@ -148,6 +150,32 @@ abstract class RosettaDatabase : RoomDatabase() { } } + /** + * 📌 МИГРАЦИЯ 12->13: Таблица pinned_messages для закреплённых сообщений (Telegram-style) + */ + private val MIGRATION_12_13 = + object : Migration(12, 13) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL( + """ + CREATE TABLE IF NOT EXISTS pinned_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + account TEXT NOT NULL, + dialog_key TEXT NOT NULL, + message_id TEXT NOT NULL, + pinned_at INTEGER NOT NULL + ) + """ + ) + database.execSQL( + "CREATE UNIQUE INDEX IF NOT EXISTS index_pinned_messages_account_dialog_key_message_id ON pinned_messages (account, dialog_key, message_id)" + ) + database.execSQL( + "CREATE INDEX IF NOT EXISTS index_pinned_messages_account_dialog_key_pinned_at ON pinned_messages (account, dialog_key, pinned_at)" + ) + } + } + fun getDatabase(context: Context): RosettaDatabase { return INSTANCE ?: synchronized(this) { @@ -168,7 +196,8 @@ abstract class RosettaDatabase : RoomDatabase() { MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, - MIGRATION_11_12 + MIGRATION_11_12, + MIGRATION_12_13 ) .fallbackToDestructiveMigration() // Для разработки - только // если миграция не diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 08055e8..669f9c7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -15,6 +15,9 @@ import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.scaleOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -169,6 +172,16 @@ fun ChatDetailScreen( var selectedMessages by remember { mutableStateOf>(emptySet()) } val isSelectionMode = selectedMessages.isNotEmpty() + // 💬 MESSAGE CONTEXT MENU STATE + var contextMenuMessage by remember { mutableStateOf(null) } + var showContextMenu by remember { mutableStateOf(false) } + var contextMenuIsPinned by remember { mutableStateOf(false) } + + // 📌 PINNED MESSAGES + val pinnedMessages by viewModel.pinnedMessages.collectAsState() + val currentPinnedIndex by viewModel.currentPinnedIndex.collectAsState() + var isPinnedBannerDismissed by remember { mutableStateOf(false) } + // Логирование изменений selection mode LaunchedEffect(isSelectionMode, selectedMessages.size) {} @@ -463,7 +476,24 @@ fun ChatDetailScreen( } } - // 🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default + // � Текст текущего pinned сообщения для баннера + val currentPinnedMessagePreview = remember(pinnedMessages, currentPinnedIndex, messages) { + if (pinnedMessages.isEmpty()) "" + else { + val idx = currentPinnedIndex.coerceIn(0, pinnedMessages.size - 1) + val pinnedMsgId = pinnedMessages[idx].messageId + messages.find { it.id == pinnedMsgId }?.text ?: "..." + } + } + + // 📌 Сброс dismissed при изменении pinned messages (когда добавляют новый pin) + LaunchedEffect(pinnedMessages.size) { + if (pinnedMessages.isNotEmpty()) { + isPinnedBannerDismissed = false + } + } + + // �🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default // (dedup + sort + date headers off the main thread) val messagesWithDates by viewModel.messagesWithDates.collectAsState() @@ -603,6 +633,7 @@ fun ChatDetailScreen( Scaffold( contentWindowInsets = WindowInsets(0.dp), topBar = { + Column { // 🔥 UNIFIED HEADER - один контейнер, контент меняется внутри Box( modifier = @@ -1237,6 +1268,42 @@ fun ChatDetailScreen( ) ) } // Закрытие Box unified header + + // 📌 PINNED MESSAGE BANNER with shrink animation + androidx.compose.animation.AnimatedVisibility( + visible = pinnedMessages.isNotEmpty() && !isPinnedBannerDismissed, + enter = expandVertically( + animationSpec = tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing) + ) + fadeIn(tween(200)), + exit = shrinkVertically( + animationSpec = tween(250, easing = androidx.compose.animation.core.FastOutSlowInEasing) + ) + fadeOut(tween(150)) + ) { + val idx = currentPinnedIndex.coerceIn(0, (pinnedMessages.size - 1).coerceAtLeast(0)) + PinnedMessageBanner( + pinnedCount = pinnedMessages.size.coerceAtLeast(1), + currentIndex = idx, + messagePreview = currentPinnedMessagePreview, + isDarkTheme = isDarkTheme, + onBannerClick = { + if (pinnedMessages.isNotEmpty()) { + val messageId = viewModel.navigateToNextPinned() + if (messageId != null) { + scrollToMessage(messageId) + } + } + }, + onCloseClick = { + if (pinnedMessages.isNotEmpty()) { + // 📌 Открепляем текущий показанный пин + val pinIdx = currentPinnedIndex.coerceIn(0, pinnedMessages.size - 1) + val pinToRemove = pinnedMessages[pinIdx] + viewModel.unpinMessage(pinToRemove.messageId) + } + } + ) + } + } // Закрытие Column topBar }, containerColor = backgroundColor, // Фон всего чата // 🔥 Bottom bar - инпут с умным padding @@ -2008,26 +2075,24 @@ fun ChatDetailScreen( .LongPress ) - if (!isSelectionMode - ) { - val imm = - context.getSystemService( - Context.INPUT_METHOD_SERVICE - ) as - InputMethodManager - imm.hideSoftInputFromWindow( - view.windowToken, - 0 - ) - focusManager - .clearFocus() - showEmojiPicker = - false - } - toggleMessageSelection( - selectionKey, - true - ) + // � Long press = selection mode + val imm = + context.getSystemService( + Context.INPUT_METHOD_SERVICE + ) as + InputMethodManager + imm.hideSoftInputFromWindow( + view.windowToken, + 0 + ) + focusManager + .clearFocus() + showEmojiPicker = + false + toggleMessageSelection( + selectionKey, + true + ) }, onClick = { val hasAvatar = @@ -2037,11 +2102,24 @@ fun ChatDetailScreen( AttachmentType .AVATAR } + val isPhotoOnly = + message.attachments.isNotEmpty() && + message.text.isEmpty() && + message.attachments.all { + it.type == AttachmentType.IMAGE + } if (isSelectionMode) { toggleMessageSelection( selectionKey, !hasAvatar ) + } else if (!hasAvatar && !isPhotoOnly) { + // 💬 Tap = context menu + contextMenuMessage = message + showContextMenu = true + scope.launch { + contextMenuIsPinned = viewModel.isMessagePinned(message.id) + } } }, onSwipeToReply = { @@ -2122,9 +2200,91 @@ fun ChatDetailScreen( onUserProfileClick(resolvedUser) } } - } - ) - } + }, + contextMenuContent = { + // 💬 Context menu anchored to this bubble + if (showContextMenu && contextMenuMessage?.id == message.id) { + val msg = contextMenuMessage!! + MessageContextMenu( + expanded = true, + onDismiss = { + showContextMenu = false + contextMenuMessage = null + }, + isDarkTheme = isDarkTheme, + isPinned = contextMenuIsPinned, + isOutgoing = msg.isOutgoing, + hasText = msg.text.isNotBlank(), + isSystemAccount = isSystemAccount, + onReply = { + viewModel.setReplyMessages(listOf(msg)) + showContextMenu = false + contextMenuMessage = null + }, + onCopy = { + clipboardManager.setText( + androidx.compose.ui.text.AnnotatedString(msg.text) + ) + showContextMenu = false + contextMenuMessage = null + }, + onForward = { + val forwardMessages = if (msg.forwardedMessages.isNotEmpty()) { + msg.forwardedMessages.map { fwd -> + ForwardManager.ForwardMessage( + messageId = fwd.messageId, + text = fwd.text, + timestamp = msg.timestamp.time, + isOutgoing = fwd.isFromMe, + senderPublicKey = fwd.senderPublicKey.ifEmpty { + if (fwd.isFromMe) currentUserPublicKey else user.publicKey + }, + originalChatPublicKey = user.publicKey, + senderName = fwd.forwardedFromName.ifEmpty { fwd.senderName.ifEmpty { "User" } }, + attachments = fwd.attachments + .filter { it.type != AttachmentType.MESSAGES } + .map { it.copy(localUri = "") } + ) + } + } else { + listOf(ForwardManager.ForwardMessage( + messageId = msg.id, + text = msg.text, + timestamp = msg.timestamp.time, + isOutgoing = msg.isOutgoing, + senderPublicKey = if (msg.isOutgoing) currentUserPublicKey else user.publicKey, + originalChatPublicKey = user.publicKey, + senderName = if (msg.isOutgoing) currentUserName.ifEmpty { "You" } + else user.title.ifEmpty { user.username.ifEmpty { "User" } }, + attachments = msg.attachments + .filter { it.type != AttachmentType.MESSAGES } + .map { it.copy(localUri = "") } + )) + } + ForwardManager.setForwardMessages(forwardMessages, showPicker = false) + showForwardPicker = true + showContextMenu = false + contextMenuMessage = null + }, + onPin = { + if (contextMenuIsPinned) { + viewModel.unpinMessage(msg.id) + } else { + viewModel.pinMessage(msg.id) + isPinnedBannerDismissed = false + } + showContextMenu = false + contextMenuMessage = null + }, + onDelete = { + viewModel.deleteMessage(msg.id) + showContextMenu = false + contextMenuMessage = null + } + ) + } + } // contextMenuContent + ) } } } @@ -2547,4 +2707,7 @@ fun ChatDetailScreen( onClearLogs = { ProtocolManager.clearLogs() } ) } + } +} + diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 87922d2..df6870f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -96,6 +96,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val database = RosettaDatabase.getDatabase(application) private val dialogDao = database.dialogDao() private val messageDao = database.messageDao() + private val pinnedMessageDao = database.pinnedMessageDao() // MessageRepository для подписки на события новых сообщений private val messageRepository = @@ -190,6 +191,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val _isForwardMode = MutableStateFlow(false) val isForwardMode: StateFlow = _isForwardMode.asStateFlow() + // 📌 Pinned messages state + private val _pinnedMessages = MutableStateFlow>(emptyList()) + val pinnedMessages: StateFlow> = _pinnedMessages.asStateFlow() + + private val _currentPinnedIndex = MutableStateFlow(0) + val currentPinnedIndex: StateFlow = _currentPinnedIndex.asStateFlow() + + private var pinnedCollectionJob: Job? = null + // Пагинация private var currentOffset = 0 private var hasMoreMessages = true @@ -603,6 +613,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // Подписываемся на онлайн статус subscribeToOnlineStatus() + // 📌 Подписываемся на pinned messages + pinnedCollectionJob?.cancel() + pinnedCollectionJob = viewModelScope.launch(Dispatchers.IO) { + val acc = myPublicKey ?: return@launch + val dialogKey = getDialogKey(acc, publicKey) + pinnedMessageDao.getPinnedMessages(acc, dialogKey).collect { pins -> + _pinnedMessages.value = pins + // Всегда показываем самый последний пин (index 0, ORDER BY DESC) + _currentPinnedIndex.value = 0 + } + } + // � P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer loadMessagesFromDatabase(delayMs = 0L) } @@ -1660,14 +1682,74 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { _isForwardMode.value = false } + // ═══════════════════════════════════════════════════════════ + // 📌 PINNED MESSAGES + // ═══════════════════════════════════════════════════════════ + + /** 📌 Закрепить сообщение */ + fun pinMessage(messageId: String) { + viewModelScope.launch(Dispatchers.IO) { + val account = myPublicKey ?: return@launch + val opponent = opponentKey ?: return@launch + val dialogKey = getDialogKey(account, opponent) + pinnedMessageDao.insertPin( + com.rosetta.messenger.database.PinnedMessageEntity( + account = account, + dialogKey = dialogKey, + messageId = messageId + ) + ) + } + } + + /** 📌 Открепить сообщение */ + fun unpinMessage(messageId: String) { + viewModelScope.launch(Dispatchers.IO) { + val account = myPublicKey ?: return@launch + val opponent = opponentKey ?: return@launch + val dialogKey = getDialogKey(account, opponent) + pinnedMessageDao.removePin(account, dialogKey, messageId) + } + } + + /** 📌 Проверить, закреплено ли сообщение */ + suspend fun isMessagePinned(messageId: String): Boolean { + val account = myPublicKey ?: return false + val opponent = opponentKey ?: return false + val dialogKey = getDialogKey(account, opponent) + return pinnedMessageDao.isPinned(account, dialogKey, messageId) + } + + /** 📌 Перейти к следующему закреплённому сообщению (от нового к старому, циклически) */ + fun navigateToNextPinned(): String? { + val pins = _pinnedMessages.value + if (pins.isEmpty()) return null + val currentIdx = _currentPinnedIndex.value + val nextIdx = (currentIdx + 1) % pins.size + _currentPinnedIndex.value = nextIdx + return pins[nextIdx].messageId + } + + /** 📌 Открепить все сообщения */ + fun unpinAllMessages() { + viewModelScope.launch(Dispatchers.IO) { + val account = myPublicKey ?: return@launch + val opponent = opponentKey ?: return@launch + val dialogKey = getDialogKey(account, opponent) + pinnedMessageDao.unpinAll(account, dialogKey) + } + } + /** 🔥 Удалить сообщение (для ошибки отправки) */ fun deleteMessage(messageId: String) { // Удаляем из UI сразу на main _messages.value = _messages.value.filter { it.id != messageId } - // Удаляем из БД в IO + // Удаляем из БД в IO + удаляем pin если был viewModelScope.launch(Dispatchers.IO) { val account = myPublicKey ?: return@launch + val dialogKey = opponentKey ?: return@launch + pinnedMessageDao.removePin(account, dialogKey, messageId) messageDao.deleteMessage(account, messageId) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 0f15b7e..c072d01 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -42,6 +42,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -3645,67 +3646,72 @@ fun DialogItemContent( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { - // 🔥 Показываем typing индикатор или последнее сообщение - if (isTyping) { - TypingIndicatorSmall() - } else if (!dialog.draftText.isNullOrEmpty()) { - // 📝 Показываем черновик (как в Telegram) - Row(modifier = Modifier.weight(1f)) { - Text( - text = "Draft: ", - fontSize = 14.sp, - color = Color(0xFFFF3B30), // Красный как в Telegram - fontWeight = FontWeight.Normal, - maxLines = 1 - ) - AppleEmojiText( - text = dialog.draftText, - modifier = Modifier.weight(1f), - fontSize = 14.sp, - color = secondaryTextColor, - fontWeight = FontWeight.Normal, - maxLines = 1, - overflow = android.text.TextUtils.TruncateAt.END, - enableLinks = false - ) - } - } else { - // 📎 Определяем что показывать - attachment или текст - val displayText = - when { - dialog.lastMessageAttachmentType == - "Photo" -> "Photo" - dialog.lastMessageAttachmentType == - "File" -> "File" - dialog.lastMessageAttachmentType == - "Avatar" -> "Avatar" - dialog.lastMessageAttachmentType == - "Forwarded" -> "Forwarded message" - dialog.lastMessage.isEmpty() -> - "No messages" - else -> dialog.lastMessage - } + // Stable weighted box prevents layout jitter on typing transition + Box( + modifier = Modifier.weight(1f).heightIn(min = 20.dp), + contentAlignment = Alignment.CenterStart + ) { + Crossfade( + targetState = isTyping, + animationSpec = tween(150), + label = "chatSubtitle" + ) { showTyping -> + if (showTyping) { + TypingIndicatorSmall() + } else if (!dialog.draftText.isNullOrEmpty()) { + Row { + Text( + text = "Draft: ", + fontSize = 14.sp, + color = Color(0xFFFF3B30), + fontWeight = FontWeight.Normal, + maxLines = 1 + ) + AppleEmojiText( + text = dialog.draftText, + modifier = Modifier.weight(1f), + fontSize = 14.sp, + color = secondaryTextColor, + fontWeight = FontWeight.Normal, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + enableLinks = false + ) + } + } else { + val displayText = + when { + dialog.lastMessageAttachmentType == + "Photo" -> "Photo" + dialog.lastMessageAttachmentType == + "File" -> "File" + dialog.lastMessageAttachmentType == + "Avatar" -> "Avatar" + dialog.lastMessageAttachmentType == + "Forwarded" -> "Forwarded message" + dialog.lastMessage.isEmpty() -> + "No messages" + else -> dialog.lastMessage + } - // 🔥 Используем AppleEmojiText для отображения эмодзи - // Если есть непрочитанные - текст темнее - AppleEmojiText( - text = displayText, - fontSize = 14.sp, - color = - if (dialog.unreadCount > 0) - textColor.copy(alpha = 0.85f) - else secondaryTextColor, - fontWeight = - if (dialog.unreadCount > 0) - FontWeight.Medium - else FontWeight.Normal, - maxLines = 1, - overflow = android.text.TextUtils.TruncateAt.END, - modifier = Modifier.weight(1f), - enableLinks = - false // 🔗 Ссылки не кликабельны в списке - // чатов - ) + AppleEmojiText( + text = displayText, + fontSize = 14.sp, + color = + if (dialog.unreadCount > 0) + textColor.copy(alpha = 0.85f) + else secondaryTextColor, + fontWeight = + if (dialog.unreadCount > 0) + FontWeight.Medium + else FontWeight.Normal, + maxLines = 1, + overflow = android.text.TextUtils.TruncateAt.END, + modifier = Modifier.fillMaxWidth(), + enableLinks = false + ) + } + } } // Unread badge @@ -3778,50 +3784,61 @@ fun DialogItemContent( } /** - * 🔥 Компактный индикатор typing для списка чатов Голубой текст "typing" с анимированными точками + * Telegram-style typing indicator for chat list — 3 bouncing Canvas circles + * with sequential wave animation (scale + vertical offset + opacity). */ @Composable fun TypingIndicatorSmall() { - val infiniteTransition = rememberInfiniteTransition(label = "typing") val typingColor = PrimaryBlue + val infiniteTransition = rememberInfiniteTransition(label = "typing") - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(1.dp) - ) { + // Each dot animates 0→1→0 in a 1200 ms cycle, staggered by 150 ms + val dotProgresses = List(3) { index -> + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0f at 0 with LinearEasing + 1f at 300 with FastOutSlowInEasing + 0f at 600 with FastOutSlowInEasing + 0f at 1200 with LinearEasing + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(index * 150) + ), + label = "dot$index" + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { Text( text = "typing", fontSize = 14.sp, color = typingColor, fontWeight = FontWeight.Medium ) + Spacer(modifier = Modifier.width(2.dp)) - // 3 анимированные точки - repeat(3) { index -> - val offsetY by - infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = -3f, - animationSpec = - infiniteRepeatable( - animation = - tween( - durationMillis = 500, - delayMillis = index * 120, - easing = FastOutSlowInEasing - ), - repeatMode = RepeatMode.Reverse - ), - label = "dot$index" + // Fixed-size canvas — big enough for bounce, never changes layout + Canvas(modifier = Modifier.size(width = 18.dp, height = 14.dp)) { + val dotRadius = 1.5.dp.toPx() + val dotSpacing = 2.5.dp.toPx() + val maxBounce = 2.dp.toPx() + val centerY = size.height / 2f + 1.dp.toPx() + for (i in 0..2) { + val p = dotProgresses[i].value + val bounce = kotlin.math.sin(p * Math.PI).toFloat() + val cx = dotRadius + i * (dotRadius * 2 + dotSpacing) + val cy = centerY - bounce * maxBounce + val alpha = 0.4f + bounce * 0.6f + drawCircle( + color = typingColor.copy(alpha = alpha), + radius = dotRadius, + center = Offset(cx, cy) ) - - Text( - text = ".", - fontSize = 14.sp, - color = typingColor, - fontWeight = FontWeight.Medium, - modifier = Modifier.offset(y = offsetY.dp) - ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index b7b60ba..251c410 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -62,6 +62,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import androidx.compose.ui.platform.LocalConfiguration import androidx.core.content.FileProvider @@ -223,6 +224,14 @@ object ImageBitmapCache { } } +/** + * 🔒 Global semaphore to limit concurrent image decode/download operations + * This prevents lag when opening collages with many photos + */ +object ImageLoadSemaphore { + val semaphore = kotlinx.coroutines.sync.Semaphore(3) +} + /** * 📐 Telegram Bubble Specification * Все константы взяты из ChatMessageCell.java и Theme.java @@ -662,15 +671,30 @@ fun ImageCollage( } // Остальные по 3 в ряд val remaining = attachments.drop(2) - remaining.chunked(3).forEachIndexed { rowIndex, rowItems -> - val isLastRow = rowIndex == remaining.chunked(3).size - 1 + val rows = remaining.chunked(3) + val totalRows = rows.size + rows.forEachIndexed { rowIndex, rowItems -> + val isLastRow = rowIndex == totalRows - 1 + val isIncompleteRow = rowItems.size < 3 Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(spacing) + horizontalArrangement = if (isIncompleteRow) + Arrangement.Start + else + Arrangement.spacedBy(spacing) ) { rowItems.forEachIndexed { index, attachment -> val isLastItem = isLastRow && index == rowItems.size - 1 - Box(modifier = Modifier.weight(1f).aspectRatio(1f)) { + // Для неполных рядов используем фиксированную ширину = 1/3 от общей + val cellModifier = if (isIncompleteRow) { + Modifier + .fillMaxWidth(1f / 3f) + .padding(end = if (index < rowItems.size - 1) spacing else 0.dp) + .aspectRatio(1f) + } else { + Modifier.weight(1f).aspectRatio(1f) + } + Box(modifier = cellModifier) { ImageAttachment( attachment = attachment, chachaKey = chachaKey, @@ -688,8 +712,6 @@ fun ImageCollage( ) } } - // Заполняем пустые места если в ряду меньше 3 фото - repeat(3 - rowItems.size) { Spacer(modifier = Modifier.weight(1f)) } } } } @@ -825,9 +847,11 @@ fun ImageAttachment( // Загружаем изображение если статус DOWNLOADED if (downloadStatus == DownloadStatus.DOWNLOADED) { - withContext(Dispatchers.IO) { - // 🚀 0. Если есть localUri - загружаем напрямую из URI (optimistic UI) - if (imageBitmap == null && attachment.localUri.isNotEmpty()) { + // 🔒 Ограничиваем параллельные загрузки через семафор + ImageLoadSemaphore.semaphore.withPermit { + withContext(Dispatchers.IO) { + // 🚀 0. Если есть localUri - загружаем напрямую из URI (optimistic UI) + if (imageBitmap == null && attachment.localUri.isNotEmpty()) { try { val uri = android.net.Uri.parse(attachment.localUri) @@ -917,6 +941,7 @@ fun ImageAttachment( downloadStatus = DownloadStatus.NOT_DOWNLOADED } } + } } if (imageBitmap == null && downloadStatus == DownloadStatus.DOWNLOADED) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 7b56d46..f2c5f2e 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -1,9 +1,12 @@ package com.rosetta.messenger.ui.chats.components import android.graphics.Bitmap +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ContentCopy import android.graphics.BitmapFactory import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.foundation.Canvas import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -25,6 +28,12 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.Layout @@ -200,42 +209,57 @@ fun DateHeader(dateText: String, secondaryTextColor: Color) { } } -/** Typing indicator with animated dots (Telegram style) */ +/** + * Telegram-style typing indicator — 3 bouncing dots drawn as Canvas circles + * with sequential wave animation (scale + vertical offset + opacity). + */ @Composable fun TypingIndicator(isDarkTheme: Boolean) { - val infiniteTransition = rememberInfiniteTransition(label = "typing") val typingColor = Color(0xFF54A9EB) + val infiniteTransition = rememberInfiniteTransition(label = "typing") - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { + // Each dot animates through a 0→1→0 cycle, staggered by 150 ms + val dotProgresses = List(3) { index -> + infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0f at 0 with LinearEasing + 1f at 300 with FastOutSlowInEasing + 0f at 600 with FastOutSlowInEasing + 0f at 1200 with LinearEasing + }, + repeatMode = RepeatMode.Restart, + initialStartOffset = StartOffset(index * 150) + ), + label = "dot$index" + ) + } + + Row(verticalAlignment = Alignment.CenterVertically) { Text(text = "typing", fontSize = 13.sp, color = typingColor) + Spacer(modifier = Modifier.width(2.dp)) - repeat(3) { index -> - val offsetY by - infiniteTransition.animateFloat( - initialValue = 0f, - targetValue = -4f, - animationSpec = - infiniteRepeatable( - animation = - tween( - durationMillis = 600, - delayMillis = index * 100, - easing = FastOutSlowInEasing - ), - repeatMode = RepeatMode.Reverse - ), - label = "dot$index" + // Fixed-size canvas — big enough for bounce, never changes layout + Canvas(modifier = Modifier.size(width = 18.dp, height = 14.dp)) { + val dotRadius = 1.5.dp.toPx() + val dotSpacing = 2.5.dp.toPx() + val maxBounce = 2.dp.toPx() + val centerY = size.height / 2f + 1.dp.toPx() + for (i in 0..2) { + val p = dotProgresses[i].value + val bounce = kotlin.math.sin(p * Math.PI).toFloat() + val cx = dotRadius + i * (dotRadius * 2 + dotSpacing) + val cy = centerY - bounce * maxBounce + val alpha = 0.4f + bounce * 0.6f + drawCircle( + color = typingColor.copy(alpha = alpha), + radius = dotRadius, + center = androidx.compose.ui.geometry.Offset(cx, cy) ) - - Text( - text = ".", - fontSize = 13.sp, - color = typingColor, - modifier = Modifier.offset(y = offsetY.dp) - ) + } } } } @@ -264,7 +288,8 @@ fun MessageBubble( onRetry: () -> Unit = {}, onDelete: () -> Unit = {}, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, - onForwardedSenderClick: (senderPublicKey: String) -> Unit = {} + onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, + contextMenuContent: @Composable () -> Unit = {} ) { // Swipe-to-reply state var swipeOffset by remember { mutableStateOf(0f) } @@ -317,7 +342,7 @@ fun MessageBubble( else Color(0xFF2196F3) // Стандартный Material Blue для входящих } val linksEnabled = !isSelectionMode - val textClickHandler: (() -> Unit)? = if (isSelectionMode) onClick else null + val textClickHandler: (() -> Unit)? = onClick val timeColor = remember(message.isOutgoing, isDarkTheme) { @@ -1010,6 +1035,8 @@ fun MessageBubble( } } } + // 💬 Context menu anchor (DropdownMenu positions relative to this Box) + contextMenuContent() } } } @@ -1872,6 +1899,451 @@ private fun SkeletonBubble( } } +// ═══════════════════════════════════════════════════════════ +// 📌 PINNED MESSAGE BANNER (Telegram-style) +// ═══════════════════════════════════════════════════════════ + +/** + * Telegram-style pinned message banner — отображается под хедером чата. + * При клике скроллит к текущему pinned сообщению и переключает на следующее. + */ +@Composable +fun PinnedMessageBanner( + pinnedCount: Int, + currentIndex: Int, + messagePreview: String, + isDarkTheme: Boolean, + onBannerClick: () -> Unit, + onCloseClick: () -> Unit +) { + val bannerBg = if (isDarkTheme) Color(0xFF212121) else Color(0xFFFFFFFF) + val textColor = if (isDarkTheme) Color.White else Color.Black + val secondaryColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) + val accentColor = PrimaryBlue + + // 📌 Animated text transition (slide up/down like Telegram) + var previousIndex by remember { mutableStateOf(currentIndex) } + var previousPreview by remember { mutableStateOf(messagePreview) } + val slideDirection = if (currentIndex > previousIndex) 1 else -1 // 1 = вверх, -1 = вниз + + val transition = updateTransition(targetState = currentIndex, label = "pinnedTransition") + val offsetY by transition.animateFloat( + transitionSpec = { tween(durationMillis = 200, easing = FastOutSlowInEasing) }, + label = "offsetY" + ) { targetIdx -> + if (targetIdx == previousIndex) 0f else 0f + } + + // Track text for outgoing animation + LaunchedEffect(currentIndex) { + previousIndex = currentIndex + previousPreview = messagePreview + } + + Box( + modifier = Modifier + .fillMaxWidth() + .background(bannerBg) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { onBannerClick() } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + // 📌 Telegram-style PinnedLineView (animated vertical segments indicator) + PinnedLineIndicator( + totalCount = pinnedCount, + selectedIndex = currentIndex, + accentColor = accentColor, + isDarkTheme = isDarkTheme, + modifier = Modifier + .width(2.dp) + .height(32.dp) + ) + + Spacer(modifier = Modifier.width(12.dp)) + + // Текст с анимацией + Column( + modifier = Modifier + .weight(1f) + .clipToBounds() + ) { + // Title: "Pinned Message" или "Pinned Message #N" + AnimatedContent( + targetState = currentIndex, + transitionSpec = { + val direction = if (targetState > initialState) 1 else -1 + (slideInVertically { height -> direction * height } + fadeIn(tween(200))) + .togetherWith(slideOutVertically { height -> -direction * height } + fadeOut(tween(150))) + }, + label = "pinnedTitle" + ) { idx -> + Text( + text = if (pinnedCount > 1) "Pinned Message #${pinnedCount - idx}" else "Pinned Message", + color = accentColor, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1 + ) + } + // Preview text + AnimatedContent( + targetState = messagePreview, + transitionSpec = { + (slideInVertically { height -> height } + fadeIn(tween(200))) + .togetherWith(slideOutVertically { height -> -height } + fadeOut(tween(150))) + }, + label = "pinnedPreview" + ) { preview -> + Text( + text = preview, + color = textColor, + fontSize = 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + // Кнопка закрытия (unpin) + Box( + modifier = Modifier + .size(32.dp) + .clip(CircleShape) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() } + ) { onCloseClick() }, + contentAlignment = Alignment.Center + ) { + Icon( + painter = TelegramIcons.Close, + contentDescription = "Unpin message", + tint = secondaryColor, + modifier = Modifier.size(16.dp) + ) + } + } + + // Bottom divider + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(0.5.dp) + .background( + if (isDarkTheme) Color.White.copy(alpha = 0.08f) + else Color.Black.copy(alpha = 0.06f) + ) + ) + } +} + +/** + * 📌 Telegram-style vertical line indicator for pinned messages. + * Shows segments for each pin (max 3 visible), active segment highlighted. + * Animates position and count transitions with 220ms cubic-bezier. + * Based on Telegram's PinnedLineView.java + */ +@Composable +private fun PinnedLineIndicator( + totalCount: Int, + selectedIndex: Int, + accentColor: Color, + isDarkTheme: Boolean, + modifier: Modifier = Modifier +) { + // Inactive color = accent with ~44% alpha (matches Telegram: alpha * 112/255) + val inactiveColor = accentColor.copy(alpha = 0.44f) + val activeColor = accentColor + + // Animate position changes + // Invert: index 0 (newest, DESC) → bottom segment, index N-1 (oldest) → top segment (Telegram-style) + val visualPosition = (totalCount - 1 - selectedIndex).coerceAtLeast(0) + val animatedPosition by animateFloatAsState( + targetValue = visualPosition.toFloat(), + animationSpec = tween(durationMillis = 220, easing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)), + label = "pinnedLinePosition" + ) + val animatedCount by animateFloatAsState( + targetValue = totalCount.toFloat(), + animationSpec = tween(durationMillis = 220, easing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1f)), + label = "pinnedLineCount" + ) + + if (totalCount <= 1) { + // Single pin — just a solid accent line + Box( + modifier = modifier + .clip(RoundedCornerShape(1.dp)) + .background(activeColor) + ) + } else { + Canvas(modifier = modifier) { + val viewPadding = 2.dp.toPx() + val maxVisible = 3 + val visibleCount = minOf(totalCount, maxVisible) + val lineH = (size.height - viewPadding * 2) / visibleCount.toFloat() + if (lineH <= 0f) return@Canvas + val linePadding = 1.dp.toPx() + val cornerRadius = size.width / 2f + + // Calculate scroll offset only when totalCount > maxVisible + var startOffset = 0f + if (totalCount > maxVisible) { + // Keep selected segment visible in the viewport + startOffset = (animatedPosition - 1) * lineH + if (startOffset < 0f) startOffset = 0f + val maxOffset = (totalCount - maxVisible).toFloat() * lineH + if (startOffset > maxOffset) startOffset = maxOffset + } + + // Draw visible segments + val start = maxOf(0, ((startOffset) / lineH).toInt() - 1) + val end = minOf(start + maxVisible + 2, totalCount) + + for (i in start until end) { + val y = viewPadding + i * lineH - startOffset + if (y + lineH < 0f || y > size.height) continue + + drawRoundRect( + color = inactiveColor, + topLeft = Offset(0f, y + linePadding), + size = Size(size.width, lineH - linePadding * 2), + cornerRadius = CornerRadius(cornerRadius, cornerRadius) + ) + } + + // Draw active (selected) segment on top + val activeY = viewPadding + animatedPosition * lineH - startOffset + drawRoundRect( + color = activeColor, + topLeft = Offset(0f, activeY + linePadding), + size = Size(size.width, lineH - linePadding * 2), + cornerRadius = CornerRadius(cornerRadius, cornerRadius) + ) + + // Fade edges when scrollable (>3 pins) + if (totalCount > maxVisible) { + // Top fade + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent.copy(alpha = 0.6f), Color.Transparent), + startY = 0f, + endY = 4.dp.toPx() + ), + size = Size(size.width, 4.dp.toPx()), + blendMode = BlendMode.DstOut + ) + // Bottom fade + drawRect( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, Color.Transparent.copy(alpha = 0.6f)), + startY = size.height - 4.dp.toPx(), + endY = size.height + ), + topLeft = Offset(0f, size.height - 4.dp.toPx()), + size = Size(size.width, 4.dp.toPx()), + blendMode = BlendMode.DstOut + ) + } + } + } +} + +// ═══════════════════════════════════════════════════════════ +// 💬 MESSAGE CONTEXT MENU (Telegram-style long press menu) +// ═══════════════════════════════════════════════════════════ + +/** + * Telegram-style context menu — появляется при long press на сообщении. + * Содержит: Reply, Copy, Forward, Pin/Unpin, Delete. + */ +@Composable +fun MessageContextMenu( + expanded: Boolean, + onDismiss: () -> Unit, + isDarkTheme: Boolean, + isPinned: Boolean, + isOutgoing: Boolean, + hasText: Boolean = true, + isSystemAccount: Boolean = false, + onReply: () -> Unit, + onCopy: () -> Unit, + onForward: () -> Unit, + onPin: () -> Unit, + onDelete: () -> Unit +) { + val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White + val textColor = if (isDarkTheme) Color.White else Color(0xFF222222) + val iconColor = if (isDarkTheme) Color.White.copy(alpha = 0.47f) else Color(0xFF676B70) + + MaterialTheme( + colorScheme = MaterialTheme.colorScheme.copy( + surface = menuBgColor, + onSurface = textColor + ) + ) { + DropdownMenu( + expanded = expanded, + onDismissRequest = onDismiss, + modifier = Modifier + .defaultMinSize(minWidth = 196.dp) + .background(menuBgColor), + properties = PopupProperties( + focusable = false, + dismissOnBackPress = true, + dismissOnClickOutside = true + ) + ) { + // Reply + if (!isSystemAccount) { + ContextMenuItem( + icon = TelegramIcons.Reply, + text = "Reply", + onClick = { + onDismiss() + onReply() + }, + tintColor = iconColor, + textColor = textColor + ) + } + + // Copy (только если есть текст) + if (hasText) { + ContextMenuItemWithVector( + icon = Icons.Default.ContentCopy, + text = "Copy", + onClick = { + onDismiss() + onCopy() + }, + tintColor = iconColor, + textColor = textColor + ) + } + + // Forward + if (!isSystemAccount) { + ContextMenuItem( + icon = TelegramIcons.Reply, + text = "Forward", + onClick = { + onDismiss() + onForward() + }, + tintColor = iconColor, + textColor = textColor, + mirrorIcon = true + ) + } + + // Pin / Unpin + ContextMenuItem( + icon = if (isPinned) TelegramIcons.Unpin else TelegramIcons.Pin, + text = if (isPinned) "Unpin" else "Pin", + onClick = { + onDismiss() + onPin() + }, + tintColor = iconColor, + textColor = textColor + ) + + // Delete + ContextMenuItem( + icon = TelegramIcons.Delete, + text = "Delete", + onClick = { + onDismiss() + onDelete() + }, + tintColor = Color(0xFFFF3B30), + textColor = Color(0xFFFF3B30) + ) + } + } +} + +@Composable +private fun ContextMenuItem( + icon: Painter, + text: String, + onClick: () -> Unit, + tintColor: Color, + textColor: Color, + mirrorIcon: Boolean = false +) { + Box( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minWidth = 196.dp, minHeight = 48.dp) + .clickable(onClick = onClick) + .padding(horizontal = 18.dp), + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + painter = icon, + contentDescription = null, + tint = tintColor, + modifier = Modifier + .size(24.dp) + .then( + if (mirrorIcon) Modifier.graphicsLayer { scaleX = -1f } + else Modifier + ) + ) + Spacer(modifier = Modifier.width(19.dp)) + Text( + text = text, + color = textColor, + fontSize = 16.sp + ) + } + } +} + +@Composable +private fun ContextMenuItemWithVector( + icon: ImageVector, + text: String, + onClick: () -> Unit, + tintColor: Color, + textColor: Color +) { + Box( + modifier = Modifier + .fillMaxWidth() + .defaultMinSize(minWidth = 196.dp, minHeight = 48.dp) + .clickable(onClick = onClick) + .padding(horizontal = 18.dp), + contentAlignment = Alignment.CenterStart + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + imageVector = icon, + contentDescription = null, + tint = tintColor, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(19.dp)) + Text( + text = text, + color = textColor, + fontSize = 16.sp + ) + } + } +} + /** Telegram-style kebab menu */ @Composable fun KebabMenu( diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt index de93bb1..13b8054 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/BlurredAvatarBackground.kt @@ -1,7 +1,12 @@ package com.rosetta.messenger.ui.components +import android.content.Context import android.graphics.Bitmap import android.os.Build +import android.renderscript.Allocation +import android.renderscript.Element +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -14,6 +19,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.utils.AvatarFileManager import kotlinx.coroutines.Dispatchers @@ -42,6 +48,8 @@ fun BoxScope.BlurredAvatarBackground( overlayColors: List? = null, isDarkTheme: Boolean = true ) { + val context = LocalContext.current + // Загрузка и blur аватарки — нужна ВСЕГДА (и для overlay-цветов, и для обычного режима) val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() ?: remember { mutableStateOf(emptyList()) } @@ -64,17 +72,7 @@ fun BoxScope.BlurredAvatarBackground( if (newOriginal != null) { originalBitmap = newOriginal blurredBitmap = withContext(Dispatchers.Default) { - val scaledBitmap = Bitmap.createScaledBitmap( - newOriginal, - newOriginal.width / 4, - newOriginal.height / 4, - true - ) - var result = scaledBitmap - repeat(2) { - result = fastBlur(result, (blurRadius / 4).toInt().coerceAtLeast(1)) - } - result + gaussianBlur(context, newOriginal, radius = 25f, passes = 3) } } } else { @@ -149,110 +147,33 @@ fun BoxScope.BlurredAvatarBackground( } /** - * Быстрое размытие по Гауссу (Box Blur - упрощенная версия) - * Основано на Stack Blur Algorithm от Mario Klingemann + * Proper Gaussian blur via RenderScript — smooth, non-pixelated. + * Scales down to 1/4 for performance, applies ScriptIntrinsicBlur (radius 25 max), + * then repeats for heavier blur. Result stays at 1/4 scale (enough for backgrounds). */ -private fun fastBlur(source: Bitmap, radius: Int): Bitmap { - if (radius < 1) return source - - val w = source.width - val h = source.height - - val bitmap = source.copy(source.config, true) - - val pixels = IntArray(w * h) - bitmap.getPixels(pixels, 0, w, 0, 0, w, h) - - // Применяем горизонтальное размытие - for (y in 0 until h) { - blurRow(pixels, y, w, h, radius) - } - - // Применяем вертикальное размытие - for (x in 0 until w) { - blurColumn(pixels, x, w, h, radius) - } - - bitmap.setPixels(pixels, 0, w, 0, 0, w, h) - return bitmap -} +@Suppress("deprecation") +private fun gaussianBlur(context: Context, source: Bitmap, radius: Float = 25f, passes: Int = 3): Bitmap { + // Scale down for performance (1/4 res is plenty for a background) + val w = (source.width / 4).coerceAtLeast(8) + val h = (source.height / 4).coerceAtLeast(8) + var current = Bitmap.createScaledBitmap(source, w, h, true) + .copy(Bitmap.Config.ARGB_8888, true) -private fun blurRow(pixels: IntArray, y: Int, w: Int, h: Int, radius: Int) { - var sumR = 0 - var sumG = 0 - var sumB = 0 - var sumA = 0 - - val dv = radius * 2 + 1 - val offset = y * w - - // Инициализация суммы - for (i in -radius..radius) { - val x = i.coerceIn(0, w - 1) - val pixel = pixels[offset + x] - sumA += (pixel shr 24) and 0xff - sumR += (pixel shr 16) and 0xff - sumG += (pixel shr 8) and 0xff - sumB += pixel and 0xff - } - - // Применяем blur - for (x in 0 until w) { - pixels[offset + x] = ((sumA / dv) shl 24) or - ((sumR / dv) shl 16) or - ((sumG / dv) shl 8) or - (sumB / dv) - - // Обновляем сумму для следующего пикселя - val xLeft = (x - radius).coerceIn(0, w - 1) - val xRight = (x + radius + 1).coerceIn(0, w - 1) - - val leftPixel = pixels[offset + xLeft] - val rightPixel = pixels[offset + xRight] - - sumA += ((rightPixel shr 24) and 0xff) - ((leftPixel shr 24) and 0xff) - sumR += ((rightPixel shr 16) and 0xff) - ((leftPixel shr 16) and 0xff) - sumG += ((rightPixel shr 8) and 0xff) - ((leftPixel shr 8) and 0xff) - sumB += (rightPixel and 0xff) - (leftPixel and 0xff) - } -} + val rs = RenderScript.create(context) + val blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)) + blur.setRadius(radius.coerceIn(1f, 25f)) -private fun blurColumn(pixels: IntArray, x: Int, w: Int, h: Int, radius: Int) { - var sumR = 0 - var sumG = 0 - var sumB = 0 - var sumA = 0 - - val dv = radius * 2 + 1 - - // Инициализация суммы - for (i in -radius..radius) { - val y = i.coerceIn(0, h - 1) - val pixel = pixels[y * w + x] - sumA += (pixel shr 24) and 0xff - sumR += (pixel shr 16) and 0xff - sumG += (pixel shr 8) and 0xff - sumB += pixel and 0xff - } - - // Применяем blur - for (y in 0 until h) { - val offset = y * w + x - pixels[offset] = ((sumA / dv) shl 24) or - ((sumR / dv) shl 16) or - ((sumG / dv) shl 8) or - (sumB / dv) - - // Обновляем сумму для следующего пикселя - val yTop = (y - radius).coerceIn(0, h - 1) - val yBottom = (y + radius + 1).coerceIn(0, h - 1) - - val topPixel = pixels[yTop * w + x] - val bottomPixel = pixels[yBottom * w + x] - - sumA += ((bottomPixel shr 24) and 0xff) - ((topPixel shr 24) and 0xff) - sumR += ((bottomPixel shr 16) and 0xff) - ((topPixel shr 16) and 0xff) - sumG += ((bottomPixel shr 8) and 0xff) - ((topPixel shr 8) and 0xff) - sumB += (bottomPixel and 0xff) - (topPixel and 0xff) + repeat(passes) { + val input = Allocation.createFromBitmap(rs, current) + val output = Allocation.createFromBitmap(rs, current) + blur.setInput(input) + blur.forEach(output) + output.copyTo(current) + input.destroy() + output.destroy() } + + blur.destroy() + rs.destroy() + return current } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt index c03a1ab..eef498a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppearanceScreen.kt @@ -1,9 +1,14 @@ package com.rosetta.messenger.ui.settings +import android.content.Context import android.graphics.Bitmap import android.os.Build import android.os.Handler import android.os.Looper +import android.renderscript.Allocation +import android.renderscript.Element +import android.renderscript.RenderScript +import android.renderscript.ScriptIntrinsicBlur import android.view.PixelCopy import android.view.View import androidx.activity.compose.BackHandler @@ -43,6 +48,7 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign @@ -336,6 +342,7 @@ private fun ProfileBlurPreview( val avatarKey = remember(avatars) { avatars.firstOrNull()?.timestamp ?: 0L } var avatarBitmap by remember { mutableStateOf(null) } var blurredBitmap by remember { mutableStateOf(null) } + val blurContext = LocalContext.current LaunchedEffect(avatarKey) { val current = avatars @@ -345,19 +352,8 @@ private fun ProfileBlurPreview( } if (decoded != null) { avatarBitmap = decoded - // Blur для фонового изображения blurredBitmap = withContext(Dispatchers.Default) { - val scaled = Bitmap.createScaledBitmap( - decoded, - decoded.width / 4, - decoded.height / 4, - true - ) - var result = scaled - repeat(3) { - result = fastBlur(result, 6) - } - result + appearanceGaussianBlur(blurContext, decoded, radius = 25f, passes = 3) } } } else { @@ -734,56 +730,31 @@ private fun ColorCircleItem( } /** - * Быстрый box blur (для preview, идентично BlurredAvatarBackground) + * Proper Gaussian blur via RenderScript — smooth, non-pixelated. */ -private fun fastBlur(source: Bitmap, radius: Int): Bitmap { - if (radius < 1) return source - val w = source.width - val h = source.height - val bitmap = source.copy(source.config, true) - val pixels = IntArray(w * h) - bitmap.getPixels(pixels, 0, w, 0, 0, w, h) - for (y in 0 until h) blurRow(pixels, y, w, radius) - for (x in 0 until w) blurColumn(pixels, x, w, h, radius) - bitmap.setPixels(pixels, 0, w, 0, 0, w, h) - return bitmap +@Suppress("deprecation") +private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Float = 25f, passes: Int = 3): Bitmap { + val w = (source.width / 4).coerceAtLeast(8) + val h = (source.height / 4).coerceAtLeast(8) + var current = Bitmap.createScaledBitmap(source, w, h, true) + .copy(Bitmap.Config.ARGB_8888, true) + + val rs = RenderScript.create(context) + val blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs)) + blur.setRadius(radius.coerceIn(1f, 25f)) + + repeat(passes) { + val input = Allocation.createFromBitmap(rs, current) + val output = Allocation.createFromBitmap(rs, current) + blur.setInput(input) + blur.forEach(output) + output.copyTo(current) + input.destroy() + output.destroy() + } + + blur.destroy() + rs.destroy() + return current } -private fun blurRow(pixels: IntArray, y: Int, w: Int, radius: Int) { - var sR = 0; var sG = 0; var sB = 0; var sA = 0 - val dv = radius * 2 + 1; val off = y * w - for (i in -radius..radius) { - val x = i.coerceIn(0, w - 1); val p = pixels[off + x] - sA += (p shr 24) and 0xff; sR += (p shr 16) and 0xff - sG += (p shr 8) and 0xff; sB += p and 0xff - } - for (x in 0 until w) { - pixels[off + x] = ((sA / dv) shl 24) or ((sR / dv) shl 16) or ((sG / dv) shl 8) or (sB / dv) - val xL = (x - radius).coerceIn(0, w - 1); val xR = (x + radius + 1).coerceIn(0, w - 1) - val lp = pixels[off + xL]; val rp = pixels[off + xR] - sA += ((rp shr 24) and 0xff) - ((lp shr 24) and 0xff) - sR += ((rp shr 16) and 0xff) - ((lp shr 16) and 0xff) - sG += ((rp shr 8) and 0xff) - ((lp shr 8) and 0xff) - sB += (rp and 0xff) - (lp and 0xff) - } -} - -private fun blurColumn(pixels: IntArray, x: Int, w: Int, h: Int, radius: Int) { - var sR = 0; var sG = 0; var sB = 0; var sA = 0 - val dv = radius * 2 + 1 - for (i in -radius..radius) { - val y = i.coerceIn(0, h - 1); val p = pixels[y * w + x] - sA += (p shr 24) and 0xff; sR += (p shr 16) and 0xff - sG += (p shr 8) and 0xff; sB += p and 0xff - } - for (y in 0 until h) { - val off = y * w + x - pixels[off] = ((sA / dv) shl 24) or ((sR / dv) shl 16) or ((sG / dv) shl 8) or (sB / dv) - val yT = (y - radius).coerceIn(0, h - 1); val yB = (y + radius + 1).coerceIn(0, h - 1) - val tp = pixels[yT * w + x]; val bp = pixels[yB * w + x] - sA += ((bp shr 24) and 0xff) - ((tp shr 24) and 0xff) - sR += ((bp shr 16) and 0xff) - ((tp shr 16) and 0xff) - sG += ((bp shr 8) and 0xff) - ((tp shr 8) and 0xff) - sB += (bp and 0xff) - (tp and 0xff) - } -} diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index c0d6c0b..dc22d3d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -11,6 +11,7 @@ import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.core.content.FileProvider import androidx.core.view.WindowCompat +import androidx.compose.animation.Crossfade import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.Spring @@ -24,11 +25,8 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed -import androidx.compose.foundation.pager.HorizontalPager -import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -101,6 +99,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.json.JSONArray import java.io.File @@ -114,7 +114,7 @@ private val EXPANDED_HEADER_HEIGHT_OTHER = 280.dp private val COLLAPSED_HEADER_HEIGHT_OTHER = 64.dp private val AVATAR_SIZE_EXPANDED_OTHER = 120.dp private val AVATAR_SIZE_COLLAPSED_OTHER = 36.dp -private const val MEDIA_THUMB_EDGE_PX = 480 +private const val MEDIA_THUMB_EDGE_PX = 240 private object SharedMediaBitmapCache { private val maxMemoryKb = (Runtime.getRuntime().maxMemory() / 1024).toInt() @@ -182,18 +182,10 @@ fun OtherProfileScreen( var showAvatarMenu by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) } var imageViewerInitialIndex by remember { mutableIntStateOf(0) } - val tabs = remember { OtherProfileTab.entries } - val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabs.size }) - val selectedTab = - tabs.getOrElse(pagerState.currentPage.coerceIn(0, tabs.lastIndex)) { - OtherProfileTab.MEDIA - } - val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp - val sharedPagerMinHeight = (screenHeightDp * 0.45f).coerceAtLeast(240.dp) - val isPagerSwiping = pagerState.isScrollInProgress - val isOnFirstPage = pagerState.currentPage == 0 && pagerState.currentPageOffsetFraction == 0f - LaunchedEffect(showImageViewer, isPagerSwiping, isOnFirstPage) { - onSwipeBackEnabledChanged(!showImageViewer && !isPagerSwiping && isOnFirstPage) + var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) } + + LaunchedEffect(showImageViewer) { + onSwipeBackEnabledChanged(!showImageViewer) } val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) @@ -533,6 +525,21 @@ fun OtherProfileScreen( // Handle back gesture BackHandler { onBack() } + // ══════════════════════════════════════════════════════ + // PRE-COMPUTE media grid state (must be in Composable context) + // ══════════════════════════════════════════════════════ + val mediaColumns = 3 + val mediaSpacing = 1.dp + val mediaScreenWidth = LocalConfiguration.current.screenWidthDp.dp + val mediaCellSize = (mediaScreenWidth - mediaSpacing * (mediaColumns - 1)) / mediaColumns + val mediaDecodeSemaphore = remember { Semaphore(4) } + // Use stable key for bitmap cache - don't recreate on size change + val mediaBitmapStates = remember { mutableStateMapOf() } + // Pre-compute indexed rows to avoid O(n) indexOf calls + val mediaIndexedRows = remember(sharedContent.mediaPhotos) { + sharedContent.mediaPhotos.chunked(mediaColumns).mapIndexed { idx, row -> idx to row } + } + Box( modifier = Modifier.fillMaxSize() @@ -698,80 +705,231 @@ fun OtherProfileScreen( // ═══════════════════════════════════════════════════════════ OtherProfileSharedTabs( selectedTab = selectedTab, - onTabSelected = { tab -> - val targetPage = tab.ordinal - if (pagerState.currentPage != targetPage) { - coroutineScope.launch { - pagerState.animateScrollToPage(targetPage) - } - } - }, + onTabSelected = { tab -> selectedTab = tab }, isDarkTheme = isDarkTheme ) + } - Spacer(modifier = Modifier.height(10.dp)) + // ══════════════════════════════════════════════════════ + // TAB CONTENT — inlined directly into LazyColumn items + // for true virtualization (only visible items compose) + // ══════════════════════════════════════════════════════ + when (selectedTab) { + OtherProfileTab.MEDIA -> { + if (sharedContent.mediaPhotos.isEmpty()) { + item(key = "media_empty") { + OtherProfileEmptyState( + animationAssetPath = "lottie/saved.json", + title = "No shared media yet", + subtitle = "Photos from your chat will appear here.", + isDarkTheme = isDarkTheme + ) + } + } else { + items( + items = mediaIndexedRows, + key = { (idx, _) -> "media_row_$idx" } + ) { (rowIdx, rowPhotos) -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(mediaSpacing) + ) { + rowPhotos.forEachIndexed { colIdx, media -> + val globalIndex = rowIdx * mediaColumns + colIdx - HorizontalPager( - state = pagerState, - modifier = Modifier.fillMaxWidth().heightIn(min = sharedPagerMinHeight), - beyondBoundsPageCount = 0, - verticalAlignment = Alignment.Top, - userScrollEnabled = true - ) { page -> - Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopStart) { - OtherProfileSharedTabContent( - selectedTab = tabs[page], - sharedContent = sharedContent, - isDarkTheme = isDarkTheme, - accountPublicKey = activeAccountPublicKey, - accountPrivateKey = activeAccountPrivateKey, - onMediaClick = { index -> - imageViewerInitialIndex = index - showImageViewer = true - }, - onFileClick = { file -> - val opened = openSharedFile(context, file) - if (!opened) { - Toast.makeText( - context, - "File is not available on this device", - Toast.LENGTH_SHORT - ) - .show() - } - }, - onLinkClick = { link -> - val normalizedLink = - if (link.startsWith("http://", ignoreCase = true) || - link.startsWith("https://", ignoreCase = true) - ) { - link - } else { - "https://$link" + // Check cache first + val cachedBitmap = mediaBitmapStates[media.key] + ?: SharedMediaBitmapCache.get(media.key) + + // Only launch decode for items not yet cached + if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) { + LaunchedEffect(media.key) { + mediaDecodeSemaphore.withPermit { + val bitmap = withContext(Dispatchers.IO) { + resolveSharedPhotoBitmap( + context = context, + media = media, + accountPublicKey = activeAccountPublicKey, + accountPrivateKey = activeAccountPrivateKey + ) + } + mediaBitmapStates[media.key] = bitmap } - val opened = + } + } + + val resolvedBitmap = cachedBitmap ?: mediaBitmapStates[media.key] + val model = remember(media.localUri, media.blob) { + resolveSharedMediaModel(media.localUri, media.blob) + } + // Decode blurred preview from base64 (small image ~4-16px) + val previewBitmap = remember(media.preview) { + if (media.preview.isNotBlank()) { runCatching { - context.startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse(normalizedLink) - ) - ) - } - .isSuccess - if (!opened) { - Toast.makeText( - context, - "Unable to open this link", - Toast.LENGTH_SHORT - ) - .show() + val bytes = Base64.decode(media.preview, Base64.DEFAULT) + BitmapFactory.decodeByteArray(bytes, 0, bytes.size) + }.getOrNull() + } else null + } + val isLoaded = resolvedBitmap != null || model != null + // Animate alpha for smooth fade-in + val imageAlpha by animateFloatAsState( + targetValue = if (isLoaded) 1f else 0f, + animationSpec = tween(300), + label = "media_fade" + ) + + Box( + modifier = Modifier + .size(mediaCellSize) + .clip(RoundedCornerShape(0.dp)) + .clickable( + enabled = model != null || resolvedBitmap != null || media.attachmentId.isNotBlank() + ) { + imageViewerInitialIndex = globalIndex + showImageViewer = true + }, + contentAlignment = Alignment.Center + ) { + // Blurred preview placeholder (always shown initially) + if (previewBitmap != null) { + Image( + bitmap = previewBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + // Fallback shimmer if no preview + Box( + modifier = Modifier + .fillMaxSize() + .background( + if (isDarkTheme) Color(0xFF1E1E1E) + else Color(0xFFECECEC) + ) + ) + } + // Full quality image fades in on top + if (resolvedBitmap != null) { + Image( + bitmap = resolvedBitmap.asImageBitmap(), + contentDescription = "Shared media", + modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha }, + contentScale = ContentScale.Crop + ) + } else if (model != null) { + coil.compose.AsyncImage( + model = model, + contentDescription = "Shared media", + modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha }, + contentScale = ContentScale.Crop + ) + } } } - ) + // Fill remaining cells in last incomplete row + repeat(mediaColumns - rowPhotos.size) { + Spacer(modifier = Modifier.size(mediaCellSize)) + } + } + } } } + OtherProfileTab.FILES -> { + if (sharedContent.files.isEmpty()) { + item(key = "files_empty") { + OtherProfileEmptyState( + animationAssetPath = "lottie/folder.json", + title = "No shared files", + subtitle = "Documents from this chat will appear here.", + isDarkTheme = isDarkTheme + ) + } + } else { + val fileTextColor = if (isDarkTheme) Color.White else Color.Black + val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) + itemsIndexed(sharedContent.files, key = { _, f -> f.key }) { index, file -> + Column { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + val opened = openSharedFile(context, file) + if (!opened) { + Toast.makeText(context, "File is not available on this device", Toast.LENGTH_SHORT).show() + } + } + .padding(horizontal = 20.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier.size(36.dp).clip(CircleShape) + .background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)), + contentAlignment = Alignment.Center + ) { + Icon(painter = TelegramIcons.File, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(18.dp)) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(text = file.fileName, color = fileTextColor, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis) + Spacer(modifier = Modifier.height(2.dp)) + Text(text = "${formatFileSize(file.sizeBytes)} • ${formatTimestamp(file.timestamp)}", color = fileSecondary, fontSize = 12.sp) + } + Spacer(modifier = Modifier.width(8.dp)) + Icon(imageVector = TablerIcons.ChevronRight, contentDescription = null, tint = fileSecondary.copy(alpha = 0.6f), modifier = Modifier.size(16.dp)) + } + if (index != sharedContent.files.lastIndex) { + Divider(color = fileDivider, thickness = 0.5.dp) + } + } + } + } + } + OtherProfileTab.LINKS -> { + if (sharedContent.links.isEmpty()) { + item(key = "links_empty") { + OtherProfileEmptyState( + animationAssetPath = "lottie/earth.json", + title = "No shared links", + subtitle = "Links from your messages will appear here.", + isDarkTheme = isDarkTheme + ) + } + } else { + val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) + val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) + + itemsIndexed(sharedContent.links, key = { _, l -> l.key }) { index, link -> + Column { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + val normalizedLink = if (link.url.startsWith("http://", ignoreCase = true) || link.url.startsWith("https://", ignoreCase = true)) link.url else "https://${link.url}" + val opened = runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(normalizedLink))) }.isSuccess + if (!opened) { + Toast.makeText(context, "Unable to open this link", Toast.LENGTH_SHORT).show() + } + } + .padding(horizontal = 20.dp, vertical = 12.dp) + ) { + Text(text = link.url, color = PrimaryBlue, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = TextDecoration.Underline) + Spacer(modifier = Modifier.height(3.dp)) + Text(text = formatTimestamp(link.timestamp), color = linkSecondary, fontSize = 12.sp) + } + if (index != sharedContent.links.lastIndex) { + Divider(color = linkDivider, thickness = 0.5.dp) + } + } + } + } + } + } + + item(key = "bottom_spacer") { Spacer(modifier = Modifier.height(32.dp)) } } @@ -1426,261 +1584,6 @@ private fun OtherProfileSharedTabs( } } -@Composable -private fun OtherProfileSharedTabContent( - selectedTab: OtherProfileTab, - sharedContent: OtherProfileSharedContent, - isDarkTheme: Boolean, - accountPublicKey: String, - accountPrivateKey: String, - onMediaClick: (Int) -> Unit, - onFileClick: (SharedFileItem) -> Unit, - onLinkClick: (String) -> Unit -) { - when (selectedTab) { - OtherProfileTab.MEDIA -> { - if (sharedContent.mediaPhotos.isEmpty()) { - OtherProfileEmptyState( - animationAssetPath = "lottie/saved.json", - title = "No shared media yet", - subtitle = "Photos from your chat will appear here.", - isDarkTheme = isDarkTheme - ) - } else { - OtherProfileMediaGrid( - photos = sharedContent.mediaPhotos, - isDarkTheme = isDarkTheme, - accountPublicKey = accountPublicKey, - accountPrivateKey = accountPrivateKey, - onMediaClick = onMediaClick - ) - } - } - OtherProfileTab.FILES -> { - if (sharedContent.files.isEmpty()) { - OtherProfileEmptyState( - animationAssetPath = "lottie/folder.json", - title = "No shared files", - subtitle = "Documents from this chat will appear here.", - isDarkTheme = isDarkTheme - ) - } else { - OtherProfileFileList(sharedContent.files, isDarkTheme, onFileClick) - } - } - OtherProfileTab.LINKS -> { - if (sharedContent.links.isEmpty()) { - OtherProfileEmptyState( - animationAssetPath = "lottie/earth.json", - title = "No shared links", - subtitle = "Links from your messages will appear here.", - isDarkTheme = isDarkTheme - ) - } else { - OtherProfileLinksList(sharedContent.links, isDarkTheme, onLinkClick) - } - } - } -} - -@Composable -private fun OtherProfileMediaGrid( - photos: List, - isDarkTheme: Boolean, - accountPublicKey: String, - accountPrivateKey: String, - onMediaClick: (Int) -> Unit -) { - val context = LocalContext.current - val columns = 3 - val spacing = 1.dp - val screenWidth = LocalConfiguration.current.screenWidthDp.dp - val cellSize = (screenWidth - spacing * (columns - 1)) / columns - val rowCount = ceil(photos.size / columns.toFloat()).toInt().coerceAtLeast(1) - val gridHeight = cellSize * rowCount + spacing * (rowCount - 1) - - LazyVerticalGrid( - columns = GridCells.Fixed(columns), - modifier = Modifier.fillMaxWidth().height(gridHeight), - userScrollEnabled = false, - horizontalArrangement = Arrangement.spacedBy(spacing), - verticalArrangement = Arrangement.spacedBy(spacing) - ) { - itemsIndexed(photos, key = { _, item -> item.key }) { index, media -> - val resolvedBitmap by - produceState( - initialValue = null, - media.key, - media.localUri, - media.blob.length, - media.preview, - media.chachaKey, - accountPublicKey, - accountPrivateKey - ) { - value = - withContext(Dispatchers.IO) { - resolveSharedPhotoBitmap( - context = context, - media = media, - accountPublicKey = accountPublicKey, - accountPrivateKey = accountPrivateKey - ) - } - } - val model = - remember(media.localUri, media.blob) { - resolveSharedMediaModel(media.localUri, media.blob) - } - Box( - modifier = - Modifier.fillMaxWidth() - .aspectRatio(1f) - .clickable( - enabled = - model != null || - resolvedBitmap != null || - media.attachmentId.isNotBlank() - ) { onMediaClick(index) }, - contentAlignment = Alignment.Center - ) { - if (resolvedBitmap != null) { - Image( - bitmap = resolvedBitmap!!.asImageBitmap(), - contentDescription = "Shared media", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else if (model != null) { - coil.compose.AsyncImage( - model = model, - contentDescription = "Shared media", - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - Box( - modifier = - Modifier.fillMaxSize() - .background( - if (isDarkTheme) Color(0xFF151515) - else Color(0xFFE9ECF2) - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = TablerIcons.PhotoOff, - contentDescription = null, - tint = if (isDarkTheme) Color(0xFF7D7D7D) else Color(0xFF8F8F8F) - ) - } - } - } - } - } -} - -@Composable -private fun OtherProfileFileList( - items: List, - isDarkTheme: Boolean, - onFileClick: (SharedFileItem) -> Unit -) { - val textColor = if (isDarkTheme) Color.White else Color.Black - val secondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val divider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) - - Column(modifier = Modifier.fillMaxWidth()) { - items.forEachIndexed { index, file -> - Row( - modifier = - Modifier.fillMaxWidth() - .clickable { onFileClick(file) } - .padding(horizontal = 20.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = - Modifier.size(36.dp) - .clip(CircleShape) - .background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)), - contentAlignment = Alignment.Center - ) { - Icon( - painter = TelegramIcons.File, - contentDescription = null, - tint = PrimaryBlue, - modifier = Modifier.size(18.dp) - ) - } - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = file.fileName, - color = textColor, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = "${formatFileSize(file.sizeBytes)} • ${formatTimestamp(file.timestamp)}", - color = secondary, - fontSize = 12.sp - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Icon( - imageVector = TablerIcons.ChevronRight, - contentDescription = null, - tint = secondary.copy(alpha = 0.6f), - modifier = Modifier.size(16.dp) - ) - } - if (index != items.lastIndex) { - Divider(color = divider, thickness = 0.5.dp) - } - } - } -} - -@Composable -private fun OtherProfileLinksList( - links: List, - isDarkTheme: Boolean, - onLinkClick: (String) -> Unit -) { - val secondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666) - val divider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5) - - Column(modifier = Modifier.fillMaxWidth()) { - links.forEachIndexed { index, link -> - Column( - modifier = - Modifier.fillMaxWidth() - .clickable { onLinkClick(link.url) } - .padding(horizontal = 20.dp, vertical = 12.dp) - ) { - Text( - text = link.url, - color = PrimaryBlue, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textDecoration = TextDecoration.Underline - ) - Spacer(modifier = Modifier.height(3.dp)) - Text(text = formatTimestamp(link.timestamp), color = secondary, fontSize = 12.sp) - } - if (index != links.lastIndex) { - Divider(color = divider, thickness = 0.5.dp) - } - } - } -} - @Composable private fun OtherProfileEmptyState( animationAssetPath: String,