Refactor and optimize various components
- Updated RosettaFirebaseMessagingService to use IO dispatcher for blocking calls. - Enhanced AvatarRepository with LRU caching and improved coroutine handling for avatar loading. - Implemented timeout for websocket connection in UnlockScreen. - Added selection mode functionality in ChatsListScreen with haptic feedback and improved UI for chat actions. - Improved animated dots in AttachmentComponents for a smoother visual effect. - Refactored image downloading and caching logic in ChatDetailComponents to streamline the process. - Optimized SwipeBackContainer to simplify gesture handling. - Adjusted swipe back behavior in OtherProfileScreen based on image viewer state.
This commit is contained in:
@@ -54,6 +54,7 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
// Account model for dropdown
|
||||
data class AccountItem(
|
||||
@@ -122,13 +123,11 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
|
||||
ProtocolManager.connect()
|
||||
|
||||
// Wait for websocket connection
|
||||
var waitAttempts = 0
|
||||
while (ProtocolManager.state.value == ProtocolState.DISCONNECTED && waitAttempts < 50) {
|
||||
kotlinx.coroutines.delay(100)
|
||||
waitAttempts++
|
||||
val connected = withTimeoutOrNull(5000) {
|
||||
ProtocolManager.state.first { it != ProtocolState.DISCONNECTED }
|
||||
}
|
||||
val connectTime = System.currentTimeMillis() - connectStart
|
||||
if (ProtocolManager.state.value == ProtocolState.DISCONNECTED) {
|
||||
if (connected == null) {
|
||||
onError("Failed to connect to server")
|
||||
onUnlocking(false)
|
||||
return
|
||||
|
||||
@@ -47,11 +47,14 @@ import com.rosetta.messenger.ui.components.BlurredAvatarBackground
|
||||
import com.rosetta.messenger.ui.components.VerifiedBadge
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.ui.settings.BackgroundBlurPresets
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.*
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
@Immutable
|
||||
data class Chat(
|
||||
@@ -185,15 +188,6 @@ fun ChatsListScreen(
|
||||
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// 🔥 Перехватываем системный back gesture - не закрываем приложение
|
||||
// Если drawer открыт - закрываем его, иначе игнорируем
|
||||
BackHandler(enabled = true) {
|
||||
if (drawerState.isOpen) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
// Если drawer закрыт - ничего не делаем (не выходим из приложения)
|
||||
}
|
||||
|
||||
// 🔥 ВСЕГДА закрываем клавиатуру при появлении ChatsListScreen
|
||||
// Используем DisposableEffect чтобы срабатывало при каждом появлении экрана
|
||||
DisposableEffect(Unit) {
|
||||
@@ -287,6 +281,29 @@ fun ChatsListScreen(
|
||||
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
|
||||
|
||||
// 🔥 Selection mode state
|
||||
var selectedChatKeys by remember { mutableStateOf<Set<String>>(emptySet()) }
|
||||
val isSelectionMode = selectedChatKeys.isNotEmpty()
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
var showSelectionMenu by remember { mutableStateOf(false) }
|
||||
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
|
||||
val mutedChats by preferencesManager.mutedChatsForAccount(accountPublicKey)
|
||||
.collectAsState(initial = emptySet())
|
||||
|
||||
// Перехватываем системный back gesture - не закрываем приложение
|
||||
BackHandler(enabled = true) {
|
||||
if (isSelectionMode) {
|
||||
selectedChatKeys = emptySet()
|
||||
} else if (drawerState.isOpen) {
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
}
|
||||
|
||||
// Close selection when drawer opens
|
||||
LaunchedEffect(drawerState.isOpen) {
|
||||
if (drawerState.isOpen) selectedChatKeys = emptySet()
|
||||
}
|
||||
|
||||
// Реактивный set заблокированных пользователей из ViewModel (Room Flow)
|
||||
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
|
||||
|
||||
@@ -811,12 +828,146 @@ fun ChatsListScreen(
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
key(isDarkTheme, showRequestsScreen) {
|
||||
TopAppBar(
|
||||
key(isDarkTheme, showRequestsScreen, isSelectionMode) {
|
||||
Crossfade(
|
||||
targetState = isSelectionMode,
|
||||
animationSpec = tween(200),
|
||||
label = "headerCrossfade"
|
||||
) { inSelection ->
|
||||
if (inSelection) {
|
||||
// ═══ SELECTION MODE HEADER ═══
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { selectedChatKeys = emptySet() }) {
|
||||
Icon(
|
||||
TablerIcons.X,
|
||||
contentDescription = "Close",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
"${selectedChatKeys.size}",
|
||||
fontWeight = FontWeight.Bold,
|
||||
fontSize = 20.sp,
|
||||
color = Color.White
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
// Mute / Unmute
|
||||
val allMuted = selectedChatKeys.all { mutedChats.contains(it) }
|
||||
IconButton(onClick = {
|
||||
val keys = selectedChatKeys.toSet()
|
||||
selectedChatKeys = emptySet()
|
||||
scope.launch {
|
||||
keys.forEach { key ->
|
||||
preferencesManager.setChatMuted(accountPublicKey, key, !allMuted)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
if (allMuted) TablerIcons.Bell else TablerIcons.BellOff,
|
||||
contentDescription = if (allMuted) "Unmute" else "Mute",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
// Delete
|
||||
IconButton(onClick = {
|
||||
val allDialogs = topLevelChatsState.dialogs
|
||||
val first = selectedChatKeys.firstOrNull()
|
||||
val dlg = allDialogs.find { it.opponentKey == first }
|
||||
if (dlg != null) dialogToDelete = dlg
|
||||
selectedChatKeys = emptySet()
|
||||
}) {
|
||||
Icon(
|
||||
TablerIcons.Trash,
|
||||
contentDescription = "Delete",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
|
||||
// Three dots menu
|
||||
Box {
|
||||
IconButton(onClick = { showSelectionMenu = true }) {
|
||||
Icon(
|
||||
TablerIcons.DotsVertical,
|
||||
contentDescription = "More",
|
||||
tint = Color.White
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showSelectionMenu,
|
||||
onDismissRequest = { showSelectionMenu = false },
|
||||
modifier = Modifier.background(if (isDarkTheme) Color(0xFF2C2C2E) else Color.White)
|
||||
) {
|
||||
// Pin / Unpin
|
||||
val allPinned = selectedChatKeys.all { pinnedChats.contains(it) }
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (allPinned) "Unpin" else "Pin",
|
||||
color = if (isDarkTheme) Color.White else Color.Black
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
selectedChatKeys.forEach { onTogglePin(it) }
|
||||
showSelectionMenu = false
|
||||
selectedChatKeys = emptySet()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
if (allPinned) TablerIcons.PinnedOff else TablerIcons.Pin,
|
||||
contentDescription = null,
|
||||
tint = if (isDarkTheme) Color.White else Color.Black
|
||||
)
|
||||
}
|
||||
)
|
||||
// Block
|
||||
val anyBlocked = selectedChatKeys.any { blockedUsers.contains(it) }
|
||||
DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (anyBlocked) "Unblock" else "Block",
|
||||
color = Color(0xFFE53935)
|
||||
)
|
||||
},
|
||||
onClick = {
|
||||
val allDialogs = topLevelChatsState.dialogs
|
||||
val first = selectedChatKeys.firstOrNull()
|
||||
val dlg = allDialogs.find { it.opponentKey == first }
|
||||
if (dlg != null) {
|
||||
if (anyBlocked) dialogToUnblock = dlg
|
||||
else dialogToBlock = dlg
|
||||
}
|
||||
showSelectionMenu = false
|
||||
selectedChatKeys = emptySet()
|
||||
},
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
TablerIcons.Ban,
|
||||
contentDescription = null,
|
||||
tint = Color(0xFFE53935)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4),
|
||||
scrolledContainerColor = if (isDarkTheme) Color(0xFF043359) else Color(0xFF0D8CF4),
|
||||
navigationIconContentColor = Color.White,
|
||||
titleContentColor = Color.White,
|
||||
actionIconContentColor = Color.White
|
||||
)
|
||||
)
|
||||
} else {
|
||||
// ═══ NORMAL HEADER ═══
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
if (showRequestsScreen) {
|
||||
// Back button for
|
||||
// Requests
|
||||
IconButton(
|
||||
onClick = {
|
||||
showRequestsScreen =
|
||||
@@ -833,8 +984,6 @@ fun ChatsListScreen(
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Menu button for
|
||||
// main screen
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope
|
||||
@@ -870,7 +1019,6 @@ fun ChatsListScreen(
|
||||
},
|
||||
title = {
|
||||
if (showRequestsScreen) {
|
||||
// Requests title
|
||||
Text(
|
||||
"Requests",
|
||||
fontWeight =
|
||||
@@ -880,7 +1028,6 @@ fun ChatsListScreen(
|
||||
color = Color.White
|
||||
)
|
||||
} else {
|
||||
// Rosetta title or Connecting animation
|
||||
if (protocolState == ProtocolState.AUTHENTICATED) {
|
||||
Text(
|
||||
"Rosetta",
|
||||
@@ -903,8 +1050,6 @@ fun ChatsListScreen(
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
// Search only on main
|
||||
// screen
|
||||
if (!showRequestsScreen) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
@@ -955,6 +1100,8 @@ fun ChatsListScreen(
|
||||
Color.White
|
||||
)
|
||||
)
|
||||
} // end else normal header
|
||||
} // end Crossfade
|
||||
}
|
||||
},
|
||||
floatingActionButton = {
|
||||
@@ -1293,6 +1440,8 @@ fun ChatsListScreen(
|
||||
isBlocked,
|
||||
isSavedMessages =
|
||||
isSavedMessages,
|
||||
isMuted =
|
||||
mutedChats.contains(dialog.opponentKey),
|
||||
avatarRepository =
|
||||
avatarRepository,
|
||||
isDrawerOpen =
|
||||
@@ -1303,6 +1452,8 @@ fun ChatsListScreen(
|
||||
isSwipedOpen =
|
||||
swipedItemKey ==
|
||||
dialog.opponentKey,
|
||||
isSelected =
|
||||
selectedChatKeys.contains(dialog.opponentKey),
|
||||
onSwipeStarted = {
|
||||
swipedItemKey =
|
||||
dialog.opponentKey
|
||||
@@ -1315,16 +1466,31 @@ fun ChatsListScreen(
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
swipedItemKey =
|
||||
null
|
||||
val user =
|
||||
chatsViewModel
|
||||
.dialogToSearchUser(
|
||||
dialog
|
||||
)
|
||||
onUserSelect(
|
||||
user
|
||||
)
|
||||
if (isSelectionMode) {
|
||||
// Toggle selection
|
||||
selectedChatKeys = if (selectedChatKeys.contains(dialog.opponentKey))
|
||||
selectedChatKeys - dialog.opponentKey
|
||||
else
|
||||
selectedChatKeys + dialog.opponentKey
|
||||
} else {
|
||||
swipedItemKey =
|
||||
null
|
||||
val user =
|
||||
chatsViewModel
|
||||
.dialogToSearchUser(
|
||||
dialog
|
||||
)
|
||||
onUserSelect(
|
||||
user
|
||||
)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
selectedChatKeys = if (selectedChatKeys.contains(dialog.opponentKey))
|
||||
selectedChatKeys - dialog.opponentKey
|
||||
else
|
||||
selectedChatKeys + dialog.opponentKey
|
||||
},
|
||||
onDelete = {
|
||||
dialogToDelete =
|
||||
@@ -1676,6 +1842,7 @@ fun ChatItem(
|
||||
chat: Chat,
|
||||
isDarkTheme: Boolean,
|
||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||
isMuted: Boolean = false,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val textColor = if (isDarkTheme) Color.White else Color.Black
|
||||
@@ -1722,6 +1889,16 @@ fun ChatItem(
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (isMuted) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
TablerIcons.BellOff,
|
||||
contentDescription = "Muted",
|
||||
tint = secondaryTextColor,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
// Read status
|
||||
Icon(
|
||||
@@ -1910,12 +2087,15 @@ fun SwipeableDialogItem(
|
||||
isTyping: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isSavedMessages: Boolean = false,
|
||||
isMuted: Boolean = false,
|
||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||
isDrawerOpen: Boolean = false,
|
||||
isSwipedOpen: Boolean = false,
|
||||
isSelected: Boolean = false,
|
||||
onSwipeStarted: () -> Unit = {},
|
||||
onSwipeClosed: () -> Unit = {},
|
||||
onClick: () -> Unit,
|
||||
onLongClick: () -> Unit = {},
|
||||
onDelete: () -> Unit = {},
|
||||
onBlock: () -> Unit = {},
|
||||
onUnblock: () -> Unit = {},
|
||||
@@ -1923,7 +2103,9 @@ fun SwipeableDialogItem(
|
||||
onPin: () -> Unit = {}
|
||||
) {
|
||||
val targetBackgroundColor =
|
||||
if (isPinned) {
|
||||
if (isSelected) {
|
||||
if (isDarkTheme) Color(0xFF1A3A5C) else Color(0xFFD6EAFF)
|
||||
} else if (isPinned) {
|
||||
if (isDarkTheme) Color(0xFF232323) else Color(0xFFE8E8ED)
|
||||
} else {
|
||||
if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
|
||||
@@ -2098,6 +2280,7 @@ fun SwipeableDialogItem(
|
||||
.pointerInput(Unit) {
|
||||
val velocityTracker = VelocityTracker()
|
||||
val touchSlop = viewConfiguration.touchSlop
|
||||
val longPressTimeoutMs = viewConfiguration.longPressTimeoutMillis
|
||||
|
||||
awaitEachGesture {
|
||||
val down =
|
||||
@@ -2114,6 +2297,99 @@ fun SwipeableDialogItem(
|
||||
var passedSlop = false
|
||||
var claimed = false
|
||||
|
||||
// Phase 1: Determine gesture type (tap / long-press / drag)
|
||||
// Wait up to longPressTimeout; if no up or slop → long press
|
||||
var gestureType = "unknown"
|
||||
|
||||
val result = withTimeoutOrNull(longPressTimeoutMs) {
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change =
|
||||
event.changes.firstOrNull {
|
||||
it.id == down.id
|
||||
}
|
||||
if (change == null) {
|
||||
gestureType = "cancelled"
|
||||
return@withTimeoutOrNull Unit
|
||||
}
|
||||
if (change.changedToUpIgnoreConsumed()) {
|
||||
change.consume()
|
||||
gestureType = "tap"
|
||||
return@withTimeoutOrNull Unit
|
||||
}
|
||||
val delta = change.positionChange()
|
||||
totalDragX += delta.x
|
||||
totalDragY += delta.y
|
||||
val dist = kotlin.math.sqrt(
|
||||
totalDragX * totalDragX +
|
||||
totalDragY * totalDragY
|
||||
)
|
||||
if (dist >= touchSlop) {
|
||||
gestureType = "drag"
|
||||
return@withTimeoutOrNull Unit
|
||||
}
|
||||
}
|
||||
@Suppress("UNREACHABLE_CODE")
|
||||
Unit
|
||||
}
|
||||
|
||||
// Timeout → long press
|
||||
if (result == null) gestureType = "longpress"
|
||||
|
||||
when (gestureType) {
|
||||
"tap" -> {
|
||||
onClick()
|
||||
return@awaitEachGesture
|
||||
}
|
||||
"cancelled" -> return@awaitEachGesture
|
||||
"longpress" -> {
|
||||
onLongClick()
|
||||
// Consume remaining events until finger lifts
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change =
|
||||
event.changes.firstOrNull {
|
||||
it.id == down.id
|
||||
} ?: break
|
||||
change.consume()
|
||||
if (change.changedToUpIgnoreConsumed()) break
|
||||
}
|
||||
return@awaitEachGesture
|
||||
}
|
||||
"drag" -> {
|
||||
// Determine drag direction
|
||||
val dominated =
|
||||
kotlin.math.abs(totalDragX) >
|
||||
kotlin.math.abs(totalDragY) * 2.0f
|
||||
|
||||
when {
|
||||
// Horizontal left swipe — reveal action buttons
|
||||
dominated && totalDragX < 0 -> {
|
||||
passedSlop = true
|
||||
claimed = true
|
||||
onSwipeStarted()
|
||||
}
|
||||
// Horizontal right swipe with buttons open — close them
|
||||
dominated && totalDragX > 0 && offsetX != 0f -> {
|
||||
passedSlop = true
|
||||
claimed = true
|
||||
}
|
||||
// Right swipe with buttons closed — let drawer handle
|
||||
totalDragX > 0 && offsetX == 0f ->
|
||||
return@awaitEachGesture
|
||||
// Vertical/diagonal — close buttons if open, let LazyColumn scroll
|
||||
else -> {
|
||||
if (offsetX != 0f) {
|
||||
offsetX = 0f
|
||||
onSwipeClosed()
|
||||
}
|
||||
return@awaitEachGesture
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Continue tracking drag
|
||||
while (true) {
|
||||
val event = awaitPointerEvent()
|
||||
val change =
|
||||
@@ -2121,137 +2397,36 @@ fun SwipeableDialogItem(
|
||||
it.id == down.id
|
||||
}
|
||||
?: break
|
||||
if (change.changedToUpIgnoreConsumed()
|
||||
) {
|
||||
// Tap detected — finger went up before touchSlop
|
||||
if (!passedSlop) {
|
||||
change.consume()
|
||||
onClick()
|
||||
}
|
||||
break
|
||||
}
|
||||
if (change.changedToUpIgnoreConsumed()) break
|
||||
|
||||
val delta = change.positionChange()
|
||||
totalDragX += delta.x
|
||||
totalDragY += delta.y
|
||||
|
||||
if (!passedSlop) {
|
||||
val dist =
|
||||
kotlin.math.sqrt(
|
||||
totalDragX *
|
||||
totalDragX +
|
||||
totalDragY *
|
||||
totalDragY
|
||||
)
|
||||
if (dist < touchSlop)
|
||||
continue
|
||||
|
||||
val dominated =
|
||||
kotlin.math.abs(
|
||||
totalDragX
|
||||
) >
|
||||
kotlin.math
|
||||
.abs(
|
||||
totalDragY
|
||||
) *
|
||||
2.0f
|
||||
|
||||
when {
|
||||
// Horizontal left
|
||||
// swipe — reveal
|
||||
// action buttons
|
||||
dominated &&
|
||||
totalDragX <
|
||||
0 -> {
|
||||
passedSlop =
|
||||
true
|
||||
claimed =
|
||||
true
|
||||
onSwipeStarted()
|
||||
change.consume()
|
||||
}
|
||||
// Horizontal right
|
||||
// swipe with
|
||||
// buttons open —
|
||||
// close them
|
||||
dominated &&
|
||||
totalDragX >
|
||||
0 &&
|
||||
offsetX !=
|
||||
0f -> {
|
||||
passedSlop =
|
||||
true
|
||||
claimed =
|
||||
true
|
||||
change.consume()
|
||||
}
|
||||
// Right swipe with
|
||||
// buttons closed —
|
||||
// let drawer handle
|
||||
totalDragX > 0 &&
|
||||
offsetX ==
|
||||
0f ->
|
||||
break
|
||||
// Vertical/diagonal
|
||||
// — close buttons
|
||||
// if open, let
|
||||
// LazyColumn scroll
|
||||
else -> {
|
||||
if (offsetX !=
|
||||
0f
|
||||
) {
|
||||
offsetX =
|
||||
0f
|
||||
onSwipeClosed()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Gesture is ours — update
|
||||
// offset
|
||||
val newOffset =
|
||||
offsetX + delta.x
|
||||
offsetX =
|
||||
newOffset.coerceIn(
|
||||
-swipeWidthPx,
|
||||
0f
|
||||
)
|
||||
velocityTracker.addPosition(
|
||||
change.uptimeMillis,
|
||||
change.position
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
val newOffset = offsetX + delta.x
|
||||
offsetX = newOffset.coerceIn(-swipeWidthPx, 0f)
|
||||
velocityTracker.addPosition(
|
||||
change.uptimeMillis,
|
||||
change.position
|
||||
)
|
||||
change.consume()
|
||||
}
|
||||
|
||||
// Snap animation
|
||||
// Phase 3: Snap animation
|
||||
if (claimed) {
|
||||
val velocity =
|
||||
velocityTracker
|
||||
.calculateVelocity()
|
||||
.x
|
||||
when {
|
||||
// Rightward fling — always
|
||||
// close
|
||||
velocity > 150f -> {
|
||||
offsetX = 0f
|
||||
onSwipeClosed()
|
||||
}
|
||||
// Strong leftward fling —
|
||||
// always open
|
||||
velocity < -300f -> {
|
||||
offsetX =
|
||||
-swipeWidthPx
|
||||
offsetX = -swipeWidthPx
|
||||
}
|
||||
// Past halfway — stay open
|
||||
kotlin.math.abs(offsetX) >
|
||||
swipeWidthPx /
|
||||
2 -> {
|
||||
offsetX =
|
||||
-swipeWidthPx
|
||||
swipeWidthPx / 2 -> {
|
||||
offsetX = -swipeWidthPx
|
||||
}
|
||||
// Less than halfway — close
|
||||
else -> {
|
||||
offsetX = 0f
|
||||
onSwipeClosed()
|
||||
@@ -2267,6 +2442,7 @@ fun SwipeableDialogItem(
|
||||
isTyping = isTyping,
|
||||
isPinned = isPinned,
|
||||
isBlocked = isBlocked,
|
||||
isMuted = isMuted,
|
||||
avatarRepository = avatarRepository,
|
||||
onClick = null // Tap handled by parent pointerInput
|
||||
)
|
||||
@@ -2290,6 +2466,7 @@ fun DialogItemContent(
|
||||
isTyping: Boolean = false,
|
||||
isPinned: Boolean = false,
|
||||
isBlocked: Boolean = false,
|
||||
isMuted: Boolean = false,
|
||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||
onClick: (() -> Unit)? = null
|
||||
) {
|
||||
@@ -2480,6 +2657,15 @@ fun DialogItemContent(
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
if (isMuted) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
Icon(
|
||||
imageVector = TablerIcons.BellOff,
|
||||
contentDescription = "Muted",
|
||||
tint = secondaryTextColor,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
|
||||
@@ -6,7 +6,14 @@ import android.graphics.Matrix
|
||||
import android.util.Base64
|
||||
import android.util.LruCache
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.StartOffset
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Image
|
||||
@@ -34,6 +41,7 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInWindow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -53,6 +61,7 @@ import compose.icons.TablerIcons
|
||||
import compose.icons.tablericons.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
@@ -62,8 +71,8 @@ import kotlin.math.min
|
||||
private const val TAG = "AttachmentComponents"
|
||||
|
||||
/**
|
||||
* 🔄 Анимированный текст с точками (Downloading... → Downloading. → Downloading.. → Downloading...)
|
||||
* Как в Telegram - точки плавно появляются и исчезают
|
||||
* Анимированный текст с волнообразными точками.
|
||||
* Три точки плавно подпрыгивают каскадом с изменением прозрачности.
|
||||
*/
|
||||
@Composable
|
||||
fun AnimatedDotsText(
|
||||
@@ -72,34 +81,78 @@ fun AnimatedDotsText(
|
||||
fontSize: androidx.compose.ui.unit.TextUnit = 12.sp,
|
||||
fontWeight: FontWeight = FontWeight.Normal
|
||||
) {
|
||||
var dotCount by remember { mutableIntStateOf(0) }
|
||||
|
||||
// Анимация точек: 0 → 1 → 2 → 3 → 0 → ...
|
||||
LaunchedEffect(Unit) {
|
||||
while (true) {
|
||||
delay(400) // Интервал между изменениями
|
||||
dotCount = (dotCount + 1) % 4
|
||||
}
|
||||
}
|
||||
|
||||
val dots = ".".repeat(dotCount)
|
||||
// Добавляем невидимые точки для фиксированной ширины текста
|
||||
val invisibleDots = ".".repeat(3 - dotCount)
|
||||
|
||||
Row {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "dots")
|
||||
|
||||
val dot0 by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1200
|
||||
0f at 0
|
||||
1f at 300
|
||||
0f at 600
|
||||
0f at 1200
|
||||
},
|
||||
repeatMode = RepeatMode.Restart,
|
||||
initialStartOffset = StartOffset(0)
|
||||
),
|
||||
label = "dot0"
|
||||
)
|
||||
val dot1 by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1200
|
||||
0f at 0
|
||||
1f at 300
|
||||
0f at 600
|
||||
0f at 1200
|
||||
},
|
||||
repeatMode = RepeatMode.Restart,
|
||||
initialStartOffset = StartOffset(200)
|
||||
),
|
||||
label = "dot1"
|
||||
)
|
||||
val dot2 by infiniteTransition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 0f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = keyframes {
|
||||
durationMillis = 1200
|
||||
0f at 0
|
||||
1f at 300
|
||||
0f at 600
|
||||
0f at 1200
|
||||
},
|
||||
repeatMode = RepeatMode.Restart,
|
||||
initialStartOffset = StartOffset(400)
|
||||
),
|
||||
label = "dot2"
|
||||
)
|
||||
|
||||
val dotValues = listOf(dot0, dot1, dot2)
|
||||
val bounceHeight = with(LocalDensity.current) { fontSize.toPx() * 0.35f }
|
||||
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "$baseText$dots",
|
||||
text = baseText,
|
||||
fontSize = fontSize,
|
||||
fontWeight = fontWeight,
|
||||
color = color
|
||||
)
|
||||
// Невидимые точки для сохранения ширины
|
||||
Text(
|
||||
text = invisibleDots,
|
||||
fontSize = fontSize,
|
||||
fontWeight = fontWeight,
|
||||
color = Color.Transparent
|
||||
)
|
||||
dotValues.forEach { progress ->
|
||||
Text(
|
||||
text = ".",
|
||||
fontSize = fontSize,
|
||||
fontWeight = fontWeight,
|
||||
color = color.copy(alpha = 0.4f + 0.6f * progress),
|
||||
modifier = Modifier.graphicsLayer {
|
||||
translationY = -bounceHeight * progress
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,29 +165,47 @@ object ImageBitmapCache {
|
||||
// Размер кэша = 1/8 доступной памяти (стандартная практика Android)
|
||||
private val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
|
||||
private val cacheSize = maxMemory / 8
|
||||
|
||||
|
||||
private val cache = object : LruCache<String, Bitmap>(cacheSize) {
|
||||
override fun sizeOf(key: String, bitmap: Bitmap): Int {
|
||||
// Размер в килобайтах
|
||||
return bitmap.byteCount / 1024
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Flow для уведомления о новых записях (заменяет polling retry loops)
|
||||
private val _updates = kotlinx.coroutines.flow.MutableSharedFlow<String>(extraBufferCapacity = 64)
|
||||
val updates: kotlinx.coroutines.flow.SharedFlow<String> = _updates
|
||||
|
||||
fun get(key: String): Bitmap? = cache.get(key)
|
||||
|
||||
|
||||
fun put(key: String, bitmap: Bitmap) {
|
||||
if (cache.get(key) == null) {
|
||||
cache.put(key, bitmap)
|
||||
_updates.tryEmit(key)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun remove(key: String) {
|
||||
cache.remove(key)
|
||||
}
|
||||
|
||||
|
||||
fun clear() {
|
||||
cache.evictAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Ждёт появления bitmap в кэше по ключу (вместо polling retry loop).
|
||||
* Возвращает null при таймауте.
|
||||
*/
|
||||
suspend fun awaitCached(key: String, timeoutMs: Long = 3000): Bitmap? {
|
||||
// Может уже быть в кэше
|
||||
get(key)?.let { return it }
|
||||
return kotlinx.coroutines.withTimeoutOrNull(timeoutMs) {
|
||||
updates.first { it == key }
|
||||
get(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2037,6 +2108,63 @@ internal fun base64ToBitmap(base64: String): Bitmap? {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* CDN download + decrypt + cache + save.
|
||||
* Shared between ReplyBubble and ForwardedImagePreview.
|
||||
*
|
||||
* @return loaded Bitmap or null
|
||||
*/
|
||||
internal suspend fun downloadAndDecryptImage(
|
||||
attachmentId: String,
|
||||
downloadTag: String,
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
cacheKey: String,
|
||||
context: android.content.Context,
|
||||
senderPublicKey: String,
|
||||
recipientPrivateKey: String
|
||||
): Bitmap? {
|
||||
if (downloadTag.isEmpty() || chachaKey.isEmpty() || privateKey.isEmpty()) return null
|
||||
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
|
||||
if (encryptedContent.isEmpty()) return@withContext null
|
||||
|
||||
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
|
||||
// Try decryptReplyBlob first (desktop decodeWithPassword)
|
||||
var decrypted = try {
|
||||
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
||||
.takeIf { it.isNotEmpty() && it != encryptedContent }
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
// Fallback: decryptAttachmentBlobWithPlainKey
|
||||
if (decrypted == null) {
|
||||
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||
encryptedContent, plainKeyAndNonce
|
||||
)
|
||||
}
|
||||
|
||||
if (decrypted == null) return@withContext null
|
||||
|
||||
val base64Data = if (decrypted.contains(",")) decrypted.substringAfter(",") else decrypted
|
||||
val bitmap = base64ToBitmap(base64Data) ?: return@withContext null
|
||||
|
||||
ImageBitmapCache.put(cacheKey, bitmap)
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = base64Data,
|
||||
attachmentId = attachmentId,
|
||||
publicKey = senderPublicKey,
|
||||
privateKey = recipientPrivateKey
|
||||
)
|
||||
|
||||
bitmap
|
||||
} catch (_: Exception) { null }
|
||||
}
|
||||
}
|
||||
|
||||
/** Форматирование размера файла */
|
||||
private fun formatFileSize(bytes: Long): String {
|
||||
return when {
|
||||
|
||||
@@ -46,8 +46,6 @@ import com.rosetta.messenger.ui.chats.models.*
|
||||
import com.rosetta.messenger.ui.chats.utils.*
|
||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.network.TransportManager
|
||||
import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.vanniktech.blurhash.BlurHash
|
||||
import compose.icons.TablerIcons
|
||||
@@ -1202,68 +1200,29 @@ fun ReplyBubble(
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
|
||||
// 5. Retry: фото может загрузиться в кэш параллельно
|
||||
// 5. Ждём пока другой composable загрузит фото в кэш
|
||||
if (imageBitmap == null) {
|
||||
repeat(6) {
|
||||
kotlinx.coroutines.delay(500)
|
||||
val retry = ImageBitmapCache.get("img_${imageAttachment.id}")
|
||||
if (retry != null) {
|
||||
imageBitmap = retry
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val awaited = ImageBitmapCache.awaitCached("img_${imageAttachment.id}")
|
||||
if (awaited != null) {
|
||||
imageBitmap = awaited
|
||||
return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
|
||||
// 6. CDN download — для форвардов, где фото загружено на CDN
|
||||
if (imageBitmap == null && imageAttachment.preview.isNotEmpty()) {
|
||||
val downloadTag = getDownloadTag(imageAttachment.preview)
|
||||
if (downloadTag.isNotEmpty()) {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val encryptedContent = TransportManager.downloadFile(
|
||||
imageAttachment.id, downloadTag
|
||||
)
|
||||
if (encryptedContent.isNotEmpty()) {
|
||||
// Desktop: decryptKeyFromSender → decodeWithPassword
|
||||
var decrypted: String? = null
|
||||
|
||||
if (chachaKey.isNotEmpty() && privateKey.isNotEmpty()) {
|
||||
try {
|
||||
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(
|
||||
chachaKey, privateKey
|
||||
)
|
||||
// decryptReplyBlob = desktop decodeWithPassword
|
||||
decrypted = try {
|
||||
MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
||||
.takeIf { it.isNotEmpty() && it != encryptedContent }
|
||||
} catch (_: Exception) { null }
|
||||
if (decrypted == null) {
|
||||
decrypted = MessageCrypto.decryptAttachmentBlobWithPlainKey(
|
||||
encryptedContent, plainKeyAndNonce
|
||||
)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
if (decrypted != null) {
|
||||
val bitmap = base64ToBitmap(decrypted)
|
||||
if (bitmap != null) {
|
||||
imageBitmap = bitmap
|
||||
ImageBitmapCache.put("img_${imageAttachment.id}", bitmap)
|
||||
// Сохраняем на диск
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = decrypted,
|
||||
attachmentId = imageAttachment.id,
|
||||
publicKey = replyData.senderPublicKey,
|
||||
privateKey = replyData.recipientPrivateKey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
val bitmap = downloadAndDecryptImage(
|
||||
attachmentId = imageAttachment.id,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
cacheKey = "img_${imageAttachment.id}",
|
||||
context = context,
|
||||
senderPublicKey = replyData.senderPublicKey,
|
||||
recipientPrivateKey = replyData.recipientPrivateKey
|
||||
)
|
||||
if (bitmap != null) imageBitmap = bitmap
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1609,54 +1568,26 @@ private fun ForwardedImagePreview(
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
|
||||
// CDN download — exactly like desktop useAttachment.ts
|
||||
if (downloadTag.isNotEmpty() && chachaKey.isNotEmpty() && privateKey.isNotEmpty()) {
|
||||
try {
|
||||
val encryptedContent = TransportManager.downloadFile(attachment.id, downloadTag)
|
||||
if (encryptedContent.isNotEmpty()) {
|
||||
// Desktop: decryptKeyFromSender → plainKeyAndNonce → decodeWithPassword
|
||||
val plainKeyAndNonce = MessageCrypto.decryptKeyFromSender(chachaKey, privateKey)
|
||||
// decryptReplyBlob = exact same as desktop decodeWithPassword:
|
||||
// bytesToJsUtf8String(plainKeyAndNonce) → PBKDF2(password,'rosetta',SHA256,1000) → AES-CBC → inflate
|
||||
val decrypted = MessageCrypto.decryptReplyBlob(encryptedContent, plainKeyAndNonce)
|
||||
if (decrypted.isNotEmpty() && decrypted != encryptedContent) {
|
||||
val base64Data = if (decrypted.contains(",")) decrypted.substringAfter(",") else decrypted
|
||||
val bitmap = base64ToBitmap(base64Data)
|
||||
if (bitmap != null) {
|
||||
imageBitmap = bitmap
|
||||
ImageBitmapCache.put(cacheKey, bitmap)
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context, base64Data, attachment.id,
|
||||
senderPublicKey, recipientPrivateKey
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
// Fallback: try decryptAttachmentBlobWithPlainKey (same logic, different entry point)
|
||||
val decrypted2 = MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKeyAndNonce)
|
||||
if (decrypted2 != null) {
|
||||
val base64Data = if (decrypted2.contains(",")) decrypted2.substringAfter(",") else decrypted2
|
||||
val bitmap = base64ToBitmap(base64Data)
|
||||
if (bitmap != null) {
|
||||
imageBitmap = bitmap
|
||||
ImageBitmapCache.put(cacheKey, bitmap)
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context, base64Data, attachment.id,
|
||||
senderPublicKey, recipientPrivateKey
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Retry from cache (another composable may have loaded it)
|
||||
// CDN download — exactly like desktop useAttachment.ts
|
||||
if (imageBitmap == null) {
|
||||
repeat(5) {
|
||||
kotlinx.coroutines.delay(400)
|
||||
ImageBitmapCache.get(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect }
|
||||
val bitmap = downloadAndDecryptImage(
|
||||
attachmentId = attachment.id,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
cacheKey = cacheKey,
|
||||
context = context,
|
||||
senderPublicKey = senderPublicKey,
|
||||
recipientPrivateKey = recipientPrivateKey
|
||||
)
|
||||
if (bitmap != null) imageBitmap = bitmap
|
||||
}
|
||||
|
||||
// Ждём пока другой composable загрузит фото в кэш
|
||||
if (imageBitmap == null) {
|
||||
ImageBitmapCache.awaitCached(cacheKey)?.let { imageBitmap = it; return@LaunchedEffect
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,9 +148,9 @@ fun SwipeBackContainer(
|
||||
alpha = currentAlpha
|
||||
}
|
||||
.background(if (isDarkTheme) Color(0xFF1B1B1B) else Color.White)
|
||||
.then(
|
||||
if (swipeEnabled && !isAnimatingIn && !isAnimatingOut) {
|
||||
Modifier.pointerInput(Unit) {
|
||||
.pointerInput(swipeEnabled, isAnimatingIn, isAnimatingOut) {
|
||||
if (!swipeEnabled || isAnimatingIn || isAnimatingOut) return@pointerInput
|
||||
|
||||
val velocityTracker = VelocityTracker()
|
||||
val touchSlop =
|
||||
viewConfiguration.touchSlop *
|
||||
@@ -304,11 +304,7 @@ fun SwipeBackContainer(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
}
|
||||
) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,8 +187,8 @@ fun OtherProfileScreen(
|
||||
}
|
||||
val screenHeightDp = LocalConfiguration.current.screenHeightDp.dp
|
||||
val sharedPagerMinHeight = (screenHeightDp * 0.45f).coerceAtLeast(240.dp)
|
||||
LaunchedEffect(selectedTab) {
|
||||
onSwipeBackEnabledChanged(selectedTab == OtherProfileTab.MEDIA)
|
||||
LaunchedEffect(showImageViewer) {
|
||||
onSwipeBackEnabledChanged(!showImageViewer)
|
||||
}
|
||||
|
||||
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFFFFFFF)
|
||||
|
||||
Reference in New Issue
Block a user