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.
This commit is contained in:
2026-02-22 12:32:19 +05:00
parent 5b9b3f83f7
commit ba7182abe6
13 changed files with 1378 additions and 697 deletions

View File

@@ -28,6 +28,7 @@ import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
@@ -1097,6 +1098,7 @@ fun MainScreen(
val biometricPrefs = remember { val biometricPrefs = remember {
com.rosetta.messenger.biometric.BiometricPreferences(context) com.rosetta.messenger.biometric.BiometricPreferences(context)
} }
val biometricAccountManager = remember { AccountManager(context) }
val activity = context as? FragmentActivity val activity = context as? FragmentActivity
BiometricEnableScreen( BiometricEnableScreen(
@@ -1108,24 +1110,40 @@ fun MainScreen(
return@BiometricEnableScreen return@BiometricEnableScreen
} }
biometricManager.encryptPassword( // Verify password against the real account before saving
activity = activity, mainScreenScope.launch {
password = password, val account = biometricAccountManager.getAccount(accountPublicKey)
onSuccess = { encryptedPassword -> if (account == null) {
mainScreenScope.launch { onError("Account not found")
biometricPrefs.saveEncryptedPassword( return@launch
accountPublicKey, }
encryptedPassword val decryptedKey = try {
) CryptoManager.decryptWithPassword(account.encryptedPrivateKey, password)
biometricPrefs.enableBiometric() } catch (_: Exception) { null }
onSuccess() 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 }
}
)
} }
) )
} }

View File

