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:
@@ -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 }
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() // Для разработки - только
|
||||
// если миграция не
|
||||
|
||||
@@ -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() }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@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)
|
||||
|
||||
val w = source.width
|
||||
val h = source.height
|
||||
val rs = RenderScript.create(context)
|
||||
val blur = ScriptIntrinsicBlur.create(rs, Element.U8_4(rs))
|
||||
blur.setRadius(radius.coerceIn(1f, 25f))
|
||||
|
||||
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)
|
||||
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()
|
||||
}
|
||||
|
||||
// Применяем вертикальное размытие
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
blur.destroy()
|
||||
rs.destroy()
|
||||
return current
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user