core, ui: relay management - add, remove relays, synchronization to relay list (#6917)

This commit is contained in:
spaced4ndy
2026-05-08 07:19:16 +00:00
committed by GitHub
parent d9cfc9bd3d
commit 6f8a07e4ea
44 changed files with 1861 additions and 182 deletions
@@ -92,6 +92,7 @@ object ChannelRelaysModel {
if (groupId.value == groupInfo.groupId) {
val i = groupRelays.indexOfFirst { it.groupRelayId == relay.groupRelayId }
if (i >= 0) groupRelays[i] = relay
else groupRelays.add(relay)
}
}
@@ -2163,6 +2163,19 @@ object ChatController {
return emptyList()
}
sealed class AddGroupRelaysResult {
data class Added(val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): AddGroupRelaysResult()
data class AddFailed(val addRelayResults: List<AddRelayResult>): AddGroupRelaysResult()
}
suspend fun apiAddGroupRelays(groupId: Long, relayIds: List<Long>): AddGroupRelaysResult? {
val r = sendCmdWithRetry(null, CC.ApiAddGroupRelays(groupId, relayIds))
if (r is API.Result && r.res is CR.GroupRelaysAdded) return AddGroupRelaysResult.Added(r.res.groupInfo, r.res.groupLink, r.res.groupRelays)
if (r is API.Result && r.res is CR.GroupRelaysAddFailed) return AddGroupRelaysResult.AddFailed(r.res.addRelayResults)
if (r != null) throw Exception("${r.responseType}: ${r.details}")
return null
}
suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? {
val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole))
if (r is API.Result && r.res is CR.SentGroupInvitation) return r.res.member
@@ -3666,6 +3679,7 @@ sealed class CC {
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiNewPublicGroup(val userId: Long, val incognito: Boolean, val relayIds: List<Long>, val groupProfile: GroupProfile): CC()
class ApiGetGroupRelays(val groupId: Long): CC()
class ApiAddGroupRelays(val groupId: Long, val relayIds: List<Long>): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC()
@@ -3870,6 +3884,7 @@ sealed class CC {
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiNewPublicGroup -> "/_public group $userId incognito=${onOff(incognito)} ${relayIds.joinToString(",")} ${json.encodeToString(groupProfile)}"
is ApiGetGroupRelays -> "/_get relays #$groupId"
is ApiAddGroupRelays -> "/_add relays #$groupId ${relayIds.joinToString(",")}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}"
@@ -4053,6 +4068,7 @@ sealed class CC {
is ApiNewGroup -> "apiNewGroup"
is ApiNewPublicGroup -> "apiNewPublicGroup"
is ApiGetGroupRelays -> "apiGetGroupRelays"
is ApiAddGroupRelays -> "apiAddGroupRelays"
is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup"
is ApiAcceptMember -> "apiAcceptMember"
@@ -6402,6 +6418,8 @@ sealed class CR {
@Serializable @SerialName("publicGroupCreated") class PublicGroupCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): CR()
@Serializable @SerialName("publicGroupCreationFailed") class PublicGroupCreationFailed(val user: UserRef, val addRelayResults: List<AddRelayResult>): CR()
@Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List<GroupRelay>): CR()
@Serializable @SerialName("groupRelaysAdded") class GroupRelaysAdded(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): CR()
@Serializable @SerialName("groupRelaysAddFailed") class GroupRelaysAddFailed(val user: UserRef, val addRelayResults: List<AddRelayResult>): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR()
@Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR()
@@ -6591,6 +6609,8 @@ sealed class CR {
is PublicGroupCreated -> "publicGroupCreated"
is PublicGroupCreationFailed -> "publicGroupCreationFailed"
is GroupRelays -> "groupRelays"
is GroupRelaysAdded -> "groupRelaysAdded"
is GroupRelaysAddFailed -> "groupRelaysAddFailed"
is SentGroupInvitation -> "sentGroupInvitation"
is UserAcceptedGroupSent -> "userAcceptedGroupSent"
is GroupLinkConnecting -> "groupLinkConnecting"
@@ -6773,6 +6793,8 @@ sealed class CR {
is PublicGroupCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays")
is PublicGroupCreationFailed -> withUser(user, "addRelayResults: $addRelayResults")
is GroupRelays -> withUser(user, "groupInfo: $groupInfo\ngroupRelays: $groupRelays")
is GroupRelaysAdded -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays")
is GroupRelaysAddFailed -> withUser(user, "addRelayResults: $addRelayResults")
is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember")
@@ -211,7 +211,7 @@ fun ChatView(
withContext(Dispatchers.Main) {
ChannelRelaysModel.set(cInfo.groupInfo.groupId, relays)
}
} else {
} else if (cInfo.groupInfo.membership.memberCurrent) {
val gInfo = chatModel.controller.apiGetUpdatedGroupLinkData(chatRh, cInfo.groupInfo.groupId)
if (gInfo != null) {
withContext(Dispatchers.Main) {
@@ -1543,14 +1543,14 @@ fun ComposeView(
) {
if (gInfo.membership.memberRole == GroupMemberRole.Owner) {
ownerRelayState?.let { s ->
if (s.activeCount < s.relays.size) {
if (s.relays.isEmpty() || s.activeCount < s.relays.size) {
OwnerChannelRelayBar(chatModel, s.relays, s.activeCount, s.failedCount, s.removedCount, relayListExpanded)
}
}
} else {
val hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?: emptyList()).sorted()
val relayMembers = chatModel.groupMembers.value
.filter { it.memberRole == GroupMemberRole.Relay }
.filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus !in listOf(GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) }
.sortedBy { hostFromRelayLink(it.relayLink ?: "") }
val showProgress = !gInfo.nextConnectPrepared || composeState.value.inProgress
val removedCount = relayMembers.count { relayMemberRemoved(it.memberStatus) }
@@ -1558,7 +1558,7 @@ fun ComposeView(
val failedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connFailedErr != null }
val resolvedCount = connectedCount + removedCount + failedCount
val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size
if (total > 0 && (removedCount + failedCount > 0 || resolvedCount < total)) {
if (total == 0 || removedCount + failedCount > 0 || resolvedCount < total) {
SubscriberChannelRelayBar(hostnames, relayMembers, connectedCount, removedCount, failedCount, total, showProgress, relayListExpanded)
}
}
@@ -1756,7 +1756,15 @@ private fun OwnerChannelRelayBar(
if (!allBroken && activeCount + failedCount + removedCount < total) {
RelayProgressIndicator(active = activeCount, total = total)
}
if (allBroken) {
if (total == 0) {
Text(generalGetString(MR.strings.relay_bar_no_relays), color = MaterialTheme.colors.secondary)
Icon(
painterResource(MR.images.ic_warning),
contentDescription = null,
tint = WarningOrange,
modifier = Modifier.size(18.dp)
)
} else if (allBroken) {
val statusText = if (removedCount == total) {
generalGetString(MR.strings.relay_bar_all_relays_removed)
} else if (failedCount == total) {
@@ -1842,7 +1850,15 @@ private fun SubscriberChannelRelayBar(
val allBroken = connectedCount == 0 && errorCount == total
Column(Modifier.background(MaterialTheme.colors.surface)) {
RelayBarHeader(relayListExpanded) {
if (allBroken) {
if (total == 0) {
Text(generalGetString(MR.strings.relay_bar_no_relays), color = MaterialTheme.colors.secondary)
Icon(
painterResource(MR.images.ic_warning),
contentDescription = null,
tint = WarningOrange,
modifier = Modifier.size(18.dp)
)
} else if (allBroken) {
val statusText = if (removedCount == total) {
generalGetString(MR.strings.relay_bar_all_relays_removed)
} else if (failedCount == total) {
@@ -1990,7 +2006,7 @@ private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState?
gInfo.membership.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
) return null
val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList()
if (relays.isEmpty()) return null
if (relays.isEmpty()) return OwnerRelayState(emptyList(), 0, 0, 0, true)
val relayMembers = relays.map { relay ->
relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId }
}
@@ -0,0 +1,237 @@
package chat.simplex.common.views.chat.group
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.chatRelayDisplayName
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.launch
data class AvailableRelay(
val relayId: Long,
val relay: UserChatRelay,
val operatorName: String?
)
@Composable
fun AddGroupRelayView(
groupInfo: GroupInfo,
existingRelayIds: Set<Long>,
onRelayAdded: () -> Unit,
close: () -> Unit
) {
var availableRelays by remember { mutableStateOf<List<AvailableRelay>>(emptyList()) }
var selectedRelayIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
var isLoading by remember { mutableStateOf(true) }
var isAdding by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
BackHandler(onBack = close)
LaunchedEffect(Unit) {
try {
val servers = ChatController.getUserServers(null)
if (servers != null) {
val relays = mutableListOf<AvailableRelay>()
for (op in servers) {
if (op.operator != null && op.operator.enabled != true) continue
val opName: String? = if (op.operator?.operatorTag != null) op.operator.tradeName else null
for (relay in op.chatRelays) {
val relayId = relay.chatRelayId
if (relay.enabled && !relay.deleted && relayId != null && relayId !in existingRelayIds) {
relays.add(AvailableRelay(relayId, relay, opName))
}
}
}
availableRelays = relays
}
} catch (e: Exception) {
Log.e(TAG, "loadAvailableRelays error: ${e.message}")
}
isLoading = false
}
AddGroupRelayLayout(
availableRelays = availableRelays,
selectedRelayIds = selectedRelayIds,
isLoading = isLoading,
isAdding = isAdding,
onToggleRelay = { relayId ->
selectedRelayIds = if (relayId in selectedRelayIds) selectedRelayIds - relayId else selectedRelayIds + relayId
},
onAddRelays = {
val relayIds = selectedRelayIds.toList()
if (relayIds.isEmpty()) return@AddGroupRelayLayout
isAdding = true
scope.launch {
addSelectedRelays(groupInfo, relayIds, selectedRelayIds, availableRelays, onRelayAdded, close) { newSelectedIds, newAvailableRelays ->
selectedRelayIds = newSelectedIds
availableRelays = newAvailableRelays
isAdding = false
}
}
}
)
}
@Composable
private fun AddGroupRelayLayout(
availableRelays: List<AvailableRelay>,
selectedRelayIds: Set<Long>,
isLoading: Boolean,
isAdding: Boolean,
onToggleRelay: (Long) -> Unit,
onAddRelays: () -> Unit
) {
ColumnWithScrollBar {
AppBarTitle(generalGetString(MR.strings.add_relays_title))
if (isLoading) {
Box(Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (availableRelays.isEmpty()) {
SectionView {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
generalGetString(MR.strings.no_available_relays),
color = MaterialTheme.colors.secondary
)
}
}
} else {
SectionView {
AddRelaysButton(
onClick = onAddRelays,
disabled = selectedRelayIds.isEmpty() || isAdding
)
}
SectionCustomFooter {
val count = selectedRelayIds.size
Text(
if (count == 0) generalGetString(MR.strings.no_relays_selected)
else String.format(generalGetString(MR.strings.num_relays_selected), count),
color = MaterialTheme.colors.secondary,
lineHeight = 18.sp,
fontSize = 14.sp
)
}
SectionDividerSpaced(maxTopPadding = true)
SectionView(generalGetString(MR.strings.select_relays).uppercase()) {
availableRelays.forEach { item ->
val selected = item.relayId in selectedRelayIds
SectionItemView(
click = { onToggleRelay(item.relayId) },
padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp)
) {
Column(Modifier.weight(1f)) {
Text(
chatRelayDisplayName(item.relay),
maxLines = 1,
color = MaterialTheme.colors.onBackground
)
if (item.operatorName != null) {
Text(
item.operatorName,
fontSize = 12.sp,
maxLines = 1,
color = MaterialTheme.colors.secondary
)
}
}
Spacer(Modifier.width(8.dp))
Icon(
painterResource(if (selected) MR.images.ic_check_circle_filled else MR.images.ic_circle),
contentDescription = null,
tint = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier.size(24.dp)
)
}
}
}
}
SectionBottomSpacer()
}
}
@Composable
private fun AddRelaysButton(onClick: () -> Unit, disabled: Boolean) {
SettingsActionItem(
painterResource(MR.images.ic_check),
generalGetString(MR.strings.add_relays_title),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
disabled = disabled,
)
}
private suspend fun addSelectedRelays(
groupInfo: GroupInfo,
relayIds: List<Long>,
selectedRelayIds: Set<Long>,
availableRelays: List<AvailableRelay>,
onRelayAdded: () -> Unit,
close: () -> Unit,
updateState: (Set<Long>, List<AvailableRelay>) -> Unit
) {
try {
val result = ChatController.apiAddGroupRelays(groupInfo.groupId, relayIds)
if (result == null) {
updateState(selectedRelayIds, availableRelays)
return
}
when (result) {
is ChatController.AddGroupRelaysResult.Added -> {
ChannelRelaysModel.set(groupId = result.groupInfo.groupId, groupRelays = result.groupRelays)
onRelayAdded()
close()
}
is ChatController.AddGroupRelaysResult.AddFailed -> {
val results = result.addRelayResults
val successIds = results.filter { it.relayError == null }.mapNotNull { it.relay.chatRelayId }.toSet()
var newSelectedIds = selectedRelayIds
var newAvailableRelays = availableRelays
if (successIds.isNotEmpty()) {
newSelectedIds = selectedRelayIds - successIds
newAvailableRelays = availableRelays.filter { it.relayId !in successIds }
onRelayAdded()
}
val errorLines = results.filter { it.relayError != null }
.map { "${chatRelayDisplayName(it.relay)}: ${it.relayError?.let { e -> ChatController.connErrorText(e) } ?: ""}" }
val successNames = results.filter { it.relayError == null }
.map { chatRelayDisplayName(it.relay) }
var msg = errorLines.joinToString("\n")
if (successNames.isNotEmpty()) {
msg += "\n" + String.format(generalGetString(MR.strings.relays_added_format), successNames.joinToString(", "))
}
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_adding_relays),
text = msg
)
updateState(newSelectedIds, newAvailableRelays)
}
}
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_adding_relays),
text = e.message ?: ""
)
updateState(selectedRelayIds, availableRelays)
}
}
@@ -2,6 +2,7 @@ package chat.simplex.common.views.chat.group
import SectionBottomSpacer
import SectionItemView
import SectionItemViewLongClickable
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
@@ -16,9 +17,11 @@ import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.setGroupMembers
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun ChannelRelaysView(
@@ -29,16 +32,18 @@ fun ChannelRelaysView(
showMemberInfo: (GroupMember, GroupRelay?) -> Unit
) {
BackHandler(onBack = close)
var groupRelays by remember { mutableStateOf<List<GroupRelay>>(emptyList()) }
val groupRelays = ChannelRelaysModel.groupRelays
LaunchedEffect(Unit) {
setGroupMembers(rhId, groupInfo, chatModel)
if (groupInfo.isOwner) {
groupRelays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
ChannelRelaysModel.set(groupId = groupInfo.groupId, groupRelays = relays)
}
}
ChannelRelaysLayout(
rhId = rhId,
groupInfo = groupInfo,
chatModel = chatModel,
groupRelays = groupRelays,
@@ -48,13 +53,14 @@ fun ChannelRelaysView(
@Composable
private fun ChannelRelaysLayout(
rhId: Long?,
groupInfo: GroupInfo,
chatModel: ChatModel,
groupRelays: List<GroupRelay>,
showMemberInfo: (GroupMember, GroupRelay?) -> Unit
) {
val relayMembers = remember { chatModel.groupMembers }.value
.filter { it.memberRole == GroupMemberRole.Relay }
.filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus != GroupMemberStatus.MemRemoved && it.memberStatus != GroupMemberStatus.MemGroupDeleted }
ColumnWithScrollBar {
AppBarTitle(generalGetString(MR.strings.channel_relays_title))
@@ -74,11 +80,21 @@ private fun ChannelRelaysLayout(
if (index > 0) {
Divider()
}
SectionItemView(
val showMenu = remember { mutableStateOf(false) }
SectionItemViewLongClickable(
click = { showMemberInfo(member, groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }) },
longClick = { showMenu.value = true },
minHeight = 54.dp,
padding = PaddingValues(horizontal = DEFAULT_PADDING)
) {
if (groupInfo.isOwner && member.canBeRemoved(groupInfo)) {
DefaultDropdownMenu(showMenu) {
ItemAction(generalGetString(MR.strings.button_remove_relay), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
removeMemberAlert(rhId, groupInfo, member)
showMenu.value = false
})
}
}
val statusText = if (groupInfo.isOwner) {
ownerRelayStatusText(member, groupRelays)
} else {
@@ -90,6 +106,32 @@ private fun ChannelRelaysLayout(
}
SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages))
}
if (groupInfo.isOwner) {
SectionView {
SectionItemView(click = {
val existingRelayIds = groupRelays.filter { it.relayStatus != RelayStatus.RsInactive }.mapNotNull { it.userChatRelay.chatRelayId }.toSet()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupRelayView(
groupInfo = groupInfo,
existingRelayIds = existingRelayIds,
onRelayAdded = { withBGApi { setGroupMembers(rhId, groupInfo, chatModel) } },
close = close
)
}
}, padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Icon(
painterResource(MR.images.ic_add),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.width(4.dp))
Text(
generalGetString(MR.strings.add_relay_button),
color = MaterialTheme.colors.primary
)
}
}
}
SectionBottomSpacer()
}
}
@@ -239,39 +239,88 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl
)
}
private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) {
val titleId = if (groupInfo.useRelays) MR.strings.button_remove_subscriber_question
else MR.strings.button_remove_member_question
val messageId = if (groupInfo.useRelays)
MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone
else if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(titleId),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) {
if (mem.memberRole == GroupMemberRole.Relay) {
val isLastActive = groupInfo.useRelays && mem.memberCurrent && run {
val activeRelays = ChatModel.groupMembers.value.filter { it.memberRole == GroupMemberRole.Relay && it.memberCurrent }
activeRelays.size <= 1
}
val message = if (isLastActive) generalGetString(MR.strings.last_active_relay_warning)
else generalGetString(MR.strings.relay_will_be_removed_from_channel)
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_relay_question),
message,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
})
} else if (groupInfo.useRelays) {
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_subscriber_question),
generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
})
} else {
val titleId = MR.strings.button_remove_member_question
val messageId = if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(titleId),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
})
})
}
}
private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, onSuccess: () -> Unit = {}) {
@@ -242,34 +242,86 @@ fun GroupMemberInfoView(
}
fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
val messageId = if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_member_question),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
if (member.memberRole == GroupMemberRole.Relay) {
val isLastActive = groupInfo.useRelays && run {
val activeRelays = chatModel.groupMembers.value.filter { it.memberRole == GroupMemberRole.Relay && it.memberCurrent }
activeRelays.size <= 1
}
val message = if (isLastActive) generalGetString(MR.strings.last_active_relay_warning)
else generalGetString(MR.strings.relay_will_be_removed_from_channel)
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_relay_question),
message,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
})
} else if (groupInfo.useRelays) {
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_subscriber_question),
generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
})
} else {
val messageId = if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_member_question),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
})
})
}
}
fun deleteMemberMessagesDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
@@ -368,7 +420,7 @@ fun GroupMemberInfoLayout(
@Composable
fun ModeratorDestructiveSection() {
val canBlockForAll = member.canBlockForAll(groupInfo)
val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay
val canRemove = member.canBeRemoved(groupInfo)
if (canBlockForAll || canRemove) {
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
@@ -380,10 +432,10 @@ fun GroupMemberInfoLayout(
}
}
if (canRemove) {
if (member.memberStatus == GroupMemberStatus.MemRemoved || member.memberStatus == GroupMemberStatus.MemLeft) {
if (member.memberStatus != GroupMemberStatus.MemRemoved && (member.memberStatus != GroupMemberStatus.MemLeft || member.memberRole == GroupMemberRole.Relay)) {
RemoveMemberButton(groupInfo.useRelays, member.memberRole == GroupMemberRole.Relay, removeMember)
} else if (member.memberRole != GroupMemberRole.Relay) {
DeleteMemberMessagesButton(deleteMemberMessages)
} else {
RemoveMemberButton(groupInfo.useRelays, removeMember)
}
}
}
@@ -753,8 +805,10 @@ fun UnblockForAllButton(onClick: () -> Unit) {
}
@Composable
fun RemoveMemberButton(useRelays: Boolean = false, onClick: () -> Unit) {
val label = if (useRelays) MR.strings.button_remove_subscriber else MR.strings.button_remove_member
fun RemoveMemberButton(useRelays: Boolean = false, isRelay: Boolean = false, onClick: () -> Unit) {
val label = if (isRelay) MR.strings.button_remove_relay
else if (useRelays) MR.strings.button_remove_subscriber
else MR.strings.button_remove_member
SettingsActionItem(
painterResource(MR.images.ic_delete),
stringResource(label),
@@ -588,7 +588,7 @@ fun relayDisplayName(relay: GroupRelay): String {
return "relay ${relay.groupRelayId}"
}
private fun chatRelayDisplayName(relay: UserChatRelay): String {
fun chatRelayDisplayName(relay: UserChatRelay): String {
if (relay.displayName.isNotEmpty()) return relay.displayName
return relay.address
}
@@ -597,7 +597,7 @@ private fun chatRelayDisplayName(relay: UserChatRelay): String {
fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false, memberStatus: GroupMemberStatus? = null) {
val removed = memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
val color = if (connFailed || removed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow
val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else status.text
val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else if (removed) generalGetString(MR.strings.relay_conn_status_removed) else status.text
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
@@ -2986,6 +2986,7 @@
<string name="relay_conn_status_deleted">deleted</string>
<string name="relay_conn_status_failed">failed</string>
<string name="relay_conn_status_removed_by_operator">removed by operator</string>
<string name="relay_conn_status_removed">removed</string>
<string name="relay_status_new">new</string>
<string name="relay_status_invited">invited</string>
<string name="relay_status_accepted">accepted</string>
@@ -3007,7 +3008,8 @@
<string name="relay_bar_connected_with_failures">%1$d/%2$d relays connected, %3$d failed</string>
<string name="relay_bar_connected_with_removed">%1$d/%2$d relays connected, %3$d removed</string>
<string name="relay_bar_connected">%1$d/%2$d relays connected</string>
<string name="relay_bar_owner_no_delivery">Adding relays will be supported later.</string>
<string name="relay_bar_no_relays">No relays</string>
<string name="relay_bar_owner_no_delivery">Add relays to restore message delivery.</string>
<string name="relay_bar_subscriber_waiting">Waiting for channel owner to add relays.</string>
<!-- GroupMemberInfoView.kt channel-related -->
@@ -3022,6 +3024,10 @@
<string name="relay_section_footer_owner">Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel.</string>
<string name="relay_section_footer_subscriber">You connected to the channel via this relay link.</string>
<string name="button_remove_subscriber">Remove subscriber</string>
<string name="button_remove_relay">Remove relay</string>
<string name="button_remove_relay_question">Remove relay?</string>
<string name="relay_will_be_removed_from_channel">Relay will be removed from channel - this cannot be undone!</string>
<string name="last_active_relay_warning">This is the last active relay. Removing it will prevent message delivery to subscribers.</string>
<string name="block_subscriber_for_all_question">Block subscriber for all?</string>
<!-- AddChannelView.kt -->
@@ -3041,6 +3047,15 @@
<string name="your_profile_shared_with_channel_relays">Your profile %1$s will be shared with channel relays and subscribers.\nRelays can access channel messages.</string>
<string name="configure_relays">Configure relays</string>
<string name="relay_status_failed">failed</string>
<string name="add_button">Add</string>
<string name="add_relay_button">Add relay</string>
<string name="add_relays_title">Add relays</string>
<string name="no_available_relays">No available relays</string>
<string name="error_adding_relays">Error adding relays</string>
<string name="relays_added_format">Relays added: %1$s.</string>
<string name="select_relays">Select relays</string>
<string name="no_relays_selected">No relays selected</string>
<string name="num_relays_selected">%d relay(s) selected</string>
<string name="relay_connection_failed">Relay connection failed</string>
<string name="not_all_relays_connected">Not all relays connected</string>
<string name="wait_verb">Wait</string>