diff --git a/android/CHANGELOG.md b/android/CHANGELOG.md index 6f91b42..c67ec6f 100644 --- a/android/CHANGELOG.md +++ b/android/CHANGELOG.md @@ -862,3 +862,17 @@ - richer reaction row (`❤️ 👍 👎 🔥 🥰 👏 😁`), - reply/edit/forward/delete actions kept in one sheet. - Removed duplicate/conflicting selection controls between top and bottom action rows. + +### Step 122 - Chat 3-dot menu + chat info media tabs shell +- Added chat header `3-dot` popup menu with Telegram-like actions: + - `Chat info` + - `Search` + - `Notifications` + - `Change wallpaper` + - `Clear history` +- Added `Chat info` bottom sheet with tabbed sections: + - `Media` + - `Files` + - `Links` + - `Voice` +- Implemented local tab content from current loaded chat messages/attachments to provide immediate media/files/links/voice overview. diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 87135fa..729b515 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -45,6 +45,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Surface import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField @@ -96,6 +98,12 @@ import androidx.compose.material.icons.filled.Movie import androidx.compose.material.icons.filled.RadioButtonChecked import androidx.compose.material.icons.filled.RadioButtonUnchecked import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Wallpaper +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Image +import androidx.compose.material.icons.filled.Link +import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import coil.compose.AsyncImage import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -105,6 +113,7 @@ import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder import java.time.Instant import java.time.ZoneId import java.time.format.DateTimeFormatter +import java.util.Locale import kotlinx.coroutines.delay import kotlin.math.roundToInt @@ -262,10 +271,15 @@ fun ChatScreen( var pendingDeleteForAll by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) } var showInlineSearch by remember { mutableStateOf(false) } + var showChatMenu by remember { mutableStateOf(false) } + var showChatInfoSheet by remember { mutableStateOf(false) } + var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) } val actionSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val forwardSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val chatInfoSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp + val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) } LaunchedEffect(state.isRecordingVoice) { if (!state.isRecordingVoice) return@LaunchedEffect @@ -402,8 +416,49 @@ fun ChatScreen( ) { Icon(imageVector = Icons.Filled.Search, contentDescription = "Search in chat") } - IconButton(onClick = onLoadMore, enabled = !state.isLoadingMore) { - Icon(imageVector = Icons.Filled.MoreVert, contentDescription = "More") + Box { + IconButton( + onClick = { showChatMenu = true }, + enabled = !state.isLoadingMore, + ) { + Icon(imageVector = Icons.Filled.MoreVert, contentDescription = "More") + } + DropdownMenu( + expanded = showChatMenu, + onDismissRequest = { showChatMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Chat info") }, + leadingIcon = { Icon(Icons.Filled.Info, contentDescription = null) }, + onClick = { + showChatMenu = false + showChatInfoSheet = true + }, + ) + DropdownMenuItem( + text = { Text("Search") }, + leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, + onClick = { + showChatMenu = false + showInlineSearch = true + }, + ) + DropdownMenuItem( + text = { Text("Notifications") }, + leadingIcon = { Icon(Icons.Filled.Notifications, contentDescription = null) }, + onClick = { showChatMenu = false }, + ) + DropdownMenuItem( + text = { Text("Change wallpaper") }, + leadingIcon = { Icon(Icons.Filled.Wallpaper, contentDescription = null) }, + onClick = { showChatMenu = false }, + ) + DropdownMenuItem( + text = { Text("Clear history") }, + leadingIcon = { Icon(Icons.Filled.DeleteOutline, contentDescription = null) }, + onClick = { showChatMenu = false }, + ) + } } } } @@ -685,6 +740,100 @@ fun ChatScreen( } } } + if (showChatInfoSheet) { + ModalBottomSheet( + onDismissRequest = { showChatInfoSheet = false }, + sheetState = chatInfoSheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (!state.chatAvatarUrl.isNullOrBlank()) { + AsyncImage( + model = state.chatAvatarUrl, + contentDescription = null, + modifier = Modifier + .size(44.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Text( + text = state.chatTitle.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.titleSmall, + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = state.chatTitle.ifBlank { "Chat #${state.chatId}" }, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + ) + if (state.chatSubtitle.isNotBlank()) { + Text( + text = state.chatSubtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ChatInfoTab.entries.forEach { tab -> + val selected = chatInfoTab == tab + Surface( + shape = RoundedCornerShape(16.dp), + color = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.22f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + }, + modifier = Modifier + .weight(1f) + .clip(RoundedCornerShape(16.dp)) + .clickable { chatInfoTab = tab }, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = tab.title, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } + } + ChatInfoTabContent( + tab = chatInfoTab, + entries = chatInfoEntries, + ) + } + } + } if (state.replyToMessage != null || state.editingMessage != null) { val header = if (state.editingMessage != null) { @@ -1574,3 +1723,167 @@ private fun formatBytes(value: Long): String { else -> "$value B" } } + +private enum class ChatInfoTab(val title: String) { + Media("Media"), + Files("Files"), + Links("Links"), + Voice("Voice"), +} + +private enum class ChatInfoEntryType { + Media, + File, + Link, + Voice, +} + +private data class ChatInfoEntry( + val type: ChatInfoEntryType, + val title: String, + val subtitle: String, + val previewImageUrl: String? = null, +) + +@Composable +private fun ChatInfoTabContent( + tab: ChatInfoTab, + entries: List, +) { + val filtered = remember(tab, entries) { + entries.filter { + when (tab) { + ChatInfoTab.Media -> it.type == ChatInfoEntryType.Media + ChatInfoTab.Files -> it.type == ChatInfoEntryType.File + ChatInfoTab.Links -> it.type == ChatInfoEntryType.Link + ChatInfoTab.Voice -> it.type == ChatInfoEntryType.Voice + } + } + } + if (filtered.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 20.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "No ${tab.title.lowercase(Locale.getDefault())} yet", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + return + } + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .height(320.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(filtered.take(60)) { entry -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)) + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (!entry.previewImageUrl.isNullOrBlank()) { + AsyncImage( + model = entry.previewImageUrl, + contentDescription = null, + modifier = Modifier + .size(46.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop, + ) + } else { + Box( + modifier = Modifier + .size(46.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + val marker = when (entry.type) { + ChatInfoEntryType.Media -> Icons.Filled.Image + ChatInfoEntryType.File -> Icons.AutoMirrored.Filled.InsertDriveFile + ChatInfoEntryType.Link -> Icons.Filled.Link + ChatInfoEntryType.Voice -> Icons.Filled.Mic + } + Icon( + imageVector = marker, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Column(modifier = Modifier.weight(1f)) { + Text( + text = entry.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + Text( + text = entry.subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + } + } + } +} + +private val urlRegex = Regex("""https?://[^\s]+""") + +private fun buildChatInfoEntries(messages: List): List { + val entries = mutableListOf() + messages + .sortedByDescending { it.id } + .forEach { message -> + val time = formatMessageTime(message.createdAt) + message.attachments.forEach { attachment -> + val normalized = attachment.fileType.lowercase(Locale.getDefault()) + when { + normalized.startsWith("image/") || normalized.startsWith("video/") -> { + entries += ChatInfoEntry( + type = ChatInfoEntryType.Media, + title = extractFileName(attachment.fileUrl), + subtitle = "$time • ${attachment.fileType}", + previewImageUrl = if (normalized.startsWith("image/")) attachment.fileUrl else null, + ) + } + + normalized.startsWith("audio/") && message.type.contains("voice", ignoreCase = true) -> { + entries += ChatInfoEntry( + type = ChatInfoEntryType.Voice, + title = extractFileName(attachment.fileUrl), + subtitle = "$time • ${formatBytes(attachment.fileSize)}", + ) + } + + else -> { + entries += ChatInfoEntry( + type = ChatInfoEntryType.File, + title = extractFileName(attachment.fileUrl), + subtitle = "$time • ${formatBytes(attachment.fileSize)}", + ) + } + } + } + urlRegex.findAll(message.text.orEmpty()).forEach { match -> + entries += ChatInfoEntry( + type = ChatInfoEntryType.Link, + title = match.value, + subtitle = "${message.senderDisplayName ?: "Unknown"} • $time", + ) + } + } + return entries +}