Исправлены синхронизация групп, выделение сообщений и фон чата
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package com.rosetta.messenger.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.database.GroupEntity
|
||||
import com.rosetta.messenger.database.MessageEntity
|
||||
@@ -23,6 +24,7 @@ import kotlin.coroutines.resume
|
||||
|
||||
class GroupRepository private constructor(context: Context) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val db = RosettaDatabase.getDatabase(context.applicationContext)
|
||||
private val groupDao = db.groupDao()
|
||||
private val messageDao = db.messageDao()
|
||||
@@ -31,9 +33,11 @@ class GroupRepository private constructor(context: Context) {
|
||||
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
|
||||
|
||||
companion object {
|
||||
private const val TAG = "GroupRepository"
|
||||
private const val GROUP_PREFIX = "#group:"
|
||||
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
|
||||
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
|
||||
private const val GROUP_CREATED_MARKER = "\$a=Group created"
|
||||
|
||||
@Volatile
|
||||
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 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(
|
||||
@@ -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 {
|
||||
val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey)
|
||||
return "group:$encrypted"
|
||||
|
||||
@@ -767,7 +767,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 📸 Обрабатываем AVATAR attachments:
|
||||
// в личке — сохраняем аватар отправителя, в группе — аватар группы (desktop parity)
|
||||
val avatarOwnerKey =
|
||||
if (isGroupMessage) packet.toPublicKey else packet.fromPublicKey
|
||||
if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey
|
||||
processAvatarAttachments(
|
||||
packet.attachments,
|
||||
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 {
|
||||
return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}"
|
||||
}
|
||||
|
||||
@@ -42,12 +42,18 @@ interface AvatarDao {
|
||||
*/
|
||||
@Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC")
|
||||
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")
|
||||
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
|
||||
|
||||
@@ -53,6 +53,36 @@ class AvatarRepository(
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить аватары пользователя
|
||||
@@ -60,14 +90,20 @@ class AvatarRepository(
|
||||
* @param allDecode true = вся история, false = только последний (для списков)
|
||||
*/
|
||||
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)
|
||||
memoryCache[publicKey]?.let { return it.flow.asStateFlow() }
|
||||
memoryCache[normalizedKey]?.let { return it.flow.asStateFlow() }
|
||||
|
||||
// Создаем новый flow для этого пользователя
|
||||
val flow = MutableStateFlow<List<AvatarInfo>>(emptyList())
|
||||
|
||||
// Подписываемся на изменения в БД
|
||||
val job = avatarDao.getAvatars(publicKey)
|
||||
val job = avatarDao.getAvatarsByKeys(keys)
|
||||
.onEach { entities ->
|
||||
val avatars = if (allDecode) {
|
||||
// Параллельная загрузка всей истории
|
||||
@@ -86,7 +122,7 @@ class AvatarRepository(
|
||||
}
|
||||
.launchIn(repositoryScope)
|
||||
|
||||
memoryCache[publicKey] = CacheEntry(flow, job)
|
||||
memoryCache[normalizedKey] = CacheEntry(flow, job)
|
||||
return flow.asStateFlow()
|
||||
}
|
||||
|
||||
@@ -94,7 +130,9 @@ class AvatarRepository(
|
||||
* Получить последний аватар пользователя (suspend версия)
|
||||
*/
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -108,22 +146,24 @@ class AvatarRepository(
|
||||
suspend fun saveAvatar(fromPublicKey: String, base64Image: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
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(
|
||||
publicKey = fromPublicKey,
|
||||
publicKey = ownerKey,
|
||||
avatar = filePath,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
avatarDao.insertAvatar(entity)
|
||||
|
||||
// Очищаем старые аватары (оставляем только последние N)
|
||||
avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY)
|
||||
avatarDao.deleteOldAvatars(ownerKey, MAX_AVATAR_HISTORY)
|
||||
|
||||
// 🔄 Обновляем memory cache если он существует
|
||||
val cached = memoryCache[fromPublicKey]
|
||||
val cached = memoryCache[ownerKey]
|
||||
if (cached != null) {
|
||||
val avatarInfo = loadAndDecryptAvatar(entity)
|
||||
if (avatarInfo != null) {
|
||||
|
||||
@@ -2,7 +2,13 @@ package com.rosetta.messenger.ui.chats
|
||||
|
||||
import android.app.Activity
|
||||
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.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import androidx.activity.compose.BackHandler
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
@@ -187,6 +194,10 @@ fun ChatDetailScreen(
|
||||
// 🔥 MESSAGE SELECTION STATE - для Reply/Forward
|
||||
var selectedMessages by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
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
|
||||
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(null) }
|
||||
@@ -211,13 +222,22 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager
|
||||
val hideKeyboardAndBack: () -> Unit = {
|
||||
// Используем нативный InputMethodManager для МГНОВЕННОГО закрытия
|
||||
val hideInputOverlays: () -> Unit = {
|
||||
val imm =
|
||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -229,10 +249,7 @@ fun ChatDetailScreen(
|
||||
else user.title.ifEmpty { user.publicKey.take(10) }
|
||||
|
||||
val openDialogInfo: () -> Unit = {
|
||||
val imm =
|
||||
context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
|
||||
imm.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
focusManager.clearFocus()
|
||||
hideInputOverlays()
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
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
|
||||
// автоматически сохраняет позицию благодаря стабильным ключам (key = message.id)
|
||||
@@ -1254,12 +1296,8 @@ fun ChatDetailScreen(
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = {
|
||||
// Закрываем
|
||||
// клавиатуру перед открытием меню
|
||||
keyboardController
|
||||
?.hide()
|
||||
focusManager
|
||||
.clearFocus()
|
||||
// Закрываем клавиатуру/emoji перед открытием меню
|
||||
hideInputOverlays()
|
||||
showMenu =
|
||||
true
|
||||
},
|
||||
@@ -1305,6 +1343,7 @@ fun ChatDetailScreen(
|
||||
onGroupInfoClick = {
|
||||
showMenu =
|
||||
false
|
||||
hideInputOverlays()
|
||||
onGroupInfoClick(
|
||||
user
|
||||
)
|
||||
@@ -1312,6 +1351,7 @@ fun ChatDetailScreen(
|
||||
onSearchMembersClick = {
|
||||
showMenu =
|
||||
false
|
||||
hideInputOverlays()
|
||||
onGroupInfoClick(
|
||||
user
|
||||
)
|
||||
@@ -1875,11 +1915,10 @@ fun ChatDetailScreen(
|
||||
// Keep wallpaper on a fixed full-screen layer so it doesn't rescale
|
||||
// when content paddings (bottom bar/IME) change.
|
||||
if (chatWallpaperResId != null) {
|
||||
Image(
|
||||
painter = painterResource(id = chatWallpaperResId),
|
||||
contentDescription = "Chat wallpaper",
|
||||
TiledChatWallpaper(
|
||||
wallpaperResId = chatWallpaperResId,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
tileScale = 0.9f
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
@@ -2233,12 +2272,21 @@ fun ChatDetailScreen(
|
||||
.clearFocus()
|
||||
showEmojiPicker =
|
||||
false
|
||||
toggleMessageSelection(
|
||||
selectMessageOnLongPress(
|
||||
selectionKey,
|
||||
true
|
||||
)
|
||||
suppressTapAfterLongPress(
|
||||
selectionKey
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
if (shouldIgnoreTapAfterLongPress(
|
||||
selectionKey
|
||||
)
|
||||
) {
|
||||
return@MessageBubble
|
||||
}
|
||||
val hasAvatar =
|
||||
message.attachments
|
||||
.any {
|
||||
@@ -3018,3 +3066,62 @@ fun ChatDetailScreen(
|
||||
|
||||
} // Закрытие 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
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.view.inputmethod.InputMethodManager
|
||||
import android.widget.Toast
|
||||
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.fadeIn
|
||||
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.graphics.toArgb
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
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.VerifiedBadge
|
||||
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 kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -238,6 +249,7 @@ fun GroupInfoScreen(
|
||||
) {
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val view = LocalView.current
|
||||
val focusManager = LocalFocusManager.current
|
||||
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -254,6 +266,19 @@ fun GroupInfoScreen(
|
||||
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
||||
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.
|
||||
DisposableEffect(topSurfaceColor, view) {
|
||||
val window = (view.context as? Activity)?.window
|
||||
@@ -301,6 +326,10 @@ fun GroupInfoScreen(
|
||||
var encryptionKeyLoading by remember { mutableStateOf(false) }
|
||||
var membersLoading 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 pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
|
||||
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||
@@ -390,6 +419,49 @@ fun GroupInfoScreen(
|
||||
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) {
|
||||
if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) {
|
||||
isMuted = preferencesManager.isChatMuted(currentUserPublicKey, dialogPublicKey)
|
||||
@@ -581,6 +653,20 @@ fun GroupInfoScreen(
|
||||
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 memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
|
||||
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
|
||||
@@ -861,6 +947,13 @@ fun GroupInfoScreen(
|
||||
avatarRepository = avatarRepository,
|
||||
size = 86.dp,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onClick = {
|
||||
if (currentUserIsAdmin) {
|
||||
showGroupAvatarPicker = true
|
||||
} else {
|
||||
openGroupAvatarViewer()
|
||||
}
|
||||
},
|
||||
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
|
||||
|
||||
@@ -80,6 +80,19 @@ private val whitespaceRegex = "\\s+".toRegex()
|
||||
|
||||
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? {
|
||||
if (!isGroupStoredKey(storedKey)) return null
|
||||
val encoded = storedKey.removePrefix("group:")
|
||||
@@ -1931,11 +1944,12 @@ fun AvatarAttachment(
|
||||
// Если это исходящее сообщение с аватаром, сохраняем для текущего
|
||||
// пользователя
|
||||
val normalizedDialogKey = dialogPublicKey.trim()
|
||||
val canonicalDialogKey = canonicalGroupDialogKey(normalizedDialogKey)
|
||||
val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey)
|
||||
val targetPublicKey =
|
||||
when {
|
||||
isGroupAvatarAttachment && normalizedDialogKey.isNotEmpty() ->
|
||||
normalizedDialogKey
|
||||
isGroupAvatarAttachment && canonicalDialogKey.isNotEmpty() ->
|
||||
canonicalDialogKey
|
||||
isOutgoing && currentUserPublicKey.isNotEmpty() ->
|
||||
currentUserPublicKey
|
||||
else -> senderPublicKey
|
||||
|
||||
@@ -575,7 +575,7 @@ private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) {
|
||||
painter = painterResource(id = wallpaperResId),
|
||||
contentDescription = "Chat wallpaper preview",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
contentScale = ContentScale.FillBounds
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user