diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index c93cc233f5..547c2b7000 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -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)") diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 9c23ac6307..a1d28b8e22 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -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) + } } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 85bb8a30b4..ea2de31569 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -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? = 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 } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index e165c01710..a141a53b4c 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 334abd76ee..5c57a46129 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -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 { diff --git a/apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift b/apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift new file mode 100644 index 0000000000..82b89beaa5 --- /dev/null +++ b/apps/ios/Shared/Views/Chat/Group/AddGroupRelayView.swift @@ -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 + 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 = [] + @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)) + } + } + } + } +} diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift index c88d639199..cf6426fe15 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -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.") diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index 21685fccd1..eee9500b3b 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -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?) { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index 4dff86f7bb..01a8833b9c 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -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) } } diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 7caeec17bb..32d6e7fe2c 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -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) diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 8c2e63bb59..a43f84f153 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -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 = ""; }; 6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = ""; }; 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = ""; }; + 6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = ""; }; 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = ""; }; 649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = ""; }; 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = ""; }; @@ -1173,6 +1175,7 @@ 64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */, 6495D7032F48CFC50060512B /* ChannelMembersView.swift */, 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */, + 6495D7072F48D0000060512B /* AddGroupRelayView.swift */, ); path = Group; sourceTree = ""; @@ -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 */, diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index a745542602..80b68f37a5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -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) } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 4d396c117e..88b4e387df 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -2163,6 +2163,19 @@ object ChatController { return emptyList() } + sealed class AddGroupRelaysResult { + data class Added(val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): AddGroupRelaysResult() + data class AddFailed(val addRelayResults: List): AddGroupRelaysResult() + } + + suspend fun apiAddGroupRelays(groupId: Long, relayIds: List): 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, val groupProfile: GroupProfile): CC() class ApiGetGroupRelays(val groupId: Long): CC() + class ApiAddGroupRelays(val groupId: Long, val relayIds: List): 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): CR() @Serializable @SerialName("publicGroupCreationFailed") class PublicGroupCreationFailed(val user: UserRef, val addRelayResults: List): CR() @Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List): CR() + @Serializable @SerialName("groupRelaysAdded") class GroupRelaysAdded(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List): CR() + @Serializable @SerialName("groupRelaysAddFailed") class GroupRelaysAddFailed(val user: UserRef, val addRelayResults: List): 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") diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 114edeee3d..154a7aec3c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -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) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index 51128646e7..d0782f6bb4 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -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 } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt new file mode 100644 index 0000000000..d0c2486069 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/AddGroupRelayView.kt @@ -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, + onRelayAdded: () -> Unit, + close: () -> Unit +) { + var availableRelays by remember { mutableStateOf>(emptyList()) } + var selectedRelayIds by remember { mutableStateOf>(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() + 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, + selectedRelayIds: Set, + 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, + selectedRelayIds: Set, + availableRelays: List, + onRelayAdded: () -> Unit, + close: () -> Unit, + updateState: (Set, List) -> 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) + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt index 39ebe5afd8..2091f66e1d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/ChannelRelaysView.kt @@ -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>(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, 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() } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 2c3a6c713b..0f64479359 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -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, onSuccess: () -> Unit = {}) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt index 1bc6f038f3..0a7eba63a0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupMemberInfoView.kt @@ -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), diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt index 1937cb3554..09372636ab 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/newchat/AddChannelView.kt @@ -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) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index ffc7810d92..34a091df31 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -2986,6 +2986,7 @@ deleted failed removed by operator + removed new invited accepted @@ -3007,7 +3008,8 @@ %1$d/%2$d relays connected, %3$d failed %1$d/%2$d relays connected, %3$d removed %1$d/%2$d relays connected - Adding relays will be supported later. + No relays + Add relays to restore message delivery. Waiting for channel owner to add relays. @@ -3022,6 +3024,10 @@ Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel. You connected to the channel via this relay link. Remove subscriber + Remove relay + Remove relay? + Relay will be removed from channel - this cannot be undone! + This is the last active relay. Removing it will prevent message delivery to subscribers. Block subscriber for all? @@ -3041,6 +3047,15 @@ Your profile %1$s will be shared with channel relays and subscribers.\nRelays can access channel messages. Configure relays failed + Add + Add relay + Add relays + No available relays + Error adding relays + Relays added: %1$s. + Select relays + No relays selected + %d relay(s) selected Relay connection failed Not all relays connected Wait diff --git a/bots/api/COMMANDS.md b/bots/api/COMMANDS.md index 660acaa41e..ed50cdbb9a 100644 --- a/bots/api/COMMANDS.md +++ b/bots/api/COMMANDS.md @@ -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 # [,...] +``` + +```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. diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 599ca83258..be03b13e5c 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -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") ] ), diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index f3758aa412..55f12f0a0a 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -71,6 +71,8 @@ chatResponsesDocsData = ("CRPublicGroupCreated", ""), ("CRPublicGroupCreationFailed", ""), ("CRGroupRelays", ""), + ("CRGroupRelaysAdded", ""), + ("CRGroupRelaysAddFailed", ""), ("CRGroupMembers", ""), ("CRGroupUpdated", ""), ("CRGroupsList", "Groups"), diff --git a/packages/simplex-chat-client/types/typescript/src/commands.ts b/packages/simplex-chat-client/types/typescript/src/commands.ts index 0f53baa4c6..f8aa6e445d 100644 --- a/packages/simplex-chat-client/types/typescript/src/commands.ts +++ b/packages/simplex-chat-client/types/typescript/src/commands.ts @@ -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 { diff --git a/packages/simplex-chat-client/types/typescript/src/responses.ts b/packages/simplex-chat-client/types/typescript/src/responses.ts index 62300e8126..e4284bf87e 100644 --- a/packages/simplex-chat-client/types/typescript/src/responses.ts +++ b/packages/simplex-chat-client/types/typescript/src/responses.ts @@ -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 diff --git a/plans/2026-04-29-relay-management.md b/plans/2026-04-29-relay-management.md new file mode 100644 index 0000000000..a44a9f0b2c --- /dev/null +++ b/plans/2026-04-29-relay-management.md @@ -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 | diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 3da5cf1422..53946e8471 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -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: diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index c5f17e5d69..5112f75859 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -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 = "", diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index aea6d620af..7a0da4e37f 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -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]} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 31e6533ad3..baa91a626b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -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)), diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index d7de3a52ad..53c623abec 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -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' <- diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 48dd63f6cf..455c07ce7b 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -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" diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index b375f77eb0..cca929d950 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -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 = diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index cdb461ea70..822068a771 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -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 diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260507_relay_inactive_at.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260507_relay_inactive_at.hs new file mode 100644 index 0000000000..f35927113c --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260507_relay_inactive_at.hs @@ -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; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 354c41eaaf..495a6bb752 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -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 ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 0ab8911ffd..4ee3f44b07 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -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 diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260507_relay_inactive_at.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260507_relay_inactive_at.hs new file mode 100644 index 0000000000..0596d4892a --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260507_relay_inactive_at.hs @@ -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; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index 7a20f8e98f..78cd98705e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -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 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, diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 21bf6cb65a..c9e60cd66e 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -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 + 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