Исправлены синхронизация групп, выделение сообщений и фон чата

This commit is contained in:
2026-03-07 23:43:09 +05:00
parent 364b166581
commit 85bddb798c
8 changed files with 352 additions and 32 deletions

View File

@@ -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"

View File

@@ -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)}"
} }

View File

@@ -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
*/ */

View File

@@ -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) {

View File

@@ -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 }
)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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
) )
} }