mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-06-05 10:12:59 +00:00
core, ui: relay management - add, remove relays, synchronization to relay list (#6917)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
@@ -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")
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+22
-6
@@ -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 }
|
||||
}
|
||||
|
||||
+237
@@ -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)
|
||||
}
|
||||
}
|
||||
+46
-4
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+79
-30
@@ -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 = {}) {
|
||||
|
||||
+85
-31
@@ -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),
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user