Релиз 1.3.4: sticky-плашка звонка и поиск сообщений в диалоге
All checks were successful
Android Kernel Build / build (push) Successful in 19m40s

This commit is contained in:
2026-03-28 15:17:58 +05:00
parent aa40f5287c
commit 46b1b3a6f1
10 changed files with 558 additions and 28 deletions

View File

@@ -1,5 +1,18 @@
# Release Notes
## 1.3.4
### Звонки и UI
- Реализован Telegram-style фон звонка в приложении: full-screen звонок теперь можно свернуть в закрепленную верхнюю плашку в чат-листе.
- Плашка звонка перенесена внутрь `ChatsListScreen` и ведет обратно в экран звонка по нажатию.
- Обновлен UI звонка: иконка сворачивания в стиле Telegram, улучшено поведение call overlay.
- Исправлено автоматическое скрытие клавиатуры при открытии экрана звонка.
### Поиск в диалоге
- В kebab-меню каждого чата добавлен пункт `Search`.
- Добавлен встроенный поиск сообщений внутри текущего диалога (через локальный индекс `message_search_index` и `dialog_key`).
- Добавлена навигация по результатам (`prev/next`) со скроллом и подсветкой найденного сообщения.
## 1.3.3
### E2EE, чаты и производительность

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.3.3"
val rosettaVersionCode = 35 // Increment on each release
val rosettaVersionName = "1.3.4"
val rosettaVersionCode = 36 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android {

View File

@@ -9,6 +9,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
@@ -38,6 +39,7 @@ import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallActionResult
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
@@ -594,6 +596,7 @@ fun MainScreen(
val rootView = LocalView.current
val callScope = rememberCoroutineScope()
val callUiState by CallManager.state.collectAsState()
var isCallOverlayExpanded by remember { mutableStateOf(true) }
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
var pendingIncomingAccept by remember { mutableStateOf(false) }
var callPermissionsRequestedOnce by remember { mutableStateOf(false) }
@@ -763,6 +766,14 @@ fun MainScreen(
CallManager.bindAccount(accountPublicKey)
}
LaunchedEffect(callUiState.isVisible) {
if (callUiState.isVisible) {
isCallOverlayExpanded = true
} else {
isCallOverlayExpanded = false
}
}
LaunchedEffect(callUiState.isVisible) {
if (callUiState.isVisible) {
focusManager.clearFocus(force = true)
@@ -776,6 +787,15 @@ fun MainScreen(
}
}
// Telegram-style behavior: while call screen is open, Back should minimize call to top banner.
BackHandler(
enabled = callUiState.isVisible &&
isCallOverlayExpanded &&
callUiState.phase != CallPhase.INCOMING
) {
isCallOverlayExpanded = false
}
LaunchedEffect(accountPublicKey, reloadTrigger) {
if (accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context)
@@ -1044,6 +1064,9 @@ fun MainScreen(
},
chatsViewModel = chatsListViewModel,
avatarRepository = avatarRepository,
callUiState = callUiState,
isCallOverlayExpanded = isCallOverlayExpanded,
onOpenCallOverlay = { isCallOverlayExpanded = true },
onAddAccount = {
onAddAccount()
},
@@ -1581,11 +1604,17 @@ fun MainScreen(
state = callUiState,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
isExpanded = isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING,
onAccept = { acceptCallWithPermission() },
onDecline = { CallManager.declineIncomingCall() },
onEnd = { CallManager.endCall() },
onToggleMute = { CallManager.toggleMute() },
onToggleSpeaker = { CallManager.toggleSpeaker() }
onToggleSpeaker = { CallManager.toggleSpeaker() },
onMinimize = {
if (callUiState.phase != CallPhase.INCOMING) {
isCallOverlayExpanded = false
}
}
)
}
}

View File

@@ -17,12 +17,15 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Оптимизация производительности и стабильности
- В release отключена frame-диагностика E2EE (детальные frame-логи оставлены только в debug)
- Оптимизирован чат-лист: убрано дублирование collectAsState и вынесены route-компоненты
- Ускорены выборки по вложениям: добавлен denormalized attachment type и индекс в БД
- Добавлен macrobenchmark-модуль с замерами startup, search и chat list scroll
- Исправлено поведение UI в звонке: клавиатура автоматически закрывается при открытии call overlay
Звонки и навигация
- Звонок можно свернуть и продолжить в фоне приложения с закрепленной верхней плашкой в чат-листе (Telegram-style)
- Обновлен экран звонка: кнопка сворачивания в стиле Telegram, улучшено поведение overlay
- Исправлено скрытие клавиатуры при открытии звонка
Поиск в диалоге
- В kebab-меню чата добавлен пункт Search
- Реализован поиск сообщений внутри текущего диалога (локально по индексу message_search_index)
- Добавлена навигация по результатам поиска (предыдущий/следующий) с автопереходом к сообщению
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -695,6 +695,24 @@ interface MessageSearchIndexDao {
offset: Int = 0
): List<MessageSearchIndexEntity>
@Query(
"""
SELECT * FROM message_search_index
WHERE account = :account
AND dialog_key = :dialogKey
AND plain_text_normalized LIKE '%' || :queryNormalized || '%'
ORDER BY timestamp DESC
LIMIT :limit OFFSET :offset
"""
)
suspend fun searchInDialog(
account: String,
dialogKey: String,
queryNormalized: String,
limit: Int,
offset: Int = 0
): List<MessageSearchIndexEntity>
@Query(
"""
SELECT m.* FROM messages m

View File

@@ -48,10 +48,12 @@ import com.rosetta.messenger.ui.icons.TelegramIcons
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.saveable.rememberSaveable
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.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@@ -125,6 +127,27 @@ private const val GROUP_MESSAGE_STACK_TIME_DIFF_MS = 5 * 60_000L
private const val DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
private enum class ChatHeaderMode {
NORMAL,
SEARCH,
SELECTION
}
private fun buildDialogKeyForSearch(account: String, opponent: String): String {
val normalizedAccount = account.trim()
val normalizedOpponent = opponent.trim()
val normalizedLower = normalizedOpponent.lowercase(Locale.ROOT)
val isGroup =
normalizedLower.startsWith("#group:") || normalizedLower.startsWith("group:")
if (isGroup) return normalizedOpponent
if (normalizedAccount == normalizedOpponent) return normalizedAccount
return if (normalizedAccount < normalizedOpponent) {
"$normalizedAccount:$normalizedOpponent"
} else {
"$normalizedOpponent:$normalizedAccount"
}
}
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
val firstCalendar =
java.util.Calendar.getInstance().apply {
@@ -308,6 +331,7 @@ fun ChatDetailScreen(
val focusManager = LocalFocusManager.current
val clipboardManager = LocalClipboardManager.current
val database = RosettaDatabase.getDatabase(context)
val searchIndexDao = remember(database) { database.messageSearchIndexDao() }
val hapticFeedback = LocalHapticFeedback.current
// 🔇 Mute state — read from PreferencesManager
@@ -772,6 +796,18 @@ fun ChatDetailScreen(
var showDeleteConfirm by remember { mutableStateOf(false) }
var showBlockConfirm by remember { mutableStateOf(false) }
var showUnblockConfirm by remember { mutableStateOf(false) }
var isInChatSearchMode by rememberSaveable(user.publicKey) { mutableStateOf(false) }
var inChatSearchQuery by rememberSaveable(user.publicKey) { mutableStateOf("") }
var inChatSearchResultIds by remember(user.publicKey) { mutableStateOf<List<String>>(emptyList()) }
var inChatSearchResultIndex by rememberSaveable(user.publicKey) { mutableIntStateOf(-1) }
val searchFieldFocusRequester = remember(user.publicKey) { FocusRequester() }
val searchDialogKey =
remember(currentUserPublicKey, user.publicKey) {
buildDialogKeyForSearch(
account = currentUserPublicKey,
opponent = user.publicKey
)
}
// Наблюдаем за статусом блокировки в реальном времени через Flow
val isBlocked by
database.blacklistDao()
@@ -1217,6 +1253,90 @@ fun ChatDetailScreen(
}
}
}
val closeInChatSearch: () -> Unit = {
isInChatSearchMode = false
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
hideInputOverlays()
}
val jumpToSearchResult: (Int) -> Unit = jump@{ targetIndex ->
val ids = inChatSearchResultIds
if (ids.isEmpty()) return@jump
val normalizedIndex =
if (targetIndex >= 0) {
targetIndex % ids.size
} else {
((targetIndex % ids.size) + ids.size) % ids.size
}
inChatSearchResultIndex = normalizedIndex
scrollToMessage(ids[normalizedIndex])
}
val searchResultCounterText =
remember(inChatSearchResultIds, inChatSearchResultIndex) {
if (inChatSearchResultIds.isEmpty() || inChatSearchResultIndex < 0) {
"0/0"
} else {
"${inChatSearchResultIndex + 1}/${inChatSearchResultIds.size}"
}
}
LaunchedEffect(isInChatSearchMode) {
if (isInChatSearchMode) {
delay(80)
searchFieldFocusRequester.requestFocus()
} else {
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
}
}
LaunchedEffect(isSelectionMode) {
if (isSelectionMode && isInChatSearchMode) {
closeInChatSearch()
}
}
LaunchedEffect(
isInChatSearchMode,
inChatSearchQuery,
currentUserPublicKey,
searchDialogKey
) {
if (!isInChatSearchMode) return@LaunchedEffect
val account = currentUserPublicKey.trim()
val normalizedQuery = inChatSearchQuery.trim().lowercase(Locale.ROOT)
if (account.isBlank() || normalizedQuery.length < 2) {
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
return@LaunchedEffect
}
delay(250)
val resultIds =
withContext(Dispatchers.IO) {
searchIndexDao
.searchInDialog(
account = account,
dialogKey = searchDialogKey,
queryNormalized = normalizedQuery,
limit = 200,
offset = 0
)
.map { it.messageId }
.distinct()
}
inChatSearchResultIds = resultIds
if (resultIds.isEmpty()) {
inChatSearchResultIndex = -1
} else {
inChatSearchResultIndex = 0
scrollToMessage(resultIds.first())
}
}
// Динамический subtitle: typing > online > offline
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
@@ -1241,7 +1361,13 @@ fun ChatDetailScreen(
}
// 🔥 Обработка системной кнопки назад
BackHandler { hideKeyboardAndBack() }
BackHandler {
if (isInChatSearchMode) {
closeInChatSearch()
} else {
hideKeyboardAndBack()
}
}
// 🔥 Lifecycle-aware отслеживание активности экрана
val lifecycleOwner = LocalLifecycleOwner.current
@@ -1433,12 +1559,18 @@ fun ChatDetailScreen(
) {
// Контент хедера с Crossfade для плавной смены - ускоренная
// анимация
val headerMode =
when {
isSelectionMode -> ChatHeaderMode.SELECTION
isInChatSearchMode -> ChatHeaderMode.SEARCH
else -> ChatHeaderMode.NORMAL
}
Crossfade(
targetState = isSelectionMode,
targetState = headerMode,
animationSpec = tween(150),
label = "headerContent"
) { selectionMode ->
if (selectionMode) {
) { currentHeaderMode ->
if (currentHeaderMode == ChatHeaderMode.SELECTION) {
// SELECTION MODE CONTENT
Row(
modifier =
@@ -1601,6 +1733,129 @@ fun ChatDetailScreen(
}
}
}
} else if (currentHeaderMode == ChatHeaderMode.SEARCH) {
// SEARCH MODE CONTENT
Row(
modifier =
Modifier.fillMaxWidth()
.height(64.dp)
.padding(horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { closeInChatSearch() },
modifier = Modifier.size(40.dp)
) {
Icon(
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Close search",
tint = Color.White,
modifier = Modifier.size(28.dp)
)
}
TextField(
value = inChatSearchQuery,
onValueChange = { value ->
inChatSearchQuery = value
},
singleLine = true,
modifier =
Modifier.weight(1f)
.focusRequester(searchFieldFocusRequester),
textStyle =
LocalTextStyle.current.copy(
color = Color.White,
fontSize = 17.sp,
fontWeight = FontWeight.Medium
),
placeholder = {
Text(
text = "Search in chat",
color = Color.White.copy(alpha = 0.65f),
fontSize = 16.sp
)
},
leadingIcon = {
Icon(
imageVector = Icons.Default.Search,
contentDescription = null,
tint = Color.White.copy(alpha = 0.75f)
)
},
trailingIcon = {
if (inChatSearchQuery.isNotBlank()) {
IconButton(
onClick = {
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
}
) {
Icon(
imageVector = Icons.Default.Close,
contentDescription = "Clear search",
tint = Color.White.copy(alpha = 0.85f)
)
}
}
},
colors =
TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
focusedTextColor = Color.White,
unfocusedTextColor = Color.White,
cursorColor = Color.White
)
)
Text(
text = searchResultCounterText,
color = Color.White.copy(alpha = 0.78f),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.padding(horizontal = 4.dp)
)
IconButton(
onClick = {
jumpToSearchResult(
inChatSearchResultIndex - 1
)
},
enabled = inChatSearchResultIds.isNotEmpty()
) {
Icon(
imageVector = TablerIcons.ChevronUp,
contentDescription = "Previous result",
tint =
if (inChatSearchResultIds.isNotEmpty()) Color.White
else Color.White.copy(alpha = 0.45f)
)
}
IconButton(
onClick = {
jumpToSearchResult(
inChatSearchResultIndex + 1
)
},
enabled = inChatSearchResultIds.isNotEmpty()
) {
Icon(
imageVector = TablerIcons.ChevronDown,
contentDescription = "Next result",
tint =
if (inChatSearchResultIds.isNotEmpty()) Color.White
else Color.White.copy(alpha = 0.45f)
)
}
}
} else {
// NORMAL HEADER CONTENT
Row(
@@ -1943,6 +2198,14 @@ fun ChatDetailScreen(
isSystemAccount,
isBlocked =
isBlocked,
onSearchInChatClick = {
showMenu = false
hideInputOverlays()
isInChatSearchMode = true
inChatSearchQuery = ""
inChatSearchResultIds = emptyList()
inChatSearchResultIndex = -1
},
onGroupInfoClick = {
showMenu =
false

View File

@@ -64,11 +64,14 @@ import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.ui.chats.components.AnimatedDotsText
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
import com.rosetta.messenger.ui.components.AppleEmojiText
@@ -268,6 +271,9 @@ fun ChatsListScreen(
onTogglePin: (String) -> Unit = {},
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
callUiState: CallUiState = CallUiState(),
isCallOverlayExpanded: Boolean = true,
onOpenCallOverlay: () -> Unit = {},
onAddAccount: () -> Unit = {},
onSwitchAccount: (String) -> Unit = {},
onDeleteAccountFromSidebar: (String) -> Unit = {}
@@ -2095,6 +2101,13 @@ fun ChatsListScreen(
Color(0xFF1A1A1A)
else Color(0xFFF2F2F7)
}
val showStickyCallBanner =
remember(callUiState, isCallOverlayExpanded) {
callUiState.isVisible &&
!isCallOverlayExpanded &&
callUiState.phase != CallPhase.INCOMING
}
val callBannerHeight = 40.dp
// 🔥 Берем dialogs из chatsState для
// консистентности
// 📌 Порядок по времени готовится в ViewModel.
@@ -2286,10 +2299,21 @@ fun ChatsListScreen(
}
}
Box(
modifier =
Modifier.fillMaxSize()
.background(listBackgroundColor)
) {
LazyColumn(
state = chatListState,
modifier =
Modifier.fillMaxSize()
.padding(
top =
if (showStickyCallBanner)
callBannerHeight
else 0.dp
)
.then(
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
else Modifier
@@ -2627,6 +2651,16 @@ fun ChatsListScreen(
}
}
}
if (showStickyCallBanner) {
CallTopBanner(
state = callUiState,
isSticky = true,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onOpenCall = onOpenCallOverlay
)
}
}
}
} // Close Requests AnimatedContent
} // Close calls/main switch

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -45,6 +46,8 @@ import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronDown
// ── Telegram-style dark gradient colors ──────────────────────────
@@ -66,11 +69,13 @@ fun CallOverlay(
state: CallUiState,
isDarkTheme: Boolean,
avatarRepository: AvatarRepository? = null,
isExpanded: Boolean = true,
onAccept: () -> Unit,
onDecline: () -> Unit,
onEnd: () -> Unit,
onToggleMute: () -> Unit,
onToggleSpeaker: () -> Unit
onToggleSpeaker: () -> Unit,
onMinimize: (() -> Unit)? = null
) {
val view = LocalView.current
LaunchedEffect(state.isVisible) {
@@ -85,7 +90,7 @@ fun CallOverlay(
}
AnimatedVisibility(
visible = state.isVisible,
visible = state.isVisible && isExpanded,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200))
) {
@@ -96,17 +101,43 @@ fun CallOverlay(
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
)
) {
// ── Top-right QR icon ──
if ((state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) && state.keyCast.isNotBlank()) {
Box(
// ── Top controls: minimize (left) + key cast QR (right) ──
val canMinimize = onMinimize != null && state.phase != CallPhase.INCOMING && state.phase != CallPhase.IDLE
val showKeyCast =
(state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) &&
state.keyCast.isNotBlank()
if (canMinimize || showKeyCast) {
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
contentAlignment = Alignment.CenterEnd
.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
if (canMinimize) {
IconButton(
onClick = { onMinimize?.invoke() },
modifier = Modifier.size(44.dp)
) {
Icon(
imageVector = TablerIcons.ChevronDown,
contentDescription = "Minimize call",
tint = Color.White,
modifier = Modifier.size(26.dp)
)
}
} else {
Spacer(modifier = Modifier.size(48.dp))
}
if (showKeyCast) {
EncryptionKeyButton(keyHex = state.keyCast)
} else {
Spacer(modifier = Modifier.size(48.dp))
}
}
}

View File

@@ -0,0 +1,129 @@
package com.rosetta.messenger.ui.chats.calls
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
private val TelegramCallGreenStart = Color(0xFF3FD17F)
private val TelegramCallGreenEnd = Color(0xFF27C4AE)
@Composable
fun CallTopBanner(
state: CallUiState,
onOpenCall: () -> Unit,
isSticky: Boolean = false,
isDarkTheme: Boolean = true,
avatarRepository: AvatarRepository? = null
) {
AnimatedVisibility(
visible = state.isVisible && state.phase != CallPhase.INCOMING,
enter = slideInVertically(initialOffsetY = { -it / 2 }, animationSpec = tween(220)) +
fadeIn(animationSpec = tween(220)),
exit = slideOutVertically(targetOffsetY = { -it / 2 }, animationSpec = tween(180)) +
fadeOut(animationSpec = tween(160))
) {
val bannerModifier =
if (isSticky) {
Modifier
.fillMaxWidth()
.height(42.dp)
} else {
Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(horizontal = 10.dp, vertical = 8.dp)
.height(42.dp)
.clip(RoundedCornerShape(12.dp))
}
Row(
modifier = bannerModifier
.background(
Brush.horizontalGradient(
listOf(TelegramCallGreenStart, TelegramCallGreenEnd)
)
)
.clickable { onOpenCall() }
.padding(horizontal = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.offset(x = if (isSticky) (-4).dp else 0.dp)) {
AvatarImage(
publicKey = state.peerPublicKey,
avatarRepository = avatarRepository,
size = 24.dp,
isDarkTheme = isDarkTheme,
showOnlineIndicator = false,
displayName = state.displayName
)
}
Spacer(modifier = Modifier.width(8.dp))
Text(
text = buildBannerText(state),
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
Box(
modifier =
Modifier
.size(30.dp)
.clip(CircleShape)
.background(Color.White.copy(alpha = 0.16f)),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Mic,
contentDescription = "Voice call",
tint = Color.White,
modifier = Modifier.size(17.dp)
)
}
}
}
}
private fun buildBannerText(state: CallUiState): String {
return state.displayName
}

View File

@@ -3245,6 +3245,7 @@ fun KebabMenu(
isGroupChat: Boolean = false,
isSystemAccount: Boolean = false,
isBlocked: Boolean,
onSearchInChatClick: () -> Unit = {},
onGroupInfoClick: () -> Unit = {},
onSearchMembersClick: () -> Unit = {},
onLeaveGroupClick: () -> Unit = {},
@@ -3276,7 +3277,16 @@ fun KebabMenu(
dismissOnClickOutside = true
)
) {
ContextMenuItemWithVector(
icon = TablerIcons.Search,
text = "Search",
onClick = onSearchInChatClick,
tintColor = iconColor,
textColor = textColor
)
if (isGroupChat) {
Divider(color = dividerColor)
ContextMenuItemWithVector(
icon = TablerIcons.Search,
text = "Search Members",