feat: Enhance group chat functionality and UI improvements

- Added support for group action system messages in MessageBubble.
- Implemented group invite handling with inline cards for joining groups.
- Updated MessageBubble to display group sender labels and admin badges.
- Enhanced image decryption logic for group attachments.
- Modified BlurredAvatarBackground to load system avatars based on public keys.
- Improved SwipeBackContainer with layer management for better swipe effects.
- Updated VerifiedBadge to use dynamic icons based on user verification status.
- Added new drawable resource for admin badge icon.
This commit is contained in:
2026-03-01 00:01:01 +05:00
parent 3f2b52b578
commit a0569648e8
28 changed files with 5053 additions and 483 deletions

View File

@@ -141,6 +141,39 @@ data class DialogEntity(
"[]" // 📎 JSON attachments последнего сообщения (кэш из messages)
)
/** Entity для групповых диалогов */
@Entity(
tableName = "groups",
indices =
[
Index(value = ["account", "group_id"], unique = true)]
)
data class GroupEntity(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
@ColumnInfo(name = "account") val account: String,
@ColumnInfo(name = "group_id") val groupId: String,
@ColumnInfo(name = "title") val title: String,
@ColumnInfo(name = "description") val description: String = "",
@ColumnInfo(name = "key") val key: String
)
@Dao
interface GroupDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertGroup(group: GroupEntity): Long
@Query(
"SELECT * FROM groups WHERE account = :account AND group_id = :groupId LIMIT 1"
)
suspend fun getGroup(account: String, groupId: String): GroupEntity?
@Query("DELETE FROM groups WHERE account = :account AND group_id = :groupId")
suspend fun deleteGroup(account: String, groupId: String): Int
@Query("DELETE FROM groups WHERE account = :account")
suspend fun deleteAllByAccount(account: String): Int
}
/** DAO для работы с сообщениями */
@Dao
interface MessageDao {
@@ -476,8 +509,12 @@ interface DialogDao {
"""
SELECT * FROM dialogs
WHERE account = :account
AND i_have_sent = 1
AND last_message_timestamp > 0
AND (
i_have_sent = 1
OR opponent_key = '0x000000000000000000000000000000000000000001'
OR opponent_key = '0x000000000000000000000000000000000000000002'
)
AND (last_message != '' OR last_message_attachments != '[]')
ORDER BY last_message_timestamp DESC
LIMIT 30
"""
@@ -489,8 +526,12 @@ interface DialogDao {
"""
SELECT * FROM dialogs
WHERE account = :account
AND i_have_sent = 1
AND last_message_timestamp > 0
AND (
i_have_sent = 1
OR opponent_key = '0x000000000000000000000000000000000000000001'
OR opponent_key = '0x000000000000000000000000000000000000000002'
)
AND (last_message != '' OR last_message_attachments != '[]')
ORDER BY last_message_timestamp DESC
LIMIT :limit OFFSET :offset
"""
@@ -506,7 +547,9 @@ interface DialogDao {
SELECT * FROM dialogs
WHERE account = :account
AND i_have_sent = 0
AND last_message_timestamp > 0
AND opponent_key != '0x000000000000000000000000000000000000000001'
AND opponent_key != '0x000000000000000000000000000000000000000002'
AND (last_message != '' OR last_message_attachments != '[]')
ORDER BY last_message_timestamp DESC
LIMIT 30
"""
@@ -519,7 +562,9 @@ interface DialogDao {
SELECT COUNT(*) FROM dialogs
WHERE account = :account
AND i_have_sent = 0
AND last_message_timestamp > 0
AND opponent_key != '0x000000000000000000000000000000000000000001'
AND opponent_key != '0x000000000000000000000000000000000000000002'
AND (last_message != '' OR last_message_attachments != '[]')
"""
)
fun getRequestsCountFlow(account: String): Flow<Int>
@@ -532,7 +577,8 @@ interface DialogDao {
@Query("""
SELECT * FROM dialogs
WHERE account = :account
AND last_message_timestamp > 0
AND (last_message != '' OR last_message_attachments != '[]')
AND opponent_key NOT LIKE '#group:%'
AND (
opponent_title = ''
OR opponent_title = opponent_key
@@ -709,6 +755,32 @@ interface DialogDao {
)
suspend fun hasSentToOpponent(account: String, opponentKey: String): Boolean
/** 🚀 Универсальный подсчет непрочитанных входящих по dialog_key (direct + group) */
@Query(
"""
SELECT COUNT(*) FROM messages
WHERE account = :account
AND dialog_key = :dialogKey
AND from_me = 0
AND read = 0
"""
)
suspend fun countUnreadByDialogKey(account: String, dialogKey: String): Int
/** 🚀 Универсальная проверка исходящих сообщений по dialog_key (direct + group) */
@Query(
"""
SELECT EXISTS(
SELECT 1 FROM messages
WHERE account = :account
AND dialog_key = :dialogKey
AND from_me = 1
LIMIT 1
)
"""
)
suspend fun hasSentByDialogKey(account: String, dialogKey: String): Boolean
/**
* 🚀 ОПТИМИЗИРОВАННЫЙ updateDialogFromMessages Заменяет монолитный SQL с 9 коррелированными
* подзапросами на:
@@ -720,31 +792,40 @@ interface DialogDao {
*/
@Transaction
suspend fun updateDialogFromMessages(account: String, opponentKey: String) {
val normalizedOpponentKey = opponentKey.trim()
val normalizedOpponentKeyLower = normalizedOpponentKey.lowercase()
val isGroupDialog =
normalizedOpponentKeyLower.startsWith("#group:") ||
normalizedOpponentKeyLower.startsWith("group:")
val isSystemDialog =
normalizedOpponentKey == "0x000000000000000000000000000000000000000001" ||
normalizedOpponentKey == "0x000000000000000000000000000000000000000002"
// 📁 Для saved messages dialogKey = account (не "$account:$account")
val dialogKey =
if (account == opponentKey) account
else if (account < opponentKey) "$account:$opponentKey"
else "$opponentKey:$account"
if (account == normalizedOpponentKey) account
else if (isGroupDialog) normalizedOpponentKey
else if (account < normalizedOpponentKey) "$account:$normalizedOpponentKey"
else "$normalizedOpponentKey:$account"
// 1. Получаем последнее сообщение — O(1) по индексу (account, dialog_key, timestamp)
val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return
// 2. Получаем существующий диалог для сохранения метаданных (online, verified, title...)
val existing = getDialog(account, opponentKey)
val existing = getDialog(account, normalizedOpponentKey)
// 3. Считаем непрочитанные — O(N) по индексу (account, from_public_key, to_public_key,
// timestamp)
val unread = countUnreadFromOpponent(account, opponentKey)
// 3. Считаем непрочитанные входящие по dialog_key.
val unread = countUnreadByDialogKey(account, dialogKey)
// 4. Проверяем были ли исходящие — O(1)
val hasSent = hasSentToOpponent(account, opponentKey)
val hasSent = hasSentByDialogKey(account, dialogKey)
// 5. Один INSERT OR REPLACE с вычисленными данными
insertDialog(
DialogEntity(
id = existing?.id ?: 0,
account = account,
opponentKey = opponentKey,
opponentKey = normalizedOpponentKey,
opponentTitle = existing?.opponentTitle ?: "",
opponentUsername = existing?.opponentUsername ?: "",
lastMessage = lastMsg.plainMessage,
@@ -753,7 +834,8 @@ interface DialogDao {
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = existing?.verified ?: 0,
iHaveSent = if (hasSent) 1 else (existing?.iHaveSent ?: 0),
// Desktop parity: request flag is always derived from message history.
iHaveSent = if (hasSent || isSystemDialog) 1 else 0,
lastMessageFromMe = lastMsg.fromMe,
lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0,
lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0,

View File

@@ -16,8 +16,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
BlacklistEntity::class,
AvatarCacheEntity::class,
AccountSyncTimeEntity::class,
GroupEntity::class,
PinnedMessageEntity::class],
version = 13,
version = 14,
exportSchema = false
)
abstract class RosettaDatabase : RoomDatabase() {
@@ -27,6 +28,7 @@ abstract class RosettaDatabase : RoomDatabase() {
abstract fun blacklistDao(): BlacklistDao
abstract fun avatarDao(): AvatarDao
abstract fun syncTimeDao(): SyncTimeDao
abstract fun groupDao(): GroupDao
abstract fun pinnedMessageDao(): PinnedMessageDao
companion object {
@@ -176,6 +178,30 @@ abstract class RosettaDatabase : RoomDatabase() {
}
}
/**
* 👥 МИГРАЦИЯ 13->14: Таблица groups для групповых диалогов
*/
private val MIGRATION_13_14 =
object : Migration(13, 14) {
override fun migrate(database: SupportSQLiteDatabase) {
database.execSQL(
"""
CREATE TABLE IF NOT EXISTS groups (
id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
account TEXT NOT NULL,
group_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
`key` TEXT NOT NULL
)
"""
)
database.execSQL(
"CREATE UNIQUE INDEX IF NOT EXISTS index_groups_account_group_id ON groups (account, group_id)"
)
}
}
fun getDatabase(context: Context): RosettaDatabase {
return INSTANCE
?: synchronized(this) {
@@ -197,7 +223,8 @@ abstract class RosettaDatabase : RoomDatabase() {
MIGRATION_9_10,
MIGRATION_10_11,
MIGRATION_11_12,
MIGRATION_12_13
MIGRATION_12_13,
MIGRATION_13_14
)
.fallbackToDestructiveMigration() // Для разработки - только
// если миграция не