@@ -230,16 +230,16 @@ class MessageRepository private constructor(private val context: Context) {
_newMessageEvents.tryEmit(dialogKey) _newMessageEvents.tryEmit(dialogKey)
} }
/** Send a system message from "Rosetta Updates" account */ /** Send a system message from "Rosetta Updates" account. Returns messageId or null. */
suspend fun addUpdateSystemMessage(messageText: String) { suspend fun addUpdateSystemMessage(messageText: String): String? {
val account = currentAccount ?: return val account = currentAccount ?: return null
val privateKey = currentPrivateKey ?: return val privateKey = currentPrivateKey ?: return null
val encryptedPlainMessage = val encryptedPlainMessage =
try { try {
CryptoManager.encryptWithPassword(messageText, privateKey) CryptoManager.encryptWithPassword(messageText, privateKey)
} catch (_: Exception) { } catch (_: Exception) {
return return null
} }
val messageId = UUID.randomUUID().toString().replace("-", "").take(32) 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) val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY)
dialogDao.insertDialog( dialogDao.insertDialog(
@@ -286,6 +286,7 @@ class MessageRepository private constructor(private val context: Context) {
dialogDao.updateDialogFromMessages(account, SYSTEM_UPDATES_PUBLIC_KEY) dialogDao.updateDialogFromMessages(account, SYSTEM_UPDATES_PUBLIC_KEY)
_newMessageEvents.tryEmit(dialogKey) _newMessageEvents.tryEmit(dialogKey)
return messageId
} }
/** /**
@@ -295,12 +296,23 @@ class MessageRepository private constructor(private val context: Context) {
suspend fun checkAndSendVersionUpdateMessage() { suspend fun checkAndSendVersionUpdateMessage() {
val account = currentAccount ?: return val account = currentAccount ?: return
val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE) 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 currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}"
if (lastNoticeVersion != currentVersion) { if (lastNoticeKey != currentKey) {
addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion)) // Delete the previous message for this version (if any)
prefs.edit().putString("lastNoticeVersion", currentVersion).apply() 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()
} }
} }

View File

@@ -28,4 +28,8 @@ object ReleaseNotes {
fun getNotice(version: String): String = fun getNotice(version: String): String =
RELEASE_NOTICE.replace(VERSION_PLACEHOLDER, version) 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)
} }

View File

@@ -3,6 +3,70 @@ package com.rosetta.messenger.database
import androidx.room.* import androidx.room.*
import kotlinx.coroutines.flow.Flow 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<List<PinnedMessageEntity>>
/** Проверить, закреплено ли сообщение */
@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 для статуса последнего сообщения */
data class LastMessageStatus( data class LastMessageStatus(
@ColumnInfo(name = "from_me") val fromMe: Int, @ColumnInfo(name = "from_me") val fromMe: Int,

View File

@@ -15,8 +15,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
DialogEntity::class, DialogEntity::class,
BlacklistEntity::class, BlacklistEntity::class,
AvatarCacheEntity::class, AvatarCacheEntity::class,
AccountSyncTimeEntity::class], AccountSyncTimeEntity::class,
version = 12, PinnedMessageEntity::class],
version = 13,
exportSchema = false exportSchema = false
) )
abstract class RosettaDatabase : RoomDatabase() { abstract class RosettaDatabase : RoomDatabase() {
@@ -26,6 +27,7 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun blacklistDao(): BlacklistDao abstract fun blacklistDao(): BlacklistDao
abstract fun avatarDao(): AvatarDao abstract fun avatarDao(): AvatarDao
abstract fun syncTimeDao(): SyncTimeDao abstract fun syncTimeDao(): SyncTimeDao
abstract fun pinnedMessageDao(): PinnedMessageDao
companion object { companion object {
@Volatile private var INSTANCE: RosettaDatabase? = null @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 { fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE return INSTANCE
?: synchronized(this) { ?: synchronized(this) {
@@ -168,7 +196,8 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_8_9, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_9_10,
MIGRATION_10_11, MIGRATION_10_11,
MIGRATION_11_12 MIGRATION_11_12,
MIGRATION_12_13
) )
.fallbackToDestructiveMigration() // Для разработки - только .fallbackToDestructiveMigration() // Для разработки - только
// если миграция не // если миграция не

View File

@@ -15,6 +15,9 @@ import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut 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.animation.togetherWith
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
@@ -169,6 +172,16 @@ fun ChatDetailScreen(
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) } var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty() val isSelectionMode = selectedMessages.isNotEmpty()
// 💬 MESSAGE CONTEXT MENU STATE
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(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 // Логирование изменений selection mode
LaunchedEffect(isSelectionMode, selectedMessages.size) {} LaunchedEffect(isSelectionMode, selectedMessages.size) {}
@@ -463,7 +476,24 @@ fun ChatDetailScreen(
} }
} }
// 🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default // <EFBFBD> Текст текущего 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
}
}
// <20>🔥 messagesWithDates — pre-computed in ViewModel on Dispatchers.Default
// (dedup + sort + date headers off the main thread) // (dedup + sort + date headers off the main thread)
val messagesWithDates by viewModel.messagesWithDates.collectAsState() val messagesWithDates by viewModel.messagesWithDates.collectAsState()
@@ -603,6 +633,7 @@ fun ChatDetailScreen(
Scaffold( Scaffold(
contentWindowInsets = WindowInsets(0.dp), contentWindowInsets = WindowInsets(0.dp),
topBar = { topBar = {
Column {
// 🔥 UNIFIED HEADER - один контейнер, контент меняется внутри // 🔥 UNIFIED HEADER - один контейнер, контент меняется внутри
Box( Box(
modifier = modifier =
@@ -1237,6 +1268,42 @@ fun ChatDetailScreen(
) )
) )
} // Закрытие Box unified header } // Закрытие 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, // Фон всего чата containerColor = backgroundColor, // Фон всего чата
// 🔥 Bottom bar - инпут с умным padding // 🔥 Bottom bar - инпут с умным padding
@@ -2008,26 +2075,24 @@ fun ChatDetailScreen(
.LongPress .LongPress
) )
if (!isSelectionMode // <20> Long press = selection mode
) { val imm =
val imm = context.getSystemService(
context.getSystemService( Context.INPUT_METHOD_SERVICE
Context.INPUT_METHOD_SERVICE ) as
) as InputMethodManager
InputMethodManager imm.hideSoftInputFromWindow(
imm.hideSoftInputFromWindow( view.windowToken,
view.windowToken, 0
0 )
) focusManager
focusManager .clearFocus()
.clearFocus() showEmojiPicker =
showEmojiPicker = false
false toggleMessageSelection(
} selectionKey,
toggleMessageSelection( true
selectionKey, )
true
)
}, },
onClick = { onClick = {
val hasAvatar = val hasAvatar =
@@ -2037,11 +2102,24 @@ fun ChatDetailScreen(
AttachmentType AttachmentType
.AVATAR .AVATAR
} }
val isPhotoOnly =
message.attachments.isNotEmpty() &&
message.text.isEmpty() &&
message.attachments.all {
it.type == AttachmentType.IMAGE
}
if (isSelectionMode) { if (isSelectionMode) {
toggleMessageSelection( toggleMessageSelection(
selectionKey, selectionKey,
!hasAvatar !hasAvatar
) )
} else if (!hasAvatar && !isPhotoOnly) {
// 💬 Tap = context menu
contextMenuMessage = message
showContextMenu = true
scope.launch {
contextMenuIsPinned = viewModel.isMessagePinned(message.id)
}
} }
}, },
onSwipeToReply = { onSwipeToReply = {
@@ -2122,9 +2200,91 @@ fun ChatDetailScreen(
onUserProfileClick(resolvedUser) 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() } onClearLogs = { ProtocolManager.clearLogs() }
) )
} }
} }
}

View File

@@ -96,6 +96,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val database = RosettaDatabase.getDatabase(application) private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao() private val dialogDao = database.dialogDao()
private val messageDao = database.messageDao() private val messageDao = database.messageDao()
private val pinnedMessageDao = database.pinnedMessageDao()
// MessageRepository для подписки на события новых сообщений // MessageRepository для подписки на события новых сообщений
private val messageRepository = private val messageRepository =
@@ -190,6 +191,15 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val _isForwardMode = MutableStateFlow(false) private val _isForwardMode = MutableStateFlow(false)
val isForwardMode: StateFlow<Boolean> = _isForwardMode.asStateFlow() val isForwardMode: StateFlow<Boolean> = _isForwardMode.asStateFlow()
// 📌 Pinned messages state
private val _pinnedMessages = MutableStateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>>(emptyList())
val pinnedMessages: StateFlow<List<com.rosetta.messenger.database.PinnedMessageEntity>> = _pinnedMessages.asStateFlow()
private val _currentPinnedIndex = MutableStateFlow(0)
val currentPinnedIndex: StateFlow<Int> = _currentPinnedIndex.asStateFlow()
private var pinnedCollectionJob: Job? = null
// Пагинация // Пагинация
private var currentOffset = 0 private var currentOffset = 0
private var hasMoreMessages = true private var hasMoreMessages = true
@@ -603,6 +613,18 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Подписываемся на онлайн статус // Подписываемся на онлайн статус
subscribeToOnlineStatus() 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
}
}
// <20> P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer // <20> P1.2: Загружаем сообщения СРАЗУ — параллельно с анимацией SwipeBackContainer
loadMessagesFromDatabase(delayMs = 0L) loadMessagesFromDatabase(delayMs = 0L)
} }
@@ -1660,14 +1682,74 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
_isForwardMode.value = false _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) { fun deleteMessage(messageId: String) {
// Удаляем из UI сразу на main // Удаляем из UI сразу на main
_messages.value = _messages.value.filter { it.id != messageId } _messages.value = _messages.value.filter { it.id != messageId }
// Удаляем из БД в IO // Удаляем из БД в IO + удаляем pin если был
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val account = myPublicKey ?: return@launch val account = myPublicKey ?: return@launch
val dialogKey = opponentKey ?: return@launch
pinnedMessageDao.removePin(account, dialogKey, messageId)
messageDao.deleteMessage(account, messageId) messageDao.deleteMessage(account, messageId)
} }
} }

View File

