Compare commits

...

19 Commits

Author SHA1 Message Date
eccaf018cf Фикс кривой иконки на экране групп
All checks were successful
Android Kernel Build / build (push) Successful in 16h10m37s
2026-03-04 17:13:33 +05:00
9e5e81d5e5 Фикс прыгающей галочки 2026-03-04 15:43:07 +05:00
62857da793 feat: enhance keyboard height management and emoji picker synchronization in GroupSetupScreen 2026-03-04 14:34:17 +05:00
d1aca8439a feat: update message status handling for error cases in ChatViewModel and UI components 2026-03-04 01:00:08 +05:00
2501296d70 feat: enhance swipe-back functionality in OtherProfileScreen and SwipeBackContainer 2026-03-03 20:47:32 +05:00
ddb6207bb5 feat: implement HorizontalPager for tab navigation in OtherProfileScreen 2026-03-03 19:07:58 +05:00
d0fc8f2f1a feat: implement fullscreen image viewer in SearchScreen and refactor MediaTabContent 2026-03-03 03:28:12 +05:00
86d42c8e10 feat: update version to 1.1.2 and enhance search, group info, UI and media features
Search Screen:
- Complete Search screen with 4 tabs: Chats, Media, Downloads, Files
- Media grid with real image loading from AttachmentFileManager + CDN fallback
- Fullscreen image viewer with swipe navigation from Media tab
- Downloads tab showing files from rosetta_downloads directory
- Files tab showing file attachments from all chats
- Links tab with URL extraction from messages

Group Features:
- Group invite cards redesigned inside chat bubbles with action button
- In-memory cache for group invite info (no loading on re-enter)
- Group members caching with TTL for faster group info loading
- Encryption key screen redesigned (Telegram-style 12x12 identicon)
- Fixed group members online status using PacketOnlineSubscribe/PacketOnlineState
- Emoji picker integration in GroupSetupScreen with proper keyboard handling

Chat & UI:
- Improved message delivery status handling and indicators
- Enhanced camera handling and UI state management
- File download manager with progress tracking
- Download indicator component
- File opening support in OtherProfileScreen (local, downloaded, blob)
- SwipeBack container improvements
- Theme screen and wallpaper selection enhancements
- FAB positioning fixes for keyboard/emoji panel in GroupSetupScreen
2026-03-03 03:13:43 +05:00
91a47892f2 feat: update version to 1.1.2 and enhance search, group info, UI and media features
Search Screen:
- Complete Search screen with 4 tabs: Chats, Media, Downloads, Files
- Media grid with real image loading from AttachmentFileManager + CDN fallback
- Fullscreen image viewer with swipe navigation from Media tab
- Downloads tab showing files from rosetta_downloads directory
- Files tab showing file attachments from all chats
- Links tab with URL extraction from messages

Group Features:
- Group invite cards redesigned inside chat bubbles with action button
- In-memory cache for group invite info (no loading on re-enter)
- Group members caching with TTL for faster group info loading
- Encryption key screen redesigned (Telegram-style 12x12 identicon)
- Fixed group members online status using PacketOnlineSubscribe/PacketOnlineState
- Emoji picker integration in GroupSetupScreen with proper keyboard handling

