Большой пакет: поиск по сообщениям, клики по тэгам, темы обоев и UX-фиксы

Что вошло:\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.
This commit is contained in:
2026-03-21 21:12:52 +05:00
parent c929685e04
commit 9d3e5bcb10
14 changed files with 1100 additions and 108 deletions

View File

@@ -129,9 +129,6 @@ dependencies {
implementation("io.coil-kt:coil-compose:2.5.0") implementation("io.coil-kt:coil-compose:2.5.0")
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support 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 // uCrop for image cropping
implementation("com.github.yalantis:ucrop:2.2.8") implementation("com.github.yalantis:ucrop:2.2.8")

View File

@@ -58,6 +58,7 @@ import com.rosetta.messenger.ui.settings.OtherProfileScreen
import com.rosetta.messenger.ui.settings.ProfileScreen import com.rosetta.messenger.ui.settings.ProfileScreen
import com.rosetta.messenger.ui.settings.SafetyScreen import com.rosetta.messenger.ui.settings.SafetyScreen
import com.rosetta.messenger.ui.settings.ThemeScreen 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.settings.UpdatesScreen
import com.rosetta.messenger.ui.splash.SplashScreen import com.rosetta.messenger.ui.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
@@ -743,6 +744,8 @@ fun MainScreen(
.backgroundBlurColorIdForAccount(accountPublicKey) .backgroundBlurColorIdForAccount(accountPublicKey)
.collectAsState(initial = "avatar") .collectAsState(initial = "avatar")
val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "") 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()) val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
// AvatarRepository для работы с аватарами // AvatarRepository для работы с аватарами
@@ -763,6 +766,29 @@ fun MainScreen(
// Coroutine scope for profile updates // Coroutine scope for profile updates
val mainScreenScope = rememberCoroutineScope() 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 // 🔥 Простая навигация с swipe back
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
// Base layer - chats list (всегда видимый, чтобы его было видно при свайпе) // Base layer - chats list (всегда видимый, чтобы его было видно при свайпе)
@@ -971,7 +997,13 @@ fun MainScreen(
onBack = { navStack = navStack.filterNot { it is Screen.Theme } }, onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
onThemeModeChange = onThemeModeChange, onThemeModeChange = onThemeModeChange,
onWallpaperChange = { wallpaperId -> 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 biometricAccountManager = remember { AccountManager(context) }
val activity = context as? FragmentActivity val activity = context as? FragmentActivity
val isFingerprintSupported = remember {
biometricManager.isFingerprintHardwareAvailable()
}
if (!isFingerprintSupported) {
LaunchedEffect(Unit) {
navStack = navStack.filterNot { it is Screen.Biometric }
}
return@SwipeBackContainer
}
BiometricEnableScreen( BiometricEnableScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,

View File

@@ -1,6 +1,7 @@
package com.rosetta.messenger.biometric package com.rosetta.messenger.biometric
import android.content.Context import android.content.Context
import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.KeyPermanentlyInvalidatedException
@@ -52,7 +53,15 @@ class BiometricAuthManager(private val context: Context) {
* Проверяет доступность STRONG биометрической аутентификации * Проверяет доступность STRONG биометрической аутентификации
* BIOMETRIC_STRONG требует Class 3 биометрию (отпечаток/лицо с криптографической привязкой) * BIOMETRIC_STRONG требует Class 3 биометрию (отпечаток/лицо с криптографической привязкой)
*/ */
fun isFingerprintHardwareAvailable(): Boolean {
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
}
fun isBiometricAvailable(): BiometricAvailability { fun isBiometricAvailable(): BiometricAvailability {
if (!isFingerprintHardwareAvailable()) {
return BiometricAvailability.NotAvailable("Отпечаток пальца не поддерживается")
}
val biometricManager = BiometricManager.from(context) val biometricManager = BiometricManager.from(context)
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) { return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {

View File

@@ -1592,7 +1592,7 @@ object MessageCrypto {
// Reset bounds to default after first continuation // Reset bounds to default after first continuation
lowerBoundary = 0x80 lowerBoundary = 0x80
upperBoundary = 0xBF upperBoundary = 0xBF
// test
if (bytesSeen == bytesNeeded) { if (bytesSeen == bytesNeeded) {
// Sequence complete — emit code point // Sequence complete — emit code point
if (codePoint <= 0xFFFF) { if (codePoint <= 0xFFFF) {

View File

@@ -28,6 +28,8 @@ class PreferencesManager(private val context: Context) {
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme") val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto" val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper 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 // Notifications
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled") val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
@@ -104,10 +106,21 @@ class PreferencesManager(private val context: Context) {
val chatWallpaperId: Flow<String> = val chatWallpaperId: Flow<String> =
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" } context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
val chatWallpaperIdLight: Flow<String> =
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_LIGHT] ?: "" }
val chatWallpaperIdDark: Flow<String> =
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_DARK] ?: "" }
suspend fun setChatWallpaperId(value: String) { suspend fun setChatWallpaperId(value: String) {
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value } 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 // 🔔 NOTIFICATIONS
// ═════════════════════════════════════════════════════════════ // ═════════════════════════════════════════════════════════════

View File

@@ -558,6 +558,18 @@ interface MessageDao {
""" """
) )
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity> suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
@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<MessageEntity>
} }
/** DAO для работы с диалогами */ /** DAO для работы с диалогами */
@@ -658,6 +670,18 @@ interface DialogDao {
@Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1") @Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1")
suspend fun getDialog(account: String, opponentKey: String): DialogEntity? 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( @Query(
""" """

View File

@@ -80,6 +80,12 @@ object ProtocolManager {
private val userInfoCache = ConcurrentHashMap<String, SearchUser>() private val userInfoCache = ConcurrentHashMap<String, SearchUser>()
// Pending resolves: publicKey → list of continuations waiting for the result // Pending resolves: publicKey → list of continuations waiting for the result
private val pendingResolves = ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<SearchUser?>>>() private val pendingResolves = ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<SearchUser?>>>()
// Pending search requests: query(username/publicKey fragment) → waiting continuations
private val pendingSearchQueries =
ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>>()
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. // UI logs are enabled by default; updates are throttled and bounded by MAX_DEBUG_LOGS.
private var uiLogsEnabled = true private var uiLogsEnabled = true
@@ -473,6 +479,56 @@ object ProtocolManager {
try { cont.resume(null) } catch (_: Exception) {} 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<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>()
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) // 🚀 Обработчик транспортного сервера (0x0F)
@@ -973,6 +1029,18 @@ object ProtocolManager {
return userInfoCache[publicKey] 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) * 🔍 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<SearchUser> {
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. * Accept a pending device login request.
*/ */

View File

@@ -31,6 +31,7 @@ import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.* 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.platform.LocalView
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp 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.data.MessageRepository
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
@@ -411,6 +414,57 @@ fun ChatDetailScreen(
val chatTitle = val chatTitle =
if (isSavedMessages) "Saved Messages" if (isSavedMessages) "Saved Messages"
else user.title.ifEmpty { user.publicKey.take(10) } 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 = { val openDialogInfo: () -> Unit = {
hideInputOverlays() hideInputOverlays()
@@ -1725,7 +1779,7 @@ fun ChatDetailScreen(
) )
if (!isSavedMessages && if (!isSavedMessages &&
!isGroupChat && !isGroupChat &&
(user.verified > (chatHeaderVerified >
0 || isRosettaOfficial) 0 || isRosettaOfficial)
) { ) {
Spacer( Spacer(
@@ -1736,7 +1790,7 @@ fun ChatDetailScreen(
) )
VerifiedBadge( VerifiedBadge(
verified = verified =
if (user.verified > 0) user.verified else 1, if (chatHeaderVerified > 0) chatHeaderVerified else 1,
size = size =
16, 16,
isDarkTheme = isDarkTheme =
@@ -2526,102 +2580,184 @@ fun ChatDetailScreen(
verticalArrangement = verticalArrangement =
Arrangement.Center Arrangement.Center
) { ) {
if (isSavedMessages) { val showSavedMessagesBackdrop =
val composition by isSavedMessages &&
rememberLottieComposition( hasChatWallpaper
LottieCompositionSpec val savedMessagesBackdropShape =
.RawRes( RoundedCornerShape(
R.raw.saved 22.dp
) )
val savedMessagesBackdropColor =
if (isDarkTheme)
Color(
0xB3212121
) )
val progress by else
animateLottieCompositionAsState( 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 =
composition, composition,
iterations = progress = {
LottieConstants progress
.IterateForever },
modifier =
Modifier.size(
120.dp
)
) )
LottieAnimation( } else {
composition = val composition by
composition, rememberLottieComposition(
progress = { LottieCompositionSpec
progress .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 =
Modifier.size( Modifier.height(
120.dp 16.dp
) )
) )
} else { Text(
val composition by text =
rememberLottieComposition( if (isSavedMessages
LottieCompositionSpec
.RawRes(
R.raw.speech
)
)
val progress by
animateLottieCompositionAsState(
composition =
composition,
iterations =
LottieConstants
.IterateForever
)
LottieAnimation(
composition =
composition,
progress = {
progress
},
modifier =
Modifier.size(
120.dp
) )
"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 = val normalizedUsername =
username.trim().trimStart('@').lowercase(Locale.ROOT) username.trim().trimStart('@').lowercase(Locale.ROOT)
if (normalizedUsername.isBlank()) return@MessageBubble if (normalizedUsername.isBlank()) return@MessageBubble
// Mention tap should not trigger bubble context-menu tap.
suppressTapAfterLongPress(selectionKey)
scope.launch { scope.launch {
val normalizedCurrentUsername = val normalizedCurrentUsername =
currentUserUsername.trim().trimStart('@').lowercase(Locale.ROOT) 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)) { if (targetPublicKey.equals(currentUserPublicKey.trim(), ignoreCase = true)) {
showContextMenu = false showContextMenu = false
contextMenuMessage = null contextMenuMessage = null
onUserProfileClick( onNavigateToChat(
SearchUser( SearchUser(
title = currentUserName.ifBlank { "You" }, title = currentUserName.ifBlank { "You" },
username = currentUserUsername.trim().trimStart('@'), username = currentUserUsername.trim().trimStart('@'),
@@ -3027,7 +3174,7 @@ fun ChatDetailScreen(
if (resolvedUser != null) { if (resolvedUser != null) {
showContextMenu = false showContextMenu = false
contextMenuMessage = null contextMenuMessage = null
onUserProfileClick(resolvedUser) onNavigateToChat(resolvedUser)
} }
} }
}, },

View File

@@ -17,6 +17,7 @@ import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.MessageLogger import com.rosetta.messenger.utils.MessageLogger
import com.rosetta.messenger.utils.MessageThrottleManager import com.rosetta.messenger.utils.MessageThrottleManager
import java.util.Date import java.util.Date
import java.util.Locale
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.* 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) { fun retryMessage(message: ChatMessage) {
// Удаляем старое сообщение // Удаляем старое сообщение

View File

@@ -1061,7 +1061,16 @@ fun ChatsListScreen(
painter = TelegramIcons.Done, painter = TelegramIcons.Done,
contentDescription = null, contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(8.dp) modifier =
Modifier.size(
8.dp
)
.offset(
x =
0.3.dp,
y =
0.dp
)
) )
} }
} }

View File

@@ -63,6 +63,9 @@ import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
import kotlinx.coroutines.Dispatchers 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.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
@@ -70,6 +73,11 @@ import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale 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 // Primary Blue color
private val PrimaryBlue = Color(0xFF54A9EB) private val PrimaryBlue = Color(0xFF54A9EB)
@@ -77,6 +85,7 @@ private val PrimaryBlue = Color(0xFF54A9EB)
/** Вкладки поиска как в Telegram */ /** Вкладки поиска как в Telegram */
private enum class SearchTab(val title: String) { private enum class SearchTab(val title: String) {
CHATS("Chats"), CHATS("Chats"),
MESSAGES("Messages"),
MEDIA("Media"), MEDIA("Media"),
DOWNLOADS("Downloads"), DOWNLOADS("Downloads"),
FILES("Files") FILES("Files")
@@ -382,6 +391,18 @@ fun SearchScreen(
onUserSelect = onUserSelect onUserSelect = onUserSelect
) )
} }
SearchTab.MESSAGES -> {
MessagesTabContent(
searchQuery = searchQuery,
currentUserPublicKey = currentUserPublicKey,
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
avatarRepository = avatarRepository,
searchLottieComposition = searchLottieComposition,
onUserSelect = onUserSelect
)
}
SearchTab.MEDIA -> { SearchTab.MEDIA -> {
MediaTabContent( MediaTabContent(
currentUserPublicKey = currentUserPublicKey, currentUserPublicKey = currentUserPublicKey,
@@ -639,14 +660,35 @@ private fun ChatsTabContent(
} else { } else {
// ═══ Search results ═══ // ═══ Search results ═══
val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase() val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase()
val compactQuery = normalizedQuery.replace(Regex("\\s+"), " ").trim()
val normalizedPublicKey = currentUserPublicKey.lowercase() val normalizedPublicKey = currentUserPublicKey.lowercase()
val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase() val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase()
val normalizedName = ownAccountName.trim().lowercase() val normalizedName = ownAccountName.trim().lowercase()
val hasValidOwnName = val hasValidOwnName =
ownAccountName.isNotBlank() && !isPlaceholderAccountName(ownAccountName) 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 = val isSavedMessagesSearch =
normalizedQuery.isNotEmpty() && normalizedQuery.isNotEmpty() &&
(normalizedPublicKey == normalizedQuery || (isSavedAliasSearch ||
normalizedPublicKey == normalizedQuery ||
normalizedPublicKey.startsWith(normalizedQuery) || normalizedPublicKey.startsWith(normalizedQuery) ||
normalizedPublicKey.take(8) == normalizedQuery || normalizedPublicKey.take(8) == normalizedQuery ||
normalizedPublicKey.takeLast(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<List<MessageSearchResult>>(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<String, String>(512) }
// Cache for dialog metadata: opponentKey → (title, username, verified)
val dialogCache = remember { ConcurrentHashMap<String, Triple<String, String, Int>>() }
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<MessageSearchResult>()
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 // 🖼️ MEDIA TAB — grid of images from all chats
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════

View File

@@ -376,6 +376,10 @@ fun MessageBubble(
} else { } else {
null null
} }
var suppressBubbleTapUntilMs by remember(message.id) { mutableLongStateOf(0L) }
val suppressBubbleTapFromSpan: () -> Unit = {
suppressBubbleTapUntilMs = System.currentTimeMillis() + 450L
}
val timeColor = val timeColor =
remember(message.isOutgoing, isDarkTheme) { remember(message.isOutgoing, isDarkTheme) {
@@ -781,7 +785,12 @@ fun MessageBubble(
remember { remember {
MutableInteractionSource() MutableInteractionSource()
}, },
onClick = onClick, onClick = {
if (System.currentTimeMillis() <= suppressBubbleTapUntilMs) {
return@combinedClickable
}
onClick()
},
onLongClick = onLongClick onLongClick = onLongClick
) )
.then( .then(
@@ -894,7 +903,8 @@ fun MessageBubble(
linksEnabled = linksEnabled, linksEnabled = linksEnabled,
onImageClick = onImageClick, onImageClick = onImageClick,
onForwardedSenderClick = onForwardedSenderClick, onForwardedSenderClick = onForwardedSenderClick,
onMentionClick = onMentionClick onMentionClick = onMentionClick,
onTextSpanPressStart = suppressBubbleTapFromSpan
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
} }
@@ -987,6 +997,8 @@ fun MessageBubble(
true, true,
onMentionClick = onMentionClick =
mentionClickHandler, mentionClickHandler,
onClickableSpanPressStart =
suppressBubbleTapFromSpan,
onClick = onClick =
textClickHandler, textClickHandler,
onLongClick = onLongClick =
@@ -1077,6 +1089,8 @@ fun MessageBubble(
enableLinks = linksEnabled, enableLinks = linksEnabled,
enableMentions = true, enableMentions = true,
onMentionClick = mentionClickHandler, onMentionClick = mentionClickHandler,
onClickableSpanPressStart =
suppressBubbleTapFromSpan,
onClick = textClickHandler, onClick = textClickHandler,
onLongClick = onLongClick =
onLongClick // 🔥 onLongClick // 🔥
@@ -1179,6 +1193,8 @@ fun MessageBubble(
enableLinks = linksEnabled, enableLinks = linksEnabled,
enableMentions = true, enableMentions = true,
onMentionClick = mentionClickHandler, onMentionClick = mentionClickHandler,
onClickableSpanPressStart =
suppressBubbleTapFromSpan,
onClick = textClickHandler, onClick = textClickHandler,
onLongClick = onLongClick =
onLongClick // 🔥 onLongClick // 🔥
@@ -2314,7 +2330,8 @@ fun ForwardedMessagesBubble(
linksEnabled: Boolean = true, linksEnabled: Boolean = true,
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> }, onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {}, onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
onMentionClick: (username: String) -> Unit = {} onMentionClick: (username: String) -> Unit = {},
onTextSpanPressStart: (() -> Unit)? = null
) { ) {
val backgroundColor = val backgroundColor =
if (isOutgoing) Color.Black.copy(alpha = 0.1f) if (isOutgoing) Color.Black.copy(alpha = 0.1f)
@@ -2425,7 +2442,8 @@ fun ForwardedMessagesBubble(
overflow = android.text.TextUtils.TruncateAt.END, overflow = android.text.TextUtils.TruncateAt.END,
enableLinks = linksEnabled, enableLinks = linksEnabled,
enableMentions = true, enableMentions = true,
onMentionClick = if (linksEnabled) onMentionClick else null onMentionClick = if (linksEnabled) onMentionClick else null,
onClickableSpanPressStart = onTextSpanPressStart
) )
} }
} }

View File

@@ -10,6 +10,7 @@ import android.graphics.drawable.Drawable
import android.net.Uri import android.net.Uri
import android.text.Editable import android.text.Editable
import android.text.SpannableStringBuilder import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.TextPaint import android.text.TextPaint
import android.text.TextWatcher import android.text.TextWatcher
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
@@ -395,6 +396,7 @@ fun AppleEmojiText(
enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки
enableMentions: Boolean = false, enableMentions: Boolean = false,
onMentionClick: ((String) -> Unit)? = null, onMentionClick: ((String) -> Unit)? = null,
onClickableSpanPressStart: (() -> Unit)? = null,
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble) onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble) onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
minHeightMultiplier: Float = 1.5f minHeightMultiplier: Float = 1.5f
@@ -439,8 +441,16 @@ fun AppleEmojiText(
setMentionColor(mentionColor.toArgb()) setMentionColor(mentionColor.toArgb())
enableMentionHighlight(enableMentions) enableMentionHighlight(enableMentions)
setOnMentionClickListener(onMentionClick) setOnMentionClickListener(onMentionClick)
// 🔥 Поддержка обычного tap (например, для selection mode) setOnClickableSpanPressStartListener(onClickableSpanPressStart)
setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } }) // 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 -> update = { view ->
@@ -464,8 +474,16 @@ fun AppleEmojiText(
view.setMentionColor(mentionColor.toArgb()) view.setMentionColor(mentionColor.toArgb())
view.enableMentionHighlight(enableMentions) view.enableMentionHighlight(enableMentions)
view.setOnMentionClickListener(onMentionClick) view.setOnMentionClickListener(onMentionClick)
// 🔥 Обновляем tap callback, чтобы не было stale lambda view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
view.setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } }) // 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 modifier = modifier
) )
@@ -508,14 +526,19 @@ class AppleEmojiTextView @JvmOverloads constructor(
private var mentionColorValue: Int = 0xFF54A9EB.toInt() private var mentionColorValue: Int = 0xFF54A9EB.toInt()
private var mentionsEnabled: Boolean = false private var mentionsEnabled: Boolean = false
private var mentionClickCallback: ((String) -> Unit)? = null private var mentionClickCallback: ((String) -> Unit)? = null
private var clickableSpanPressStartCallback: (() -> Unit)? = null
// 🔥 Long press callback для selection в MessageBubble // 🔥 Long press callback для selection в MessageBubble
var onLongClickCallback: (() -> Unit)? = null var onLongClickCallback: (() -> Unit)? = null
private var downOnClickableSpan: Boolean = false
private var suppressPerformClickOnce: Boolean = false
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod // 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onLongPress(e: MotionEvent) { override fun onLongPress(e: MotionEvent) {
onLongClickCallback?.invoke() if (!downOnClickableSpan) {
onLongClickCallback?.invoke()
}
} }
}) })
@@ -529,12 +552,64 @@ class AppleEmojiTextView @JvmOverloads constructor(
* GestureDetector обрабатывает long press, затем передаем событие parent для ссылок * GestureDetector обрабатывает long press, затем передаем событие parent для ссылок
*/ */
override fun dispatchTouchEvent(event: MotionEvent): Boolean { 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 обработать событие (для long press)
gestureDetector.onTouchEvent(event) gestureDetector.onTouchEvent(event)
// Передаем событие дальше для обработки ссылок // Передаем событие дальше для обработки ссылок
return super.dispatchTouchEvent(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() updateMovementMethod()
} }
fun setOnClickableSpanPressStartListener(listener: (() -> Unit)?) {
clickableSpanPressStartCallback = listener
}
/** /**
* 🔥 Включить/выключить кликабельные ссылки * 🔥 Включить/выключить кликабельные ссылки
* @param enable - включить ссылки * @param enable - включить ссылки

View File

@@ -7,6 +7,7 @@ data class ThemeWallpaper(
val id: String, val id: String,
val name: String, val name: String,
val preferredTheme: WallpaperTheme, val preferredTheme: WallpaperTheme,
val pairGroup: String,
@DrawableRes val drawableRes: Int @DrawableRes val drawableRes: Int
) )
@@ -23,77 +24,119 @@ object ThemeWallpapers {
id = "back_3", id = "back_3",
name = "Wallpaper 1", name = "Wallpaper 1",
preferredTheme = WallpaperTheme.DARK, preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_1",
drawableRes = R.drawable.wallpaper_back_3 drawableRes = R.drawable.wallpaper_back_3
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_4", id = "back_4",
name = "Wallpaper 2", name = "Wallpaper 2",
preferredTheme = WallpaperTheme.LIGHT, preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_1",
drawableRes = R.drawable.wallpaper_back_4 drawableRes = R.drawable.wallpaper_back_4
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_5", id = "back_5",
name = "Wallpaper 3", name = "Wallpaper 3",
preferredTheme = WallpaperTheme.DARK, preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_back_5 drawableRes = R.drawable.wallpaper_back_5
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_6", id = "back_6",
name = "Wallpaper 4", name = "Wallpaper 4",
preferredTheme = WallpaperTheme.LIGHT, preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_back_6 drawableRes = R.drawable.wallpaper_back_6
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_7", id = "back_7",
name = "Wallpaper 5", name = "Wallpaper 5",
preferredTheme = WallpaperTheme.LIGHT, preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_2",
drawableRes = R.drawable.wallpaper_back_7 drawableRes = R.drawable.wallpaper_back_7
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_8", id = "back_8",
name = "Wallpaper 6", name = "Wallpaper 6",
preferredTheme = WallpaperTheme.LIGHT, preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_3",
drawableRes = R.drawable.wallpaper_back_8 drawableRes = R.drawable.wallpaper_back_8
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_9", id = "back_9",
name = "Wallpaper 7", name = "Wallpaper 7",
preferredTheme = WallpaperTheme.LIGHT, preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_1",
drawableRes = R.drawable.wallpaper_back_9 drawableRes = R.drawable.wallpaper_back_9
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_10", id = "back_10",
name = "Wallpaper 8", name = "Wallpaper 8",
preferredTheme = WallpaperTheme.LIGHT, preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_4",
drawableRes = R.drawable.wallpaper_back_10 drawableRes = R.drawable.wallpaper_back_10
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_11", id = "back_11",
name = "Wallpaper 9", name = "Wallpaper 9",
preferredTheme = WallpaperTheme.DARK, preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_3",
drawableRes = R.drawable.wallpaper_back_11 drawableRes = R.drawable.wallpaper_back_11
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_1", id = "back_1",
name = "Wallpaper 10", name = "Wallpaper 10",
preferredTheme = WallpaperTheme.LIGHT, preferredTheme = WallpaperTheme.LIGHT,
pairGroup = "pair_3",
drawableRes = R.drawable.wallpaper_back_1 drawableRes = R.drawable.wallpaper_back_1
), ),
ThemeWallpaper( ThemeWallpaper(
id = "back_2", id = "back_2",
name = "Wallpaper 11", name = "Wallpaper 11",
preferredTheme = WallpaperTheme.DARK, preferredTheme = WallpaperTheme.DARK,
pairGroup = "pair_4",
drawableRes = R.drawable.wallpaper_back_2 drawableRes = R.drawable.wallpaper_back_2
) )
) )
fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id } private val byId: Map<String, ThemeWallpaper> = all.associateBy { it.id }
private val byPairGroup: Map<String, List<ThemeWallpaper>> = all.groupBy { it.pairGroup }
fun findById(id: String): ThemeWallpaper? = byId[id]
fun forTheme(isDarkTheme: Boolean): List<ThemeWallpaper> { fun forTheme(isDarkTheme: Boolean): List<ThemeWallpaper> {
val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT
return all.filter { it.preferredTheme == targetTheme } 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 @DrawableRes
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
} }