@@ -42,6 +42,7 @@ import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalContext 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -3645,67 +3646,72 @@ fun DialogItemContent(
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 🔥 Показываем typing индикатор или последнее сообщение // Stable weighted box prevents layout jitter on typing transition
if (isTyping) { Box(
TypingIndicatorSmall() modifier = Modifier.weight(1f).heightIn(min = 20.dp),
} else if (!dialog.draftText.isNullOrEmpty()) { contentAlignment = Alignment.CenterStart
// 📝 Показываем черновик (как в Telegram) ) {
Row(modifier = Modifier.weight(1f)) { Crossfade(
Text( targetState = isTyping,
text = "Draft: ", animationSpec = tween(150),
fontSize = 14.sp, label = "chatSubtitle"
color = Color(0xFFFF3B30), // Красный как в Telegram ) { showTyping ->
fontWeight = FontWeight.Normal, if (showTyping) {
maxLines = 1 TypingIndicatorSmall()
) } else if (!dialog.draftText.isNullOrEmpty()) {
AppleEmojiText( Row {
text = dialog.draftText, Text(
modifier = Modifier.weight(1f), text = "Draft: ",
fontSize = 14.sp, fontSize = 14.sp,
color = secondaryTextColor, color = Color(0xFFFF3B30),
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
maxLines = 1, maxLines = 1
overflow = android.text.TextUtils.TruncateAt.END, )
enableLinks = false AppleEmojiText(
) text = dialog.draftText,
} modifier = Modifier.weight(1f),
} else { fontSize = 14.sp,
// 📎 Определяем что показывать - attachment или текст color = secondaryTextColor,
val displayText = fontWeight = FontWeight.Normal,
when { maxLines = 1,
dialog.lastMessageAttachmentType == overflow = android.text.TextUtils.TruncateAt.END,
"Photo" -> "Photo" enableLinks = false
dialog.lastMessageAttachmentType == )
"File" -> "File" }
dialog.lastMessageAttachmentType == } else {
"Avatar" -> "Avatar" val displayText =
dialog.lastMessageAttachmentType == when {
"Forwarded" -> "Forwarded message" dialog.lastMessageAttachmentType ==
dialog.lastMessage.isEmpty() -> "Photo" -> "Photo"
"No messages" dialog.lastMessageAttachmentType ==
else -> dialog.lastMessage "File" -> "File"
} dialog.lastMessageAttachmentType ==
"Avatar" -> "Avatar"
dialog.lastMessageAttachmentType ==
"Forwarded" -> "Forwarded message"
dialog.lastMessage.isEmpty() ->
"No messages"
else -> dialog.lastMessage
}
// 🔥 Используем AppleEmojiText для отображения эмодзи AppleEmojiText(
// Если есть непрочитанные - текст темнее text = displayText,
AppleEmojiText( fontSize = 14.sp,
text = displayText, color =
fontSize = 14.sp, if (dialog.unreadCount > 0)
color = textColor.copy(alpha = 0.85f)
if (dialog.unreadCount > 0) else secondaryTextColor,
textColor.copy(alpha = 0.85f) fontWeight =
else secondaryTextColor, if (dialog.unreadCount > 0)
fontWeight = FontWeight.Medium
if (dialog.unreadCount > 0) else FontWeight.Normal,
FontWeight.Medium maxLines = 1,
else FontWeight.Normal, overflow = android.text.TextUtils.TruncateAt.END,
maxLines = 1, modifier = Modifier.fillMaxWidth(),
overflow = android.text.TextUtils.TruncateAt.END, enableLinks = false
modifier = Modifier.weight(1f), )
enableLinks = }
false // 🔗 Ссылки не кликабельны в списке }
// чатов
)
} }
// Unread badge // 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 @Composable
fun TypingIndicatorSmall() { fun TypingIndicatorSmall() {
val infiniteTransition = rememberInfiniteTransition(label = "typing")
val typingColor = PrimaryBlue val typingColor = PrimaryBlue
val infiniteTransition = rememberInfiniteTransition(label = "typing")
Row( // Each dot animates 0→1→0 in a 1200 ms cycle, staggered by 150 ms
verticalAlignment = Alignment.CenterVertically, val dotProgresses = List(3) { index ->
horizontalArrangement = Arrangement.spacedBy(1.dp) 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(
text = "typing", text = "typing",
fontSize = 14.sp, fontSize = 14.sp,
color = typingColor, color = typingColor,
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
Spacer(modifier = Modifier.width(2.dp))
// 3 анимированные точки // Fixed-size canvas — big enough for bounce, never changes layout
repeat(3) { index -> Canvas(modifier = Modifier.size(width = 18.dp, height = 14.dp)) {
val offsetY by val dotRadius = 1.5.dp.toPx()
infiniteTransition.animateFloat( val dotSpacing = 2.5.dp.toPx()
initialValue = 0f, val maxBounce = 2.dp.toPx()
targetValue = -3f, val centerY = size.height / 2f + 1.dp.toPx()
animationSpec = for (i in 0..2) {
infiniteRepeatable( val p = dotProgresses[i].value
animation = val bounce = kotlin.math.sin(p * Math.PI).toFloat()
tween( val cx = dotRadius + i * (dotRadius * 2 + dotSpacing)
durationMillis = 500, val cy = centerY - bounce * maxBounce
delayMillis = index * 120, val alpha = 0.4f + bounce * 0.6f
easing = FastOutSlowInEasing drawCircle(
), color = typingColor.copy(alpha = alpha),
repeatMode = RepeatMode.Reverse radius = dotRadius,
), center = Offset(cx, cy)
label = "dot$index"
) )
}
Text(
text = ".",
fontSize = 14.sp,
color = typingColor,
fontWeight = FontWeight.Medium,
modifier = Modifier.offset(y = offsetY.dp)
)
} }
} }
} }

View File

@@ -62,6 +62,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalConfiguration
import androidx.core.content.FileProvider 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 * 📐 Telegram Bubble Specification
* Все константы взяты из ChatMessageCell.java и Theme.java * Все константы взяты из ChatMessageCell.java и Theme.java
@@ -662,15 +671,30 @@ fun ImageCollage(
} }
// Остальные по 3 в ряд // Остальные по 3 в ряд
val remaining = attachments.drop(2) val remaining = attachments.drop(2)
remaining.chunked(3).forEachIndexed { rowIndex, rowItems -> val rows = remaining.chunked(3)
val isLastRow = rowIndex == remaining.chunked(3).size - 1 val totalRows = rows.size
rows.forEachIndexed { rowIndex, rowItems ->
val isLastRow = rowIndex == totalRows - 1
val isIncompleteRow = rowItems.size < 3
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(spacing) horizontalArrangement = if (isIncompleteRow)
Arrangement.Start
else
Arrangement.spacedBy(spacing)
) { ) {
rowItems.forEachIndexed { index, attachment -> rowItems.forEachIndexed { index, attachment ->
val isLastItem = isLastRow && index == rowItems.size - 1 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( ImageAttachment(
attachment = attachment, attachment = attachment,
chachaKey = chachaKey, 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 // Загружаем изображение если статус DOWNLOADED
if (downloadStatus == DownloadStatus.DOWNLOADED) { if (downloadStatus == DownloadStatus.DOWNLOADED) {
withContext(Dispatchers.IO) { // 🔒 Ограничиваем параллельные загрузки через семафор
// 🚀 0. Если есть localUri - загружаем напрямую из URI (optimistic UI) ImageLoadSemaphore.semaphore.withPermit {
if (imageBitmap == null && attachment.localUri.isNotEmpty()) { withContext(Dispatchers.IO) {
// 🚀 0. Если есть localUri - загружаем напрямую из URI (optimistic UI)
if (imageBitmap == null && attachment.localUri.isNotEmpty()) {
try { try {
val uri = android.net.Uri.parse(attachment.localUri) val uri = android.net.Uri.parse(attachment.localUri)
@@ -917,6 +941,7 @@ fun ImageAttachment(
downloadStatus = DownloadStatus.NOT_DOWNLOADED downloadStatus = DownloadStatus.NOT_DOWNLOADED
} }
} }
}
} }
if (imageBitmap == null && downloadStatus == DownloadStatus.DOWNLOADED) { if (imageBitmap == null && downloadStatus == DownloadStatus.DOWNLOADED) {

View File

@@ -1,9 +1,12 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.graphics.Bitmap import android.graphics.Bitmap
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background 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.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector 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.input.pointer.pointerInput
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.Layout 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 @Composable
fun TypingIndicator(isDarkTheme: Boolean) { fun TypingIndicator(isDarkTheme: Boolean) {
val infiniteTransition = rememberInfiniteTransition(label = "typing")
val typingColor = Color(0xFF54A9EB) val typingColor = Color(0xFF54A9EB)
val infiniteTransition = rememberInfiniteTransition(label = "typing")
Row( // Each dot animates through a 0→1→0 cycle, staggered by 150 ms
verticalAlignment = Alignment.CenterVertically, val dotProgresses = List(3) { index ->
horizontalArrangement = Arrangement.spacedBy(2.dp) 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) Text(text = "typing", fontSize = 13.sp, color = typingColor)
Spacer(modifier = Modifier.width(2.dp))
repeat(3) { index -> // Fixed-size canvas — big enough for bounce, never changes layout
val offsetY by Canvas(modifier = Modifier.size(width = 18.dp, height = 14.dp)) {
infiniteTransition.animateFloat( val dotRadius = 1.5.dp.toPx()
initialValue = 0f, val dotSpacing = 2.5.dp.toPx()
targetValue = -4f, val maxBounce = 2.dp.toPx()
animationSpec = val centerY = size.height / 2f + 1.dp.toPx()
infiniteRepeatable( for (i in 0..2) {
animation = val p = dotProgresses[i].value
tween( val bounce = kotlin.math.sin(p * Math.PI).toFloat()
durationMillis = 600, val cx = dotRadius + i * (dotRadius * 2 + dotSpacing)
delayMillis = index * 100, val cy = centerY - bounce * maxBounce
easing = FastOutSlowInEasing val alpha = 0.4f + bounce * 0.6f
), drawCircle(
repeatMode = RepeatMode.Reverse color = typingColor.copy(alpha = alpha),
), radius = dotRadius,
label = "dot$index" 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 = {}, onRetry: () -> Unit = {},
onDelete: () -> Unit = {}, onDelete: () -> Unit = {},
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {} onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
contextMenuContent: @Composable () -> Unit = {}
) { ) {
// Swipe-to-reply state // Swipe-to-reply state
var swipeOffset by remember { mutableStateOf(0f) } var swipeOffset by remember { mutableStateOf(0f) }
@@ -317,7 +342,7 @@ fun MessageBubble(
else Color(0xFF2196F3) // Стандартный Material Blue для входящих else Color(0xFF2196F3) // Стандартный Material Blue для входящих
} }
val linksEnabled = !isSelectionMode val linksEnabled = !isSelectionMode
val textClickHandler: (() -> Unit)? = if (isSelectionMode) onClick else null val textClickHandler: (() -> Unit)? = onClick
val timeColor = val timeColor =
remember(message.isOutgoing, isDarkTheme) { 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 */ /** Telegram-style kebab menu */
@Composable @Composable
fun KebabMenu( fun KebabMenu(

View File

@@ -1,7 +1,12 @@
package com.rosetta.messenger.ui.components package com.rosetta.messenger.ui.components
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build 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.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box 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.asImageBitmap
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -42,6 +48,8 @@ fun BoxScope.BlurredAvatarBackground(
overlayColors: List<Color>? = null, overlayColors: List<Color>? = null,
isDarkTheme: Boolean = true isDarkTheme: Boolean = true
) { ) {
val context = LocalContext.current
// Загрузка и blur аватарки — нужна ВСЕГДА (и для overlay-цветов, и для обычного режима) // Загрузка и blur аватарки — нужна ВСЕГДА (и для overlay-цветов, и для обычного режима)
val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState() val avatars by avatarRepository?.getAvatars(publicKey, allDecode = false)?.collectAsState()
?: remember { mutableStateOf(emptyList()) } ?: remember { mutableStateOf(emptyList()) }
@@ -64,17 +72,7 @@ fun BoxScope.BlurredAvatarBackground(
if (newOriginal != null) { if (newOriginal != null) {
originalBitmap = newOriginal originalBitmap = newOriginal
blurredBitmap = withContext(Dispatchers.Default) { blurredBitmap = withContext(Dispatchers.Default) {
val scaledBitmap = Bitmap.createScaledBitmap( gaussianBlur(context, newOriginal, radius = 25f, passes = 3)
newOriginal,
newOriginal.width / 4,
newOriginal.height / 4,
true
)
var result = scaledBitmap
repeat(2) {
result = fastBlur(result, (blurRadius / 4).toInt().coerceAtLeast(1))
}
result
} }
} }
} else { } else {
@@ -149,110 +147,33 @@ fun BoxScope.BlurredAvatarBackground(
} }
/** /**
* Быстрое размытие по Гауссу (Box Blur - упрощенная версия) * Proper Gaussian blur via RenderScript — smooth, non-pixelated.
* Основано на Stack Blur Algorithm от Mario Klingemann * 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 { @Suppress("deprecation")
if (radius < 1) return source 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 val w = (source.width / 4).coerceAtLeast(8)
val h = source.height val h = (source.height / 4).coerceAtLeast(8)
var current = Bitmap.createScaledBitmap(source, w, h, true)
val bitmap = source.copy(source.config, true) .copy(Bitmap.Config.ARGB_8888, 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
}
private fun blurRow(pixels: IntArray, y: Int, w: Int, h: Int, radius: Int) { val rs = RenderScript.create(context)
var sumR = 0 val blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
var sumG = 0 blur.setRadius(radius.coerceIn(1f, 25f))
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)
}
}
private fun blurColumn(pixels: IntArray, x: Int, w: Int, h: Int, radius: Int) { repeat(passes) {
var sumR = 0 val input = Allocation.createFromBitmap(rs, current)
var sumG = 0 val output = Allocation.createFromBitmap(rs, current)
var sumB = 0 blur.setInput(input)
var sumA = 0 blur.forEach(output)
output.copyTo(current)
val dv = radius * 2 + 1 input.destroy()
output.destroy()
// Инициализация суммы
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)
} }
blur.destroy()
rs.destroy()
return current
} }

View File

@@ -1,9 +1,14 @@
package com.rosetta.messenger.ui.settings package com.rosetta.messenger.ui.settings
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper 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.PixelCopy
import android.view.View import android.view.View
import androidx.activity.compose.BackHandler 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.ContentScale
import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.boundsInRoot
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -336,6 +342,7 @@ private fun ProfileBlurPreview(
val avatarKey = remember(avatars) { avatars.firstOrNull()?.timestamp ?: 0L } val avatarKey = remember(avatars) { avatars.firstOrNull()?.timestamp ?: 0L }
var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) } var avatarBitmap by remember { mutableStateOf<Bitmap?>(null) }
var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) } var blurredBitmap by remember { mutableStateOf<Bitmap?>(null) }
val blurContext = LocalContext.current
LaunchedEffect(avatarKey) { LaunchedEffect(avatarKey) {
val current = avatars val current = avatars
@@ -345,19 +352,8 @@ private fun ProfileBlurPreview(
} }
if (decoded != null) { if (decoded != null) {
avatarBitmap = decoded avatarBitmap = decoded
// Blur для фонового изображения
blurredBitmap = withContext(Dispatchers.Default) { blurredBitmap = withContext(Dispatchers.Default) {
val scaled = Bitmap.createScaledBitmap( appearanceGaussianBlur(blurContext, decoded, radius = 25f, passes = 3)
decoded,
decoded.width / 4,
decoded.height / 4,
true
)
var result = scaled
repeat(3) {
result = fastBlur(result, 6)
}
result
} }
} }
} else { } 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 { @Suppress("deprecation")
if (radius < 1) return source private fun appearanceGaussianBlur(context: Context, source: Bitmap, radius: Float = 25f, passes: Int = 3): Bitmap {
val w = source.width val w = (source.width / 4).coerceAtLeast(8)
val h = source.height val h = (source.height / 4).coerceAtLeast(8)
val bitmap = source.copy(source.config, true) var current = Bitmap.createScaledBitmap(source, w, h, true)
val pixels = IntArray(w * h) .copy(Bitmap.Config.ARGB_8888, true)
bitmap.getPixels(pixels, 0, w, 0, 0, w, h)
for (y in 0 until h) blurRow(pixels, y, w, radius) val rs = RenderScript.create(context)
for (x in 0 until w) blurColumn(pixels, x, w, h, radius) val blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
bitmap.setPixels(pixels, 0, w, 0, 0, w, h) blur.setRadius(radius.coerceIn(1f, 25f))
return bitmap
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)
}
}

View File

@@ -11,6 +11,7 @@ import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateColorAsState import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.LinearOutSlowInEasing import androidx.compose.animation.core.LinearOutSlowInEasing
import androidx.compose.animation.core.Spring import androidx.compose.animation.core.Spring
@@ -24,11 +25,8 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -101,6 +99,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import java.io.File 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 COLLAPSED_HEADER_HEIGHT_OTHER = 64.dp
private val AVATAR_SIZE_EXPANDED_OTHER = 120.dp private val AVATAR_SIZE_EXPANDED_OTHER = 120.dp
private val AVATAR_SIZE_COLLAPSED_OTHER = 36.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 object SharedMediaBitmapCache {
private val maxMemoryKb = (Runtime.getRuntime().maxMemory() / 1024).toInt() private val maxMemoryKb = (Runtime.getRuntime().maxMemory() / 1024).toInt()
@@ -182,18 +182,10 @@ fun OtherProfileScreen(
var showAvatarMenu by remember { mutableStateOf(false) } var showAvatarMenu by remember { mutableStateOf(false) }
var showImageViewer by remember { mutableStateOf(false) } var showImageViewer by remember { mutableStateOf(false) }
var imageViewerInitialIndex by remember { mutableIntStateOf(0) } var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
val tabs = remember { OtherProfileTab.entries } var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
val pagerState = rememberPagerState(initialPage = 0, pageCount = { tabs.size })
val selectedTab = LaunchedEffect(showImageViewer) {
tabs.getOrElse(pagerState.currentPage.coerceIn(0, tabs.lastIndex)) { onSwipeBackEnabledChanged(!showImageViewer)
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)
} }
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF) val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
@@ -533,6 +525,21 @@ fun OtherProfileScreen(
// Handle back gesture // Handle back gesture
BackHandler { onBack() } 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<String, android.graphics.Bitmap?>() }
// 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( Box(
modifier = modifier =
Modifier.fillMaxSize() Modifier.fillMaxSize()
@@ -698,80 +705,231 @@ fun OtherProfileScreen(
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
OtherProfileSharedTabs( OtherProfileSharedTabs(
selectedTab = selectedTab, selectedTab = selectedTab,
onTabSelected = { tab -> onTabSelected = { tab -> selectedTab = tab },
val targetPage = tab.ordinal
if (pagerState.currentPage != targetPage) {
coroutineScope.launch {
pagerState.animateScrollToPage(targetPage)
}
}
},
isDarkTheme = isDarkTheme 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( // Check cache first
state = pagerState, val cachedBitmap = mediaBitmapStates[media.key]
modifier = Modifier.fillMaxWidth().heightIn(min = sharedPagerMinHeight), ?: SharedMediaBitmapCache.get(media.key)
beyondBoundsPageCount = 0,
verticalAlignment = Alignment.Top, // Only launch decode for items not yet cached
userScrollEnabled = true if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) {
) { page -> LaunchedEffect(media.key) {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopStart) { mediaDecodeSemaphore.withPermit {
OtherProfileSharedTabContent( val bitmap = withContext(Dispatchers.IO) {
selectedTab = tabs[page], resolveSharedPhotoBitmap(
sharedContent = sharedContent, context = context,
isDarkTheme = isDarkTheme, media = media,
accountPublicKey = activeAccountPublicKey, accountPublicKey = activeAccountPublicKey,
accountPrivateKey = activeAccountPrivateKey, accountPrivateKey = activeAccountPrivateKey
onMediaClick = { index -> )
imageViewerInitialIndex = index }
showImageViewer = true mediaBitmapStates[media.key] = bitmap
},
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"
} }
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 { runCatching {
context.startActivity( val bytes = Base64.decode(media.preview, Base64.DEFAULT)
Intent( BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
Intent.ACTION_VIEW, }.getOrNull()
Uri.parse(normalizedLink) } else null
) }
) val isLoaded = resolvedBitmap != null || model != null
} // Animate alpha for smooth fade-in
.isSuccess val imageAlpha by animateFloatAsState(
if (!opened) { targetValue = if (isLoaded) 1f else 0f,
Toast.makeText( animationSpec = tween(300),
context, label = "media_fade"
"Unable to open this link", )
Toast.LENGTH_SHORT
) Box(
.show() 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)) 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<SharedPhotoItem>,
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<android.graphics.Bitmap?>(
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<SharedFileItem>,
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<SharedLinkItem>,
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 @Composable
private fun OtherProfileEmptyState( private fun OtherProfileEmptyState(
animationAssetPath: String, animationAssetPath: String,