Chat & UI:
- Improved message delivery status handling and indicators
- Enhanced camera handling and UI state management
- File download manager with progress tracking
- Download indicator component
- File opening support in OtherProfileScreen (local, downloaded, blob)
- SwipeBack container improvements
- Theme screen and wallpaper selection enhancements
- FAB positioning fixes for keyboard/emoji panel in GroupSetupScreen
2026-03-03 03:09:22 +05:00
c53cb87595 feat: implement caching for group invite info and enhance message bubble layout for group invites 2026-03-03 03:06:11 +05:00
36fb8609d5 feat: adjust FAB bottom padding logic in GroupSetupScreen for improved keyboard and emoji panel handling; update font size in SearchScreen 2026-03-03 00:09:41 +05:00
50b27fcbb3 feat: update FAB bottom padding logic in GroupSetupScreen to improve positioning with keyboard and emoji panel 2026-03-03 00:02:27 +05:00
9ddad8ec3c feat: update FAB positioning logic in GroupSetupScreen to account for emoji panel and keyboard visibility 2026-03-03 00:02:09 +05:00
3f3dd956cb feat: enhance file opening logic in OtherProfileScreen to support local, downloaded, and blob files 2026-03-02 23:54:16 +05:00
8c7ac53506 feat: Enhance chat UI with group invite handling and new download indicator
- Added support for standalone group invites in MessageBubble component.
- Improved bubble padding and width handling for group invites.
- Refactored MessageBubble to streamline background and border logic.
- Introduced AnimatedDownloadIndicator for a more engaging download experience.
- Created ThemeWallpapers data structure to manage chat wallpapers.
- Implemented WallpaperSelectorRow and WallpaperSelectorItem for theme customization.
- Updated ThemeScreen to allow wallpaper selection and preview.
- Added new drawable resources for download and search icons.
2026-03-02 23:40:44 +05:00
16c48992a5 feat: implement group members caching and enhance emoji picker functionality in GroupSetupScreen 2026-03-02 15:31:38 +05:00
df8fbfc5d3 feat: add delay for focus request in GroupSetupScreen during details step 2026-03-02 14:19:59 +05:00
8f7544c655 feat: enhance camera handling and UI state management in chat components 2026-03-02 14:07:42 +05:00
6d379148b0 feat: improve message delivery status handling and UI indicators in chat screens 2026-03-02 11:35:03 +05:00
57 changed files with 3743 additions and 1054 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.1.1"
val rosettaVersionCode = 13 // Increment on each release
val rosettaVersionName = "1.1.2"
val rosettaVersionCode = 14 // Increment on each release
android {
namespace = "com.rosetta.messenger"

View File

@@ -54,7 +54,7 @@ class KeyboardTransitionCoordinator {
var keyboardHeight by mutableStateOf(0.dp)
var emojiHeight by mutableStateOf(0.dp)
// 🔥 Сохраняем максимальную высоту клавиатуры для правильного восстановления emoji
// Максимальная высота клавиатуры (защищает от промежуточных значений при анимации закрытия)
private var maxKeyboardHeight by mutableStateOf(0.dp)
// ============ Флаги видимости ============
@@ -84,8 +84,10 @@ class KeyboardTransitionCoordinator {
currentState = TransitionState.KEYBOARD_TO_EMOJI
isTransitioning = true
// 🔥 Гарантируем что emojiHeight = maxKeyboardHeight (не меняется при закрытии клавиатуры)
if (maxKeyboardHeight > 0.dp) {
// Устанавливаем emojiHeight = текущая высота клавиатуры (клавиатура ещё открыта)
if (keyboardHeight > 0.dp) {
emojiHeight = keyboardHeight
} else if (maxKeyboardHeight > 0.dp) {
emojiHeight = maxKeyboardHeight
}
@@ -234,7 +236,7 @@ class KeyboardTransitionCoordinator {
if (height > 100.dp && height != keyboardHeight) {
keyboardHeight = height
// 🔥 Сохраняем максимальную высоту
// Обновляем maxKeyboardHeight только вверх (защита от промежуточных значений анимации)
if (height > maxKeyboardHeight) {
maxKeyboardHeight = height
}
@@ -244,14 +246,14 @@ class KeyboardTransitionCoordinator {
emojiHeight = height
}
} else if (height == 0.dp && keyboardHeight != 0.dp) {
// 🔥 Клавиатура закрывается - восстанавливаем emojiHeight до МАКСИМАЛЬНОЙ высоты
// Восстанавливаем emojiHeight до максимальной высоты
if (maxKeyboardHeight > 0.dp) {
// Клавиатура закрывается.
// Если emoji уже показан (keyboard→emoji переход), НЕ трогаем emojiHeight —
// requestShowEmoji() уже установил правильное значение = текущая высота клавиатуры.
// Восстанавливаем из maxKeyboardHeight только если emoji НЕ виден (обычное закрытие).
if (!isEmojiVisible && !isEmojiBoxVisible && maxKeyboardHeight > 0.dp) {
emojiHeight = maxKeyboardHeight
}
// Обнуляем keyboardHeight
keyboardHeight = 0.dp
}
@@ -272,7 +274,8 @@ class KeyboardTransitionCoordinator {
* emojiHeight должна оставаться фиксированной!
*/
fun syncHeights() {
// 🔥 Синхронизируем ТОЛЬКО если клавиатура ОТКРЫТА и высота больше текущей emoji
// Синхронизируем только вверх — при закрытии клавиатуры промежуточные значения
// не должны уменьшать emojiHeight. Точная высота ставится в requestShowEmoji().
if (keyboardHeight > 100.dp && keyboardHeight > emojiHeight) {
emojiHeight = keyboardHeight
}

View File

@@ -676,6 +676,7 @@ fun MainScreen(
prefsManager
.backgroundBlurColorIdForAccount(accountPublicKey)
.collectAsState(initial = "avatar")
val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "")
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
// AvatarRepository для работы с аватарами
@@ -894,13 +895,18 @@ fun MainScreen(
isVisible = isThemeVisible,
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
isDarkTheme = isDarkTheme,
layer = 2
layer = 2,
deferToChildren = true
) {
ThemeScreen(
isDarkTheme = isDarkTheme,
currentThemeMode = themeMode,
currentWallpaperId = chatWallpaperId,
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
onThemeModeChange = onThemeModeChange
onThemeModeChange = onThemeModeChange,
onWallpaperChange = { wallpaperId ->
mainScreenScope.launch { prefsManager.setChatWallpaperId(wallpaperId) }
}
)
}
@@ -989,6 +995,7 @@ fun MainScreen(
} + Screen.ChatDetail(forwardUser)
},
isDarkTheme = isDarkTheme,
chatWallpaperId = chatWallpaperId,
avatarRepository = avatarRepository,
onImageViewerChanged = { isLocked -> isChatSwipeLocked = isLocked }
)
@@ -1072,6 +1079,9 @@ fun MainScreen(
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
accountName = accountName,
accountUsername = accountUsername,
avatarRepository = avatarRepository,
onBack = { navStack = navStack.filterNot { it is Screen.GroupSetup } },
onGroupOpened = { groupUser ->
navStack =

View File

@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.PacketGroupLeave
import com.rosetta.messenger.network.ProtocolManager
import java.security.SecureRandom
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
@@ -27,6 +28,8 @@ class GroupRepository private constructor(context: Context) {
private val messageDao = db.messageDao()
private val dialogDao = db.dialogDao()
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
companion object {
private const val GROUP_PREFIX = "#group:"
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
@@ -159,6 +162,20 @@ class GroupRepository private constructor(context: Context) {
return response.members
}
fun getCachedInviteInfo(groupId: String): GroupInviteInfoResult? {
val normalized = normalizeGroupId(groupId)
return inviteInfoCache[normalized]
}
fun cacheInviteInfo(groupId: String, status: GroupStatus, membersCount: Int) {
val normalized = normalizeGroupId(groupId)
inviteInfoCache[normalized] = GroupInviteInfoResult(
groupId = normalized,
membersCount = membersCount,
status = status
)
}
suspend fun requestInviteInfo(groupPublicKeyOrId: String): GroupInviteInfoResult? {
val groupId = normalizeGroupId(groupPublicKeyOrId)
if (groupId.isBlank()) return null
@@ -176,11 +193,13 @@ class GroupRepository private constructor(context: Context) {
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
?: return null
return GroupInviteInfoResult(
val result = GroupInviteInfoResult(
groupId = groupId,
membersCount = response.membersCount.coerceAtLeast(0),
status = response.groupStatus
)
inviteInfoCache[groupId] = result
return result
}
suspend fun createGroup(

View File

@@ -27,6 +27,7 @@ class PreferencesManager(private val context: Context) {
val HAS_SEEN_ONBOARDING = booleanPreferencesKey("has_seen_onboarding")
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper
// Notifications
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
@@ -100,6 +101,13 @@ class PreferencesManager(private val context: Context) {
context.dataStore.edit { preferences -> preferences[THEME_MODE] = value } // Persist
}
val chatWallpaperId: Flow<String> =
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
suspend fun setChatWallpaperId(value: String) {
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value }
}
// ═════════════════════════════════════════════════════════════
// 🔔 NOTIFICATIONS
// ═════════════════════════════════════════════════════════════

View File

@@ -17,28 +17,27 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Группы и интерфейс
- Полностью обновлен экран группы в стиле приложения (по паритету с desktop логикой)
- В участниках добавлены верификации, админ-метка и тултип администратора
- Добавлен просмотр Encryption Key с QR-кодом
- Улучшены секции Media/Files/Links: корректные пустые состояния и выравнивание медиа-сетки
Поиск
- Новый полноценный экран поиска с вкладками: Чаты, Медиа, Загрузки, Файлы
- Медиа-сетка с загрузкой реальных фотографий из истории чатов
- Просмотр фото на весь экран со свайпом между ними из вкладки Медиа
- Вкладка Загрузки — скачанные файлы
- Вкладка Файлы — файловые вложения из всех чатов
Сообщения и списки
- Group Invite теперь отображается как invite-карточка вместо хэша (в чате и в chat list)
- Для групп в chat list показывается иконка и автор последнего сообщения (You/имя отправителя)
- Исправлено выравнивание превью вида "You: Photo"
- Системные события группы (например joined the group) приведены к desktop-стилю
Группы
- Приглашения в группу теперь отображаются внутри пузыря сообщения с кнопкой действия
- Кэширование информации о приглашениях (больше нет загрузки при повторном открытии)
- Кэширование участников группы для быстрого открытия списка
- Экран ключа шифрования переработан в стиле Telegram (12×12 identicon)
- Исправлен онлайн-статус участников — теперь онлайн только те кто реально онлайн
- Emoji-клавиатура в экране создания группы
Модерация групп
- Добавлены свайп и long-press действия по участникам (Kick)
- Улучшены цвета, haptic и размеры action-кнопки; исправлен конфликт свайпа item vs экран
- Для групп в chat list добавлены swipe-actions: Pin, Leave, Delete
Синхронизация и стабильность
- Исправлены пропуски сообщений при массовой синхронизации личных и групповых чатов
- Sync теперь не продвигает курсор батча при ошибках обработки и делает безопасные ретраи
- Исправлены кейсы, где requests зависели от состояния устройства, а не аккаунта
- Rosetta Updates и Safe исключены из requests
Чат и интерфейс
- Улучшены индикаторы доставки сообщений
- Менеджер загрузки файлов с отображением прогресса
- Открытие файлов в профиле пользователя (локальные, загруженные, из blob)
- Новые обои для чатов
- Улучшена работа камеры и управление состоянием UI
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -490,6 +490,42 @@ interface MessageDao {
"""
)
suspend fun updateDeliveryStatusAndTimestamp(account: String, messageId: String, status: Int, timestamp: Long)
// ═══════════════════════════════════════════════════════════
// 🔍 SEARCH: Media, Files
// ═══════════════════════════════════════════════════════════
/**
* Получить сообщения с IMAGE вложениями (type: 0)
* Для вкладки "Media" в поиске
*/
@Query(
"""
SELECT * FROM messages
WHERE account = :account
AND attachments != '[]'
AND attachments LIKE '%"type":0%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun getMessagesWithMedia(account: String, limit: Int, offset: Int): List<MessageEntity>
/**
* Получить сообщения с FILE вложениями (type: 2)
* Для вкладки "Files" в поиске
*/
@Query(
"""
SELECT * FROM messages
WHERE account = :account
AND attachments != '[]'
AND attachments LIKE '%"type":2%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
}
/** DAO для работы с диалогами */

View File

@@ -0,0 +1,220 @@
package com.rosetta.messenger.network
import android.content.Context
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import java.io.File
data class FileDownloadState(
val attachmentId: String,
val fileName: String,
val status: FileDownloadStatus,
/** 0f..1f */
val progress: Float = 0f
)
enum class FileDownloadStatus {
QUEUED,
DOWNLOADING,
DECRYPTING,
DONE,
ERROR
}
object FileDownloadManager {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
/** Все активные/завершённые скачивания */
private val _downloads = MutableStateFlow<Map<String, FileDownloadState>>(emptyMap())
val downloads: StateFlow<Map<String, FileDownloadState>> = _downloads.asStateFlow()
/** Текущие Job'ы — чтобы не запускать повторно */
private val jobs = mutableMapOf<String, Job>()
// ─── helpers ───
private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:")
private fun decodeGroupPassword(storedKey: String, privateKey: String): String? {
if (!isGroupStoredKey(storedKey)) return null
val encoded = storedKey.removePrefix("group:")
if (encoded.isBlank()) return null
return CryptoManager.decryptWithPassword(encoded, privateKey)
}
private fun decodeBase64Payload(data: String): ByteArray? {
val raw = data.trim()
if (raw.isBlank()) return null
val payload =
if (raw.startsWith("data:") && raw.contains(",")) raw.substringAfter(",")
else raw
return try {
android.util.Base64.decode(payload, android.util.Base64.DEFAULT)
} catch (_: Exception) {
null
}
}
// ─── public API ───
/**
* Проверяет, идёт ли уже скачивание этого attachment
*/
fun isDownloading(attachmentId: String): Boolean {
val state = _downloads.value[attachmentId] ?: return false
return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING
}
/**
* Возвращает Flow<FileDownloadState?> для конкретного attachment
*/
fun progressOf(attachmentId: String): Flow<FileDownloadState?> =
_downloads.map { it[attachmentId] }.distinctUntilChanged()
/**
* Запускает скачивание файла. Если уже скачивается — игнорирует.
* Скачивание продолжается даже если пользователь вышел из чата.
*/
fun download(
context: Context,
attachmentId: String,
downloadTag: String,
chachaKey: String,
privateKey: String,
fileName: String,
savedFile: File
) {
// Уже в процессе?
if (jobs[attachmentId]?.isActive == true) return
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f)
jobs[attachmentId] = scope.launch {
try {
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, 0f)
// Запускаем polling прогресса из TransportManager
val progressJob = launch {
TransportManager.downloading.collect { list ->
val entry = list.find { it.id == attachmentId }
if (entry != null) {
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
val p = (entry.progress / 100f) * 0.8f
update(attachmentId, fileName, FileDownloadStatus.DOWNLOADING, p)
}
}
}
val success = withContext(Dispatchers.IO) {
if (isGroupStoredKey(chachaKey)) {
downloadGroupFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
} else {
downloadDirectFile(attachmentId, downloadTag, chachaKey, privateKey, fileName, savedFile)
}
}
progressJob.cancel()
if (success) {
update(attachmentId, fileName, FileDownloadStatus.DONE, 1f)
} else {
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
e.printStackTrace()
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
} catch (_: OutOfMemoryError) {
System.gc()
update(attachmentId, fileName, FileDownloadStatus.ERROR, 0f)
} finally {
jobs.remove(attachmentId)
// Автоочистка через 5 секунд после завершения
scope.launch {
delay(5000)
_downloads.update { it - attachmentId }
}
}
}
}
/**
* Отменяет скачивание
*/
fun cancel(attachmentId: String) {
jobs[attachmentId]?.cancel()
jobs.remove(attachmentId)
_downloads.update { it - attachmentId }
}
// ─── internal download logic (moved from FileAttachment) ───
private suspend fun downloadGroupFile(
attachmentId: String,
downloadTag: String,
chachaKey: String,
privateKey: String,
fileName: String,
savedFile: File
): Boolean {
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.82f)
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) return false
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
val bytes = decrypted?.let { decodeBase64Payload(it) } ?: return false
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.93f)
withContext(Dispatchers.IO) {
savedFile.parentFile?.mkdirs()
savedFile.writeBytes(bytes)
}
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
return true
}
private suspend fun downloadDirectFile(
attachmentId: String,
downloadTag: String,
chachaKey: String,
privateKey: String,
fileName: String,
savedFile: File
): Boolean {
// Streaming: скачиваем во temp file
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.83f)
val decryptedKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.88f)
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
withContext(Dispatchers.IO) {
try {
MessageCrypto.decryptAttachmentFileStreaming(
tempFile,
decryptedKeyAndNonce,
savedFile
)
} finally {
tempFile.delete()
}
}
update(attachmentId, fileName, FileDownloadStatus.DECRYPTING, 0.98f)
return true
}
private fun update(id: String, fileName: String, status: FileDownloadStatus, progress: Float) {
_downloads.update { map ->
map + (id to FileDownloadState(id, fileName, status, progress))
}
}
}

View File

@@ -23,6 +23,7 @@ import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
@@ -51,6 +52,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
@@ -91,6 +93,7 @@ import com.rosetta.messenger.ui.chats.utils.*
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.components.VerifiedBadge
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.settings.ThemeWallpapers
import com.rosetta.messenger.ui.utils.SystemBarsStyleUtils
import com.rosetta.messenger.utils.MediaUtils
import java.text.SimpleDateFormat
@@ -121,6 +124,7 @@ fun ChatDetailScreen(
currentUserName: String = "",
totalUnreadFromOthers: Int = 0,
isDarkTheme: Boolean,
chatWallpaperId: String = "",
avatarRepository: AvatarRepository? = null,
onImageViewerChanged: (Boolean) -> Unit = {}
) {
@@ -144,6 +148,7 @@ fun ChatDetailScreen(
// UI Theme
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val chatWallpaperResId = remember(chatWallpaperId) { ThemeWallpapers.drawableResOrNull(chatWallpaperId) }
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val headerIconColor = Color.White
@@ -633,6 +638,14 @@ fun ChatDetailScreen(
isScreenActive = false
viewModel.setDialogActive(false)
}
Lifecycle.Event.ON_STOP -> {
// Hard-stop camera/picker overlays when app goes background.
// On next app open everything must start closed/off.
showInAppCamera = false
showMediaPicker = false
pendingCameraPhotoUri = null
pendingGalleryImages = emptyList()
}
else -> {}
}
}
@@ -1806,14 +1819,29 @@ fun ChatDetailScreen(
) { paddingValues ->
// 🔥 Box wrapper для overlay (MediaPicker над клавиатурой)
Box(modifier = Modifier.fillMaxSize()) {
// 🔥 Column структура - список сжимается когда клавиатура
// открывается
Column(
modifier =
Modifier.fillMaxSize()
.padding(paddingValues)
.background(backgroundColor)
) {
// 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",
modifier = Modifier.matchParentSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier =
Modifier.matchParentSize()
.background(backgroundColor)
)
}
Box(modifier = Modifier.fillMaxSize().padding(paddingValues)) {
// 🔥 Column структура - список сжимается когда клавиатура
// открывается
Column(
modifier = Modifier.fillMaxSize()
) {
// Список сообщений - занимает всё доступное место
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
// Плавная анимация bottom padding при входе/выходе из selection mode
@@ -2487,27 +2515,6 @@ fun ChatDetailScreen(
} // Закрытие Box wrapper для Scaffold content
} // Закрытие Box
// 📸 Image Viewer Overlay with Telegram-style shared element animation
if (showImageViewer && imageViewerImages.isNotEmpty()) {
ImageViewerScreen(
images = imageViewerImages,
initialIndex = imageViewerInitialIndex,
privateKey = currentUserPrivateKey,
onDismiss = {
showImageViewer = false
imageViewerSourceBounds = null
imageViewerImages = emptyList()
onImageViewerChanged(false)
},
onClosingStart = {
// Сразу сбрасываем status bar при начале закрытия (до анимации)
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
},
isDarkTheme = isDarkTheme,
sourceBounds = imageViewerSourceBounds
)
}
// Диалог подтверждения удаления чата
if (showDeleteConfirm) {
val isLeaveGroupDialog = user.publicKey.startsWith("#group:")
@@ -2765,7 +2772,30 @@ fun ChatDetailScreen(
)
}
// 📷 In-App Camera (без системного превью!)
} // Закрытие Scaffold content lambda
// <20> Image Viewer Overlay — FULLSCREEN поверх Scaffold
if (showImageViewer && imageViewerImages.isNotEmpty()) {
ImageViewerScreen(
images = imageViewerImages,
initialIndex = imageViewerInitialIndex,
privateKey = currentUserPrivateKey,
onDismiss = {
showImageViewer = false
imageViewerSourceBounds = null
imageViewerImages = emptyList()
onImageViewerChanged(false)
},
onClosingStart = {
// Сразу сбрасываем status bar при начале закрытия (до анимации)
SystemBarsStyleUtils.applyChatStatusBar(window, view, isDarkTheme)
},
isDarkTheme = isDarkTheme,
sourceBounds = imageViewerSourceBounds
)
}
// <20>📷 In-App Camera — FULLSCREEN поверх Scaffold (вне content lambda)
if (showInAppCamera) {
InAppCameraScreen(
onDismiss = { showInAppCamera = false },
@@ -2827,5 +2857,5 @@ fun ChatDetailScreen(
)
}
}
} // Закрытие outer Box
}

View File

@@ -396,6 +396,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val currentDialogKey = getDialogKey(account, opponent)
if (update.dialogKey == currentDialogKey) {
if (!isDialogActive) return@collect
when (update.status) {
DeliveryStatus.DELIVERED -> {
// Обновляем конкретное сообщение
@@ -1157,7 +1158,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
when (entity.delivered) {
0 -> MessageStatus.SENDING
1 -> if (entity.read == 1) MessageStatus.READ else MessageStatus.DELIVERED
2 -> MessageStatus.SENT
2 -> MessageStatus.ERROR
3 -> MessageStatus.READ
else -> MessageStatus.SENT
},
@@ -2508,8 +2509,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
saveDialog(text, timestamp)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT) // Changed from ERROR
updateMessageStatus(messageId, MessageStatus.ERROR)
}
// Update error status in DB + dialog
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
saveDialog(text, timestamp)
} finally {
isSending = false
}
@@ -3711,7 +3715,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
} catch (e: Exception) {
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.SENT) }
withContext(Dispatchers.Main) { updateMessageStatus(messageId, MessageStatus.ERROR) }
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
saveDialog(if (text.isNotEmpty()) text else "file", timestamp)
} finally {
isSending = false
}
@@ -3941,7 +3947,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
saveDialog("\$a=Avatar", timestamp)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
updateMessageStatus(messageId, MessageStatus.SENT)
updateMessageStatus(messageId, MessageStatus.ERROR)
android.widget.Toast.makeText(
getApplication(),
"Failed to send avatar: ${e.message}",
@@ -3949,6 +3955,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
)
.show()
}
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
saveDialog("\$a=Avatar", timestamp)
} finally {
isSending = false
}
@@ -4097,7 +4105,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
content = encryptedContent,
timestamp = timestamp,
chachaKey = encryptedKey,
read = if (isFromMe) 1 else 0,
read = if (isFromMe && opponent == account) 1 else 0,
fromMe = if (isFromMe) 1 else 0,
delivered = delivered,
messageId = finalMessageId,

View File

@@ -442,7 +442,11 @@ fun ChatsListScreen(
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
// 🔥 Пользователи, которые сейчас печатают
// <EFBFBD> Active downloads tracking (for header indicator)
val activeDownloads by com.rosetta.messenger.network.TransportManager.downloading.collectAsState()
val hasActiveDownloads = activeDownloads.isNotEmpty()
// <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState()
// Load dialogs when account is available
@@ -1593,6 +1597,16 @@ fun ChatsListScreen(
},
actions = {
if (!showRequestsScreen) {
// 📥 Animated download indicator (Telegram-style)
Box(
modifier = androidx.compose.ui.Modifier.size(48.dp),
contentAlignment = Alignment.Center
) {
com.rosetta.messenger.ui.components.AnimatedDownloadIndicator(
isActive = hasActiveDownloads,
color = Color.White
)
}
IconButton(
onClick = {
if (protocolState ==
@@ -3800,30 +3814,14 @@ fun DialogItemContent(
// - lastMessageDelivered == 0 → часики
// (отправляется)
// - lastMessageDelivered == 2 → ошибка
when (dialog.lastMessageDelivered) {
2 -> {
// ERROR - показываем иконку ошибки
Icon(
imageVector =
TablerIcons
.AlertCircle,
contentDescription =
"Sending failed",
tint =
Color(
0xFFFF3B30
), // iOS красный
modifier =
Modifier.size(16.dp)
)
Spacer(
modifier =
Modifier.width(4.dp)
)
}
3 -> {
// READ (delivered=3) - две синие
// галочки
val isReadByOpponent =
dialog.lastMessageDelivered == 3 ||
(dialog.lastMessageDelivered == 1 &&
dialog.lastMessageRead == 1)
when {
isReadByOpponent -> {
// READ - две синие галочки
Box(
modifier =
Modifier.width(20.dp)
@@ -3867,7 +3865,11 @@ fun DialogItemContent(
Modifier.width(4.dp)
)
}
1 -> {
dialog.lastMessageDelivered == 2 -> {
// ERROR - не показываем статус рядом с временем,
// error badge показывается внизу справа (как в Telegram)
}
dialog.lastMessageDelivered == 1 -> {
// DELIVERED - одна серая галочка
Icon(
painter =
@@ -4102,6 +4104,27 @@ fun DialogItemContent(
}
}
// Error badge (Telegram-style) — красный кружок с "!" вместо unread badge
if (dialog.lastMessageFromMe == 1 && dialog.lastMessageDelivered == 2) {
Spacer(modifier = Modifier.width(8.dp))
Box(
modifier =
Modifier.size(22.dp)
.clip(CircleShape)
.background(Color(0xFFE53935)),
contentAlignment = Alignment.Center
) {
Text(
text = "!",
fontSize = 13.sp,
fontWeight = FontWeight.Bold,
color = Color.White,
lineHeight = 13.sp,
maxLines = 1
)
}
}
// Unread badge
if (dialog.unreadCount > 0) {
Spacer(modifier = Modifier.width(8.dp))

View File

@@ -21,10 +21,7 @@ import compose.icons.TablerIcons
import compose.icons.tablericons.*
import kotlinx.coroutines.launch
/**
* Full-screen connection logs viewer.
* Shows all protocol/WebSocket logs from ProtocolManager.debugLogs.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectionLogsScreen(
@@ -43,7 +40,6 @@ fun ConnectionLogsScreen(
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
// Auto-scroll to bottom when new logs arrive
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1)
@@ -56,7 +52,6 @@ fun ConnectionLogsScreen(
.background(bgColor)
.statusBarsPadding()
) {
// Header
Box(
modifier = Modifier
.fillMaxWidth()
@@ -83,7 +78,6 @@ fun ConnectionLogsScreen(
modifier = Modifier.weight(1f)
)
// Clear button
IconButton(onClick = { ProtocolManager.clearLogs() }) {
Icon(
imageVector = TablerIcons.Trash,
@@ -93,7 +87,6 @@ fun ConnectionLogsScreen(
)
}
// Scroll to bottom
IconButton(onClick = {
scope.launch {
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
@@ -109,7 +102,6 @@ fun ConnectionLogsScreen(
}
}
// Status bar
Row(
modifier = Modifier
.fillMaxWidth()
@@ -159,7 +151,6 @@ fun ConnectionLogsScreen(
)
}
// Logs list
if (logs.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),

View File

@@ -3,6 +3,9 @@ package com.rosetta.messenger.ui.chats
import android.app.Activity
import android.widget.Toast
import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
@@ -25,10 +28,13 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
@@ -79,6 +85,7 @@ import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.onSizeChanged
@@ -105,6 +112,9 @@ import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.OnlineState
import com.rosetta.messenger.network.PacketOnlineState
import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
@@ -149,6 +159,40 @@ private data class GroupSharedStats(
val linksCount: Int = 0
)
private data class GroupMembersCacheEntry(
val members: List<String>,
val memberInfoByKey: Map<String, SearchUser>,
val updatedAtMs: Long
)
private object GroupMembersMemoryCache {
private const val TTL_MS = 90_000L
private val cache = mutableMapOf<String, GroupMembersCacheEntry>()
fun getAny(key: String): GroupMembersCacheEntry? = synchronized(cache) { cache[key] }
fun getFresh(key: String): GroupMembersCacheEntry? = synchronized(cache) {
val entry = cache[key] ?: return@synchronized null
if (System.currentTimeMillis() - entry.updatedAtMs <= TTL_MS) entry else null
}
fun put(key: String, members: List<String>, memberInfoByKey: Map<String, SearchUser>) {
if (key.isBlank()) return
synchronized(cache) {
cache[key] =
GroupMembersCacheEntry(
members = members,
memberInfoByKey = memberInfoByKey,
updatedAtMs = System.currentTimeMillis()
)
}
}
fun remove(key: String) {
synchronized(cache) { cache.remove(key) }
}
}
private data class GroupMediaItem(
val key: String,
val attachment: MessageAttachment,
@@ -160,12 +204,14 @@ private data class GroupMediaItem(
)
private val URL_REGEX = Regex("(?i)\\b((?:https?://|www\\.)[^\\s<>()]+)")
private val KEY_IMAGE_COLORS = listOf(
Color(0xFFD0EBFF),
Color(0xFFA5D8FF),
Color(0xFF74C0FC),
Color(0xFF4DABF7),
Color(0xFF339AF0)
// Identicon colors — same palette for both themes (like Telegram)
// White stays white so the pattern is visible on dark backgrounds
private val IDENTICON_COLORS = intArrayOf(
0xFFFFFFFF.toInt(),
0xFFD0E8FF.toInt(),
0xFF228BE6.toInt(),
0xFF1971C2.toInt()
)
@Composable
@@ -180,8 +226,6 @@ fun GroupInfoScreen(
onGroupLeft: () -> Unit = {},
onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
) {
BackHandler(onBack = onBack)
val context = androidx.compose.ui.platform.LocalContext.current
val view = LocalView.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
@@ -242,16 +286,22 @@ fun GroupInfoScreen(
var showMenu by remember { mutableStateOf(false) }
var showLeaveConfirm by remember { mutableStateOf(false) }
var isLeaving by remember { mutableStateOf(false) }
var showEncryptionDialog by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var showEncryptionPage by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var encryptionKey by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var encryptionKeyLoading by remember { mutableStateOf(false) }
var membersLoading by remember { mutableStateOf(false) }
var isMuted by remember { mutableStateOf(false) }
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) }
val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf<String, SearchUser>() }
// Real online status from PacketOnlineState (0x05), NOT from SearchUser.online
val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf<String, Boolean>() }
val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) {
"${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}"
}
val groupEntity by produceState<com.rosetta.messenger.database.GroupEntity?>(
initialValue = null,
@@ -341,61 +391,150 @@ fun GroupInfoScreen(
}
}
fun refreshMembers() {
fun refreshMembers(force: Boolean = false, showLoader: Boolean = true) {
if (normalizedGroupId.isBlank()) return
if (isRefreshingMembers && !force) return
scope.launch {
membersLoading = true
val fetchedMembers = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(normalizedGroupId).orEmpty()
if (!force) {
GroupMembersMemoryCache.getFresh(membersCacheKey)?.let { cached ->
members = cached.members
memberInfoByKey.clear()
memberInfoByKey.putAll(cached.memberInfoByKey)
return@launch
}
}
members = fetchedMembers.distinct()
membersLoading = false
if (members.isEmpty()) return@launch
val hasAnyCache = GroupMembersMemoryCache.getAny(membersCacheKey) != null
val shouldShowLoader = showLoader && members.isEmpty() && !hasAnyCache
if (shouldShowLoader) membersLoading = true
isRefreshingMembers = true
try {
val fetchedMembers = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(normalizedGroupId).orEmpty()
}
val distinctMembers = fetchedMembers.distinct()
if (distinctMembers.isNotEmpty() || members.isEmpty()) {
members = distinctMembers
}
val resolvedUsers = withContext(Dispatchers.IO) {
val resolvedMap = LinkedHashMap<String, SearchUser>()
members.forEach { memberKey ->
val cached = ProtocolManager.getCachedUserInfo(memberKey)
if (cached != null) {
resolvedMap[memberKey] = cached
} else {
ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser ->
resolvedMap[memberKey] = resolvedUser
if (members.isEmpty()) return@launch
val resolvedUsers = withContext(Dispatchers.IO) {
val resolvedMap = LinkedHashMap<String, SearchUser>()
members.forEach { memberKey ->
val cached = ProtocolManager.getCachedUserInfo(memberKey)
if (cached != null) {
resolvedMap[memberKey] = cached
} else {
ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser ->
resolvedMap[memberKey] = resolvedUser
}
}
}
resolvedMap
}
resolvedMap
}
if (resolvedUsers.isNotEmpty()) {
memberInfoByKey.putAll(resolvedUsers)
if (resolvedUsers.isNotEmpty()) {
memberInfoByKey.putAll(resolvedUsers)
}
GroupMembersMemoryCache.put(
key = membersCacheKey,
members = members,
memberInfoByKey = memberInfoByKey.toMap()
)
} finally {
if (shouldShowLoader) membersLoading = false
isRefreshingMembers = false
}
}
}
LaunchedEffect(normalizedGroupId) {
refreshMembers()
LaunchedEffect(membersCacheKey) {
val cachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey)
cachedEntry?.let { cached ->
members = cached.members
memberInfoByKey.clear()
memberInfoByKey.putAll(cached.memberInfoByKey)
}
if (GroupMembersMemoryCache.getFresh(membersCacheKey) == null) {
refreshMembers(force = true, showLoader = cachedEntry == null)
}
}
val onlineCount by remember(members, memberInfoByKey) {
// 🟢 Subscribe to online status for group members via PacketOnlineSubscribe / PacketOnlineState
// Desktop parity: users start as OFFLINE, only become ONLINE when server sends 0x05
val onlinePacketHandler = remember<(com.rosetta.messenger.network.Packet) -> Unit>(dialogPublicKey) {
{ packet ->
val onlinePacket = packet as PacketOnlineState
onlinePacket.publicKeysState.forEach { item ->
memberOnlineStatus[item.publicKey] = (item.state == OnlineState.ONLINE)
}
}
}
DisposableEffect(dialogPublicKey) {
ProtocolManager.waitPacket(0x05, onlinePacketHandler)
onDispose {
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
}
}
// Subscribe to online status whenever members list changes
LaunchedEffect(members, currentUserPrivateKey) {
if (members.isEmpty() || currentUserPrivateKey.isBlank()) return@LaunchedEffect
withContext(Dispatchers.IO) {
try {
val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey)
val keysToSubscribe = members.filter { key ->
!key.trim().equals(currentUserPublicKey.trim(), ignoreCase = true)
}
if (keysToSubscribe.isNotEmpty()) {
val packet = PacketOnlineSubscribe().apply {
this.privateKey = privateKeyHash
keysToSubscribe.forEach { addPublicKey(it) }
}
ProtocolManager.send(packet)
}
} catch (_: Exception) {}
}
}
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
val onlineCount by remember(members, memberOnlineStatus, normalizedCurrentUserKey) {
derivedStateOf {
members.count { key -> (memberInfoByKey[key]?.online ?: 0) > 0 }
if (members.isEmpty()) {
0
} else {
val selfOnline = if (normalizedCurrentUserKey.isNotBlank()) 1 else 0
val othersOnline =
members.count { key ->
val isCurrentUser =
normalizedCurrentUserKey.isNotBlank() &&
key.trim().equals(normalizedCurrentUserKey, ignoreCase = true)
!isCurrentUser && (memberOnlineStatus[key] == true)
}
selfOnline + othersOnline
}
}
}
val memberItems by remember(members, memberInfoByKey, searchQuery) {
val memberItems by remember(members, memberInfoByKey, memberOnlineStatus, searchQuery) {
derivedStateOf {
val query = searchQuery.trim().lowercase()
members.mapIndexed { index, key ->
val info = memberInfoByKey[key]
val isOnline = memberOnlineStatus[key] == true ||
key.trim().equals(currentUserPublicKey.trim(), ignoreCase = true)
val fallbackName = shortPublicKey(key)
val displayTitle =
info?.title?.takeIf { it.isNotBlank() }
?: info?.username?.takeIf { it.isNotBlank() }
?: fallbackName
val subtitle = when {
(info?.online ?: 0) > 0 -> "online"
isOnline -> "online"
info?.username?.isNotBlank() == true -> "@${info.username}"
else -> key.take(18)
}
@@ -404,14 +543,14 @@ fun GroupInfoScreen(
title = displayTitle,
subtitle = subtitle,
verified = info?.verified ?: 0,
online = (info?.online ?: 0) > 0,
online = isOnline,
isAdmin = index == 0,
searchUser = SearchUser(
publicKey = key,
title = info?.title ?: displayTitle,
username = info?.username.orEmpty(),
verified = info?.verified ?: 0,
online = info?.online ?: 0
online = if (isOnline) 1 else 0
)
)
}.filter { member ->
@@ -425,7 +564,6 @@ fun GroupInfoScreen(
}
}
}
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
val currentUserIsAdmin by remember(members, normalizedCurrentUserKey) {
derivedStateOf {
members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true
@@ -435,6 +573,14 @@ fun GroupInfoScreen(
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(null) }
var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) }
BackHandler {
if (showEncryptionPage) {
showEncryptionPage = false
} else {
onBack()
}
}
LaunchedEffect(selectedTab) {
if (selectedTab != GroupInfoTab.MEMBERS) {
swipedMemberKey = null
@@ -541,6 +687,7 @@ fun GroupInfoScreen(
}
isLeaving = false
if (left) {
GroupMembersMemoryCache.remove(membersCacheKey)
onGroupLeft()
} else {
Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show()
@@ -580,7 +727,12 @@ fun GroupInfoScreen(
if (removed) {
members = members.filterNot { it.trim().equals(memberKey, ignoreCase = true) }
memberInfoByKey.remove(member.publicKey)
refreshMembers()
GroupMembersMemoryCache.put(
key = membersCacheKey,
members = members,
memberInfoByKey = memberInfoByKey.toMap()
)
refreshMembers(force = true, showLoader = false)
Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(context, "Failed to remove member", Toast.LENGTH_SHORT).show()
@@ -605,7 +757,7 @@ fun GroupInfoScreen(
return@launch
}
encryptionKey = key
showEncryptionDialog = true
showEncryptionPage = true
}
}
@@ -1115,60 +1267,24 @@ fun GroupInfoScreen(
)
}
if (showEncryptionDialog) {
AnimatedVisibility(
visible = showEncryptionPage,
enter = fadeIn(animationSpec = tween(durationMillis = 260)),
exit = fadeOut(animationSpec = tween(durationMillis = 200)),
modifier = Modifier.fillMaxSize()
) {
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
AlertDialog(
onDismissRequest = { showEncryptionDialog = false },
title = { Text("Encryption key") },
text = {
Column {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
DesktopStyleKeyImage(
keyRender = encryptionKey,
size = 180.dp,
radius = 14.dp
)
}
Spacer(modifier = Modifier.height(12.dp))
SelectionContainer {
Column {
if (displayLines.isNotEmpty()) {
displayLines.forEach { line ->
Text(
text = line,
color = secondaryText,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
Spacer(modifier = Modifier.height(10.dp))
}
Text(
text = "This key encrypts and decrypts group messages.",
color = secondaryText,
fontSize = 12.sp
)
}
}
}
},
confirmButton = {
TextButton(
onClick = {
clipboardManager.setText(AnnotatedString(encryptionKey))
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
}
) {
Text("Copy", color = accentColor)
}
},
dismissButton = {
TextButton(onClick = { showEncryptionDialog = false }) {
Text("Close", color = secondaryText)
}
GroupEncryptionKeyPage(
encryptionKey = encryptionKey,
displayLines = displayLines,
peerTitle = groupTitle,
isDarkTheme = isDarkTheme,
topSurfaceColor = topSurfaceColor,
backgroundColor = backgroundColor,
onBack = { showEncryptionPage = false },
onCopy = {
clipboardManager.setText(AnnotatedString(encryptionKey))
Toast.makeText(context, "Encryption key copied", Toast.LENGTH_SHORT).show()
}
)
}
@@ -1241,38 +1357,179 @@ fun GroupInfoScreen(
}
@Composable
private fun DesktopStyleKeyImage(
private fun TelegramStyleIdenticon(
keyRender: String,
size: androidx.compose.ui.unit.Dp,
radius: androidx.compose.ui.unit.Dp = 0.dp
isDarkTheme: Boolean
) {
val composition = remember(keyRender) {
buildList(64) {
val source = if (keyRender.isBlank()) "rosetta" else keyRender
for (i in 0 until 64) {
val code = source[i % source.length].code
val colorIndex = code % KEY_IMAGE_COLORS.size
add(KEY_IMAGE_COLORS[colorIndex])
}
}
val palette = IDENTICON_COLORS
// Convert key string to byte array, then use 2-bit grouping like Telegram's IdenticonDrawable
val keyBytes = remember(keyRender) {
val source = keyRender.ifBlank { "rosetta" }
source.toByteArray(Charsets.UTF_8)
}
Canvas(
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(radius))
.background(KEY_IMAGE_COLORS.first())
) {
val cells = 8
val cells = 12
val cellSize = this.size.minDimension / cells.toFloat()
for (i in 0 until 64) {
val row = i / cells
val col = i % cells
drawRect(
color = composition[i],
topLeft = Offset(col * cellSize, row * cellSize),
size = Size(cellSize, cellSize)
)
var bitPointer = 0
for (iy in 0 until cells) {
for (ix in 0 until cells) {
val byteIndex = (bitPointer / 8) % keyBytes.size
val bitOffset = bitPointer % 8
val value = (keyBytes[byteIndex].toInt() shr bitOffset) and 0x3
val colorIndex = kotlin.math.abs(value) % 4
drawRect(
color = Color(palette[colorIndex]),
topLeft = Offset(ix * cellSize, iy * cellSize),
size = Size(cellSize, cellSize)
)
bitPointer += 2
}
}
}
}
@Composable
private fun GroupEncryptionKeyPage(
encryptionKey: String,
displayLines: List<String>,
peerTitle: String,
isDarkTheme: Boolean,
topSurfaceColor: Color,
backgroundColor: Color,
onBack: () -> Unit,
onCopy: () -> Unit
) {
val uriHandler = LocalUriHandler.current
val safePeerTitle = peerTitle.ifBlank { "this group" }
// Rosetta theme colors
val identiconBg = if (isDarkTheme) Color(0xFF111111) else Color(0xFFF2F2F7)
val bottomBg = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White
val codeColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF6D7883)
val descriptionColor = Color(0xFF8E8E93)
val linkColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
) {
// Top bar
Row(
modifier = Modifier
.fillMaxWidth()
.background(topSurfaceColor)
.padding(horizontal = 4.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Default.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
Text(
text = "Encryption Key",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onCopy) {
Text(
text = "Copy",
color = Color.White.copy(alpha = 0.9f),
fontWeight = FontWeight.Medium
)
}
}
// Two-half layout like Telegram
Column(
modifier = Modifier
.fillMaxSize()
.weight(1f)
) {
// Top half - Identicon image on gray background
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(identiconBg)
.padding(24.dp),
contentAlignment = Alignment.Center
) {
TelegramStyleIdenticon(
keyRender = encryptionKey,
size = 280.dp,
isDarkTheme = isDarkTheme
)
}
// Bottom half - Code + Description on white background
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(bottomBg)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Hex code display
SelectionContainer {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
displayLines.forEach { line ->
Text(
text = line,
color = codeColor,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
letterSpacing = 0.5.sp,
textAlign = TextAlign.Center
)
}
}
}
Spacer(modifier = Modifier.height(20.dp))
// Description text
Text(
text = "This image and text were derived from the encryption key for this group with $safePeerTitle.\n\nIf they look the same on $safePeerTitle's device, end-to-end encryption is guaranteed.",
color = descriptionColor,
fontSize = 14.sp,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Learn more at rosetta.im",
color = linkColor,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.clickable { uriHandler.openUri("https://rosetta.im/") }
)
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
}
@@ -1772,14 +2029,24 @@ private fun encodeGroupKeyForDisplay(encryptKey: String): List<String> {
val normalized = encryptKey.trim()
if (normalized.isBlank()) return emptyList()
// Telegram-style: each char → XOR 27 → 2-char hex, grouped as:
// "ab cd ef 12 34 56 78 9a" (4 pairs + double space + 4 pairs per line)
val hexPairs = normalized.map { symbol ->
(symbol.code xor 27).toString(16).padStart(2, '0')
}
val lines = mutableListOf<String>()
normalized.chunked(16).forEach { chunk ->
val bytes = mutableListOf<String>()
chunk.forEach { symbol ->
val encoded = (symbol.code xor 27).toString(16).padStart(2, '0')
bytes.add(encoded)
for (lineStart in hexPairs.indices step 8) {
val lineEnd = minOf(lineStart + 8, hexPairs.size)
val linePairs = hexPairs.subList(lineStart, lineEnd)
val sb = StringBuilder()
for ((i, pair) in linePairs.withIndex()) {
if (i > 0) {
sb.append(if (i % 4 == 0) " " else " ")
}
sb.append(pair)
}
lines.add(bytes.joinToString(" "))
lines.add(sb.toString())
}
return lines
}

View File

@@ -1,61 +1,186 @@
package com.rosetta.messenger.ui.chats
import android.content.Context
import android.net.Uri
import android.app.Activity
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.view.WindowCompat
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.R
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.ImageCropHelper
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@OptIn(ExperimentalMaterial3Api::class)
private enum class GroupSetupStep {
DETAILS,
DESCRIPTION
}
@Composable
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
fun GroupSetupScreen(
isDarkTheme: Boolean,
accountPublicKey: String,
accountPrivateKey: String,
accountName: String,
accountUsername: String,
avatarRepository: AvatarRepository? = null,
onBack: () -> Unit,
onGroupOpened: (SearchUser) -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val view = LocalView.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val nameFocusRequester = remember { FocusRequester() }
var selectedTab by remember { mutableIntStateOf(0) }
var title by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
var inviteString by remember { mutableStateOf("") }
var isLoading by remember { mutableStateOf(false) }
var errorText by remember { mutableStateOf<String?>(null) }
var step by rememberSaveable { mutableStateOf(GroupSetupStep.DETAILS) }
var title by rememberSaveable { mutableStateOf("") }
var description by rememberSaveable { mutableStateOf("") }
var selectedAvatarUri by rememberSaveable { mutableStateOf<String?>(null) }
var isLoading by rememberSaveable { mutableStateOf(false) }
var errorText by rememberSaveable { mutableStateOf<String?>(null) }
var showEmojiKeyboard by rememberSaveable { mutableStateOf(false) }
var showPhotoPicker by rememberSaveable { mutableStateOf(false) }
val coordinator = rememberKeyboardTransitionCoordinator()
var lastToggleTime by remember { mutableLongStateOf(0L) }
val toggleCooldownMs = 500L
val cropLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
val cropError = ImageCropHelper.getCropError(result)
if (croppedUri != null) {
selectedAvatarUri = croppedUri.toString()
} else if (cropError != null) {
android.widget.Toast
.makeText(context, "Failed to crop photo", android.widget.Toast.LENGTH_SHORT)
.show()
}
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val topSurfaceColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val sectionColor = if (isDarkTheme) Color(0xFF222224) else Color.White
val primaryTextColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = Color(0xFF8E8E93)
val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
androidx.compose.runtime.DisposableEffect(topSurfaceColor, view) {
val window = (view.context as? Activity)?.window
if (window == null) {
onDispose { }
} else {
val controller = WindowCompat.getInsetsController(window, view)
val previousColor = window.statusBarColor
val previousLightIcons = controller.isAppearanceLightStatusBars
window.statusBarColor = topSurfaceColor.toArgb()
controller.isAppearanceLightStatusBars = false
onDispose {
window.statusBarColor = previousColor
controller.isAppearanceLightStatusBars = previousLightIcons
}
}
}
val normalizedUsername = remember(accountUsername) {
accountUsername.trim().trimStart('@')
}
val selfTitle =
remember(accountName, normalizedUsername, accountPublicKey) {
accountName.trim()
.ifBlank { normalizedUsername }
.ifBlank { shortPublicKey(accountPublicKey) }
}
val selfSubtitle = if (normalizedUsername.isNotBlank()) "@$normalizedUsername" else "you"
fun openGroup(dialogPublicKey: String, groupTitle: String) {
onGroupOpened(
@@ -77,13 +202,6 @@ fun GroupSetupScreen(
description = description.trim()
)
suspend fun joinGroup() =
GroupRepository.getInstance(context).joinGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
inviteString = inviteString.trim()
)
fun mapError(status: GroupStatus, fallback: String): String {
return when (status) {
GroupStatus.BANNED -> "You are banned in this group"
@@ -92,157 +210,615 @@ fun GroupSetupScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Groups", fontWeight = FontWeight.SemiBold) },
navigationIcon = {
TextButton(onClick = onBack) {
Text("Back")
}
}
)
fun handleBack() {
if (isLoading) return
errorText = null
if (step == GroupSetupStep.DESCRIPTION) {
step = GroupSetupStep.DETAILS
} else {
onBack()
}
) { paddingValues ->
Column(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.Top
) {
TabRow(selectedTabIndex = selectedTab) {
Tab(
selected = selectedTab == 0,
onClick = {
selectedTab = 0
errorText = null
},
text = { Text("Create") }
)
Tab(
selected = selectedTab == 1,
onClick = {
selectedTab = 1
errorText = null
},
text = { Text("Join") }
)
}
}
Spacer(modifier = Modifier.height(16.dp))
BackHandler(onBack = ::handleBack)
if (selectedTab == 0) {
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Group title") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = { Text("Description (optional)") },
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
minLines = 3,
maxLines = 4
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isLoading) return@Button
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { createGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
openGroup(result.dialogPublicKey, result.title)
} else {
errorText =
mapError(
result.status,
result.error ?: "Cannot create group"
)
}
isLoading = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = title.trim().isNotEmpty() && !isLoading
) {
if (isLoading) {
CircularProgressIndicator(strokeWidth = 2.dp)
} else {
Text("Create Group")
}
}
} else {
OutlinedTextField(
value = inviteString,
onValueChange = { inviteString = it },
label = { Text("Invite string") },
modifier = Modifier.fillMaxWidth(),
minLines = 3,
maxLines = 6,
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
if (isLoading) return@Button
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { joinGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
openGroup(result.dialogPublicKey, result.title)
} else {
errorText =
mapError(
result.status,
result.error ?: "Cannot join group"
)
}
isLoading = false
}
},
modifier = Modifier.fillMaxWidth(),
enabled = inviteString.trim().isNotEmpty() && !isLoading
) {
if (isLoading) {
CircularProgressIndicator(strokeWidth = 2.dp)
} else {
Text("Join Group")
}
}
}
val canGoNext = title.trim().isNotEmpty()
val canCreate = canGoNext && !isLoading
val actionEnabled = if (step == GroupSetupStep.DETAILS) canGoNext else canCreate
val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density)
val imeBottomDp = with(density) { imeBottomPx.toDp() }
if (!errorText.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = errorText ?: "",
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.height(16.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text =
if (selectedTab == 0) {
"Creates a new private group and joins it automatically."
} else {
"Paste a full invite string that starts with #group:."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
LaunchedEffect(step) {
if (step != GroupSetupStep.DETAILS) {
if (showEmojiKeyboard || coordinator.isEmojiVisible || coordinator.isEmojiBoxVisible) {
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
}
}
}
LaunchedEffect(Unit) {
val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context)
if (savedPx > 0) {
coordinator.initializeEmojiHeight(with(density) { savedPx.toDp() })
}
}
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
var isKeyboardVisible by remember { mutableStateOf(false) }
LaunchedEffect(imeBottomPx) {
val currentImeHeight = with(density) { imeBottomPx.toDp() }
coordinator.updateKeyboardHeight(currentImeHeight)
isKeyboardVisible = currentImeHeight > 50.dp
if (currentImeHeight > 100.dp) {
coordinator.syncHeights()
lastStableKeyboardHeight = currentImeHeight
}
}
// Save keyboard height for emoji picker sync
LaunchedEffect(isKeyboardVisible, showEmojiKeyboard) {
if (isKeyboardVisible && !showEmojiKeyboard) {
kotlinx.coroutines.delay(350)
if (isKeyboardVisible && !showEmojiKeyboard && lastStableKeyboardHeight > 300.dp) {
val heightPx = with(density) { lastStableKeyboardHeight.toPx().toInt() }
KeyboardHeightProvider.saveKeyboardHeight(context, heightPx)
}
}
}
fun toggleEmojiPicker() {
val now = System.currentTimeMillis()
if (now - lastToggleTime < toggleCooldownMs || step != GroupSetupStep.DETAILS || isLoading) return
lastToggleTime = now
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (coordinator.isEmojiVisible) {
coordinator.requestShowKeyboard(
showKeyboard = {
nameFocusRequester.requestFocus()
keyboardController?.show()
@Suppress("DEPRECATION")
imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
},
hideEmoji = { showEmojiKeyboard = false }
)
return
}
if (imeBottomPx > 0) {
coordinator.requestShowEmoji(
hideKeyboard = {
imm.hideSoftInputFromWindow(view.windowToken, 0)
keyboardController?.hide()
},
showEmoji = {
focusManager.clearFocus(force = true)
showEmojiKeyboard = true
}
)
return
}
if (coordinator.emojiHeight == 0.dp) {
val fallbackHeight = if (lastStableKeyboardHeight > 0.dp) {
lastStableKeyboardHeight
} else {
with(density) { KeyboardHeightProvider.getSavedKeyboardHeight(context).toDp() }
}
coordinator.initializeEmojiHeight(fallbackHeight)
}
coordinator.openEmojiOnly(
showEmoji = {
focusManager.clearFocus(force = true)
showEmojiKeyboard = true
}
)
}
LaunchedEffect(Unit) {
if (step == GroupSetupStep.DETAILS) {
delay(120)
nameFocusRequester.requestFocus()
keyboardController?.show()
}
}
Scaffold(
containerColor = backgroundColor,
topBar = {
TopAppBar(
title = {
Text(
text = if (step == GroupSetupStep.DETAILS) "New Group" else "Group Description",
fontWeight = FontWeight.SemiBold,
color = Color.White,
fontSize = 20.sp
)
},
navigationIcon = {
IconButton(onClick = ::handleBack) {
Icon(
painter = TelegramIcons.ArrowBack,
contentDescription = "Back",
tint = Color.White
)
}
},
colors =
TopAppBarDefaults.topAppBarColors(
containerColor = topSurfaceColor,
titleContentColor = Color.White,
navigationIconContentColor = Color.White
)
)
},
bottomBar = {
if (step == GroupSetupStep.DETAILS) {
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiKeyboard
) {
OptimizedEmojiPicker(
isVisible = true,
isDarkTheme = isDarkTheme,
onEmojiSelected = { emojiCode ->
val emoji = decodeEmojiCodeToUnicode(emojiCode)
title = (title + emoji).take(80)
},
onClose = { toggleEmojiPicker() },
modifier = Modifier.fillMaxWidth()
)
}
}
}
) { paddingValues ->
// Outer Box fills the full Scaffold area (no paddingValues applied)
// so the FAB can be positioned consistently above *any* keyboard.
Box(modifier = Modifier.fillMaxSize()) {
// Content area respects Scaffold padding (topBar + bottomBar/emoji)
Column(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
.navigationBarsPadding()
) {
if (step == GroupSetupStep.DETAILS) {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier
.size(72.dp)
) {
Box(
modifier =
Modifier
.size(64.dp)
.clip(CircleShape)
.background(sectionColor)
.clickable(enabled = !isLoading) {
showPhotoPicker = true
},
contentAlignment = Alignment.Center
) {
if (!selectedAvatarUri.isNullOrBlank()) {
AsyncImage(
model =
ImageRequest.Builder(context)
.data(Uri.parse(selectedAvatarUri))
.crossfade(true)
.build(),
contentDescription = "Group avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
painter = TelegramIcons.Camera,
contentDescription = "Set group avatar",
tint = accentColor,
modifier = Modifier.size(24.dp)
)
}
}
}
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = title,
onValueChange = { newValue -> title = newValue.take(80) },
singleLine = true,
textStyle = TextStyle(
color = primaryTextColor,
fontSize = 18.sp,
fontWeight = FontWeight.Medium
),
cursorBrush = SolidColor(accentColor),
enabled = !isLoading,
modifier =
Modifier
.fillMaxWidth()
.focusRequester(nameFocusRequester)
.onFocusChanged { focusState ->
if (focusState.isFocused &&
showEmojiKeyboard &&
!coordinator.isTransitioning
) {
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
}
}
.padding(vertical = 2.dp),
decorationBox = { innerTextField ->
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.CenterStart
) {
if (title.isBlank()) {
Text(
text = "Group name",
color = secondaryTextColor.copy(alpha = 0.88f),
fontSize = 18.sp,
fontWeight = FontWeight.Normal
)
}
innerTextField()
}
}
)
}
IconButton(
onClick = { toggleEmojiPicker() },
enabled = !isLoading
) {
Icon(
painter = if (showEmojiKeyboard || coordinator.isEmojiBoxVisible) {
TelegramIcons.Keyboard
} else {
TelegramIcons.Smile
},
contentDescription = "Emoji",
tint = secondaryTextColor,
modifier = Modifier.size(22.dp)
)
}
}
Spacer(modifier = Modifier.height(6.dp))
Box(
modifier =
Modifier
.fillMaxWidth()
.height(2.dp)
.background(accentColor.copy(alpha = 0.9f), RoundedCornerShape(1.dp))
)
}
}
Spacer(modifier = Modifier.height(22.dp))
Text(
text = "1 member",
color = accentColor,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
modifier = Modifier.padding(bottom = 0.dp)
)
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(sectionColor)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarImage(
publicKey = accountPublicKey,
avatarRepository = avatarRepository,
size = 50.dp,
isDarkTheme = isDarkTheme,
displayName = selfTitle
)
Spacer(modifier = Modifier.size(12.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.Center
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Text(
text = selfTitle,
color = primaryTextColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Icon(
painter = painterResource(id = R.drawable.ic_arrow_badge_down_filled),
contentDescription = "Admin",
tint = Color(0xFFF6C445),
modifier = Modifier.size(14.dp)
)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = selfSubtitle,
color = secondaryTextColor,
fontSize = 13.sp
)
}
}
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "After creating the group, you can invite as many users as you need.",
color = secondaryTextColor,
fontSize = 13.sp,
lineHeight = 18.sp
)
} else {
Spacer(modifier = Modifier.height(16.dp))
Row(
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(cardColor)
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(50.dp).clip(CircleShape).background(accentColor.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center
) {
if (!selectedAvatarUri.isNullOrBlank()) {
AsyncImage(
model =
ImageRequest.Builder(context)
.data(Uri.parse(selectedAvatarUri))
.crossfade(true)
.build(),
contentDescription = "Group avatar preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
painter = TelegramIcons.Photos,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(24.dp)
)
}
}
Spacer(modifier = Modifier.size(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title.trim(),
color = primaryTextColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Add a description (optional)",
color = secondaryTextColor,
fontSize = 13.sp
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Description",
color = secondaryTextColor,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
modifier = Modifier.padding(bottom = 8.dp)
)
TextField(
value = description,
onValueChange = { newValue -> description = newValue.take(400) },
modifier =
Modifier
.fillMaxWidth()
.height(150.dp)
.clip(RoundedCornerShape(14.dp))
.background(sectionColor),
minLines = 6,
maxLines = 8,
textStyle = MaterialTheme.typography.bodyLarge.copy(color = primaryTextColor, fontSize = 16.sp),
placeholder = {
Text(
text = "Group description",
color = secondaryTextColor,
fontSize = 16.sp
)
},
colors =
TextFieldDefaults.colors(
focusedTextColor = primaryTextColor,
unfocusedTextColor = primaryTextColor,
focusedContainerColor = sectionColor,
unfocusedContainerColor = sectionColor,
disabledContainerColor = sectionColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
focusedPlaceholderColor = secondaryTextColor,
unfocusedPlaceholderColor = secondaryTextColor,
cursorColor = accentColor
)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Description is optional. You can change it later in group info.",
color = secondaryTextColor,
fontSize = 13.sp,
textAlign = TextAlign.Start
)
}
if (!errorText.isNullOrBlank()) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = errorText.orEmpty(),
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
FloatingActionButton(
onClick = {
if (step == GroupSetupStep.DETAILS) {
if (canGoNext) {
errorText = null
step = GroupSetupStep.DESCRIPTION
}
return@FloatingActionButton
}
if (!canCreate) return@FloatingActionButton
errorText = null
isLoading = true
scope.launch {
val result = withContext(Dispatchers.IO) { createGroup() }
if (result.success && !result.dialogPublicKey.isNullOrBlank()) {
withContext(Dispatchers.IO) {
persistLocalGroupAvatar(
context = context,
avatarRepository = avatarRepository,
groupDialogPublicKey = result.dialogPublicKey,
avatarUriString = selectedAvatarUri
)
}
openGroup(
dialogPublicKey = result.dialogPublicKey,
groupTitle = result.title.ifBlank { title.trim() }
)
} else {
errorText = mapError(result.status, result.error ?: "Cannot create group")
}
isLoading = false
}
},
containerColor = if (actionEnabled) accentColor else accentColor.copy(alpha = 0.42f),
contentColor = Color.White,
shape = CircleShape,
modifier = run {
// Берём максимум из всех позиций — при переключении keyboard↔emoji
// одна уходит вниз, другая уже на месте, FAB не прыгает.
val keyboardBottom = if (imeBottomDp > 0.dp) imeBottomDp + 14.dp else 0.dp
val emojiBottom = if (coordinator.isEmojiBoxVisible && coordinator.emojiHeight > 0.dp) coordinator.emojiHeight + 14.dp else 0.dp
val fabBottom = maxOf(keyboardBottom, emojiBottom, 18.dp)
Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = fabBottom)
.size(58.dp)
}
) {
if (isLoading && step == GroupSetupStep.DESCRIPTION) {
CircularProgressIndicator(
color = Color.White,
strokeWidth = 2.dp,
modifier = Modifier.size(22.dp)
)
} else {
Icon(
painter = TelegramIcons.Done,
contentDescription = "Continue"
)
}
}
}
}
ProfilePhotoPicker(
isVisible = showPhotoPicker,
onDismiss = { showPhotoPicker = false },
onPhotoSelected = { uri ->
showPhotoPicker = false
val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme)
cropLauncher.launch(cropIntent)
},
isDarkTheme = isDarkTheme
)
}
private suspend fun persistLocalGroupAvatar(
context: android.content.Context,
avatarRepository: AvatarRepository?,
groupDialogPublicKey: String,
avatarUriString: String?
) {
val repository = avatarRepository ?: return
val safeUri = avatarUriString?.takeIf { it.isNotBlank() } ?: return
val uri = runCatching { Uri.parse(safeUri) }.getOrNull() ?: return
val rawBytes =
runCatching {
context.contentResolver.openInputStream(uri)?.use { stream -> stream.readBytes() }
}.getOrNull() ?: return
if (rawBytes.isEmpty()) return
val preparedBase64 = AvatarFileManager.imagePrepareForNetworkTransfer(context, rawBytes)
if (preparedBase64.isBlank()) return
repository.saveAvatar(
fromPublicKey = groupDialogPublicKey,
base64Image = "data:image/png;base64,$preparedBase64"
)
}
private fun decodeEmojiCodeToUnicode(value: String): String {
val match = Regex("^:emoji_([a-fA-F0-9_-]+):$").matchEntire(value) ?: return value
val codePoints =
match.groupValues[1]
.split("-")
.mapNotNull { code -> code.toIntOrNull(16) }
if (codePoints.isEmpty()) return value
return String(codePoints.toIntArray(), 0, codePoints.size)
}
private fun shortPublicKey(publicKey: String): String {
val normalized = publicKey.trim()
return if (normalized.length <= 12) normalized else "${normalized.take(6)}...${normalized.takeLast(4)}"
}

View File

@@ -122,6 +122,8 @@ internal fun CameraGridItem(
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
val iconScale = remember { Animatable(0f) }
LaunchedEffect(Unit) {
@@ -145,6 +147,21 @@ internal fun CameraGridItem(
) == PackageManager.PERMISSION_GRANTED
}
DisposableEffect(lifecycleOwner, hasCameraPermission) {
onDispose {
val provider = cameraProvider
val preview = previewUseCase
if (provider != null && preview != null) {
try {
provider.unbind(preview)
} catch (_: Exception) {
}
}
previewUseCase = null
cameraProvider = null
}
}
Box(
modifier = Modifier
.aspectRatio(1f)
@@ -164,17 +181,24 @@ internal fun CameraGridItem(
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
try {
val cameraProvider = cameraProviderFuture.get()
val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
previewUseCase?.let { existing ->
try {
provider.unbind(existing)
} catch (_: Exception) {
}
}
provider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview
)
previewUseCase = preview
} catch (_: Exception) {
// Camera init failed
}

View File

@@ -440,6 +440,17 @@ fun ChatAttachAlert(
} else {
requestPermissions()
}
} else if (shouldShow || isClosing) {
// Parent hidden externally (e.g. app background): force-close immediately.
// This guarantees camera preview does not stay active across app reopen.
pendingCaptionFocus = false
captionInputActive = false
showEmojiPicker = false
coordinator.isEmojiVisible = false
coordinator.isEmojiBoxVisible = false
isClosing = false
shouldShow = false
showAlbumMenu = false
}
}

View File

@@ -1433,7 +1433,6 @@ fun FileAttachment(
messageStatus: MessageStatus = MessageStatus.READ
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var downloadStatus by remember { mutableStateOf(DownloadStatus.PENDING) }
var downloadProgress by remember { mutableStateOf(0f) }
@@ -1465,7 +1464,30 @@ fun FileAttachment(
val downloadsDir = remember { File(context.filesDir, "rosetta_downloads").apply { mkdirs() } }
val savedFile = remember(fileName) { File(downloadsDir, fileName) }
// 📥 Подписываемся на глобальный FileDownloadManager для live-прогресса
val managerState by com.rosetta.messenger.network.FileDownloadManager
.progressOf(attachment.id)
.collectAsState(initial = null)
// Синхронизируем локальный UI с глобальным менеджером
LaunchedEffect(managerState) {
val state = managerState ?: return@LaunchedEffect
downloadProgress = state.progress
downloadStatus = when (state.status) {
com.rosetta.messenger.network.FileDownloadStatus.QUEUED,
com.rosetta.messenger.network.FileDownloadStatus.DOWNLOADING -> DownloadStatus.DOWNLOADING
com.rosetta.messenger.network.FileDownloadStatus.DECRYPTING -> DownloadStatus.DECRYPTING
com.rosetta.messenger.network.FileDownloadStatus.DONE -> DownloadStatus.DOWNLOADED
com.rosetta.messenger.network.FileDownloadStatus.ERROR -> DownloadStatus.ERROR
}
}
LaunchedEffect(attachment.id) {
// Если менеджер уже качает этот файл — подхватим состояние оттуда
if (com.rosetta.messenger.network.FileDownloadManager.isDownloading(attachment.id)) {
downloadStatus = DownloadStatus.DOWNLOADING
return@LaunchedEffect
}
downloadStatus = if (isDownloadTag(preview)) {
// Проверяем, был ли файл уже скачан ранее
if (savedFile.exists()) DownloadStatus.DOWNLOADED
@@ -1507,76 +1529,20 @@ fun FileAttachment(
}
}
// 📥 Запуск скачивания через глобальный FileDownloadManager
val download: () -> Unit = {
if (downloadTag.isNotEmpty()) {
scope.launch {
try {
downloadStatus = DownloadStatus.DOWNLOADING
// Streaming: скачиваем во temp file, не в память
val success =
if (isGroupStoredKey(chachaKey)) {
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING
val groupPassword = decodeGroupPassword(chachaKey, privateKey)
if (groupPassword.isNullOrBlank()) {
false
} else {
val decrypted = CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
val bytes = decrypted?.let { decodeBase64Payload(it) }
if (bytes != null) {
withContext(Dispatchers.IO) {
savedFile.parentFile?.mkdirs()
savedFile.writeBytes(bytes)
}
true
} else {
false
}
}
} else {
// Streaming: скачиваем во temp file, не в память
val tempFile = TransportManager.downloadFileRaw(attachment.id, downloadTag)
downloadProgress = 0.5f
downloadStatus = DownloadStatus.DECRYPTING
val decryptedKeyAndNonce =
MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
downloadProgress = 0.6f
// Streaming decrypt: tempFile → AES → inflate → base64 → savedFile
// Пиковое потребление памяти ~128KB вместо ~200MB
withContext(Dispatchers.IO) {
try {
MessageCrypto.decryptAttachmentFileStreaming(
tempFile,
decryptedKeyAndNonce,
savedFile
)
} finally {
tempFile.delete()
}
}
}
downloadProgress = 0.95f
if (success) {
downloadProgress = 1f
downloadStatus = DownloadStatus.DOWNLOADED
} else {
downloadStatus = DownloadStatus.ERROR
}
} catch (e: Exception) {
e.printStackTrace()
downloadStatus = DownloadStatus.ERROR
} catch (_: OutOfMemoryError) {
System.gc()
downloadStatus = DownloadStatus.ERROR
}
}
downloadStatus = DownloadStatus.DOWNLOADING
downloadProgress = 0f
com.rosetta.messenger.network.FileDownloadManager.download(
context = context,
attachmentId = attachment.id,
downloadTag = downloadTag,
chachaKey = chachaKey,
privateKey = privateKey,
fileName = fileName,
savedFile = savedFile
)
}
}
@@ -1623,7 +1589,9 @@ fun FileAttachment(
) {
when (downloadStatus) {
DownloadStatus.DOWNLOADING, DownloadStatus.DECRYPTING -> {
// Determinate progress like Telegram
CircularProgressIndicator(
progress = downloadProgress.coerceIn(0f, 1f),
modifier = Modifier.size(24.dp),
color = Color.White,
strokeWidth = 2.dp
@@ -1693,10 +1661,14 @@ fun FileAttachment(
when (downloadStatus) {
DownloadStatus.DOWNLOADING -> {
AnimatedDotsText(
baseText = "Downloading",
color = statusColor,
fontSize = 12.sp
// Telegram-style: "1.2 MB / 5.4 MB"
// CDN download maps to progress 0..0.8
val cdnFraction = (downloadProgress / 0.8f).coerceIn(0f, 1f)
val downloadedBytes = (cdnFraction * fileSize).toLong()
Text(
text = "${formatFileSize(downloadedBytes)} / ${formatFileSize(fileSize)}",
fontSize = 12.sp,
color = statusColor
)
}
DownloadStatus.DECRYPTING -> {

View File

@@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.PersonAdd
import android.graphics.BitmapFactory
@@ -592,11 +593,19 @@ fun MessageBubble(
.IMAGE
}
val isStandaloneGroupInvite =
message.attachments.isEmpty() &&
message.replyData == null &&
message.forwardedMessages.isEmpty() &&
message.text.isNotBlank() &&
isGroupInviteCode(message.text)
// Для сообщений только с фото - минимальный padding и тонкий border
// Для фото + caption - padding только внизу для текста
val bubblePadding =
when {
isSafeSystemMessage -> PaddingValues(0.dp)
isStandaloneGroupInvite -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
hasOnlyMedia -> PaddingValues(0.dp)
hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
@@ -676,6 +685,8 @@ fun MessageBubble(
val bubbleWidthModifier =
if (isSafeSystemMessage) {
Modifier.widthIn(min = 220.dp, max = 320.dp)
} else if (isStandaloneGroupInvite) {
Modifier.widthIn(min = 180.dp, max = 260.dp)
} else if (hasImageWithCaption || hasOnlyMedia) {
Modifier.width(
photoWidth
@@ -703,46 +714,52 @@ fun MessageBubble(
onClick = onClick,
onLongClick = onLongClick
)
.clip(bubbleShape)
.then(
if (hasOnlyMedia) {
Modifier.border(
width = bubbleBorderWidth,
color =
if (message.isOutgoing
) {
Color.White
.copy(
alpha =
0.15f
)
} else {
if (isDarkTheme
)
Color.White
.copy(
alpha =
0.1f
)
else
Color.Black
.copy(
alpha =
0.08f
)
},
shape = bubbleShape
)
} else if (isSafeSystemMessage) {
Modifier.background(
if (isDarkTheme) Color(0xFF2A2A2D)
else Color(0xFFF0F0F4)
)
if (false) {
Modifier
} else {
Modifier.background(bubbleColor)
Modifier.clip(bubbleShape)
.then(
if (hasOnlyMedia) {
Modifier.border(
width = bubbleBorderWidth,
color =
if (message.isOutgoing
) {
Color.White
.copy(
alpha =
0.15f
)
} else {
if (isDarkTheme
)
Color.White
.copy(
alpha =
0.1f
)
else
Color.Black
.copy(
alpha =
0.08f
)
},
shape = bubbleShape
)
} else if (isSafeSystemMessage) {
Modifier.background(
if (isDarkTheme) Color(0xFF2A2A2D)
else Color(0xFFF0F0F4)
)
} else {
Modifier.background(bubbleColor)
}
)
.padding(bubblePadding)
}
)
.padding(bubblePadding)
) {
if (isSafeSystemMessage) {
SafeSystemMessageCard(
@@ -1045,35 +1062,12 @@ fun MessageBubble(
accountPublicKey = currentUserPublicKey,
accountPrivateKey = privateKey,
actionsEnabled = !isSelectionMode,
timestamp = message.timestamp,
messageStatus = displayStatus,
onRetry = onRetry,
onDelete = onDelete,
onOpenGroup = onGroupInviteOpen
)
Spacer(modifier = Modifier.height(6.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = timeFormat.format(message.timestamp),
color = timeColor,
fontSize = 11.sp,
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
)
if (message.isOutgoing) {
Spacer(modifier = Modifier.width(2.dp))
AnimatedMessageStatus(
status = displayStatus,
timeColor = statusColor,
isDarkTheme = isDarkTheme,
isOutgoing = message.isOutgoing,
timestamp = message.timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
}
}
} else {
// Telegram-style: текст + время с автоматическим
// переносом
@@ -1275,6 +1269,10 @@ private fun GroupInviteInlineCard(
accountPublicKey: String,
accountPrivateKey: String,
actionsEnabled: Boolean,
timestamp: Date,
messageStatus: MessageStatus,
onRetry: () -> Unit,
onDelete: () -> Unit,
onOpenGroup: (SearchUser) -> Unit
) {
val context = LocalContext.current
@@ -1284,9 +1282,10 @@ private fun GroupInviteInlineCard(
val normalizedInvite = remember(inviteText) { inviteText.trim() }
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }
var status by remember(normalizedInvite) { mutableStateOf<GroupStatus>(GroupStatus.NOT_JOINED) }
var membersCount by remember(normalizedInvite) { mutableStateOf(0) }
var statusLoading by remember(normalizedInvite) { mutableStateOf(true) }
val cachedInfo = remember(normalizedInvite) { parsedInvite?.let { groupRepository.getCachedInviteInfo(it.groupId) } }
var status by remember(normalizedInvite) { mutableStateOf(cachedInfo?.status ?: GroupStatus.NOT_JOINED) }
var membersCount by remember(normalizedInvite) { mutableStateOf(cachedInfo?.membersCount ?: 0) }
var statusLoading by remember(normalizedInvite) { mutableStateOf(cachedInfo == null) }
var actionLoading by remember(normalizedInvite) { mutableStateOf(false) }
LaunchedEffect(normalizedInvite, accountPublicKey) {
@@ -1297,7 +1296,9 @@ private fun GroupInviteInlineCard(
return@LaunchedEffect
}
statusLoading = true
if (cachedInfo == null) {
statusLoading = true
}
val localGroupExists =
withContext(Dispatchers.IO) {
@@ -1314,12 +1315,13 @@ private fun GroupInviteInlineCard(
}
membersCount = inviteInfo?.membersCount ?: 0
status =
when {
localGroupExists -> GroupStatus.JOINED
inviteInfo != null -> inviteInfo.status
else -> GroupStatus.NOT_JOINED
}
val newStatus = when {
localGroupExists -> GroupStatus.JOINED
inviteInfo != null -> inviteInfo.status
else -> GroupStatus.NOT_JOINED
}
status = newStatus
groupRepository.cacheInviteInfo(parsedInvite.groupId, newStatus, membersCount)
statusLoading = false
}
@@ -1350,30 +1352,16 @@ private fun GroupInviteInlineCard(
}
}
val cardBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.16f)
} else if (isDarkTheme) {
Color.White.copy(alpha = 0.06f)
} else {
Color.Black.copy(alpha = 0.03f)
}
val cardBorder =
if (isOutgoing) {
Color.White.copy(alpha = 0.22f)
} else if (isDarkTheme) {
Color.White.copy(alpha = 0.12f)
} else {
Color.Black.copy(alpha = 0.08f)
}
val titleColor =
if (isOutgoing) Color.White
else if (isDarkTheme) Color.White
else Color(0xFF1A1A1A)
val subtitleColor =
if (isOutgoing) Color.White.copy(alpha = 0.82f)
else if (isDarkTheme) Color(0xFFA9AFBA)
else Color(0xFF70757F)
val timeColor =
if (isOutgoing) Color.White.copy(alpha = 0.74f)
else if (isDarkTheme) Color(0xFF8E8E93)
else Color(0xFF666666)
val statusColor = if (isOutgoing) Color.White else timeColor
val timeFormat = remember { SimpleDateFormat("HH:mm", Locale.getDefault()) }
val accentColor =
when (status) {
@@ -1441,6 +1429,7 @@ private fun GroupInviteInlineCard(
if (joinResult.success) {
status = GroupStatus.JOINED
groupRepository.cacheInviteInfo(parsedInvite.groupId, GroupStatus.JOINED, membersCount)
openParsedGroup()
} else {
status = joinResult.status
@@ -1455,28 +1444,27 @@ private fun GroupInviteInlineCard(
}
}
Surface(
modifier = Modifier.fillMaxWidth(),
color = cardBackground,
shape = RoundedCornerShape(12.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
) {
Column {
// Icon + Title row
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Group icon circle
Box(
modifier =
Modifier.size(34.dp)
.clip(CircleShape)
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (isOutgoing) Color.White.copy(alpha = 0.15f)
else accentColor.copy(alpha = 0.12f)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Link,
imageVector = Icons.Default.Groups,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(18.dp)
tint = if (isOutgoing) Color.White else accentColor,
modifier = Modifier.size(20.dp)
)
}
@@ -1485,63 +1473,93 @@ private fun GroupInviteInlineCard(
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
color = titleColor,
fontSize = 14.sp,
color = if (isOutgoing) Color.White else if (isDarkTheme) Color.White else Color(0xFF1A1A1A),
fontSize = 15.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Spacer(modifier = Modifier.height(1.dp))
Text(
text = subtitle,
color = subtitleColor,
fontSize = 11.sp,
maxLines = 2,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(8.dp))
}
}
Surface(
modifier =
Modifier.clip(RoundedCornerShape(8.dp)).clickable(
enabled = actionEnabled,
onClick = ::handleAction
),
color =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else {
accentColor.copy(alpha = 0.14f)
},
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (actionLoading || statusLoading) {
CircularProgressIndicator(
modifier = Modifier.size(12.dp),
strokeWidth = 1.8.dp,
color = accentColor
)
} else {
Icon(
imageVector = actionIcon,
contentDescription = null,
tint = accentColor,
modifier = Modifier.size(12.dp)
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = actionLabel,
color = accentColor,
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
}
Spacer(modifier = Modifier.height(8.dp))
// Action button (full width)
Surface(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(
enabled = actionEnabled,
onClick = ::handleAction
),
color = if (isOutgoing) Color.White.copy(alpha = 0.18f)
else accentColor.copy(alpha = 0.10f),
shape = RoundedCornerShape(8.dp)
) {
Row(
modifier = Modifier.padding(vertical = 7.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.weight(1f))
if (actionLoading || statusLoading) {
CircularProgressIndicator(
modifier = Modifier.size(13.dp),
strokeWidth = 1.5.dp,
color = if (isOutgoing) Color.White else accentColor
)
} else {
Icon(
imageVector = actionIcon,
contentDescription = null,
tint = if (isOutgoing) Color.White else accentColor,
modifier = Modifier.size(14.dp)
)
}
Spacer(modifier = Modifier.width(6.dp))
Text(
text = actionLabel,
color = if (isOutgoing) Color.White else accentColor,
fontSize = 13.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.weight(1f))
}
}
Spacer(modifier = Modifier.height(4.dp))
// Time + status row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = timeFormat.format(timestamp),
color = timeColor,
fontSize = 11.sp
)
if (isOutgoing) {
Spacer(modifier = Modifier.width(2.dp))
AnimatedMessageStatus(
status = messageStatus,
timeColor = statusColor,
isDarkTheme = isDarkTheme,
isOutgoing = true,
timestamp = timestamp.time,
onRetry = onRetry,
onDelete = onDelete
)
}
}
}
@@ -1632,6 +1650,19 @@ fun AnimatedMessageStatus(
onRetry: () -> Unit = {},
onDelete: () -> Unit = {}
) {
// Force recomposition when SENDING message exceeds 80s timeout
var timeoutTick by remember { mutableLongStateOf(0L) }
if (status == MessageStatus.SENDING && timestamp > 0) {
val remainingMs = (timestamp + 80_000L) - System.currentTimeMillis()
if (remainingMs > 0) {
LaunchedEffect(timestamp) {
kotlinx.coroutines.delay(remainingMs + 100)
timeoutTick++
}
}
}
@Suppress("UNUSED_EXPRESSION") timeoutTick // read to subscribe
val isTimedOut =
status == MessageStatus.SENDING &&
timestamp > 0 &&
@@ -1688,18 +1719,30 @@ fun AnimatedMessageStatus(
label = "statusIcon"
) { currentStatus ->
if (currentStatus == MessageStatus.ERROR) {
Icon(
imageVector = TablerIcons.AlertCircle,
contentDescription = null,
tint = animatedColor,
// Telegram-style: red filled circle with white "!" inside
Box(
modifier =
Modifier.size(iconSize)
Modifier.padding(start = 4.dp)
.size(iconSize)
.align(Alignment.CenterStart)
.scale(scale)
.background(
color = Color(0xFFE53935),
shape = CircleShape
)
.clickable {
showErrorMenu = true
}
)
},
contentAlignment = Alignment.Center
) {
Text(
text = "!",
color = Color.White,
fontSize = 10.sp,
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold,
lineHeight = 10.sp
)
}
} else {
if (currentStatus == MessageStatus.READ) {
Box(
@@ -1746,39 +1789,76 @@ fun AnimatedMessageStatus(
}
}
DropdownMenu(
expanded = showErrorMenu,
onDismissRequest = { showErrorMenu = false }
val menuBgColor = if (isDarkTheme) Color(0xFF272829) else Color.White
val menuTextColor = if (isDarkTheme) Color.White else Color(0xFF222222)
val menuIconColor = if (isDarkTheme) Color.White.copy(alpha = 0.47f) else Color(0xFF676B70)
MaterialTheme(
colorScheme = MaterialTheme.colorScheme.copy(
surface = menuBgColor,
onSurface = menuTextColor
)
) {
DropdownMenuItem(
text = { Text("Retry") },
onClick = {
showErrorMenu = false
onRetry()
},
leadingIcon = {
Icon(
painter = TelegramIcons.Retry,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
DropdownMenu(
expanded = showErrorMenu,
onDismissRequest = { showErrorMenu = false },
modifier = Modifier
.defaultMinSize(minWidth = 196.dp)
.background(menuBgColor)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable {
showErrorMenu = false
onRetry()
}
.padding(horizontal = 18.dp),
contentAlignment = Alignment.CenterStart
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = TelegramIcons.Retry,
contentDescription = null,
tint = menuIconColor,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(19.dp))
Text(
text = "Retry",
color = menuTextColor,
fontSize = 16.sp
)
}
}
)
DropdownMenuItem(
text = { Text("Delete", color = Color(0xFFE53935)) },
onClick = {
showErrorMenu = false
onDelete()
},
leadingIcon = {
Icon(
painter = TelegramIcons.Delete,
contentDescription = null,
tint = Color(0xFFE53935),
modifier = Modifier.size(18.dp)
)
Box(
modifier = Modifier
.fillMaxWidth()
.defaultMinSize(minHeight = 48.dp)
.clickable {
showErrorMenu = false
onDelete()
}
.padding(horizontal = 18.dp),
contentAlignment = Alignment.CenterStart
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
painter = TelegramIcons.Delete,
contentDescription = null,
tint = Color(0xFFFF3B30),
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(19.dp))
Text(
text = "Delete",
color = Color(0xFFFF3B30),
fontSize = 16.sp
)
}
}
)
}
}
}
}

View File

@@ -1668,6 +1668,8 @@ private fun CameraGridItem(
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val backgroundColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
var cameraProvider by remember { mutableStateOf<ProcessCameraProvider?>(null) }
var previewUseCase by remember { mutableStateOf<Preview?>(null) }
// Bounce animation for camera icon
val iconScale = remember { Animatable(0f) }
@@ -1699,6 +1701,21 @@ private fun CameraGridItem(
) == PackageManager.PERMISSION_GRANTED
}
DisposableEffect(lifecycleOwner, hasCameraPermission) {
onDispose {
val provider = cameraProvider
val preview = previewUseCase
if (provider != null && preview != null) {
try {
provider.unbind(preview)
} catch (_: Exception) {
}
}
previewUseCase = null
cameraProvider = null
}
}
Box(
modifier = Modifier
.aspectRatio(1f)
@@ -1719,7 +1736,8 @@ private fun CameraGridItem(
val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
cameraProviderFuture.addListener({
try {
val cameraProvider = cameraProviderFuture.get()
val provider = cameraProviderFuture.get()
cameraProvider = provider
val preview = Preview.Builder().build().also {
it.setSurfaceProvider(previewView.surfaceProvider)
@@ -1728,12 +1746,18 @@ private fun CameraGridItem(
// Use back camera
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
previewUseCase?.let { existing ->
try {
provider.unbind(existing)
} catch (_: Exception) {
}
}
provider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview
)
previewUseCase = preview
} catch (e: Exception) {
// Camera init failed
}

View File

@@ -69,7 +69,7 @@ fun Message.toChatMessage() = ChatMessage(
status = when (deliveryStatus) {
DeliveryStatus.WAITING -> MessageStatus.SENDING
DeliveryStatus.DELIVERED -> if (isRead) MessageStatus.READ else MessageStatus.DELIVERED
DeliveryStatus.ERROR -> MessageStatus.SENT
DeliveryStatus.ERROR -> MessageStatus.ERROR
DeliveryStatus.READ -> MessageStatus.READ
}
)

View File

@@ -0,0 +1,125 @@
package com.rosetta.messenger.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.*
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.unit.dp
@Composable
fun AnimatedDownloadIndicator(
isActive: Boolean,
color: Color = Color.White,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = isActive,
enter = fadeIn(animationSpec = tween(200)) + scaleIn(
initialScale = 0.6f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
)
),
exit = fadeOut(animationSpec = tween(200)) + scaleOut(
targetScale = 0.6f,
animationSpec = tween(150)
),
modifier = modifier
) {
// Infinite rotation for the circular progress arc
val infiniteTransition = rememberInfiniteTransition(label = "download_rotation")
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1200, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
label = "download_rotation_angle"
)
// Pulsing arrow bounce
val arrowBounce by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 800, easing = FastOutSlowInEasing),
repeatMode = RepeatMode.Reverse
),
label = "arrow_bounce"
)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(24.dp)
) {
Canvas(modifier = Modifier.size(24.dp)) {
val centerX = size.width / 2
val centerY = size.height / 2
val radius = size.width / 2 - 2.dp.toPx()
val strokeWidth = 2.dp.toPx()
// 1) Rotating arc (circular progress indicator)
rotate(degrees = rotation, pivot = Offset(centerX, centerY)) {
drawArc(
color = color,
startAngle = 0f,
sweepAngle = 120f,
useCenter = false,
style = Stroke(width = strokeWidth, cap = StrokeCap.Round),
topLeft = Offset(centerX - radius, centerY - radius),
size = androidx.compose.ui.geometry.Size(radius * 2, radius * 2)
)
}
// 2) Arrow pointing down (download symbol)
val arrowOffset = arrowBounce * 1.5.dp.toPx()
val arrowStroke = 2.dp.toPx()
val arrowTop = centerY - 4.dp.toPx() + arrowOffset
val arrowBottom = centerY + 4.dp.toPx() + arrowOffset
val arrowWing = 3.dp.toPx()
// Vertical line of arrow
drawLine(
color = color,
start = Offset(centerX, arrowTop),
end = Offset(centerX, arrowBottom),
strokeWidth = arrowStroke,
cap = StrokeCap.Round
)
// Left wing of arrowhead
drawLine(
color = color,
start = Offset(centerX - arrowWing, arrowBottom - arrowWing),
end = Offset(centerX, arrowBottom),
strokeWidth = arrowStroke,
cap = StrokeCap.Round
)
// Right wing of arrowhead
drawLine(
color = color,
start = Offset(centerX + arrowWing, arrowBottom - arrowWing),
end = Offset(centerX, arrowBottom),
strokeWidth = arrowStroke,
cap = StrokeCap.Round
)
}
}
}
}

View File

@@ -88,6 +88,7 @@ fun SwipeBackContainer(
layer: Int = 1,
swipeEnabled: Boolean = true,
propagateBackgroundProgress: Boolean = true,
deferToChildren: Boolean = false,
content: @Composable () -> Unit
) {
// 🚀 Lazy composition: skip ALL setup until the screen is opened for the first time.
@@ -243,7 +244,7 @@ fun SwipeBackContainer(
alpha = currentAlpha
}
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut) {
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut, deferToChildren) {
if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput
val velocityTracker = VelocityTracker()
@@ -268,12 +269,17 @@ fun SwipeBackContainer(
var totalDragY = 0f
var passedSlop = false
// Use Initial pass to intercept BEFORE children
// deferToChildren=true: pre-slop uses Main pass so children
// (e.g. LazyRow) process first — if they consume, we back off.
// deferToChildren=false (default): always use Initial pass
// to intercept before children (original behavior).
// Post-claim: always Initial to block children.
while (true) {
val event =
awaitPointerEvent(
val pass =
if (startedSwipe || !deferToChildren)
PointerEventPass.Initial
)
else PointerEventPass.Main
val event = awaitPointerEvent(pass)
val change =
event.changes.firstOrNull {
it.id == down.id
@@ -289,6 +295,9 @@ fun SwipeBackContainer(
totalDragY += dragDelta.y
if (!passedSlop) {
// Child (e.g. LazyRow) already consumed — let it handle
if (change.isConsumed) break
val totalDistance =
kotlin.math.sqrt(
totalDragX *

View File

@@ -19,6 +19,8 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
@@ -98,6 +100,7 @@ import compose.icons.tablericons.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
@@ -183,9 +186,30 @@ fun OtherProfileScreen(
var showImageViewer by remember { mutableStateOf(false) }
var imageViewerInitialIndex by remember { mutableIntStateOf(0) }
var selectedTab by remember { mutableStateOf(OtherProfileTab.MEDIA) }
val pagerState = rememberPagerState(
initialPage = 0,
pageCount = { OtherProfileTab.entries.size }
)
// Tab click → animate pager
LaunchedEffect(selectedTab) {
val page = OtherProfileTab.entries.indexOf(selectedTab)
if (pagerState.currentPage != page) {
pagerState.animateScrollToPage(page)
}
}
// Pager swipe → update tab + control swipe-back
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
selectedTab = OtherProfileTab.entries[page]
// Swipe-back only on first tab (Media); on other tabs pager handles swipe
onSwipeBackEnabledChanged(page == 0 && !showImageViewer)
}
}
LaunchedEffect(showImageViewer) {
onSwipeBackEnabledChanged(!showImageViewer)
onSwipeBackEnabledChanged(!showImageViewer && pagerState.currentPage == 0)
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
@@ -711,217 +735,218 @@ fun OtherProfileScreen(
}
// ══════════════════════════════════════════════════════
// TAB CONTENT — inlined directly into LazyColumn items
// for true virtualization (only visible items compose)
// TAB CONTENT — HorizontalPager for swipe between tabs
// On first tab (Media) swipe-right triggers swipe-back;
// on other tabs swipe-right goes to the previous tab.
// ══════════════════════════════════════════════════════
when (selectedTab) {
OtherProfileTab.MEDIA -> {
if (sharedContent.mediaPhotos.isEmpty()) {
item(key = "media_empty") {
OtherProfileEmptyState(
animationAssetPath = "lottie/saved.json",
title = "No shared media yet",
subtitle = "Photos from your chat will appear here.",
isDarkTheme = isDarkTheme
)
}
} else {
items(
items = mediaIndexedRows,
key = { (idx, _) -> "media_row_$idx" }
) { (rowIdx, rowPhotos) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
) {
rowPhotos.forEachIndexed { colIdx, media ->
val globalIndex = rowIdx * mediaColumns + colIdx
item(key = "tab_pager") {
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Top
) { page ->
when (page) {
// ── MEDIA ──
0 -> {
if (sharedContent.mediaPhotos.isEmpty()) {
OtherProfileEmptyState(
animationAssetPath = "lottie/saved.json",
title = "No shared media yet",
subtitle = "Photos from your chat will appear here.",
isDarkTheme = isDarkTheme
)
} else {
Column(modifier = Modifier.fillMaxWidth()) {
mediaIndexedRows.forEach { (rowIdx, rowPhotos) ->
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(mediaSpacing)
) {
rowPhotos.forEachIndexed { colIdx, media ->
val globalIndex = rowIdx * mediaColumns + colIdx
// Check cache first
val cachedBitmap = mediaBitmapStates[media.key]
?: SharedMediaBitmapCache.get(media.key)
val cachedBitmap = mediaBitmapStates[media.key]
?: SharedMediaBitmapCache.get(media.key)
// Only launch decode for items not yet cached
if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) {
LaunchedEffect(media.key) {
mediaDecodeSemaphore.withPermit {
val bitmap = withContext(Dispatchers.IO) {
resolveSharedPhotoBitmap(
context = context,
media = media,
accountPublicKey = activeAccountPublicKey,
accountPrivateKey = activeAccountPrivateKey
)
if (cachedBitmap == null && !mediaBitmapStates.containsKey(media.key)) {
LaunchedEffect(media.key) {
mediaDecodeSemaphore.withPermit {
val bitmap = withContext(Dispatchers.IO) {
resolveSharedPhotoBitmap(
context = context,
media = media,
accountPublicKey = activeAccountPublicKey,
accountPrivateKey = activeAccountPrivateKey
)
}
mediaBitmapStates[media.key] = bitmap
}
}
}
mediaBitmapStates[media.key] = bitmap
val resolvedBitmap = cachedBitmap ?: mediaBitmapStates[media.key]
val model = remember(media.localUri, media.blob) {
resolveSharedMediaModel(media.localUri, media.blob)
}
val previewBitmap = remember(media.preview) {
if (media.preview.isNotBlank()) {
runCatching {
val bytes = Base64.decode(media.preview, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}.getOrNull()
} else null
}
val isLoaded = resolvedBitmap != null || model != null
val imageAlpha by animateFloatAsState(
targetValue = if (isLoaded) 1f else 0f,
animationSpec = tween(300),
label = "media_fade"
)
Box(
modifier = Modifier
.size(mediaCellSize)
.clip(RoundedCornerShape(0.dp))
.clickable(
enabled = model != null || resolvedBitmap != null || media.attachmentId.isNotBlank()
) {
imageViewerInitialIndex = globalIndex
showImageViewer = true
},
contentAlignment = Alignment.Center
) {
if (previewBitmap != null) {
Image(
bitmap = previewBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.fillMaxSize()
.background(
if (isDarkTheme) Color(0xFF1E1E1E)
else Color(0xFFECECEC)
)
)
}
if (resolvedBitmap != null) {
Image(
bitmap = resolvedBitmap.asImageBitmap(),
contentDescription = "Shared media",
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha },
contentScale = ContentScale.Crop
)
} else if (model != null) {
coil.compose.AsyncImage(
model = model,
contentDescription = "Shared media",
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha },
contentScale = ContentScale.Crop
)
}
}
}
repeat(mediaColumns - rowPhotos.size) {
Spacer(modifier = Modifier.size(mediaCellSize))
}
}
}
}
}
}
// ── FILES ──
1 -> {
if (sharedContent.files.isEmpty()) {
OtherProfileEmptyState(
animationAssetPath = "lottie/folder.json",
title = "No shared files",
subtitle = "Documents from this chat will appear here.",
isDarkTheme = isDarkTheme
)
} else {
val fileTextColor = if (isDarkTheme) Color.White else Color.Black
val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
val resolvedBitmap = cachedBitmap ?: mediaBitmapStates[media.key]
val model = remember(media.localUri, media.blob) {
resolveSharedMediaModel(media.localUri, media.blob)
}
// Decode blurred preview from base64 (small image ~4-16px)
val previewBitmap = remember(media.preview) {
if (media.preview.isNotBlank()) {
runCatching {
val bytes = Base64.decode(media.preview, Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
}.getOrNull()
} else null
}
val isLoaded = resolvedBitmap != null || model != null
// Animate alpha for smooth fade-in
val imageAlpha by animateFloatAsState(
targetValue = if (isLoaded) 1f else 0f,
animationSpec = tween(300),
label = "media_fade"
)
Box(
modifier = Modifier
.size(mediaCellSize)
.clip(RoundedCornerShape(0.dp))
.clickable(
enabled = model != null || resolvedBitmap != null || media.attachmentId.isNotBlank()
) {
imageViewerInitialIndex = globalIndex
showImageViewer = true
},
contentAlignment = Alignment.Center
) {
// Blurred preview placeholder (always shown initially)
if (previewBitmap != null) {
Image(
bitmap = previewBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
// Fallback shimmer if no preview
Box(
Column(modifier = Modifier.fillMaxWidth()) {
sharedContent.files.forEachIndexed { index, file ->
Column {
Row(
modifier = Modifier
.fillMaxSize()
.background(
if (isDarkTheme) Color(0xFF1E1E1E)
else Color(0xFFECECEC)
)
)
}
// Full quality image fades in on top
if (resolvedBitmap != null) {
Image(
bitmap = resolvedBitmap.asImageBitmap(),
contentDescription = "Shared media",
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha },
contentScale = ContentScale.Crop
)
} else if (model != null) {
coil.compose.AsyncImage(
model = model,
contentDescription = "Shared media",
modifier = Modifier.fillMaxSize().graphicsLayer { alpha = imageAlpha },
contentScale = ContentScale.Crop
)
.fillMaxWidth()
.clickable {
val opened = openSharedFile(context, file)
if (!opened) {
Toast.makeText(context, "File is not available on this device", Toast.LENGTH_SHORT).show()
}
}
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(36.dp).clip(CircleShape)
.background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)),
contentAlignment = Alignment.Center
) {
Icon(painter = TelegramIcons.File, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(18.dp))
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = file.fileName, color = fileTextColor, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(modifier = Modifier.height(2.dp))
Text(text = "${formatFileSize(file.sizeBytes)}${formatTimestamp(file.timestamp)}", color = fileSecondary, fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Icon(imageVector = TablerIcons.ChevronRight, contentDescription = null, tint = fileSecondary.copy(alpha = 0.6f), modifier = Modifier.size(16.dp))
}
if (index != sharedContent.files.lastIndex) {
Divider(color = fileDivider, thickness = 0.5.dp)
}
}
}
}
// Fill remaining cells in last incomplete row
repeat(mediaColumns - rowPhotos.size) {
Spacer(modifier = Modifier.size(mediaCellSize))
}
}
}
}
}
OtherProfileTab.FILES -> {
if (sharedContent.files.isEmpty()) {
item(key = "files_empty") {
OtherProfileEmptyState(
animationAssetPath = "lottie/folder.json",
title = "No shared files",
subtitle = "Documents from this chat will appear here.",
isDarkTheme = isDarkTheme
)
}
} else {
val fileTextColor = if (isDarkTheme) Color.White else Color.Black
val fileSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val fileDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
// ── LINKS ──
2 -> {
if (sharedContent.links.isEmpty()) {
OtherProfileEmptyState(
animationAssetPath = "lottie/earth.json",
title = "No shared links",
subtitle = "Links from your messages will appear here.",
isDarkTheme = isDarkTheme
)
} else {
val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
itemsIndexed(sharedContent.files, key = { _, f -> f.key }) { index, file ->
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
val opened = openSharedFile(context, file)
if (!opened) {
Toast.makeText(context, "File is not available on this device", Toast.LENGTH_SHORT).show()
}
}
.padding(horizontal = 20.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier.size(36.dp).clip(CircleShape)
.background(PrimaryBlue.copy(alpha = if (isDarkTheme) 0.25f else 0.12f)),
contentAlignment = Alignment.Center
) {
Icon(painter = TelegramIcons.File, contentDescription = null, tint = PrimaryBlue, modifier = Modifier.size(18.dp))
Column(modifier = Modifier.fillMaxWidth()) {
sharedContent.links.forEachIndexed { index, link ->
Column {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
val normalizedLink = if (link.url.startsWith("http://", ignoreCase = true) || link.url.startsWith("https://", ignoreCase = true)) link.url else "https://${link.url}"
val opened = runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(normalizedLink))) }.isSuccess
if (!opened) {
Toast.makeText(context, "Unable to open this link", Toast.LENGTH_SHORT).show()
}
}
.padding(horizontal = 20.dp, vertical = 12.dp)
) {
Text(text = link.url, color = PrimaryBlue, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = TextDecoration.Underline)
Spacer(modifier = Modifier.height(3.dp))
Text(text = formatTimestamp(link.timestamp), color = linkSecondary, fontSize = 12.sp)
}
if (index != sharedContent.links.lastIndex) {
Divider(color = linkDivider, thickness = 0.5.dp)
}
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(text = file.fileName, color = fileTextColor, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis)
Spacer(modifier = Modifier.height(2.dp))
Text(text = "${formatFileSize(file.sizeBytes)}${formatTimestamp(file.timestamp)}", color = fileSecondary, fontSize = 12.sp)
}
Spacer(modifier = Modifier.width(8.dp))
Icon(imageVector = TablerIcons.ChevronRight, contentDescription = null, tint = fileSecondary.copy(alpha = 0.6f), modifier = Modifier.size(16.dp))
}
if (index != sharedContent.files.lastIndex) {
Divider(color = fileDivider, thickness = 0.5.dp)
}
}
}
}
}
OtherProfileTab.LINKS -> {
if (sharedContent.links.isEmpty()) {
item(key = "links_empty") {
OtherProfileEmptyState(
animationAssetPath = "lottie/earth.json",
title = "No shared links",
subtitle = "Links from your messages will appear here.",
isDarkTheme = isDarkTheme
)
}
} else {
val linkSecondary = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val linkDivider = if (isDarkTheme) Color(0xFF333333) else Color(0xFFD9DDE5)
itemsIndexed(sharedContent.links, key = { _, l -> l.key }) { index, link ->
Column {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable {
val normalizedLink = if (link.url.startsWith("http://", ignoreCase = true) || link.url.startsWith("https://", ignoreCase = true)) link.url else "https://${link.url}"
val opened = runCatching { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(normalizedLink))) }.isSuccess
if (!opened) {
Toast.makeText(context, "Unable to open this link", Toast.LENGTH_SHORT).show()
}
}
.padding(horizontal = 20.dp, vertical = 12.dp)
) {
Text(text = link.url, color = PrimaryBlue, fontSize = 15.sp, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, textDecoration = TextDecoration.Underline)
Spacer(modifier = Modifier.height(3.dp))
Text(text = formatTimestamp(link.timestamp), color = linkSecondary, fontSize = 12.sp)
}
if (index != sharedContent.links.lastIndex) {
Divider(color = linkDivider, thickness = 0.5.dp)
}
}
}
@@ -1402,11 +1427,24 @@ private fun openUriInExternalApp(context: android.content.Context, uri: Uri, mim
private fun openSharedFile(context: android.content.Context, file: SharedFileItem): Boolean {
val mimeType = inferMimeType(file.fileName)
// 1. Попробовать localUri
if (file.localUri.isNotBlank()) {
val local = runCatching { Uri.parse(file.localUri) }.getOrNull()
if (local != null && openUriInExternalApp(context, local, mimeType)) return true
}
// 2. Проверить rosetta_downloads (файлы скачанные через CDN в чате)
val downloadsDir = File(context.filesDir, "rosetta_downloads")
val downloadedFile = File(downloadsDir, file.fileName)
if (downloadedFile.exists() && downloadedFile.length() > 0) {
val uri = runCatching {
FileProvider.getUriForFile(context, "${context.packageName}.provider", downloadedFile)
}.getOrNull()
if (uri != null && openUriInExternalApp(context, uri, mimeType)) return true
}
// 3. Попробовать blob (inline-файлы)
val bytes = decodeBlobPayload(file.blob) ?: return false
val extension = file.fileName.substringAfterLast('.', "").lowercase(Locale.getDefault())
val suffix = if (extension.isBlank()) ".bin" else ".$extension"

View File

@@ -5,11 +5,14 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
@@ -18,7 +21,6 @@ import androidx.compose.material3.*
import compose.icons.TablerIcons
import compose.icons.tablericons.*
import com.rosetta.messenger.ui.icons.TelegramIcons
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -37,7 +39,9 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
@@ -64,8 +68,10 @@ private fun maxRevealRadius(center: Offset, bounds: IntSize): Float {
fun ThemeScreen(
isDarkTheme: Boolean,
currentThemeMode: String,
currentWallpaperId: String,
onBack: () -> Unit,
onThemeModeChange: (String) -> Unit
onThemeModeChange: (String) -> Unit,
onWallpaperChange: (String) -> Unit
) {
val view = LocalView.current
if (!view.isInEditMode) {
@@ -78,7 +84,6 @@ fun ThemeScreen(
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
val surfaceColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val scope = rememberCoroutineScope()
@@ -95,11 +100,16 @@ fun ThemeScreen(
var lightOptionCenter by remember { mutableStateOf<Offset?>(null) }
var darkOptionCenter by remember { mutableStateOf<Offset?>(null) }
var systemOptionCenter by remember { mutableStateOf<Offset?>(null) }
var wallpaperId by remember { mutableStateOf(currentWallpaperId) }
LaunchedEffect(currentThemeMode) {
themeMode = currentThemeMode
}
LaunchedEffect(currentWallpaperId) {
wallpaperId = currentWallpaperId
}
fun resolveThemeIsDark(mode: String): Boolean =
when (mode) {
"dark" -> true
@@ -214,7 +224,7 @@ fun ThemeScreen(
// ═══════════════════════════════════════════════════════
// CHAT PREVIEW - Message bubbles like in real chat
// ═══════════════════════════════════════════════════════
ChatPreview(isDarkTheme = isDarkTheme)
ChatPreview(isDarkTheme = isDarkTheme, wallpaperId = wallpaperId)
Spacer(modifier = Modifier.height(24.dp))
@@ -266,7 +276,27 @@ fun ThemeScreen(
secondaryTextColor = secondaryTextColor
)
Spacer(modifier = Modifier.height(32.dp))
TelegramSectionHeader("Chat Wallpaper", secondaryTextColor)
WallpaperSelectorRow(
isDarkTheme = isDarkTheme,
selectedWallpaperId = wallpaperId,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onWallpaperSelected = { selectedId ->
if (selectedId != wallpaperId) {
wallpaperId = selectedId
onWallpaperChange(selectedId)
}
}
)
TelegramInfoText(
text = "Selected wallpaper is used for chat backgrounds.",
secondaryTextColor = secondaryTextColor
)
Spacer(modifier = Modifier.height(24.dp))
}
}
@@ -420,16 +450,111 @@ private fun TelegramInfoText(text: String, secondaryTextColor: Color) {
)
}
@Composable
private fun WallpaperSelectorRow(
isDarkTheme: Boolean,
selectedWallpaperId: String,
textColor: Color,
secondaryTextColor: Color,
onWallpaperSelected: (String) -> Unit
) {
LazyRow(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
item(key = "none") {
WallpaperSelectorItem(
title = "No wallpaper",
wallpaperResId = null,
isSelected = selectedWallpaperId.isBlank(),
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onClick = { onWallpaperSelected("") }
)
}
items(items = ThemeWallpapers.all, key = { it.id }) { wallpaper ->
WallpaperSelectorItem(
title = wallpaper.name,
wallpaperResId = wallpaper.drawableRes,
isSelected = wallpaper.id == selectedWallpaperId,
isDarkTheme = isDarkTheme,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onClick = { onWallpaperSelected(wallpaper.id) }
)
}
}
}
@Composable
private fun WallpaperSelectorItem(
title: String,
wallpaperResId: Int?,
isSelected: Boolean,
isDarkTheme: Boolean,
textColor: Color,
secondaryTextColor: Color,
onClick: () -> Unit
) {
val borderColor = if (isSelected) Color(0xFF007AFF) else secondaryTextColor.copy(alpha = 0.35f)
Column(
modifier =
Modifier.width(118.dp).clickable(onClick = onClick),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier.fillMaxWidth().height(76.dp),
shape = RoundedCornerShape(10.dp),
color = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
border = androidx.compose.foundation.BorderStroke(if (isSelected) 2.dp else 1.dp, borderColor)
) {
if (wallpaperResId != null) {
Image(
painter = painterResource(id = wallpaperResId),
contentDescription = title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier =
Modifier.fillMaxSize()
.background(if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7))
)
}
}
Text(
text = title,
fontSize = 12.sp,
color = if (isSelected) textColor else secondaryTextColor,
modifier = Modifier.padding(top = 8.dp),
maxLines = 1
)
}
}
// ═══════════════════════════════════════════════════════════════════
// 💬 CHAT PREVIEW - Real message bubbles preview
// ═══════════════════════════════════════════════════════════════════
@Composable
private fun ChatPreview(isDarkTheme: Boolean) {
private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) {
val wallpaperResId = remember(wallpaperId) { ThemeWallpapers.drawableResOrNull(wallpaperId) }
val hasWallpaper = wallpaperResId != null
val chatBgColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF0F0F0)
// Message colors matching real ChatDetailScreen
val myBubbleColor = Color(0xFF248AE6) // PrimaryBlue - same for both themes
val otherBubbleColor = if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
val otherBubbleColor =
if (hasWallpaper) {
if (isDarkTheme) Color(0xFF2C2E33) else Color.White
} else {
if (isDarkTheme) Color(0xFF212121) else Color(0xFFF5F5F5)
}
val myTextColor = Color.White // White text on blue bubble
val otherTextColor = if (isDarkTheme) Color.White else Color.Black
val myTimeColor = Color.White // White time on blue bubble (matches real chat)
@@ -444,56 +569,65 @@ private fun ChatPreview(isDarkTheme: Boolean) {
color = chatBgColor,
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Incoming message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
MessageBubble(
text = "Hey! How's it going? 👋",
time = "10:42",
isMe = false,
bubbleColor = otherBubbleColor,
textColor = otherTextColor,
timeColor = otherTimeColor
Box(modifier = Modifier.fillMaxSize()) {
if (wallpaperResId != null) {
Image(
painter = painterResource(id = wallpaperResId),
contentDescription = "Chat wallpaper preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
// Outgoing message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
Column(
modifier = Modifier.fillMaxSize().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
MessageBubble(
text = "Hey! All good, just checking out this new theme 😊",
time = "10:43",
isMe = true,
bubbleColor = myBubbleColor,
textColor = myTextColor,
timeColor = myTimeColor,
checkmarkColor = myCheckColor
)
}
// Incoming message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
MessageBubble(
text = "Hey! How's it going? 👋",
time = "10:42",
isMe = false,
bubbleColor = otherBubbleColor,
textColor = otherTextColor,
timeColor = otherTimeColor
)
}
// Incoming message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
MessageBubble(
text = "Nice! Looks great! 🔥",
time = "10:44",
isMe = false,
bubbleColor = otherBubbleColor,
textColor = otherTextColor,
timeColor = otherTimeColor
)
// Outgoing message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
MessageBubble(
text = "Hey! All good, just checking out this new theme 😊",
time = "10:43",
isMe = true,
bubbleColor = myBubbleColor,
textColor = myTextColor,
timeColor = myTimeColor,
checkmarkColor = myCheckColor
)
}
// Incoming message
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start
) {
MessageBubble(
text = "Nice! Looks great! 🔥",
time = "10:44",
isMe = false,
bubbleColor = otherBubbleColor,
textColor = otherTextColor,
timeColor = otherTimeColor
)
}
}
}
}

View File

@@ -0,0 +1,33 @@
package com.rosetta.messenger.ui.settings
import androidx.annotation.DrawableRes
import com.rosetta.messenger.R
data class ThemeWallpaper(
val id: String,
val name: String,
@DrawableRes val drawableRes: Int
)
object ThemeWallpapers {
// Desktop parity: keep the same order/mapping as desktop WALLPAPERS.
val all: List<ThemeWallpaper> =
listOf(
ThemeWallpaper(id = "back_3", name = "Wallpaper 1", drawableRes = R.drawable.wallpaper_back_3),
ThemeWallpaper(id = "back_4", name = "Wallpaper 2", drawableRes = R.drawable.wallpaper_back_4),
ThemeWallpaper(id = "back_5", name = "Wallpaper 3", drawableRes = R.drawable.wallpaper_back_5),
ThemeWallpaper(id = "back_6", name = "Wallpaper 4", drawableRes = R.drawable.wallpaper_back_6),
ThemeWallpaper(id = "back_7", name = "Wallpaper 5", drawableRes = R.drawable.wallpaper_back_7),
ThemeWallpaper(id = "back_8", name = "Wallpaper 6", drawableRes = R.drawable.wallpaper_back_8),
ThemeWallpaper(id = "back_9", name = "Wallpaper 7", drawableRes = R.drawable.wallpaper_back_9),
ThemeWallpaper(id = "back_10", name = "Wallpaper 8", drawableRes = R.drawable.wallpaper_back_10),
ThemeWallpaper(id = "back_11", name = "Wallpaper 9", drawableRes = R.drawable.wallpaper_back_11),
ThemeWallpaper(id = "back_1", name = "Wallpaper 10", drawableRes = R.drawable.wallpaper_back_1),
ThemeWallpaper(id = "back_2", name = "Wallpaper 11", drawableRes = R.drawable.wallpaper_back_2)
)
fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id }
@DrawableRes
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 413 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 310 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 479 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B