From 9d3e5bcb10ede907b667b974f1d288799f75d523 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 21 Mar 2026 21:12:52 +0500 Subject: [PATCH] =?UTF-8?q?=D0=91=D0=BE=D0=BB=D1=8C=D1=88=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BA=D0=B5=D1=82:=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=20=D0=BF=D0=BE=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=D0=BC,=20=D0=BA=D0=BB=D0=B8=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D0=BE=20=D1=82=D1=8D=D0=B3=D0=B0=D0=BC,=20=D1=82=D0=B5?= =?UTF-8?q?=D0=BC=D1=8B=20=D0=BE=D0=B1=D0=BE=D0=B5=D0=B2=20=D0=B8=20UX-?= =?UTF-8?q?=D1=84=D0=B8=D0=BA=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Что вошло:\n- Добавлен полноценный Messages-tab в SearchScreen: поиск по тексту сообщений по всей базе, батчевый проход, параллельная дешифровка, кеш расшифровки, подсветка совпадений, сниппеты и быстрый переход в нужный диалог.\n- В Chats-tab добавлены алиасы для Saved Messages (saved/saved messages/избранное/сохраненные и др.), чтобы чат открывался по текстовому поиску даже без точного username/public key.\n- Для search-бэкенда расширен DAO: getAllMessagesPaged() для постраничного обхода сообщений аккаунта.\n- Исправлена логика клика по @тэгам в сообщениях:\n - переход теперь ведет сразу в чат пользователя (а не в профиль);\n - добавлен fallback-резолв username -> user через локальный диалог, кеш протокола и PacketSearch;\n - добавлен DAO getDialogByUsername() (регистронезависимо и с игнором @).\n- Усилена обработка PacketSearch в ProtocolManager:\n - добавлена очередь ожидания pendingSearchQueries;\n - нормализация query (без @, lowercase);\n - устойчивый матч ответов сервера (raw/normalized/by username);\n - добавлены методы getCachedUserByUsername() и searchUsers().\n- Исправлен конфликт тачей между ClickableSpan и bubble-menu:\n - в AppleEmojiText/AppleEmojiTextView добавлен callback начала тапа по span;\n - улучшен hit-test по span (включая пограничные offset/layout fallback);\n - suppress performClick на span-тапах;\n - в MessageBubble добавлен тайм-guard, чтобы tap по span не открывал context menu.\n- Стабилизирован verified-бейдж в заголовке чата: агрегируется из переданного user, кеша протокола, локальной БД и серверного resolve; отображается консистентно в личных чатах.\n- Улучшен пустой экран Saved Messages при обоях: добавлена аккуратная подложка/бордер и выровненный текст, чтобы контент оставался читабельным на любом фоне.\n- Реализована автосвязка обоев между светлой/темной темами:\n - добавлены pairGroup и mapToTheme/resolveWallpaperForTheme в ThemeWallpapers;\n - добавлены отдельные prefs-ключи для light/dark wallpaper;\n - MainActivity теперь автоматически подбирает и сохраняет обои под активную тему и сохраняет выбор по теме.\n- Биометрия: если на устройстве нет hardware fingerprint, экран включения биометрии не показывается (и доступность возвращает NotAvailable).\n- Небольшие UI-фиксы: поправлено позиционирование галочки в сайдбаре.\n- Техдолг: удалена неиспользуемая зависимость jsoup из build.gradle. --- app/build.gradle.kts | 3 - .../com/rosetta/messenger/MainActivity.kt | 44 +- .../biometric/BiometricAuthManager.kt | 9 + .../rosetta/messenger/crypto/MessageCrypto.kt | 2 +- .../messenger/data/PreferencesManager.kt | 13 + .../messenger/database/MessageEntities.kt | 24 + .../messenger/network/ProtocolManager.kt | 114 +++++ .../messenger/ui/chats/ChatDetailScreen.kt | 329 +++++++++---- .../messenger/ui/chats/ChatViewModel.kt | 39 ++ .../messenger/ui/chats/ChatsListScreen.kt | 11 +- .../messenger/ui/chats/SearchScreen.kt | 460 +++++++++++++++++- .../chats/components/ChatDetailComponents.kt | 26 +- .../ui/components/AppleEmojiEditText.kt | 89 +++- .../messenger/ui/settings/ThemeWallpapers.kt | 45 +- 14 files changed, 1100 insertions(+), 108 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6cd687d..be7a6c9 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -129,9 +129,6 @@ dependencies { implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support - // Jsoup for HTML parsing (Link Preview OG tags) - implementation("org.jsoup:jsoup:1.17.2") - // uCrop for image cropping implementation("com.github.yalantis:ucrop:2.2.8") diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 32b7ab0..dc6a0b2 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -58,6 +58,7 @@ import com.rosetta.messenger.ui.settings.OtherProfileScreen import com.rosetta.messenger.ui.settings.ProfileScreen import com.rosetta.messenger.ui.settings.SafetyScreen import com.rosetta.messenger.ui.settings.ThemeScreen +import com.rosetta.messenger.ui.settings.ThemeWallpapers import com.rosetta.messenger.ui.settings.UpdatesScreen import com.rosetta.messenger.ui.splash.SplashScreen import com.rosetta.messenger.ui.theme.RosettaAndroidTheme @@ -743,6 +744,8 @@ fun MainScreen( .backgroundBlurColorIdForAccount(accountPublicKey) .collectAsState(initial = "avatar") val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "") + val chatWallpaperIdLight by prefsManager.chatWallpaperIdLight.collectAsState(initial = "") + val chatWallpaperIdDark by prefsManager.chatWallpaperIdDark.collectAsState(initial = "") val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet()) // AvatarRepository для работы с аватарами @@ -763,6 +766,29 @@ fun MainScreen( // Coroutine scope for profile updates val mainScreenScope = rememberCoroutineScope() + LaunchedEffect(isDarkTheme, chatWallpaperId, chatWallpaperIdLight, chatWallpaperIdDark) { + val targetWallpaperId = + ThemeWallpapers.resolveWallpaperForTheme( + currentWallpaperId = chatWallpaperId, + isDarkTheme = isDarkTheme, + darkThemeWallpaperId = chatWallpaperIdDark, + lightThemeWallpaperId = chatWallpaperIdLight + ) + + if (targetWallpaperId != chatWallpaperId) { + prefsManager.setChatWallpaperId(targetWallpaperId) + } + + val currentThemeStored = + if (isDarkTheme) chatWallpaperIdDark else chatWallpaperIdLight + if (currentThemeStored != targetWallpaperId) { + prefsManager.setChatWallpaperIdForTheme( + isDarkTheme = isDarkTheme, + value = targetWallpaperId + ) + } + } + // 🔥 Простая навигация с swipe back Box(modifier = Modifier.fillMaxSize()) { // Base layer - chats list (всегда видимый, чтобы его было видно при свайпе) @@ -971,7 +997,13 @@ fun MainScreen( onBack = { navStack = navStack.filterNot { it is Screen.Theme } }, onThemeModeChange = onThemeModeChange, onWallpaperChange = { wallpaperId -> - mainScreenScope.launch { prefsManager.setChatWallpaperId(wallpaperId) } + mainScreenScope.launch { + prefsManager.setChatWallpaperIdForTheme( + isDarkTheme = isDarkTheme, + value = wallpaperId + ) + prefsManager.setChatWallpaperId(wallpaperId) + } } ) } @@ -1280,6 +1312,16 @@ fun MainScreen( } val biometricAccountManager = remember { AccountManager(context) } val activity = context as? FragmentActivity + val isFingerprintSupported = remember { + biometricManager.isFingerprintHardwareAvailable() + } + + if (!isFingerprintSupported) { + LaunchedEffect(Unit) { + navStack = navStack.filterNot { it is Screen.Biometric } + } + return@SwipeBackContainer + } BiometricEnableScreen( isDarkTheme = isDarkTheme, diff --git a/app/src/main/java/com/rosetta/messenger/biometric/BiometricAuthManager.kt b/app/src/main/java/com/rosetta/messenger/biometric/BiometricAuthManager.kt index a5f5ded..03d11f9 100644 --- a/app/src/main/java/com/rosetta/messenger/biometric/BiometricAuthManager.kt +++ b/app/src/main/java/com/rosetta/messenger/biometric/BiometricAuthManager.kt @@ -1,6 +1,7 @@ package com.rosetta.messenger.biometric import android.content.Context +import android.content.pm.PackageManager import android.os.Build import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyPermanentlyInvalidatedException @@ -52,7 +53,15 @@ class BiometricAuthManager(private val context: Context) { * Проверяет доступность STRONG биометрической аутентификации * BIOMETRIC_STRONG требует Class 3 биометрию (отпечаток/лицо с криптографической привязкой) */ + fun isFingerprintHardwareAvailable(): Boolean { + return context.packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT) + } + fun isBiometricAvailable(): BiometricAvailability { + if (!isFingerprintHardwareAvailable()) { + return BiometricAvailability.NotAvailable("Отпечаток пальца не поддерживается") + } + val biometricManager = BiometricManager.from(context) return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { diff --git a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt index 6ec21ec..ad97810 100644 --- a/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt +++ b/app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt @@ -1592,7 +1592,7 @@ object MessageCrypto { // Reset bounds to default after first continuation lowerBoundary = 0x80 upperBoundary = 0xBF - +// test if (bytesSeen == bytesNeeded) { // Sequence complete — emit code point if (codePoint <= 0xFFFF) { diff --git a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt index ca0c418..aa02e85 100644 --- a/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt +++ b/app/src/main/java/com/rosetta/messenger/data/PreferencesManager.kt @@ -28,6 +28,8 @@ class PreferencesManager(private val context: Context) { val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme") val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto" val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper + val CHAT_WALLPAPER_ID_LIGHT = stringPreferencesKey("chat_wallpaper_id_light") + val CHAT_WALLPAPER_ID_DARK = stringPreferencesKey("chat_wallpaper_id_dark") // Notifications val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled") @@ -104,10 +106,21 @@ class PreferencesManager(private val context: Context) { val chatWallpaperId: Flow = context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" } + val chatWallpaperIdLight: Flow = + context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_LIGHT] ?: "" } + + val chatWallpaperIdDark: Flow = + context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_DARK] ?: "" } + suspend fun setChatWallpaperId(value: String) { context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value } } + suspend fun setChatWallpaperIdForTheme(isDarkTheme: Boolean, value: String) { + val key = if (isDarkTheme) CHAT_WALLPAPER_ID_DARK else CHAT_WALLPAPER_ID_LIGHT + context.dataStore.edit { preferences -> preferences[key] = value } + } + // ═════════════════════════════════════════════════════════════ // 🔔 NOTIFICATIONS // ═════════════════════════════════════════════════════════════ diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index c5b9579..75a4af3 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -558,6 +558,18 @@ interface MessageDao { """ ) suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List + + /** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */ + @Query( + """ + SELECT * FROM messages + WHERE account = :account + AND plain_message != '' + ORDER BY timestamp DESC + LIMIT :limit OFFSET :offset + """ + ) + suspend fun getAllMessagesPaged(account: String, limit: Int, offset: Int): List } /** DAO для работы с диалогами */ @@ -658,6 +670,18 @@ interface DialogDao { @Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1") suspend fun getDialog(account: String, opponentKey: String): DialogEntity? + /** Найти direct-диалог по username собеседника (без учета регистра и '@'). */ + @Query( + """ + SELECT * FROM dialogs + WHERE account = :account + AND opponent_key NOT LIKE '#group:%' + AND LOWER(REPLACE(TRIM(opponent_username), '@', '')) = LOWER(REPLACE(TRIM(:username), '@', '')) + LIMIT 1 + """ + ) + suspend fun getDialogByUsername(account: String, username: String): DialogEntity? + /** Обновить последнее сообщение */ @Query( """ diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 71bb1e6..e5eaca7 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -80,6 +80,12 @@ object ProtocolManager { private val userInfoCache = ConcurrentHashMap() // Pending resolves: publicKey → list of continuations waiting for the result private val pendingResolves = ConcurrentHashMap>>() + // Pending search requests: query(username/publicKey fragment) → waiting continuations + private val pendingSearchQueries = + ConcurrentHashMap>>>() + + private fun normalizeSearchQuery(value: String): String = + value.trim().removePrefix("@").lowercase(Locale.ROOT) // UI logs are enabled by default; updates are throttled and bounded by MAX_DEBUG_LOGS. private var uiLogsEnabled = true @@ -473,6 +479,56 @@ object ProtocolManager { try { cont.resume(null) } catch (_: Exception) {} } } + + // Resume pending username/query searches. + // Server may return query in different case/format, so match robustly. + if (searchPacket.search.isNotEmpty()) { + val rawQuery = searchPacket.search.trim() + val normalizedQuery = normalizeSearchQuery(rawQuery) + val continuations = LinkedHashSet>>() + + fun collectByKey(key: String) { + if (key.isEmpty()) return + pendingSearchQueries.remove(key)?.let { continuations.addAll(it) } + } + + collectByKey(rawQuery) + if (normalizedQuery.isNotEmpty() && normalizedQuery != rawQuery) { + collectByKey(normalizedQuery) + } + + if (continuations.isEmpty()) { + val matchedByQuery = + pendingSearchQueries.keys.firstOrNull { pendingKey -> + pendingKey.equals(rawQuery, ignoreCase = true) || + normalizeSearchQuery(pendingKey) == normalizedQuery + } + if (matchedByQuery != null) collectByKey(matchedByQuery) + } + + if (continuations.isEmpty() && searchPacket.users.isNotEmpty()) { + val responseUsernames = + searchPacket.users + .map { normalizeSearchQuery(it.username) } + .filter { it.isNotEmpty() } + .toSet() + if (responseUsernames.isNotEmpty()) { + val matchedByUsers = + pendingSearchQueries.keys.firstOrNull { pendingKey -> + val normalizedPending = normalizeSearchQuery(pendingKey) + normalizedPending.isNotEmpty() && + responseUsernames.contains(normalizedPending) + } + if (matchedByUsers != null) collectByKey(matchedByUsers) + } + } + + continuations.forEach { cont -> + try { + cont.resume(searchPacket.users) + } catch (_: Exception) {} + } + } } // 🚀 Обработчик транспортного сервера (0x0F) @@ -973,6 +1029,18 @@ object ProtocolManager { return userInfoCache[publicKey] } + /** + * 🔍 Get cached user by username (no network request). + * Username compare is case-insensitive and ignores '@'. + */ + fun getCachedUserByUsername(username: String): SearchUser? { + val normalizedUsername = normalizeSearchQuery(username) + if (normalizedUsername.isEmpty()) return null + return userInfoCache.values.firstOrNull { cached -> + normalizeSearchQuery(cached.username) == normalizedUsername + } + } + /** * 🔍 Resolve publicKey → full SearchUser (with server request if needed) */ @@ -1009,6 +1077,52 @@ object ProtocolManager { } } + /** + * 🔍 Search users by query (usually username without '@'). + * Returns raw PacketSearch users list for the exact query. + */ + suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List { + val normalizedQuery = normalizeSearchQuery(query) + if (normalizedQuery.isEmpty()) return emptyList() + + val privateHash = try { getProtocol().getPrivateHash() } catch (_: Exception) { null } + ?: return emptyList() + + val cachedMatches = + userInfoCache.values.filter { cached -> + normalizeSearchQuery(cached.username) == normalizedQuery && cached.publicKey.isNotBlank() + } + if (cachedMatches.isNotEmpty()) { + return cachedMatches.distinctBy { it.publicKey } + } + + return try { + withTimeout(timeoutMs) { + suspendCancellableCoroutine { cont -> + pendingSearchQueries + .getOrPut(normalizedQuery) { mutableListOf() } + .add(cont) + + cont.invokeOnCancellation { + pendingSearchQueries[normalizedQuery]?.remove(cont) + if (pendingSearchQueries[normalizedQuery]?.isEmpty() == true) { + pendingSearchQueries.remove(normalizedQuery) + } + } + + val packet = PacketSearch().apply { + this.privateKey = privateHash + this.search = normalizedQuery + } + send(packet) + } + } + } catch (_: Exception) { + pendingSearchQueries.remove(normalizedQuery) + emptyList() + } + } + /** * Accept a pending device login request. */ diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 2a0fad3..4a66fb7 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -70,6 +71,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp @@ -90,6 +92,7 @@ import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.AttachmentType +import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert @@ -411,6 +414,57 @@ fun ChatDetailScreen( val chatTitle = if (isSavedMessages) "Saved Messages" else user.title.ifEmpty { user.publicKey.take(10) } + var chatHeaderVerified by + remember(user.publicKey, user.verified) { + mutableIntStateOf(user.verified.coerceAtLeast(0)) + } + + LaunchedEffect( + user.publicKey, + user.verified, + currentUserPublicKey, + isSavedMessages, + isGroupChat + ) { + chatHeaderVerified = user.verified.coerceAtLeast(0) + + if (isSavedMessages || isGroupChat || currentUserPublicKey.isBlank()) { + return@LaunchedEffect + } + + val normalizedPublicKey = user.publicKey.trim() + if (normalizedPublicKey.isBlank()) return@LaunchedEffect + + val cachedVerified = + ProtocolManager.getCachedUserInfo(normalizedPublicKey)?.verified ?: 0 + if (cachedVerified > chatHeaderVerified) { + chatHeaderVerified = cachedVerified + } + + val localVerified = + withContext(Dispatchers.IO) { + runCatching { + database + .dialogDao() + .getDialog(currentUserPublicKey, normalizedPublicKey) + ?.verified ?: 0 + } + .getOrDefault(0) + } + if (localVerified > chatHeaderVerified) { + chatHeaderVerified = localVerified + } + + val resolvedVerified = + runCatching { + viewModel.resolveUserForProfile(normalizedPublicKey)?.verified + ?: 0 + } + .getOrDefault(0) + if (resolvedVerified > chatHeaderVerified) { + chatHeaderVerified = resolvedVerified + } + } val openDialogInfo: () -> Unit = { hideInputOverlays() @@ -1725,7 +1779,7 @@ fun ChatDetailScreen( ) if (!isSavedMessages && !isGroupChat && - (user.verified > + (chatHeaderVerified > 0 || isRosettaOfficial) ) { Spacer( @@ -1736,7 +1790,7 @@ fun ChatDetailScreen( ) VerifiedBadge( verified = - if (user.verified > 0) user.verified else 1, + if (chatHeaderVerified > 0) chatHeaderVerified else 1, size = 16, isDarkTheme = @@ -2526,102 +2580,184 @@ fun ChatDetailScreen( verticalArrangement = Arrangement.Center ) { - if (isSavedMessages) { - val composition by - rememberLottieComposition( - LottieCompositionSpec - .RawRes( - R.raw.saved - ) + val showSavedMessagesBackdrop = + isSavedMessages && + hasChatWallpaper + val savedMessagesBackdropShape = + RoundedCornerShape( + 22.dp + ) + val savedMessagesBackdropColor = + if (isDarkTheme) + Color( + 0xB3212121 ) - val progress by - animateLottieCompositionAsState( + else + Color( + 0xB32A2A2A + ) + val contentModifier = + if ( + showSavedMessagesBackdrop + ) { + Modifier + .widthIn( + max = + 340.dp + ) + .fillMaxWidth() + .background( + color = + savedMessagesBackdropColor, + shape = + savedMessagesBackdropShape + ) + .border( + width = + 1.dp, + color = + Color.White + .copy( + alpha = + 0.12f + ), + shape = + savedMessagesBackdropShape + ) + .padding( + horizontal = + 22.dp, + vertical = + 18.dp + ) + } else { + Modifier + } + + Column( + modifier = + contentModifier, + horizontalAlignment = + Alignment + .CenterHorizontally + ) { + val emptyStateTextAlign = + if (isSavedMessages) + TextAlign.Center + else + TextAlign.Start + val emptyStateTextModifier = + if (isSavedMessages) + Modifier.fillMaxWidth() + else + Modifier + if (isSavedMessages) { + val composition by + rememberLottieComposition( + LottieCompositionSpec + .RawRes( + R.raw.saved + ) + ) + val progress by + animateLottieCompositionAsState( + composition = + composition, + iterations = + LottieConstants + .IterateForever + ) + LottieAnimation( composition = composition, - iterations = - LottieConstants - .IterateForever + progress = { + progress + }, + modifier = + Modifier.size( + 120.dp + ) ) - LottieAnimation( - composition = - composition, - progress = { - progress - }, + } else { + val composition by + rememberLottieComposition( + LottieCompositionSpec + .RawRes( + R.raw.speech + ) + ) + val progress by + animateLottieCompositionAsState( + composition = + composition, + iterations = + LottieConstants + .IterateForever + ) + LottieAnimation( + composition = + composition, + progress = { + progress + }, + modifier = + Modifier.size( + 120.dp + ) + ) + } + Spacer( modifier = - Modifier.size( - 120.dp + Modifier.height( + 16.dp ) ) - } else { - val composition by - rememberLottieComposition( - LottieCompositionSpec - .RawRes( - R.raw.speech - ) - ) - val progress by - animateLottieCompositionAsState( - composition = - composition, - iterations = - LottieConstants - .IterateForever - ) - LottieAnimation( - composition = - composition, - progress = { - progress - }, - modifier = - Modifier.size( - 120.dp + Text( + text = + if (isSavedMessages ) + "Save messages here for quick access" + else + "No messages yet", + fontSize = + 16.sp, + color = + dateHeaderTextColor, + fontWeight = + FontWeight + .Medium, + textAlign = + emptyStateTextAlign, + modifier = + emptyStateTextModifier + ) + Spacer( + modifier = + Modifier.height( + 8.dp + ) + ) + Text( + text = + if (isSavedMessages + ) + "Forward messages here or send notes to yourself" + else + "Send a message to start the conversation", + fontSize = + 14.sp, + color = + dateHeaderTextColor + .copy( + alpha = + 0.7f + ), + textAlign = + emptyStateTextAlign, + modifier = + emptyStateTextModifier ) } - Spacer( - modifier = - Modifier.height( - 16.dp - ) - ) - Text( - text = - if (isSavedMessages - ) - "Save messages here for quick access" - else - "No messages yet", - fontSize = 16.sp, - color = - dateHeaderTextColor, - fontWeight = - FontWeight - .Medium - ) - Spacer( - modifier = - Modifier.height( - 8.dp - ) - ) - Text( - text = - if (isSavedMessages - ) - "Forward messages here or send notes to yourself" - else - "Send a message to start the conversation", - fontSize = 14.sp, - color = - dateHeaderTextColor - .copy( - alpha = - 0.7f - ) - ) } } // Есть сообщения @@ -2981,6 +3117,8 @@ fun ChatDetailScreen( val normalizedUsername = username.trim().trimStart('@').lowercase(Locale.ROOT) if (normalizedUsername.isBlank()) return@MessageBubble + // Mention tap should not trigger bubble context-menu tap. + suppressTapAfterLongPress(selectionKey) scope.launch { val normalizedCurrentUsername = currentUserUsername.trim().trimStart('@').lowercase(Locale.ROOT) @@ -3006,12 +3144,21 @@ fun ChatDetailScreen( } } - if (targetPublicKey.isBlank()) return@launch + if (targetPublicKey.isBlank()) { + val resolvedByUsername = + viewModel.resolveUserByUsername(normalizedUsername) + if (resolvedByUsername != null) { + showContextMenu = false + contextMenuMessage = null + onNavigateToChat(resolvedByUsername) + } + return@launch + } if (targetPublicKey.equals(currentUserPublicKey.trim(), ignoreCase = true)) { showContextMenu = false contextMenuMessage = null - onUserProfileClick( + onNavigateToChat( SearchUser( title = currentUserName.ifBlank { "You" }, username = currentUserUsername.trim().trimStart('@'), @@ -3027,7 +3174,7 @@ fun ChatDetailScreen( if (resolvedUser != null) { showContextMenu = false contextMenuMessage = null - onUserProfileClick(resolvedUser) + onNavigateToChat(resolvedUser) } } }, diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index f708a28..bf7a3e3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -17,6 +17,7 @@ import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.MessageLogger import com.rosetta.messenger.utils.MessageThrottleManager import java.util.Date +import java.util.Locale import java.util.UUID import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.* @@ -2290,6 +2291,44 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) } + /** + * Resolve @username to SearchUser using PacketSearch. + * Used for clickable mentions inside chat messages. + */ + suspend fun resolveUserByUsername(username: String, timeoutMs: Long = 3000): SearchUser? { + val normalized = username.trim().trimStart('@').lowercase(Locale.ROOT) + if (normalized.isBlank()) return null + + // 1) Local DB first: in private chats this gives instant/stable navigation. + val account = myPublicKey?.trim().orEmpty() + if (account.isNotBlank()) { + val localDialog = + withContext(Dispatchers.IO) { + runCatching { dialogDao.getDialogByUsername(account, normalized) }.getOrNull() + } + if (localDialog != null && localDialog.opponentKey.isNotBlank()) { + return SearchUser( + title = localDialog.opponentTitle.ifBlank { normalized }, + username = localDialog.opponentUsername, + publicKey = localDialog.opponentKey, + verified = localDialog.verified, + online = localDialog.isOnline + ) + } + } + + // 2) In-memory protocol cache. + ProtocolManager.getCachedUserByUsername(normalized)?.let { return it } + + // 3) Server search fallback. + val results = ProtocolManager.searchUsers(normalized, timeoutMs) + if (results.isEmpty()) return null + + return results.firstOrNull { + it.username.trim().trimStart('@').lowercase(Locale.ROOT) == normalized + } ?: results.firstOrNull() + } + /** 🔥 Повторить отправку сообщения (для ошибки) */ fun retryMessage(message: ChatMessage) { // Удаляем старое сообщение diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 81f7d40..3f2ef33 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -1061,7 +1061,16 @@ fun ChatsListScreen( painter = TelegramIcons.Done, contentDescription = null, tint = Color.White, - modifier = Modifier.size(8.dp) + modifier = + Modifier.size( + 8.dp + ) + .offset( + x = + 0.3.dp, + y = + 0.dp + ) ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index ecdd0ca..5436b28 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -63,6 +63,9 @@ import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.json.JSONArray @@ -70,6 +73,11 @@ import java.io.File import java.text.SimpleDateFormat import java.util.Date import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import com.rosetta.messenger.crypto.CryptoManager +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withStyle // Primary Blue color private val PrimaryBlue = Color(0xFF54A9EB) @@ -77,6 +85,7 @@ private val PrimaryBlue = Color(0xFF54A9EB) /** Вкладки поиска как в Telegram */ private enum class SearchTab(val title: String) { CHATS("Chats"), + MESSAGES("Messages"), MEDIA("Media"), DOWNLOADS("Downloads"), FILES("Files") @@ -382,6 +391,18 @@ fun SearchScreen( onUserSelect = onUserSelect ) } + SearchTab.MESSAGES -> { + MessagesTabContent( + searchQuery = searchQuery, + currentUserPublicKey = currentUserPublicKey, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor, + avatarRepository = avatarRepository, + searchLottieComposition = searchLottieComposition, + onUserSelect = onUserSelect + ) + } SearchTab.MEDIA -> { MediaTabContent( currentUserPublicKey = currentUserPublicKey, @@ -639,14 +660,35 @@ private fun ChatsTabContent( } else { // ═══ Search results ═══ val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase() + val compactQuery = normalizedQuery.replace(Regex("\\s+"), " ").trim() val normalizedPublicKey = currentUserPublicKey.lowercase() val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase() val normalizedName = ownAccountName.trim().lowercase() val hasValidOwnName = ownAccountName.isNotBlank() && !isPlaceholderAccountName(ownAccountName) + val savedMessagesAliases = + listOf( + "saved", + "saved message", + "saved messages", + "savedmessages", + "bookmarks", + "bookmark", + "избранное", + "сохраненное", + "сохранённое", + "сохраненные", + "сохранённые" + ) + val isSavedAliasSearch = + compactQuery.length >= 3 && + savedMessagesAliases.any { alias -> + alias.startsWith(compactQuery) || compactQuery.startsWith(alias) + } val isSavedMessagesSearch = normalizedQuery.isNotEmpty() && - (normalizedPublicKey == normalizedQuery || + (isSavedAliasSearch || + normalizedPublicKey == normalizedQuery || normalizedPublicKey.startsWith(normalizedQuery) || normalizedPublicKey.take(8) == normalizedQuery || normalizedPublicKey.takeLast(8) == normalizedQuery || @@ -916,6 +958,422 @@ private fun SearchSkeleton(isDarkTheme: Boolean) { } } +// ═══════════════════════════════════════════════════════════ +// 💬 MESSAGES TAB — search through decrypted message text +// ═══════════════════════════════════════════════════════════ + +/** A single message search result */ +private data class MessageSearchResult( + val messageId: String, + val dialogKey: String, + val opponentKey: String, + val opponentTitle: String, + val opponentUsername: String, + val plainText: String, + val timestamp: Long, + val fromMe: Boolean, + val verified: Int = 0 +) + +/** + * Optimized message search: loads messages in batches, decrypts plainMessage + * fields in parallel (Semaphore-limited), filters client-side, and caches + * decrypted text to avoid re-decryption on subsequent queries. + */ +@Composable +private fun MessagesTabContent( + searchQuery: String, + currentUserPublicKey: String, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color, + avatarRepository: AvatarRepository?, + searchLottieComposition: com.airbnb.lottie.LottieComposition?, + onUserSelect: (SearchUser) -> Unit +) { + val context = LocalContext.current + var results by remember { mutableStateOf>(emptyList()) } + var isSearching by remember { mutableStateOf(false) } + val dividerColor = remember(isDarkTheme) { + if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8) + } + + // Persistent decryption cache: messageId → plaintext (survives re-queries) + val decryptCache = remember { ConcurrentHashMap(512) } + // Cache for dialog metadata: opponentKey → (title, username, verified) + val dialogCache = remember { ConcurrentHashMap>() } + + val dateFormat = remember { SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) } + + // Debounced search: waits 600ms after typing stops, then searches + LaunchedEffect(searchQuery, currentUserPublicKey) { + results = emptyList() + if (searchQuery.length < 2 || currentUserPublicKey.isBlank()) { + isSearching = false + return@LaunchedEffect + } + isSearching = true + // Debounce + kotlinx.coroutines.delay(600) + + withContext(Dispatchers.IO) { + try { + val db = RosettaDatabase.getDatabase(context) + val repo = com.rosetta.messenger.data.MessageRepository.getInstance(context) + val privateKey = repo.getCurrentPrivateKey().orEmpty() + if (privateKey.isBlank()) { + isSearching = false + return@withContext + } + + // Pre-warm PBKDF2 cache for this password + CryptoManager.getPbkdf2Key(privateKey) + + // Load dialog metadata once + if (dialogCache.isEmpty()) { + val dialogs = db.dialogDao().getDialogsPaged(currentUserPublicKey, 500, 0) + for (d in dialogs) { + dialogCache[d.opponentKey] = Triple(d.opponentTitle, d.opponentUsername, d.verified) + } + } + + val queryLower = searchQuery.trim().lowercase() + val matched = mutableListOf() + val semaphore = Semaphore(4) + val batchSize = 200 + var offset = 0 + val maxMessages = 5000 // Safety cap + val maxResults = 50 // Don't return more than 50 matches + + while (offset < maxMessages && matched.size < maxResults) { + val batch = db.messageDao().getAllMessagesPaged( + currentUserPublicKey, batchSize, offset + ) + if (batch.isEmpty()) break + + // Decrypt in parallel, filter by query + val batchResults = kotlinx.coroutines.coroutineScope { + batch.chunked(20).flatMap { chunk -> + chunk.map { msg -> + async { + semaphore.withPermit { + val cached = decryptCache[msg.messageId] + val plain = if (cached != null) { + cached + } else { + val decrypted = try { + CryptoManager.decryptWithPassword( + msg.plainMessage, privateKey + ) + } catch (_: Exception) { null } + if (!decrypted.isNullOrBlank()) { + decryptCache[msg.messageId] = decrypted + } + decrypted + } + + if (!plain.isNullOrBlank() && plain.lowercase().contains(queryLower)) { + val opponent = if (msg.fromMe == 1) msg.toPublicKey else msg.fromPublicKey + val normalized = opponent.trim() + val meta = dialogCache[normalized] + MessageSearchResult( + messageId = msg.messageId, + dialogKey = msg.dialogKey, + opponentKey = normalized, + opponentTitle = meta?.first.orEmpty(), + opponentUsername = meta?.second.orEmpty(), + plainText = plain, + timestamp = msg.timestamp, + fromMe = msg.fromMe == 1, + verified = meta?.third ?: 0 + ) + } else null + } + } + }.awaitAll().filterNotNull() + } + } + + matched.addAll(batchResults) + offset += batchSize + } + + results = matched.take(maxResults) + } catch (_: Exception) { + results = emptyList() + } + isSearching = false + } + } + + Box(modifier = Modifier.fillMaxSize()) { + when { + searchQuery.length < 2 -> { + // Idle state — prompt to type + Column( + modifier = Modifier + .fillMaxSize() + .imePadding(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + if (searchLottieComposition != null) { + val progress by animateLottieCompositionAsState( + composition = searchLottieComposition, + iterations = 1, + isPlaying = true, + restartOnPlay = false + ) + LottieAnimation( + composition = searchLottieComposition, + progress = { progress }, + modifier = Modifier.size(100.dp) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Search in messages", + fontSize = 17.sp, + fontWeight = FontWeight.Medium, + color = textColor + ) + Spacer(modifier = Modifier.height(6.dp)) + Text( + text = "Type at least 2 characters", + fontSize = 14.sp, + color = secondaryTextColor + ) + } + } + isSearching -> { + // Loading state + Box( + modifier = Modifier + .fillMaxSize() + .imePadding(), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator( + modifier = Modifier.size(32.dp), + color = PrimaryBlue, + strokeWidth = 2.5.dp + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "Searching messages...", + fontSize = 14.sp, + color = secondaryTextColor + ) + } + } + } + results.isEmpty() -> { + // No results + Column( + modifier = Modifier + .fillMaxSize() + .imePadding(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.search_files_filled), + contentDescription = null, + tint = secondaryTextColor.copy(alpha = 0.4f), + modifier = Modifier.size(48.dp) + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = "No messages found", + fontSize = 17.sp, + fontWeight = FontWeight.Medium, + color = textColor.copy(alpha = 0.8f) + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = "Try a different search term", + fontSize = 14.sp, + color = secondaryTextColor + ) + } + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(vertical = 4.dp) + ) { + items(results, key = { it.messageId }) { result -> + MessageSearchResultItem( + result = result, + searchQuery = searchQuery, + dateFormat = dateFormat, + currentUserPublicKey = currentUserPublicKey, + isDarkTheme = isDarkTheme, + textColor = textColor, + secondaryTextColor = secondaryTextColor, + avatarRepository = avatarRepository, + onClick = { + val user = SearchUser( + title = result.opponentTitle, + username = result.opponentUsername, + publicKey = result.opponentKey, + verified = result.verified, + online = 0 + ) + onUserSelect(user) + } + ) + Divider( + color = dividerColor, + thickness = 0.5.dp, + modifier = Modifier.padding(start = 76.dp) + ) + } + } + } + } + } +} + +@Composable +private fun MessageSearchResultItem( + result: MessageSearchResult, + searchQuery: String, + dateFormat: SimpleDateFormat, + currentUserPublicKey: String, + isDarkTheme: Boolean, + textColor: Color, + secondaryTextColor: Color, + avatarRepository: AvatarRepository?, + onClick: () -> Unit +) { + val isGroup = result.opponentKey.startsWith("#group:") || result.opponentKey.startsWith("group:") + val displayName = when { + result.opponentKey == currentUserPublicKey -> "Saved Messages" + result.opponentTitle.isNotBlank() -> result.opponentTitle + result.opponentUsername.isNotBlank() -> result.opponentUsername + else -> result.opponentKey.take(8) + "..." + } + + // Build highlighted snippet: show text around the match + val snippet = remember(result.plainText, searchQuery) { + buildMessageSnippet(result.plainText, searchQuery) + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically + ) { + AvatarImage( + publicKey = result.opponentKey, + avatarRepository = avatarRepository, + size = 48.dp, + isDarkTheme = isDarkTheme, + showOnlineIndicator = false, + isOnline = false, + displayName = displayName + ) + + Spacer(modifier = Modifier.width(12.dp)) + + Column(modifier = Modifier.weight(1f)) { + // Top line: name + date + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.weight(1f, fill = false) + ) { + Text( + text = displayName, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = textColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (result.verified != 0) { + Spacer(modifier = Modifier.width(4.dp)) + VerifiedBadge(verified = result.verified, size = 16, isDarkTheme = isDarkTheme) + } + } + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = dateFormat.format(Date(result.timestamp)), + fontSize = 13.sp, + color = secondaryTextColor, + maxLines = 1 + ) + } + + Spacer(modifier = Modifier.height(2.dp)) + + // Bottom line: message snippet with highlighted match + val annotated = remember(snippet, searchQuery) { + buildHighlightedText(snippet, searchQuery, secondaryTextColor, PrimaryBlue) + } + Text( + text = annotated, + fontSize = 14.sp, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +/** Extract a snippet around the first match, ~80 chars context */ +private fun buildMessageSnippet(text: String, query: String): String { + val lower = text.lowercase() + val queryLower = query.trim().lowercase() + val idx = lower.indexOf(queryLower) + if (idx < 0) return text.take(100) + + val start = (idx - 30).coerceAtLeast(0) + val end = (idx + queryLower.length + 50).coerceAtMost(text.length) + val prefix = if (start > 0) "..." else "" + val suffix = if (end < text.length) "..." else "" + return prefix + text.substring(start, end).replace('\n', ' ') + suffix +} + +/** Build AnnotatedString with the query highlighted in blue */ +private fun buildHighlightedText( + text: String, + query: String, + baseColor: Color, + highlightColor: Color +) = buildAnnotatedString { + val lower = text.lowercase() + val queryLower = query.trim().lowercase() + var cursor = 0 + + while (cursor < text.length) { + val matchIdx = lower.indexOf(queryLower, cursor) + if (matchIdx < 0) { + withStyle(SpanStyle(color = baseColor)) { + append(text.substring(cursor)) + } + break + } + if (matchIdx > cursor) { + withStyle(SpanStyle(color = baseColor)) { + append(text.substring(cursor, matchIdx)) + } + } + withStyle(SpanStyle(color = highlightColor, fontWeight = FontWeight.SemiBold)) { + append(text.substring(matchIdx, matchIdx + queryLower.length)) + } + cursor = matchIdx + queryLower.length + } +} + // ═══════════════════════════════════════════════════════════ // 🖼️ MEDIA TAB — grid of images from all chats // ═══════════════════════════════════════════════════════════ diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index ac38855..e8c9368 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -376,6 +376,10 @@ fun MessageBubble( } else { null } + var suppressBubbleTapUntilMs by remember(message.id) { mutableLongStateOf(0L) } + val suppressBubbleTapFromSpan: () -> Unit = { + suppressBubbleTapUntilMs = System.currentTimeMillis() + 450L + } val timeColor = remember(message.isOutgoing, isDarkTheme) { @@ -781,7 +785,12 @@ fun MessageBubble( remember { MutableInteractionSource() }, - onClick = onClick, + onClick = { + if (System.currentTimeMillis() <= suppressBubbleTapUntilMs) { + return@combinedClickable + } + onClick() + }, onLongClick = onLongClick ) .then( @@ -894,7 +903,8 @@ fun MessageBubble( linksEnabled = linksEnabled, onImageClick = onImageClick, onForwardedSenderClick = onForwardedSenderClick, - onMentionClick = onMentionClick + onMentionClick = onMentionClick, + onTextSpanPressStart = suppressBubbleTapFromSpan ) Spacer(modifier = Modifier.height(4.dp)) } @@ -987,6 +997,8 @@ fun MessageBubble( true, onMentionClick = mentionClickHandler, + onClickableSpanPressStart = + suppressBubbleTapFromSpan, onClick = textClickHandler, onLongClick = @@ -1077,6 +1089,8 @@ fun MessageBubble( enableLinks = linksEnabled, enableMentions = true, onMentionClick = mentionClickHandler, + onClickableSpanPressStart = + suppressBubbleTapFromSpan, onClick = textClickHandler, onLongClick = onLongClick // 🔥 @@ -1179,6 +1193,8 @@ fun MessageBubble( enableLinks = linksEnabled, enableMentions = true, onMentionClick = mentionClickHandler, + onClickableSpanPressStart = + suppressBubbleTapFromSpan, onClick = textClickHandler, onLongClick = onLongClick // 🔥 @@ -2314,7 +2330,8 @@ fun ForwardedMessagesBubble( linksEnabled: Boolean = true, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, - onMentionClick: (username: String) -> Unit = {} + onMentionClick: (username: String) -> Unit = {}, + onTextSpanPressStart: (() -> Unit)? = null ) { val backgroundColor = if (isOutgoing) Color.Black.copy(alpha = 0.1f) @@ -2425,7 +2442,8 @@ fun ForwardedMessagesBubble( overflow = android.text.TextUtils.TruncateAt.END, enableLinks = linksEnabled, enableMentions = true, - onMentionClick = if (linksEnabled) onMentionClick else null + onMentionClick = if (linksEnabled) onMentionClick else null, + onClickableSpanPressStart = onTextSpanPressStart ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt index 43e286c..4a9c588 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/components/AppleEmojiEditText.kt @@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable import android.net.Uri import android.text.Editable import android.text.SpannableStringBuilder +import android.text.Spanned import android.text.TextPaint import android.text.TextWatcher import android.text.method.LinkMovementMethod @@ -395,6 +396,7 @@ fun AppleEmojiText( enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки enableMentions: Boolean = false, onMentionClick: ((String) -> Unit)? = null, + onClickableSpanPressStart: (() -> Unit)? = null, onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble) onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble) minHeightMultiplier: Float = 1.5f @@ -439,8 +441,16 @@ fun AppleEmojiText( setMentionColor(mentionColor.toArgb()) enableMentionHighlight(enableMentions) setOnMentionClickListener(onMentionClick) - // 🔥 Поддержка обычного tap (например, для selection mode) - setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } }) + setOnClickableSpanPressStartListener(onClickableSpanPressStart) + // In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap. + val canUseTextViewClick = !enableLinks + setOnClickListener( + if (canUseTextViewClick && onClick != null) { + View.OnClickListener { onClick.invoke() } + } else { + null + } + ) } }, update = { view -> @@ -464,8 +474,16 @@ fun AppleEmojiText( view.setMentionColor(mentionColor.toArgb()) view.enableMentionHighlight(enableMentions) view.setOnMentionClickListener(onMentionClick) - // 🔥 Обновляем tap callback, чтобы не было stale lambda - view.setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } }) + view.setOnClickableSpanPressStartListener(onClickableSpanPressStart) + // In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap. + val canUseTextViewClick = !enableLinks + view.setOnClickListener( + if (canUseTextViewClick && onClick != null) { + View.OnClickListener { onClick.invoke() } + } else { + null + } + ) }, modifier = modifier ) @@ -508,14 +526,19 @@ class AppleEmojiTextView @JvmOverloads constructor( private var mentionColorValue: Int = 0xFF54A9EB.toInt() private var mentionsEnabled: Boolean = false private var mentionClickCallback: ((String) -> Unit)? = null + private var clickableSpanPressStartCallback: (() -> Unit)? = null // 🔥 Long press callback для selection в MessageBubble var onLongClickCallback: (() -> Unit)? = null + private var downOnClickableSpan: Boolean = false + private var suppressPerformClickOnce: Boolean = false // 🔥 GestureDetector для обработки long press поверх LinkMovementMethod private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onLongPress(e: MotionEvent) { - onLongClickCallback?.invoke() + if (!downOnClickableSpan) { + onLongClickCallback?.invoke() + } } }) @@ -529,12 +552,64 @@ class AppleEmojiTextView @JvmOverloads constructor( * GestureDetector обрабатывает long press, затем передаем событие parent для ссылок */ override fun dispatchTouchEvent(event: MotionEvent): Boolean { + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downOnClickableSpan = isTouchOnClickableSpan(event) + suppressPerformClickOnce = downOnClickableSpan + if (downOnClickableSpan) { + clickableSpanPressStartCallback?.invoke() + parent?.requestDisallowInterceptTouchEvent(true) + } + } + MotionEvent.ACTION_CANCEL, + MotionEvent.ACTION_UP -> { + downOnClickableSpan = false + parent?.requestDisallowInterceptTouchEvent(false) + } + } + // Позволяем GestureDetector обработать событие (для long press) gestureDetector.onTouchEvent(event) // Передаем событие дальше для обработки ссылок return super.dispatchTouchEvent(event) } + override fun performClick(): Boolean { + if (suppressPerformClickOnce) { + suppressPerformClickOnce = false + return true + } + return super.performClick() + } + + private fun isTouchOnClickableSpan(event: MotionEvent): Boolean { + val currentText = text as? Spanned ?: return false + val hasClickableAtOffset: (Int) -> Boolean = { offset -> + if (offset < 0 || offset > currentText.length) { + false + } else { + val start = (offset - 1).coerceAtLeast(0) + val end = (offset + 1).coerceAtMost(currentText.length) + currentText.getSpans(start, end, ClickableSpan::class.java).isNotEmpty() + } + } + + val directOffset = runCatching { getOffsetForPosition(event.x, event.y) }.getOrNull() + if (directOffset != null && hasClickableAtOffset(directOffset)) { + return true + } + + val textLayout = layout ?: return false + val x = (event.x - totalPaddingLeft + scrollX).toInt() + val y = (event.y - totalPaddingTop + scrollY).toInt() + if (x < 0 || y < 0 || x > textLayout.width || y > textLayout.height) return false + + val line = textLayout.getLineForVertical(y) + val horizontal = x.toFloat().coerceIn(textLayout.getLineLeft(line), textLayout.getLineRight(line)) + val layoutOffset = textLayout.getOffsetForHorizontal(line, horizontal) + return hasClickableAtOffset(layoutOffset) + } + /** * 🔥 Установить цвет для ссылок */ @@ -556,6 +631,10 @@ class AppleEmojiTextView @JvmOverloads constructor( updateMovementMethod() } + fun setOnClickableSpanPressStartListener(listener: (() -> Unit)?) { + clickableSpanPressStartCallback = listener + } + /** * 🔥 Включить/выключить кликабельные ссылки * @param enable - включить ссылки diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt index fe3cf59..2aeca12 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeWallpapers.kt @@ -7,6 +7,7 @@ data class ThemeWallpaper( val id: String, val name: String, val preferredTheme: WallpaperTheme, + val pairGroup: String, @DrawableRes val drawableRes: Int ) @@ -23,77 +24,119 @@ object ThemeWallpapers { id = "back_3", name = "Wallpaper 1", preferredTheme = WallpaperTheme.DARK, + pairGroup = "pair_1", drawableRes = R.drawable.wallpaper_back_3 ), ThemeWallpaper( id = "back_4", name = "Wallpaper 2", preferredTheme = WallpaperTheme.LIGHT, + pairGroup = "pair_1", drawableRes = R.drawable.wallpaper_back_4 ), ThemeWallpaper( id = "back_5", name = "Wallpaper 3", preferredTheme = WallpaperTheme.DARK, + pairGroup = "pair_2", drawableRes = R.drawable.wallpaper_back_5 ), ThemeWallpaper( id = "back_6", name = "Wallpaper 4", preferredTheme = WallpaperTheme.LIGHT, + pairGroup = "pair_2", drawableRes = R.drawable.wallpaper_back_6 ), ThemeWallpaper( id = "back_7", name = "Wallpaper 5", preferredTheme = WallpaperTheme.LIGHT, + pairGroup = "pair_2", drawableRes = R.drawable.wallpaper_back_7 ), ThemeWallpaper( id = "back_8", name = "Wallpaper 6", preferredTheme = WallpaperTheme.LIGHT, + pairGroup = "pair_3", drawableRes = R.drawable.wallpaper_back_8 ), ThemeWallpaper( id = "back_9", name = "Wallpaper 7", preferredTheme = WallpaperTheme.LIGHT, + pairGroup = "pair_1", drawableRes = R.drawable.wallpaper_back_9 ), ThemeWallpaper( id = "back_10", name = "Wallpaper 8", preferredTheme = WallpaperTheme.LIGHT, + pairGroup = "pair_4", drawableRes = R.drawable.wallpaper_back_10 ), ThemeWallpaper( id = "back_11", name = "Wallpaper 9", preferredTheme = WallpaperTheme.DARK, + pairGroup = "pair_3", drawableRes = R.drawable.wallpaper_back_11 ), ThemeWallpaper( id = "back_1", name = "Wallpaper 10", preferredTheme = WallpaperTheme.LIGHT, + pairGroup = "pair_3", drawableRes = R.drawable.wallpaper_back_1 ), ThemeWallpaper( id = "back_2", name = "Wallpaper 11", preferredTheme = WallpaperTheme.DARK, + pairGroup = "pair_4", drawableRes = R.drawable.wallpaper_back_2 ) ) - fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id } + private val byId: Map = all.associateBy { it.id } + private val byPairGroup: Map> = all.groupBy { it.pairGroup } + + fun findById(id: String): ThemeWallpaper? = byId[id] fun forTheme(isDarkTheme: Boolean): List { val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT return all.filter { it.preferredTheme == targetTheme } } + fun mapToTheme(wallpaperId: String, isDarkTheme: Boolean): String { + if (wallpaperId.isBlank()) return "" + + val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT + val wallpaper = findById(wallpaperId) ?: return wallpaperId + if (wallpaper.preferredTheme == targetTheme) return wallpaperId + + val fromPair = + byPairGroup[wallpaper.pairGroup] + ?.firstOrNull { it.preferredTheme == targetTheme } + ?.id + if (!fromPair.isNullOrBlank()) return fromPair + + return all.firstOrNull { it.preferredTheme == targetTheme }?.id ?: wallpaperId + } + + fun resolveWallpaperForTheme( + currentWallpaperId: String, + isDarkTheme: Boolean, + darkThemeWallpaperId: String, + lightThemeWallpaperId: String + ): String { + val savedForTargetTheme = if (isDarkTheme) darkThemeWallpaperId else lightThemeWallpaperId + val mappedSaved = mapToTheme(savedForTargetTheme, isDarkTheme) + if (mappedSaved.isNotBlank()) return mappedSaved + return mapToTheme(currentWallpaperId, isDarkTheme) + } + @DrawableRes fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes }