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 2c5bfb4..049733c 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 @@ -1,6 +1,7 @@ package ru.daemonlord.messenger.ui.chat import android.Manifest +import android.app.Activity import android.content.Context import android.content.Intent import android.content.pm.PackageManager @@ -67,6 +68,7 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.derivedStateOf import androidx.compose.animation.core.LinearEasing @@ -81,7 +83,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -93,7 +97,10 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.WindowInsets @@ -106,6 +113,7 @@ import androidx.compose.ui.layout.onSizeChanged import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.core.content.ContextCompat +import androidx.core.view.WindowCompat import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Forward import androidx.compose.material.icons.automirrored.filled.ArrowBack @@ -125,10 +133,10 @@ 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.Pause -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.filled.Wallpaper import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.FormatBold @@ -141,6 +149,7 @@ import androidx.compose.material.icons.filled.VisibilityOff import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import coil.compose.AsyncImage +import ru.daemonlord.messenger.R import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.Dispatchers @@ -278,6 +287,9 @@ fun ChatRoute( onInlineSearchChanged = viewModel::onInlineSearchChanged, onJumpInlineSearch = viewModel::jumpInlineSearch, onVisibleIncomingMessageId = viewModel::onVisibleIncomingMessageId, + onToggleChatNotifications = viewModel::onToggleChatNotifications, + onClearHistory = viewModel::onClearHistory, + onDeleteChat = viewModel::onDeleteOrLeaveChat, ) } @@ -312,8 +324,12 @@ fun ChatScreen( onInlineSearchChanged: (String) -> Unit, onJumpInlineSearch: (Boolean) -> Unit, onVisibleIncomingMessageId: (Long?) -> Unit, + onToggleChatNotifications: () -> Unit, + onClearHistory: () -> Unit, + onDeleteChat: () -> Unit, ) { val context = LocalContext.current + val view = LocalView.current val listState = rememberLazyListState() val scope = rememberCoroutineScope() val allImageUrls = remember(state.messages) { @@ -344,7 +360,25 @@ fun ChatScreen( .distinct() } val isChannelChat = state.chatType.equals("channel", ignoreCase = true) + val isPrivateChat = state.chatType.equals("private", ignoreCase = true) val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) } + val senderNameByUserId = remember(state.messages) { + state.messages + .mapNotNull { message -> + val name = message.senderDisplayName?.trim().orEmpty() + if (name.isBlank()) null else message.senderId to name + } + .toMap() + } + val replyAuthorByMessageId = remember(state.messages, senderNameByUserId) { + state.messages.associate { message -> + val resolved = message.senderDisplayName + ?.takeIf { it.isNotBlank() } + ?: senderNameByUserId[message.senderId] + ?: "Unknown user" + message.id to resolved + } + } var didInitialAutoScroll by remember(state.chatId) { mutableStateOf(false) } var viewerImageIndex by remember { mutableStateOf(null) } var viewerVideoUrl by remember { mutableStateOf(null) } @@ -365,6 +399,8 @@ fun ChatScreen( var giphyErrorMessage by remember { mutableStateOf(null) } var isPickerSending by remember { mutableStateOf(false) } var showChatMenu by remember { mutableStateOf(false) } + var showClearHistoryDialog by remember { mutableStateOf(false) } + var showDeleteChatDialog by remember { mutableStateOf(false) } var showChatInfoSheet by remember { mutableStateOf(false) } var chatInfoTab by remember { mutableStateOf(ChatInfoTab.Media) } var composerValue by remember { mutableStateOf(TextFieldValue(state.inputText)) } @@ -393,6 +429,22 @@ fun ChatScreen( lastVisibleIndex < lastIndex - 1 } } + val isLightTheme = MaterialTheme.colorScheme.background.luminance() > 0.5f + val chatTopBarColor = if (isLightTheme) { + MaterialTheme.colorScheme.surface.copy(alpha = 0.98f) + } else { + MaterialTheme.colorScheme.surface + .copy(alpha = 0.92f) + .compositeOver(Color(0xFF1B1A22)) + } + if (!view.isInEditMode) { + SideEffect { + val window = (context as? Activity)?.window ?: return@SideEffect + window.statusBarColor = chatTopBarColor.toArgb() + WindowCompat.getInsetsController(window, view)?.isAppearanceLightStatusBars = + chatTopBarColor.luminance() > 0.5f + } + } LaunchedEffect(state.isRecordingVoice) { if (!state.isRecordingVoice) return@LaunchedEffect @@ -439,6 +491,11 @@ fun ChatScreen( onInlineSearchChanged("") } } + LaunchedEffect(state.chatDeletedNonce) { + if (state.chatDeletedNonce > 0L) { + onBack() + } + } LaunchedEffect(emojiPickerTab, gifSearchQuery, giphyApiKey) { if (emojiPickerTab != ComposerPickerTab.Gif) return@LaunchedEffect val query = gifSearchQuery.trim() @@ -486,11 +543,19 @@ fun ChatScreen( .fillMaxSize() .background( brush = Brush.verticalGradient( - colors = listOf( - Color(0xFF1B1A22), - Color(0xFF242034), - Color(0xFF1A202A), - ), + colors = if (isLightTheme) { + listOf( + Color(0xFFE1DCEC), + Color(0xFFD8D2E4), + Color(0xFFCFC8DC), + ) + } else { + listOf( + Color(0xFF1B1A22), + Color(0xFF242034), + Color(0xFF1A202A), + ) + }, ), ) .windowInsetsPadding(WindowInsets.safeDrawing) @@ -500,7 +565,7 @@ fun ChatScreen( Row( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.92f)) + .background(chatTopBarColor) .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -533,7 +598,7 @@ fun ChatScreen( Row( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)) + .background(chatTopBarColor) .padding(horizontal = 8.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -610,15 +675,15 @@ fun ChatScreen( onDismissRequest = { showChatMenu = false }, ) { DropdownMenuItem( - text = { Text("Chat info") }, - leadingIcon = { Icon(Icons.Filled.Info, contentDescription = null) }, + text = { Text(stringResource(id = R.string.chat_menu_notifications)) }, + leadingIcon = { Icon(Icons.Filled.Notifications, contentDescription = null) }, onClick = { showChatMenu = false - showChatInfoSheet = true + onToggleChatNotifications() }, ) DropdownMenuItem( - text = { Text("Search") }, + text = { Text(stringResource(id = R.string.chat_menu_search)) }, leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) }, onClick = { showChatMenu = false @@ -626,19 +691,28 @@ fun ChatScreen( }, ) DropdownMenuItem( - text = { Text("Change wallpaper") }, + text = { Text(stringResource(id = R.string.chat_menu_change_wallpaper)) }, leadingIcon = { Icon(Icons.Filled.Wallpaper, contentDescription = null) }, - onClick = { showChatMenu = false }, + onClick = { + showChatMenu = false + Toast.makeText(context, context.getString(R.string.chat_wallpaper_coming_soon), Toast.LENGTH_SHORT).show() + }, ) DropdownMenuItem( - text = { Text("Notifications") }, - leadingIcon = { Icon(Icons.Filled.Notifications, contentDescription = null) }, - onClick = { showChatMenu = false }, - ) - DropdownMenuItem( - text = { Text("Clear history") }, + text = { Text(stringResource(id = R.string.chat_menu_clear_history)) }, leadingIcon = { Icon(Icons.Filled.DeleteOutline, contentDescription = null) }, - onClick = { showChatMenu = false }, + onClick = { + showChatMenu = false + showClearHistoryDialog = true + }, + ) + DropdownMenuItem( + text = { Text(if (isPrivateChat) stringResource(id = R.string.chat_delete_dialog) else stringResource(id = R.string.chat_leave_delete)) }, + leadingIcon = { Icon(Icons.Filled.DeleteOutline, contentDescription = null) }, + onClick = { + showChatMenu = false + showDeleteChatDialog = true + }, ) } } @@ -657,7 +731,7 @@ fun ChatScreen( value = state.inlineSearchQuery, onValueChange = onInlineSearchChanged, modifier = Modifier.weight(1f), - label = { Text("Search in chat") }, + label = { Text(stringResource(id = R.string.chat_search_in_chat)) }, singleLine = true, ) IconButton( @@ -678,7 +752,7 @@ fun ChatScreen( } if (showInlineSearch && state.inlineSearchMatches.isNotEmpty()) { Text( - text = "Matches: ${state.inlineSearchMatches.size}", + text = stringResource(id = R.string.chat_search_matches, state.inlineSearchMatches.size), modifier = Modifier.padding(horizontal = 12.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall, ) @@ -702,7 +776,7 @@ fun ChatScreen( ) Column(modifier = Modifier.weight(1f)) { Text( - text = "Pinned message", + text = stringResource(id = R.string.chat_pinned_message), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.SemiBold, ) @@ -823,6 +897,8 @@ fun ChatScreen( isSelected = isSelected, isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI, isInlineHighlighted = state.highlightedMessageId == message.id, + senderNameByUserId = senderNameByUserId, + replyAuthorByMessageId = replyAuthorByMessageId, reactions = state.reactionByMessageId[message.id].orEmpty(), onAttachmentImageClick = { imageUrl -> val idx = allImageUrls.indexOf(imageUrl) @@ -962,7 +1038,7 @@ fun ChatScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Icon(imageVector = Icons.AutoMirrored.Filled.Reply, contentDescription = null) - Text("Reply", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(id = R.string.chat_action_reply), style = MaterialTheme.typography.bodyLarge) } } Surface( @@ -984,7 +1060,7 @@ fun ChatScreen( ) { Icon(imageVector = Icons.Filled.Edit, contentDescription = null) Text( - "Edit", + stringResource(id = R.string.chat_action_edit), style = MaterialTheme.typography.bodyLarge, color = if (state.selectedCanEdit) { MaterialTheme.colorScheme.onSurface @@ -1013,7 +1089,7 @@ fun ChatScreen( horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Icon(imageVector = Icons.AutoMirrored.Filled.Forward, contentDescription = null) - Text("Forward", style = MaterialTheme.typography.bodyLarge) + Text(stringResource(id = R.string.chat_action_forward), style = MaterialTheme.typography.bodyLarge) } } Surface( @@ -1041,7 +1117,7 @@ fun ChatScreen( tint = MaterialTheme.colorScheme.error, ) Text( - "Delete", + stringResource(id = R.string.chat_action_delete), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.error, ) @@ -1053,7 +1129,7 @@ fun ChatScreen( onClearSelection() }, modifier = Modifier.fillMaxWidth(), - ) { Text("Close") } + ) { Text(stringResource(id = R.string.common_close)) } } } } @@ -1072,14 +1148,14 @@ fun ChatScreen( ) { Text( text = if (state.forwardingMessageIds.size == 1) { - "Forward message #${state.forwardingMessageIds.first()}" + stringResource(id = R.string.chat_forward_one, state.forwardingMessageIds.first()) } else { - "Forward ${state.forwardingMessageIds.size} messages" + stringResource(id = R.string.chat_forward_many, state.forwardingMessageIds.size) }, style = MaterialTheme.typography.labelLarge, ) if (state.availableForwardTargets.isEmpty()) { - Text("No available chats") + Text(stringResource(id = R.string.chat_no_available_chats)) } else { state.availableForwardTargets.forEach { target -> Button( @@ -1096,7 +1172,7 @@ fun ChatScreen( enabled = !state.isForwarding, modifier = Modifier.fillMaxWidth(), ) { - Text(if (state.isForwarding) "Forwarding..." else "Cancel") + Text(if (state.isForwarding) stringResource(id = R.string.chat_forwarding) else stringResource(id = R.string.common_cancel)) } } } @@ -1262,9 +1338,14 @@ fun ChatScreen( if (state.replyToMessage != null || state.editingMessage != null) { val header = if (state.editingMessage != null) { - "Editing message #${state.editingMessage.id}" + stringResource(id = R.string.chat_editing_message, state.editingMessage.id) } else { - "Reply to ${state.replyToMessage?.senderDisplayName ?: "#${state.replyToMessage?.id}"}" + "${stringResource(id = R.string.chat_reply_to)} ${ + state.replyToMessage?.senderDisplayName + ?.takeIf { it.isNotBlank() } + ?: state.replyToMessage?.senderId?.let { senderNameByUserId[it] } + ?: stringResource(id = R.string.common_unknown_user) + }" } Row( modifier = Modifier @@ -1275,7 +1356,7 @@ fun ChatScreen( verticalAlignment = Alignment.CenterVertically, ) { Text(text = header, style = MaterialTheme.typography.bodySmall) - Button(onClick = onCancelComposeAction) { Text("Cancel") } + Button(onClick = onCancelComposeAction) { Text(stringResource(id = R.string.common_cancel)) } } } @@ -1288,6 +1369,7 @@ fun ChatScreen( ) } else if (isChannelChat && !state.canSendMessages) { ChannelReadOnlyBar( + onToggleNotifications = onToggleChatNotifications, modifier = Modifier .fillMaxWidth() .navigationBarsPadding() @@ -1417,7 +1499,8 @@ fun ChatScreen( onInputChanged(it.text) }, modifier = Modifier.weight(1f), - placeholder = { Text("Message") }, + placeholder = { Text(stringResource(id = R.string.chat_message_placeholder)) }, + shape = RoundedCornerShape(14.dp), maxLines = 4, colors = TextFieldDefaults.colors( @@ -1486,13 +1569,13 @@ fun ChatScreen( if (showDeleteDialog) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, - title = { Text("Delete message") }, + title = { Text(stringResource(id = R.string.chat_delete_message_title)) }, text = { Text( if (pendingDeleteForAll) { - "Delete selected message for everyone?" + stringResource(id = R.string.chat_delete_message_for_everyone) } else { - "Delete selected message(s) for you?" + stringResource(id = R.string.chat_delete_message_for_me) } ) }, @@ -1503,7 +1586,7 @@ fun ChatScreen( showDeleteDialog = false pendingDeleteForAll = false }, - ) { Text("Delete") } + ) { Text(stringResource(id = R.string.common_delete)) } }, dismissButton = { TextButton( @@ -1511,7 +1594,51 @@ fun ChatScreen( showDeleteDialog = false pendingDeleteForAll = false }, - ) { Text("Cancel") } + ) { Text(stringResource(id = R.string.common_cancel)) } + }, + ) + } + if (showClearHistoryDialog) { + AlertDialog( + onDismissRequest = { showClearHistoryDialog = false }, + title = { Text(stringResource(id = R.string.chat_menu_clear_history)) }, + text = { Text(stringResource(id = R.string.chat_clear_history_confirm)) }, + confirmButton = { + TextButton( + onClick = { + onClearHistory() + showClearHistoryDialog = false + }, + ) { Text(stringResource(id = R.string.chat_clear)) } + }, + dismissButton = { + TextButton(onClick = { showClearHistoryDialog = false }) { Text(stringResource(id = R.string.common_cancel)) } + }, + ) + } + if (showDeleteChatDialog) { + AlertDialog( + onDismissRequest = { showDeleteChatDialog = false }, + title = { Text(if (isPrivateChat) stringResource(id = R.string.chat_delete_dialog) else stringResource(id = R.string.chat_leave_chat)) }, + text = { + Text( + if (isPrivateChat) { + stringResource(id = R.string.chat_delete_dialog_confirm) + } else { + stringResource(id = R.string.chat_leave_delete_confirm) + } + ) + }, + confirmButton = { + TextButton( + onClick = { + onDeleteChat() + showDeleteChatDialog = false + }, + ) { Text(if (isPrivateChat) stringResource(id = R.string.common_delete) else stringResource(id = R.string.chat_leave)) } + }, + dismissButton = { + TextButton(onClick = { showDeleteChatDialog = false }) { Text(stringResource(id = R.string.common_cancel)) } }, ) } @@ -1577,7 +1704,7 @@ fun ChatScreen( } }, enabled = currentIndex > 0, - ) { Text("Prev") } + ) { Text(stringResource(id = R.string.chat_search_prev)) } Button( onClick = { viewerImageIndex = if (allImageUrls.isEmpty()) null else { @@ -1585,7 +1712,7 @@ fun ChatScreen( } }, enabled = currentIndex < allImageUrls.lastIndex, - ) { Text("Next") } + ) { Text(stringResource(id = R.string.chat_search_next)) } } } } @@ -1826,7 +1953,7 @@ fun ChatScreen( onValueChange = { gifSearchQuery = it }, modifier = Modifier.fillMaxWidth(), singleLine = true, - placeholder = { Text("Search GIFs") }, + placeholder = { Text(stringResource(id = R.string.chat_search_gifs)) }, ) when { isGiphyLoading -> { @@ -1945,6 +2072,8 @@ private fun MessageBubble( isSelected: Boolean, isMultiSelecting: Boolean, isInlineHighlighted: Boolean, + senderNameByUserId: Map, + replyAuthorByMessageId: Map, reactions: List, onAttachmentImageClick: (String) -> Unit, onAttachmentVideoClick: (String) -> Unit, @@ -1981,6 +2110,20 @@ private fun MessageBubble( } else { MaterialTheme.colorScheme.onSurfaceVariant } + val senderName = message.senderDisplayName + ?.takeIf { it.isNotBlank() } + ?: senderNameByUserId[message.senderId] + val legacyTextUrl = message.text?.trim()?.takeIf { it.startsWith("http", ignoreCase = true) } + val hasLegacyStickerImage = message.attachments.isEmpty() && + message.type.equals("image", ignoreCase = true) && + !legacyTextUrl.isNullOrBlank() && + isStickerAsset(fileUrl = legacyTextUrl, fileType = "image/url") + val singleImageAttachment = message.attachments.singleOrNull()?.takeIf { + it.fileType.lowercase().startsWith("image/") + } + val hasSingleStickerAttachment = singleImageAttachment?.let { + isStickerAsset(fileUrl = it.fileUrl, fileType = it.fileType) + } == true || hasLegacyStickerImage Row( modifier = Modifier.fillMaxWidth(), @@ -1996,26 +2139,43 @@ private fun MessageBubble( } Column( modifier = Modifier - .fillMaxWidth(if (renderAsChannelPost) 0.94f else 0.8f) - .widthIn(min = if (renderAsChannelPost) 120.dp else 82.dp) - .background( - color = when { - isSelected -> MaterialTheme.colorScheme.tertiaryContainer - isInlineHighlighted -> MaterialTheme.colorScheme.secondaryContainer - else -> bubbleColor + .fillMaxWidth( + if (hasSingleStickerAttachment) { + if (renderAsChannelPost) 0.58f else 0.52f + } else if (renderAsChannelPost) { + 0.94f + } else { + 0.8f + }, + ) + .widthIn(min = if (hasSingleStickerAttachment) 96.dp else if (renderAsChannelPost) 120.dp else 82.dp) + .then( + if (hasSingleStickerAttachment) { + Modifier + } else { + Modifier.background( + color = when { + isSelected -> MaterialTheme.colorScheme.tertiaryContainer + isInlineHighlighted -> MaterialTheme.colorScheme.secondaryContainer + else -> bubbleColor + }, + shape = bubbleShape, + ) }, - shape = bubbleShape, ) .combinedClickable( onClick = onClick, onLongClick = onLongPress, ) - .padding(horizontal = if (renderAsChannelPost) 11.dp else 10.dp, vertical = 7.dp), + .padding( + horizontal = if (hasSingleStickerAttachment) 0.dp else if (renderAsChannelPost) 11.dp else 10.dp, + vertical = if (hasSingleStickerAttachment) 2.dp else 7.dp, + ), verticalArrangement = Arrangement.spacedBy(3.dp), ) { - if ((!alignAsOutgoing || renderAsChannelPost) && !message.senderDisplayName.isNullOrBlank()) { + if ((!alignAsOutgoing || renderAsChannelPost) && !senderName.isNullOrBlank()) { Text( - text = message.senderDisplayName, + text = senderName, style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.primary, @@ -2031,7 +2191,10 @@ private fun MessageBubble( } if (message.replyToMessageId != null) { - val replyAuthor = message.replyPreviewSenderName?.takeIf { it.isNotBlank() } ?: "#${message.replyToMessageId}" + val replyAuthor = message.replyPreviewSenderName + ?.takeIf { it.isNotBlank() } + ?: replyAuthorByMessageId[message.replyToMessageId] + ?: "Unknown user" val replySnippet = message.replyPreviewText?.takeIf { it.isNotBlank() } ?: "[media]" Row( modifier = Modifier @@ -2092,15 +2255,25 @@ private fun MessageBubble( ) } - if (isLegacyImageUrlMessage && textUrl != null) { - val badgeLabel = mediaBadgeLabel(fileType = "image/url", url = textUrl) + if (isLegacyImageUrlMessage) { + val isLegacyStickerMessage = isStickerAsset(fileUrl = textUrl, fileType = "image/url") + val badgeLabel = if (isLegacyStickerMessage) null else mediaBadgeLabel(fileType = "image/url", url = textUrl) + val openable = !isLegacyStickerMessage && badgeLabel == null Box( modifier = Modifier - .fillMaxWidth() - .height(188.dp) + .then( + if (isLegacyStickerMessage) { + Modifier + .size(176.dp) + } else { + Modifier + .fillMaxWidth() + .height(188.dp) + }, + ) .clip(RoundedCornerShape(12.dp)) .let { base -> - if (isGifOrStickerLegacyImage) { + if (isGifOrStickerLegacyImage || !openable) { base } else { base.clickable { onAttachmentImageClick(textUrl) } @@ -2111,7 +2284,7 @@ private fun MessageBubble( model = textUrl, contentDescription = "Image", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, + contentScale = if (isLegacyStickerMessage) ContentScale.Fit else ContentScale.Crop, ) if (!badgeLabel.isNullOrBlank()) { MediaTypeBadge( @@ -2124,7 +2297,7 @@ private fun MessageBubble( } } - if (isLegacyVideoUrlMessage && textUrl != null) { + if (isLegacyVideoUrlMessage) { VideoAttachmentCard( url = textUrl, fileType = message.type, @@ -2157,12 +2330,21 @@ private fun MessageBubble( if (imageAttachments.isNotEmpty()) { if (imageAttachments.size == 1) { val single = imageAttachments.first() - val badgeLabel = mediaBadgeLabel(fileType = single.fileType, url = single.fileUrl) - val openable = badgeLabel == null + val isStickerImage = isStickerAsset(fileUrl = single.fileUrl, fileType = single.fileType) + val badgeLabel = if (isStickerImage) null else mediaBadgeLabel(fileType = single.fileType, url = single.fileUrl) + val openable = !isStickerImage && badgeLabel == null Box( modifier = Modifier - .fillMaxWidth() - .height(188.dp) + .then( + if (isStickerImage) { + Modifier + .size(176.dp) + } else { + Modifier + .fillMaxWidth() + .height(188.dp) + }, + ) .clip(RoundedCornerShape(12.dp)) .let { base -> if (openable) base.clickable { onAttachmentImageClick(single.fileUrl) } else base @@ -2172,7 +2354,7 @@ private fun MessageBubble( model = single.fileUrl, contentDescription = "Image", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, + contentScale = if (isStickerImage) ContentScale.Fit else ContentScale.Crop, ) if (!badgeLabel.isNullOrBlank()) { MediaTypeBadge( @@ -2279,12 +2461,16 @@ private fun MessageBubble( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - val status = when (message.status) { - "read" -> " ✓✓" - "delivered" -> " ✓✓" - "sent" -> " ✓" - "pending" -> " …" - else -> "" + val status = if (isOutgoing && !renderAsChannelPost) { + when (message.status) { + "read" -> " ✓✓" + "delivered" -> " ✓✓" + "sent" -> " ✓" + "pending" -> " …" + else -> "" + } + } else { + "" } Text( text = if (renderAsChannelPost) { @@ -2362,7 +2548,7 @@ private fun CircleVideoAttachmentPlayer(url: String) { } }, ) - Text("Circle video", style = MaterialTheme.typography.labelSmall) + Text(stringResource(id = R.string.chat_circle_video), style = MaterialTheme.typography.labelSmall) } } @@ -2488,43 +2674,28 @@ private fun DaySeparatorChip(label: String) { @Composable private fun ChannelReadOnlyBar( + onToggleNotifications: () -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), verticalAlignment = Alignment.CenterVertically, ) { - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f), - modifier = Modifier.size(46.dp), - ) { - IconButton(onClick = { }) { - Icon(imageVector = Icons.Filled.Search, contentDescription = "Search in channel") - } - } Surface( shape = RoundedCornerShape(999.dp), color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f), - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .clickable { onToggleNotifications() }, ) { Text( - text = "Включить звук", + text = stringResource(id = R.string.chat_enable_sound), modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), style = MaterialTheme.typography.bodyMedium, textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) } - Surface( - shape = CircleShape, - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.94f), - modifier = Modifier.size(46.dp), - ) { - IconButton(onClick = { }) { - Icon(imageVector = Icons.Filled.Notifications, contentDescription = "Channel notifications") - } - } } } @@ -2553,7 +2724,7 @@ private fun MultiSelectActionBar( enabled = canReply, modifier = Modifier.fillMaxWidth(), ) { - Text("Reply") + Text(stringResource(id = R.string.chat_action_reply)) } } Surface( @@ -2566,7 +2737,7 @@ private fun MultiSelectActionBar( enabled = selectedCount > 0, modifier = Modifier.fillMaxWidth(), ) { - Text("Forward") + Text(stringResource(id = R.string.chat_action_forward)) } } } @@ -2578,7 +2749,7 @@ private fun parseMessageLocalDate(createdAt: String): LocalDate? { }.getOrNull() } -private val daySeparatorFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale("ru")) +private val daySeparatorFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("d MMMM", Locale.getDefault()) private fun formatDateSeparatorLabel(date: LocalDate): String { val today = LocalDate.now() @@ -2630,8 +2801,8 @@ private fun VoiceRecordingStatusRow( verticalAlignment = Alignment.CenterVertically, ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = onCancel) { Text("Cancel") } - Button(onClick = onSend) { Text("Send") } + Button(onClick = onCancel) { Text(stringResource(id = R.string.common_cancel)) } + Button(onClick = onSend) { Text(stringResource(id = R.string.common_send)) } } } } @@ -3521,7 +3692,16 @@ private fun isGifLikeUrl(url: String): Boolean { private fun isStickerLikeUrl(url: String): Boolean { if (url.isBlank()) return false val normalized = url.lowercase(Locale.getDefault()) - return normalized.contains("twemoji") || normalized.endsWith(".webp") + return normalized.contains("twemoji") || + normalized.endsWith(".webp") || + normalized.contains("sticker_") || + normalized.contains("/stickers/") +} + +private fun isStickerAsset(fileUrl: String, fileType: String): Boolean { + val normalizedType = fileType.lowercase(Locale.getDefault()) + if (normalizedType.contains("webp")) return true + return isStickerLikeUrl(fileUrl) } private fun mediaBadgeLabel(fileType: String, url: String): String? { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index f6e3081..23049dd 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -30,9 +30,11 @@ import androidx.compose.material3.AssistChip import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -40,6 +42,7 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable @@ -56,6 +59,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -68,17 +72,21 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.DoneAll +import androidx.compose.material.icons.filled.Groups import androidx.compose.material.icons.filled.Inventory2 import androidx.compose.material.icons.filled.FolderOpen import androidx.compose.material.icons.filled.LightMode +import androidx.compose.material.icons.filled.BookmarkBorder import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.NotificationsOff import androidx.compose.material.icons.filled.PushPin import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.DragHandle import androidx.compose.material.icons.filled.Clear +import androidx.appcompat.app.AppCompatDelegate import kotlinx.coroutines.flow.collectLatest import coil.compose.AsyncImage +import ru.daemonlord.messenger.R import ru.daemonlord.messenger.domain.chat.model.ChatItem import java.time.Instant import java.time.LocalDate @@ -134,6 +142,7 @@ fun ChatListRoute( onUpdateChatProfile = viewModel::updateChatProfile, onClearChat = viewModel::clearChat, onDeleteChat = viewModel::deleteChatForMe, + onDeleteChatForAll = viewModel::deleteChatForAll, onToggleChatMute = viewModel::toggleChatMute, onSelectManageChat = viewModel::onManagementChatSelected, onCreateInvite = viewModel::createInvite, @@ -173,6 +182,7 @@ fun ChatListScreen( onUpdateChatProfile: (Long, String?, String?) -> Unit, onClearChat: (Long) -> Unit, onDeleteChat: (Long) -> Unit, + onDeleteChatForAll: (Long) -> Unit, onToggleChatMute: (Long) -> Unit, onSelectManageChat: (Long?) -> Unit, onCreateInvite: (Long) -> Unit, @@ -200,6 +210,13 @@ fun ChatListScreen( var selectedManageChatIdText by remember { mutableStateOf("") } var manageUserIdText by remember { mutableStateOf("") } var manageRoleText by remember { mutableStateOf("member") } + var showCreateGroupDialog by remember { mutableStateOf(false) } + var showCreateChannelDialog by remember { mutableStateOf(false) } + var quickCreateGroupTitle by remember { mutableStateOf("") } + var quickCreateChannelTitle by remember { mutableStateOf("") } + var quickCreateChannelHandle by remember { mutableStateOf("") } + var showDeleteChatsDialog by remember { mutableStateOf(false) } + var deleteSelectedForAll by remember { mutableStateOf(false) } val isTabletLayout = LocalConfiguration.current.screenWidthDp >= 840 val listState = rememberLazyListState() val selectedChats = remember(state.chats, selectedChatIds) { @@ -282,9 +299,9 @@ fun ChatListScreen( when { selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString() isSearchMode -> "" - state.isConnecting -> "Connecting..." - state.selectedTab == ChatTab.ARCHIVED -> "Archived" - else -> "Chats" + state.isConnecting -> stringResource(id = R.string.chats_connecting) + state.selectedTab == ChatTab.ARCHIVED -> stringResource(id = R.string.chats_archived) + else -> stringResource(id = R.string.nav_chats) } ) }, @@ -311,8 +328,7 @@ fun ChatListScreen( ) } IconButton(onClick = { - selectedChatIds.forEach { chatId -> onDeleteChat(chatId) } - selectedChatIds = emptySet() + showDeleteChatsDialog = true }) { Icon( imageVector = Icons.Filled.Delete, @@ -394,45 +410,45 @@ fun ChatListScreen( onDismissRequest = { showDefaultMenu = false }, ) { DropdownMenuItem( - text = { Text("Day mode") }, + text = { + val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES + Text( + if (isNight) { + stringResource(id = R.string.menu_day_mode) + } else { + stringResource(id = R.string.menu_night_mode) + } + ) + }, leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) }, onClick = { showDefaultMenu = false - Toast.makeText(context, "Theme switch in Settings.", Toast.LENGTH_SHORT).show() + val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES + if (isNight) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + Toast.makeText(context, context.getString(R.string.toast_day_mode_enabled), Toast.LENGTH_SHORT).show() + } else { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + Toast.makeText(context, context.getString(R.string.toast_night_mode_enabled), Toast.LENGTH_SHORT).show() + } }, ) DropdownMenuItem( - text = { Text("Create group") }, - leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) }, + text = { Text(stringResource(id = R.string.menu_create_group)) }, + leadingIcon = { Icon(Icons.Filled.Groups, contentDescription = null) }, onClick = { showDefaultMenu = false - managementExpanded = true + showCreateGroupDialog = true }, ) DropdownMenuItem( - text = { Text("Create channel") }, - leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) }, - onClick = { - showDefaultMenu = false - managementExpanded = true - }, - ) - DropdownMenuItem( - text = { Text("Saved") }, - leadingIcon = { Icon(Icons.Filled.Inventory2, contentDescription = null) }, + text = { Text(stringResource(id = R.string.menu_saved)) }, + leadingIcon = { Icon(Icons.Filled.BookmarkBorder, contentDescription = null) }, onClick = { showDefaultMenu = false onOpenSaved() }, ) - DropdownMenuItem( - text = { Text("Proxy") }, - leadingIcon = { Icon(Icons.Filled.NotificationsOff, contentDescription = null) }, - onClick = { - showDefaultMenu = false - Toast.makeText(context, "Proxy settings will be added next.", Toast.LENGTH_SHORT).show() - }, - ) } } } @@ -447,22 +463,22 @@ fun ChatListScreen( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { FilterChip( - label = "All", + label = stringResource(id = R.string.filter_all), selected = state.selectedFilter == ChatListFilter.ALL, onClick = { onFilterSelected(ChatListFilter.ALL) }, ) FilterChip( - label = "People", + label = stringResource(id = R.string.filter_people), selected = state.selectedFilter == ChatListFilter.PEOPLE, onClick = { onFilterSelected(ChatListFilter.PEOPLE) }, ) FilterChip( - label = "Groups", + label = stringResource(id = R.string.filter_groups), selected = state.selectedFilter == ChatListFilter.GROUPS, onClick = { onFilterSelected(ChatListFilter.GROUPS) }, ) FilterChip( - label = "Channels", + label = stringResource(id = R.string.filter_channels), selected = state.selectedFilter == ChatListFilter.CHANNELS, onClick = { onFilterSelected(ChatListFilter.CHANNELS) }, ) @@ -475,7 +491,7 @@ fun ChatListScreen( ) { when { state.isLoading -> { - CenterState(text = "Loading chats...", loading = true) + CenterState(text = stringResource(id = R.string.chats_loading), loading = true) } !state.errorMessage.isNullOrBlank() -> { @@ -483,7 +499,7 @@ fun ChatListScreen( } state.chats.isEmpty() -> { - CenterState(text = "No chats found", loading = false) + CenterState(text = stringResource(id = R.string.chats_not_found), loading = false) } else -> { @@ -759,6 +775,120 @@ fun ChatListScreen( } } } + if (showCreateGroupDialog) { + AlertDialog( + onDismissRequest = { showCreateGroupDialog = false }, + title = { Text("Create group") }, + text = { + OutlinedTextField( + value = quickCreateGroupTitle, + onValueChange = { quickCreateGroupTitle = it }, + label = { Text("Group title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + }, + confirmButton = { + TextButton( + onClick = { + val title = quickCreateGroupTitle.trim() + if (title.isNotBlank()) { + onCreateGroup(title, emptyList()) + showCreateGroupDialog = false + quickCreateGroupTitle = "" + } + }, + ) { Text("Create") } + }, + dismissButton = { + TextButton(onClick = { showCreateGroupDialog = false }) { Text("Cancel") } + }, + ) + } + if (showCreateChannelDialog) { + AlertDialog( + onDismissRequest = { showCreateChannelDialog = false }, + title = { Text("Create channel") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = quickCreateChannelTitle, + onValueChange = { quickCreateChannelTitle = it }, + label = { Text("Channel title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = quickCreateChannelHandle, + onValueChange = { quickCreateChannelHandle = it }, + label = { Text("Handle") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + TextButton( + onClick = { + val title = quickCreateChannelTitle.trim() + val handle = quickCreateChannelHandle.trim() + if (title.isNotBlank() && handle.isNotBlank()) { + onCreateChannel(title, handle, null) + showCreateChannelDialog = false + quickCreateChannelTitle = "" + quickCreateChannelHandle = "" + } + }, + ) { Text("Create") } + }, + dismissButton = { + TextButton(onClick = { showCreateChannelDialog = false }) { Text("Cancel") } + }, + ) + } + if (showDeleteChatsDialog) { + AlertDialog( + onDismissRequest = { showDeleteChatsDialog = false }, + title = { Text("Delete selected chats") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Are you sure you want to delete selected chats?") + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { deleteSelectedForAll = !deleteSelectedForAll }, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = deleteSelectedForAll, + onCheckedChange = { deleteSelectedForAll = it }, + ) + Text("Delete for all (where allowed)") + } + } + }, + confirmButton = { + TextButton( + onClick = { + selectedChatIds.forEach { chatId -> + if (deleteSelectedForAll) onDeleteChatForAll(chatId) else onDeleteChat(chatId) + } + selectedChatIds = emptySet() + deleteSelectedForAll = false + showDeleteChatsDialog = false + }, + ) { Text("Delete") } + }, + dismissButton = { + TextButton( + onClick = { + showDeleteChatsDialog = false + deleteSelectedForAll = false + }, + ) { Text("Cancel") } + }, + ) + } } @Composable diff --git a/android/app/src/main/res/values-ru/strings.xml b/android/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..362d282 --- /dev/null +++ b/android/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,130 @@ + + + Benya Messenger + Чаты + Контакты + Настройки + Профиль + + Подключение... + Архив + Загрузка чатов... + Чаты не найдены + + Все + Люди + Группы + Каналы + + Дневной режим + Ночной режим + Создать группу + Избранное + Включен дневной режим. + Включен ночной режим. + + Отмена + Закрыть + Удалить + Отправить + Неизвестный пользователь + + Уведомления + Поиск + Изменить обои + Очистить историю + Смена обоев будет добавлена позже. + Удалить диалог + Выйти и удалить чат + Выйти из чата + Выйти + Поиск по чату + Назад + Далее + Поиск GIF + Совпадений: %1$d + Закрепленное сообщение + Сообщение + Ответить + Изменить + Переслать + Удалить + Удалить сообщение + Переслать сообщение #%1$d + Переслать %1$d сообщений + Нет доступных чатов + Пересылка... + Редактирование сообщения #%1$d + Ответ + Удалить выбранное сообщение для всех? + Удалить выбранные сообщения у вас? + Удалить все сообщения в этом чате? Это действие нельзя отменить. + Очистить + Удалить диалог у вас? История будет очищена. + Выйти из чата и убрать его из списка? + Включить звук + Видеокружок + + Пользователь + АККАУНТЫ + Нет активного аккаунта + Добавить аккаунт + Аккаунт + Настройки чатов + Конфиденциальность + Уведомления + Данные и память + Папки с чатами + Устройства + Энергосбережение + Язык + Номер, имя пользователя, «О себе» + Обои, ночной режим, анимации + Время захода, устройства, ключи доступа + Звуки, звонки, счетчик сообщений + Настройки загрузки медиафайлов + Сортировка чатов по папкам + Управление активными сессиями + Экономия энергии при низком заряде + Этот раздел будет расширен на следующем шаге. + Управление папками чатов будет добавлено на следующей итерации. + Настройки энергосбережения будут добавлены отдельным этапом. + Язык приложения + Использовать язык устройства + Английский + Системный + Русский + Английский + Аккаунты + Активный + Переключить + Открыть профиль + Выйти + Оформление + Светлая + Темная + Системная + Включить уведомления + Показывать превью сообщений + Обновить историю уведомлений + Последние уведомления + Пока нет серверных уведомлений. + Личные сообщения + Время захода + Аватар + Приглашения в группы + Сохранить приватность + Сессии и безопасность + Неизвестное устройство + Неизвестный IP + Отозвать + Отозвать все сессии + Код 2FA + Включить + Выключить + Код для регенерации recovery + Сгенерировать recovery-коды заново + все + контакты + никто + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 5153ab7..e88aa43 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,130 @@ Benya Messenger + Chats + Contacts + Settings + Profile + + Connecting... + Archived + Loading chats... + + All + People + Groups + Channels + + Day mode + Night mode + Create group + Saved + Day mode enabled. + Night mode enabled. + No chats found + + Cancel + Close + Delete + Send + Unknown user + + Notifications + Search + Change wallpaper + Clear history + Wallpaper change will be added later. + Delete dialog + Leave and delete chat + Leave chat + Leave + Search in chat + Prev + Next + Search GIFs + Matches: %1$d + Pinned message + Message + Reply + Edit + Forward + Delete + Delete message + Forward message #%1$d + Forward %1$d messages + No available chats + Forwarding... + Editing message #%1$d + Reply to + Delete selected message for everyone? + Delete selected message(s) for you? + Delete all messages in this chat? This action cannot be undone. + Clear + Delete dialog for you? History will be cleared. + Leave the chat and remove it from your list? + Enable sound + Circle video + + User + ACCOUNTS + No active account + Add account + Account + Chat settings + Privacy + Notifications + Data and storage + Chat folders + Devices + Power saving + Language + Phone number, username, bio + Wallpaper, night mode, animations + Last seen, devices, passkeys + Sounds, message counter + Media download settings + Sort chats by folders + Manage active sessions + Save power on low battery + This section will be expanded in the next step. + Chat folders management will be added in next iteration. + Power saving settings will be added in a separate step. + App language + Use device language + English + System + Russian + English + Accounts + Active + Switch + Open profile + Logout + Appearance + Light + Dark + System + Enable notifications + Show message preview + Refresh notification history + Recent notifications + No server notifications yet. + Private messages + Last seen + Avatar + Group invites + Save privacy + Sessions & Security + Unknown device + Unknown IP + Revoke + Revoke all sessions + 2FA code + Enable + Disable + Code for recovery regeneration + Regenerate recovery codes + everyone + contacts + nobody