Добавлен экран Calls в сайдбар и улучшено управление историей звонков

This commit is contained in:
2026-03-27 18:22:21 +05:00
parent 39b0b0e107
commit c3e97eee56
5 changed files with 763 additions and 20 deletions

View File

@@ -1015,6 +1015,9 @@ fun MainScreen(
onUserSelect = { selectedChatUser ->
pushScreen(Screen.ChatDetail(selectedChatUser))
},
onStartCall = { user ->
startCallWithPermission(user)
},
backgroundBlurColorId = backgroundBlurColorId,
pinnedChats = pinnedChats,
onTogglePin = { opponentKey ->

View File

@@ -174,6 +174,16 @@ interface GroupDao {
suspend fun deleteAllByAccount(account: String): Int
}
/** Строка истории звонков (messages + данные собеседника из dialogs) */
data class CallHistoryRow(
@Embedded val message: MessageEntity,
@ColumnInfo(name = "peer_key") val peerKey: String,
@ColumnInfo(name = "peer_title") val peerTitle: String?,
@ColumnInfo(name = "peer_username") val peerUsername: String?,
@ColumnInfo(name = "peer_verified") val peerVerified: Int?,
@ColumnInfo(name = "peer_online") val peerOnline: Int?
)
/** DAO для работы с сообщениями */
@Dao
interface MessageDao {
@@ -559,6 +569,65 @@ interface MessageDao {
)
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
/**
* 📞 История звонков на основе CALL attachments (type: 4)
* LEFT JOIN на dialogs нужен для имени/username/verified без дополнительных запросов.
*/
@Query(
"""
SELECT
m.*,
CASE
WHEN m.from_me = 1 THEN m.to_public_key
ELSE m.from_public_key
END AS peer_key,
d.opponent_title AS peer_title,
d.opponent_username AS peer_username,
d.verified AS peer_verified,
d.is_online AS peer_online
FROM messages m
LEFT JOIN dialogs d
ON d.account = m.account
AND d.opponent_key = CASE
WHEN m.from_me = 1 THEN m.to_public_key
ELSE m.from_public_key
END
WHERE m.account = :account
AND m.attachments != '[]'
AND (m.attachments LIKE '%"type":4%' OR m.attachments LIKE '%"type": 4%')
ORDER BY m.timestamp DESC, m.message_id DESC
LIMIT :limit
"""
)
fun getCallHistoryFlow(account: String, limit: Int = 300): Flow<List<CallHistoryRow>>
/** Пиры, у которых есть call attachments (нужно для пересчета dialogs после удаления). */
@Query(
"""
SELECT DISTINCT
CASE
WHEN from_me = 1 THEN to_public_key
ELSE from_public_key
END AS peer_key
FROM messages
WHERE account = :account
AND attachments != '[]'
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
"""
)
suspend fun getCallHistoryPeers(account: String): List<String>
/** Удалить все call events из messages для аккаунта. */
@Query(
"""
DELETE FROM messages
WHERE account = :account
AND attachments != '[]'
AND (attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%')
"""
)
suspend fun deleteAllCallMessages(account: String): Int
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
@Query(
"""

View File

@@ -63,10 +63,12 @@ import com.rosetta.messenger.data.EncryptedAccount
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.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.components.AnimatedDotsText
import com.rosetta.messenger.ui.chats.components.DeviceVerificationBanner
import com.rosetta.messenger.ui.components.AppleEmojiText
@@ -260,6 +262,7 @@ fun ChatsListScreen(
onRequestsClick: () -> Unit = {},
onNewChat: () -> Unit,
onUserSelect: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
onStartCall: (com.rosetta.messenger.network.SearchUser) -> Unit = {},
backgroundBlurColorId: String = "avatar",
pinnedChats: Set<String> = emptySet(),
onTogglePin: (String) -> Unit = {},
@@ -477,6 +480,7 @@ fun ChatsListScreen(
}
.sortedByDescending { it.progress }
}
val database = remember(context) { RosettaDatabase.getDatabase(context) }
val activeFileDownloads = remember(accountFileDownloads) {
accountFileDownloads.filter {
it.status == com.rosetta.messenger.network.FileDownloadStatus.QUEUED ||
@@ -520,14 +524,19 @@ fun ChatsListScreen(
// 📬 Requests screen state
var showRequestsScreen by remember { mutableStateOf(false) }
var showCallsScreen by remember { mutableStateOf(false) }
var showCallsMenu by remember { mutableStateOf(false) }
var showDownloadsScreen by remember { mutableStateOf(false) }
var isInlineRequestsTransitionLocked by remember { mutableStateOf(false) }
var isInlineCallsTransitionLocked by remember { mutableStateOf(false) }
var isRequestsRouteTapLocked by remember { mutableStateOf(false) }
val inlineRequestsTransitionLockMs = 340L
val inlineCallsTransitionLockMs = 340L
val requestsRouteTapLockMs = 420L
fun setInlineRequestsVisible(visible: Boolean) {
if (showRequestsScreen == visible || isInlineRequestsTransitionLocked) return
if (visible) showCallsScreen = false
isInlineRequestsTransitionLocked = true
showRequestsScreen = visible
scope.launch {
@@ -536,6 +545,52 @@ fun ChatsListScreen(
}
}
fun setInlineCallsVisible(visible: Boolean) {
if (showCallsScreen == visible || isInlineCallsTransitionLocked) return
if (visible) showRequestsScreen = false
isInlineCallsTransitionLocked = true
showCallsScreen = visible
if (!visible) showCallsMenu = false
scope.launch {
kotlinx.coroutines.delay(inlineCallsTransitionLockMs)
isInlineCallsTransitionLocked = false
}
}
suspend fun clearAllCallsHistory(): Int {
if (accountPublicKey.isBlank()) return 0
val messageDao = database.messageDao()
val dialogDao = database.dialogDao()
val peers = messageDao.getCallHistoryPeers(accountPublicKey).map { it.trim() }
.filter { it.isNotBlank() }.distinct()
val deletedCount = messageDao.deleteAllCallMessages(accountPublicKey)
if (deletedCount <= 0) return 0
peers.forEach { peerKey ->
val dialogKey =
if (accountPublicKey == peerKey) {
accountPublicKey
} else if (accountPublicKey < peerKey) {
"$accountPublicKey:$peerKey"
} else {
"$peerKey:$accountPublicKey"
}
val remaining = messageDao.getMessageCount(accountPublicKey, dialogKey)
if (remaining > 0) {
if (peerKey == accountPublicKey) {
dialogDao.updateSavedMessagesDialogFromMessages(accountPublicKey)
} else {
dialogDao.updateDialogFromMessages(accountPublicKey, peerKey)
}
} else {
dialogDao.deleteDialog(accountPublicKey, peerKey)
}
}
return deletedCount
}
fun openRequestsRouteSafely() {
if (isRequestsRouteTapLocked) return
isRequestsRouteTapLocked = true
@@ -548,6 +603,7 @@ fun ChatsListScreen(
LaunchedEffect(currentAccountKey) {
showDownloadsScreen = false
showCallsScreen = false
}
// 📂 Accounts section expanded state (arrow toggle)
@@ -571,6 +627,7 @@ fun ChatsListScreen(
var dialogToBlock by remember { mutableStateOf<DialogUiModel?>(null) }
var dialogToUnblock by remember { mutableStateOf<DialogUiModel?>(null) }
var accountToDelete by remember { mutableStateOf<EncryptedAccount?>(null) }
var showDeleteCallsDialog by remember { mutableStateOf(false) }
var deviceResolveRequest by
remember {
mutableStateOf<Pair<DeviceEntry, DeviceResolveAction>?>(null)
@@ -587,9 +644,11 @@ fun ChatsListScreen(
// Back: drawer → закрыть, selection → сбросить
// Когда ничего не открыто — НЕ перехватываем, система сама закроет приложение корректно
BackHandler(enabled = showDownloadsScreen || isSelectionMode || drawerState.isOpen) {
BackHandler(enabled = showDownloadsScreen || showCallsScreen || isSelectionMode || drawerState.isOpen) {
if (showDownloadsScreen) {
showDownloadsScreen = false
} else if (showCallsScreen) {
setInlineCallsVisible(false)
} else if (isSelectionMode) {
selectedChatKeys = emptySet()
} else if (drawerState.isOpen) {
@@ -766,7 +825,7 @@ fun ChatsListScreen(
) {
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = !showRequestsScreen && !showDownloadsScreen,
gesturesEnabled = !showRequestsScreen && !showDownloadsScreen && !showCallsScreen,
drawerContent = {
ModalDrawerSheet(
drawerContainerColor = Color.Transparent,
@@ -1194,6 +1253,23 @@ fun ChatsListScreen(
}
)
// 📞 Calls
DrawerMenuItemEnhanced(
icon = TablerIcons.Phone,
text = "Calls",
iconColor = menuIconColor,
textColor = menuTextColor,
onClick = {
scope.launch {
drawerState.close()
kotlinx.coroutines
.delay(100)
setInlineCallsVisible(true)
onCallsClick()
}
}
)
// 👥 New Group
DrawerMenuItemEnhanced(
icon = TablerIcons.Users,
@@ -1413,6 +1489,7 @@ fun ChatsListScreen(
key(
isDarkTheme,
showRequestsScreen,
showCallsScreen,
showDownloadsScreen,
isSelectionMode
) {
@@ -1553,11 +1630,15 @@ fun ChatsListScreen(
// ═══ NORMAL HEADER ═══
TopAppBar(
navigationIcon = {
if (showRequestsScreen || showDownloadsScreen) {
if (showRequestsScreen || showDownloadsScreen || showCallsScreen) {
IconButton(
onClick = {
if (showDownloadsScreen) {
showDownloadsScreen = false
} else if (showCallsScreen) {
setInlineCallsVisible(
false
)
} else {
setInlineRequestsVisible(
false
@@ -1650,6 +1731,13 @@ fun ChatsListScreen(
fontSize = 20.sp,
color = Color.White
)
} else if (showCallsScreen) {
Text(
"Calls",
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
color = Color.White
)
} else if (showRequestsScreen) {
Text(
"Requests",
@@ -1689,7 +1777,50 @@ fun ChatsListScreen(
}
},
actions = {
if (!showRequestsScreen && !showDownloadsScreen) {
if (showCallsScreen) {
Box {
IconButton(
onClick = {
showCallsMenu = true
}
) {
Icon(
TablerIcons.DotsVertical,
contentDescription = "Calls menu",
tint = Color.White
)
}
DropdownMenu(
expanded = showCallsMenu,
onDismissRequest = {
showCallsMenu = false
},
modifier = Modifier.background(
if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
)
) {
DropdownMenuItem(
text = {
Text(
"Delete all calls",
color = if (isDarkTheme) Color.White else Color.Black
)
},
onClick = {
showCallsMenu = false
showDeleteCallsDialog = true
},
leadingIcon = {
Icon(
imageVector = TablerIcons.Trash,
contentDescription = null,
tint = Color(0xFFE55A5A)
)
}
)
}
}
} else if (!showRequestsScreen && !showDownloadsScreen) {
// 📥 Animated download indicator (Telegram-style)
Box(
modifier =
@@ -1898,6 +2029,97 @@ fun ChatsListScreen(
modifier = Modifier.fillMaxSize()
)
} else {
// 🎬 Animated content transition between main list and
// calls
AnimatedContent(
targetState = showCallsScreen,
transitionSpec = {
if (targetState) {
slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> fullWidth } + fadeIn(
animationSpec = tween(200)
) togetherWith
slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> -fullWidth / 4 } + fadeOut(
animationSpec = tween(150)
)
} else {
slideInHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> -fullWidth / 4 } + fadeIn(
animationSpec = tween(200)
) togetherWith
slideOutHorizontally(
animationSpec = tween(280, easing = FastOutSlowInEasing)
) { fullWidth -> fullWidth } + fadeOut(
animationSpec = tween(150)
)
}
},
label = "CallsTransition"
) { isCallsScreen ->
if (isCallsScreen) {
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
val velocityTracker = androidx.compose.ui.input.pointer.util.VelocityTracker()
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
velocityTracker.resetTracking()
velocityTracker.addPosition(down.uptimeMillis, down.position)
var totalDragX = 0f
var totalDragY = 0f
var claimed = false
val touchSlop = viewConfiguration.touchSlop * 0.6f
while (true) {
val event = awaitPointerEvent()
val change = event.changes.firstOrNull { it.id == down.id } ?: break
if (change.changedToUpIgnoreConsumed()) break
val delta = change.positionChange()
totalDragX += delta.x
totalDragY += delta.y
velocityTracker.addPosition(change.uptimeMillis, change.position)
if (!claimed) {
val distance = kotlin.math.sqrt(totalDragX * totalDragX + totalDragY * totalDragY)
if (distance < touchSlop) continue
if (totalDragX > 0 && kotlin.math.abs(totalDragX) > kotlin.math.abs(totalDragY) * 1.2f) {
claimed = true
change.consume()
} else {
break
}
} else {
change.consume()
}
}
if (claimed) {
val velocityX = velocityTracker.calculateVelocity().x
val screenWidth = size.width.toFloat()
if (totalDragX > screenWidth * 0.08f || velocityX > 200f) {
setInlineCallsVisible(false)
}
}
}
}
) {
CallsHistoryScreen(
isDarkTheme = isDarkTheme,
accountPublicKey = accountPublicKey,
avatarRepository = avatarRepository,
onOpenChat = onUserSelect,
onStartCall = onStartCall,
onStartNewCall = onSearchClick,
modifier = Modifier.fillMaxSize()
)
}
} else {
// 🎬 Animated content transition between main list and
// requests
AnimatedContent(
@@ -2592,7 +2814,9 @@ fun ChatsListScreen(
}
}
}
} // Close AnimatedContent
} // Close Requests AnimatedContent
} // Close calls/main switch
} // Close Calls AnimatedContent
} // Close downloads/main content switch
} // Close Downloads AnimatedContent
@@ -2604,6 +2828,56 @@ fun ChatsListScreen(
// 🔥 Confirmation Dialogs
if (showDeleteCallsDialog) {
AlertDialog(
onDismissRequest = { showDeleteCallsDialog = false },
containerColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White,
title = {
Text(
"Delete all calls",
fontWeight = FontWeight.Bold,
color = textColor
)
},
text = {
Text(
"This will remove all call records from history. Chats and contacts will stay unchanged.",
color = secondaryTextColor
)
},
confirmButton = {
TextButton(
onClick = {
showDeleteCallsDialog = false
scope.launch {
val deleted = clearAllCallsHistory()
if (deleted > 0) {
Toast.makeText(
context,
"Deleted $deleted call records",
Toast.LENGTH_SHORT
).show()
} else {
Toast.makeText(
context,
"Call history is already empty",
Toast.LENGTH_SHORT
).show()
}
}
}
) {
Text("Delete", color = Color(0xFFE55A5A))
}
},
dismissButton = {
TextButton(onClick = { showDeleteCallsDialog = false }) {
Text("Cancel", color = PrimaryBlue)
}
}
)
}
// Delete Dialog Confirmation
if (dialogsToDelete.isNotEmpty()) {
val count = dialogsToDelete.size

View File

@@ -96,27 +96,17 @@ fun CallOverlay(
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
)
) {
// ── Top bar: "Encrypted" left + QR icon right ──
if (state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) {
Row(
// ── Top-right QR icon ──
if ((state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) && state.keyCast.isNotBlank()) {
Box(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
contentAlignment = Alignment.CenterEnd
) {
Text(
text = "\uD83D\uDD12 Encrypted",
color = Color.White.copy(alpha = 0.4f),
fontSize = 13.sp,
)
// QR grid icon — tap to show popover
if (state.keyCast.isNotBlank()) {
EncryptionKeyButton(keyHex = state.keyCast)
}
EncryptionKeyButton(keyHex = state.keyCast)
}
}

View File

@@ -0,0 +1,407 @@
package com.rosetta.messenger.ui.chats.calls
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallMade
import androidx.compose.material.icons.filled.CallReceived
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.Divider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 androidx.compose.ui.platform.LocalContext
import com.rosetta.messenger.database.CallHistoryRow
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons
import compose.icons.tablericons.Phone
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import org.json.JSONArray
private data class CallHistoryItem(
val messageId: String,
val peerKey: String,
val peerTitle: String,
val peerUsername: String,
val peerVerified: Int,
val peerOnline: Int,
val timestamp: Long,
val isOutgoing: Boolean,
val durationSec: Int,
val isMissed: Boolean
) {
fun toSearchUser(): SearchUser =
SearchUser(
publicKey = peerKey,
title = peerTitle,
username = peerUsername,
verified = peerVerified,
online = peerOnline
)
}
@Composable
fun CallsHistoryScreen(
isDarkTheme: Boolean,
accountPublicKey: String,
avatarRepository: AvatarRepository?,
onOpenChat: (SearchUser) -> Unit,
onStartCall: (SearchUser) -> Unit,
onStartNewCall: () -> Unit,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val messageDao = remember(context) { RosettaDatabase.getDatabase(context).messageDao() }
val rows by produceState(initialValue = emptyList<CallHistoryRow>(), accountPublicKey) {
if (accountPublicKey.isBlank()) {
value = emptyList()
return@produceState
}
messageDao.getCallHistoryFlow(accountPublicKey).collect { value = it }
}
val items = remember(rows) { rows.map { it.toCallHistoryItem() } }
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF2F2F7)
val textColor = if (isDarkTheme) Color.White else Color(0xFF111111)
val secondaryTextColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
val dividerColor = if (isDarkTheme) Color(0xFF2D2D2F) else Color(0xFFE7E7EA)
LazyColumn(
modifier = modifier.fillMaxSize().background(backgroundColor),
contentPadding = PaddingValues(bottom = 16.dp)
) {
item(key = "start_new_call") {
StartNewCallRow(
isDarkTheme = isDarkTheme,
onClick = onStartNewCall
)
Divider(color = dividerColor, thickness = 0.5.dp)
Text(
text = "You can add up to 200 participants to a call.",
color = secondaryTextColor,
fontSize = 13.sp,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp)
)
Divider(color = dividerColor, thickness = 0.5.dp)
}
if (items.isEmpty()) {
item(key = "empty_calls") {
EmptyCallsState(
isDarkTheme = isDarkTheme,
title = "No calls yet",
subtitle = "Your call history will appear here",
modifier = Modifier.fillMaxWidth().padding(top = 64.dp)
)
}
} else {
items(items, key = { it.messageId }) { item ->
CallHistoryRowItem(
item = item,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
textColor = textColor,
secondaryTextColor = secondaryTextColor,
onOpenChat = onOpenChat,
onStartCall = onStartCall
)
Divider(color = dividerColor, thickness = 0.5.dp)
}
}
}
}
@Composable
private fun StartNewCallRow(
isDarkTheme: Boolean,
onClick: () -> Unit
) {
val rowColor = if (isDarkTheme) Color(0xFF1B2B3A) else Color(0xFFEAF4FF)
val textColor = if (isDarkTheme) Color(0xFF74B8FF) else Color(0xFF1A73E8)
Row(
modifier = Modifier.fillMaxWidth().background(rowColor).clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 13.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = TablerIcons.Phone,
contentDescription = null,
tint = textColor,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Start New Call",
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
@Composable
private fun CallHistoryRowItem(
item: CallHistoryItem,
isDarkTheme: Boolean,
avatarRepository: AvatarRepository?,
textColor: Color,
secondaryTextColor: Color,
onOpenChat: (SearchUser) -> Unit,
onStartCall: (SearchUser) -> Unit
) {
val subtitleColor =
when {
item.isMissed -> Color(0xFFE55A5A)
isDarkTheme -> Color(0xFF56D97A)
else -> Color(0xFF1EA75E)
}
val directionIconColor =
if (item.durationSec == 0) Color(0xFFE55A5A) else subtitleColor
val directionIcon =
when {
item.durationSec == 0 -> Icons.Default.Close
item.isOutgoing -> Icons.Default.CallMade
else -> Icons.Default.CallReceived
}
val subtitleText =
when {
item.durationSec > 0 -> "${item.directionLabel()} ${formatCallTimestamp(item.timestamp)}"
else -> item.directionLabel() + " " + formatCallTimestamp(item.timestamp)
}
Row(
modifier =
Modifier.fillMaxWidth()
.clickable { onOpenChat(item.toSearchUser()) }
.padding(horizontal = 14.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AvatarImage(
publicKey = item.peerKey,
avatarRepository = avatarRepository,
size = 52.dp,
isDarkTheme = isDarkTheme,
displayName = item.peerTitle
)
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier.weight(1f)
) {
Text(
text = item.peerTitle,
color = textColor,
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(3.dp))
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = directionIcon,
contentDescription = null,
tint = directionIconColor,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = subtitleText,
color = if (item.isMissed) Color(0xFFE55A5A) else secondaryTextColor,
fontSize = 13.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
IconButton(
onClick = { onStartCall(item.toSearchUser()) }
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = "Call",
tint = PrimaryBlue
)
}
}
}
@Composable
private fun EmptyCallsState(
isDarkTheme: Boolean,
title: String,
subtitle: String,
modifier: Modifier = Modifier
) {
val iconTint = if (isDarkTheme) Color(0xFF5B5C63) else Color(0xFFAFB0B8)
val titleColor = if (isDarkTheme) Color(0xFFE1E1E6) else Color(0xFF1F1F23)
val subtitleColor = if (isDarkTheme) Color(0xFF9D9DA3) else Color(0xFF7A7A80)
Column(
modifier = modifier.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Box(
modifier = Modifier.size(72.dp).background(iconTint.copy(alpha = 0.2f), CircleShape),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Call,
contentDescription = null,
tint = iconTint,
modifier = Modifier.size(34.dp)
)
}
Spacer(modifier = Modifier.height(14.dp))
Text(
text = title,
color = titleColor,
fontSize = 18.sp,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = subtitle,
color = subtitleColor,
fontSize = 14.sp
)
}
}
private fun CallHistoryRow.toCallHistoryItem(): CallHistoryItem {
val displayName = resolveDisplayName(peerTitle.orEmpty(), peerUsername.orEmpty(), peerKey)
val username = peerUsername.orEmpty().trim().trimStart('@')
val durationSec = parseCallDurationFromAttachments(message.attachments)
val isOutgoing = message.fromMe == 1
val isMissed = !isOutgoing && durationSec == 0
return CallHistoryItem(
messageId = message.messageId,
peerKey = peerKey,
peerTitle = displayName,
peerUsername = username,
peerVerified = peerVerified ?: 0,
peerOnline = peerOnline ?: 0,
timestamp = message.timestamp,
isOutgoing = isOutgoing,
durationSec = durationSec,
isMissed = isMissed
)
}
private fun resolveDisplayName(title: String, username: String, publicKey: String): String {
val normalizedTitle = title.trim()
if (normalizedTitle.isNotEmpty() &&
normalizedTitle != publicKey &&
normalizedTitle != publicKey.take(7) &&
normalizedTitle != publicKey.take(8)
) {
return normalizedTitle
}
val normalizedUsername = username.trim().trimStart('@')
if (normalizedUsername.isNotEmpty()) return normalizedUsername
return publicKey.take(8)
}
private fun parseCallDurationFromAttachments(attachmentsJson: String): Int {
if (attachmentsJson.isBlank() || attachmentsJson == "[]") return 0
return runCatching {
val attachments = JSONArray(attachmentsJson)
for (i in 0 until attachments.length()) {
val attachment = attachments.optJSONObject(i) ?: continue
if (attachment.optInt("type", -1) != 4) continue
return parseCallDurationSeconds(attachment.optString("preview", ""))
}
0
}.getOrDefault(0)
}
private fun parseCallDurationSeconds(preview: String): Int {
if (preview.isBlank()) return 0
preview.substringAfterLast("::").trim().toIntOrNull()?.let {
return it.coerceAtLeast(0)
}
val durationRegex =
Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*(\\d+)", RegexOption.IGNORE_CASE)
durationRegex.find(preview)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let {
return it.coerceAtLeast(0)
}
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
}
private fun CallHistoryItem.directionLabel(): String {
return when {
durationSec == 0 && isOutgoing -> "Rejected call"
durationSec == 0 && !isOutgoing -> "Missed call"
isOutgoing -> "Outgoing call"
else -> "Incoming call"
}
}
private fun formatCallTimestamp(timestamp: Long): String {
if (timestamp <= 0L) return ""
val now = Calendar.getInstance()
val callTime = Calendar.getInstance().apply { timeInMillis = timestamp }
val sameYear = now.get(Calendar.YEAR) == callTime.get(Calendar.YEAR)
val sameDay =
sameYear && now.get(Calendar.DAY_OF_YEAR) == callTime.get(Calendar.DAY_OF_YEAR)
val yesterday = now.clone() as Calendar
yesterday.add(Calendar.DAY_OF_YEAR, -1)
val isYesterday =
sameYear && yesterday.get(Calendar.DAY_OF_YEAR) == callTime.get(Calendar.DAY_OF_YEAR)
return when {
sameDay -> SimpleDateFormat("HH:mm", Locale.getDefault()).format(Date(timestamp))
isYesterday -> "Yesterday"
else -> SimpleDateFormat("dd MMM", Locale.getDefault()).format(Date(timestamp))
}
}