From c3e97eee560e4c4f5206741eca4577d5251cafcf Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Fri, 27 Mar 2026 18:22:21 +0500 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20Calls=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=B0=D0=B9=D0=B4=D0=B1=D0=B0=D1=80=20=D0=B8=20=D1=83?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D1=88=D0=B5=D0=BD=D0=BE=20=D1=83=D0=BF=D1=80?= =?UTF-8?q?=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=B8=D0=B5=D0=B9=20=D0=B7=D0=B2=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rosetta/messenger/MainActivity.kt | 3 + .../messenger/database/MessageEntities.kt | 69 +++ .../messenger/ui/chats/ChatsListScreen.kt | 284 +++++++++++- .../messenger/ui/chats/calls/CallOverlay.kt | 20 +- .../ui/chats/calls/CallsHistoryScreen.kt | 407 ++++++++++++++++++ 5 files changed, 763 insertions(+), 20 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index c7353ad..666ac1a 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -1015,6 +1015,9 @@ fun MainScreen( onUserSelect = { selectedChatUser -> pushScreen(Screen.ChatDetail(selectedChatUser)) }, + onStartCall = { user -> + startCallWithPermission(user) + }, backgroundBlurColorId = backgroundBlurColorId, pinnedChats = pinnedChats, onTogglePin = { opponentKey -> diff --git a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt index ff229f1..f23fa40 100644 --- a/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/MessageEntities.kt @@ -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 + /** + * 📞 История звонков на основе 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> + + /** Пиры, у которых есть 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 + + /** Удалить все 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( """ diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 623317c..1dcbaa0 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -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 = 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(null) } var dialogToUnblock by remember { mutableStateOf(null) } var accountToDelete by remember { mutableStateOf(null) } + var showDeleteCallsDialog by remember { mutableStateOf(false) } var deviceResolveRequest by remember { mutableStateOf?>(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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt index 787d566..f63e366 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallOverlay.kt @@ -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) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt new file mode 100644 index 0000000..dd3f6a0 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/calls/CallsHistoryScreen.kt @@ -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(), 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)) + } +}