feat: add unknown private chat actions banner
Some checks failed
Android CI / android (push) Has started running
Android Release / release (push) Has been cancelled
CI / test (push) Has been cancelled

feat: show add-contact and block actions for unknown users in private chats

refactor: expose counterpart user ids through chat domain models
This commit is contained in:
2026-04-05 15:13:48 +03:00
parent 904ce1bbcd
commit 2a6aa7e8ad
9 changed files with 161 additions and 0 deletions

View File

@@ -28,6 +28,7 @@ interface ChatDao {
c.muted,
c.unread_count,
c.unread_mentions_count,
c.counterpart_user_id,
COALESCE(c.counterpart_name, u.display_name) AS counterpart_name,
COALESCE(c.counterpart_username, u.username) AS counterpart_username,
COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url,
@@ -62,6 +63,7 @@ interface ChatDao {
c.muted,
c.unread_count,
c.unread_mentions_count,
c.counterpart_user_id,
COALESCE(c.counterpart_name, u.display_name) AS counterpart_name,
COALESCE(c.counterpart_username, u.username) AS counterpart_username,
COALESCE(c.counterpart_avatar_url, u.avatar_url) AS counterpart_avatar_url,

View File

@@ -27,6 +27,8 @@ data class ChatListLocalModel(
val unreadCount: Int,
@ColumnInfo(name = "unread_mentions_count")
val unreadMentionsCount: Int,
@ColumnInfo(name = "counterpart_user_id")
val counterpartUserId: Long?,
@ColumnInfo(name = "counterpart_name")
val counterpartName: String?,
@ColumnInfo(name = "counterpart_username")

View File

@@ -18,6 +18,7 @@ fun ChatListLocalModel.toDomain(): ChatItem {
muted = muted,
unreadCount = unreadCount,
unreadMentionsCount = unreadMentionsCount,
counterpartUserId = counterpartUserId,
counterpartUsername = counterpartUsername,
counterpartName = counterpartName,
counterpartAvatarUrl = counterpartAvatarUrl,
@@ -46,6 +47,7 @@ fun ChatEntity.toDomain(): ChatItem {
muted = muted,
unreadCount = unreadCount,
unreadMentionsCount = unreadMentionsCount,
counterpartUserId = counterpartUserId,
counterpartUsername = counterpartUsername,
counterpartName = counterpartName,
counterpartAvatarUrl = counterpartAvatarUrl,

View File

@@ -13,6 +13,7 @@ data class ChatItem(
val muted: Boolean,
val unreadCount: Int,
val unreadMentionsCount: Int,
val counterpartUserId: Long?,
val counterpartUsername: String?,
val counterpartName: String?,
val counterpartAvatarUrl: String?,

View File

@@ -57,8 +57,10 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Slider
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.DropdownMenu
@@ -438,6 +440,8 @@ fun ChatRoute(
onToggleChatNotifications = viewModel::onToggleChatNotifications,
onClearHistory = viewModel::onClearHistory,
onDeleteChat = viewModel::onDeleteOrLeaveChat,
onAddCounterpartToContacts = viewModel::onAddCounterpartToContacts,
onBlockCounterpart = viewModel::onBlockCounterpart,
onPromoteMember = viewModel::promoteMember,
onDemoteMember = viewModel::demoteMember,
onBanMember = viewModel::banMember,
@@ -526,6 +530,8 @@ private data class ChatScreenActions(
val onToggleChatNotifications: () -> Unit,
val onClearHistory: () -> Unit,
val onDeleteChat: () -> Unit,
val onAddCounterpartToContacts: () -> Unit,
val onBlockCounterpart: () -> Unit,
val onPromoteMember: (Long) -> Unit,
val onDemoteMember: (Long) -> Unit,
val onBanMember: (Long) -> Unit,
@@ -578,6 +584,8 @@ private fun ChatScreen(
val onToggleChatNotifications = actions.onToggleChatNotifications
val onClearHistory = actions.onClearHistory
val onDeleteChat = actions.onDeleteChat
val onAddCounterpartToContacts = actions.onAddCounterpartToContacts
val onBlockCounterpart = actions.onBlockCounterpart
val onPromoteMember = actions.onPromoteMember
val onDemoteMember = actions.onDemoteMember
val onBanMember = actions.onBanMember
@@ -591,6 +599,10 @@ private fun ChatScreen(
val allViewerMediaItems = remember(state.messages) { buildChatViewerMediaItems(state.messages) }
val isChannelChat = state.chatType.equals("channel", ignoreCase = true)
val isPrivateChat = state.chatType.equals("private", ignoreCase = true)
val showUnknownPrivateChatBanner = isPrivateChat &&
state.counterpartUserId != null &&
!state.isCounterpartContact &&
!state.isCounterpartBlocked
val canShowMembersTab = state.chatType.equals("group", ignoreCase = true) ||
(state.chatType.equals("channel", ignoreCase = true) && state.canManageMembers)
val timelineItems = remember(state.messages, context) { buildChatTimelineItems(state.messages, context) }
@@ -1087,6 +1099,52 @@ private fun ChatScreen(
}
}
}
AnimatedVisibility(
visible = showUnknownPrivateChatBanner,
enter = fadeIn(),
exit = fadeOut(),
) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
shape = RoundedCornerShape(18.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f),
) {
Column(
modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text(
text = stringResource(id = R.string.chat_unknown_user_banner_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Text(
text = stringResource(id = R.string.chat_unknown_user_banner_subtitle),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
FilledTonalButton(
onClick = onAddCounterpartToContacts,
modifier = Modifier.weight(1f),
) {
Text(stringResource(id = R.string.chat_unknown_user_add_contact))
}
OutlinedButton(
onClick = onBlockCounterpart,
modifier = Modifier.weight(1f),
) {
Text(stringResource(id = R.string.chat_unknown_user_block))
}
}
}
}
}
val strip = topAudioStrip
if (strip != null) {
Row(

View File

@@ -21,6 +21,7 @@ import ru.daemonlord.messenger.core.notifications.ActiveChatTracker
import ru.daemonlord.messenger.core.notifications.NotificationDispatcher
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.R
import ru.daemonlord.messenger.domain.account.repository.AccountRepository
import ru.daemonlord.messenger.domain.chat.repository.ChatRepository
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatUseCase
import ru.daemonlord.messenger.domain.chat.usecase.ObserveChatsUseCase
@@ -68,6 +69,7 @@ class ChatViewModel @Inject constructor(
private val listMessageReactionsUseCase: ListMessageReactionsUseCase,
private val toggleMessageReactionUseCase: ToggleMessageReactionUseCase,
private val chatRepository: ChatRepository,
private val accountRepository: AccountRepository,
private val observeChatUseCase: ObserveChatUseCase,
private val observeChatsUseCase: ObserveChatsUseCase,
private val handleRealtimeEventsUseCase: HandleRealtimeEventsUseCase,
@@ -86,6 +88,7 @@ class ChatViewModel @Inject constructor(
private var lastReadMessageId: Long? = null
private val reactionsRequestedMessageIds = mutableSetOf<Long>()
private var membersLoadKey: String? = null
private var privateChatRelationKey: Long? = null
private var typingResetJob: Job? = null
init {
@@ -774,6 +777,7 @@ class ChatViewModel @Inject constructor(
}
it.copy(
chatType = chat.type,
counterpartUserId = chat.counterpartUserId,
chatRole = role,
chatMuted = chat.muted,
chatTitle = chatTitle,
@@ -800,6 +804,56 @@ class ChatViewModel @Inject constructor(
membersLoadKey = nextLoadKey
refreshMembersAndBans()
}
refreshPrivateChatRelationship(
chatType = chat.type,
counterpartUserId = chat.counterpartUserId,
)
}
}
}
fun onAddCounterpartToContacts() {
val userId = uiState.value.counterpartUserId ?: return
viewModelScope.launch {
when (val result = accountRepository.addContact(userId = userId)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isCounterpartContact = true,
isCounterpartBlocked = false,
errorMessage = null,
)
}
refreshPrivateChatRelationship(
chatType = uiState.value.chatType,
counterpartUserId = userId,
force = true,
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
fun onBlockCounterpart() {
val userId = uiState.value.counterpartUserId ?: return
viewModelScope.launch {
when (val result = accountRepository.blockUser(userId = userId)) {
is AppResult.Success -> {
_uiState.update {
it.copy(
isCounterpartContact = false,
isCounterpartBlocked = true,
errorMessage = null,
)
}
refreshPrivateChatRelationship(
chatType = uiState.value.chatType,
counterpartUserId = userId,
force = true,
)
}
is AppResult.Error -> _uiState.update { it.copy(errorMessage = result.reason.toUiMessage()) }
}
}
}
@@ -901,6 +955,37 @@ class ChatViewModel @Inject constructor(
}
}
private fun refreshPrivateChatRelationship(
chatType: String,
counterpartUserId: Long?,
force: Boolean = false,
) {
if (!chatType.equals("private", ignoreCase = true) || counterpartUserId == null) {
privateChatRelationKey = null
_uiState.update {
it.copy(
isCounterpartContact = false,
isCounterpartBlocked = false,
)
}
return
}
if (!force && privateChatRelationKey == counterpartUserId) return
privateChatRelationKey = counterpartUserId
viewModelScope.launch {
val contactsResult = accountRepository.listContacts()
val blockedResult = accountRepository.listBlockedUsers()
val isContact = (contactsResult as? AppResult.Success)?.data?.any { it.id == counterpartUserId } == true
val isBlocked = (blockedResult as? AppResult.Success)?.data?.any { it.id == counterpartUserId } == true
_uiState.update {
it.copy(
isCounterpartContact = isContact,
isCounterpartBlocked = isBlocked,
)
}
}
}
private fun updateMemberRole(userId: Long, role: String) {
viewModelScope.launch {
when (val result = chatRepository.updateMemberRole(chatId = chatId, userId = userId, role = role)) {

View File

@@ -18,9 +18,12 @@ data class MessageUiState(
val chatSubtitle: String = "",
val chatAvatarUrl: String? = null,
val chatType: String = "",
val counterpartUserId: Long? = null,
val chatRole: String? = null,
val chatMuted: Boolean = false,
val chatUnreadCount: Int = 0,
val isCounterpartContact: Boolean = false,
val isCounterpartBlocked: Boolean = false,
val chatMembers: List<ChatMemberItem> = emptyList(),
val chatBans: List<ChatBanItem> = emptyList(),
val canManageMembers: Boolean = false,

View File

@@ -136,6 +136,10 @@
<string name="chat_status_online">в сети</string>
<string name="chat_status_last_seen_recently">был(а) недавно</string>
<string name="chat_status_typing">печатает…</string>
<string name="chat_unknown_user_banner_title">Незнакомый пользователь</string>
<string name="chat_unknown_user_banner_subtitle">Можно добавить этого человека в контакты или заблокировать прямо из чата.</string>
<string name="chat_unknown_user_add_contact">Добавить контакт</string>
<string name="chat_unknown_user_block">Заблокировать</string>
<string name="chat_type_group">группа</string>
<string name="chat_type_channel">канал</string>
<string name="chat_info_tab_media">Медиа</string>

View File

@@ -136,6 +136,10 @@
<string name="chat_status_online">online</string>
<string name="chat_status_last_seen_recently">last seen recently</string>
<string name="chat_status_typing">typing…</string>
<string name="chat_unknown_user_banner_title">Unknown user</string>
<string name="chat_unknown_user_banner_subtitle">You can add this person to contacts or block them right from the chat.</string>
<string name="chat_unknown_user_add_contact">Add contact</string>
<string name="chat_unknown_user_block">Block user</string>
<string name="chat_type_group">group</string>
<string name="chat_type_channel">channel</string>
<string name="chat_info_tab_media">Media</string>