Исправлены синхронизация групп, выделение сообщений и фон чата
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package com.rosetta.messenger.data
|
package com.rosetta.messenger.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
import com.rosetta.messenger.crypto.CryptoManager
|
||||||
import com.rosetta.messenger.database.GroupEntity
|
import com.rosetta.messenger.database.GroupEntity
|
||||||
import com.rosetta.messenger.database.MessageEntity
|
import com.rosetta.messenger.database.MessageEntity
|
||||||
@@ -23,6 +24,7 @@ import kotlin.coroutines.resume
|
|||||||
|
|
||||||
class GroupRepository private constructor(context: Context) {
|
class GroupRepository private constructor(context: Context) {
|
||||||
|
|
||||||
|
private val appContext = context.applicationContext
|
||||||
private val db = RosettaDatabase.getDatabase(context.applicationContext)
|
private val db = RosettaDatabase.getDatabase(context.applicationContext)
|
||||||
private val groupDao = db.groupDao()
|
private val groupDao = db.groupDao()
|
||||||
private val messageDao = db.messageDao()
|
private val messageDao = db.messageDao()
|
||||||
@@ -31,9 +33,11 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
|
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
private const val TAG = "GroupRepository"
|
||||||
private const val GROUP_PREFIX = "#group:"
|
private const val GROUP_PREFIX = "#group:"
|
||||||
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
|
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
|
||||||
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
|
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
|
||||||
|
private const val GROUP_CREATED_MARKER = "\$a=Group created"
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var INSTANCE: GroupRepository? = null
|
private var INSTANCE: GroupRepository? = null
|
||||||
@@ -232,7 +236,20 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
return GroupJoinResult(success = false, error = "Failed to construct invite")
|
return GroupJoinResult(success = false, error = "Failed to construct invite")
|
||||||
}
|
}
|
||||||
|
|
||||||
return joinGroup(accountPublicKey, accountPrivateKey, invite)
|
val joinResult = joinGroup(accountPublicKey, accountPrivateKey, invite)
|
||||||
|
|
||||||
|
if (joinResult.success) {
|
||||||
|
val dialogPublicKey = joinResult.dialogPublicKey
|
||||||
|
if (!dialogPublicKey.isNullOrBlank()) {
|
||||||
|
emitGroupCreatedMarker(
|
||||||
|
accountPublicKey = accountPublicKey,
|
||||||
|
accountPrivateKey = accountPrivateKey,
|
||||||
|
dialogPublicKey = dialogPublicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return joinResult
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun joinGroup(
|
suspend fun joinGroup(
|
||||||
@@ -455,6 +472,23 @@ class GroupRepository private constructor(context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun emitGroupCreatedMarker(
|
||||||
|
accountPublicKey: String,
|
||||||
|
accountPrivateKey: String,
|
||||||
|
dialogPublicKey: String
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val messages = MessageRepository.getInstance(appContext)
|
||||||
|
messages.initialize(accountPublicKey, accountPrivateKey)
|
||||||
|
messages.sendMessage(
|
||||||
|
toPublicKey = dialogPublicKey,
|
||||||
|
text = GROUP_CREATED_MARKER
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to emit group-created marker for sync visibility", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
|
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
|
||||||
val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey)
|
val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey)
|
||||||
return "group:$encrypted"
|
return "group:$encrypted"
|
||||||
|
|||||||
@@ -767,7 +767,7 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
// 📸 Обрабатываем AVATAR attachments:
|
// 📸 Обрабатываем AVATAR attachments:
|
||||||
// в личке — сохраняем аватар отправителя, в группе — аватар группы (desktop parity)
|
// в личке — сохраняем аватар отправителя, в группе — аватар группы (desktop parity)
|
||||||
val avatarOwnerKey =
|
val avatarOwnerKey =
|
||||||
if (isGroupMessage) packet.toPublicKey else packet.fromPublicKey
|
if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey
|
||||||
processAvatarAttachments(
|
processAvatarAttachments(
|
||||||
packet.attachments,
|
packet.attachments,
|
||||||
avatarOwnerKey,
|
avatarOwnerKey,
|
||||||
@@ -1179,6 +1179,11 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun toGroupDialogPublicKey(value: String): String {
|
||||||
|
val groupId = normalizeGroupId(value)
|
||||||
|
return if (groupId.isBlank()) value.trim() else "#group:$groupId"
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
|
private fun buildStoredGroupKey(groupKey: String, privateKey: String): String {
|
||||||
return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
|
return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,18 @@ interface AvatarDao {
|
|||||||
@Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC")
|
@Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC")
|
||||||
fun getAvatars(publicKey: String): Flow<List<AvatarCacheEntity>>
|
fun getAvatars(publicKey: String): Flow<List<AvatarCacheEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC")
|
||||||
|
fun getAvatarsByKeys(publicKeys: List<String>): Flow<List<AvatarCacheEntity>>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить последний аватар пользователя
|
* Получить последний аватар пользователя
|
||||||
*/
|
*/
|
||||||
@Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC LIMIT 1")
|
@Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC LIMIT 1")
|
||||||
suspend fun getLatestAvatar(publicKey: String): AvatarCacheEntity?
|
suspend fun getLatestAvatar(publicKey: String): AvatarCacheEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC LIMIT 1")
|
||||||
|
suspend fun getLatestAvatarByKeys(publicKeys: List<String>): AvatarCacheEntity?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить последний аватар пользователя как Flow
|
* Получить последний аватар пользователя как Flow
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -54,20 +54,56 @@ class AvatarRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun normalizeOwnerKey(publicKey: String): String {
|
||||||
|
val trimmed = publicKey.trim()
|
||||||
|
if (trimmed.isBlank()) return trimmed
|
||||||
|
return when {
|
||||||
|
trimmed.startsWith("#group:") -> {
|
||||||
|
val groupId = trimmed.removePrefix("#group:").trim()
|
||||||
|
if (groupId.isBlank()) trimmed else "#group:$groupId"
|
||||||
|
}
|
||||||
|
trimmed.startsWith("group:", ignoreCase = true) -> {
|
||||||
|
val groupId = trimmed.substringAfter(':').trim()
|
||||||
|
if (groupId.isBlank()) trimmed else "#group:$groupId"
|
||||||
|
}
|
||||||
|
else -> trimmed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lookupKeys(publicKey: String): List<String> {
|
||||||
|
val normalized = normalizeOwnerKey(publicKey)
|
||||||
|
if (normalized.isBlank()) return emptyList()
|
||||||
|
val keys = linkedSetOf(normalized)
|
||||||
|
if (normalized.startsWith("#group:")) {
|
||||||
|
keys.add(normalized.removePrefix("#group:"))
|
||||||
|
}
|
||||||
|
val trimmed = publicKey.trim()
|
||||||
|
if (trimmed.isNotBlank()) {
|
||||||
|
keys.add(trimmed)
|
||||||
|
}
|
||||||
|
return keys.toList()
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Получить аватары пользователя
|
* Получить аватары пользователя
|
||||||
* @param publicKey Публичный ключ пользователя
|
* @param publicKey Публичный ключ пользователя
|
||||||
* @param allDecode true = вся история, false = только последний (для списков)
|
* @param allDecode true = вся история, false = только последний (для списков)
|
||||||
*/
|
*/
|
||||||
fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow<List<AvatarInfo>> {
|
fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow<List<AvatarInfo>> {
|
||||||
|
val normalizedKey = normalizeOwnerKey(publicKey)
|
||||||
|
val keys = lookupKeys(publicKey)
|
||||||
|
if (normalizedKey.isBlank() || keys.isEmpty()) {
|
||||||
|
return MutableStateFlow(emptyList<AvatarInfo>()).asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
// Проверяем LRU cache (accessOrder=true обновляет позицию при get)
|
// Проверяем LRU cache (accessOrder=true обновляет позицию при get)
|
||||||
memoryCache[publicKey]?.let { return it.flow.asStateFlow() }
|
memoryCache[normalizedKey]?.let { return it.flow.asStateFlow() }
|
||||||
|
|
||||||
// Создаем новый flow для этого пользователя
|
// Создаем новый flow для этого пользователя
|
||||||
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
|
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
|
||||||
|
|
||||||
// Подписываемся на изменения в БД
|
// Подписываемся на изменения в БД
|
||||||
val job = avatarDao.getAvatars(publicKey)
|
val job = avatarDao.getAvatarsByKeys(keys)
|
||||||
.onEach { entities ->
|
.onEach { entities ->
|
||||||
val avatars = if (allDecode) {
|
val avatars = if (allDecode) {
|
||||||
// Параллельная загрузка всей истории
|
// Параллельная загрузка всей истории
|
||||||
@@ -86,7 +122,7 @@ class AvatarRepository(
|
|||||||
}
|
}
|
||||||
.launchIn(repositoryScope)
|
.launchIn(repositoryScope)
|
||||||
|
|
||||||
memoryCache[publicKey] = CacheEntry(flow, job)
|
memoryCache[normalizedKey] = CacheEntry(flow, job)
|
||||||
return flow.asStateFlow()
|
return flow.asStateFlow()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +130,9 @@ class AvatarRepository(
|
|||||||
* Получить последний аватар пользователя (suspend версия)
|
* Получить последний аватар пользователя (suspend версия)
|
||||||
*/
|
*/
|
||||||
suspend fun getLatestAvatar(publicKey: String): AvatarInfo? {
|
suspend fun getLatestAvatar(publicKey: String): AvatarInfo? {
|
||||||
val entity = avatarDao.getLatestAvatar(publicKey) ?: return null
|
val keys = lookupKeys(publicKey)
|
||||||
|
if (keys.isEmpty()) return null
|
||||||
|
val entity = avatarDao.getLatestAvatarByKeys(keys) ?: return null
|
||||||
return loadAndDecryptAvatar(entity)
|
return loadAndDecryptAvatar(entity)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,22 +146,24 @@ class AvatarRepository(
|
|||||||
suspend fun saveAvatar(fromPublicKey: String, base64Image: String) {
|
suspend fun saveAvatar(fromPublicKey: String, base64Image: String) {
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
|
val ownerKey = normalizeOwnerKey(fromPublicKey)
|
||||||
|
if (ownerKey.isBlank()) return@withContext
|
||||||
// Сохраняем файл
|
// Сохраняем файл
|
||||||
val filePath = AvatarFileManager.saveAvatar(context, base64Image, fromPublicKey)
|
val filePath = AvatarFileManager.saveAvatar(context, base64Image, ownerKey)
|
||||||
|
|
||||||
// Сохраняем в БД
|
// Сохраняем в БД
|
||||||
val entity = AvatarCacheEntity(
|
val entity = AvatarCacheEntity(
|
||||||
publicKey = fromPublicKey,
|
publicKey = ownerKey,
|
||||||
avatar = filePath,
|
avatar = filePath,
|
||||||
timestamp = System.currentTimeMillis()
|
timestamp = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
avatarDao.insertAvatar(entity)
|
avatarDao.insertAvatar(entity)
|
||||||
|
|
||||||
// Очищаем старые аватары (оставляем только последние N)
|
// Очищаем старые аватары (оставляем только последние N)
|
||||||
avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY)
|
avatarDao.deleteOldAvatars(ownerKey, MAX_AVATAR_HISTORY)
|
||||||
|
|
||||||
// 🔄 Обновляем memory cache если он существует
|
// 🔄 Обновляем memory cache если он существует
|
||||||
val cached = memoryCache[fromPublicKey]
|
val cached = memoryCache[ownerKey]
|
||||||
if (cached != null) {
|
if (cached != null) {
|
||||||
val avatarInfo = loadAndDecryptAvatar(entity)
|
val avatarInfo = loadAndDecryptAvatar(entity)
|
||||||
if (avatarInfo != null) {
|
if (avatarInfo != null) {
|
||||||
|
|||||||
@@ -2,7 +2,13 @@ package com.rosetta.messenger.ui.chats
|
|||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.graphics.drawable.BitmapDrawable
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.view.Gravity
|
||||||
|
import android.view.View
|
||||||
import android.view.inputmethod.InputMethodManager
|
import android.view.inputmethod.InputMethodManager
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
@@ -66,6 +72,7 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.LifecycleEventObserver
|
import androidx.lifecycle.LifecycleEventObserver
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
@@ -187,6 +194,10 @@ fun ChatDetailScreen(
|
|||||||
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
|
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
|
||||||
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||||
val isSelectionMode = selectedMessages.isNotEmpty()
|
val isSelectionMode = selectedMessages.isNotEmpty()
|
||||||
|
// После long press AndroidView текста может прислать tap на отпускание.
|
||||||
|
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
|
||||||
|
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
|
||||||
|
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
// 💬 MESSAGE CONTEXT MENU STATE
|
// 💬 MESSAGE CONTEXT MENU STATE
|
||||||
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
||||||
@@ -211,13 +222,22 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
|
val hideInputOverlays: () -> Unit = {
|
||||||
val hideKeyboardAndBack: () -> Unit = {
|
|
||||||
// Используем нативный InputMethodManager для МГНОВЕННОГО закрытия
|
|
||||||
val imm =
|
val imm =
|
||||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
focusManager.clearFocus()
|
window?.let { win ->
|
||||||
|
androidx.core.view.WindowCompat.getInsetsController(win, view)
|
||||||
|
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
|
||||||
|
}
|
||||||
|
keyboardController?.hide()
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
showEmojiPicker = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
|
||||||
|
val hideKeyboardAndBack: () -> Unit = {
|
||||||
|
hideInputOverlays()
|
||||||
onBack()
|
onBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,10 +249,7 @@ fun ChatDetailScreen(
|
|||||||
else user.title.ifEmpty { user.publicKey.take(10) }
|
else user.title.ifEmpty { user.publicKey.take(10) }
|
||||||
|
|
||||||
val openDialogInfo: () -> Unit = {
|
val openDialogInfo: () -> Unit = {
|
||||||
val imm =
|
hideInputOverlays()
|
||||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
|
||||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
|
||||||
focusManager.clearFocus()
|
|
||||||
showContextMenu = false
|
showContextMenu = false
|
||||||
contextMenuMessage = null
|
contextMenuMessage = null
|
||||||
if (isGroupChat) {
|
if (isGroupChat) {
|
||||||
@@ -532,6 +549,31 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Long press должен только включать selection для сообщения (идемпотентно),
|
||||||
|
// иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается".
|
||||||
|
val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit =
|
||||||
|
{ messageId, canSelect ->
|
||||||
|
if (canSelect && !selectedMessages.contains(messageId)) {
|
||||||
|
selectedMessages = selectedMessages + messageId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val suppressTapAfterLongPress: (messageId: String) -> Unit =
|
||||||
|
{ messageId ->
|
||||||
|
longPressSuppressedMessageId = messageId
|
||||||
|
longPressSuppressUntilMs = System.currentTimeMillis() + 350L
|
||||||
|
}
|
||||||
|
val shouldIgnoreTapAfterLongPress: (messageId: String) -> Boolean =
|
||||||
|
{ messageId ->
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val isSuppressed =
|
||||||
|
longPressSuppressedMessageId == messageId &&
|
||||||
|
now <= longPressSuppressUntilMs
|
||||||
|
if (isSuppressed || now > longPressSuppressUntilMs) {
|
||||||
|
longPressSuppressedMessageId = null
|
||||||
|
}
|
||||||
|
isSuppressed
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 ПАГИНАЦИЯ: Загружаем старые сообщения при прокрутке вверх
|
// 🔥 ПАГИНАЦИЯ: Загружаем старые сообщения при прокрутке вверх
|
||||||
// NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true
|
// NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true
|
||||||
// автоматически сохраняет позицию благодаря стабильным ключам (key = message.id)
|
// автоматически сохраняет позицию благодаря стабильным ключам (key = message.id)
|
||||||
@@ -1254,12 +1296,8 @@ fun ChatDetailScreen(
|
|||||||
Box {
|
Box {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
// Закрываем
|
// Закрываем клавиатуру/emoji перед открытием меню
|
||||||
// клавиатуру перед открытием меню
|
hideInputOverlays()
|
||||||
keyboardController
|
|
||||||
?.hide()
|
|
||||||
focusManager
|
|
||||||
.clearFocus()
|
|
||||||
showMenu =
|
showMenu =
|
||||||
true
|
true
|
||||||
},
|
},
|
||||||
@@ -1305,6 +1343,7 @@ fun ChatDetailScreen(
|
|||||||
onGroupInfoClick = {
|
onGroupInfoClick = {
|
||||||
showMenu =
|
showMenu =
|
||||||
false
|
false
|
||||||
|
hideInputOverlays()
|
||||||
onGroupInfoClick(
|
onGroupInfoClick(
|
||||||
user
|
user
|
||||||
)
|
)
|
||||||
@@ -1312,6 +1351,7 @@ fun ChatDetailScreen(
|
|||||||
onSearchMembersClick = {
|
onSearchMembersClick = {
|
||||||
showMenu =
|
showMenu =
|
||||||
false
|
false
|
||||||
|
hideInputOverlays()
|
||||||
onGroupInfoClick(
|
onGroupInfoClick(
|
||||||
user
|
user
|
||||||
)
|
)
|
||||||
@@ -1875,11 +1915,10 @@ fun ChatDetailScreen(
|
|||||||
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
|
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
|
||||||
// when content paddings (bottom bar/IME) change.
|
// when content paddings (bottom bar/IME) change.
|
||||||
if (chatWallpaperResId != null) {
|
if (chatWallpaperResId != null) {
|
||||||
Image(
|
TiledChatWallpaper(
|
||||||
painter = painterResource(id = chatWallpaperResId),
|
wallpaperResId = chatWallpaperResId,
|
||||||
contentDescription = "Chat wallpaper",
|
|
||||||
modifier = Modifier.matchParentSize(),
|
modifier = Modifier.matchParentSize(),
|
||||||
contentScale = ContentScale.Crop
|
tileScale = 0.9f
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Box(
|
Box(
|
||||||
@@ -2233,12 +2272,21 @@ fun ChatDetailScreen(
|
|||||||
.clearFocus()
|
.clearFocus()
|
||||||
showEmojiPicker =
|
showEmojiPicker =
|
||||||
false
|
false
|
||||||
toggleMessageSelection(
|
selectMessageOnLongPress(
|
||||||
selectionKey,
|
selectionKey,
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
|
suppressTapAfterLongPress(
|
||||||
|
selectionKey
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onClick = {
|
onClick = {
|
||||||
|
if (shouldIgnoreTapAfterLongPress(
|
||||||
|
selectionKey
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return@MessageBubble
|
||||||
|
}
|
||||||
val hasAvatar =
|
val hasAvatar =
|
||||||
message.attachments
|
message.attachments
|
||||||
.any {
|
.any {
|
||||||
@@ -3018,3 +3066,62 @@ fun ChatDetailScreen(
|
|||||||
|
|
||||||
} // Закрытие outer Box
|
} // Закрытие outer Box
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TiledChatWallpaper(
|
||||||
|
wallpaperResId: Int,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
tileScale: Float = 0.9f
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val wallpaperDrawable =
|
||||||
|
remember(wallpaperResId, tileScale, context) {
|
||||||
|
val decoded = BitmapFactory.decodeResource(context.resources, wallpaperResId)
|
||||||
|
val normalizedScale = tileScale.coerceIn(0.2f, 2f)
|
||||||
|
|
||||||
|
val scaledBitmap =
|
||||||
|
decoded?.let { original ->
|
||||||
|
if (normalizedScale == 1f) {
|
||||||
|
original
|
||||||
|
} else {
|
||||||
|
val width =
|
||||||
|
(original.width * normalizedScale)
|
||||||
|
.toInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
val height =
|
||||||
|
(original.height * normalizedScale)
|
||||||
|
.toInt()
|
||||||
|
.coerceAtLeast(1)
|
||||||
|
val scaled =
|
||||||
|
Bitmap.createScaledBitmap(
|
||||||
|
original,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
if (scaled != original) {
|
||||||
|
original.recycle()
|
||||||
|
}
|
||||||
|
scaled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val safeBitmap =
|
||||||
|
scaledBitmap
|
||||||
|
?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
|
||||||
|
.apply {
|
||||||
|
eraseColor(android.graphics.Color.TRANSPARENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
BitmapDrawable(context.resources, safeBitmap).apply {
|
||||||
|
setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
|
||||||
|
gravity = Gravity.TOP or Gravity.START
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AndroidView(
|
||||||
|
modifier = modifier,
|
||||||
|
factory = { ctx -> View(ctx).apply { background = wallpaperDrawable } },
|
||||||
|
update = { view -> view.background = wallpaperDrawable }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package com.rosetta.messenger.ui.chats
|
package com.rosetta.messenger.ui.chats
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.inputmethod.InputMethodManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@@ -88,6 +92,7 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
@@ -131,8 +136,14 @@ import com.rosetta.messenger.ui.components.AvatarImage
|
|||||||
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
|
import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay
|
||||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||||
import com.rosetta.messenger.ui.icons.TelegramIcons
|
import com.rosetta.messenger.ui.icons.TelegramIcons
|
||||||
|
import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer
|
||||||
|
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
|
||||||
|
import com.rosetta.messenger.utils.AvatarFileManager
|
||||||
|
import com.rosetta.messenger.utils.ImageCropHelper
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -238,6 +249,7 @@ fun GroupInfoScreen(
|
|||||||
) {
|
) {
|
||||||
val context = androidx.compose.ui.platform.LocalContext.current
|
val context = androidx.compose.ui.platform.LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
|
val focusManager = LocalFocusManager.current
|
||||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -254,6 +266,19 @@ fun GroupInfoScreen(
|
|||||||
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
||||||
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
|
val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E)
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||||
|
repeat(3) {
|
||||||
|
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||||
|
(context as? Activity)?.window?.let { window ->
|
||||||
|
WindowCompat.getInsetsController(window, view)
|
||||||
|
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
|
||||||
|
}
|
||||||
|
focusManager.clearFocus(force = true)
|
||||||
|
delay(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keep status bar unified with group header color.
|
// Keep status bar unified with group header color.
|
||||||
DisposableEffect(topSurfaceColor, view) {
|
DisposableEffect(topSurfaceColor, view) {
|
||||||
val window = (view.context as? Activity)?.window
|
val window = (view.context as? Activity)?.window
|
||||||
@@ -301,6 +326,10 @@ fun GroupInfoScreen(
|
|||||||
var encryptionKeyLoading by remember { mutableStateOf(false) }
|
var encryptionKeyLoading by remember { mutableStateOf(false) }
|
||||||
var membersLoading by remember { mutableStateOf(false) }
|
var membersLoading by remember { mutableStateOf(false) }
|
||||||
var isMuted by remember { mutableStateOf(false) }
|
var isMuted by remember { mutableStateOf(false) }
|
||||||
|
var showGroupAvatarPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||||
|
var showGroupAvatarViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||||
|
var groupAvatarViewerTimestamp by rememberSaveable(dialogPublicKey) { mutableStateOf(0L) }
|
||||||
|
var groupAvatarViewerBitmap by remember(dialogPublicKey) { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||||
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
|
||||||
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
||||||
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
|
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||||
@@ -390,6 +419,49 @@ fun GroupInfoScreen(
|
|||||||
groupEntity?.description?.trim().orEmpty()
|
groupEntity?.description?.trim().orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val cropLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
|
||||||
|
val cropError = ImageCropHelper.getCropError(result)
|
||||||
|
if (croppedUri != null) {
|
||||||
|
scope.launch {
|
||||||
|
val preparedBase64 =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val imageBytes =
|
||||||
|
runCatching {
|
||||||
|
context.contentResolver.openInputStream(croppedUri)?.use { stream ->
|
||||||
|
stream.readBytes()
|
||||||
|
}
|
||||||
|
}.getOrNull()
|
||||||
|
imageBytes?.let { bytes ->
|
||||||
|
val prepared = AvatarFileManager.imagePrepareForNetworkTransfer(context, bytes)
|
||||||
|
if (prepared.isBlank()) null else "data:image/png;base64,$prepared"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val repository = avatarRepository
|
||||||
|
val saved =
|
||||||
|
preparedBase64 != null &&
|
||||||
|
repository != null &&
|
||||||
|
runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
repository.saveAvatar(dialogPublicKey, preparedBase64)
|
||||||
|
}
|
||||||
|
}.isSuccess
|
||||||
|
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
if (saved) "Group avatar updated" else "Failed to update group avatar",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
} else if (cropError != null) {
|
||||||
|
Toast.makeText(context, "Failed to crop photo", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(currentUserPublicKey, dialogPublicKey) {
|
LaunchedEffect(currentUserPublicKey, dialogPublicKey) {
|
||||||
if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) {
|
if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) {
|
||||||
isMuted = preferencesManager.isChatMuted(currentUserPublicKey, dialogPublicKey)
|
isMuted = preferencesManager.isChatMuted(currentUserPublicKey, dialogPublicKey)
|
||||||
@@ -581,6 +653,20 @@ fun GroupInfoScreen(
|
|||||||
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
|
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun openGroupAvatarViewer() {
|
||||||
|
val repository = avatarRepository ?: return
|
||||||
|
scope.launch {
|
||||||
|
val latestAvatar = repository.getAvatars(dialogPublicKey, allDecode = false).first().firstOrNull()
|
||||||
|
?: return@launch
|
||||||
|
groupAvatarViewerTimestamp = latestAvatar.timestamp / 1000L
|
||||||
|
groupAvatarViewerBitmap =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
AvatarFileManager.base64ToBitmap(latestAvatar.base64Data)
|
||||||
|
}
|
||||||
|
showGroupAvatarViewer = true
|
||||||
|
}
|
||||||
|
}
|
||||||
var swipedMemberKey by remember(dialogPublicKey) { mutableStateOf<String?>(null) }
|
var swipedMemberKey by remember(dialogPublicKey) { mutableStateOf<String?>(null) }
|
||||||
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
|
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
|
||||||
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
|
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||||
@@ -861,6 +947,13 @@ fun GroupInfoScreen(
|
|||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
size = 86.dp,
|
size = 86.dp,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
|
onClick = {
|
||||||
|
if (currentUserIsAdmin) {
|
||||||
|
showGroupAvatarPicker = true
|
||||||
|
} else {
|
||||||
|
openGroupAvatarViewer()
|
||||||
|
}
|
||||||
|
},
|
||||||
displayName = groupTitle
|
displayName = groupTitle
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1408,6 +1501,27 @@ fun GroupInfoScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProfilePhotoPicker(
|
||||||
|
isVisible = showGroupAvatarPicker,
|
||||||
|
onDismiss = { showGroupAvatarPicker = false },
|
||||||
|
onPhotoSelected = { uri ->
|
||||||
|
showGroupAvatarPicker = false
|
||||||
|
val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme)
|
||||||
|
cropLauncher.launch(cropIntent)
|
||||||
|
},
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
|
|
||||||
|
FullScreenAvatarViewer(
|
||||||
|
isVisible = showGroupAvatarViewer,
|
||||||
|
onDismiss = { showGroupAvatarViewer = false },
|
||||||
|
displayName = groupTitle.ifBlank { shortPublicKey(dialogPublicKey) },
|
||||||
|
avatarTimestamp = groupAvatarViewerTimestamp,
|
||||||
|
avatarBitmap = groupAvatarViewerBitmap,
|
||||||
|
publicKey = dialogPublicKey,
|
||||||
|
isDarkTheme = isDarkTheme
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -80,6 +80,19 @@ private val whitespaceRegex = "\\s+".toRegex()
|
|||||||
|
|
||||||
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
|
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
|
||||||
|
|
||||||
|
private fun canonicalGroupDialogKey(value: String): String {
|
||||||
|
val trimmed = value.trim()
|
||||||
|
if (trimmed.isBlank()) return ""
|
||||||
|
val groupId =
|
||||||
|
when {
|
||||||
|
trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim()
|
||||||
|
trimmed.startsWith("group:", ignoreCase = true) ->
|
||||||
|
trimmed.substringAfter(':').trim()
|
||||||
|
else -> return trimmed
|
||||||
|
}
|
||||||
|
return if (groupId.isBlank()) "" else "#group:$groupId"
|
||||||
|
}
|
||||||
|
|
||||||
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
|
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
|
||||||
if (!isGroupStoredKey(storedKey)) return null
|
if (!isGroupStoredKey(storedKey)) return null
|
||||||
val encoded = storedKey.removePrefix("group:")
|
val encoded = storedKey.removePrefix("group:")
|
||||||
@@ -1931,11 +1944,12 @@ fun AvatarAttachment(
|
|||||||
// Если это исходящее сообщение с аватаром, сохраняем для текущего
|
// Если это исходящее сообщение с аватаром, сохраняем для текущего
|
||||||
// пользователя
|
// пользователя
|
||||||
val normalizedDialogKey = dialogPublicKey.trim()
|
val normalizedDialogKey = dialogPublicKey.trim()
|
||||||
|
val canonicalDialogKey = canonicalGroupDialogKey(normalizedDialogKey)
|
||||||
val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey)
|
val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey)
|
||||||
val targetPublicKey =
|
val targetPublicKey =
|
||||||
when {
|
when {
|
||||||
isGroupAvatarAttachment && normalizedDialogKey.isNotEmpty() ->
|
isGroupAvatarAttachment && canonicalDialogKey.isNotEmpty() ->
|
||||||
normalizedDialogKey
|
canonicalDialogKey
|
||||||
isOutgoing && currentUserPublicKey.isNotEmpty() ->
|
isOutgoing && currentUserPublicKey.isNotEmpty() ->
|
||||||
currentUserPublicKey
|
currentUserPublicKey
|
||||||
else -> senderPublicKey
|
else -> senderPublicKey
|
||||||
|
|||||||
@@ -575,7 +575,7 @@ private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) {
|
|||||||
painter = painterResource(id = wallpaperResId),
|
painter = painterResource(id = wallpaperResId),
|
||||||
contentDescription = "Chat wallpaper preview",
|
contentDescription = "Chat wallpaper preview",
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop
|
contentScale = ContentScale.FillBounds
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user