mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-05-10 17:18:31 +00:00
core, ui: relay management - add, remove relays, synchronization to relay list (#6917)
This commit is contained in:
@@ -73,6 +73,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
|
||||
case apiNewPublicGroup(userId: Int64, incognito: Bool, relayIds: [Int64], groupProfile: GroupProfile)
|
||||
case apiGetGroupRelays(groupId: Int64)
|
||||
case apiAddGroupRelays(groupId: Int64, relayIds: [Int64])
|
||||
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
|
||||
case apiJoinGroup(groupId: Int64)
|
||||
case apiAcceptMember(groupId: Int64, groupMemberId: Int64, memberRole: GroupMemberRole)
|
||||
@@ -275,6 +276,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
|
||||
case let .apiNewPublicGroup(userId, incognito, relayIds, groupProfile): return "/_public group \(userId) incognito=\(onOff(incognito)) \(relayIds.map(String.init).joined(separator: ",")) \(encodeJSON(groupProfile))"
|
||||
case let .apiGetGroupRelays(groupId): return "/_get relays #\(groupId)"
|
||||
case let .apiAddGroupRelays(groupId, relayIds): return "/_add relays #\(groupId) \(relayIds.map(String.init).joined(separator: ","))"
|
||||
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
|
||||
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
|
||||
case let .apiAcceptMember(groupId, groupMemberId, memberRole): return "/_accept member #\(groupId) \(groupMemberId) \(memberRole.rawValue)"
|
||||
@@ -468,6 +470,7 @@ enum ChatCommand: ChatCmdProtocol {
|
||||
case .apiNewGroup: return "apiNewGroup"
|
||||
case .apiNewPublicGroup: return "apiNewPublicGroup"
|
||||
case .apiGetGroupRelays: return "apiGetGroupRelays"
|
||||
case .apiAddGroupRelays: return "apiAddGroupRelays"
|
||||
case .apiAddMember: return "apiAddMember"
|
||||
case .apiJoinGroup: return "apiJoinGroup"
|
||||
case .apiAcceptMember: return "apiAcceptMember"
|
||||
@@ -944,6 +947,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
case publicGroupCreated(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay])
|
||||
case publicGroupCreationFailed(user: UserRef, addRelayResults: [AddRelayResult])
|
||||
case groupRelays(user: UserRef, groupInfo: GroupInfo, groupRelays: [GroupRelay])
|
||||
case groupRelaysAdded(user: UserRef, groupInfo: GroupInfo, groupLink: GroupLink, groupRelays: [GroupRelay])
|
||||
case groupRelaysAddFailed(user: UserRef, addRelayResults: [AddRelayResult])
|
||||
case sentGroupInvitation(user: UserRef, groupInfo: GroupInfo, contact: Contact, member: GroupMember)
|
||||
case userAcceptedGroupSent(user: UserRef, groupInfo: GroupInfo, hostContact: Contact?)
|
||||
case userDeletedMembers(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], withMessages: Bool)
|
||||
@@ -997,6 +1002,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
case .publicGroupCreated: "publicGroupCreated"
|
||||
case .publicGroupCreationFailed: "publicGroupCreationFailed"
|
||||
case .groupRelays: "groupRelays"
|
||||
case .groupRelaysAdded: "groupRelaysAdded"
|
||||
case .groupRelaysAddFailed: "groupRelaysAddFailed"
|
||||
case .sentGroupInvitation: "sentGroupInvitation"
|
||||
case .userAcceptedGroupSent: "userAcceptedGroupSent"
|
||||
case .userDeletedMembers: "userDeletedMembers"
|
||||
@@ -1046,6 +1053,8 @@ enum ChatResponse2: Decodable, ChatAPIResult {
|
||||
case let .publicGroupCreated(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)")
|
||||
case let .publicGroupCreationFailed(u, addRelayResults): return withUser(u, "addRelayResults: \(addRelayResults)")
|
||||
case let .groupRelays(u, groupInfo, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupRelays: \(groupRelays)")
|
||||
case let .groupRelaysAdded(u, groupInfo, groupLink, groupRelays): return withUser(u, "groupInfo: \(groupInfo)\ngroupLink: \(groupLink)\ngroupRelays: \(groupRelays)")
|
||||
case let .groupRelaysAddFailed(u, addRelayResults): return withUser(u, "addRelayResults: \(addRelayResults)")
|
||||
case let .sentGroupInvitation(u, groupInfo, contact, member): return withUser(u, "groupInfo: \(groupInfo)\ncontact: \(contact)\nmember: \(member)")
|
||||
case let .userAcceptedGroupSent(u, groupInfo, hostContact): return withUser(u, "groupInfo: \(groupInfo)\nhostContact: \(String(describing: hostContact))")
|
||||
case let .userDeletedMembers(u, groupInfo, members, withMessages): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\nwithMessages: \(withMessages)")
|
||||
|
||||
@@ -344,9 +344,12 @@ class ChannelRelaysModel: ObservableObject {
|
||||
}
|
||||
|
||||
func updateRelay(_ groupInfo: GroupInfo, _ relay: GroupRelay) {
|
||||
if groupId == groupInfo.groupId,
|
||||
let i = groupRelays.firstIndex(where: { $0.groupRelayId == relay.groupRelayId }) {
|
||||
groupRelays[i] = relay
|
||||
if groupId == groupInfo.groupId {
|
||||
if let i = groupRelays.firstIndex(where: { $0.groupRelayId == relay.groupRelayId }) {
|
||||
groupRelays[i] = relay
|
||||
} else {
|
||||
groupRelays.append(relay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1891,6 +1891,22 @@ func apiGetGroupRelays(_ groupId: Int64) async -> [GroupRelay] {
|
||||
return []
|
||||
}
|
||||
|
||||
enum AddGroupRelaysResult {
|
||||
case added(GroupInfo, GroupLink, [GroupRelay])
|
||||
case addFailed([AddRelayResult])
|
||||
}
|
||||
|
||||
func apiAddGroupRelays(_ groupId: Int64, relayIds: [Int64]) async throws -> AddGroupRelaysResult? {
|
||||
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiAddGroupRelays(groupId: groupId, relayIds: relayIds))
|
||||
switch r {
|
||||
case let .result(.groupRelaysAdded(_, groupInfo, groupLink, groupRelays)):
|
||||
return .added(groupInfo, groupLink, groupRelays)
|
||||
case let .result(.groupRelaysAddFailed(_, addRelayResults)):
|
||||
return .addFailed(addRelayResults)
|
||||
default: if let r { throw r.unexpected } else { return nil }
|
||||
}
|
||||
}
|
||||
|
||||
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
|
||||
let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
|
||||
if case let .sentGroupInvitation(_, _, _, member) = r { return member }
|
||||
|
||||
@@ -745,7 +745,7 @@ struct ChatView: View {
|
||||
ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
} else if groupInfo.membership.memberCurrent {
|
||||
Task {
|
||||
if let gInfo = await apiGetUpdatedGroupLinkData(groupInfo.groupId) {
|
||||
await MainActor.run {
|
||||
|
||||
@@ -395,13 +395,13 @@ struct ComposeView: View {
|
||||
if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
|
||||
![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) {
|
||||
if gInfo.membership.memberRole == .owner {
|
||||
if let s = ownerState, s.activeCount < s.relays.count {
|
||||
if let s = ownerState, s.relays.isEmpty || s.activeCount < s.relays.count {
|
||||
ownerChannelRelayBar(relays: s.relays, activeCount: s.activeCount, failedCount: s.failedCount, removedCount: s.removedCount)
|
||||
}
|
||||
} else {
|
||||
let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted()
|
||||
let relayMembers = chatModel.groupMembers
|
||||
.filter { $0.wrapped.memberRole == .relay }
|
||||
.filter { $0.wrapped.memberRole == .relay && ![.memRemoved, .memGroupDeleted].contains($0.wrapped.memberStatus) }
|
||||
.sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") }
|
||||
let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress
|
||||
let removedCount = relayMembers.filter { relayMemberRemoved($0.wrapped.memberStatus) }.count
|
||||
@@ -409,7 +409,7 @@ struct ComposeView: View {
|
||||
let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count
|
||||
let resolvedCount = connectedCount + removedCount + failedCount
|
||||
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
|
||||
if total > 0, removedCount + failedCount > 0 || resolvedCount < total {
|
||||
if total == 0 || removedCount + failedCount > 0 || resolvedCount < total {
|
||||
subscriberChannelRelayBar(
|
||||
hostnames: hostnames,
|
||||
relayMembers: relayMembers,
|
||||
@@ -735,9 +735,9 @@ struct ComposeView: View {
|
||||
gInfo.membership.memberRole == .owner,
|
||||
![.memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus)
|
||||
else { return nil }
|
||||
let relays = channelRelaysModel.groupId == gInfo.groupId
|
||||
? channelRelaysModel.groupRelays : []
|
||||
guard !relays.isEmpty else { return nil }
|
||||
guard channelRelaysModel.groupId == gInfo.groupId else { return nil }
|
||||
let relays = channelRelaysModel.groupRelays
|
||||
guard !relays.isEmpty else { return ([], 0, 0, 0, true) }
|
||||
let relayMembers = relays.map { relay in
|
||||
(relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped)
|
||||
}
|
||||
@@ -763,7 +763,11 @@ struct ComposeView: View {
|
||||
if !allBroken && activeCount + failedCount + removedCount < total {
|
||||
RelayProgressIndicator(active: activeCount, total: total)
|
||||
}
|
||||
if allBroken {
|
||||
if total == 0 {
|
||||
Text("No relays")
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.orange)
|
||||
} else if allBroken {
|
||||
if removedCount == total {
|
||||
Text("All relays removed")
|
||||
} else if failedCount == total {
|
||||
@@ -793,7 +797,7 @@ struct ComposeView: View {
|
||||
}
|
||||
if relayListExpanded {
|
||||
if allBroken {
|
||||
Text("Adding relays will be supported later.")
|
||||
Text("Add relays to restore message delivery.")
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
@@ -843,7 +847,11 @@ struct ComposeView: View {
|
||||
let allBroken = connectedCount == 0 && errorCount == total
|
||||
VStack(spacing: 0) {
|
||||
relayBarHeader {
|
||||
if allBroken {
|
||||
if total == 0 {
|
||||
Text("No relays")
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.foregroundColor(.orange)
|
||||
} else if allBroken {
|
||||
if removedCount == total {
|
||||
Text("All relays removed")
|
||||
} else if failedCount == total {
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//
|
||||
// AddGroupRelayView.swift
|
||||
// SimpleX (iOS)
|
||||
//
|
||||
// Created by simplex on 29.04.2026.
|
||||
// Copyright © 2026 SimpleX Chat. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import SimpleXChat
|
||||
|
||||
struct AddGroupRelayView: View {
|
||||
var groupInfo: GroupInfo
|
||||
var existingRelayIds: Set<Int64>
|
||||
var onRelayAdded: () -> Void
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State private var availableRelays: [(relayId: Int64, relay: UserChatRelay, operatorName: String?)] = []
|
||||
@State private var selectedRelayIds: Set<Int64> = []
|
||||
@State private var isLoading = true
|
||||
@State private var isAdding = false
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
List {
|
||||
if isLoading {
|
||||
Section {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
} else if availableRelays.isEmpty {
|
||||
Section {
|
||||
Text("No available relays")
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
}
|
||||
} else {
|
||||
Section {
|
||||
ForEach(availableRelays, id: \.relayId) { item in
|
||||
relayCheckRow(item.relayId, item.relay, operatorName: item.operatorName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.modifier(ThemedBackground(grouped: true))
|
||||
.navigationTitle("Add relays")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") { dismiss() }
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Add") { addSelectedRelays() }
|
||||
.disabled(selectedRelayIds.isEmpty || isAdding)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { await loadAvailableRelays() }
|
||||
}
|
||||
|
||||
private func relayCheckRow(_ relayId: Int64, _ relay: UserChatRelay, operatorName: String?) -> some View {
|
||||
let selected = selectedRelayIds.contains(relayId)
|
||||
return Button {
|
||||
if selected {
|
||||
selectedRelayIds.remove(relayId)
|
||||
} else {
|
||||
selectedRelayIds.insert(relayId)
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(chatRelayDisplayName(relay))
|
||||
.foregroundColor(theme.colors.onBackground)
|
||||
.lineLimit(1)
|
||||
if let opName = operatorName {
|
||||
Text(opName)
|
||||
.font(.caption)
|
||||
.foregroundColor(theme.colors.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
|
||||
.foregroundColor(selected ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadAvailableRelays() async {
|
||||
do {
|
||||
let servers = try await getUserServers()
|
||||
var relays: [(relayId: Int64, relay: UserChatRelay, operatorName: String?)] = []
|
||||
for op in servers {
|
||||
if let oper = op.operator, oper.enabled != true { continue }
|
||||
let opName: String? = op.operator?.operatorTag != nil ? op.operator?.tradeName : nil
|
||||
for relay in op.chatRelays {
|
||||
if relay.enabled && !relay.deleted,
|
||||
let relayId = relay.chatRelayId,
|
||||
!existingRelayIds.contains(relayId) {
|
||||
relays.append((relayId, relay, opName))
|
||||
}
|
||||
}
|
||||
}
|
||||
await MainActor.run {
|
||||
availableRelays = relays
|
||||
isLoading = false
|
||||
}
|
||||
} catch {
|
||||
logger.error("loadAvailableRelays error: \(responseError(error))")
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func addSelectedRelays() {
|
||||
let relayIds = Array(selectedRelayIds)
|
||||
guard !relayIds.isEmpty else { return }
|
||||
isAdding = true
|
||||
Task {
|
||||
do {
|
||||
guard let result = try await apiAddGroupRelays(groupInfo.groupId, relayIds: relayIds) else {
|
||||
await MainActor.run { isAdding = false }
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
isAdding = false
|
||||
switch result {
|
||||
case let .added(gInfo, _, relays):
|
||||
ChannelRelaysModel.shared.set(groupId: gInfo.groupId, groupRelays: relays)
|
||||
onRelayAdded()
|
||||
dismiss()
|
||||
case let .addFailed(results):
|
||||
let successIds = Set(results.filter { $0.relayError == nil }.compactMap { $0.relay.chatRelayId })
|
||||
if !successIds.isEmpty {
|
||||
selectedRelayIds.subtract(successIds)
|
||||
availableRelays.removeAll { successIds.contains($0.relayId) }
|
||||
onRelayAdded()
|
||||
}
|
||||
let errorLines = results.filter { $0.relayError != nil }
|
||||
.map { "\(chatRelayDisplayName($0.relay)): \($0.relayError.map { connErrorText($0) } ?? "")" }
|
||||
let successNames = results.filter { $0.relayError == nil }
|
||||
.map { chatRelayDisplayName($0.relay) }
|
||||
var msg = errorLines.joined(separator: "\n")
|
||||
if !successNames.isEmpty {
|
||||
msg += "\n" + String.localizedStringWithFormat(NSLocalizedString("Relays added: %@.", comment: "alert message"), successNames.joined(separator: ", "))
|
||||
}
|
||||
showAlert(
|
||||
NSLocalizedString("Error adding relays", comment: "alert title"),
|
||||
message: msg
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
isAdding = false
|
||||
showAlert(NSLocalizedString("Error adding relays", comment: "alert title"), message: responseError(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,24 +14,47 @@ struct ChannelRelaysView: View {
|
||||
var groupInfo: GroupInfo
|
||||
@EnvironmentObject var chatModel: ChatModel
|
||||
@EnvironmentObject var theme: AppTheme
|
||||
@State private var groupRelays: [GroupRelay] = []
|
||||
@ObservedObject private var channelRelaysModel = ChannelRelaysModel.shared
|
||||
@State private var showAddRelay = false
|
||||
|
||||
private var groupRelays: [GroupRelay] {
|
||||
channelRelaysModel.groupId == groupInfo.groupId ? channelRelaysModel.groupRelays : []
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
relaysList()
|
||||
if groupInfo.isOwner {
|
||||
Section {
|
||||
Button {
|
||||
showAddRelay = true
|
||||
} label: {
|
||||
Label("Add relay", systemImage: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showAddRelay) {
|
||||
let existingRelayIds = Set(groupRelays.filter { $0.relayStatus != .rsInactive }.compactMap { $0.userChatRelay.chatRelayId })
|
||||
AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) {
|
||||
Task { await chatModel.loadGroupMembers(groupInfo) }
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
Task {
|
||||
await chatModel.loadGroupMembers(groupInfo)
|
||||
if groupInfo.isOwner {
|
||||
groupRelays = await apiGetGroupRelays(groupInfo.groupId)
|
||||
let relays = await apiGetGroupRelays(groupInfo.groupId)
|
||||
await MainActor.run {
|
||||
ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func relaysList() -> some View {
|
||||
let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay }
|
||||
let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberStatus != .memRemoved && $0.wrapped.memberStatus != .memGroupDeleted }
|
||||
if relayMembers.isEmpty {
|
||||
Section {
|
||||
Text("No chat relays")
|
||||
@@ -40,7 +63,7 @@ struct ChannelRelaysView: View {
|
||||
} else {
|
||||
Section {
|
||||
ForEach(relayMembers) { member in
|
||||
NavigationLink {
|
||||
let link = NavigationLink {
|
||||
GroupMemberInfoView(
|
||||
groupInfo: groupInfo,
|
||||
chat: chat,
|
||||
@@ -55,6 +78,18 @@ struct ChannelRelaysView: View {
|
||||
: subscriberRelayStatusText(member.wrapped)
|
||||
relayMemberRow(member.wrapped, statusText: statusText)
|
||||
}
|
||||
if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) {
|
||||
link.swipeActions(edge: .trailing) {
|
||||
Button {
|
||||
showRemoveMemberAlert(groupInfo, member.wrapped)
|
||||
} label: {
|
||||
Label("Remove relay", systemImage: "trash")
|
||||
}
|
||||
.tint(.red)
|
||||
}
|
||||
} else {
|
||||
link
|
||||
}
|
||||
}
|
||||
} footer: {
|
||||
Text("Chat relays forward messages to channel subscribers.")
|
||||
|
||||
@@ -924,26 +924,54 @@ struct GroupChatInfoView: View {
|
||||
}
|
||||
|
||||
func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) {
|
||||
showAlert(
|
||||
groupInfo.useRelays
|
||||
? NSLocalizedString("Remove subscriber?", comment: "alert title")
|
||||
: NSLocalizedString("Remove member?", comment: "alert title"),
|
||||
message:
|
||||
groupInfo.useRelays
|
||||
? NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message")
|
||||
: groupInfo.businessChat == nil
|
||||
? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
|
||||
: NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
|
||||
},
|
||||
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
if mem.memberRole == .relay {
|
||||
let isLastActive = groupInfo.useRelays && mem.memberCurrent && {
|
||||
let activeRelays = ChatModel.shared.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberCurrent }
|
||||
return activeRelays.count <= 1
|
||||
}()
|
||||
showAlert(
|
||||
NSLocalizedString("Remove relay?", comment: "alert title"),
|
||||
message: isLastActive
|
||||
? NSLocalizedString("This is the last active relay. Removing it will prevent message delivery to subscribers.", comment: "alert message")
|
||||
: NSLocalizedString("Relay will be removed from channel - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
} else if groupInfo.useRelays {
|
||||
showAlert(
|
||||
NSLocalizedString("Remove subscriber?", comment: "alert title"),
|
||||
message: NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
|
||||
},
|
||||
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
} else {
|
||||
showAlert(
|
||||
NSLocalizedString("Remove member?", comment: "alert title"),
|
||||
message: groupInfo.businessChat == nil
|
||||
? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
|
||||
: NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
|
||||
actions: {[
|
||||
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
|
||||
},
|
||||
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
|
||||
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
|
||||
},
|
||||
cancelAlertAction
|
||||
]}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) {
|
||||
|
||||
@@ -641,13 +641,11 @@ struct GroupMemberInfoView: View {
|
||||
blockForAllButton(mem)
|
||||
}
|
||||
}
|
||||
// TODO [relays] removing relay should also remove its link from group link data;
|
||||
// TODO - removing last relay should be prohibited or show warning
|
||||
if canRemove && mem.memberRole != .relay {
|
||||
if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft {
|
||||
deleteMemberMessagesButton(mem)
|
||||
} else {
|
||||
if canRemove {
|
||||
if mem.memberStatus != .memRemoved && (mem.memberStatus != .memLeft || mem.memberRole == .relay) {
|
||||
removeMemberButton(mem)
|
||||
} else if mem.memberRole != .relay {
|
||||
deleteMemberMessagesButton(mem)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,7 +703,10 @@ struct GroupMemberInfoView: View {
|
||||
Button(role: .destructive) {
|
||||
showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss)
|
||||
} label: {
|
||||
Label(groupInfo.useRelays ? "Remove subscriber" : "Remove member", systemImage: "trash")
|
||||
let text = mem.memberRole == .relay ? "Remove relay"
|
||||
: groupInfo.useRelays ? "Remove subscriber"
|
||||
: "Remove member"
|
||||
Label(text, systemImage: "trash")
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +479,7 @@ func relayDisplayName(_ relay: GroupRelay) -> String {
|
||||
return "relay \(relay.groupRelayId)"
|
||||
}
|
||||
|
||||
private func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
|
||||
func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
|
||||
if !relay.displayName.isEmpty { return relay.displayName }
|
||||
return relay.address
|
||||
}
|
||||
@@ -487,7 +487,7 @@ private func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
|
||||
func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View {
|
||||
let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false
|
||||
let color: Color = connFailed || removed ? .red : (status == .rsActive ? .green : .yellow)
|
||||
let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : status.text
|
||||
let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : removed ? "removed" : status.text
|
||||
return HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(color)
|
||||
|
||||
@@ -169,6 +169,7 @@
|
||||
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
|
||||
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; };
|
||||
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; };
|
||||
6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7072F48D0000060512B /* AddGroupRelayView.swift */; };
|
||||
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
|
||||
64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; };
|
||||
@@ -546,6 +547,7 @@
|
||||
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
|
||||
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = "<group>"; };
|
||||
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = "<group>"; };
|
||||
6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = "<group>"; };
|
||||
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
|
||||
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
|
||||
64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = "<group>"; };
|
||||
@@ -1173,6 +1175,7 @@
|
||||
64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */,
|
||||
6495D7032F48CFC50060512B /* ChannelMembersView.swift */,
|
||||
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */,
|
||||
6495D7072F48D0000060512B /* AddGroupRelayView.swift */,
|
||||
);
|
||||
path = Group;
|
||||
sourceTree = "<group>";
|
||||
@@ -1633,6 +1636,7 @@
|
||||
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */,
|
||||
647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */,
|
||||
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */,
|
||||
6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */,
|
||||
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
|
||||
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
|
||||
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,6 +32,7 @@ This file is generated automatically.
|
||||
- [APINewGroup](#apinewgroup)
|
||||
- [APINewPublicGroup](#apinewpublicgroup)
|
||||
- [APIGetGroupRelays](#apigetgrouprelays)
|
||||
- [APIAddGroupRelays](#apiaddgrouprelays)
|
||||
- [APIUpdateGroupProfile](#apiupdategroupprofile)
|
||||
|
||||
[Group link commands](#group-link-commands)
|
||||
@@ -1034,6 +1035,51 @@ ChatCmdError: Command error (only used in WebSockets API).
|
||||
---
|
||||
|
||||
|
||||
### APIAddGroupRelays
|
||||
|
||||
Add relays to group.
|
||||
|
||||
*Network usage*: interactive.
|
||||
|
||||
**Parameters**:
|
||||
- groupId: int64
|
||||
- relayIds: [int64]
|
||||
|
||||
**Syntax**:
|
||||
|
||||
```
|
||||
/_add relays #<groupId> <relayIds[0]>[,<relayIds[1]>...]
|
||||
```
|
||||
|
||||
```javascript
|
||||
'/_add relays #' + groupId + ' ' + relayIds.join(',') // JavaScript
|
||||
```
|
||||
|
||||
```python
|
||||
'/_add relays #' + str(groupId) + ' ' + ','.join(map(str, relayIds)) # Python
|
||||
```
|
||||
|
||||
**Responses**:
|
||||
|
||||
GroupRelaysAdded: Group relays added.
|
||||
- type: "groupRelaysAdded"
|
||||
- user: [User](./TYPES.md#user)
|
||||
- groupInfo: [GroupInfo](./TYPES.md#groupinfo)
|
||||
- groupLink: [GroupLink](./TYPES.md#grouplink)
|
||||
- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)]
|
||||
|
||||
GroupRelaysAddFailed: Group relays add failed.
|
||||
- type: "groupRelaysAddFailed"
|
||||
- user: [User](./TYPES.md#user)
|
||||
- addRelayResults: [[AddRelayResult](./TYPES.md#addrelayresult)]
|
||||
|
||||
ChatCmdError: Command error (only used in WebSockets API).
|
||||
- type: "chatCmdError"
|
||||
- chatError: [ChatError](./TYPES.md#chaterror)
|
||||
|
||||
---
|
||||
|
||||
|
||||
### APIUpdateGroupProfile
|
||||
|
||||
Update group profile.
|
||||
|
||||
@@ -119,6 +119,7 @@ chatCommandsDocsData =
|
||||
("APINewGroup", [], "Create group.", ["CRGroupCreated", "CRChatCmdError"], [], Nothing, "/_group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Json "groupProfile"),
|
||||
("APINewPublicGroup", [], "Create public group.", ["CRPublicGroupCreated", "CRPublicGroupCreationFailed", "CRChatCmdError"], [], Just UNInteractive, "/_public group " <> Param "userId" <> OnOffParam "incognito" "incognito" (Just False) <> " " <> Join ',' "relayIds" <> " " <> Json "groupProfile"),
|
||||
("APIGetGroupRelays", [], "Get group relays.", ["CRGroupRelays", "CRChatCmdError"], [], Nothing, "/_get relays #" <> Param "groupId"),
|
||||
("APIAddGroupRelays", [], "Add relays to group.", ["CRGroupRelaysAdded", "CRGroupRelaysAddFailed", "CRChatCmdError"], [], Just UNInteractive, "/_add relays #" <> Param "groupId" <> " " <> Join ',' "relayIds"),
|
||||
("APIUpdateGroupProfile", [], "Update group profile.", ["CRGroupUpdated", "CRChatCmdError"], [], Just UNBackground, "/_group_profile #" <> Param "groupId" <> " " <> Json "groupProfile")
|
||||
]
|
||||
),
|
||||
|
||||
@@ -71,6 +71,8 @@ chatResponsesDocsData =
|
||||
("CRPublicGroupCreated", ""),
|
||||
("CRPublicGroupCreationFailed", ""),
|
||||
("CRGroupRelays", ""),
|
||||
("CRGroupRelaysAdded", ""),
|
||||
("CRGroupRelaysAddFailed", ""),
|
||||
("CRGroupMembers", ""),
|
||||
("CRGroupUpdated", ""),
|
||||
("CRGroupsList", "Groups"),
|
||||
|
||||
@@ -372,6 +372,21 @@ export namespace APIGetGroupRelays {
|
||||
}
|
||||
}
|
||||
|
||||
// Add relays to group.
|
||||
// Network usage: interactive.
|
||||
export interface APIAddGroupRelays {
|
||||
groupId: number // int64
|
||||
relayIds: number[] // int64, non-empty
|
||||
}
|
||||
|
||||
export namespace APIAddGroupRelays {
|
||||
export type Response = CR.GroupRelaysAdded | CR.GroupRelaysAddFailed | CR.ChatCmdError
|
||||
|
||||
export function cmdString(self: APIAddGroupRelays): string {
|
||||
return '/_add relays #' + self.groupId + ' ' + self.relayIds.join(',')
|
||||
}
|
||||
}
|
||||
|
||||
// Update group profile.
|
||||
// Network usage: background.
|
||||
export interface APIUpdateGroupProfile {
|
||||
|
||||
@@ -30,6 +30,8 @@ export type ChatResponse =
|
||||
| CR.PublicGroupCreated
|
||||
| CR.PublicGroupCreationFailed
|
||||
| CR.GroupRelays
|
||||
| CR.GroupRelaysAdded
|
||||
| CR.GroupRelaysAddFailed
|
||||
| CR.GroupMembers
|
||||
| CR.GroupUpdated
|
||||
| CR.GroupsList
|
||||
@@ -85,6 +87,8 @@ export namespace CR {
|
||||
| "publicGroupCreated"
|
||||
| "publicGroupCreationFailed"
|
||||
| "groupRelays"
|
||||
| "groupRelaysAdded"
|
||||
| "groupRelaysAddFailed"
|
||||
| "groupMembers"
|
||||
| "groupUpdated"
|
||||
| "groupsList"
|
||||
@@ -275,6 +279,20 @@ export namespace CR {
|
||||
groupRelays: T.GroupRelay[]
|
||||
}
|
||||
|
||||
export interface GroupRelaysAdded extends Interface {
|
||||
type: "groupRelaysAdded"
|
||||
user: T.User
|
||||
groupInfo: T.GroupInfo
|
||||
groupLink: T.GroupLink
|
||||
groupRelays: T.GroupRelay[]
|
||||
}
|
||||
|
||||
export interface GroupRelaysAddFailed extends Interface {
|
||||
type: "groupRelaysAddFailed"
|
||||
user: T.User
|
||||
addRelayResults: T.AddRelayResult[]
|
||||
}
|
||||
|
||||
export interface GroupMembers extends Interface {
|
||||
type: "groupMembers"
|
||||
user: T.User
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
# Relay Management Improvements
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Channel owners currently can only add relays during channel creation (`APINewPublicGroup`). Once a channel is created, there is no way to:
|
||||
1. Add a new relay to an existing channel.
|
||||
2. Remove a relay from an existing channel.
|
||||
3. Have relays and subscribers automatically detect and synchronize relay state changes.
|
||||
|
||||
Several TODO markers in the codebase (`[relays]`) confirm these are planned but unimplemented. The `runRelayGroupLinkChecks` function (Commands.hs:4729) is a stub. The LINK event handler (Subscriber.hs:1308-1309) has a TODO for relay deletion detection. No `APIAddGroupRelays` command exists.
|
||||
|
||||
## Solution Summary
|
||||
|
||||
### Add relay to existing channel
|
||||
|
||||
New `APIAddGroupRelays` command that reuses the existing `addRelays` function (Commands.hs:3887, in `processChatCommand`'s `where` block). The `addRelays` flow is asynchronous: after the invitation is sent (RSNew→RSInvited), the relay responds with its relay link (→RSAccepted), and the CON event handler (Subscriber.hs:861-864) calls `setGroupLinkDataAsync` to publish the new relay link. The LINK callback then promotes RSAccepted→RSActive.
|
||||
|
||||
### Remove relay from existing channel
|
||||
|
||||
Use the existing `APIRemoveMembers` command, extended with relay-specific handling. In channels, `APIRemoveMembers` already sends `XGrpMemDel` to all relay members via `sendGroupMessages` (the `memberSendAction` routing ensures the message goes to relays only, which forward it to subscribers). This is the correct approach: broadcasting the removal through *other* relays ensures all subscribers learn about the removal even if the removed relay is malicious and refuses to notify them. Link data synchronization serves as a backup mechanism.
|
||||
|
||||
The extension needed: when removing a relay member, also update its `GroupRelay.relay_status` to `RSInactive`. Currently `APIRemoveMembers` updates `GroupMember` status (via `deleteOrUpdateMemberRecordIO`) and calls `updatePublicGroupData` (which updates link data), but does not touch the `GroupRelay` record.
|
||||
|
||||
### State synchronization
|
||||
|
||||
Three actors synchronize via the group link data on the SMP server:
|
||||
|
||||
- **Owner**: publishes the authoritative relay list in link data via `setGroupLinkData`. The `getConnectedGroupRelays` function (which filters by `member_status = GSMemConnected AND relay_status IN (RSAccepted, RSActive)`) determines which relays appear in link data.
|
||||
- **Relay**: `runRelayGroupLinkChecks` (implement the stub) periodically fetches group link data to confirm its own link is present. If absent → self-cleanup.
|
||||
- **Subscriber**: when opening a channel, the UI already calls `APIGetUpdatedGroupLinkData` (Commands.hs:1777) which fetches link data from the SMP server. This handler will be extended to also synchronize relay state: connect to newly discovered relays, disconnect from removed relays.
|
||||
|
||||
---
|
||||
|
||||
## Detailed Technical Design
|
||||
|
||||
### 1. Relay Deactivation on Member Removal
|
||||
|
||||
**File**: `src/Simplex/Chat/Library/Internal.hs` (lines 1804-1821)
|
||||
|
||||
Two member-removal primitives exist: `deleteOrUpdateMemberRecordIO` (IO, line 1808) and `updateMemberRecordDeleted` (CM, line 1816). Both run in DB context. Relay deactivation belongs inside these functions so it runs in the same transaction as the member status change.
|
||||
|
||||
**New helper** in Internal.hs:
|
||||
|
||||
```haskell
|
||||
deactivateRelayIfNeeded :: DB.Connection -> GroupMember -> IO ()
|
||||
deactivateRelayIfNeeded db m =
|
||||
when (isRelay m) $ do
|
||||
relay_ <- runExceptT $ getGroupRelayByGMId db (groupMemberId' m)
|
||||
forM_ relay_ $ \relay -> void $ updateRelayStatus db relay RSInactive
|
||||
```
|
||||
|
||||
**Extend `deleteOrUpdateMemberRecordIO`** (line 1808):
|
||||
|
||||
```haskell
|
||||
deleteOrUpdateMemberRecordIO db user@User {userId} gInfo m = do
|
||||
(gInfo', m') <- deleteSupportChatIfExists db user gInfo m
|
||||
checkGroupMemberHasItems db user m' >>= \case
|
||||
Just _ -> updateGroupMemberStatus db userId m' GSMemRemoved
|
||||
Nothing -> deleteGroupMember db user m'
|
||||
deactivateRelayIfNeeded db m
|
||||
pure gInfo'
|
||||
```
|
||||
|
||||
**Extend `updateMemberRecordDeleted`** (line 1816):
|
||||
|
||||
```haskell
|
||||
updateMemberRecordDeleted user@User {userId} gInfo m newStatus =
|
||||
withStore' $ \db -> do
|
||||
(gInfo', m') <- deleteSupportChatIfExists db user gInfo m
|
||||
updateGroupMemberStatus db userId m' newStatus
|
||||
deactivateRelayIfNeeded db m
|
||||
pure gInfo'
|
||||
```
|
||||
|
||||
This covers all four call sites:
|
||||
- `delMember` in `deleteMemsSend` (Commands.hs:2896) — owner removing relay via `APIRemoveMembers`
|
||||
- `deleteOrUpdateMemberRecord` in `xGrpMemDel` (Subscriber.hs:3123) — receiving relay deletion notification
|
||||
- `updateMemberRecordDeleted` in `xGrpMemDel` (Subscriber.hs:3121) — relay deletion with forwarding
|
||||
- `updateMemberRecordDeleted` in `xGrpLeave` (Subscriber.hs:3168) — relay leaves voluntarily
|
||||
|
||||
For subscribers who have no `GroupRelay` records, `getGroupRelayByGMId` returns `Left`, `forM_` on `Left` is a no-op — safe.
|
||||
|
||||
**Cleanup**: remove the now-redundant separate relay deactivation in `xGrpLeave` (Subscriber.hs:3169-3172):
|
||||
|
||||
```haskell
|
||||
-- Before:
|
||||
gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft
|
||||
when (isRelay m) $
|
||||
withStore' $ \db -> do
|
||||
relay_ <- runExceptT $ getGroupRelayByGMId db (groupMemberId' m)
|
||||
forM_ relay_ $ \relay -> void $ updateRelayStatus db relay RSInactive
|
||||
gInfo'' <- updatePublicGroupData user gInfo'
|
||||
|
||||
-- After:
|
||||
gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft
|
||||
gInfo'' <- updatePublicGroupData user gInfo'
|
||||
```
|
||||
|
||||
**`APIRemoveMembers` requires no changes** — `delMember` (line 2891) already calls `deleteOrUpdateMemberRecordIO` which now handles relay deactivation internally. The `getConnectedGroupRelays` query filters by both `member_status = GSMemConnected` and `relay_status IN (RSAccepted, RSActive)`, so the removed relay is excluded from link data when `updatePublicGroupData` runs (line 2828-2829).
|
||||
|
||||
**iOS UI**: The remove button is currently hidden on the relay member info page by an explicit guard in `adminDestructiveSection` (GroupMemberInfoView.swift:646: `mem.memberRole != .relay`). Changes needed:
|
||||
|
||||
1. **Remove the relay guard** — change the condition to allow relay members to be removed. The `canBeRemoved()` permission check (ChatTypes.swift:2868) already validates that the user has sufficient role.
|
||||
|
||||
2. **Relay-specific button text** — the `removeMemberButton` (line 708) currently shows `"Remove subscriber"` for channels (`groupInfo.useRelays`). Add a relay branch: when `mem.memberRole == .relay`, show `"Remove relay"` instead.
|
||||
|
||||
3. **Relay-specific alert text** — `showRemoveMemberAlert` (GroupChatInfoView.swift:926) currently shows `"Remove subscriber?"` / `"Subscriber will be removed from channel"` for channels. Add a relay branch: `"Remove relay?"` / `"Relay will be removed from channel"`.
|
||||
|
||||
4. **Last active relay warning** — when removing a relay, check if it's the last active relay (count relay members with `memberCurrent` status in `chatModel.groupMembers`). If so, show a warning: `"This is the last active relay. Removing it will prevent message delivery to subscribers."` The count is available client-side from `chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberCurrent }`.
|
||||
|
||||
No new API command needed for removal — the existing `apiRemoveMembers` is used.
|
||||
|
||||
### 2. New `APIAddGroupRelays` Command
|
||||
|
||||
**File**: `src/Simplex/Chat/Controller.hs`
|
||||
|
||||
```haskell
|
||||
-- New command
|
||||
| APIAddGroupRelays GroupId (NonEmpty Int64) -- group ID, chat_relay_ids
|
||||
|
||||
-- New responses
|
||||
| CRGroupRelaysAdded { user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay] }
|
||||
| CRGroupRelaysAddFailed { user :: User, addRelayResults :: [AddRelayResult] }
|
||||
```
|
||||
|
||||
**File**: `src/Simplex/Chat/Library/Commands.hs`
|
||||
|
||||
New handler:
|
||||
|
||||
```
|
||||
APIAddGroupRelays groupId relayIds -> withUser $ \user -> withGroupLock "addGroupRelays" groupId $ do
|
||||
-- 1. Validate: user is owner, group uses relays
|
||||
gInfo <- withFastStore $ \db -> getGroupInfo db vr user groupId
|
||||
assertUserGroupRole gInfo GROwner
|
||||
unless (useRelays' gInfo) $ throwCmdError "group does not use relays"
|
||||
|
||||
-- 2. Get group link (needed for relay invitation)
|
||||
gLink <- withFastStore $ \db -> getGroupLink db user gInfo
|
||||
sLnk <- case connShortLink' (connLinkContact gLink) of
|
||||
Just sl -> pure sl
|
||||
Nothing -> throwChatError $ CEException "group link has no short link"
|
||||
|
||||
-- 3. Load requested relay configs
|
||||
relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds)
|
||||
|
||||
-- 4. Reuse existing addRelays function (Commands.hs:3887)
|
||||
results <- addRelays user gInfo sLnk relays
|
||||
|
||||
-- 5. Check results
|
||||
case partitionEithers (map snd results) of
|
||||
([], _) -> do
|
||||
-- Relay connection is asynchronous: invitation sent (RSNew→RSInvited).
|
||||
-- When relay responds (RSAccepted) and connects (CON at Subscriber.hs:861-864),
|
||||
-- setGroupLinkDataAsync is called automatically to add the relay link.
|
||||
-- The LINK callback then promotes RSAccepted→RSActive.
|
||||
relays' <- withFastStore $ \db -> liftIO $ getGroupRelays db gInfo
|
||||
pure $ CRGroupRelaysAdded user gInfo gLink relays'
|
||||
_ -> do
|
||||
let toRelayResult (r, Left e) = AddRelayResult r (Just e)
|
||||
toRelayResult (r, Right _) = AddRelayResult r Nothing
|
||||
pure $ CRGroupRelaysAddFailed user (map toRelayResult results)
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Uses `withGroupLock` to prevent concurrent relay modifications.
|
||||
- Reuses `addRelays` unchanged — it handles the full invitation flow (create relay member, create GroupRelay record, send `XGrpRelayInv`, update status RSNew→RSInvited).
|
||||
- No synchronous `setGroupLinkData` call needed: the CON event handler calls `setGroupLinkDataAsync` when the relay connects.
|
||||
|
||||
### 3. Extend `APIGetUpdatedGroupLinkData` for Subscriber Relay Sync
|
||||
|
||||
**File**: `src/Simplex/Chat/Library/Commands.hs` (lines 1777-1787)
|
||||
|
||||
Currently this handler fetches link data from the SMP server and updates group profile and member count. It is called by the iOS UI when a non-owner subscriber opens a channel (ChatView.swift:750). The `ConnLinkData` it receives already contains the relay list in `UserContactData.relays`.
|
||||
|
||||
Extend the handler to also synchronize relay state:
|
||||
|
||||
```
|
||||
APIGetUpdatedGroupLinkData groupId -> withUser $ \user -> do
|
||||
gInfo@GroupInfo {groupProfile = p} <- withFastStore $ \db -> getGroupInfo db vr user groupId
|
||||
case p of
|
||||
GroupProfile {publicGroup = Just PublicGroupProfile {groupLink = sLnk}} | useRelays' gInfo -> do
|
||||
(_, cData@(ContactLinkData _ UserContactData {relays = currentRelayLinks})) <-
|
||||
getShortLinkConnReq nm user sLnk
|
||||
groupSLinkData_ <- liftIO $ decodeLinkUserData cData
|
||||
gInfo' <- case groupSLinkData_ of
|
||||
Just sLinkData -> fst <$> updateGroupFromLinkData user gInfo sLinkData
|
||||
_ -> pure gInfo
|
||||
-- Sync relay state for non-owner subscribers
|
||||
when (memberRole' (membership gInfo) /= GROwner) $
|
||||
syncSubscriberRelays nm user gInfo' currentRelayLinks
|
||||
pure $ CRGroupInfo user gInfo'
|
||||
_ -> throwCmdError "group link data not available"
|
||||
```
|
||||
|
||||
**Parameterize `connectToRelay`** — move from `APIConnectPreparedGroup`'s `where` block to `processChatCommand`'s `where` block so both `APIConnectPreparedGroup` and subscriber sync can use it. The captured closure variables become explicit parameters or are derived internally:
|
||||
|
||||
```
|
||||
-- In processChatCommand's where block (for connectViaContact access).
|
||||
-- connectViaContact ignores incognito param for relay groups (Commands.hs:3545-3546),
|
||||
-- using incognitoMembershipProfile gInfo instead.
|
||||
connectToRelay :: User -> GroupInfo -> ShortLinkContact -> CM (ShortLinkContact, GroupMember, Either ChatError ())
|
||||
connectToRelay user gInfo relayLink = do
|
||||
vr <- chatVersionRange
|
||||
gVar <- asks random
|
||||
relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo relayLink
|
||||
r <- tryAllErrors $ do
|
||||
(fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <-
|
||||
getShortLinkConnReq nm user relayLink
|
||||
relayLinkData_ <- liftIO $ decodeLinkUserData cData
|
||||
case (relayLinkData_, linkEntityId) of
|
||||
(Just RelayShortLinkData {relayProfile = p}, Just entityId) ->
|
||||
withFastStore $ \db -> updateRelayMemberData db user relayMember (MemberId entityId) (MemberKey relayKey) p
|
||||
_ -> throwChatError $ CEException "relay link: no relay link data or entity id"
|
||||
let cReq = linkConnReq fd
|
||||
relayLinkToConnect = CCLink cReq (Just relayLink)
|
||||
void $ connectViaContact user (Just $ PCEGroup gInfo relayMember) False relayLinkToConnect Nothing Nothing
|
||||
relayMember' <- withFastStore $ \db -> getGroupMember db vr user (groupId' gInfo) (groupMemberId' relayMember)
|
||||
pure (relayLink, relayMember', r)
|
||||
```
|
||||
|
||||
`getCreateRelayForMember` stays outside `tryAllErrors` — the member must be available for re-read even on failure (for `RelayConnectionResult` reporting). `APIConnectPreparedGroup` calls `mapConcurrently (connectToRelay user gInfo') relays` as before.
|
||||
|
||||
**New function** `syncSubscriberRelays` in `processChatCommand`'s scope (reuses `connectToRelay`):
|
||||
|
||||
```
|
||||
syncSubscriberRelays :: NetworkRequestMode -> User -> GroupInfo -> [ShortLinkContact] -> CM ()
|
||||
syncSubscriberRelays nm user gInfo currentRelayLinks = tryAllErrors $ do
|
||||
vr <- chatVersionRange
|
||||
-- Get local relay members (all members with GRRelay role, regardless of status)
|
||||
localRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo
|
||||
-- GroupMember.relayLink :: Maybe ShortLinkContact (Types.hs:1041)
|
||||
-- Set by getCreateRelayForMember (Store/Groups.hs:1392) when subscriber connects to a relay.
|
||||
let activeRelayMembers = filter memberCurrent localRelayMembers
|
||||
localRelayLinks = mapMaybe relayLink activeRelayMembers
|
||||
|
||||
-- Discover new relays (in link data but not among active local relay members)
|
||||
let newRelayLinks = filter (`notElem` localRelayLinks) currentRelayLinks
|
||||
forM_ newRelayLinks $ \rlnk -> tryAllErrors $
|
||||
void $ connectToRelay user gInfo rlnk
|
||||
|
||||
-- Discover removed relays (active local relay member whose link is absent from link data)
|
||||
forM_ activeRelayMembers $ \m ->
|
||||
case relayLink m of
|
||||
Just rlnk | rlnk `notElem` currentRelayLinks ->
|
||||
tryAllErrors $ do
|
||||
deleteMemberConnection m
|
||||
void $ updateMemberRecordDeleted user gInfo m GSMemRemoved
|
||||
_ -> pure ()
|
||||
```
|
||||
|
||||
**Note on `getCreateRelayForMember` idempotency**: This function queries `WHERE m.relay_link = ?` without filtering by member status (Store/Groups.hs:1379). If a relay was previously removed (GSMemRemoved) and is later re-added by the owner, `getCreateRelayForMember` will return the old removed member. During implementation, verify whether the member status needs to be reset before reconnecting, or whether `connectViaContact` handles this correctly.
|
||||
|
||||
### 4. LINK Event Handler — Detect Relay Removal (Owner)
|
||||
|
||||
**File**: `src/Simplex/Chat/Library/Subscriber.hs` (lines 1308-1317)
|
||||
|
||||
Replace the TODO with relay removal detection. The LINK callback fires when this owner updates link data (via `setGroupLinkData` / `setConnShortLink`). Currently multi-owner channels are not supported, so this only fires after the same owner's own actions (add/remove relay, profile update). When multi-owner support is added, another owner's link data update on the SMP server would need a separate mechanism (e.g., periodic link data fetch or subscription) for this owner to learn about it — the LINK callback only fires in response to this client's own `setConnShortLink` calls.
|
||||
|
||||
```haskell
|
||||
updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool) -> IO ([GroupRelay], Bool)
|
||||
updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed) =
|
||||
case relayLink of
|
||||
Just rLink
|
||||
| rLink `elem` relayLinks && relayStatus == RSAccepted -> do
|
||||
-- Relay link present in link data, promote to active
|
||||
relay' <- updateRelayStatus db relay RSActive
|
||||
pure (relay' : acc, True)
|
||||
| rLink `elem` relayLinks -> pure (relay : acc, changed)
|
||||
| relayStatus `elem` [RSAccepted, RSActive, RSInactive] -> do
|
||||
-- Relay link ABSENT from link data — set to inactive.
|
||||
-- TODO [multi-owner] When multi-owner channels are supported, another owner removing
|
||||
-- a relay updates link data on the SMP server, but this owner won't receive a LINK
|
||||
-- callback for it (LINK only fires in response to own setConnShortLink calls).
|
||||
-- A separate mechanism will be needed for cross-owner relay state synchronization.
|
||||
relay' <- updateRelayStatus db relay RSInactive
|
||||
pure (relay' : acc, True)
|
||||
_ -> pure (relay : acc, changed)
|
||||
```
|
||||
|
||||
After the same owner's `APIRemoveMembers` call, the relay is already `RSInactive` before `updatePublicGroupData` triggers the LINK callback. The guard matches `RSInactive` but `updateRelayStatus` is idempotent (RSInactive→RSInactive is a no-op write).
|
||||
|
||||
### 5. Relay Self-Check (`runRelayGroupLinkChecks`)
|
||||
|
||||
**File**: `src/Simplex/Chat/Library/Commands.hs` (lines 4729-4735)
|
||||
|
||||
Implement the stub. The existing `startRelayChecks` (Commands.hs:225-233) already launches `runRelayGroupLinkChecks` as an async task via `relayGroupLinkChecksAsync`. The stub currently does `pure ()` and exits immediately. Replace with a periodic loop following the `cleanupManager` pattern (Commands.hs:4643):
|
||||
|
||||
```
|
||||
runRelayGroupLinkChecks :: User -> CM ()
|
||||
runRelayGroupLinkChecks user = do
|
||||
initialDelay <- asks (initialCleanupManagerDelay . config)
|
||||
liftIO $ threadDelay' initialDelay
|
||||
interval <- asks (cleanupManagerInterval . config) -- or a dedicated config field
|
||||
forever $ do
|
||||
flip catchAllErrors eToView $ do
|
||||
lift waitChatStartedAndActivated
|
||||
checkRelayGroups
|
||||
liftIO $ threadDelay' $ diffToMicroseconds interval
|
||||
where
|
||||
checkRelayGroups = do
|
||||
vr <- chatVersionRange
|
||||
-- Get all groups where this client is a relay (relay_own_status is set and not RSInactive)
|
||||
relayGroups <- withFastStore' $ \db -> getRelayOwnGroups db vr user
|
||||
forM_ relayGroups $ \gInfo -> tryAllErrors $ do
|
||||
case publicGroup (groupProfile gInfo) of
|
||||
Just PublicGroupProfile {groupLink = sLnk} -> do
|
||||
-- getShortLinkConnReq' returns (FixedLinkData, ConnLinkData m).
|
||||
-- ConnLinkData 'CMContact = ContactLinkData VersionRangeSMPA UserContactData
|
||||
-- (NOT UserContactLinkData which is for the LINK event's auData)
|
||||
(_, ContactLinkData _ UserContactData {relays = relayLinks}) <-
|
||||
getShortLinkConnReq' NRMBackground user sLnk
|
||||
-- Check if our own relay link is present
|
||||
gLink_ <- withFastStore' $ \db -> runExceptT $ getGroupLink db user gInfo
|
||||
case gLink_ of
|
||||
Right GroupLink {connLinkContact = CCLink _ (Just ourLink)} ->
|
||||
if ourLink `elem` relayLinks
|
||||
then do
|
||||
-- Our link is present — promote to RSActive if still RSAccepted
|
||||
gInfo' <- withFastStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSAccepted RSActive
|
||||
when (relayOwnStatus gInfo' /= relayOwnStatus gInfo) $
|
||||
toView $ CEvtGroupRelayUpdated user gInfo' (membership gInfo')
|
||||
else do
|
||||
-- Our link is ABSENT — we have been removed
|
||||
withFastStore' $ \db -> updateRelayOwnStatus_ db gInfo RSInactive
|
||||
-- Per RFC: relay should forward "relay is deleted" notification to
|
||||
-- connected members, then clean up. The x.grp.mem.del from owner
|
||||
-- may also arrive and trigger cleanup independently.
|
||||
_ -> pure ()
|
||||
_ -> pure ()
|
||||
```
|
||||
|
||||
**New store function** in `Store/Groups.hs`:
|
||||
|
||||
```haskell
|
||||
getRelayOwnGroups :: DB.Connection -> VersionRangeChat -> User -> IO [GroupInfo]
|
||||
-- SELECT groups WHERE relay_own_status IS NOT NULL AND relay_own_status != 'inactive'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State Synchronization Summary
|
||||
|
||||
```
|
||||
SMP Server (group link data)
|
||||
┌──────────────────────────────┐
|
||||
│ UserContactData { │
|
||||
│ relays: [relay1, relay2] │
|
||||
│ } │
|
||||
└──────────┬───────────────────┘
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
│ writes │ reads │ reads
|
||||
▼ ▼ ▼
|
||||
Owner Relay (self) Subscriber
|
||||
setGroupLinkData runRelayGroup syncSubscriber
|
||||
(via updatePublic LinkChecks Relays (in
|
||||
GroupData) APIGetUpdated
|
||||
GroupLinkData)
|
||||
Triggers: Triggers: Triggers:
|
||||
- Add relay - Periodic check - Opening channel
|
||||
- Remove member (existing UI flow)
|
||||
- Profile update
|
||||
```
|
||||
|
||||
**Owner writes** → SMP server is updated → **Relays and Subscribers read** → discover changes → adjust local state.
|
||||
|
||||
**Key design principle**: The `XGrpMemDel` message broadcast through other relays is the primary notification mechanism for relay removal. Subscribers receive it promptly via their connected relays. Link data synchronization via `APIGetUpdatedGroupLinkData` is the backup mechanism — it catches cases where the `XGrpMemDel` was missed (subscriber offline, relay connection issues) and handles new relay discovery.
|
||||
|
||||
---
|
||||
|
||||
## Edge Cases and Failure Recovery
|
||||
|
||||
1. **Add relay fails (network)**: `addRelays` handles temporary errors. The relay remains in RSInvited; owner can retry or the relay will process the pending invitation when it comes online.
|
||||
|
||||
2. **Removed relay is malicious / refuses to notify subscribers**: Not a problem. `APIRemoveMembers` sends `XGrpMemDel` to all relay members. Other (non-malicious) relays forward it to subscribers. Subscribers learn about the removal regardless of the removed relay's behavior.
|
||||
|
||||
3. **Remove relay, all relays offline**: `XGrpMemDel` is queued for delivery. Link data is still updated. Subscribers will discover the change via `APIGetUpdatedGroupLinkData` next time they open the channel.
|
||||
|
||||
4. **Owner removes last relay**: Subscribers lose message delivery. Owner must add a new relay. Subscribers will discover the new relay via `syncSubscriberRelays` when they next open the channel.
|
||||
|
||||
5. **Relay goes offline permanently**: Owner removes it via `APIRemoveMembers`. New subscribers won't see it in link data. Existing subscribers with connections to this relay will experience connection failures. On next channel open, `syncSubscriberRelays` discovers the relay link is gone and marks it removed locally.
|
||||
|
||||
6. **Subscriber discovers new relay via link data**: `syncSubscriberRelays` calls `connectToRelay` (same function used by `APIConnectPreparedGroup`).
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Relay deactivation in member-removal primitives** — add `deactivateRelayIfNeeded` helper to `deleteOrUpdateMemberRecordIO` and `updateMemberRecordDeleted` in Internal.hs; remove redundant code from `xGrpLeave`.
|
||||
2. **LINK handler relay-removal detection** — implement the TODO in Subscriber.hs to detect absent relay links.
|
||||
3. **`APIAddGroupRelays`** — new command, reuses `addRelays`.
|
||||
4. **`runRelayGroupLinkChecks`** — relay self-check implementation.
|
||||
5. **Extend `APIGetUpdatedGroupLinkData`** — add `syncSubscriberRelays` for subscriber relay synchronization.
|
||||
6. **iOS UI** — ChannelRelaysView add/remove buttons, AddGroupRelayView sheet, API functions.
|
||||
|
||||
## Files Changed (Backend)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/Simplex/Chat/Controller.hs` | Add `APIAddGroupRelays` command; add `CRGroupRelaysAdded`, `CRGroupRelaysAddFailed` responses |
|
||||
| `src/Simplex/Chat/Library/Internal.hs` | Add `deactivateRelayIfNeeded` helper; extend `deleteOrUpdateMemberRecordIO` and `updateMemberRecordDeleted` to call it |
|
||||
| `src/Simplex/Chat/Library/Commands.hs` | Parameterize and move `connectToRelay` to `processChatCommand` scope; implement `APIAddGroupRelays` handler; implement `runRelayGroupLinkChecks`; extend `APIGetUpdatedGroupLinkData`; add `syncSubscriberRelays` (all in `processChatCommand` scope for `connectViaContact` access) |
|
||||
| `src/Simplex/Chat/Library/Subscriber.hs` | Fix LINK handler relay removal detection; remove redundant relay deactivation from `xGrpLeave` |
|
||||
| `src/Simplex/Chat/Store/Groups.hs` | Add `getRelayOwnGroups` |
|
||||
|
||||
## Files Changed (iOS)
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/ios/Shared/Model/AppAPITypes.swift` | Add `APIAddGroupRelays` command, `CRGroupRelaysAdded`/`CRGroupRelaysAddFailed` responses |
|
||||
| `apps/ios/Shared/Model/SimpleXAPI.swift` | Add `apiAddGroupRelays` function |
|
||||
| `apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift` | Remove `.relay` guard from `adminDestructiveSection` (line 646); add relay-specific button/alert text; add last-active-relay warning |
|
||||
| `apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift` | Add relay branch to `showRemoveMemberAlert` text |
|
||||
| `apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift` | Add relay button |
|
||||
| `apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift` | NEW: relay selection sheet |
|
||||
@@ -131,6 +131,7 @@ library
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at
|
||||
else
|
||||
exposed-modules:
|
||||
Simplex.Chat.Archive
|
||||
@@ -284,6 +285,7 @@ library
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at
|
||||
other-modules:
|
||||
Paths_simplex_chat
|
||||
hs-source-dirs:
|
||||
|
||||
@@ -117,6 +117,9 @@ defaultChatConfig =
|
||||
deliveryWorkerDelay = 0,
|
||||
deliveryBucketSize = 10000,
|
||||
channelSubscriberRole = GRObserver,
|
||||
relayChecksInitialDelay = 30 * 1000000, -- 30 seconds
|
||||
relayChecksInterval = 30 * 60, -- 30 minutes
|
||||
relayInactiveTTL = nominalDay,
|
||||
relayRequestRetryInterval = RetryInterval {initialInterval = 5_000000, increaseAfter = 0, maxInterval = 600_000000},
|
||||
relayRequestExpiry = (10, nominalDay),
|
||||
deviceNameForRemote = "",
|
||||
|
||||
@@ -159,6 +159,9 @@ data ChatConfig = ChatConfig
|
||||
deliveryWorkerDelay :: Int64, -- microseconds
|
||||
deliveryBucketSize :: Int,
|
||||
channelSubscriberRole :: GroupMemberRole, -- TODO [relays] starting role should be communicated in protocol from owner to relays
|
||||
relayChecksInitialDelay :: Int64,
|
||||
relayChecksInterval :: NominalDiffTime,
|
||||
relayInactiveTTL :: NominalDiffTime,
|
||||
relayRequestRetryInterval :: RetryInterval,
|
||||
relayRequestExpiry :: (Int, NominalDiffTime),
|
||||
highlyAvailable :: Bool,
|
||||
@@ -521,6 +524,7 @@ data ChatCommand
|
||||
-- TODO [relays] starting role should be communicated in protocol from owner to relays (see channelSubscriberRole config)
|
||||
| APINewPublicGroup {userId :: UserId, incognito :: IncognitoEnabled, relayIds :: NonEmpty Int64, groupProfile :: GroupProfile}
|
||||
| APIGetGroupRelays {groupId :: GroupId}
|
||||
| APIAddGroupRelays {groupId :: GroupId, relayIds :: NonEmpty Int64}
|
||||
| NewPublicGroup IncognitoEnabled (NonEmpty Int64) GroupProfile
|
||||
| AddMember GroupName ContactName GroupMemberRole
|
||||
| JoinGroup {groupName :: GroupName, enableNtfs :: MsgFilter}
|
||||
@@ -732,6 +736,8 @@ data ChatResponse
|
||||
| CRPublicGroupCreated {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]}
|
||||
| CRPublicGroupCreationFailed {user :: User, addRelayResults :: [AddRelayResult]}
|
||||
| CRGroupRelays {user :: User, groupInfo :: GroupInfo, groupRelays :: [GroupRelay]}
|
||||
| CRGroupRelaysAdded {user :: User, groupInfo :: GroupInfo, groupLink :: GroupLink, groupRelays :: [GroupRelay]}
|
||||
| CRGroupRelaysAddFailed {user :: User, addRelayResults :: [AddRelayResult]}
|
||||
| CRGroupMembers {user :: User, group :: Group}
|
||||
| CRMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]}
|
||||
-- | CRGroupConversationsArchived {user :: User, groupInfo :: GroupInfo, archivedGroupConversations :: [GroupConversation]}
|
||||
|
||||
@@ -1778,11 +1778,14 @@ processChatCommand vr nm = \case
|
||||
gInfo@GroupInfo {groupProfile = p, groupSummary = GroupSummary {publicMemberCount = localCount}} <- withFastStore $ \db -> getGroupInfo db vr user groupId
|
||||
case p of
|
||||
GroupProfile {publicGroup = Just PublicGroupProfile {groupLink = sLnk}} | useRelays' gInfo -> do
|
||||
(_, cData) <- getShortLinkConnReq nm user sLnk
|
||||
(_, cData@(ContactLinkData _ UserContactData {relays = currentRelayLinks})) <- getShortLinkConnReq' nm user sLnk
|
||||
groupSLinkData_ <- liftIO $ decodeLinkUserData cData
|
||||
gInfo' <- case groupSLinkData_ of
|
||||
Just sLinkData -> fst <$> updateGroupFromLinkData user gInfo sLinkData
|
||||
_ -> pure gInfo
|
||||
when (memberRole' (membership gInfo) /= GROwner && memberCurrent (membership gInfo)) $
|
||||
withGroupLock "syncSubscriberRelays" groupId $
|
||||
syncSubscriberRelays user gInfo' currentRelayLinks
|
||||
pure $ CRGroupInfo user gInfo'
|
||||
_ -> throwCmdError "group link data not available"
|
||||
APIGroupMemberInfo gId gMemberId -> withUser $ \user -> do
|
||||
@@ -2135,7 +2138,7 @@ processChatCommand vr nm = \case
|
||||
_ -> Nothing
|
||||
void $ createLinkOwnerMember db vr user gInfo' ctId_ (MemberId ownerId) ownerKey
|
||||
pure gInfo'
|
||||
rs <- mapConcurrently (connectToRelay gInfo') relays
|
||||
rs <- mapConcurrently (connectToRelay user gInfo') relays
|
||||
let relayFailed = \case (_, _, Left _) -> True; _ -> False
|
||||
(failed, succeeded) = partition relayFailed rs
|
||||
if null succeeded
|
||||
@@ -2162,23 +2165,6 @@ processChatCommand vr nm = \case
|
||||
isTempErr = \case
|
||||
(_, _, Left ChatErrorAgent {agentError = e}) -> temporaryOrHostError e
|
||||
_ -> False
|
||||
connectToRelay gInfo' relayLink = do
|
||||
gVar <- asks random
|
||||
-- Save relayLink to re-use relay member record on retry (check by relayLink)
|
||||
relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo' relayLink
|
||||
r <- tryAllErrors $ do
|
||||
(fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <- getShortLinkConnReq nm user relayLink
|
||||
relayLinkData_ <- liftIO $ decodeLinkUserData cData
|
||||
case (relayLinkData_, linkEntityId) of
|
||||
(Just RelayShortLinkData {relayProfile = p}, Just entityId) ->
|
||||
withFastStore $ \db -> updateRelayMemberData db user relayMember (MemberId entityId) (MemberKey relayKey) p
|
||||
_ -> throwChatError $ CEException "relay link: no relay link data or entity id"
|
||||
let cReq = linkConnReq fd
|
||||
relayLinkToConnect = CCLink cReq (Just relayLink)
|
||||
void $ connectViaContact user (Just $ PCEGroup gInfo' relayMember) incognito relayLinkToConnect Nothing Nothing
|
||||
-- Re-read member to get updated activeConn and updated data (from updateRelayMemberData)
|
||||
relayMember' <- withFastStore $ \db -> getGroupMember db vr user groupId (groupMemberId' relayMember)
|
||||
pure (relayLink, relayMember', r)
|
||||
retryRelayConnectionAsync gInfo' relayLink relayMember@GroupMember {activeConn} = do
|
||||
forM_ activeConn $ \conn -> do
|
||||
deleteAgentConnectionAsync $ aConnId conn
|
||||
@@ -2547,6 +2533,37 @@ processChatCommand vr nm = \case
|
||||
relays <- liftIO $ getGroupRelays db gInfo
|
||||
pure (gInfo, relays)
|
||||
pure $ CRGroupRelays user gInfo relays
|
||||
APIAddGroupRelays groupId relayIds -> withUser $ \user -> withGroupLock "addGroupRelays" groupId $ do
|
||||
(gInfo, existingRelays) <- withFastStore $ \db -> do
|
||||
gi <- getGroupInfo db vr user groupId
|
||||
rs <- liftIO $ getGroupRelays db gi
|
||||
pure (gi, rs)
|
||||
assertUserGroupRole gInfo GROwner
|
||||
unless (useRelays' gInfo) $ throwCmdError "group does not use relays"
|
||||
let existingRelayIds = map (\GroupRelay {userChatRelay = UserChatRelay {chatRelayId = DBEntityId rId}} -> rId) existingRelays
|
||||
when (any (`elem` existingRelayIds) relayIds) $ throwCmdError "some relays are already in the group"
|
||||
gLink@GroupLink {connLinkContact = ccLink} <- withFastStore $ \db -> getGroupLink db user gInfo
|
||||
sLnk <- case connShortLink' ccLink of
|
||||
Just sl -> pure sl
|
||||
Nothing -> throwChatError $ CEException "group link has no short link"
|
||||
relays <- withFastStore $ \db -> mapM (getChatRelayById db user) (L.toList relayIds)
|
||||
results <- addRelays user gInfo sLnk relays
|
||||
case partitionEithers (map snd results) of
|
||||
([], _) -> do
|
||||
relays' <- withFastStore $ \db -> liftIO $ getGroupRelays db gInfo
|
||||
pure $ CRGroupRelaysAdded user gInfo gLink relays'
|
||||
(errors@(e : _), _) -> do
|
||||
if all isTempErr errors
|
||||
then throwError e
|
||||
else do
|
||||
let toRelayResult (r, Left e') = AddRelayResult r (Just e')
|
||||
toRelayResult (r, Right _) = AddRelayResult r Nothing
|
||||
pure $ CRGroupRelaysAddFailed user (map toRelayResult results)
|
||||
where
|
||||
isTempErr :: ChatError -> Bool
|
||||
isTempErr = \case
|
||||
ChatErrorAgent {agentError = e} -> temporaryOrHostError e
|
||||
_ -> False
|
||||
APIAddMember groupId contactId memRole -> withUser $ \user -> withGroupLock "addMember" groupId $ do
|
||||
-- TODO for large groups: no need to load all members to determine if contact is a member
|
||||
(group, contact) <- withFastStore $ \db -> (,) <$> getGroup db vr user groupId <*> getContact db vr user contactId
|
||||
@@ -3577,6 +3594,44 @@ processChatCommand vr nm = \case
|
||||
ct' <- withStore $ \db -> getContact db vr user contactId
|
||||
pure $ CRSentInvitationToContact user ct' incognitoProfile
|
||||
_ -> throwCmdError "contact already has connection"
|
||||
connectToRelay :: User -> GroupInfo -> ShortLinkContact -> CM (ShortLinkContact, GroupMember, Either ChatError ())
|
||||
connectToRelay user gInfo relayLink = do
|
||||
gVar <- asks random
|
||||
-- Save relayLink to re-use relay member record on retry (check by relayLink)
|
||||
relayMember <- withFastStore $ \db -> getCreateRelayForMember db vr gVar user gInfo relayLink
|
||||
r <- tryAllErrors $ do
|
||||
(fd@FixedLinkData {rootKey = relayKey, linkEntityId}, cData) <- getShortLinkConnReq nm user relayLink
|
||||
relayLinkData_ <- liftIO $ decodeLinkUserData cData
|
||||
case (relayLinkData_, linkEntityId) of
|
||||
(Just RelayShortLinkData {relayProfile = p}, Just entityId) ->
|
||||
withFastStore $ \db -> updateRelayMemberData db user relayMember (MemberId entityId) (MemberKey relayKey) p
|
||||
_ -> throwChatError $ CEException "relay link: no relay link data or entity id"
|
||||
let cReq = linkConnReq fd
|
||||
relayLinkToConnect = CCLink cReq (Just relayLink)
|
||||
void $ connectViaContact user (Just $ PCEGroup gInfo relayMember) (incognitoMembership gInfo) relayLinkToConnect Nothing Nothing
|
||||
relayMember' <- withFastStore $ \db -> getGroupMember db vr user (groupId' gInfo) (groupMemberId' relayMember)
|
||||
pure (relayLink, relayMember', r)
|
||||
syncSubscriberRelays :: User -> GroupInfo -> [ShortLinkContact] -> CM ()
|
||||
syncSubscriberRelays user gInfo currentRelayLinks = void . tryAllErrors $ do
|
||||
localRelayMembers <- withFastStore' $ \db -> getGroupRelayMembers db vr user gInfo
|
||||
let activeRelayMembers = filter memberCurrent localRelayMembers
|
||||
memberRelayLink GroupMember {relayLink = rl} = rl
|
||||
localRelayLinks = mapMaybe memberRelayLink activeRelayMembers
|
||||
newRelayLinks = filter (`notElem` localRelayLinks) currentRelayLinks
|
||||
forM_ newRelayLinks $ \rlnk -> void . tryAllErrors $
|
||||
connectToRelay user gInfo rlnk
|
||||
forM_ localRelayMembers $ \m ->
|
||||
case memberRelayLink m of
|
||||
-- Remove relay if its link is no longer in the current link data.
|
||||
-- Inactive relays (e.g. left) are only cleaned up when no active relays remain,
|
||||
-- as that is the only case where the owner's relay removal can't be forwarded.
|
||||
Just rlnk | rlnk `notElem` currentRelayLinks,
|
||||
memberCurrent m || null activeRelayMembers ->
|
||||
void . tryAllErrors $ do
|
||||
deleteMemberConnection m
|
||||
deleteOrUpdateMemberRecord user gInfo m
|
||||
_ -> pure ()
|
||||
|
||||
prepareContact :: User -> ConnReqContact -> PQSupport -> CM (ConnId, VersionChat)
|
||||
prepareContact user cReq pqSup = do
|
||||
-- 0) toggle disabled - PQSupportOff
|
||||
@@ -4727,12 +4782,42 @@ deleteInProgressGroup user gInfo = do
|
||||
withFastStore' $ \db -> deleteGroup db user gInfo
|
||||
|
||||
runRelayGroupLinkChecks :: User -> CM ()
|
||||
runRelayGroupLinkChecks _user = do
|
||||
-- TODO [relays] relay: periodically check presence of relay link in group links of served groups
|
||||
-- TODO - retrieve group link data
|
||||
-- TODO - if relay link is present, update relay status to RSActive
|
||||
-- TODO - if relay link is absent and status was RSActive -> update to new "Removed" status?
|
||||
pure ()
|
||||
runRelayGroupLinkChecks user = do
|
||||
initialDelay <- asks (relayChecksInitialDelay . config)
|
||||
liftIO $ threadDelay' initialDelay
|
||||
interval <- asks (relayChecksInterval . config)
|
||||
forever $ do
|
||||
flip catchAllErrors eToView $ do
|
||||
lift waitChatStartedAndActivated
|
||||
checkRelayServedGroups
|
||||
checkRelayInactiveGroups
|
||||
liftIO $ threadDelay' $ diffToMicroseconds interval
|
||||
where
|
||||
checkRelayServedGroups = do
|
||||
vr <- chatVersionRange
|
||||
relayGroups <- withStore' $ \db -> getRelayServedGroups db vr user
|
||||
forM_ relayGroups $ \gInfo@GroupInfo {groupProfile = gp} -> flip catchAllErrors eToView $ do
|
||||
case publicGroup gp of
|
||||
Just PublicGroupProfile {groupLink = sLnk} -> do
|
||||
(_, ContactLinkData _ UserContactData {relays = relayLinks}) <-
|
||||
getShortLinkConnReq' NRMBackground user sLnk
|
||||
gLink_ <- withStore' $ \db -> runExceptT $ getGroupLink db user gInfo
|
||||
case gLink_ of
|
||||
Right GroupLink {connLinkContact = CCLink _ (Just ourLink)} ->
|
||||
if ourLink `elem` relayLinks
|
||||
then do
|
||||
-- TODO [relays] emit event to UI when relay own status promoted to RSActive
|
||||
-- CEvtGroupRelayUpdated requires GroupRelay (owner-side), not available on relay side
|
||||
void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSAccepted RSActive
|
||||
else void $ withStore' $ \db -> updateRelayOwnStatusFromTo db gInfo RSActive RSInactive
|
||||
_ -> pure ()
|
||||
_ -> pure ()
|
||||
checkRelayInactiveGroups = do
|
||||
vr <- chatVersionRange
|
||||
ttl <- asks (relayInactiveTTL . config)
|
||||
inactiveGroups <- withStore' $ \db -> getRelayInactiveGroups db vr user ttl
|
||||
forM_ inactiveGroups $ \gInfo -> flip catchAllErrors eToView $
|
||||
deleteGroupConnections user gInfo False
|
||||
|
||||
expireChatItems :: User -> Int64 -> Bool -> CM ()
|
||||
expireChatItems user@User {userId} globalTTL sync = do
|
||||
@@ -5026,6 +5111,7 @@ chatCommandP =
|
||||
("/public group" <|> "/pg") *> (NewPublicGroup <$> incognitoP <* " relays=" <*> strP <* A.space <* char_ '#' <*> channelProfile),
|
||||
"/_public group " *> (APINewPublicGroup <$> A.decimal <*> incognitoOnOffP <*> _strP <* A.space <*> jsonP),
|
||||
"/_get relays #" *> (APIGetGroupRelays <$> A.decimal),
|
||||
"/_add relays #" *> (APIAddGroupRelays <$> A.decimal <*> _strP),
|
||||
("/add " <|> "/a ") *> char_ '#' *> (AddMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)),
|
||||
("/join " <|> "/j ") *> char_ '#' *> (JoinGroup <$> displayNameP <*> (" mute" $> MFNone <|> pure MFAll)),
|
||||
"/accept member " *> char_ '#' *> (AcceptMember <$> displayNameP <* A.space <* char_ '@' <*> displayNameP <*> (memberRole <|> pure GRMember)),
|
||||
|
||||
@@ -1808,9 +1808,12 @@ deleteOrUpdateMemberRecord user gInfo m =
|
||||
deleteOrUpdateMemberRecordIO :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO GroupInfo
|
||||
deleteOrUpdateMemberRecordIO db user@User {userId} gInfo m = do
|
||||
(gInfo', m') <- deleteSupportChatIfExists db user gInfo m
|
||||
checkGroupMemberHasItems db user m' >>= \case
|
||||
Just _ -> updateGroupMemberStatus db userId m' GSMemRemoved
|
||||
Nothing -> deleteGroupMember db user m'
|
||||
if isRelay m'
|
||||
then deleteGroupMember db user m'
|
||||
else
|
||||
checkGroupMemberHasItems db user m' >>= \case
|
||||
Just _ -> updateGroupMemberStatus db userId m' GSMemRemoved
|
||||
Nothing -> deleteGroupMember db user m'
|
||||
pure gInfo'
|
||||
|
||||
updateMemberRecordDeleted :: User -> GroupInfo -> GroupMember -> GroupMemberStatus -> CM GroupInfo
|
||||
@@ -1818,8 +1821,15 @@ updateMemberRecordDeleted user@User {userId} gInfo m newStatus =
|
||||
withStore' $ \db -> do
|
||||
(gInfo', m') <- deleteSupportChatIfExists db user gInfo m
|
||||
updateGroupMemberStatus db userId m' newStatus
|
||||
deactivateRelay_ db m
|
||||
pure gInfo'
|
||||
|
||||
deactivateRelay_ :: DB.Connection -> GroupMember -> IO ()
|
||||
deactivateRelay_ db m =
|
||||
when (isRelay m) $ do
|
||||
relay_ <- runExceptT $ getGroupRelayByGMId db (groupMemberId' m)
|
||||
forM_ relay_ $ \relay -> void $ updateRelayStatus db relay RSInactive
|
||||
|
||||
deleteSupportChatIfExists :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember)
|
||||
deleteSupportChatIfExists db user gInfo m = do
|
||||
gInfo' <-
|
||||
|
||||
@@ -931,7 +931,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
newDeliveryTasks <- reverse <$> foldM (processAChatMsg gInfo' scopeInfo m' tags eInfo) [] aChatMsgs
|
||||
shouldDelConns <-
|
||||
if isUserGrpFwdRelay gInfo' && not (blockedByAdmin m)
|
||||
then createDeliveryTasks gInfo' m' newDeliveryTasks
|
||||
then
|
||||
let tasks
|
||||
| relayOwnStatus gInfo' == Just RSInactive = filter relayRemovedNewTask newDeliveryTasks
|
||||
| otherwise = newDeliveryTasks
|
||||
in createDeliveryTasks gInfo' m' tasks
|
||||
else pure False
|
||||
withRcpt <- checkSendRcpt $ rights aChatMsgs
|
||||
pure (withRcpt, shouldDelConns)
|
||||
@@ -1039,6 +1043,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
where
|
||||
aChatMsgHasReceipt (APMsg _ (ParsedMsg _ _ ChatMessage {chatMsgEvent})) =
|
||||
hasDeliveryReceipt (toCMEventTag chatMsgEvent)
|
||||
relayRemovedNewTask :: NewMessageDeliveryTask -> Bool
|
||||
relayRemovedNewTask NewMessageDeliveryTask {taskContext = DeliveryTaskContext {jobScope}} = isRelayRemoved jobScope
|
||||
createDeliveryTasks :: GroupInfo -> GroupMember -> [NewMessageDeliveryTask] -> CM ShouldDeleteGroupConns
|
||||
createDeliveryTasks gInfo'@GroupInfo {groupId = gId} m' newDeliveryTasks = do
|
||||
let relayRemovedTask_ = find (\NewMessageDeliveryTask {taskContext = DeliveryTaskContext {jobScope}} -> isRelayRemoved jobScope) newDeliveryTasks
|
||||
@@ -1306,8 +1312,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
pure (gInfo, gLink, relays', changed)
|
||||
toView $ CEvtGroupLinkDataUpdated user gInfo gLink relays relaysChanged
|
||||
where
|
||||
-- TODO [relays] owner: on relay deletion (link absent from relayLinks)
|
||||
-- TODO move status RSActive to new "Removed" status / remove relay record
|
||||
updateRelay :: DB.Connection -> GroupRelay -> ([GroupRelay], Bool) -> IO ([GroupRelay], Bool)
|
||||
updateRelay db relay@GroupRelay {relayLink, relayStatus} (acc, changed) =
|
||||
case relayLink of
|
||||
@@ -1315,6 +1319,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
| rLink `elem` relayLinks && relayStatus == RSAccepted -> do
|
||||
relay' <- updateRelayStatus db relay RSActive
|
||||
pure (relay' : acc, True)
|
||||
| rLink `elem` relayLinks -> pure (relay : acc, changed)
|
||||
| relayStatus == RSActive -> do
|
||||
-- Relay link absent from link data — deactivate.
|
||||
-- RSAccepted relays are not deactivated: their own link data update
|
||||
-- may not have been processed yet (race with concurrent relay connections).
|
||||
-- TODO [relays] multi-owner: Another owner removing a relay updates link data on
|
||||
-- TODO the SMP server, but this owner won't receive a LINK callback for it
|
||||
-- TODO (LINK only fires in response to own setConnShortLink calls).
|
||||
relay' <- updateRelayStatus db relay RSInactive
|
||||
pure (relay' : acc, True)
|
||||
_ -> pure (relay : acc, changed)
|
||||
_ -> throwChatError $ CECommandError "LINK event expected for a group link only"
|
||||
_ -> throwChatError $ CECommandError "unexpected cmdFunction"
|
||||
@@ -3096,10 +3110,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
deleteGroupLinkIfExists user gInfo
|
||||
-- TODO [relays] possible improvement is to immediately delete rcv queues if isUserGrpFwdRelay
|
||||
unless (isUserGrpFwdRelay gInfo) $ deleteGroupConnections user gInfo False
|
||||
withStore' $ \db -> updateGroupMemberStatus db userId membership GSMemRemoved
|
||||
withStore' $ \db -> do
|
||||
updateGroupMemberStatus db userId membership GSMemRemoved
|
||||
when (isJust $ relayOwnStatus gInfo) $ updateRelayOwnStatus_ db gInfo RSInactive
|
||||
let membership' = membership {memberStatus = GSMemRemoved}
|
||||
when withMessages $ deleteMessages gInfo membership' SMDSnd
|
||||
deleteMemberItem gInfo RGEUserDeleted
|
||||
deleteMemberItem msg gInfo RGEUserDeleted
|
||||
toView $ CEvtDeletedMemberUser user gInfo {membership = membership'} m withMessages msgSigned
|
||||
pure $ Just DJSGroup {jobSpec = DJRelayRemoved}
|
||||
else
|
||||
@@ -3127,7 +3143,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
let wasDeleted = memberStatus == GSMemRemoved || memberStatus == GSMemLeft
|
||||
deletedMember' = deletedMember {memberStatus = GSMemRemoved}
|
||||
when withMessages $ deleteMessages gInfo'' deletedMember' SMDRcv
|
||||
unless wasDeleted $ deleteMemberItem gInfo'' $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile)
|
||||
-- Clear forwardedByMember if it references the deleted member,
|
||||
-- as the member record was already deleted above.
|
||||
let RcvMessage {forwardedByMember = fwdBy} = msg
|
||||
msg' = if fwdBy == Just groupMemberId then (msg :: RcvMessage) {forwardedByMember = Nothing} else msg
|
||||
unless wasDeleted $ deleteMemberItem msg' gInfo'' $ RGEMemberDeleted groupMemberId (fromLocalProfile memberProfile)
|
||||
toView $ CEvtDeletedMember user gInfo'' m deletedMember' withMessages msgSigned
|
||||
pure deliveryScope
|
||||
where
|
||||
@@ -3135,9 +3155,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
| senderRole < GRAdmin || senderRole < memberRole =
|
||||
messageError "x.grp.mem.del with insufficient member permissions" $> Nothing
|
||||
| otherwise = a
|
||||
deleteMemberItem gi gEvent = do
|
||||
deleteMemberItem msg' gi gEvent = do
|
||||
(gi', m', scopeInfo) <- mkGroupChatScope gi m
|
||||
(ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gi' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent)
|
||||
(ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gi' scopeInfo m') msg' brokerTs (CIRcvGroupEvent gEvent)
|
||||
groupMsgToView cInfo ci
|
||||
deleteMessages :: MsgDirectionI d => GroupInfo -> GroupMember -> SMsgDirection d -> CM ()
|
||||
deleteMessages gInfo' delMem msgDir
|
||||
@@ -3168,10 +3188,6 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
deleteMemberConnection m
|
||||
-- member record is not deleted to allow creation of "member left" chat item
|
||||
gInfo' <- updateMemberRecordDeleted user gInfo m GSMemLeft
|
||||
when (isRelay m) $
|
||||
withStore' $ \db -> do
|
||||
relay_ <- runExceptT $ getGroupRelayByGMId db (groupMemberId' m)
|
||||
forM_ relay_ $ \relay -> void $ updateRelayStatus db relay RSInactive
|
||||
gInfo'' <- updatePublicGroupData user gInfo'
|
||||
unless (muteEventInChannel gInfo'' m) $ do
|
||||
(gInfo''', m', scopeInfo) <- mkGroupChatScope gInfo'' m
|
||||
@@ -3526,19 +3542,24 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do
|
||||
processDeliveryTask :: MessageDeliveryTask -> CM ()
|
||||
processDeliveryTask task@MessageDeliveryTask {jobScope} =
|
||||
case jobScopeImpliedSpec jobScope of
|
||||
DJDeliveryJob _includePending ->
|
||||
withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do
|
||||
let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks
|
||||
withStore' $ \db -> do
|
||||
createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body
|
||||
forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed
|
||||
forM_ largeTaskIds $ \taskId -> setDeliveryTaskErrStatus db taskId "large"
|
||||
lift . void $ getDeliveryJobWorker True deliveryKey
|
||||
DJDeliveryJob _includePending
|
||||
| relayOwnStatus gInfo == Just RSInactive -> do
|
||||
logWarn "delivery task worker: relay inactive"
|
||||
withStore' $ \db -> setDeliveryTaskErrStatus db (deliveryTaskId task) "relay inactive"
|
||||
| otherwise ->
|
||||
withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do
|
||||
let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks
|
||||
withStore' $ \db -> do
|
||||
createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body
|
||||
forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed
|
||||
forM_ largeTaskIds $ \taskId -> setDeliveryTaskErrStatus db taskId "large"
|
||||
lift . void $ getDeliveryJobWorker True deliveryKey
|
||||
where
|
||||
singleSenderGMId_ :: NonEmpty MessageDeliveryTask -> Maybe GroupMemberId
|
||||
singleSenderGMId_ (MessageDeliveryTask {senderGMId = senderGMId'} :| ts)
|
||||
| all (\MessageDeliveryTask {senderGMId} -> senderGMId == senderGMId') ts = Just senderGMId'
|
||||
| otherwise = Nothing
|
||||
-- DJRelayRemoved is allowed when RSInactive - it forwards XGrpMemDel about relay's own deletion
|
||||
DJRelayRemoved
|
||||
| workerScope /= DWSGroup ->
|
||||
throwChatError $ CEInternalError "delivery task worker: relay removed task in wrong worker scope"
|
||||
@@ -3591,9 +3612,14 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do
|
||||
processDeliveryJob :: MessageDeliveryJob -> CM ()
|
||||
processDeliveryJob job =
|
||||
case jobScopeImpliedSpec jobScope of
|
||||
DJDeliveryJob _includePending -> do
|
||||
sendBodyToMembers
|
||||
withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete
|
||||
DJDeliveryJob _includePending
|
||||
| relayOwnStatus gInfo == Just RSInactive -> do
|
||||
logWarn "delivery job worker: relay inactive"
|
||||
withStore' $ \db -> setDeliveryJobErrStatus db (deliveryJobId job) "relay inactive"
|
||||
| otherwise -> do
|
||||
sendBodyToMembers
|
||||
withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete
|
||||
-- DJRelayRemoved is allowed when RSInactive - it forwards XGrpMemDel about relay's own deletion
|
||||
DJRelayRemoved
|
||||
| workerScope /= DWSGroup ->
|
||||
throwChatError $ CEInternalError "delivery job worker: relay removed job in wrong worker scope"
|
||||
|
||||
@@ -94,6 +94,9 @@ module Simplex.Chat.Store.Groups
|
||||
setGroupInProgressDone,
|
||||
createRelayRequestGroup,
|
||||
updateRelayOwnStatusFromTo,
|
||||
updateRelayOwnStatus_,
|
||||
getRelayServedGroups,
|
||||
getRelayInactiveGroups,
|
||||
createNewContactMemberAsync,
|
||||
createJoiningMember,
|
||||
getMemberJoinRequest,
|
||||
@@ -188,7 +191,7 @@ import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing)
|
||||
import Data.Ord (Down (..))
|
||||
import Data.Text (Text)
|
||||
import qualified Data.Text as T
|
||||
import Data.Time.Clock (UTCTime (..), getCurrentTime)
|
||||
import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime)
|
||||
import Data.Text.Encoding (encodeUtf8)
|
||||
import Simplex.Chat.Messages
|
||||
import Simplex.Chat.Operators
|
||||
@@ -1585,7 +1588,29 @@ updateRelayOwnStatusFromTo db gInfo@GroupInfo {groupId} fromStatus toStatus = do
|
||||
updateRelayOwnStatus_ :: DB.Connection -> GroupInfo -> RelayStatus -> IO ()
|
||||
updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute db "UPDATE groups SET relay_own_status = ?, updated_at = ? WHERE group_id = ?" (relayStatus, currentTs, groupId)
|
||||
let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing
|
||||
DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId)
|
||||
|
||||
getRelayServedGroups :: DB.Connection -> VersionRangeChat -> User -> IO [GroupInfo]
|
||||
getRelayServedGroups db vr User {userId, userContactId} = do
|
||||
map (toGroupInfo vr userContactId [])
|
||||
<$> DB.query
|
||||
db
|
||||
( groupInfoQuery
|
||||
<> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?)"
|
||||
)
|
||||
(userId, userContactId, RSAccepted, RSActive)
|
||||
|
||||
getRelayInactiveGroups :: DB.Connection -> VersionRangeChat -> User -> NominalDiffTime -> IO [GroupInfo]
|
||||
getRelayInactiveGroups db vr User {userId, userContactId} ttl = do
|
||||
cutoffTs <- addUTCTime (- ttl) <$> getCurrentTime
|
||||
map (toGroupInfo vr userContactId [])
|
||||
<$> DB.query
|
||||
db
|
||||
( groupInfoQuery
|
||||
<> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status = ? AND g.relay_inactive_at IS NOT NULL AND g.relay_inactive_at <= ?"
|
||||
)
|
||||
(userId, userContactId, RSInactive, cutoffTs)
|
||||
|
||||
createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO ()
|
||||
createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) chatV peerChatVRange subMode =
|
||||
|
||||
@@ -29,6 +29,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Text, Maybe Text)]
|
||||
@@ -57,7 +58,8 @@ schemaMigrations =
|
||||
("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link),
|
||||
("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays),
|
||||
("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed),
|
||||
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries)
|
||||
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries),
|
||||
("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at where
|
||||
|
||||
import Data.Text (Text)
|
||||
import Text.RawString.QQ (r)
|
||||
|
||||
m20260507_relay_inactive_at :: Text
|
||||
m20260507_relay_inactive_at =
|
||||
[r|
|
||||
ALTER TABLE groups ADD COLUMN relay_inactive_at TIMESTAMPTZ;
|
||||
|]
|
||||
|
||||
down_m20260507_relay_inactive_at :: Text
|
||||
down_m20260507_relay_inactive_at =
|
||||
[r|
|
||||
ALTER TABLE groups DROP COLUMN relay_inactive_at;
|
||||
|]
|
||||
@@ -962,7 +962,8 @@ CREATE TABLE test_chat_schema.groups (
|
||||
public_member_count bigint,
|
||||
relay_request_retries bigint DEFAULT 0 NOT NULL,
|
||||
relay_request_delay bigint DEFAULT 0 NOT NULL,
|
||||
relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL
|
||||
relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL,
|
||||
relay_inactive_at timestamp with time zone
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at
|
||||
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
|
||||
|
||||
schemaMigrations :: [(String, Query, Maybe Query)]
|
||||
@@ -303,7 +304,8 @@ schemaMigrations =
|
||||
("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link),
|
||||
("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays),
|
||||
("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed),
|
||||
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries)
|
||||
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries),
|
||||
("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at)
|
||||
]
|
||||
|
||||
-- | The list of migrations in ascending order by date
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20260507_relay_inactive_at :: Query
|
||||
m20260507_relay_inactive_at =
|
||||
[sql|
|
||||
ALTER TABLE groups ADD COLUMN relay_inactive_at TEXT;
|
||||
|]
|
||||
|
||||
down_m20260507_relay_inactive_at :: Query
|
||||
down_m20260507_relay_inactive_at =
|
||||
[sql|
|
||||
ALTER TABLE groups DROP COLUMN relay_inactive_at;
|
||||
|]
|
||||
@@ -273,6 +273,16 @@ Query:
|
||||
Plan:
|
||||
SEARCH connections USING PRIMARY KEY (conn_id=?)
|
||||
|
||||
Query:
|
||||
SELECT user_id FROM users u
|
||||
WHERE u.deleted = ?
|
||||
AND NOT EXISTS (SELECT c.conn_id FROM connections c WHERE c.user_id = u.user_id)
|
||||
|
||||
Plan:
|
||||
SCAN u
|
||||
CORRELATED SCALAR SUBQUERY 1
|
||||
SEARCH c USING COVERING INDEX idx_connections_user (user_id=?)
|
||||
|
||||
Query:
|
||||
SELECT user_id FROM users u
|
||||
WHERE u.user_id = ?
|
||||
@@ -525,6 +535,21 @@ Query:
|
||||
Plan:
|
||||
SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?)
|
||||
|
||||
Query:
|
||||
DELETE FROM encrypted_rcv_message_hashes
|
||||
WHERE encrypted_rcv_message_hash_id IN (
|
||||
SELECT encrypted_rcv_message_hash_id
|
||||
FROM encrypted_rcv_message_hashes
|
||||
WHERE created_at < ?
|
||||
ORDER BY created_at ASC
|
||||
LIMIT ?
|
||||
)
|
||||
|
||||
Plan:
|
||||
SEARCH encrypted_rcv_message_hashes USING INTEGER PRIMARY KEY (rowid=?)
|
||||
LIST SUBQUERY 1
|
||||
SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_created_at (created_at<?)
|
||||
|
||||
Query:
|
||||
INSERT INTO connections
|
||||
(user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, pq_support, duplex_handshake) VALUES (?,?,?,?,?,?,?)
|
||||
@@ -1085,6 +1110,14 @@ Query: SELECT conn_id FROM connections WHERE deleted = 0
|
||||
Plan:
|
||||
SCAN connections
|
||||
|
||||
Query: SELECT conn_id FROM connections WHERE deleted = ?
|
||||
Plan:
|
||||
SCAN connections
|
||||
|
||||
Query: SELECT conn_id FROM connections WHERE deleted_at_wait_delivery IS NOT NULL
|
||||
Plan:
|
||||
SCAN connections
|
||||
|
||||
Query: SELECT conn_id FROM connections WHERE user_id = ?
|
||||
Plan:
|
||||
SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?)
|
||||
|
||||
@@ -6808,6 +6808,10 @@ Query: SELECT last_insert_rowid()
|
||||
Plan:
|
||||
SCAN CONSTANT ROW
|
||||
|
||||
Query: SELECT local_display_name FROM group_members
|
||||
Plan:
|
||||
SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name
|
||||
|
||||
Query: SELECT max(active_order) FROM users
|
||||
Plan:
|
||||
SEARCH users
|
||||
|
||||
@@ -176,7 +176,8 @@ CREATE TABLE groups(
|
||||
public_member_count INTEGER,
|
||||
relay_request_retries INTEGER NOT NULL DEFAULT 0,
|
||||
relay_request_delay INTEGER NOT NULL DEFAULT 0,
|
||||
relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00', -- received
|
||||
relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00',
|
||||
relay_inactive_at TEXT, -- received
|
||||
FOREIGN KEY(user_id, local_display_name)
|
||||
REFERENCES display_names(user_id, local_display_name)
|
||||
ON DELETE CASCADE
|
||||
|
||||
@@ -182,6 +182,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte
|
||||
CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView
|
||||
CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results
|
||||
CRGroupRelays u g relays -> ttyUser u $ viewGroupRelays g relays
|
||||
CRGroupRelaysAdded u g _groupLink relays -> ttyUser u $ viewGroupRelays g relays
|
||||
CRGroupRelaysAddFailed u results -> ttyUser u $ viewGroupRelaysAddFailed results
|
||||
CRGroupMembers u g -> ttyUser u $ viewGroupMembers g
|
||||
CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms
|
||||
-- CRGroupConversationsArchived u _g _conversations -> ttyUser u []
|
||||
@@ -1239,14 +1241,18 @@ viewGroupCreated g testView =
|
||||
where
|
||||
relaysInstruction = "wait for selected relay(s) to join, then you can invite members via group link"
|
||||
|
||||
viewPublicGroupCreationFailed :: [AddRelayResult] -> [StyledString]
|
||||
viewPublicGroupCreationFailed results =
|
||||
["channel not created, results:"]
|
||||
<> map showRelayResult results
|
||||
viewRelayResults :: StyledString -> [AddRelayResult] -> [StyledString]
|
||||
viewRelayResults header results = [header] <> map showRelayResult results
|
||||
where
|
||||
showRelayResult (AddRelayResult UserChatRelay {chatRelayId = DBEntityId i} err_) =
|
||||
" relay " <> sShow i <> ": " <> maybe "ok" (plain . tshow) err_
|
||||
|
||||
viewPublicGroupCreationFailed :: [AddRelayResult] -> [StyledString]
|
||||
viewPublicGroupCreationFailed = viewRelayResults "channel not created, results:"
|
||||
|
||||
viewGroupRelaysAddFailed :: [AddRelayResult] -> [StyledString]
|
||||
viewGroupRelaysAddFailed = viewRelayResults "relays not added, results:"
|
||||
|
||||
viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString]
|
||||
viewCannotResendInvitation g c =
|
||||
[ ttyContact c <> " is already invited to group " <> ttyGroup' g,
|
||||
|
||||
@@ -269,6 +269,9 @@ chatGroupTests = do
|
||||
it "subscriber should update profile in channel (signed)" testChannelSubscriberProfileUpdate
|
||||
it "should report relay results when one relay deleted its address" testChannelCreateDeletedRelay
|
||||
it "should deliver support scope messages via relay" testChannelSupportScope
|
||||
it "should add relay to existing channel" testChannelAddRelay
|
||||
it "should remove relay from channel" testChannelRemoveRelay
|
||||
it "should remove left relay from channel" testChannelRemoveLeftRelay
|
||||
describe "channel message operations" $ do
|
||||
it "should update channel message" testChannelMessageUpdate
|
||||
it "should delete channel message" testChannelMessageDelete
|
||||
@@ -9686,6 +9689,240 @@ testChannelSubscriberProfileUpdate ps =
|
||||
dan `hasContactProfiles` ["alice", "bob", "kate", "dave"]
|
||||
eve `hasContactProfiles` ["alice", "bob", "kate", "dave", "eve"]
|
||||
|
||||
testChannelAddRelay :: HasCallStack => TestParams -> IO ()
|
||||
testChannelAddRelay ps =
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
|
||||
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
|
||||
withNewTestChat ps "dan" danProfile $ \dan ->
|
||||
withNewTestChat ps "eve" eveProfile $ \eve -> do
|
||||
-- create channel with 1 relay (bob)
|
||||
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
|
||||
|
||||
-- subscriber joins through bob (the only relay at this point)
|
||||
memberJoinChannel "team" [bob] [alice] shortLink fullLink dan
|
||||
|
||||
-- configure cath as a second relay
|
||||
cath ##> "/ad"
|
||||
(cathSLink, _cLink) <- getContactLinks cath True
|
||||
alice ##> ("/relays name=cath " <> cathSLink)
|
||||
alice <## "ok"
|
||||
|
||||
-- can't add same relay twice
|
||||
alice ##> "/_add relays #1 1"
|
||||
alice <## "bad chat command: some relays are already in the group"
|
||||
|
||||
-- add cath relay to existing channel
|
||||
alice ##> "/_add relays #1 2"
|
||||
alice <## "#team: group relays:"
|
||||
alice <## " - relay id 1: active"
|
||||
alice <## " - relay id 2: invited"
|
||||
|
||||
-- wait for cath to join as relay (async)
|
||||
concurrentlyN_
|
||||
[ do
|
||||
alice <## "#team: group link relays updated, current relays:"
|
||||
alice
|
||||
<### [ " - relay id 1: active",
|
||||
" - relay id 2: active"
|
||||
]
|
||||
alice <## "group link:"
|
||||
void $ getTermLine alice,
|
||||
cath <## "#team: you joined the group as relay"
|
||||
]
|
||||
|
||||
threadDelay 100000
|
||||
|
||||
-- existing subscriber discovers and connects to new relay
|
||||
dan ##> "/_get group link data #1"
|
||||
dan <## "group ID: 1"
|
||||
void $ getTermLine dan -- subscribers: N
|
||||
concurrentlyN_
|
||||
[ do
|
||||
dan <## "#team: joining the group (connecting to relay cath)..."
|
||||
dan <## "#team: you joined the group (connected to relay cath)",
|
||||
do
|
||||
cath <## "dan (Daniel): accepting request to join group #team..."
|
||||
cath <## "#team: dan joined the group"
|
||||
]
|
||||
|
||||
threadDelay 100000
|
||||
|
||||
-- new subscriber joins through both relays
|
||||
memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink eve
|
||||
|
||||
-- verify delivery through both relays
|
||||
alice #> "#team hello"
|
||||
[bob, cath] *<# "#team> hello"
|
||||
[dan, eve] *<# "#team> hello [>>]"
|
||||
|
||||
testChannelRemoveRelay :: HasCallStack => TestParams -> IO ()
|
||||
testChannelRemoveRelay ps =
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
|
||||
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
|
||||
withNewTestChat ps "dan" danProfile $ \dan -> do
|
||||
(shortLink, fullLink) <- prepareChannel2Relays "team" alice bob cath
|
||||
memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan
|
||||
|
||||
-- verify delivery works
|
||||
alice #> "#team hello"
|
||||
[bob, cath] *<# "#team> hello"
|
||||
dan <# "#team> hello [>>]"
|
||||
|
||||
-- remove relay bob
|
||||
threadDelay 100000
|
||||
alice ##> "/rm #team bob"
|
||||
alice <## "#team: you removed bob from the group (signed)"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## "#team: alice removed you from the group (signed)"
|
||||
bob <## "use /d #team to delete the group",
|
||||
-- cath doesn't have bob in member list (relays aren't introduced to each other),
|
||||
-- so x.grp.mem.del arrives with unknown member ID — cath still forwards it (Left branch in xGrpMemDel)
|
||||
cath <## "error: x.grp.mem.del with unknown member ID",
|
||||
dan <## "#team: alice removed bob from the group (signed)"
|
||||
]
|
||||
|
||||
-- verify delivery still works via remaining relay (cath)
|
||||
threadDelay 100000
|
||||
alice #> "#team still working"
|
||||
cath <# "#team> still working"
|
||||
dan <# "#team> still working [>>]"
|
||||
|
||||
-- remove last relay cath
|
||||
threadDelay 100000
|
||||
alice ##> "/rm #team cath"
|
||||
alice <## "#team: you removed cath from the group (signed)"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
cath <## "#team: alice removed you from the group (signed)"
|
||||
cath <## "use /d #team to delete the group",
|
||||
dan <## "#team: alice removed cath from the group (signed)"
|
||||
]
|
||||
|
||||
-- verify delivery stops — no relays to forward
|
||||
threadDelay 100000
|
||||
alice #> "#team no relays"
|
||||
(dan </)
|
||||
|
||||
-- bob's and cath's member records should be deleted on alice's and dan's sides
|
||||
threadDelay 100000
|
||||
aliceMembers <- withCCTransaction alice $ \db ->
|
||||
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
|
||||
aliceMembers `shouldMatchList` [Only "alice", Only "dan"]
|
||||
danMembers <- withCCTransaction dan $ \db ->
|
||||
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
|
||||
danMembers `shouldMatchList` [Only "dan", Only "alice"]
|
||||
|
||||
-- re-add bob as relay
|
||||
alice ##> "/_add relays #1 1"
|
||||
alice <## "#team: group relays:"
|
||||
alice .<##. (" - relay id", ": invited")
|
||||
|
||||
-- wait for bob to rejoin as relay (bob gets LDN "team_1" since old group record exists)
|
||||
concurrentlyN_
|
||||
[ do
|
||||
alice <## "#team: group link relays updated, current relays:"
|
||||
alice .<##. (" - relay id", ": active")
|
||||
alice <## "group link:"
|
||||
void $ getTermLine alice,
|
||||
bob <## "#team_1: you joined the group as relay"
|
||||
]
|
||||
|
||||
threadDelay 100000
|
||||
|
||||
-- subscriber discovers and connects to new relay
|
||||
dan ##> "/_get group link data #1"
|
||||
dan <## "group ID: 1"
|
||||
void $ getTermLine dan -- subscribers: N
|
||||
concurrentlyN_
|
||||
[ do
|
||||
dan <## "#team: joining the group (connecting to relay bob)..."
|
||||
dan <## "#team: you joined the group (connected to relay bob)",
|
||||
do
|
||||
bob <## "dan_1 (Daniel): accepting request to join group #team_1..."
|
||||
bob <## "#team_1: dan_1 joined the group"
|
||||
]
|
||||
|
||||
threadDelay 100000
|
||||
|
||||
-- verify delivery works again through re-added relay
|
||||
alice #> "#team relays restored"
|
||||
bob <# "#team_1> relays restored"
|
||||
dan <# "#team> relays restored [>>]"
|
||||
|
||||
testChannelRemoveLeftRelay :: HasCallStack => TestParams -> IO ()
|
||||
testChannelRemoveLeftRelay ps =
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice ->
|
||||
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
|
||||
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
|
||||
withNewTestChat ps "dan" danProfile $ \dan -> do
|
||||
(shortLink, fullLink) <- prepareChannel2Relays "team" alice bob cath
|
||||
memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan
|
||||
|
||||
-- verify delivery works
|
||||
alice #> "#team hello"
|
||||
[bob, cath] *<# "#team> hello"
|
||||
dan <# "#team> hello [>>]"
|
||||
|
||||
-- bob leaves
|
||||
threadDelay 100000
|
||||
bob ##> "/l team"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
bob <## "#team: you left the group"
|
||||
bob <## "use /d #team to delete the group",
|
||||
alice <## "#team: bob left the group (signed)",
|
||||
dan <## "#team: bob left the group (signed)"
|
||||
]
|
||||
|
||||
-- alice removes left bob
|
||||
threadDelay 100000
|
||||
alice ##> "/rm #team bob"
|
||||
alice <## "#team: you removed bob from the group (signed)"
|
||||
concurrentlyN_
|
||||
[ cath <## "error: x.grp.mem.del with unknown member ID",
|
||||
dan <## "#team: alice removed bob from the group (signed)"
|
||||
]
|
||||
|
||||
-- bob's member record should be deleted on alice's and dan's sides
|
||||
threadDelay 100000
|
||||
aliceMembers <- withCCTransaction alice $ \db ->
|
||||
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
|
||||
aliceMembers `shouldMatchList` [Only "alice", Only "cath", Only "dan"]
|
||||
danMembers <- withCCTransaction dan $ \db ->
|
||||
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
|
||||
danMembers `shouldMatchList` [Only "dan", Only "alice", Only "cath"]
|
||||
|
||||
-- cath leaves
|
||||
threadDelay 100000
|
||||
cath ##> "/l team"
|
||||
concurrentlyN_
|
||||
[ do
|
||||
cath <## "#team: you left the group"
|
||||
cath <## "use /d #team to delete the group",
|
||||
alice <## "#team: cath left the group (signed)",
|
||||
dan <## "#team: cath left the group (signed)"
|
||||
]
|
||||
|
||||
-- alice removes left cath - dan doesn't receive (no relay to forward)
|
||||
threadDelay 100000
|
||||
alice ##> "/rm #team cath"
|
||||
alice <## "#team: you removed cath from the group (signed)"
|
||||
|
||||
-- dan syncs with link - should clean up cath's stale record
|
||||
threadDelay 100000
|
||||
dan ##> "/_get group link data #1"
|
||||
dan <## "group ID: 1"
|
||||
void $ getTermLine dan -- subscribers: N
|
||||
|
||||
-- cath's member record should be cleaned up on dan's side after sync
|
||||
threadDelay 100000
|
||||
danMembers2 <- withCCTransaction dan $ \db ->
|
||||
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
|
||||
danMembers2 `shouldMatchList` [Only "dan", Only "alice"]
|
||||
|
||||
testChannelCreateDeletedRelay :: HasCallStack => TestParams -> IO ()
|
||||
testChannelCreateDeletedRelay ps =
|
||||
withNewTestChat ps "alice" aliceProfile $ \alice -> do
|
||||
|
||||
Reference in New Issue
Block a user