android: fix chat theme toggle and add member management in chat info
This commit is contained in:
@@ -48,6 +48,7 @@ fun MessageLocalModel.toDomain(currentUserId: Long?): MessageItem {
|
|||||||
chatId = message.chatId,
|
chatId = message.chatId,
|
||||||
senderId = message.senderId,
|
senderId = message.senderId,
|
||||||
senderDisplayName = message.senderDisplayName,
|
senderDisplayName = message.senderDisplayName,
|
||||||
|
senderUsername = message.senderUsername,
|
||||||
type = message.type,
|
type = message.type,
|
||||||
text = message.text,
|
text = message.text,
|
||||||
createdAt = message.createdAt,
|
createdAt = message.createdAt,
|
||||||
|
|||||||
@@ -686,6 +686,7 @@ class NetworkMessageRepository @Inject constructor(
|
|||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
senderId = senderId,
|
senderId = senderId,
|
||||||
senderDisplayName = senderDisplayName,
|
senderDisplayName = senderDisplayName,
|
||||||
|
senderUsername = senderUsername,
|
||||||
type = type,
|
type = type,
|
||||||
text = text,
|
text = text,
|
||||||
createdAt = createdAt,
|
createdAt = createdAt,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ data class MessageItem(
|
|||||||
val chatId: Long,
|
val chatId: Long,
|
||||||
val senderId: Long,
|
val senderId: Long,
|
||||||
val senderDisplayName: String?,
|
val senderDisplayName: String?,
|
||||||
|
val senderUsername: String? = null,
|
||||||
val type: String,
|
val type: String,
|
||||||
val text: String?,
|
val text: String?,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import androidx.compose.foundation.lazy.grid.items
|
|||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.AssistChip
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
@@ -161,6 +162,8 @@ import kotlinx.serialization.json.jsonObject
|
|||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
import ru.daemonlord.messenger.BuildConfig
|
import ru.daemonlord.messenger.BuildConfig
|
||||||
import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator
|
import ru.daemonlord.messenger.core.audio.AppAudioFocusCoordinator
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
|
import ru.daemonlord.messenger.ui.chat.voice.VoiceRecorder
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
@@ -290,6 +293,12 @@ fun ChatRoute(
|
|||||||
onToggleChatNotifications = viewModel::onToggleChatNotifications,
|
onToggleChatNotifications = viewModel::onToggleChatNotifications,
|
||||||
onClearHistory = viewModel::onClearHistory,
|
onClearHistory = viewModel::onClearHistory,
|
||||||
onDeleteChat = viewModel::onDeleteOrLeaveChat,
|
onDeleteChat = viewModel::onDeleteOrLeaveChat,
|
||||||
|
onPromoteMember = viewModel::promoteMember,
|
||||||
|
onDemoteMember = viewModel::demoteMember,
|
||||||
|
onBanMember = viewModel::banMember,
|
||||||
|
onKickMember = viewModel::kickMember,
|
||||||
|
onTransferOwnership = viewModel::transferOwnership,
|
||||||
|
onUnbanMember = viewModel::unbanMember,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,6 +336,12 @@ fun ChatScreen(
|
|||||||
onToggleChatNotifications: () -> Unit,
|
onToggleChatNotifications: () -> Unit,
|
||||||
onClearHistory: () -> Unit,
|
onClearHistory: () -> Unit,
|
||||||
onDeleteChat: () -> Unit,
|
onDeleteChat: () -> Unit,
|
||||||
|
onPromoteMember: (Long) -> Unit,
|
||||||
|
onDemoteMember: (Long) -> Unit,
|
||||||
|
onBanMember: (Long) -> Unit,
|
||||||
|
onKickMember: (Long) -> Unit,
|
||||||
|
onTransferOwnership: (Long) -> Unit,
|
||||||
|
onUnbanMember: (Long) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val view = LocalView.current
|
val view = LocalView.current
|
||||||
@@ -361,14 +376,28 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
val isChannelChat = state.chatType.equals("channel", ignoreCase = true)
|
val isChannelChat = state.chatType.equals("channel", ignoreCase = true)
|
||||||
val isPrivateChat = state.chatType.equals("private", ignoreCase = true)
|
val isPrivateChat = state.chatType.equals("private", ignoreCase = true)
|
||||||
|
val canShowMembersTab = state.chatType.equals("group", ignoreCase = true) ||
|
||||||
|
(state.chatType.equals("channel", ignoreCase = true) && state.canManageMembers)
|
||||||
val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) }
|
val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) }
|
||||||
val senderNameByUserId = remember(state.messages) {
|
val senderNameByUserId = remember(state.messages, state.chatMembers) {
|
||||||
state.messages
|
val fromMembers = state.chatMembers.associate { member ->
|
||||||
|
val resolved = member.name.ifBlank {
|
||||||
|
member.username?.takeIf { it.isNotBlank() }?.let { "@$it" } ?: "User #${member.userId}"
|
||||||
|
}
|
||||||
|
member.userId to resolved
|
||||||
|
}
|
||||||
|
val fromMessages = state.messages
|
||||||
.mapNotNull { message ->
|
.mapNotNull { message ->
|
||||||
val name = message.senderDisplayName?.trim().orEmpty()
|
val name = message.senderDisplayName?.trim().orEmpty()
|
||||||
if (name.isBlank()) null else message.senderId to name
|
val resolved = when {
|
||||||
|
name.isNotBlank() -> name
|
||||||
|
!message.senderUsername.isNullOrBlank() -> "@${message.senderUsername}"
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
if (resolved.isNullOrBlank()) null else message.senderId to resolved
|
||||||
}
|
}
|
||||||
.toMap()
|
.toMap()
|
||||||
|
fromMembers + fromMessages
|
||||||
}
|
}
|
||||||
val replyAuthorByMessageId = remember(state.messages, senderNameByUserId) {
|
val replyAuthorByMessageId = remember(state.messages, senderNameByUserId) {
|
||||||
state.messages.associate { message ->
|
state.messages.associate { message ->
|
||||||
@@ -429,6 +458,20 @@ fun ChatScreen(
|
|||||||
lastVisibleIndex < lastIndex - 1
|
lastVisibleIndex < lastIndex - 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LaunchedEffect(canShowMembersTab) {
|
||||||
|
if (!canShowMembersTab && chatInfoTab == ChatInfoTab.Members) {
|
||||||
|
chatInfoTab = ChatInfoTab.Media
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val availableInfoTabs = remember(canShowMembersTab) {
|
||||||
|
buildList {
|
||||||
|
add(ChatInfoTab.Media)
|
||||||
|
add(ChatInfoTab.Files)
|
||||||
|
add(ChatInfoTab.Links)
|
||||||
|
add(ChatInfoTab.Voice)
|
||||||
|
if (canShowMembersTab) add(ChatInfoTab.Members)
|
||||||
|
}
|
||||||
|
}
|
||||||
val isLightTheme = MaterialTheme.colorScheme.background.luminance() > 0.5f
|
val isLightTheme = MaterialTheme.colorScheme.background.luminance() > 0.5f
|
||||||
val chatTopBarColor = if (isLightTheme) {
|
val chatTopBarColor = if (isLightTheme) {
|
||||||
MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)
|
MaterialTheme.colorScheme.surface.copy(alpha = 0.98f)
|
||||||
@@ -1237,7 +1280,7 @@ fun ChatScreen(
|
|||||||
.horizontalScroll(rememberScrollState()),
|
.horizontalScroll(rememberScrollState()),
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
ChatInfoTab.entries.forEach { tab ->
|
availableInfoTabs.forEach { tab ->
|
||||||
val selected = chatInfoTab == tab
|
val selected = chatInfoTab == tab
|
||||||
Surface(
|
Surface(
|
||||||
shape = RoundedCornerShape(16.dp),
|
shape = RoundedCornerShape(16.dp),
|
||||||
@@ -1305,6 +1348,16 @@ fun ChatScreen(
|
|||||||
forceCycleSpeedAudioSourceId = null
|
forceCycleSpeedAudioSourceId = null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
members = state.chatMembers,
|
||||||
|
bans = state.chatBans,
|
||||||
|
canManageMembers = state.canManageMembers,
|
||||||
|
canTransferOwnership = state.chatRole.equals("owner", ignoreCase = true),
|
||||||
|
onPromoteMember = onPromoteMember,
|
||||||
|
onDemoteMember = onDemoteMember,
|
||||||
|
onBanMember = onBanMember,
|
||||||
|
onKickMember = onKickMember,
|
||||||
|
onTransferOwnership = onTransferOwnership,
|
||||||
|
onUnbanMember = onUnbanMember,
|
||||||
onEntryClick = { entry ->
|
onEntryClick = { entry ->
|
||||||
when (entry.type) {
|
when (entry.type) {
|
||||||
ChatInfoEntryType.Media -> {
|
ChatInfoEntryType.Media -> {
|
||||||
@@ -1343,6 +1396,7 @@ fun ChatScreen(
|
|||||||
"${stringResource(id = R.string.chat_reply_to)} ${
|
"${stringResource(id = R.string.chat_reply_to)} ${
|
||||||
state.replyToMessage?.senderDisplayName
|
state.replyToMessage?.senderDisplayName
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: state.replyToMessage?.senderUsername?.takeIf { it.isNotBlank() }?.let { "@$it" }
|
||||||
?: state.replyToMessage?.senderId?.let { senderNameByUserId[it] }
|
?: state.replyToMessage?.senderId?.let { senderNameByUserId[it] }
|
||||||
?: stringResource(id = R.string.common_unknown_user)
|
?: stringResource(id = R.string.common_unknown_user)
|
||||||
}"
|
}"
|
||||||
@@ -2112,6 +2166,7 @@ private fun MessageBubble(
|
|||||||
}
|
}
|
||||||
val senderName = message.senderDisplayName
|
val senderName = message.senderDisplayName
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: message.senderUsername?.takeIf { it.isNotBlank() }?.let { "@$it" }
|
||||||
?: senderNameByUserId[message.senderId]
|
?: senderNameByUserId[message.senderId]
|
||||||
val legacyTextUrl = message.text?.trim()?.takeIf { it.startsWith("http", ignoreCase = true) }
|
val legacyTextUrl = message.text?.trim()?.takeIf { it.startsWith("http", ignoreCase = true) }
|
||||||
val hasLegacyStickerImage = message.attachments.isEmpty() &&
|
val hasLegacyStickerImage = message.attachments.isEmpty() &&
|
||||||
@@ -2191,8 +2246,21 @@ private fun MessageBubble(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (message.replyToMessageId != null) {
|
if (message.replyToMessageId != null) {
|
||||||
val replyAuthor = message.replyPreviewSenderName
|
val replyAuthorFromPreview = message.replyPreviewSenderName
|
||||||
?.takeIf { it.isNotBlank() }
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?.let { preview ->
|
||||||
|
val legacyUserId = Regex("""User\s*#(\d+)""", RegexOption.IGNORE_CASE)
|
||||||
|
.find(preview)
|
||||||
|
?.groupValues
|
||||||
|
?.getOrNull(1)
|
||||||
|
?.toLongOrNull()
|
||||||
|
if (legacyUserId != null) {
|
||||||
|
senderNameByUserId[legacyUserId] ?: preview
|
||||||
|
} else {
|
||||||
|
preview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val replyAuthor = replyAuthorFromPreview
|
||||||
?: replyAuthorByMessageId[message.replyToMessageId]
|
?: replyAuthorByMessageId[message.replyToMessageId]
|
||||||
?: "Unknown user"
|
?: "Unknown user"
|
||||||
val replySnippet = message.replyPreviewText?.takeIf { it.isNotBlank() } ?: "[media]"
|
val replySnippet = message.replyPreviewText?.takeIf { it.isNotBlank() } ?: "[media]"
|
||||||
@@ -3364,6 +3432,7 @@ private enum class ChatInfoTab(val title: String) {
|
|||||||
Files("Files"),
|
Files("Files"),
|
||||||
Links("Links"),
|
Links("Links"),
|
||||||
Voice("Voice"),
|
Voice("Voice"),
|
||||||
|
Members("Members"),
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum class ChatInfoEntryType {
|
private enum class ChatInfoEntryType {
|
||||||
@@ -3396,8 +3465,34 @@ private fun ChatInfoTabContent(
|
|||||||
onForceStopAudioSourceHandled: (String) -> Unit,
|
onForceStopAudioSourceHandled: (String) -> Unit,
|
||||||
onForceToggleAudioSourceHandled: (String) -> Unit,
|
onForceToggleAudioSourceHandled: (String) -> Unit,
|
||||||
onForceCycleSpeedAudioSourceHandled: (String) -> Unit,
|
onForceCycleSpeedAudioSourceHandled: (String) -> Unit,
|
||||||
|
members: List<ChatMemberItem>,
|
||||||
|
bans: List<ChatBanItem>,
|
||||||
|
canManageMembers: Boolean,
|
||||||
|
canTransferOwnership: Boolean,
|
||||||
|
onPromoteMember: (Long) -> Unit,
|
||||||
|
onDemoteMember: (Long) -> Unit,
|
||||||
|
onBanMember: (Long) -> Unit,
|
||||||
|
onKickMember: (Long) -> Unit,
|
||||||
|
onTransferOwnership: (Long) -> Unit,
|
||||||
|
onUnbanMember: (Long) -> Unit,
|
||||||
onEntryClick: (ChatInfoEntry) -> Unit,
|
onEntryClick: (ChatInfoEntry) -> Unit,
|
||||||
) {
|
) {
|
||||||
|
if (tab == ChatInfoTab.Members) {
|
||||||
|
ChatMembersTabContent(
|
||||||
|
members = members,
|
||||||
|
bans = bans,
|
||||||
|
canManageMembers = canManageMembers,
|
||||||
|
canTransferOwnership = canTransferOwnership,
|
||||||
|
onPromoteMember = onPromoteMember,
|
||||||
|
onDemoteMember = onDemoteMember,
|
||||||
|
onBanMember = onBanMember,
|
||||||
|
onKickMember = onKickMember,
|
||||||
|
onTransferOwnership = onTransferOwnership,
|
||||||
|
onUnbanMember = onUnbanMember,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val filtered = remember(tab, entries) {
|
val filtered = remember(tab, entries) {
|
||||||
entries.filter {
|
entries.filter {
|
||||||
when (tab) {
|
when (tab) {
|
||||||
@@ -3405,6 +3500,7 @@ private fun ChatInfoTabContent(
|
|||||||
ChatInfoTab.Files -> it.type == ChatInfoEntryType.File
|
ChatInfoTab.Files -> it.type == ChatInfoEntryType.File
|
||||||
ChatInfoTab.Links -> it.type == ChatInfoEntryType.Link
|
ChatInfoTab.Links -> it.type == ChatInfoEntryType.Link
|
||||||
ChatInfoTab.Voice -> it.type == ChatInfoEntryType.Voice
|
ChatInfoTab.Voice -> it.type == ChatInfoEntryType.Voice
|
||||||
|
ChatInfoTab.Members -> false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3625,6 +3721,158 @@ private fun ChatInfoTabContent(
|
|||||||
|
|
||||||
private val urlRegex = Regex("""https?://[^\s]+""")
|
private val urlRegex = Regex("""https?://[^\s]+""")
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ChatMembersTabContent(
|
||||||
|
members: List<ChatMemberItem>,
|
||||||
|
bans: List<ChatBanItem>,
|
||||||
|
canManageMembers: Boolean,
|
||||||
|
canTransferOwnership: Boolean,
|
||||||
|
onPromoteMember: (Long) -> Unit,
|
||||||
|
onDemoteMember: (Long) -> Unit,
|
||||||
|
onBanMember: (Long) -> Unit,
|
||||||
|
onKickMember: (Long) -> Unit,
|
||||||
|
onTransferOwnership: (Long) -> Unit,
|
||||||
|
onUnbanMember: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
if (members.isEmpty() && bans.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 20.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "No members data",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(320.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (members.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Members (${members.size})",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(members, key = { it.userId }) { member ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.55f))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = member.name.ifBlank { member.username?.let { "@$it" } ?: "User #${member.userId}" },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "${member.role} • id ${member.userId}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canManageMembers && !member.role.equals("owner", ignoreCase = true)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.horizontalScroll(rememberScrollState()),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
) {
|
||||||
|
if (member.role.equals("member", ignoreCase = true)) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onPromoteMember(member.userId) },
|
||||||
|
label = { Text("Promote") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (member.role.equals("admin", ignoreCase = true)) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onDemoteMember(member.userId) },
|
||||||
|
label = { Text("Demote") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (canTransferOwnership) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onTransferOwnership(member.userId) },
|
||||||
|
label = { Text("Transfer owner") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onBanMember(member.userId) },
|
||||||
|
label = { Text("Ban") },
|
||||||
|
)
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onKickMember(member.userId) },
|
||||||
|
label = { Text("Kick") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bans.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Banned (${bans.size})",
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(bans, key = { it.userId }) { ban ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.55f))
|
||||||
|
.padding(horizontal = 10.dp, vertical = 9.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = ban.name.ifBlank { ban.username?.let { "@$it" } ?: "User #${ban.userId}" },
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "id ${ban.userId}",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (canManageMembers) {
|
||||||
|
AssistChip(
|
||||||
|
onClick = { onUnbanMember(ban.userId) },
|
||||||
|
label = { Text("Unban") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntry> {
|
private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntry> {
|
||||||
val entries = mutableListOf<ChatInfoEntry>()
|
val entries = mutableListOf<ChatInfoEntry>()
|
||||||
messages
|
messages
|
||||||
@@ -3674,7 +3922,7 @@ private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntr
|
|||||||
entries += ChatInfoEntry(
|
entries += ChatInfoEntry(
|
||||||
type = ChatInfoEntryType.Link,
|
type = ChatInfoEntryType.Link,
|
||||||
title = match.value,
|
title = match.value,
|
||||||
subtitle = "${message.senderDisplayName ?: "Unknown"} • $time",
|
subtitle = "${message.senderDisplayName?.takeIf { it.isNotBlank() } ?: message.senderUsername?.let { "@$it" } ?: "Unknown"} • $time",
|
||||||
resourceUrl = match.value,
|
resourceUrl = match.value,
|
||||||
sourceMessageId = message.id,
|
sourceMessageId = message.id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ class ChatViewModel @Inject constructor(
|
|||||||
private var lastDeliveredMessageId: Long? = null
|
private var lastDeliveredMessageId: Long? = null
|
||||||
private var lastReadMessageId: Long? = null
|
private var lastReadMessageId: Long? = null
|
||||||
private val reactionsRequestedMessageIds = mutableSetOf<Long>()
|
private val reactionsRequestedMessageIds = mutableSetOf<Long>()
|
||||||
|
private var membersLoadKey: String? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
activeChatTracker.setActiveChat(chatId)
|
activeChatTracker.setActiveChat(chatId)
|
||||||
@@ -504,6 +505,45 @@ class ChatViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun promoteMember(userId: Long) {
|
||||||
|
updateMemberRole(userId = userId, role = "admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun demoteMember(userId: Long) {
|
||||||
|
updateMemberRole(userId = userId, role = "member")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun transferOwnership(userId: Long) {
|
||||||
|
updateMemberRole(userId = userId, role = "owner")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun kickMember(userId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.removeMember(chatId = chatId, userId = userId)) {
|
||||||
|
is AppResult.Success -> refreshMembersAndBans()
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun banMember(userId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.banMember(chatId = chatId, userId = userId)) {
|
||||||
|
is AppResult.Success -> refreshMembersAndBans()
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unbanMember(userId: Long) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.unbanMember(chatId = chatId, userId = userId)) {
|
||||||
|
is AppResult.Success -> refreshMembersAndBans()
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onSendClick() {
|
fun onSendClick() {
|
||||||
val text = uiState.value.inputText.trim()
|
val text = uiState.value.inputText.trim()
|
||||||
if (text.isBlank()) return
|
if (text.isBlank()) return
|
||||||
@@ -712,16 +752,54 @@ class ChatViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
it.copy(
|
it.copy(
|
||||||
chatType = chat.type,
|
chatType = chat.type,
|
||||||
|
chatRole = role,
|
||||||
chatTitle = chatTitle,
|
chatTitle = chatTitle,
|
||||||
chatSubtitle = chatSubtitle,
|
chatSubtitle = chatSubtitle,
|
||||||
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,
|
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,
|
||||||
chatUnreadCount = chat.unreadCount.coerceAtLeast(0),
|
chatUnreadCount = chat.unreadCount.coerceAtLeast(0),
|
||||||
|
canManageMembers = role == "owner" || role == "admin",
|
||||||
canSendMessages = canSend,
|
canSendMessages = canSend,
|
||||||
sendRestrictionText = restriction,
|
sendRestrictionText = restriction,
|
||||||
pinnedMessageId = chat.pinnedMessageId,
|
pinnedMessageId = chat.pinnedMessageId,
|
||||||
pinnedMessage = pinnedMessage,
|
pinnedMessage = pinnedMessage,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val shouldLoadMembers = chat.type.equals("group", ignoreCase = true) ||
|
||||||
|
(chat.type.equals("channel", ignoreCase = true) && (role == "owner" || role == "admin"))
|
||||||
|
val nextLoadKey = "${chat.id}:${chat.type.lowercase()}:${role ?: "none"}"
|
||||||
|
if (shouldLoadMembers && membersLoadKey != nextLoadKey) {
|
||||||
|
membersLoadKey = nextLoadKey
|
||||||
|
refreshMembersAndBans()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateMemberRole(userId: Long, role: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {
|
||||||
|
is AppResult.Success -> refreshMembersAndBans()
|
||||||
|
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshMembersAndBans() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val membersResult = chatRepository.listMembers(chatId = chatId)
|
||||||
|
val bansResult = chatRepository.listBans(chatId = chatId)
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
chatMembers = (membersResult as? AppResult.Success)?.data ?: it.chatMembers,
|
||||||
|
chatBans = (bansResult as? AppResult.Success)?.data ?: it.chatBans,
|
||||||
|
errorMessage = listOf(membersResult, bansResult)
|
||||||
|
.filterIsInstance<AppResult.Error>()
|
||||||
|
.firstOrNull()
|
||||||
|
?.reason
|
||||||
|
?.toUiMessage()
|
||||||
|
?: it.errorMessage,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package ru.daemonlord.messenger.ui.chat
|
|||||||
|
|
||||||
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
import ru.daemonlord.messenger.domain.message.model.MessageItem
|
||||||
import ru.daemonlord.messenger.domain.message.model.MessageReaction
|
import ru.daemonlord.messenger.domain.message.model.MessageReaction
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatMemberItem
|
||||||
|
import ru.daemonlord.messenger.domain.chat.model.ChatBanItem
|
||||||
|
|
||||||
data class MessageUiState(
|
data class MessageUiState(
|
||||||
val chatId: Long = 0L,
|
val chatId: Long = 0L,
|
||||||
@@ -14,7 +16,11 @@ data class MessageUiState(
|
|||||||
val chatSubtitle: String = "",
|
val chatSubtitle: String = "",
|
||||||
val chatAvatarUrl: String? = null,
|
val chatAvatarUrl: String? = null,
|
||||||
val chatType: String = "",
|
val chatType: String = "",
|
||||||
|
val chatRole: String? = null,
|
||||||
val chatUnreadCount: Int = 0,
|
val chatUnreadCount: Int = 0,
|
||||||
|
val chatMembers: List<ChatMemberItem> = emptyList(),
|
||||||
|
val chatBans: List<ChatBanItem> = emptyList(),
|
||||||
|
val canManageMembers: Boolean = false,
|
||||||
val messages: List<MessageItem> = emptyList(),
|
val messages: List<MessageItem> = emptyList(),
|
||||||
val pinnedMessageId: Long? = null,
|
val pinnedMessageId: Long? = null,
|
||||||
val pinnedMessage: MessageItem? = null,
|
val pinnedMessage: MessageItem? = null,
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -83,10 +84,10 @@ 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 androidx.compose.material.icons.filled.DragHandle
|
||||||
import androidx.compose.material.icons.filled.Clear
|
import androidx.compose.material.icons.filled.Clear
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import kotlinx.coroutines.flow.collectLatest
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
import ru.daemonlord.messenger.R
|
import ru.daemonlord.messenger.R
|
||||||
|
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||||
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
import ru.daemonlord.messenger.domain.chat.model.ChatItem
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
@@ -151,6 +152,7 @@ fun ChatListRoute(
|
|||||||
onRemoveMember = viewModel::removeMember,
|
onRemoveMember = viewModel::removeMember,
|
||||||
onBanMember = viewModel::banMember,
|
onBanMember = viewModel::banMember,
|
||||||
onUnbanMember = viewModel::unbanMember,
|
onUnbanMember = viewModel::unbanMember,
|
||||||
|
onToggleDayNightMode = viewModel::toggleDayNightMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +193,7 @@ fun ChatListScreen(
|
|||||||
onRemoveMember: (Long, Long) -> Unit,
|
onRemoveMember: (Long, Long) -> Unit,
|
||||||
onBanMember: (Long, Long) -> Unit,
|
onBanMember: (Long, Long) -> Unit,
|
||||||
onUnbanMember: (Long, Long) -> Unit,
|
onUnbanMember: (Long, Long) -> Unit,
|
||||||
|
onToggleDayNightMode: ((AppThemeMode) -> Unit) -> Unit,
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var managementExpanded by remember { mutableStateOf(false) }
|
var managementExpanded by remember { mutableStateOf(false) }
|
||||||
@@ -411,9 +414,8 @@ fun ChatListScreen(
|
|||||||
) {
|
) {
|
||||||
DropdownMenuItem(
|
DropdownMenuItem(
|
||||||
text = {
|
text = {
|
||||||
val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES
|
|
||||||
Text(
|
Text(
|
||||||
if (isNight) {
|
if (MaterialTheme.colorScheme.background.luminance() < 0.5f) {
|
||||||
stringResource(id = R.string.menu_day_mode)
|
stringResource(id = R.string.menu_day_mode)
|
||||||
} else {
|
} else {
|
||||||
stringResource(id = R.string.menu_night_mode)
|
stringResource(id = R.string.menu_night_mode)
|
||||||
@@ -423,13 +425,13 @@ fun ChatListScreen(
|
|||||||
leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) },
|
leadingIcon = { Icon(Icons.Filled.LightMode, contentDescription = null) },
|
||||||
onClick = {
|
onClick = {
|
||||||
showDefaultMenu = false
|
showDefaultMenu = false
|
||||||
val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES
|
onToggleDayNightMode { nextMode ->
|
||||||
if (isNight) {
|
val toastRes = when (nextMode) {
|
||||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
AppThemeMode.LIGHT -> R.string.toast_day_mode_enabled
|
||||||
Toast.makeText(context, context.getString(R.string.toast_day_mode_enabled), Toast.LENGTH_SHORT).show()
|
AppThemeMode.DARK -> R.string.toast_night_mode_enabled
|
||||||
} else {
|
AppThemeMode.SYSTEM -> R.string.toast_day_mode_enabled
|
||||||
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
}
|
||||||
Toast.makeText(context, context.getString(R.string.toast_night_mode_enabled), Toast.LENGTH_SHORT).show()
|
Toast.makeText(context, context.getString(toastRes), Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package ru.daemonlord.messenger.ui.chats
|
package ru.daemonlord.messenger.ui.chats
|
||||||
|
|
||||||
|
import androidx.appcompat.app.AppCompatDelegate
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -24,6 +25,8 @@ import ru.daemonlord.messenger.domain.common.AppResult
|
|||||||
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
import ru.daemonlord.messenger.domain.realtime.RealtimeManager
|
||||||
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
|
import ru.daemonlord.messenger.domain.realtime.model.RealtimeConnectionState
|
||||||
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
|
import ru.daemonlord.messenger.domain.realtime.usecase.HandleRealtimeEventsUseCase
|
||||||
|
import ru.daemonlord.messenger.domain.settings.model.AppThemeMode
|
||||||
|
import ru.daemonlord.messenger.domain.settings.repository.ThemeRepository
|
||||||
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
|
import ru.daemonlord.messenger.domain.search.repository.SearchRepository
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -38,6 +41,7 @@ class ChatListViewModel @Inject constructor(
|
|||||||
private val chatRepository: ChatRepository,
|
private val chatRepository: ChatRepository,
|
||||||
private val chatSearchRepository: ChatSearchRepository,
|
private val chatSearchRepository: ChatSearchRepository,
|
||||||
private val searchRepository: SearchRepository,
|
private val searchRepository: SearchRepository,
|
||||||
|
private val themeRepository: ThemeRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val selectedTab = MutableStateFlow(ChatTab.ALL)
|
private val selectedTab = MutableStateFlow(ChatTab.ALL)
|
||||||
@@ -170,6 +174,29 @@ class ChatListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun toggleDayNightMode(onResult: (AppThemeMode) -> Unit = {}) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val current = themeRepository.getThemeMode()
|
||||||
|
val next = when (current) {
|
||||||
|
AppThemeMode.DARK -> AppThemeMode.LIGHT
|
||||||
|
AppThemeMode.LIGHT -> AppThemeMode.DARK
|
||||||
|
AppThemeMode.SYSTEM -> {
|
||||||
|
val isNight = AppCompatDelegate.getDefaultNightMode() == AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
if (isNight) AppThemeMode.LIGHT else AppThemeMode.DARK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
themeRepository.setThemeMode(next)
|
||||||
|
AppCompatDelegate.setDefaultNightMode(
|
||||||
|
when (next) {
|
||||||
|
AppThemeMode.LIGHT -> AppCompatDelegate.MODE_NIGHT_NO
|
||||||
|
AppThemeMode.DARK -> AppCompatDelegate.MODE_NIGHT_YES
|
||||||
|
AppThemeMode.SYSTEM -> AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM
|
||||||
|
}
|
||||||
|
)
|
||||||
|
onResult(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun onManagementChatSelected(chatId: Long?) {
|
fun onManagementChatSelected(chatId: Long?) {
|
||||||
_uiState.update { it.copy(selectedManageChatId = chatId) }
|
_uiState.update { it.copy(selectedManageChatId = chatId) }
|
||||||
if (chatId != null) {
|
if (chatId != null) {
|
||||||
|
|||||||
Reference in New Issue
Block a user