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.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 }
}
)
)
}
}
)
}

View File

@@ -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()
}
}

View File

@@ -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)
}

View File

@@ -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<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 LastMessageStatus(
@ColumnInfo(name = "from_me") val fromMe: Int,

View File

@@ -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() // Для разработки - только
// если миграция не

View File

@@ -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<Set<String>>(emptySet()) }
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
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)
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
)
// <20> 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() }
)
}
}
}

View File

@@ -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<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 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
}
}
// <20> 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)
}
}

View File

@@ -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)
)
}
}
}
}

View File

@@ -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) {

View File

@@ -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(

View File

@@ -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<Color>? = 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
}

View File

@@ -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<Bitmap?>(null) }
var blurredBitmap by remember { mutableStateOf<Bitmap?>(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)
}
}

View File

@@ -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<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(
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<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
private fun OtherProfileEmptyState(
animationAssetPath: String,