Compare commits
10 Commits
b271917594
...
434ccef30c
| Author | SHA1 | Date | |
|---|---|---|---|
| 434ccef30c | |||
| 26f4597c3b | |||
| fa1288479f | |||
| 46b1b3a6f1 | |||
| aa40f5287c | |||
| 93a2de315a | |||
| 0fc637b42a | |||
| 59addf4373 | |||
| 3953d93207 | |||
| de958e10a1 |
@@ -1,5 +1,18 @@
|
|||||||
# Release Notes
|
# 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
|
## 1.3.3
|
||||||
|
|
||||||
### E2EE, чаты и производительность
|
### E2EE, чаты и производительность
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.3.3"
|
val rosettaVersionName = "1.3.6"
|
||||||
val rosettaVersionCode = 35 // Increment on each release
|
val rosettaVersionCode = 38 // Increment on each release
|
||||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|||||||
@@ -9,6 +9,9 @@
|
|||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
@@ -67,6 +70,11 @@
|
|||||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".network.CallForegroundService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="microphone|mediaPlayback" />
|
||||||
|
|
||||||
<!-- Firebase notification icon (optional, for better looking notifications) -->
|
<!-- Firebase notification icon (optional, for better looking notifications) -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import android.Manifest
|
|||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.WindowManager
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@@ -38,6 +40,7 @@ import com.rosetta.messenger.data.RecentSearchesManager
|
|||||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
import com.rosetta.messenger.network.CallActionResult
|
import com.rosetta.messenger.network.CallActionResult
|
||||||
|
import com.rosetta.messenger.network.CallPhase
|
||||||
import com.rosetta.messenger.network.CallManager
|
import com.rosetta.messenger.network.CallManager
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
@@ -594,9 +597,13 @@ fun MainScreen(
|
|||||||
val rootView = LocalView.current
|
val rootView = LocalView.current
|
||||||
val callScope = rememberCoroutineScope()
|
val callScope = rememberCoroutineScope()
|
||||||
val callUiState by CallManager.state.collectAsState()
|
val callUiState by CallManager.state.collectAsState()
|
||||||
|
var isCallOverlayExpanded by remember { mutableStateOf(true) }
|
||||||
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
|
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
|
||||||
var pendingIncomingAccept by remember { mutableStateOf(false) }
|
var pendingIncomingAccept by remember { mutableStateOf(false) }
|
||||||
var callPermissionsRequestedOnce by remember { mutableStateOf(false) }
|
var callPermissionsRequestedOnce by remember { mutableStateOf(false) }
|
||||||
|
val isCallScreenVisible =
|
||||||
|
callUiState.isVisible &&
|
||||||
|
(isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING)
|
||||||
|
|
||||||
val mandatoryCallPermissions = remember {
|
val mandatoryCallPermissions = remember {
|
||||||
listOf(Manifest.permission.RECORD_AUDIO)
|
listOf(Manifest.permission.RECORD_AUDIO)
|
||||||
@@ -765,17 +772,54 @@ fun MainScreen(
|
|||||||
|
|
||||||
LaunchedEffect(callUiState.isVisible) {
|
LaunchedEffect(callUiState.isVisible) {
|
||||||
if (callUiState.isVisible) {
|
if (callUiState.isVisible) {
|
||||||
focusManager.clearFocus(force = true)
|
isCallOverlayExpanded = true
|
||||||
|
} else {
|
||||||
|
isCallOverlayExpanded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback for cases where IME survives focus reset due to window transitions.
|
val forceHideCallKeyboard: () -> Unit = {
|
||||||
val activity = rootView.context as? android.app.Activity
|
focusManager.clearFocus(force = true)
|
||||||
activity?.window?.let { window ->
|
rootView.findFocus()?.clearFocus()
|
||||||
WindowCompat.getInsetsController(window, rootView)
|
rootView.clearFocus()
|
||||||
?.hide(WindowInsetsCompat.Type.ime())
|
val activity = rootView.context as? android.app.Activity
|
||||||
|
activity?.window?.let { window ->
|
||||||
|
WindowCompat.getInsetsController(window, rootView)
|
||||||
|
.hide(WindowInsetsCompat.Type.ime())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(isCallScreenVisible) {
|
||||||
|
val activity = rootView.context as? android.app.Activity
|
||||||
|
val previousSoftInputMode = activity?.window?.attributes?.softInputMode
|
||||||
|
if (isCallScreenVisible) {
|
||||||
|
forceHideCallKeyboard()
|
||||||
|
activity?.window?.setSoftInputMode(
|
||||||
|
WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
if (isCallScreenVisible && previousSoftInputMode != null) {
|
||||||
|
activity.window.setSoftInputMode(previousSoftInputMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCallScreenVisible) {
|
||||||
|
SideEffect {
|
||||||
|
forceHideCallKeyboard()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
LaunchedEffect(accountPublicKey, reloadTrigger) {
|
||||||
if (accountPublicKey.isNotBlank()) {
|
if (accountPublicKey.isNotBlank()) {
|
||||||
val accountManager = AccountManager(context)
|
val accountManager = AccountManager(context)
|
||||||
@@ -1044,6 +1088,9 @@ fun MainScreen(
|
|||||||
},
|
},
|
||||||
chatsViewModel = chatsListViewModel,
|
chatsViewModel = chatsListViewModel,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
|
callUiState = callUiState,
|
||||||
|
isCallOverlayExpanded = isCallOverlayExpanded,
|
||||||
|
onOpenCallOverlay = { isCallOverlayExpanded = true },
|
||||||
onAddAccount = {
|
onAddAccount = {
|
||||||
onAddAccount()
|
onAddAccount()
|
||||||
},
|
},
|
||||||
@@ -1578,14 +1625,20 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
CallOverlay(
|
CallOverlay(
|
||||||
state = callUiState,
|
state = callUiState,
|
||||||
isDarkTheme = isDarkTheme,
|
isDarkTheme = isDarkTheme,
|
||||||
avatarRepository = avatarRepository,
|
avatarRepository = avatarRepository,
|
||||||
onAccept = { acceptCallWithPermission() },
|
isExpanded = isCallOverlayExpanded || callUiState.phase == CallPhase.INCOMING,
|
||||||
onDecline = { CallManager.declineIncomingCall() },
|
onAccept = { acceptCallWithPermission() },
|
||||||
onEnd = { CallManager.endCall() },
|
onDecline = { CallManager.declineIncomingCall() },
|
||||||
onToggleMute = { CallManager.toggleMute() },
|
onEnd = { CallManager.endCall() },
|
||||||
onToggleSpeaker = { CallManager.toggleSpeaker() }
|
onToggleMute = { CallManager.toggleMute() },
|
||||||
|
onToggleSpeaker = { CallManager.toggleSpeaker() },
|
||||||
|
onMinimize = {
|
||||||
|
if (callUiState.phase != CallPhase.INCOMING) {
|
||||||
|
isCallOverlayExpanded = false
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,12 +17,13 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Оптимизация производительности и стабильности
|
Hotfix звонков
|
||||||
- В release отключена frame-диагностика E2EE (детальные frame-логи оставлены только в debug)
|
- Исправлен регресс качества аудио в E2EE звонках после 1.3.5
|
||||||
- Оптимизирован чат-лист: убрано дублирование collectAsState и вынесены route-компоненты
|
- Возвращена стабильная схема обработки состояния звонка, как в 1.3.3
|
||||||
- Ускорены выборки по вложениям: добавлен denormalized attachment type и индекс в БД
|
- Нативный C++ шифратор (XChaCha20/HSalsa20) оставлен без изменений
|
||||||
- Добавлен macrobenchmark-модуль с замерами startup, search и chat list scroll
|
|
||||||
- Исправлено поведение UI в звонке: клавиатура автоматически закрывается при открытии call overlay
|
UI
|
||||||
|
- Сохранено принудительное скрытие клавиатуры на экране звонка
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -695,6 +695,24 @@ interface MessageSearchIndexDao {
|
|||||||
offset: Int = 0
|
offset: Int = 0
|
||||||
): List<MessageSearchIndexEntity>
|
): 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(
|
@Query(
|
||||||
"""
|
"""
|
||||||
SELECT m.* FROM messages m
|
SELECT m.* FROM messages m
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Person
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.rosetta.messenger.MainActivity
|
||||||
|
import com.rosetta.messenger.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keeps call alive while app goes to background.
|
||||||
|
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
|
||||||
|
*/
|
||||||
|
class CallForegroundService : Service() {
|
||||||
|
|
||||||
|
private data class Snapshot(
|
||||||
|
val phase: CallPhase,
|
||||||
|
val displayName: String,
|
||||||
|
val statusText: String,
|
||||||
|
val durationSec: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
val action = intent?.action ?: ACTION_SYNC
|
||||||
|
CallManager.initialize(applicationContext)
|
||||||
|
|
||||||
|
when (action) {
|
||||||
|
ACTION_STOP -> {
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
ACTION_END -> {
|
||||||
|
CallManager.endCall()
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
ACTION_DECLINE -> {
|
||||||
|
CallManager.declineIncomingCall()
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
ACTION_ACCEPT -> {
|
||||||
|
val result = CallManager.acceptIncomingCall()
|
||||||
|
if (result == CallActionResult.STARTED || CallManager.state.value.phase != CallPhase.IDLE) {
|
||||||
|
openCallUi()
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Accept action ignored: $result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
val snapshot = extractSnapshot(intent)
|
||||||
|
if (snapshot.phase == CallPhase.IDLE) {
|
||||||
|
stopForegroundCompat()
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureNotificationChannel()
|
||||||
|
val notification = buildNotification(snapshot)
|
||||||
|
startForegroundCompat(notification, snapshot.phase)
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractSnapshot(intent: Intent?): Snapshot {
|
||||||
|
val state = CallManager.state.value
|
||||||
|
val payloadIntent = intent
|
||||||
|
if (payloadIntent == null || !payloadIntent.hasExtra(EXTRA_PHASE)) {
|
||||||
|
return Snapshot(
|
||||||
|
phase = state.phase,
|
||||||
|
displayName = state.displayName,
|
||||||
|
statusText = state.statusText,
|
||||||
|
durationSec = state.durationSec
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rawPhase = payloadIntent.getStringExtra(EXTRA_PHASE).orEmpty()
|
||||||
|
val phase = runCatching { CallPhase.valueOf(rawPhase) }.getOrElse { state.phase }
|
||||||
|
val displayName =
|
||||||
|
payloadIntent.getStringExtra(EXTRA_DISPLAY_NAME)
|
||||||
|
.orEmpty()
|
||||||
|
.ifBlank { state.displayName }
|
||||||
|
.ifBlank { "Unknown" }
|
||||||
|
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
|
||||||
|
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
|
||||||
|
return Snapshot(
|
||||||
|
phase = phase,
|
||||||
|
displayName = displayName,
|
||||||
|
statusText = statusText,
|
||||||
|
durationSec = durationSec.coerceAtLeast(0)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
val existing = manager.getNotificationChannel(CHANNEL_ID)
|
||||||
|
if (existing != null) return
|
||||||
|
|
||||||
|
val channel =
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
"Calls",
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Ongoing call controls"
|
||||||
|
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||||
|
setShowBadge(false)
|
||||||
|
}
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(snapshot: Snapshot): Notification {
|
||||||
|
val openAppPendingIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
REQUEST_OPEN_APP,
|
||||||
|
Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
|
||||||
|
},
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val endCallPendingIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
REQUEST_END_CALL,
|
||||||
|
Intent(this, CallForegroundService::class.java).setAction(ACTION_END),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val declinePendingIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
REQUEST_DECLINE_CALL,
|
||||||
|
Intent(this, CallForegroundService::class.java).setAction(ACTION_DECLINE),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
val answerPendingIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
REQUEST_ACCEPT_CALL,
|
||||||
|
Intent(this, CallForegroundService::class.java).setAction(ACTION_ACCEPT),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val defaultStatus =
|
||||||
|
when (snapshot.phase) {
|
||||||
|
CallPhase.INCOMING -> "Incoming call"
|
||||||
|
CallPhase.OUTGOING -> "Calling"
|
||||||
|
CallPhase.CONNECTING -> "Connecting"
|
||||||
|
CallPhase.ACTIVE -> "Call in progress"
|
||||||
|
CallPhase.IDLE -> "Call ended"
|
||||||
|
}
|
||||||
|
val contentText = snapshot.statusText.ifBlank { defaultStatus }
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val person = Person.Builder().setName(snapshot.displayName).setImportant(true).build()
|
||||||
|
val style =
|
||||||
|
if (snapshot.phase == CallPhase.INCOMING) {
|
||||||
|
Notification.CallStyle.forIncomingCall(
|
||||||
|
person,
|
||||||
|
declinePendingIntent,
|
||||||
|
answerPendingIntent
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Notification.CallStyle.forOngoingCall(person, endCallPendingIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Notification.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(snapshot.displayName)
|
||||||
|
.setContentText(contentText)
|
||||||
|
.setContentIntent(openAppPendingIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setCategory(Notification.CATEGORY_CALL)
|
||||||
|
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||||
|
.setStyle(style)
|
||||||
|
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
|
||||||
|
.apply {
|
||||||
|
if (snapshot.phase == CallPhase.ACTIVE) {
|
||||||
|
setUsesChronometer(true)
|
||||||
|
setWhen(System.currentTimeMillis() - snapshot.durationSec * 1000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_notification)
|
||||||
|
.setContentTitle(snapshot.displayName)
|
||||||
|
.setContentText(contentText)
|
||||||
|
.setContentIntent(openAppPendingIntent)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
|
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||||
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setOngoing(true)
|
||||||
|
.apply {
|
||||||
|
if (snapshot.phase == CallPhase.INCOMING) {
|
||||||
|
addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent)
|
||||||
|
addAction(android.R.drawable.ic_menu_close_clear_cancel, "Decline", declinePendingIntent)
|
||||||
|
} else {
|
||||||
|
addAction(android.R.drawable.ic_menu_close_clear_cancel, "End", endCallPendingIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.apply {
|
||||||
|
if (snapshot.phase == CallPhase.ACTIVE) {
|
||||||
|
setUsesChronometer(true)
|
||||||
|
setWhen(System.currentTimeMillis() - snapshot.durationSec * 1000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundCompat(notification: Notification, phase: CallPhase) {
|
||||||
|
val started =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val preferredType =
|
||||||
|
when (phase) {
|
||||||
|
CallPhase.INCOMING -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||||
|
CallPhase.OUTGOING,
|
||||||
|
CallPhase.CONNECTING,
|
||||||
|
CallPhase.ACTIVE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||||
|
CallPhase.IDLE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||||
|
}
|
||||||
|
startForegroundTyped(notification, preferredType) ||
|
||||||
|
startForegroundTyped(notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) ||
|
||||||
|
startForegroundUntyped(notification)
|
||||||
|
} else {
|
||||||
|
startForegroundUntyped(notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!started) {
|
||||||
|
Log.e(TAG, "Failed to start foreground service safely; stopping service")
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundTyped(notification: Notification, type: Int): Boolean {
|
||||||
|
return try {
|
||||||
|
startForeground(NOTIFICATION_ID, notification, type)
|
||||||
|
true
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundUntyped(notification: Notification): Boolean {
|
||||||
|
return try {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
true
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
Log.w(TAG, "Untyped startForeground failed: ${error.message}")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun stopForegroundCompat() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
} else {
|
||||||
|
stopForeground(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "CallForegroundService"
|
||||||
|
private const val CHANNEL_ID = "rosetta_calls"
|
||||||
|
private const val NOTIFICATION_ID = 9010
|
||||||
|
private const val REQUEST_OPEN_APP = 9011
|
||||||
|
private const val REQUEST_END_CALL = 9012
|
||||||
|
private const val REQUEST_DECLINE_CALL = 9013
|
||||||
|
private const val REQUEST_ACCEPT_CALL = 9014
|
||||||
|
|
||||||
|
private const val ACTION_SYNC = "com.rosetta.messenger.call.ACTION_SYNC"
|
||||||
|
private const val ACTION_END = "com.rosetta.messenger.call.ACTION_END"
|
||||||
|
private const val ACTION_DECLINE = "com.rosetta.messenger.call.ACTION_DECLINE"
|
||||||
|
private const val ACTION_ACCEPT = "com.rosetta.messenger.call.ACTION_ACCEPT"
|
||||||
|
private const val ACTION_STOP = "com.rosetta.messenger.call.ACTION_STOP"
|
||||||
|
|
||||||
|
private const val EXTRA_PHASE = "extra_phase"
|
||||||
|
private const val EXTRA_DISPLAY_NAME = "extra_display_name"
|
||||||
|
private const val EXTRA_STATUS_TEXT = "extra_status_text"
|
||||||
|
private const val EXTRA_DURATION_SEC = "extra_duration_sec"
|
||||||
|
const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
|
||||||
|
|
||||||
|
fun syncWithCallState(context: Context, state: CallUiState) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
if (state.phase == CallPhase.IDLE) {
|
||||||
|
appContext.stopService(Intent(appContext, CallForegroundService::class.java))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent =
|
||||||
|
Intent(appContext, CallForegroundService::class.java)
|
||||||
|
.setAction(ACTION_SYNC)
|
||||||
|
.putExtra(EXTRA_PHASE, state.phase.name)
|
||||||
|
.putExtra(EXTRA_DISPLAY_NAME, state.displayName)
|
||||||
|
.putExtra(EXTRA_STATUS_TEXT, state.statusText)
|
||||||
|
.putExtra(EXTRA_DURATION_SEC, state.durationSec)
|
||||||
|
|
||||||
|
runCatching { ContextCompat.startForegroundService(appContext, intent) }
|
||||||
|
.onFailure { error ->
|
||||||
|
Log.w(TAG, "Failed to start foreground service: ${error.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
val appContext = context.applicationContext
|
||||||
|
val intent = Intent(appContext, CallForegroundService::class.java).setAction(ACTION_STOP)
|
||||||
|
runCatching { appContext.startService(intent) }
|
||||||
|
.onFailure {
|
||||||
|
appContext.stopService(Intent(appContext, CallForegroundService::class.java))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openCallUi() {
|
||||||
|
val intent =
|
||||||
|
Intent(this, MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
|
||||||
|
}
|
||||||
|
runCatching { startActivity(intent) }
|
||||||
|
.onFailure { error -> Log.w(TAG, "Failed to open call UI: ${error.message}") }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -359,7 +359,7 @@ object CallManager {
|
|||||||
updateState {
|
updateState {
|
||||||
it.copy(
|
it.copy(
|
||||||
phase = CallPhase.CONNECTING,
|
phase = CallPhase.CONNECTING,
|
||||||
statusText = "Connecting..."
|
statusText = "Connecting"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ensurePeerConnectionAndOffer()
|
ensurePeerConnectionAndOffer()
|
||||||
|
|||||||
@@ -48,10 +48,12 @@ import com.rosetta.messenger.ui.icons.TelegramIcons
|
|||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
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 DIRECT_MESSAGE_STACK_TIME_DIFF_MS = 60_000L
|
||||||
private const val MESSAGE_SKELETON_VISIBILITY_DELAY_MS = 180L
|
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 {
|
private fun isSameLocalDay(firstTimestampMs: Long, secondTimestampMs: Long): Boolean {
|
||||||
val firstCalendar =
|
val firstCalendar =
|
||||||
java.util.Calendar.getInstance().apply {
|
java.util.Calendar.getInstance().apply {
|
||||||
@@ -308,6 +331,7 @@ fun ChatDetailScreen(
|
|||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
val database = RosettaDatabase.getDatabase(context)
|
val database = RosettaDatabase.getDatabase(context)
|
||||||
|
val searchIndexDao = remember(database) { database.messageSearchIndexDao() }
|
||||||
val hapticFeedback = LocalHapticFeedback.current
|
val hapticFeedback = LocalHapticFeedback.current
|
||||||
|
|
||||||
// 🔇 Mute state — read from PreferencesManager
|
// 🔇 Mute state — read from PreferencesManager
|
||||||
@@ -772,6 +796,18 @@ fun ChatDetailScreen(
|
|||||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||||
var showBlockConfirm by remember { mutableStateOf(false) }
|
var showBlockConfirm by remember { mutableStateOf(false) }
|
||||||
var showUnblockConfirm 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
|
// Наблюдаем за статусом блокировки в реальном времени через Flow
|
||||||
val isBlocked by
|
val isBlocked by
|
||||||
database.blacklistDao()
|
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
|
// Динамический subtitle: typing > online > offline
|
||||||
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
|
val isSystemAccount = MessageRepository.isSystemAccount(user.publicKey)
|
||||||
@@ -1241,7 +1361,13 @@ fun ChatDetailScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 Обработка системной кнопки назад
|
// 🔥 Обработка системной кнопки назад
|
||||||
BackHandler { hideKeyboardAndBack() }
|
BackHandler {
|
||||||
|
if (isInChatSearchMode) {
|
||||||
|
closeInChatSearch()
|
||||||
|
} else {
|
||||||
|
hideKeyboardAndBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 Lifecycle-aware отслеживание активности экрана
|
// 🔥 Lifecycle-aware отслеживание активности экрана
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
@@ -1433,12 +1559,18 @@ fun ChatDetailScreen(
|
|||||||
) {
|
) {
|
||||||
// Контент хедера с Crossfade для плавной смены - ускоренная
|
// Контент хедера с Crossfade для плавной смены - ускоренная
|
||||||
// анимация
|
// анимация
|
||||||
|
val headerMode =
|
||||||
|
when {
|
||||||
|
isSelectionMode -> ChatHeaderMode.SELECTION
|
||||||
|
isInChatSearchMode -> ChatHeaderMode.SEARCH
|
||||||
|
else -> ChatHeaderMode.NORMAL
|
||||||
|
}
|
||||||
Crossfade(
|
Crossfade(
|
||||||
targetState = isSelectionMode,
|
targetState = headerMode,
|
||||||
animationSpec = tween(150),
|
animationSpec = tween(150),
|
||||||
label = "headerContent"
|
label = "headerContent"
|
||||||
) { selectionMode ->
|
) { currentHeaderMode ->
|
||||||
if (selectionMode) {
|
if (currentHeaderMode == ChatHeaderMode.SELECTION) {
|
||||||
// SELECTION MODE CONTENT
|
// SELECTION MODE CONTENT
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
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 {
|
} else {
|
||||||
// NORMAL HEADER CONTENT
|
// NORMAL HEADER CONTENT
|
||||||
Row(
|
Row(
|
||||||
@@ -1943,6 +2198,14 @@ fun ChatDetailScreen(
|
|||||||
isSystemAccount,
|
isSystemAccount,
|
||||||
isBlocked =
|
isBlocked =
|
||||||
isBlocked,
|
isBlocked,
|
||||||
|
onSearchInChatClick = {
|
||||||
|
showMenu = false
|
||||||
|
hideInputOverlays()
|
||||||
|
isInChatSearchMode = true
|
||||||
|
inChatSearchQuery = ""
|
||||||
|
inChatSearchResultIds = emptyList()
|
||||||
|
inChatSearchResultIndex = -1
|
||||||
|
},
|
||||||
onGroupInfoClick = {
|
onGroupInfoClick = {
|
||||||
showMenu =
|
showMenu =
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -64,11 +64,14 @@ import com.rosetta.messenger.data.MessageRepository
|
|||||||
import com.rosetta.messenger.data.RecentSearchesManager
|
import com.rosetta.messenger.data.RecentSearchesManager
|
||||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
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.DeviceEntry
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
|
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.AnimatedDotsText
|
||||||
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
|
||||||
import com.rosetta.messenger.ui.components.AppleEmojiText
|
import com.rosetta.messenger.ui.components.AppleEmojiText
|
||||||
@@ -268,6 +271,9 @@ fun ChatsListScreen(
|
|||||||
onTogglePin: (String) -> Unit = {},
|
onTogglePin: (String) -> Unit = {},
|
||||||
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
chatsViewModel: ChatsListViewModel = androidx.lifecycle.viewmodel.compose.viewModel(),
|
||||||
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
avatarRepository: com.rosetta.messenger.repository.AvatarRepository? = null,
|
||||||
|
callUiState: CallUiState = CallUiState(),
|
||||||
|
isCallOverlayExpanded: Boolean = true,
|
||||||
|
onOpenCallOverlay: () -> Unit = {},
|
||||||
onAddAccount: () -> Unit = {},
|
onAddAccount: () -> Unit = {},
|
||||||
onSwitchAccount: (String) -> Unit = {},
|
onSwitchAccount: (String) -> Unit = {},
|
||||||
onDeleteAccountFromSidebar: (String) -> Unit = {}
|
onDeleteAccountFromSidebar: (String) -> Unit = {}
|
||||||
@@ -2095,6 +2101,13 @@ fun ChatsListScreen(
|
|||||||
Color(0xFF1A1A1A)
|
Color(0xFF1A1A1A)
|
||||||
else Color(0xFFF2F2F7)
|
else Color(0xFFF2F2F7)
|
||||||
}
|
}
|
||||||
|
val showStickyCallBanner =
|
||||||
|
remember(callUiState, isCallOverlayExpanded) {
|
||||||
|
callUiState.isVisible &&
|
||||||
|
!isCallOverlayExpanded &&
|
||||||
|
callUiState.phase != CallPhase.INCOMING
|
||||||
|
}
|
||||||
|
val callBannerHeight = 40.dp
|
||||||
// 🔥 Берем dialogs из chatsState для
|
// 🔥 Берем dialogs из chatsState для
|
||||||
// консистентности
|
// консистентности
|
||||||
// 📌 Порядок по времени готовится в ViewModel.
|
// 📌 Порядок по времени готовится в ViewModel.
|
||||||
@@ -2286,10 +2299,21 @@ fun ChatsListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.fillMaxSize()
|
||||||
|
.background(listBackgroundColor)
|
||||||
|
) {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = chatListState,
|
state = chatListState,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier.fillMaxSize()
|
Modifier.fillMaxSize()
|
||||||
|
.padding(
|
||||||
|
top =
|
||||||
|
if (showStickyCallBanner)
|
||||||
|
callBannerHeight
|
||||||
|
else 0.dp
|
||||||
|
)
|
||||||
.then(
|
.then(
|
||||||
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
|
if (requestsCount > 0) Modifier.nestedScroll(requestsNestedScroll)
|
||||||
else Modifier
|
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 Requests AnimatedContent
|
||||||
} // Close calls/main switch
|
} // Close calls/main switch
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.ContentCopy
|
|||||||
import androidx.compose.material.icons.filled.QrCode2
|
import androidx.compose.material.icons.filled.QrCode2
|
||||||
import androidx.compose.material.icons.filled.VolumeUp
|
import androidx.compose.material.icons.filled.VolumeUp
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
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.network.CallUiState
|
||||||
import com.rosetta.messenger.repository.AvatarRepository
|
import com.rosetta.messenger.repository.AvatarRepository
|
||||||
import com.rosetta.messenger.ui.components.AvatarImage
|
import com.rosetta.messenger.ui.components.AvatarImage
|
||||||
|
import compose.icons.TablerIcons
|
||||||
|
import compose.icons.tablericons.ChevronDown
|
||||||
|
|
||||||
// ── Telegram-style dark gradient colors ──────────────────────────
|
// ── Telegram-style dark gradient colors ──────────────────────────
|
||||||
|
|
||||||
@@ -66,11 +69,13 @@ fun CallOverlay(
|
|||||||
state: CallUiState,
|
state: CallUiState,
|
||||||
isDarkTheme: Boolean,
|
isDarkTheme: Boolean,
|
||||||
avatarRepository: AvatarRepository? = null,
|
avatarRepository: AvatarRepository? = null,
|
||||||
|
isExpanded: Boolean = true,
|
||||||
onAccept: () -> Unit,
|
onAccept: () -> Unit,
|
||||||
onDecline: () -> Unit,
|
onDecline: () -> Unit,
|
||||||
onEnd: () -> Unit,
|
onEnd: () -> Unit,
|
||||||
onToggleMute: () -> Unit,
|
onToggleMute: () -> Unit,
|
||||||
onToggleSpeaker: () -> Unit
|
onToggleSpeaker: () -> Unit,
|
||||||
|
onMinimize: (() -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
LaunchedEffect(state.isVisible) {
|
LaunchedEffect(state.isVisible) {
|
||||||
@@ -85,7 +90,7 @@ fun CallOverlay(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
visible = state.isVisible,
|
visible = state.isVisible && isExpanded,
|
||||||
enter = fadeIn(tween(300)),
|
enter = fadeIn(tween(300)),
|
||||||
exit = fadeOut(tween(200))
|
exit = fadeOut(tween(200))
|
||||||
) {
|
) {
|
||||||
@@ -96,17 +101,43 @@ fun CallOverlay(
|
|||||||
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
|
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
// ── Top-right QR icon ──
|
// ── Top controls: minimize (left) + key cast QR (right) ──
|
||||||
if ((state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) && state.keyCast.isNotBlank()) {
|
val canMinimize = onMinimize != null && state.phase != CallPhase.INCOMING && state.phase != CallPhase.IDLE
|
||||||
Box(
|
val showKeyCast =
|
||||||
|
(state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) &&
|
||||||
|
state.keyCast.isNotBlank()
|
||||||
|
|
||||||
|
if (canMinimize || showKeyCast) {
|
||||||
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.statusBarsPadding()
|
.statusBarsPadding()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
contentAlignment = Alignment.CenterEnd
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
EncryptionKeyButton(keyHex = state.keyCast)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -3245,6 +3245,7 @@ fun KebabMenu(
|
|||||||
isGroupChat: Boolean = false,
|
isGroupChat: Boolean = false,
|
||||||
isSystemAccount: Boolean = false,
|
isSystemAccount: Boolean = false,
|
||||||
isBlocked: Boolean,
|
isBlocked: Boolean,
|
||||||
|
onSearchInChatClick: () -> Unit = {},
|
||||||
onGroupInfoClick: () -> Unit = {},
|
onGroupInfoClick: () -> Unit = {},
|
||||||
onSearchMembersClick: () -> Unit = {},
|
onSearchMembersClick: () -> Unit = {},
|
||||||
onLeaveGroupClick: () -> Unit = {},
|
onLeaveGroupClick: () -> Unit = {},
|
||||||
@@ -3276,7 +3277,16 @@ fun KebabMenu(
|
|||||||
dismissOnClickOutside = true
|
dismissOnClickOutside = true
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
ContextMenuItemWithVector(
|
||||||
|
icon = TablerIcons.Search,
|
||||||
|
text = "Search",
|
||||||
|
onClick = onSearchInChatClick,
|
||||||
|
tintColor = iconColor,
|
||||||
|
textColor = textColor
|
||||||
|
)
|
||||||
|
|
||||||
if (isGroupChat) {
|
if (isGroupChat) {
|
||||||
|
Divider(color = dividerColor)
|
||||||
ContextMenuItemWithVector(
|
ContextMenuItemWithVector(
|
||||||
icon = TablerIcons.Search,
|
icon = TablerIcons.Search,
|
||||||
text = "Search Members",
|
text = "Search Members",
|
||||||
|
|||||||
Reference in New Issue
Block a user