android: add chats menu select and search interaction states
This commit is contained in:
@@ -546,3 +546,13 @@
|
|||||||
- today: `HH:mm`,
|
- today: `HH:mm`,
|
||||||
- this week: localized short weekday,
|
- this week: localized short weekday,
|
||||||
- older: `dd.MM.yy`.
|
- older: `dd.MM.yy`.
|
||||||
|
|
||||||
|
### Step 88 - Chats list interaction states (menu/select/search)
|
||||||
|
- Added default overflow menu (`⋮`) state in chats header with Telegram-like quick actions UI.
|
||||||
|
- Added long-press multi-select mode for chat rows with:
|
||||||
|
- top selection bar (`count`, action icons),
|
||||||
|
- dedicated overflow menu for selected chats.
|
||||||
|
- Added dedicated search-mode state in chats screen:
|
||||||
|
- search field + section chips (`Chats/Channels/Apps/Posts`),
|
||||||
|
- horizontal recent avatars strip,
|
||||||
|
- list filtered by active query.
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package ru.daemonlord.messenger.ui.chats
|
package ru.daemonlord.messenger.ui.chats
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.combinedClickable
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -24,6 +26,8 @@ import androidx.compose.material3.AssistChip
|
|||||||
import androidx.compose.material3.AssistChipDefaults
|
import androidx.compose.material3.AssistChipDefaults
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.DropdownMenu
|
||||||
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
@@ -54,9 +58,16 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Delete
|
||||||
|
import androidx.compose.material.icons.filled.DoneAll
|
||||||
import androidx.compose.material.icons.filled.Inventory2
|
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.MoreVert
|
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.Search
|
||||||
|
import androidx.compose.material.icons.filled.DragHandle
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
@@ -139,7 +150,12 @@ fun ChatListScreen(
|
|||||||
onUnbanMember: (Long, Long) -> Unit,
|
onUnbanMember: (Long, Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
var managementExpanded by remember { mutableStateOf(false) }
|
var managementExpanded by remember { mutableStateOf(false) }
|
||||||
var searchExpanded by remember { mutableStateOf(false) }
|
var showDefaultMenu by remember { mutableStateOf(false) }
|
||||||
|
var showSelectionMenu by remember { mutableStateOf(false) }
|
||||||
|
var isSearchMode by remember { mutableStateOf(false) }
|
||||||
|
var searchSection by remember { mutableStateOf(SearchSection.Chats) }
|
||||||
|
var localSearchQuery by remember { mutableStateOf("") }
|
||||||
|
var selectedChatIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
|
||||||
var createTitle by remember { mutableStateOf("") }
|
var createTitle by remember { mutableStateOf("") }
|
||||||
var createMemberIds by remember { mutableStateOf("") }
|
var createMemberIds by remember { mutableStateOf("") }
|
||||||
var createHandle by remember { mutableStateOf("") }
|
var createHandle by remember { mutableStateOf("") }
|
||||||
@@ -185,7 +201,21 @@ fun ChatListScreen(
|
|||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (state.selectedTab == ChatTab.ARCHIVED) {
|
if (selectedChatIds.isNotEmpty()) {
|
||||||
|
IconButton(onClick = { selectedChatIds = emptySet() }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = "Cancel selection",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (isSearchMode) {
|
||||||
|
IconButton(onClick = { isSearchMode = false }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
|
contentDescription = "Back",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (state.selectedTab == ChatTab.ARCHIVED) {
|
||||||
IconButton(onClick = { onTabSelected(ChatTab.ALL) }) {
|
IconButton(onClick = { onTabSelected(ChatTab.ALL) }) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||||
@@ -197,6 +227,8 @@ fun ChatListScreen(
|
|||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
when {
|
when {
|
||||||
|
selectedChatIds.isNotEmpty() -> selectedChatIds.size.toString()
|
||||||
|
isSearchMode -> "Search"
|
||||||
state.isConnecting -> "Connecting..."
|
state.isConnecting -> "Connecting..."
|
||||||
state.selectedTab == ChatTab.ARCHIVED -> "Archived"
|
state.selectedTab == ChatTab.ARCHIVED -> "Archived"
|
||||||
else -> "Chats"
|
else -> "Chats"
|
||||||
@@ -204,24 +236,127 @@ fun ChatListScreen(
|
|||||||
)
|
)
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
IconButton(onClick = { searchExpanded = !searchExpanded }) {
|
if (selectedChatIds.isNotEmpty()) {
|
||||||
Icon(
|
IconButton(onClick = {}) {
|
||||||
imageVector = Icons.Filled.Search,
|
Icon(
|
||||||
contentDescription = if (searchExpanded) "Hide search" else "Show search",
|
imageVector = Icons.Filled.NotificationsOff,
|
||||||
)
|
contentDescription = "Mute selected",
|
||||||
}
|
)
|
||||||
IconButton(onClick = { managementExpanded = !managementExpanded }) {
|
}
|
||||||
Icon(
|
IconButton(onClick = {}) {
|
||||||
imageVector = if (managementExpanded) Icons.Filled.Close else Icons.Filled.MoreVert,
|
Icon(
|
||||||
contentDescription = if (managementExpanded) "Close menu" else "Open menu",
|
imageVector = Icons.Filled.FolderOpen,
|
||||||
)
|
contentDescription = "Archive selected",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = {}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Delete,
|
||||||
|
contentDescription = "Delete selected",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showSelectionMenu = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.MoreVert,
|
||||||
|
contentDescription = "Selection menu",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showSelectionMenu,
|
||||||
|
onDismissRequest = { showSelectionMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Unpin") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.PushPin, contentDescription = null) },
|
||||||
|
onClick = { showSelectionMenu = false },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Add to folder") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) },
|
||||||
|
onClick = { showSelectionMenu = false },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Mark unread") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.DoneAll, contentDescription = null) },
|
||||||
|
onClick = { showSelectionMenu = false },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Clear cache") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Delete, contentDescription = null) },
|
||||||
|
onClick = { showSelectionMenu = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isSearchMode) {
|
||||||
|
IconButton(onClick = { localSearchQuery = "" }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Close,
|
||||||
|
contentDescription = "Clear search",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
isSearchMode = true
|
||||||
|
localSearchQuery = state.searchQuery
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Search,
|
||||||
|
contentDescription = "Show search",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { managementExpanded = !managementExpanded }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (managementExpanded) Icons.Filled.Close else Icons.Filled.MoreVert,
|
||||||
|
contentDescription = if (managementExpanded) "Close menu" else "Open menu",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Box {
|
||||||
|
IconButton(onClick = { showDefaultMenu = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.MoreVert,
|
||||||
|
contentDescription = "Open menu",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showDefaultMenu,
|
||||||
|
onDismissRequest = { showDefaultMenu = false },
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Day mode") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) },
|
||||||
|
onClick = { showDefaultMenu = false },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Create group") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.FolderOpen, contentDescription = null) },
|
||||||
|
onClick = {
|
||||||
|
showDefaultMenu = false
|
||||||
|
managementExpanded = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Saved") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.Inventory2, contentDescription = null) },
|
||||||
|
onClick = { showDefaultMenu = false },
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text("Proxy") },
|
||||||
|
leadingIcon = { Icon(Icons.Filled.NotificationsOff, contentDescription = null) },
|
||||||
|
onClick = { showDefaultMenu = false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (searchExpanded) {
|
if (isSearchMode) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = state.searchQuery,
|
value = localSearchQuery,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
|
localSearchQuery = it
|
||||||
onSearchChanged(it)
|
onSearchChanged(it)
|
||||||
onGlobalSearchChanged(it)
|
onGlobalSearchChanged(it)
|
||||||
},
|
},
|
||||||
@@ -237,6 +372,63 @@ fun ChatListScreen(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
SearchSection.entries.forEach { section ->
|
||||||
|
FilterChip(
|
||||||
|
label = section.label,
|
||||||
|
selected = searchSection == section,
|
||||||
|
onClick = { searchSection = section },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (searchSection == SearchSection.Chats) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
state.chats.take(8).forEach { chat ->
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier.widthIn(max = 72.dp),
|
||||||
|
) {
|
||||||
|
if (!chat.avatarUrl.isNullOrBlank()) {
|
||||||
|
AsyncImage(
|
||||||
|
model = chat.avatarUrl,
|
||||||
|
contentDescription = "Recent ${chat.displayTitle}",
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(CircleShape),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(56.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(chat.displayTitle.firstOrNull()?.uppercase() ?: "?")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = chat.displayTitle,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (state.globalSearchQuery.trim().length >= 2) {
|
if (state.globalSearchQuery.trim().length >= 2) {
|
||||||
Surface(
|
Surface(
|
||||||
@@ -273,33 +465,35 @@ fun ChatListScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(
|
if (!isSearchMode) {
|
||||||
modifier = Modifier
|
Row(
|
||||||
.fillMaxWidth()
|
modifier = Modifier
|
||||||
.horizontalScroll(rememberScrollState())
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 2.dp),
|
.horizontalScroll(rememberScrollState())
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
.padding(horizontal = 16.dp, vertical = 2.dp),
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
FilterChip(
|
) {
|
||||||
label = "All",
|
FilterChip(
|
||||||
selected = state.selectedFilter == ChatListFilter.ALL,
|
label = "All",
|
||||||
onClick = { onFilterSelected(ChatListFilter.ALL) },
|
selected = state.selectedFilter == ChatListFilter.ALL,
|
||||||
)
|
onClick = { onFilterSelected(ChatListFilter.ALL) },
|
||||||
FilterChip(
|
)
|
||||||
label = "People",
|
FilterChip(
|
||||||
selected = state.selectedFilter == ChatListFilter.PEOPLE,
|
label = "People",
|
||||||
onClick = { onFilterSelected(ChatListFilter.PEOPLE) },
|
selected = state.selectedFilter == ChatListFilter.PEOPLE,
|
||||||
)
|
onClick = { onFilterSelected(ChatListFilter.PEOPLE) },
|
||||||
FilterChip(
|
)
|
||||||
label = "Groups",
|
FilterChip(
|
||||||
selected = state.selectedFilter == ChatListFilter.GROUPS,
|
label = "Groups",
|
||||||
onClick = { onFilterSelected(ChatListFilter.GROUPS) },
|
selected = state.selectedFilter == ChatListFilter.GROUPS,
|
||||||
)
|
onClick = { onFilterSelected(ChatListFilter.GROUPS) },
|
||||||
FilterChip(
|
)
|
||||||
label = "Channels",
|
FilterChip(
|
||||||
selected = state.selectedFilter == ChatListFilter.CHANNELS,
|
label = "Channels",
|
||||||
onClick = { onFilterSelected(ChatListFilter.CHANNELS) },
|
selected = state.selectedFilter == ChatListFilter.CHANNELS,
|
||||||
)
|
onClick = { onFilterSelected(ChatListFilter.CHANNELS) },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
@@ -341,7 +535,26 @@ fun ChatListScreen(
|
|||||||
) { chat ->
|
) { chat ->
|
||||||
ChatRow(
|
ChatRow(
|
||||||
chat = chat,
|
chat = chat,
|
||||||
onClick = { onOpenChat(chat.id) },
|
isSelecting = selectedChatIds.isNotEmpty(),
|
||||||
|
isSelected = selectedChatIds.contains(chat.id),
|
||||||
|
onClick = {
|
||||||
|
if (selectedChatIds.isNotEmpty()) {
|
||||||
|
selectedChatIds = if (selectedChatIds.contains(chat.id)) {
|
||||||
|
selectedChatIds - chat.id
|
||||||
|
} else {
|
||||||
|
selectedChatIds + chat.id
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
onOpenChat(chat.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongClick = {
|
||||||
|
selectedChatIds = if (selectedChatIds.contains(chat.id)) {
|
||||||
|
selectedChatIds
|
||||||
|
} else {
|
||||||
|
selectedChatIds + chat.id
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -536,14 +749,21 @@ fun ChatListScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
private fun ChatRow(
|
private fun ChatRow(
|
||||||
chat: ChatItem,
|
chat: ChatItem,
|
||||||
|
isSelecting: Boolean,
|
||||||
|
isSelected: Boolean,
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
|
onLongClick: () -> Unit,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onClick)
|
.combinedClickable(
|
||||||
|
onClick = onClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
)
|
||||||
.padding(horizontal = 14.dp, vertical = 8.dp),
|
.padding(horizontal = 14.dp, vertical = 8.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
@@ -637,11 +857,24 @@ private fun ChatRow(
|
|||||||
if (chat.unreadCount > 0) {
|
if (chat.unreadCount > 0) {
|
||||||
BadgeChip(label = chat.unreadCount.toString())
|
BadgeChip(label = chat.unreadCount.toString())
|
||||||
}
|
}
|
||||||
|
if (isSelecting) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isSelected) Icons.Filled.DoneAll else Icons.Filled.DragHandle,
|
||||||
|
contentDescription = if (isSelected) "Selected" else "Selectable",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum class SearchSection(val label: String) {
|
||||||
|
Chats("Chats"),
|
||||||
|
Channels("Channels"),
|
||||||
|
Apps("Apps"),
|
||||||
|
Posts("Posts"),
|
||||||
|
}
|
||||||
|
|
||||||
private val chatTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
private val chatTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
private val chatDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yy")
|
private val chatDateFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yy")
|
||||||
private val chatWeekdayFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE", Locale.getDefault())
|
private val chatWeekdayFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE", Locale.getDefault())
|
||||||
|
|||||||
Reference in New Issue
Block a user