Refactor SwipeBackContainer for improved performance and readability
- Added lazy composition to skip setup until the screen is first opened, reducing allocations. - Cleaned up code formatting for better readability. - Enhanced comments for clarity on functionality. - Streamlined gesture handling logic for swipe detection and animation.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -8,15 +8,15 @@ import androidx.room.migration.Migration
|
||||
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [
|
||||
EncryptedAccountEntity::class,
|
||||
MessageEntity::class,
|
||||
DialogEntity::class,
|
||||
BlacklistEntity::class,
|
||||
AvatarCacheEntity::class
|
||||
],
|
||||
version = 10,
|
||||
exportSchema = false
|
||||
entities =
|
||||
[
|
||||
EncryptedAccountEntity::class,
|
||||
MessageEntity::class,
|
||||
DialogEntity::class,
|
||||
BlacklistEntity::class,
|
||||
AvatarCacheEntity::class],
|
||||
version = 11,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class RosettaDatabase : RoomDatabase() {
|
||||
abstract fun accountDao(): AccountDao
|
||||
@@ -26,93 +26,140 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
abstract fun avatarDao(): AvatarDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var INSTANCE: RosettaDatabase? = null
|
||||
@Volatile private var INSTANCE: RosettaDatabase? = null
|
||||
|
||||
private val MIGRATION_4_5 = object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем новые столбцы для индикаторов прочтения
|
||||
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0")
|
||||
database.execSQL("ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
private val MIGRATION_4_5 =
|
||||
object : Migration(4, 5) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем новые столбцы для индикаторов прочтения
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN last_message_from_me INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN last_message_delivered INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN last_message_read INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_5_6 = object : Migration(5, 6) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем поле username в encrypted_accounts
|
||||
database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT")
|
||||
}
|
||||
}
|
||||
private val MIGRATION_5_6 =
|
||||
object : Migration(5, 6) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем поле username в encrypted_accounts
|
||||
database.execSQL("ALTER TABLE encrypted_accounts ADD COLUMN username TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_6_7 = object : Migration(6, 7) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем таблицу для кэша аватаров
|
||||
database.execSQL("""
|
||||
private val MIGRATION_6_7 =
|
||||
object : Migration(6, 7) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Создаем таблицу для кэша аватаров
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS avatar_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
avatar TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL
|
||||
)
|
||||
""")
|
||||
database.execSQL("CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)")
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS index_avatar_cache_public_key_timestamp ON avatar_cache (public_key, timestamp)"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val MIGRATION_7_8 = object : Migration(7, 8) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Удаляем таблицу avatar_delivery (больше не нужна)
|
||||
database.execSQL("DROP TABLE IF EXISTS avatar_delivery")
|
||||
}
|
||||
}
|
||||
private val MIGRATION_7_8 =
|
||||
object : Migration(7, 8) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Удаляем таблицу avatar_delivery (больше не нужна)
|
||||
database.execSQL("DROP TABLE IF EXISTS avatar_delivery")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop)
|
||||
* Blob слишком большой для SQLite CursorWindow (2MB лимит)
|
||||
* Просто обнуляем attachments - изображения перескачаются с CDN
|
||||
* 🔥 МИГРАЦИЯ 8->9: Очищаем blob из attachments (как в desktop) Blob слишком большой для
|
||||
* SQLite CursorWindow (2MB лимит) Просто обнуляем attachments - изображения перескачаются с
|
||||
* CDN
|
||||
*/
|
||||
private val MIGRATION_8_9 = object : Migration(8, 9) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Очищаем все attachments с большими blob'ами
|
||||
// Они будут перескачаны с CDN при открытии
|
||||
database.execSQL("""
|
||||
private val MIGRATION_8_9 =
|
||||
object : Migration(8, 9) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Очищаем все attachments с большими blob'ами
|
||||
// Они будут перескачаны с CDN при открытии
|
||||
database.execSQL(
|
||||
"""
|
||||
UPDATE messages
|
||||
SET attachments = '[]'
|
||||
WHERE length(attachments) > 10000
|
||||
""")
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments
|
||||
* Для пользователей которые уже были на версии 9
|
||||
* 🔥 МИГРАЦИЯ 9->10: Повторная очистка blob из attachments Для пользователей которые уже
|
||||
* были на версии 9
|
||||
*/
|
||||
private val MIGRATION_9_10 = object : Migration(9, 10) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Очищаем все attachments с большими blob'ами
|
||||
database.execSQL("""
|
||||
private val MIGRATION_9_10 =
|
||||
object : Migration(9, 10) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Очищаем все attachments с большими blob'ами
|
||||
database.execSQL(
|
||||
"""
|
||||
UPDATE messages
|
||||
SET attachments = '[]'
|
||||
WHERE length(attachments) > 10000
|
||||
""")
|
||||
}
|
||||
}
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🚀 МИГРАЦИЯ 10->11: Денормализация — кэш attachments последнего сообщения в dialogs
|
||||
* Устраняет N+1 проблему: ранее для каждого диалога делался отдельный запрос к messages
|
||||
*/
|
||||
private val MIGRATION_10_11 =
|
||||
object : Migration(10, 11) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
// Добавляем столбец для кэша attachments последнего сообщения
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN last_message_attachments TEXT NOT NULL DEFAULT '[]'"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDatabase(context: Context): RosettaDatabase {
|
||||
return INSTANCE ?: synchronized(this) {
|
||||
val instance = Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
RosettaDatabase::class.java,
|
||||
"rosetta_secure.db"
|
||||
)
|
||||
.setJournalMode(JournalMode.WRITE_AHEAD_LOGGING) // WAL mode for performance
|
||||
.addMigrations(MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10)
|
||||
.fallbackToDestructiveMigration() // Для разработки - только если миграция не найдена
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
return INSTANCE
|
||||
?: synchronized(this) {
|
||||
val instance =
|
||||
Room.databaseBuilder(
|
||||
context.applicationContext,
|
||||
RosettaDatabase::class.java,
|
||||
"rosetta_secure.db"
|
||||
)
|
||||
.setJournalMode(
|
||||
JournalMode.WRITE_AHEAD_LOGGING
|
||||
) // WAL mode for performance
|
||||
.addMigrations(
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
MIGRATION_8_9,
|
||||
MIGRATION_9_10,
|
||||
MIGRATION_10_11
|
||||
)
|
||||
.fallbackToDestructiveMigration() // Для разработки - только
|
||||
// если миграция не
|
||||
// найдена
|
||||
.build()
|
||||
INSTANCE = instance
|
||||
instance
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +1,68 @@
|
||||
package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.app.Application
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.database.DialogEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.database.BlacklistEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.PacketOnlineSubscribe
|
||||
import com.rosetta.messenger.network.PacketSearch
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import androidx.compose.runtime.Immutable
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* UI модель диалога с расшифрованным lastMessage
|
||||
*/
|
||||
/** UI модель диалога с расшифрованным lastMessage */
|
||||
@Immutable
|
||||
data class DialogUiModel(
|
||||
val id: Long,
|
||||
val account: String,
|
||||
val opponentKey: String,
|
||||
val opponentTitle: String,
|
||||
val opponentUsername: String,
|
||||
val lastMessage: String, // 🔓 Расшифрованный текст
|
||||
val lastMessageTimestamp: Long,
|
||||
val unreadCount: Int,
|
||||
val isOnline: Int,
|
||||
val lastSeen: Long,
|
||||
val verified: Int,
|
||||
val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey)
|
||||
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
|
||||
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
|
||||
val lastMessageRead: Int = 0, // Прочитано (0/1)
|
||||
val lastMessageAttachmentType: String? = null // 📎 Тип attachment: "Photo", "File", или null
|
||||
val id: Long,
|
||||
val account: String,
|
||||
val opponentKey: String,
|
||||
val opponentTitle: String,
|
||||
val opponentUsername: String,
|
||||
val lastMessage: String, // 🔓 Расшифрованный текст
|
||||
val lastMessageTimestamp: Long,
|
||||
val unreadCount: Int,
|
||||
val isOnline: Int,
|
||||
val lastSeen: Long,
|
||||
val verified: Int,
|
||||
val isSavedMessages: Boolean = false, // 📁 Флаг для Saved Messages (account == opponentKey)
|
||||
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
|
||||
val lastMessageDelivered: Int = 0, // Статус доставки (0=WAITING, 1=DELIVERED, 2=ERROR)
|
||||
val lastMessageRead: Int = 0, // Прочитано (0/1)
|
||||
val lastMessageAttachmentType: String? =
|
||||
null // 📎 Тип attachment: "Photo", "File", или null
|
||||
)
|
||||
|
||||
/**
|
||||
* 🔥 Комбинированное состояние чатов для атомарного обновления UI
|
||||
* Это предотвращает "дергание" когда dialogs и requests обновляются независимо
|
||||
* 🔥 Комбинированное состояние чатов для атомарного обновления UI Это предотвращает "дергание"
|
||||
* когда dialogs и requests обновляются независимо
|
||||
*/
|
||||
@Immutable
|
||||
data class ChatsUiState(
|
||||
val dialogs: List<DialogUiModel> = emptyList(),
|
||||
val requests: List<DialogUiModel> = emptyList(),
|
||||
val requestsCount: Int = 0
|
||||
val dialogs: List<DialogUiModel> = emptyList(),
|
||||
val requests: List<DialogUiModel> = emptyList(),
|
||||
val requestsCount: Int = 0
|
||||
) {
|
||||
val isEmpty: Boolean get() = dialogs.isEmpty() && requestsCount == 0
|
||||
val hasContent: Boolean get() = dialogs.isNotEmpty() || requestsCount > 0
|
||||
val isEmpty: Boolean
|
||||
get() = dialogs.isEmpty() && requestsCount == 0
|
||||
val hasContent: Boolean
|
||||
get() = dialogs.isNotEmpty() || requestsCount > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* ViewModel для списка чатов
|
||||
* Загружает диалоги из базы данных и расшифровывает lastMessage
|
||||
*/
|
||||
/** ViewModel для списка чатов Загружает диалоги из базы данных и расшифровывает lastMessage */
|
||||
class ChatsListViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|
||||
private val database = RosettaDatabase.getDatabase(application)
|
||||
private val dialogDao = database.dialogDao()
|
||||
private val messageDao = database.messageDao() // 🔥 Добавляем для получения статуса последнего сообщения
|
||||
|
||||
private var currentAccount: String = ""
|
||||
private var currentPrivateKey: String? = null
|
||||
@@ -94,19 +91,18 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
// 🔥 НОВОЕ: Комбинированное состояние - обновляется атомарно!
|
||||
// 🎯 ИСПРАВЛЕНИЕ: debounce предотвращает мерцание при быстрых обновлениях
|
||||
val chatsState: StateFlow<ChatsUiState> = combine(
|
||||
_dialogs,
|
||||
_requests,
|
||||
_requestsCount
|
||||
) { dialogs, requests, count ->
|
||||
ChatsUiState(dialogs, requests, count)
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу начинаем следить
|
||||
ChatsUiState()
|
||||
)
|
||||
val chatsState: StateFlow<ChatsUiState> =
|
||||
combine(_dialogs, _requests, _requestsCount) { dialogs, requests, count ->
|
||||
ChatsUiState(dialogs, requests, count)
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 Игнорируем дублирующиеся состояния
|
||||
.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted
|
||||
.Eagerly, // 🔥 КРИТИЧНО: Eagerly вместо WhileSubscribed - сразу
|
||||
// начинаем следить
|
||||
ChatsUiState()
|
||||
)
|
||||
|
||||
// Загрузка
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
@@ -114,12 +110,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
|
||||
private val TAG = "ChatsListVM"
|
||||
|
||||
/**
|
||||
* Установить текущий аккаунт и загрузить диалоги
|
||||
*/
|
||||
/** Установить текущий аккаунт и загрузить диалоги */
|
||||
fun setAccount(publicKey: String, privateKey: String) {
|
||||
val setAccountStart = System.currentTimeMillis()
|
||||
if (currentAccount == publicKey) {
|
||||
if (currentAccount == publicKey) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -129,222 +123,324 @@ if (currentAccount == publicKey) {
|
||||
currentAccount = publicKey
|
||||
currentPrivateKey = privateKey
|
||||
// Подписываемся на обычные диалоги
|
||||
@OptIn(FlowPreview::class)
|
||||
viewModelScope.launch {
|
||||
dialogDao.getDialogsFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
||||
.map { dialogsList ->
|
||||
val mapStart = System.currentTimeMillis()
|
||||
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
|
||||
withContext(Dispatchers.Default) {
|
||||
dialogsList.map { dialog ->
|
||||
async {
|
||||
// 🔥 Загружаем информацию о пользователе если её нет
|
||||
// 📁 НЕ загружаем для Saved Messages
|
||||
val isSavedMessages = (dialog.account == dialog.opponentKey)
|
||||
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey || dialog.opponentTitle == dialog.opponentKey.take(7))) {
|
||||
loadUserInfoForDialog(dialog.opponentKey)
|
||||
}
|
||||
|
||||
// 🚀 Расшифровка теперь кэшируется в CryptoManager!
|
||||
val decryptedLastMessage = try {
|
||||
if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) {
|
||||
CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey)
|
||||
?: dialog.lastMessage
|
||||
} else {
|
||||
dialog.lastMessage
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
dialog.lastMessage // Fallback на зашифрованный текст
|
||||
}
|
||||
|
||||
// 🔥🔥🔥 НОВЫЙ ПОДХОД: Получаем статус НАПРЯМУЮ из таблицы messages
|
||||
// Это гарантирует синхронизацию с тем что показывается в диалоге
|
||||
val lastMsgStatus = messageDao.getLastMessageStatus(publicKey, dialog.opponentKey)
|
||||
val actualFromMe = lastMsgStatus?.fromMe ?: 0
|
||||
val actualDelivered = if (actualFromMe == 1) (lastMsgStatus?.delivered ?: 0) else 0
|
||||
val actualRead = if (actualFromMe == 1) (lastMsgStatus?.read ?: 0) else 0
|
||||
|
||||
// 📎 Определяем тип attachment последнего сообщения
|
||||
// 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст),
|
||||
// если текст пустой - это Forward (показываем "Forwarded message")
|
||||
val attachmentType = try {
|
||||
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
|
||||
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
|
||||
val attachments = org.json.JSONArray(attachmentsJson)
|
||||
if (attachments.length() > 0) {
|
||||
val firstAttachment = attachments.getJSONObject(0)
|
||||
val type = firstAttachment.optInt("type", -1)
|
||||
when (type) {
|
||||
0 -> "Photo" // AttachmentType.IMAGE = 0
|
||||
1 -> {
|
||||
// AttachmentType.MESSAGES = 1 (Reply или Forward)
|
||||
// Reply: есть текст сообщения -> показываем текст (null)
|
||||
// Forward: текст пустой -> показываем "Forwarded"
|
||||
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
|
||||
}
|
||||
2 -> "File" // AttachmentType.FILE = 2
|
||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||
else -> null
|
||||
dialogDao
|
||||
.getDialogsFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
|
||||
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
|
||||
.map { dialogsList ->
|
||||
val mapStart = System.currentTimeMillis()
|
||||
// <20> ОПТИМИЗАЦИЯ: Параллельная расшифровка всех сообщений
|
||||
withContext(Dispatchers.Default) {
|
||||
dialogsList
|
||||
.map { dialog ->
|
||||
async {
|
||||
// 🔥 Загружаем информацию о пользователе если её нет
|
||||
// 📁 НЕ загружаем для Saved Messages
|
||||
val isSavedMessages =
|
||||
(dialog.account == dialog.opponentKey)
|
||||
if (!isSavedMessages &&
|
||||
(dialog.opponentTitle.isEmpty() ||
|
||||
dialog.opponentTitle ==
|
||||
dialog.opponentKey ||
|
||||
dialog.opponentTitle ==
|
||||
dialog.opponentKey.take(
|
||||
7
|
||||
))
|
||||
) {
|
||||
loadUserInfoForDialog(dialog.opponentKey)
|
||||
}
|
||||
} else null
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
|
||||
// 🚀 Расшифровка теперь кэшируется в CryptoManager!
|
||||
val decryptedLastMessage =
|
||||
try {
|
||||
if (privateKey.isNotEmpty() &&
|
||||
dialog.lastMessage
|
||||
.isNotEmpty()
|
||||
) {
|
||||
CryptoManager.decryptWithPassword(
|
||||
dialog.lastMessage,
|
||||
privateKey
|
||||
)
|
||||
?: dialog.lastMessage
|
||||
} else {
|
||||
dialog.lastMessage
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
dialog.lastMessage // Fallback на
|
||||
// зашифрованный текст
|
||||
}
|
||||
|
||||
// <20> ОПТИМИЗАЦИЯ: Используем денормализованные поля из
|
||||
// DialogEntity
|
||||
// Статус и attachments уже записаны в dialogs через
|
||||
// updateDialogFromMessages()
|
||||
// Это устраняет N+1 проблему (ранее: 2 запроса на
|
||||
// каждый диалог)
|
||||
|
||||
// 📎 Определяем тип attachment из кэшированного поля в
|
||||
// DialogEntity
|
||||
val attachmentType =
|
||||
try {
|
||||
val attachmentsJson =
|
||||
dialog.lastMessageAttachments
|
||||
if (attachmentsJson.isNotEmpty() &&
|
||||
attachmentsJson != "[]"
|
||||
) {
|
||||
val attachments =
|
||||
org.json.JSONArray(
|
||||
attachmentsJson
|
||||
)
|
||||
if (attachments.length() > 0) {
|
||||
val firstAttachment =
|
||||
attachments.getJSONObject(0)
|
||||
val type =
|
||||
firstAttachment.optInt(
|
||||
"type",
|
||||
-1
|
||||
)
|
||||
when (type) {
|
||||
0 ->
|
||||
"Photo" // AttachmentType.IMAGE = 0
|
||||
1 -> {
|
||||
// AttachmentType.MESSAGES =
|
||||
// 1 (Reply или Forward)
|
||||
// Reply: есть текст
|
||||
// сообщения -> показываем
|
||||
// текст (null)
|
||||
// Forward: текст пустой ->
|
||||
// показываем "Forwarded"
|
||||
if (decryptedLastMessage
|
||||
.isNotEmpty()
|
||||
)
|
||||
null
|
||||
else "Forwarded"
|
||||
}
|
||||
2 ->
|
||||
"File" // AttachmentType.FILE = 2
|
||||
3 ->
|
||||
"Avatar" // AttachmentType.AVATAR = 3
|
||||
else -> null
|
||||
}
|
||||
} else null
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
DialogUiModel(
|
||||
id = dialog.id,
|
||||
account = dialog.account,
|
||||
opponentKey = dialog.opponentKey,
|
||||
opponentTitle = dialog.opponentTitle,
|
||||
opponentUsername = dialog.opponentUsername,
|
||||
lastMessage = decryptedLastMessage,
|
||||
lastMessageTimestamp =
|
||||
dialog.lastMessageTimestamp,
|
||||
unreadCount = dialog.unreadCount,
|
||||
isOnline = dialog.isOnline,
|
||||
lastSeen = dialog.lastSeen,
|
||||
verified = dialog.verified,
|
||||
isSavedMessages =
|
||||
isSavedMessages, // 📁 Saved Messages
|
||||
lastMessageFromMe =
|
||||
dialog.lastMessageFromMe, // 🚀 Из
|
||||
// DialogEntity (денормализовано)
|
||||
lastMessageDelivered =
|
||||
dialog.lastMessageDelivered, // 🚀 Из
|
||||
// DialogEntity (денормализовано)
|
||||
lastMessageRead =
|
||||
dialog.lastMessageRead, // 🚀 Из
|
||||
// DialogEntity
|
||||
// (денормализовано)
|
||||
lastMessageAttachmentType =
|
||||
attachmentType // 📎 Тип attachment
|
||||
)
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
.also {
|
||||
val mapTime = System.currentTimeMillis() - mapStart
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||
.collect { decryptedDialogs ->
|
||||
_dialogs.value = decryptedDialogs
|
||||
|
||||
DialogUiModel(
|
||||
id = dialog.id,
|
||||
account = dialog.account,
|
||||
opponentKey = dialog.opponentKey,
|
||||
opponentTitle = dialog.opponentTitle,
|
||||
opponentUsername = dialog.opponentUsername,
|
||||
lastMessage = decryptedLastMessage,
|
||||
lastMessageTimestamp = dialog.lastMessageTimestamp,
|
||||
unreadCount = dialog.unreadCount,
|
||||
isOnline = dialog.isOnline,
|
||||
lastSeen = dialog.lastSeen,
|
||||
verified = dialog.verified,
|
||||
isSavedMessages = isSavedMessages, // 📁 Saved Messages
|
||||
lastMessageFromMe = actualFromMe, // 🔥 Используем актуальные данные из messages
|
||||
lastMessageDelivered = actualDelivered, // 🔥 Используем актуальные данные из messages
|
||||
lastMessageRead = actualRead, // 🔥 Используем актуальные данные из messages
|
||||
lastMessageAttachmentType = attachmentType // 📎 Тип attachment
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
}.also {
|
||||
val mapTime = System.currentTimeMillis() - mapStart
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||
.collect { decryptedDialogs ->
|
||||
_dialogs.value = decryptedDialogs
|
||||
|
||||
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
||||
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный статус
|
||||
val opponentsToSubscribe = decryptedDialogs
|
||||
.filter { !it.isSavedMessages }
|
||||
.map { it.opponentKey }
|
||||
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
|
||||
}
|
||||
// 🟢 Подписываемся на онлайн-статусы всех собеседников
|
||||
// 📁 Исключаем Saved Messages - не нужно подписываться на свой собственный
|
||||
// статус
|
||||
val opponentsToSubscribe =
|
||||
decryptedDialogs.filter { !it.isSavedMessages }.map {
|
||||
it.opponentKey
|
||||
}
|
||||
subscribeToOnlineStatuses(opponentsToSubscribe, privateKey)
|
||||
}
|
||||
}
|
||||
|
||||
// 📬 Подписываемся на requests (запросы от новых пользователей)
|
||||
@OptIn(FlowPreview::class)
|
||||
viewModelScope.launch {
|
||||
dialogDao.getRequestsFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.map { requestsList ->
|
||||
// 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка
|
||||
withContext(Dispatchers.Default) {
|
||||
requestsList.map { dialog ->
|
||||
async {
|
||||
// 🔥 Загружаем информацию о пользователе если её нет
|
||||
// 📁 НЕ загружаем для Saved Messages
|
||||
val isSavedMessages = (dialog.account == dialog.opponentKey)
|
||||
if (!isSavedMessages && (dialog.opponentTitle.isEmpty() || dialog.opponentTitle == dialog.opponentKey)) {
|
||||
loadUserInfoForRequest(dialog.opponentKey)
|
||||
}
|
||||
|
||||
// 🚀 Расшифровка теперь кэшируется в CryptoManager!
|
||||
val decryptedLastMessage = try {
|
||||
if (privateKey.isNotEmpty() && dialog.lastMessage.isNotEmpty()) {
|
||||
CryptoManager.decryptWithPassword(dialog.lastMessage, privateKey)
|
||||
?: dialog.lastMessage
|
||||
} else {
|
||||
dialog.lastMessage
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
dialog.lastMessage
|
||||
}
|
||||
|
||||
// 📎 Определяем тип attachment последнего сообщения
|
||||
// 🔥 Reply vs Forward: если есть текст - это Reply (показываем текст),
|
||||
// если текст пустой - это Forward (показываем "Forwarded message")
|
||||
val attachmentType = try {
|
||||
val attachmentsJson = messageDao.getLastMessageAttachments(publicKey, dialog.opponentKey)
|
||||
if (!attachmentsJson.isNullOrEmpty() && attachmentsJson != "[]") {
|
||||
val attachments = org.json.JSONArray(attachmentsJson)
|
||||
if (attachments.length() > 0) {
|
||||
val firstAttachment = attachments.getJSONObject(0)
|
||||
val type = firstAttachment.optInt("type", -1)
|
||||
when (type) {
|
||||
0 -> "Photo" // AttachmentType.IMAGE = 0
|
||||
1 -> {
|
||||
// AttachmentType.MESSAGES = 1 (Reply или Forward)
|
||||
// Reply: есть текст сообщения -> показываем текст (null)
|
||||
// Forward: текст пустой -> показываем "Forwarded"
|
||||
if (decryptedLastMessage.isNotEmpty()) null else "Forwarded"
|
||||
}
|
||||
2 -> "File" // AttachmentType.FILE = 2
|
||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||
else -> null
|
||||
dialogDao
|
||||
.getRequestsFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.debounce(100) // 🚀 Батчим быстрые обновления
|
||||
.map { requestsList ->
|
||||
// 🚀 ОПТИМИЗАЦИЯ: Параллельная расшифровка
|
||||
withContext(Dispatchers.Default) {
|
||||
requestsList
|
||||
.map { dialog ->
|
||||
async {
|
||||
// 🔥 Загружаем информацию о пользователе если её нет
|
||||
// 📁 НЕ загружаем для Saved Messages
|
||||
val isSavedMessages =
|
||||
(dialog.account == dialog.opponentKey)
|
||||
if (!isSavedMessages &&
|
||||
(dialog.opponentTitle.isEmpty() ||
|
||||
dialog.opponentTitle ==
|
||||
dialog.opponentKey)
|
||||
) {
|
||||
loadUserInfoForRequest(dialog.opponentKey)
|
||||
}
|
||||
} else null
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
DialogUiModel(
|
||||
id = dialog.id,
|
||||
account = dialog.account,
|
||||
opponentKey = dialog.opponentKey,
|
||||
opponentTitle = dialog.opponentTitle, // 🔥 Показываем имя как в обычных чатах
|
||||
opponentUsername = dialog.opponentUsername,
|
||||
lastMessage = decryptedLastMessage,
|
||||
lastMessageTimestamp = dialog.lastMessageTimestamp,
|
||||
unreadCount = dialog.unreadCount,
|
||||
isOnline = dialog.isOnline,
|
||||
lastSeen = dialog.lastSeen,
|
||||
verified = dialog.verified,
|
||||
isSavedMessages = (dialog.account == dialog.opponentKey), // 📁 Saved Messages
|
||||
lastMessageFromMe = dialog.lastMessageFromMe,
|
||||
lastMessageDelivered = dialog.lastMessageDelivered,
|
||||
lastMessageRead = dialog.lastMessageRead,
|
||||
lastMessageAttachmentType = attachmentType // 📎 Тип attachment
|
||||
)
|
||||
}
|
||||
}.awaitAll()
|
||||
// 🚀 Расшифровка теперь кэшируется в CryptoManager!
|
||||
val decryptedLastMessage =
|
||||
try {
|
||||
if (privateKey.isNotEmpty() &&
|
||||
dialog.lastMessage
|
||||
.isNotEmpty()
|
||||
) {
|
||||
CryptoManager.decryptWithPassword(
|
||||
dialog.lastMessage,
|
||||
privateKey
|
||||
)
|
||||
?: dialog.lastMessage
|
||||
} else {
|
||||
dialog.lastMessage
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
dialog.lastMessage
|
||||
}
|
||||
|
||||
// 📎 Определяем тип attachment из кэшированного поля в
|
||||
// DialogEntity
|
||||
val attachmentType =
|
||||
try {
|
||||
val attachmentsJson =
|
||||
dialog.lastMessageAttachments
|
||||
if (attachmentsJson.isNotEmpty() &&
|
||||
attachmentsJson != "[]"
|
||||
) {
|
||||
val attachments =
|
||||
org.json.JSONArray(
|
||||
attachmentsJson
|
||||
)
|
||||
if (attachments.length() > 0) {
|
||||
val firstAttachment =
|
||||
attachments.getJSONObject(0)
|
||||
val type =
|
||||
firstAttachment.optInt(
|
||||
"type",
|
||||
-1
|
||||
)
|
||||
when (type) {
|
||||
0 ->
|
||||
"Photo" // AttachmentType.IMAGE = 0
|
||||
1 -> {
|
||||
// AttachmentType.MESSAGES =
|
||||
// 1 (Reply или Forward)
|
||||
// Reply: есть текст
|
||||
// сообщения -> показываем
|
||||
// текст (null)
|
||||
// Forward: текст пустой ->
|
||||
// показываем "Forwarded"
|
||||
if (decryptedLastMessage
|
||||
.isNotEmpty()
|
||||
)
|
||||
null
|
||||
else "Forwarded"
|
||||
}
|
||||
2 ->
|
||||
"File" // AttachmentType.FILE = 2
|
||||
3 ->
|
||||
"Avatar" // AttachmentType.AVATAR = 3
|
||||
else -> null
|
||||
}
|
||||
} else null
|
||||
} else null
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
DialogUiModel(
|
||||
id = dialog.id,
|
||||
account = dialog.account,
|
||||
opponentKey = dialog.opponentKey,
|
||||
opponentTitle =
|
||||
dialog.opponentTitle, // 🔥 Показываем
|
||||
// имя как в
|
||||
// обычных чатах
|
||||
opponentUsername = dialog.opponentUsername,
|
||||
lastMessage = decryptedLastMessage,
|
||||
lastMessageTimestamp =
|
||||
dialog.lastMessageTimestamp,
|
||||
unreadCount = dialog.unreadCount,
|
||||
isOnline = dialog.isOnline,
|
||||
lastSeen = dialog.lastSeen,
|
||||
verified = dialog.verified,
|
||||
isSavedMessages =
|
||||
(dialog.account ==
|
||||
dialog.opponentKey), // 📁 Saved
|
||||
// Messages
|
||||
lastMessageFromMe = dialog.lastMessageFromMe,
|
||||
lastMessageDelivered =
|
||||
dialog.lastMessageDelivered,
|
||||
lastMessageRead = dialog.lastMessageRead,
|
||||
lastMessageAttachmentType =
|
||||
attachmentType // 📎 Тип attachment
|
||||
)
|
||||
}
|
||||
}
|
||||
.awaitAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||
.collect { decryptedRequests ->
|
||||
_requests.value = decryptedRequests
|
||||
}
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся списки
|
||||
.collect { decryptedRequests -> _requests.value = decryptedRequests }
|
||||
}
|
||||
|
||||
// 📊 Подписываемся на количество requests
|
||||
viewModelScope.launch {
|
||||
dialogDao.getRequestsCountFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
|
||||
.collect { count ->
|
||||
_requestsCount.value = count
|
||||
}
|
||||
dialogDao
|
||||
.getRequestsCountFlow(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
|
||||
.collect { count -> _requestsCount.value = count }
|
||||
}
|
||||
|
||||
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при blockUser()/unblockUser()
|
||||
// 🔥 Подписываемся на блоклист — Room автоматически переэмитит при
|
||||
// blockUser()/unblockUser()
|
||||
viewModelScope.launch {
|
||||
database.blacklistDao().getBlockedUsers(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.map { entities -> entities.map { it.publicKey }.toSet() }
|
||||
.distinctUntilChanged()
|
||||
.collect { blockedSet ->
|
||||
_blockedUsers.value = blockedSet
|
||||
}
|
||||
database.blacklistDao()
|
||||
.getBlockedUsers(publicKey)
|
||||
.flowOn(Dispatchers.IO)
|
||||
.map { entities -> entities.map { it.publicKey }.toSet() }
|
||||
.distinctUntilChanged()
|
||||
.collect { blockedSet -> _blockedUsers.value = blockedSet }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🟢 Подписаться на онлайн-статусы всех собеседников
|
||||
* 🔥 Фильтруем уже подписанные ключи чтобы избежать бесконечного цикла
|
||||
* 🟢 Подписаться на онлайн-статусы всех собеседников 🔥 Фильтруем уже подписанные ключи чтобы
|
||||
* избежать бесконечного цикла
|
||||
*/
|
||||
private fun subscribeToOnlineStatuses(opponentKeys: List<String>, privateKey: String) {
|
||||
if (opponentKeys.isEmpty()) return
|
||||
|
||||
// 🔥 КРИТИЧНО: Фильтруем уже подписанные ключи!
|
||||
val newKeys = opponentKeys.filter { !subscribedOnlineKeys.contains(it) }
|
||||
if (newKeys.isEmpty()) return // Все уже подписаны
|
||||
if (newKeys.isEmpty()) return // Все уже подписаны
|
||||
|
||||
// Добавляем в Set ДО отправки пакета чтобы избежать race condition
|
||||
subscribedOnlineKeys.addAll(newKeys)
|
||||
@@ -353,32 +449,30 @@ if (currentAccount == publicKey) {
|
||||
try {
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
|
||||
val packet = PacketOnlineSubscribe().apply {
|
||||
this.privateKey = privateKeyHash
|
||||
newKeys.forEach { key ->
|
||||
addPublicKey(key)
|
||||
}
|
||||
}
|
||||
val packet =
|
||||
PacketOnlineSubscribe().apply {
|
||||
this.privateKey = privateKeyHash
|
||||
newKeys.forEach { key -> addPublicKey(key) }
|
||||
}
|
||||
|
||||
ProtocolManager.send(packet)
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создать или обновить диалог после отправки/получения сообщения
|
||||
* 🔥 Используем updateDialogFromMessages для пересчета счетчиков из messages
|
||||
* 📁 SAVED MESSAGES: Использует специальный метод для saved messages
|
||||
* Создать или обновить диалог после отправки/получения сообщения 🔥 Используем
|
||||
* updateDialogFromMessages для пересчета счетчиков из messages 📁 SAVED MESSAGES: Использует
|
||||
* специальный метод для saved messages
|
||||
*/
|
||||
suspend fun upsertDialog(
|
||||
opponentKey: String,
|
||||
opponentTitle: String,
|
||||
opponentUsername: String = "",
|
||||
lastMessage: String,
|
||||
timestamp: Long,
|
||||
verified: Int = 0,
|
||||
isOnline: Int = 0
|
||||
opponentKey: String,
|
||||
opponentTitle: String,
|
||||
opponentUsername: String = "",
|
||||
lastMessage: String,
|
||||
timestamp: Long,
|
||||
verified: Int = 0,
|
||||
isOnline: Int = 0
|
||||
) {
|
||||
if (currentAccount.isEmpty()) return
|
||||
|
||||
@@ -394,29 +488,31 @@ if (currentAccount == publicKey) {
|
||||
|
||||
// Обновляем информацию о собеседнике если есть
|
||||
if (opponentTitle.isNotEmpty()) {
|
||||
dialogDao.updateOpponentInfo(currentAccount, opponentKey, opponentTitle, opponentUsername, verified)
|
||||
dialogDao.updateOpponentInfo(
|
||||
currentAccount,
|
||||
opponentKey,
|
||||
opponentTitle,
|
||||
opponentUsername,
|
||||
verified
|
||||
)
|
||||
}
|
||||
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Конвертировать DialogUiModel в SearchUser для навигации
|
||||
*/
|
||||
/** Конвертировать DialogUiModel в SearchUser для навигации */
|
||||
fun dialogToSearchUser(dialog: DialogUiModel): SearchUser {
|
||||
return SearchUser(
|
||||
title = dialog.opponentTitle,
|
||||
username = dialog.opponentUsername,
|
||||
publicKey = dialog.opponentKey,
|
||||
verified = dialog.verified,
|
||||
online = dialog.isOnline
|
||||
title = dialog.opponentTitle,
|
||||
username = dialog.opponentUsername,
|
||||
publicKey = dialog.opponentKey,
|
||||
verified = dialog.verified,
|
||||
online = dialog.isOnline
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить диалог и все сообщения с собеседником
|
||||
* 🔥 ПОЛНОСТЬЮ очищает все данные - диалог, сообщения, кэш
|
||||
* Удалить диалог и все сообщения с собеседником 🔥 ПОЛНОСТЬЮ очищает все данные - диалог,
|
||||
* сообщения, кэш
|
||||
*/
|
||||
suspend fun deleteDialog(opponentKey: String) {
|
||||
if (currentAccount.isEmpty()) return
|
||||
@@ -430,11 +526,12 @@ if (currentAccount == publicKey) {
|
||||
_requestsCount.value = _requests.value.size
|
||||
|
||||
// Вычисляем правильный dialog_key (отсортированная комбинация ключей)
|
||||
val dialogKey = if (currentAccount < opponentKey) {
|
||||
"$currentAccount:$opponentKey"
|
||||
} else {
|
||||
"$opponentKey:$currentAccount"
|
||||
}
|
||||
val dialogKey =
|
||||
if (currentAccount < opponentKey) {
|
||||
"$currentAccount:$opponentKey"
|
||||
} else {
|
||||
"$opponentKey:$currentAccount"
|
||||
}
|
||||
|
||||
// 🗑️ 1. Очищаем ВСЕ кэши сообщений
|
||||
MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey)
|
||||
@@ -442,79 +539,72 @@ if (currentAccount == publicKey) {
|
||||
ChatViewModel.clearCacheForOpponent(opponentKey)
|
||||
|
||||
// 🗑️ 2. Проверяем сколько сообщений в БД до удаления
|
||||
val messageCountBefore = database.messageDao().getMessageCount(currentAccount, dialogKey)
|
||||
val messageCountBefore =
|
||||
database.messageDao().getMessageCount(currentAccount, dialogKey)
|
||||
|
||||
// 🗑️ 3. Удаляем все сообщения из диалога по dialog_key
|
||||
val deletedByDialogKey = database.messageDao().deleteDialog(
|
||||
account = currentAccount,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
val deletedByDialogKey =
|
||||
database.messageDao()
|
||||
.deleteDialog(account = currentAccount, dialogKey = dialogKey)
|
||||
|
||||
// 🗑️ 4. Также удаляем по from/to ключам (на всякий случай - старые сообщения)
|
||||
val deletedBetweenUsers = database.messageDao().deleteMessagesBetweenUsers(
|
||||
account = currentAccount,
|
||||
user1 = opponentKey,
|
||||
user2 = currentAccount
|
||||
)
|
||||
val deletedBetweenUsers =
|
||||
database.messageDao()
|
||||
.deleteMessagesBetweenUsers(
|
||||
account = currentAccount,
|
||||
user1 = opponentKey,
|
||||
user2 = currentAccount
|
||||
)
|
||||
|
||||
// 🗑️ 5. Проверяем сколько сообщений осталось
|
||||
val messageCountAfter = database.messageDao().getMessageCount(currentAccount, dialogKey)
|
||||
|
||||
// 🗑️ 6. Удаляем диалог из таблицы dialogs
|
||||
database.dialogDao().deleteDialog(
|
||||
account = currentAccount,
|
||||
opponentKey = opponentKey
|
||||
)
|
||||
database.dialogDao().deleteDialog(account = currentAccount, opponentKey = opponentKey)
|
||||
|
||||
// 🗑️ 7. Проверяем что диалог удален
|
||||
val dialogAfter = database.dialogDao().getDialog(currentAccount, opponentKey)
|
||||
|
||||
} catch (e: Exception) {
|
||||
// В случае ошибки - возвращаем диалог обратно (откатываем оптимистичное обновление)
|
||||
// Flow обновится автоматически из БД
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Заблокировать пользователя
|
||||
*/
|
||||
/** Заблокировать пользователя */
|
||||
suspend fun blockUser(publicKey: String) {
|
||||
if (currentAccount.isEmpty()) return
|
||||
|
||||
try {
|
||||
database.blacklistDao().blockUser(
|
||||
com.rosetta.messenger.database.BlacklistEntity(
|
||||
publicKey = publicKey,
|
||||
account = currentAccount
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
database.blacklistDao()
|
||||
.blockUser(
|
||||
com.rosetta.messenger.database.BlacklistEntity(
|
||||
publicKey = publicKey,
|
||||
account = currentAccount
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Разблокировать пользователя
|
||||
*/
|
||||
/** Разблокировать пользователя */
|
||||
suspend fun unblockUser(publicKey: String) {
|
||||
if (currentAccount.isEmpty()) return
|
||||
|
||||
try {
|
||||
database.blacklistDao().unblockUser(publicKey, currentAccount)
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 📬 Загрузить информацию о пользователе для request
|
||||
* 📁 НЕ загружаем для Saved Messages (свой publicKey)
|
||||
* 📬 Загрузить информацию о пользователе для request 📁 НЕ загружаем для Saved Messages (свой
|
||||
* publicKey)
|
||||
*/
|
||||
private fun loadUserInfoForRequest(publicKey: String) {
|
||||
loadUserInfoForDialog(publicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Загрузить информацию о пользователе для диалога
|
||||
* 📁 НЕ загружаем для Saved Messages (свой publicKey)
|
||||
* 🔥 Загрузить информацию о пользователе для диалога 📁 НЕ загружаем для Saved Messages (свой
|
||||
* publicKey)
|
||||
*/
|
||||
private fun loadUserInfoForDialog(publicKey: String) {
|
||||
// 📁 Не запрашиваем информацию о самом себе (Saved Messages)
|
||||
@@ -528,10 +618,11 @@ if (currentAccount == publicKey) {
|
||||
}
|
||||
requestedUserInfoKeys.add(publicKey)
|
||||
|
||||
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val sharedPrefs = getApplication<Application>().getSharedPreferences("rosetta", Application.MODE_PRIVATE)
|
||||
val sharedPrefs =
|
||||
getApplication<Application>()
|
||||
.getSharedPreferences("rosetta", Application.MODE_PRIVATE)
|
||||
val currentUserPrivateKey = sharedPrefs.getString("private_key", "") ?: ""
|
||||
|
||||
if (currentUserPrivateKey.isEmpty()) return@launch
|
||||
@@ -539,21 +630,18 @@ if (currentAccount == publicKey) {
|
||||
// 🔥 ВАЖНО: Используем хеш ключа, как в MessageRepository.requestUserInfo
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey)
|
||||
|
||||
|
||||
// Запрашиваем информацию о пользователе с сервера
|
||||
val packet = PacketSearch().apply {
|
||||
this.privateKey = privateKeyHash
|
||||
this.search = publicKey
|
||||
}
|
||||
val packet =
|
||||
PacketSearch().apply {
|
||||
this.privateKey = privateKeyHash
|
||||
this.search = publicKey
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить заблокирован ли пользователь
|
||||
*/
|
||||
/** Проверить заблокирован ли пользователь */
|
||||
suspend fun isUserBlocked(publicKey: String): Boolean {
|
||||
if (currentAccount.isEmpty()) return false
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.rosetta.messenger.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.*
|
||||
@@ -16,16 +18,14 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.content.Context
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
// Telegram's CubicBezierInterpolator(0.25, 0.1, 0.25, 1.0)
|
||||
private val TelegramEasing = CubicBezierEasing(0.25f, 0.1f, 0.25f, 1.0f)
|
||||
|
||||
// Swipe-back thresholds (Telegram-like)
|
||||
private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete
|
||||
private const val FLING_VELOCITY_THRESHOLD = 150f // px/s — very sensitive to flings
|
||||
private const val COMPLETION_THRESHOLD = 0.2f // 20% of screen width — very easy to complete
|
||||
private const val FLING_VELOCITY_THRESHOLD = 150f // px/s — very sensitive to flings
|
||||
private const val ANIMATION_DURATION_ENTER = 300
|
||||
private const val ANIMATION_DURATION_EXIT = 200
|
||||
private const val EDGE_ZONE_DP = 200
|
||||
@@ -33,8 +33,7 @@ private const val EDGE_ZONE_DP = 200
|
||||
/**
|
||||
* Telegram-style swipe back container (optimized)
|
||||
*
|
||||
* Wraps content and allows swiping from the left edge to go back.
|
||||
* Features:
|
||||
* Wraps content and allows swiping from the left edge to go back. Features:
|
||||
* - Edge-only swipe detection (left 30dp)
|
||||
* - Direct state update during drag (no coroutine overhead)
|
||||
* - VelocityTracker for fling detection
|
||||
@@ -45,12 +44,18 @@ private const val EDGE_ZONE_DP = 200
|
||||
*/
|
||||
@Composable
|
||||
fun SwipeBackContainer(
|
||||
isVisible: Boolean,
|
||||
onBack: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
swipeEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
isVisible: Boolean,
|
||||
onBack: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
swipeEnabled: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
|
||||
// Saves ~15 remember/Animatable allocations per unused screen at startup (×12 screens).
|
||||
var wasEverVisible by remember { mutableStateOf(false) }
|
||||
if (isVisible) wasEverVisible = true
|
||||
if (!wasEverVisible) return
|
||||
|
||||
val density = LocalDensity.current
|
||||
val configuration = LocalConfiguration.current
|
||||
val screenWidthPx = with(density) { configuration.screenWidthDp.dp.toPx() }
|
||||
@@ -74,7 +79,8 @@ fun SwipeBackContainer(
|
||||
// Coroutine scope for animations
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через InputMethodManager)
|
||||
// 🔥 Keyboard controller для скрытия клавиатуры при свайпе (надёжный метод через
|
||||
// InputMethodManager)
|
||||
val context = LocalContext.current
|
||||
val view = LocalView.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
@@ -94,14 +100,15 @@ fun SwipeBackContainer(
|
||||
// Animate in: fade-in
|
||||
shouldShow = true
|
||||
isAnimatingIn = true
|
||||
offsetAnimatable.snapTo(0f) // No slide for entry
|
||||
offsetAnimatable.snapTo(0f) // No slide for entry
|
||||
alphaAnimatable.snapTo(0f)
|
||||
alphaAnimatable.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = ANIMATION_DURATION_ENTER,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
targetValue = 1f,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis = ANIMATION_DURATION_ENTER,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
)
|
||||
isAnimatingIn = false
|
||||
} else if (!isVisible && shouldShow && !isAnimatingOut) {
|
||||
@@ -109,11 +116,8 @@ fun SwipeBackContainer(
|
||||
isAnimatingOut = true
|
||||
alphaAnimatable.snapTo(1f)
|
||||
alphaAnimatable.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = 200,
|
||||
easing = FastOutSlowInEasing
|
||||
)
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(durationMillis = 200, easing = FastOutSlowInEasing)
|
||||
)
|
||||
shouldShow = false
|
||||
isAnimatingOut = false
|
||||
@@ -128,133 +132,176 @@ fun SwipeBackContainer(
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Scrim (dimming layer behind the screen) - only when swiping
|
||||
if (currentOffset > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = scrimAlpha))
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = scrimAlpha)))
|
||||
}
|
||||
|
||||
// Content with swipe gesture
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationX = currentOffset
|
||||
alpha = currentAlpha
|
||||
}
|
||||
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
||||
.then(
|
||||
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
val velocityTracker = VelocityTracker()
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
|
||||
awaitEachGesture {
|
||||
val down = awaitFirstDown(requireUnconsumed = false)
|
||||
|
||||
// Edge-only detection
|
||||
if (down.position.x > edgeZonePx) {
|
||||
return@awaitEachGesture
|
||||
modifier =
|
||||
Modifier.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
translationX = currentOffset
|
||||
alpha = currentAlpha
|
||||
}
|
||||
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
||||
.then(
|
||||
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
val velocityTracker = VelocityTracker()
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
|
||||
velocityTracker.resetTracking()
|
||||
var startedSwipe = false
|
||||
var totalDragX = 0f
|
||||
var totalDragY = 0f
|
||||
var passedSlop = false
|
||||
awaitEachGesture {
|
||||
val down =
|
||||
awaitFirstDown(
|
||||
requireUnconsumed = false
|
||||
)
|
||||
|
||||
// Use Initial pass to intercept BEFORE children
|
||||
while (true) {
|
||||
val event = awaitPointerEvent(PointerEventPass.Initial)
|
||||
val change = event.changes.firstOrNull { it.id == down.id }
|
||||
?: break
|
||||
// Edge-only detection
|
||||
if (down.position.x > edgeZonePx) {
|
||||
return@awaitEachGesture
|
||||
}
|
||||
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
break
|
||||
}
|
||||
velocityTracker.resetTracking()
|
||||
var startedSwipe = false
|
||||
var totalDragX = 0f
|
||||
var totalDragY = 0f
|
||||
var passedSlop = false
|
||||
|
||||
val dragDelta = change.positionChange()
|
||||
totalDragX += dragDelta.x
|
||||
totalDragY += dragDelta.y
|
||||
// Use Initial pass to intercept BEFORE children
|
||||
while (true) {
|
||||
val event =
|
||||
awaitPointerEvent(
|
||||
PointerEventPass.Initial
|
||||
)
|
||||
val change =
|
||||
event.changes.firstOrNull {
|
||||
it.id == down.id
|
||||
}
|
||||
?: break
|
||||
|
||||
if (!passedSlop) {
|
||||
val totalDistance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
|
||||
if (totalDistance < touchSlop) continue
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
break
|
||||
}
|
||||
|
||||
// Slop exceeded — only claim rightward + mostly horizontal
|
||||
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.5f) {
|
||||
passedSlop = true
|
||||
startedSwipe = true
|
||||
isDragging = true
|
||||
dragOffset = offsetAnimatable.value
|
||||
val dragDelta = change.positionChange()
|
||||
totalDragX += dragDelta.x
|
||||
totalDragY += dragDelta.y
|
||||
|
||||
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
if (!passedSlop) {
|
||||
val totalDistance =
|
||||
kotlin.math.sqrt(
|
||||
totalDragX *
|
||||
totalDragX +
|
||||
totalDragY *
|
||||
totalDragY
|
||||
)
|
||||
if (totalDistance < touchSlop) continue
|
||||
|
||||
change.consume()
|
||||
// Slop exceeded — only claim rightward
|
||||
// + mostly horizontal
|
||||
if (totalDragX > 0 &&
|
||||
kotlin.math.abs(
|
||||
totalDragX
|
||||
) >
|
||||
kotlin.math.abs(
|
||||
totalDragY
|
||||
) * 1.5f
|
||||
) {
|
||||
passedSlop = true
|
||||
startedSwipe = true
|
||||
isDragging = true
|
||||
dragOffset = offsetAnimatable.value
|
||||
|
||||
val imm =
|
||||
context.getSystemService(
|
||||
Context.INPUT_METHOD_SERVICE
|
||||
) as
|
||||
InputMethodManager
|
||||
imm.hideSoftInputFromWindow(
|
||||
view.windowToken,
|
||||
0
|
||||
)
|
||||
focusManager.clearFocus()
|
||||
|
||||
change.consume()
|
||||
} else {
|
||||
// Vertical or leftward — let
|
||||
// children handle
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// We own the gesture — update drag
|
||||
dragOffset =
|
||||
(dragOffset + dragDelta.x)
|
||||
.coerceIn(
|
||||
0f,
|
||||
screenWidthPx
|
||||
)
|
||||
velocityTracker.addPosition(
|
||||
change.uptimeMillis,
|
||||
change.position
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag end
|
||||
if (startedSwipe) {
|
||||
isDragging = false
|
||||
val velocity =
|
||||
velocityTracker.calculateVelocity()
|
||||
.x
|
||||
val currentProgress =
|
||||
dragOffset / screenWidthPx
|
||||
|
||||
val shouldComplete =
|
||||
currentProgress >
|
||||
0.5f || // Past 50% — always
|
||||
// complete
|
||||
velocity >
|
||||
FLING_VELOCITY_THRESHOLD || // Fast fling right
|
||||
(currentProgress >
|
||||
COMPLETION_THRESHOLD &&
|
||||
velocity >
|
||||
-FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back
|
||||
|
||||
scope.launch {
|
||||
offsetAnimatable.snapTo(dragOffset)
|
||||
|
||||
if (shouldComplete) {
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = screenWidthPx,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
ANIMATION_DURATION_EXIT,
|
||||
easing =
|
||||
TelegramEasing
|
||||
)
|
||||
)
|
||||
onBack()
|
||||
} else {
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec =
|
||||
tween(
|
||||
durationMillis =
|
||||
ANIMATION_DURATION_EXIT,
|
||||
easing =
|
||||
TelegramEasing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
dragOffset = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Vertical or leftward — let children handle
|
||||
break
|
||||
Modifier
|
||||
}
|
||||
} else {
|
||||
// We own the gesture — update drag
|
||||
dragOffset = (dragOffset + dragDelta.x)
|
||||
.coerceIn(0f, screenWidthPx)
|
||||
velocityTracker.addPosition(
|
||||
change.uptimeMillis,
|
||||
change.position
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drag end
|
||||
if (startedSwipe) {
|
||||
isDragging = false
|
||||
val velocity = velocityTracker.calculateVelocity().x
|
||||
val currentProgress = dragOffset / screenWidthPx
|
||||
|
||||
val shouldComplete =
|
||||
currentProgress > 0.5f || // Past 50% — always complete
|
||||
velocity > FLING_VELOCITY_THRESHOLD || // Fast fling right
|
||||
(currentProgress > COMPLETION_THRESHOLD &&
|
||||
velocity > -FLING_VELOCITY_THRESHOLD) // 20%+ and not flinging back
|
||||
|
||||
scope.launch {
|
||||
offsetAnimatable.snapTo(dragOffset)
|
||||
|
||||
if (shouldComplete) {
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = screenWidthPx,
|
||||
animationSpec = tween(
|
||||
durationMillis = ANIMATION_DURATION_EXIT,
|
||||
easing = TelegramEasing
|
||||
)
|
||||
)
|
||||
onBack()
|
||||
} else {
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = tween(
|
||||
durationMillis = ANIMATION_DURATION_EXIT,
|
||||
easing = TelegramEasing
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
dragOffset = 0f
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
content()
|
||||
}
|
||||
)
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user