android: remove wallet menu and continue chat/settings localization
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

This commit is contained in:
2026-03-11 04:49:48 +03:00
parent a4fd60919e
commit cd7fb878b3
4 changed files with 703 additions and 137 deletions

View File

@@ -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<Int?>(null) }
var viewerVideoUrl by remember { mutableStateOf<String?>(null) }
@@ -365,6 +399,8 @@ fun ChatScreen(
var giphyErrorMessage by remember { mutableStateOf<String?>(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<Long, String>,
replyAuthorByMessageId: Map<Long, String>,
reactions: List<ru.daemonlord.messenger.domain.message.model.MessageReaction>,
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? {

View File

@@ -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

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Benya Messenger</string>
<string name="nav_chats">Чаты</string>
<string name="nav_contacts">Контакты</string>
<string name="nav_settings">Настройки</string>
<string name="nav_profile">Профиль</string>
<string name="chats_connecting">Подключение...</string>
<string name="chats_archived">Архив</string>
<string name="chats_loading">Загрузка чатов...</string>
<string name="chats_not_found">Чаты не найдены</string>
<string name="filter_all">Все</string>
<string name="filter_people">Люди</string>
<string name="filter_groups">Группы</string>
<string name="filter_channels">Каналы</string>
<string name="menu_day_mode">Дневной режим</string>
<string name="menu_night_mode">Ночной режим</string>
<string name="menu_create_group">Создать группу</string>
<string name="menu_saved">Избранное</string>
<string name="toast_day_mode_enabled">Включен дневной режим.</string>
<string name="toast_night_mode_enabled">Включен ночной режим.</string>
<string name="common_cancel">Отмена</string>
<string name="common_close">Закрыть</string>
<string name="common_delete">Удалить</string>
<string name="common_send">Отправить</string>
<string name="common_unknown_user">Неизвестный пользователь</string>
<string name="chat_menu_notifications">Уведомления</string>
<string name="chat_menu_search">Поиск</string>
<string name="chat_menu_change_wallpaper">Изменить обои</string>
<string name="chat_menu_clear_history">Очистить историю</string>
<string name="chat_wallpaper_coming_soon">Смена обоев будет добавлена позже.</string>
<string name="chat_delete_dialog">Удалить диалог</string>
<string name="chat_leave_delete">Выйти и удалить чат</string>
<string name="chat_leave_chat">Выйти из чата</string>
<string name="chat_leave">Выйти</string>
<string name="chat_search_in_chat">Поиск по чату</string>
<string name="chat_search_prev">Назад</string>
<string name="chat_search_next">Далее</string>
<string name="chat_search_gifs">Поиск GIF</string>
<string name="chat_search_matches">Совпадений: %1$d</string>
<string name="chat_pinned_message">Закрепленное сообщение</string>
<string name="chat_message_placeholder">Сообщение</string>
<string name="chat_action_reply">Ответить</string>
<string name="chat_action_edit">Изменить</string>
<string name="chat_action_forward">Переслать</string>
<string name="chat_action_delete">Удалить</string>
<string name="chat_delete_message_title">Удалить сообщение</string>
<string name="chat_forward_one">Переслать сообщение #%1$d</string>
<string name="chat_forward_many">Переслать %1$d сообщений</string>
<string name="chat_no_available_chats">Нет доступных чатов</string>
<string name="chat_forwarding">Пересылка...</string>
<string name="chat_editing_message">Редактирование сообщения #%1$d</string>
<string name="chat_reply_to">Ответ</string>
<string name="chat_delete_message_for_everyone">Удалить выбранное сообщение для всех?</string>
<string name="chat_delete_message_for_me">Удалить выбранные сообщения у вас?</string>
<string name="chat_clear_history_confirm">Удалить все сообщения в этом чате? Это действие нельзя отменить.</string>
<string name="chat_clear">Очистить</string>
<string name="chat_delete_dialog_confirm">Удалить диалог у вас? История будет очищена.</string>
<string name="chat_leave_delete_confirm">Выйти из чата и убрать его из списка?</string>
<string name="chat_enable_sound">Включить звук</string>
<string name="chat_circle_video">Видеокружок</string>
<string name="settings_user_fallback">Пользователь</string>
<string name="settings_accounts_header">АККАУНТЫ</string>
<string name="settings_no_active_account">Нет активного аккаунта</string>
<string name="settings_add_account">Добавить аккаунт</string>
<string name="settings_folder_account">Аккаунт</string>
<string name="settings_folder_chat">Настройки чатов</string>
<string name="settings_folder_privacy">Конфиденциальность</string>
<string name="settings_folder_notifications">Уведомления</string>
<string name="settings_folder_data">Данные и память</string>
<string name="settings_folder_folders">Папки с чатами</string>
<string name="settings_folder_devices">Устройства</string>
<string name="settings_folder_power">Энергосбережение</string>
<string name="settings_folder_language">Язык</string>
<string name="settings_account_subtitle">Номер, имя пользователя, «О себе»</string>
<string name="settings_chat_subtitle">Обои, ночной режим, анимации</string>
<string name="settings_privacy_subtitle">Время захода, устройства, ключи доступа</string>
<string name="settings_notifications_subtitle">Звуки, звонки, счетчик сообщений</string>
<string name="settings_data_subtitle">Настройки загрузки медиафайлов</string>
<string name="settings_folders_subtitle">Сортировка чатов по папкам</string>
<string name="settings_devices_subtitle">Управление активными сессиями</string>
<string name="settings_power_subtitle">Экономия энергии при низком заряде</string>
<string name="settings_placeholder_data">Этот раздел будет расширен на следующем шаге.</string>
<string name="settings_placeholder_folders">Управление папками чатов будет добавлено на следующей итерации.</string>
<string name="settings_placeholder_power">Настройки энергосбережения будут добавлены отдельным этапом.</string>
<string name="settings_language_title">Язык приложения</string>
<string name="settings_language_system_subtitle">Использовать язык устройства</string>
<string name="settings_language_english_subtitle">Английский</string>
<string name="language_system">Системный</string>
<string name="language_russian">Русский</string>
<string name="language_english">Английский</string>
<string name="settings_accounts">Аккаунты</string>
<string name="settings_active">Активный</string>
<string name="settings_switch">Переключить</string>
<string name="settings_open_profile">Открыть профиль</string>
<string name="settings_logout">Выйти</string>
<string name="settings_appearance">Оформление</string>
<string name="theme_light">Светлая</string>
<string name="theme_dark">Темная</string>
<string name="theme_system">Системная</string>
<string name="settings_enable_notifications">Включить уведомления</string>
<string name="settings_show_preview">Показывать превью сообщений</string>
<string name="settings_refresh_notifications">Обновить историю уведомлений</string>
<string name="settings_recent_notifications">Последние уведомления</string>
<string name="settings_no_notifications_yet">Пока нет серверных уведомлений.</string>
<string name="privacy_private_messages">Личные сообщения</string>
<string name="privacy_last_seen">Время захода</string>
<string name="privacy_avatar">Аватар</string>
<string name="privacy_group_invites">Приглашения в группы</string>
<string name="privacy_save">Сохранить приватность</string>
<string name="settings_sessions_security">Сессии и безопасность</string>
<string name="settings_unknown_device">Неизвестное устройство</string>
<string name="settings_unknown_ip">Неизвестный IP</string>
<string name="settings_revoke">Отозвать</string>
<string name="settings_revoke_all">Отозвать все сессии</string>
<string name="settings_2fa_code">Код 2FA</string>
<string name="settings_enable">Включить</string>
<string name="settings_disable">Выключить</string>
<string name="settings_recovery_regen_code">Код для регенерации recovery</string>
<string name="settings_regenerate_recovery_codes">Сгенерировать recovery-коды заново</string>
<string name="privacy_everyone">все</string>
<string name="privacy_contacts">контакты</string>
<string name="privacy_nobody">никто</string>
</resources>

View File

@@ -1,4 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Benya Messenger</string>
<string name="nav_chats">Chats</string>
<string name="nav_contacts">Contacts</string>
<string name="nav_settings">Settings</string>
<string name="nav_profile">Profile</string>
<string name="chats_connecting">Connecting...</string>
<string name="chats_archived">Archived</string>
<string name="chats_loading">Loading chats...</string>
<string name="filter_all">All</string>
<string name="filter_people">People</string>
<string name="filter_groups">Groups</string>
<string name="filter_channels">Channels</string>
<string name="menu_day_mode">Day mode</string>
<string name="menu_night_mode">Night mode</string>
<string name="menu_create_group">Create group</string>
<string name="menu_saved">Saved</string>
<string name="toast_day_mode_enabled">Day mode enabled.</string>
<string name="toast_night_mode_enabled">Night mode enabled.</string>
<string name="chats_not_found">No chats found</string>
<string name="common_cancel">Cancel</string>
<string name="common_close">Close</string>
<string name="common_delete">Delete</string>
<string name="common_send">Send</string>
<string name="common_unknown_user">Unknown user</string>
<string name="chat_menu_notifications">Notifications</string>
<string name="chat_menu_search">Search</string>
<string name="chat_menu_change_wallpaper">Change wallpaper</string>
<string name="chat_menu_clear_history">Clear history</string>
<string name="chat_wallpaper_coming_soon">Wallpaper change will be added later.</string>
<string name="chat_delete_dialog">Delete dialog</string>
<string name="chat_leave_delete">Leave and delete chat</string>
<string name="chat_leave_chat">Leave chat</string>
<string name="chat_leave">Leave</string>
<string name="chat_search_in_chat">Search in chat</string>
<string name="chat_search_prev">Prev</string>
<string name="chat_search_next">Next</string>
<string name="chat_search_gifs">Search GIFs</string>
<string name="chat_search_matches">Matches: %1$d</string>
<string name="chat_pinned_message">Pinned message</string>
<string name="chat_message_placeholder">Message</string>
<string name="chat_action_reply">Reply</string>
<string name="chat_action_edit">Edit</string>
<string name="chat_action_forward">Forward</string>
<string name="chat_action_delete">Delete</string>
<string name="chat_delete_message_title">Delete message</string>
<string name="chat_forward_one">Forward message #%1$d</string>
<string name="chat_forward_many">Forward %1$d messages</string>
<string name="chat_no_available_chats">No available chats</string>
<string name="chat_forwarding">Forwarding...</string>
<string name="chat_editing_message">Editing message #%1$d</string>
<string name="chat_reply_to">Reply to</string>
<string name="chat_delete_message_for_everyone">Delete selected message for everyone?</string>
<string name="chat_delete_message_for_me">Delete selected message(s) for you?</string>
<string name="chat_clear_history_confirm">Delete all messages in this chat? This action cannot be undone.</string>
<string name="chat_clear">Clear</string>
<string name="chat_delete_dialog_confirm">Delete dialog for you? History will be cleared.</string>
<string name="chat_leave_delete_confirm">Leave the chat and remove it from your list?</string>
<string name="chat_enable_sound">Enable sound</string>
<string name="chat_circle_video">Circle video</string>
<string name="settings_user_fallback">User</string>
<string name="settings_accounts_header">ACCOUNTS</string>
<string name="settings_no_active_account">No active account</string>
<string name="settings_add_account">Add account</string>
<string name="settings_folder_account">Account</string>
<string name="settings_folder_chat">Chat settings</string>
<string name="settings_folder_privacy">Privacy</string>
<string name="settings_folder_notifications">Notifications</string>
<string name="settings_folder_data">Data and storage</string>
<string name="settings_folder_folders">Chat folders</string>
<string name="settings_folder_devices">Devices</string>
<string name="settings_folder_power">Power saving</string>
<string name="settings_folder_language">Language</string>
<string name="settings_account_subtitle">Phone number, username, bio</string>
<string name="settings_chat_subtitle">Wallpaper, night mode, animations</string>
<string name="settings_privacy_subtitle">Last seen, devices, passkeys</string>
<string name="settings_notifications_subtitle">Sounds, message counter</string>
<string name="settings_data_subtitle">Media download settings</string>
<string name="settings_folders_subtitle">Sort chats by folders</string>
<string name="settings_devices_subtitle">Manage active sessions</string>
<string name="settings_power_subtitle">Save power on low battery</string>
<string name="settings_placeholder_data">This section will be expanded in the next step.</string>
<string name="settings_placeholder_folders">Chat folders management will be added in next iteration.</string>
<string name="settings_placeholder_power">Power saving settings will be added in a separate step.</string>
<string name="settings_language_title">App language</string>
<string name="settings_language_system_subtitle">Use device language</string>
<string name="settings_language_english_subtitle">English</string>
<string name="language_system">System</string>
<string name="language_russian">Russian</string>
<string name="language_english">English</string>
<string name="settings_accounts">Accounts</string>
<string name="settings_active">Active</string>
<string name="settings_switch">Switch</string>
<string name="settings_open_profile">Open profile</string>
<string name="settings_logout">Logout</string>
<string name="settings_appearance">Appearance</string>
<string name="theme_light">Light</string>
<string name="theme_dark">Dark</string>
<string name="theme_system">System</string>
<string name="settings_enable_notifications">Enable notifications</string>
<string name="settings_show_preview">Show message preview</string>
<string name="settings_refresh_notifications">Refresh notification history</string>
<string name="settings_recent_notifications">Recent notifications</string>
<string name="settings_no_notifications_yet">No server notifications yet.</string>
<string name="privacy_private_messages">Private messages</string>
<string name="privacy_last_seen">Last seen</string>
<string name="privacy_avatar">Avatar</string>
<string name="privacy_group_invites">Group invites</string>
<string name="privacy_save">Save privacy</string>
<string name="settings_sessions_security">Sessions &amp; Security</string>
<string name="settings_unknown_device">Unknown device</string>
<string name="settings_unknown_ip">Unknown IP</string>
<string name="settings_revoke">Revoke</string>
<string name="settings_revoke_all">Revoke all sessions</string>
<string name="settings_2fa_code">2FA code</string>
<string name="settings_enable">Enable</string>
<string name="settings_disable">Disable</string>
<string name="settings_recovery_regen_code">Code for recovery regeneration</string>
<string name="settings_regenerate_recovery_codes">Regenerate recovery codes</string>
<string name="privacy_everyone">everyone</string>
<string name="privacy_contacts">contacts</string>
<string name="privacy_nobody">nobody</string>
</resources>