Большой пакет: поиск по сообщениям, клики по тэгам, темы обоев и 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:
@@ -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)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
// Удаляем старое сообщение
|
||||
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 - включить ссылки
|
||||
|
||||
@@ -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<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> {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user