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

This commit is contained in:
spaced4ndy
2026-05-08 07:19:16 +00:00
committed by GitHub
parent d9cfc9bd3d
commit 6f8a07e4ea
44 changed files with 1861 additions and 182 deletions
+9
View File
@@ -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)")
+6 -3
View File
@@ -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)
}
}
}
+16
View File
@@ -1891,6 +1891,22 @@ func apiGetGroupRelays(_ groupId: Int64) async -> [GroupRelay] {
return []
}
enum AddGroupRelaysResult {
case added(GroupInfo, GroupLink, [GroupRelay])
case addFailed([AddRelayResult])
}
func apiAddGroupRelays(_ groupId: Int64, relayIds: [Int64]) async throws -> AddGroupRelaysResult? {
let r: APIResult<ChatResponse2>? = await chatApiSendCmdWithRetry(.apiAddGroupRelays(groupId: groupId, relayIds: relayIds))
switch r {
case let .result(.groupRelaysAdded(_, groupInfo, groupLink, groupRelays)):
return .added(groupInfo, groupLink, groupRelays)
case let .result(.groupRelaysAddFailed(_, addRelayResults)):
return .addFailed(addRelayResults)
default: if let r { throw r.unexpected } else { return nil }
}
}
func apiAddMember(_ groupId: Int64, _ contactId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember {
let r: ChatResponse2 = try await chatSendCmd(.apiAddMember(groupId: groupId, contactId: contactId, memberRole: memberRole))
if case let .sentGroupInvitation(_, _, _, member) = r { return member }
+1 -1
View File
@@ -745,7 +745,7 @@ struct ChatView: View {
ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays)
}
}
} else {
} else if groupInfo.membership.memberCurrent {
Task {
if let gInfo = await apiGetUpdatedGroupLinkData(groupInfo.groupId) {
await MainActor.run {
@@ -395,13 +395,13 @@ struct ComposeView: View {
if let gInfo = chat.chatInfo.groupInfo, gInfo.useRelays,
![.memRejected, .memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus) {
if gInfo.membership.memberRole == .owner {
if let s = ownerState, s.activeCount < s.relays.count {
if let s = ownerState, s.relays.isEmpty || s.activeCount < s.relays.count {
ownerChannelRelayBar(relays: s.relays, activeCount: s.activeCount, failedCount: s.failedCount, removedCount: s.removedCount)
}
} else {
let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted()
let relayMembers = chatModel.groupMembers
.filter { $0.wrapped.memberRole == .relay }
.filter { $0.wrapped.memberRole == .relay && ![.memRemoved, .memGroupDeleted].contains($0.wrapped.memberStatus) }
.sorted { hostFromRelayLink($0.wrapped.relayLink ?? "") < hostFromRelayLink($1.wrapped.relayLink ?? "") }
let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress
let removedCount = relayMembers.filter { relayMemberRemoved($0.wrapped.memberStatus) }.count
@@ -409,7 +409,7 @@ struct ComposeView: View {
let failedCount = relayMembers.filter { !relayMemberRemoved($0.wrapped.memberStatus) && $0.wrapped.activeConn?.connFailedErr != nil }.count
let resolvedCount = connectedCount + removedCount + failedCount
let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count
if total > 0, removedCount + failedCount > 0 || resolvedCount < total {
if total == 0 || removedCount + failedCount > 0 || resolvedCount < total {
subscriberChannelRelayBar(
hostnames: hostnames,
relayMembers: relayMembers,
@@ -735,9 +735,9 @@ struct ComposeView: View {
gInfo.membership.memberRole == .owner,
![.memLeft, .memRemoved, .memGroupDeleted].contains(gInfo.membership.memberStatus)
else { return nil }
let relays = channelRelaysModel.groupId == gInfo.groupId
? channelRelaysModel.groupRelays : []
guard !relays.isEmpty else { return nil }
guard channelRelaysModel.groupId == gInfo.groupId else { return nil }
let relays = channelRelaysModel.groupRelays
guard !relays.isEmpty else { return ([], 0, 0, 0, true) }
let relayMembers = relays.map { relay in
(relay, chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })?.wrapped)
}
@@ -763,7 +763,11 @@ struct ComposeView: View {
if !allBroken && activeCount + failedCount + removedCount < total {
RelayProgressIndicator(active: activeCount, total: total)
}
if allBroken {
if total == 0 {
Text("No relays")
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
} else if allBroken {
if removedCount == total {
Text("All relays removed")
} else if failedCount == total {
@@ -793,7 +797,7 @@ struct ComposeView: View {
}
if relayListExpanded {
if allBroken {
Text("Adding relays will be supported later.")
Text("Add relays to restore message delivery.")
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption)
.foregroundColor(theme.colors.secondary)
@@ -843,7 +847,11 @@ struct ComposeView: View {
let allBroken = connectedCount == 0 && errorCount == total
VStack(spacing: 0) {
relayBarHeader {
if allBroken {
if total == 0 {
Text("No relays")
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
} else if allBroken {
if removedCount == total {
Text("All relays removed")
} else if failedCount == total {
@@ -0,0 +1,161 @@
//
// AddGroupRelayView.swift
// SimpleX (iOS)
//
// Created by simplex on 29.04.2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct AddGroupRelayView: View {
var groupInfo: GroupInfo
var existingRelayIds: Set<Int64>
var onRelayAdded: () -> Void
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss
@State private var availableRelays: [(relayId: Int64, relay: UserChatRelay, operatorName: String?)] = []
@State private var selectedRelayIds: Set<Int64> = []
@State private var isLoading = true
@State private var isAdding = false
var body: some View {
NavigationView {
List {
if isLoading {
Section {
ProgressView()
.frame(maxWidth: .infinity)
}
} else if availableRelays.isEmpty {
Section {
Text("No available relays")
.foregroundColor(theme.colors.secondary)
}
} else {
Section {
ForEach(availableRelays, id: \.relayId) { item in
relayCheckRow(item.relayId, item.relay, operatorName: item.operatorName)
}
}
}
}
.modifier(ThemedBackground(grouped: true))
.navigationTitle("Add relays")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Add") { addSelectedRelays() }
.disabled(selectedRelayIds.isEmpty || isAdding)
}
}
}
.task { await loadAvailableRelays() }
}
private func relayCheckRow(_ relayId: Int64, _ relay: UserChatRelay, operatorName: String?) -> some View {
let selected = selectedRelayIds.contains(relayId)
return Button {
if selected {
selectedRelayIds.remove(relayId)
} else {
selectedRelayIds.insert(relayId)
}
} label: {
HStack {
VStack(alignment: .leading) {
Text(chatRelayDisplayName(relay))
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
if let opName = operatorName {
Text(opName)
.font(.caption)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
}
}
Spacer()
Image(systemName: selected ? "checkmark.circle.fill" : "circle")
.foregroundColor(selected ? theme.colors.primary : Color(uiColor: .tertiaryLabel).asAnotherColorFromSecondary(theme))
}
}
}
private func loadAvailableRelays() async {
do {
let servers = try await getUserServers()
var relays: [(relayId: Int64, relay: UserChatRelay, operatorName: String?)] = []
for op in servers {
if let oper = op.operator, oper.enabled != true { continue }
let opName: String? = op.operator?.operatorTag != nil ? op.operator?.tradeName : nil
for relay in op.chatRelays {
if relay.enabled && !relay.deleted,
let relayId = relay.chatRelayId,
!existingRelayIds.contains(relayId) {
relays.append((relayId, relay, opName))
}
}
}
await MainActor.run {
availableRelays = relays
isLoading = false
}
} catch {
logger.error("loadAvailableRelays error: \(responseError(error))")
await MainActor.run {
isLoading = false
}
}
}
private func addSelectedRelays() {
let relayIds = Array(selectedRelayIds)
guard !relayIds.isEmpty else { return }
isAdding = true
Task {
do {
guard let result = try await apiAddGroupRelays(groupInfo.groupId, relayIds: relayIds) else {
await MainActor.run { isAdding = false }
return
}
await MainActor.run {
isAdding = false
switch result {
case let .added(gInfo, _, relays):
ChannelRelaysModel.shared.set(groupId: gInfo.groupId, groupRelays: relays)
onRelayAdded()
dismiss()
case let .addFailed(results):
let successIds = Set(results.filter { $0.relayError == nil }.compactMap { $0.relay.chatRelayId })
if !successIds.isEmpty {
selectedRelayIds.subtract(successIds)
availableRelays.removeAll { successIds.contains($0.relayId) }
onRelayAdded()
}
let errorLines = results.filter { $0.relayError != nil }
.map { "\(chatRelayDisplayName($0.relay)): \($0.relayError.map { connErrorText($0) } ?? "")" }
let successNames = results.filter { $0.relayError == nil }
.map { chatRelayDisplayName($0.relay) }
var msg = errorLines.joined(separator: "\n")
if !successNames.isEmpty {
msg += "\n" + String.localizedStringWithFormat(NSLocalizedString("Relays added: %@.", comment: "alert message"), successNames.joined(separator: ", "))
}
showAlert(
NSLocalizedString("Error adding relays", comment: "alert title"),
message: msg
)
}
}
} catch {
await MainActor.run {
isAdding = false
showAlert(NSLocalizedString("Error adding relays", comment: "alert title"), message: responseError(error))
}
}
}
}
}
@@ -14,24 +14,47 @@ struct ChannelRelaysView: View {
var groupInfo: GroupInfo
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State private var groupRelays: [GroupRelay] = []
@ObservedObject private var channelRelaysModel = ChannelRelaysModel.shared
@State private var showAddRelay = false
private var groupRelays: [GroupRelay] {
channelRelaysModel.groupId == groupInfo.groupId ? channelRelaysModel.groupRelays : []
}
var body: some View {
List {
relaysList()
if groupInfo.isOwner {
Section {
Button {
showAddRelay = true
} label: {
Label("Add relay", systemImage: "plus")
}
}
}
}
.sheet(isPresented: $showAddRelay) {
let existingRelayIds = Set(groupRelays.filter { $0.relayStatus != .rsInactive }.compactMap { $0.userChatRelay.chatRelayId })
AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) {
Task { await chatModel.loadGroupMembers(groupInfo) }
}
}
.onAppear {
Task {
await chatModel.loadGroupMembers(groupInfo)
if groupInfo.isOwner {
groupRelays = await apiGetGroupRelays(groupInfo.groupId)
let relays = await apiGetGroupRelays(groupInfo.groupId)
await MainActor.run {
ChannelRelaysModel.shared.set(groupId: groupInfo.groupId, groupRelays: relays)
}
}
}
}
}
@ViewBuilder private func relaysList() -> some View {
let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay }
let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberStatus != .memRemoved && $0.wrapped.memberStatus != .memGroupDeleted }
if relayMembers.isEmpty {
Section {
Text("No chat relays")
@@ -40,7 +63,7 @@ struct ChannelRelaysView: View {
} else {
Section {
ForEach(relayMembers) { member in
NavigationLink {
let link = NavigationLink {
GroupMemberInfoView(
groupInfo: groupInfo,
chat: chat,
@@ -55,6 +78,18 @@ struct ChannelRelaysView: View {
: subscriberRelayStatusText(member.wrapped)
relayMemberRow(member.wrapped, statusText: statusText)
}
if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) {
link.swipeActions(edge: .trailing) {
Button {
showRemoveMemberAlert(groupInfo, member.wrapped)
} label: {
Label("Remove relay", systemImage: "trash")
}
.tint(.red)
}
} else {
link
}
}
} footer: {
Text("Chat relays forward messages to channel subscribers.")
@@ -924,26 +924,54 @@ struct GroupChatInfoView: View {
}
func showRemoveMemberAlert(_ groupInfo: GroupInfo, _ mem: GroupMember, dismiss: DismissAction? = nil) {
showAlert(
groupInfo.useRelays
? NSLocalizedString("Remove subscriber?", comment: "alert title")
: NSLocalizedString("Remove member?", comment: "alert title"),
message:
groupInfo.useRelays
? NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message")
: groupInfo.businessChat == nil
? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
: NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
actions: {[
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
},
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
},
cancelAlertAction
]}
)
if mem.memberRole == .relay {
let isLastActive = groupInfo.useRelays && mem.memberCurrent && {
let activeRelays = ChatModel.shared.groupMembers.filter { $0.wrapped.memberRole == .relay && $0.wrapped.memberCurrent }
return activeRelays.count <= 1
}()
showAlert(
NSLocalizedString("Remove relay?", comment: "alert title"),
message: isLastActive
? NSLocalizedString("This is the last active relay. Removing it will prevent message delivery to subscribers.", comment: "alert message")
: NSLocalizedString("Relay will be removed from channel - this cannot be undone!", comment: "alert message"),
actions: {[
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
},
cancelAlertAction
]}
)
} else if groupInfo.useRelays {
showAlert(
NSLocalizedString("Remove subscriber?", comment: "alert title"),
message: NSLocalizedString("Subscriber will be removed from channel - this cannot be undone!", comment: "alert message"),
actions: {[
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
},
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
},
cancelAlertAction
]}
)
} else {
showAlert(
NSLocalizedString("Remove member?", comment: "alert title"),
message: groupInfo.businessChat == nil
? NSLocalizedString("Member will be removed from group - this cannot be undone!", comment: "alert message")
: NSLocalizedString("Member will be removed from chat - this cannot be undone!", comment: "alert message"),
actions: {[
UIAlertAction(title: NSLocalizedString("Remove", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: false, dismiss: dismiss)
},
UIAlertAction(title: NSLocalizedString("Remove and delete messages", comment: "alert action"), style: .destructive) { _ in
removeMember(groupInfo, mem, withMessages: true, dismiss: dismiss)
},
cancelAlertAction
]}
)
}
}
func removeMember(_ groupInfo: GroupInfo, _ mem: GroupMember, withMessages: Bool, dismiss: DismissAction?) {
@@ -641,13 +641,11 @@ struct GroupMemberInfoView: View {
blockForAllButton(mem)
}
}
// TODO [relays] removing relay should also remove its link from group link data;
// TODO - removing last relay should be prohibited or show warning
if canRemove && mem.memberRole != .relay {
if mem.memberStatus == .memRemoved || mem.memberStatus == .memLeft {
deleteMemberMessagesButton(mem)
} else {
if canRemove {
if mem.memberStatus != .memRemoved && (mem.memberStatus != .memLeft || mem.memberRole == .relay) {
removeMemberButton(mem)
} else if mem.memberRole != .relay {
deleteMemberMessagesButton(mem)
}
}
}
@@ -705,7 +703,10 @@ struct GroupMemberInfoView: View {
Button(role: .destructive) {
showRemoveMemberAlert(groupInfo, mem, dismiss: dismiss)
} label: {
Label(groupInfo.useRelays ? "Remove subscriber" : "Remove member", systemImage: "trash")
let text = mem.memberRole == .relay ? "Remove relay"
: groupInfo.useRelays ? "Remove subscriber"
: "Remove member"
Label(text, systemImage: "trash")
.foregroundColor(.red)
}
}
@@ -479,7 +479,7 @@ func relayDisplayName(_ relay: GroupRelay) -> String {
return "relay \(relay.groupRelayId)"
}
private func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
if !relay.displayName.isEmpty { return relay.displayName }
return relay.address
}
@@ -487,7 +487,7 @@ private func chatRelayDisplayName(_ relay: UserChatRelay) -> String {
func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false, memberStatus: GroupMemberStatus? = nil) -> some View {
let removed = memberStatus.map { [.memLeft, .memRemoved, .memGroupDeleted].contains($0) } ?? false
let color: Color = connFailed || removed ? .red : (status == .rsActive ? .green : .yellow)
let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : status.text
let text: LocalizedStringKey = connFailed ? "failed" : memberStatus == .memLeft ? "removed by operator" : removed ? "removed" : status.text
return HStack(spacing: 4) {
Circle()
.fill(color)
@@ -169,6 +169,7 @@
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; };
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; };
6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7072F48D0000060512B /* AddGroupRelayView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64A779F62DBFB9F200FDEF2F /* MemberAdmissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */; };
@@ -546,6 +547,7 @@
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = "<group>"; };
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = "<group>"; };
6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64A779F52DBFB9F200FDEF2F /* MemberAdmissionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemberAdmissionView.swift; sourceTree = "<group>"; };
@@ -1173,6 +1175,7 @@
64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */,
6495D7032F48CFC50060512B /* ChannelMembersView.swift */,
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */,
6495D7072F48D0000060512B /* AddGroupRelayView.swift */,
);
path = Group;
sourceTree = "<group>";
@@ -1633,6 +1636,7 @@
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */,
647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */,
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */,
6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */,
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */,
@@ -92,6 +92,7 @@ object ChannelRelaysModel {
if (groupId.value == groupInfo.groupId) {
val i = groupRelays.indexOfFirst { it.groupRelayId == relay.groupRelayId }
if (i >= 0) groupRelays[i] = relay
else groupRelays.add(relay)
}
}
@@ -2163,6 +2163,19 @@ object ChatController {
return emptyList()
}
sealed class AddGroupRelaysResult {
data class Added(val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): AddGroupRelaysResult()
data class AddFailed(val addRelayResults: List<AddRelayResult>): AddGroupRelaysResult()
}
suspend fun apiAddGroupRelays(groupId: Long, relayIds: List<Long>): AddGroupRelaysResult? {
val r = sendCmdWithRetry(null, CC.ApiAddGroupRelays(groupId, relayIds))
if (r is API.Result && r.res is CR.GroupRelaysAdded) return AddGroupRelaysResult.Added(r.res.groupInfo, r.res.groupLink, r.res.groupRelays)
if (r is API.Result && r.res is CR.GroupRelaysAddFailed) return AddGroupRelaysResult.AddFailed(r.res.addRelayResults)
if (r != null) throw Exception("${r.responseType}: ${r.details}")
return null
}
suspend fun apiAddMember(rh: Long?, groupId: Long, contactId: Long, memberRole: GroupMemberRole): GroupMember? {
val r = sendCmd(rh, CC.ApiAddMember(groupId, contactId, memberRole))
if (r is API.Result && r.res is CR.SentGroupInvitation) return r.res.member
@@ -3666,6 +3679,7 @@ sealed class CC {
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiNewPublicGroup(val userId: Long, val incognito: Boolean, val relayIds: List<Long>, val groupProfile: GroupProfile): CC()
class ApiGetGroupRelays(val groupId: Long): CC()
class ApiAddGroupRelays(val groupId: Long, val relayIds: List<Long>): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
class ApiAcceptMember(val groupId: Long, val groupMemberId: Long, val memberRole: GroupMemberRole): CC()
@@ -3870,6 +3884,7 @@ sealed class CC {
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiNewPublicGroup -> "/_public group $userId incognito=${onOff(incognito)} ${relayIds.joinToString(",")} ${json.encodeToString(groupProfile)}"
is ApiGetGroupRelays -> "/_get relays #$groupId"
is ApiAddGroupRelays -> "/_add relays #$groupId ${relayIds.joinToString(",")}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
is ApiAcceptMember -> "/_accept member #$groupId $groupMemberId ${memberRole.memberRole}"
@@ -4053,6 +4068,7 @@ sealed class CC {
is ApiNewGroup -> "apiNewGroup"
is ApiNewPublicGroup -> "apiNewPublicGroup"
is ApiGetGroupRelays -> "apiGetGroupRelays"
is ApiAddGroupRelays -> "apiAddGroupRelays"
is ApiAddMember -> "apiAddMember"
is ApiJoinGroup -> "apiJoinGroup"
is ApiAcceptMember -> "apiAcceptMember"
@@ -6402,6 +6418,8 @@ sealed class CR {
@Serializable @SerialName("publicGroupCreated") class PublicGroupCreated(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): CR()
@Serializable @SerialName("publicGroupCreationFailed") class PublicGroupCreationFailed(val user: UserRef, val addRelayResults: List<AddRelayResult>): CR()
@Serializable @SerialName("groupRelays") class GroupRelays(val user: UserRef, val groupInfo: GroupInfo, val groupRelays: List<GroupRelay>): CR()
@Serializable @SerialName("groupRelaysAdded") class GroupRelaysAdded(val user: UserRef, val groupInfo: GroupInfo, val groupLink: GroupLink, val groupRelays: List<GroupRelay>): CR()
@Serializable @SerialName("groupRelaysAddFailed") class GroupRelaysAddFailed(val user: UserRef, val addRelayResults: List<AddRelayResult>): CR()
@Serializable @SerialName("sentGroupInvitation") class SentGroupInvitation(val user: UserRef, val groupInfo: GroupInfo, val contact: Contact, val member: GroupMember): CR()
@Serializable @SerialName("userAcceptedGroupSent") class UserAcceptedGroupSent (val user: UserRef, val groupInfo: GroupInfo, val hostContact: Contact? = null): CR()
@Serializable @SerialName("groupLinkConnecting") class GroupLinkConnecting (val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember): CR()
@@ -6591,6 +6609,8 @@ sealed class CR {
is PublicGroupCreated -> "publicGroupCreated"
is PublicGroupCreationFailed -> "publicGroupCreationFailed"
is GroupRelays -> "groupRelays"
is GroupRelaysAdded -> "groupRelaysAdded"
is GroupRelaysAddFailed -> "groupRelaysAddFailed"
is SentGroupInvitation -> "sentGroupInvitation"
is UserAcceptedGroupSent -> "userAcceptedGroupSent"
is GroupLinkConnecting -> "groupLinkConnecting"
@@ -6773,6 +6793,8 @@ sealed class CR {
is PublicGroupCreated -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays")
is PublicGroupCreationFailed -> withUser(user, "addRelayResults: $addRelayResults")
is GroupRelays -> withUser(user, "groupInfo: $groupInfo\ngroupRelays: $groupRelays")
is GroupRelaysAdded -> withUser(user, "groupInfo: $groupInfo\ngroupLink: $groupLink\ngroupRelays: $groupRelays")
is GroupRelaysAddFailed -> withUser(user, "addRelayResults: $addRelayResults")
is SentGroupInvitation -> withUser(user, "groupInfo: $groupInfo\ncontact: $contact\nmember: $member")
is UserAcceptedGroupSent -> json.encodeToString(groupInfo)
is GroupLinkConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember")
@@ -211,7 +211,7 @@ fun ChatView(
withContext(Dispatchers.Main) {
ChannelRelaysModel.set(cInfo.groupInfo.groupId, relays)
}
} else {
} else if (cInfo.groupInfo.membership.memberCurrent) {
val gInfo = chatModel.controller.apiGetUpdatedGroupLinkData(chatRh, cInfo.groupInfo.groupId)
if (gInfo != null) {
withContext(Dispatchers.Main) {
@@ -1543,14 +1543,14 @@ fun ComposeView(
) {
if (gInfo.membership.memberRole == GroupMemberRole.Owner) {
ownerRelayState?.let { s ->
if (s.activeCount < s.relays.size) {
if (s.relays.isEmpty() || s.activeCount < s.relays.size) {
OwnerChannelRelayBar(chatModel, s.relays, s.activeCount, s.failedCount, s.removedCount, relayListExpanded)
}
}
} else {
val hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?: emptyList()).sorted()
val relayMembers = chatModel.groupMembers.value
.filter { it.memberRole == GroupMemberRole.Relay }
.filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus !in listOf(GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted) }
.sortedBy { hostFromRelayLink(it.relayLink ?: "") }
val showProgress = !gInfo.nextConnectPrepared || composeState.value.inProgress
val removedCount = relayMembers.count { relayMemberRemoved(it.memberStatus) }
@@ -1558,7 +1558,7 @@ fun ComposeView(
val failedCount = relayMembers.count { !relayMemberRemoved(it.memberStatus) && it.activeConn?.connFailedErr != null }
val resolvedCount = connectedCount + removedCount + failedCount
val total = if (relayMembers.isNotEmpty()) relayMembers.size else hostnames.size
if (total > 0 && (removedCount + failedCount > 0 || resolvedCount < total)) {
if (total == 0 || removedCount + failedCount > 0 || resolvedCount < total) {
SubscriberChannelRelayBar(hostnames, relayMembers, connectedCount, removedCount, failedCount, total, showProgress, relayListExpanded)
}
}
@@ -1756,7 +1756,15 @@ private fun OwnerChannelRelayBar(
if (!allBroken && activeCount + failedCount + removedCount < total) {
RelayProgressIndicator(active = activeCount, total = total)
}
if (allBroken) {
if (total == 0) {
Text(generalGetString(MR.strings.relay_bar_no_relays), color = MaterialTheme.colors.secondary)
Icon(
painterResource(MR.images.ic_warning),
contentDescription = null,
tint = WarningOrange,
modifier = Modifier.size(18.dp)
)
} else if (allBroken) {
val statusText = if (removedCount == total) {
generalGetString(MR.strings.relay_bar_all_relays_removed)
} else if (failedCount == total) {
@@ -1842,7 +1850,15 @@ private fun SubscriberChannelRelayBar(
val allBroken = connectedCount == 0 && errorCount == total
Column(Modifier.background(MaterialTheme.colors.surface)) {
RelayBarHeader(relayListExpanded) {
if (allBroken) {
if (total == 0) {
Text(generalGetString(MR.strings.relay_bar_no_relays), color = MaterialTheme.colors.secondary)
Icon(
painterResource(MR.images.ic_warning),
contentDescription = null,
tint = WarningOrange,
modifier = Modifier.size(18.dp)
)
} else if (allBroken) {
val statusText = if (removedCount == total) {
generalGetString(MR.strings.relay_bar_all_relays_removed)
} else if (failedCount == total) {
@@ -1990,7 +2006,7 @@ private fun ownerRelayState(chat: Chat, chatModel: ChatModel): OwnerRelayState?
gInfo.membership.memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
) return null
val relays = if (ChannelRelaysModel.groupId.value == gInfo.groupId) ChannelRelaysModel.groupRelays.toList() else emptyList()
if (relays.isEmpty()) return null
if (relays.isEmpty()) return OwnerRelayState(emptyList(), 0, 0, 0, true)
val relayMembers = relays.map { relay ->
relay to chatModel.groupMembers.value.firstOrNull { it.groupMemberId == relay.groupMemberId }
}
@@ -0,0 +1,237 @@
package chat.simplex.common.views.chat.group
import SectionBottomSpacer
import SectionCustomFooter
import SectionDividerSpaced
import SectionItemView
import SectionView
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.newchat.chatRelayDisplayName
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import kotlinx.coroutines.launch
data class AvailableRelay(
val relayId: Long,
val relay: UserChatRelay,
val operatorName: String?
)
@Composable
fun AddGroupRelayView(
groupInfo: GroupInfo,
existingRelayIds: Set<Long>,
onRelayAdded: () -> Unit,
close: () -> Unit
) {
var availableRelays by remember { mutableStateOf<List<AvailableRelay>>(emptyList()) }
var selectedRelayIds by remember { mutableStateOf<Set<Long>>(emptySet()) }
var isLoading by remember { mutableStateOf(true) }
var isAdding by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
BackHandler(onBack = close)
LaunchedEffect(Unit) {
try {
val servers = ChatController.getUserServers(null)
if (servers != null) {
val relays = mutableListOf<AvailableRelay>()
for (op in servers) {
if (op.operator != null && op.operator.enabled != true) continue
val opName: String? = if (op.operator?.operatorTag != null) op.operator.tradeName else null
for (relay in op.chatRelays) {
val relayId = relay.chatRelayId
if (relay.enabled && !relay.deleted && relayId != null && relayId !in existingRelayIds) {
relays.add(AvailableRelay(relayId, relay, opName))
}
}
}
availableRelays = relays
}
} catch (e: Exception) {
Log.e(TAG, "loadAvailableRelays error: ${e.message}")
}
isLoading = false
}
AddGroupRelayLayout(
availableRelays = availableRelays,
selectedRelayIds = selectedRelayIds,
isLoading = isLoading,
isAdding = isAdding,
onToggleRelay = { relayId ->
selectedRelayIds = if (relayId in selectedRelayIds) selectedRelayIds - relayId else selectedRelayIds + relayId
},
onAddRelays = {
val relayIds = selectedRelayIds.toList()
if (relayIds.isEmpty()) return@AddGroupRelayLayout
isAdding = true
scope.launch {
addSelectedRelays(groupInfo, relayIds, selectedRelayIds, availableRelays, onRelayAdded, close) { newSelectedIds, newAvailableRelays ->
selectedRelayIds = newSelectedIds
availableRelays = newAvailableRelays
isAdding = false
}
}
}
)
}
@Composable
private fun AddGroupRelayLayout(
availableRelays: List<AvailableRelay>,
selectedRelayIds: Set<Long>,
isLoading: Boolean,
isAdding: Boolean,
onToggleRelay: (Long) -> Unit,
onAddRelays: () -> Unit
) {
ColumnWithScrollBar {
AppBarTitle(generalGetString(MR.strings.add_relays_title))
if (isLoading) {
Box(Modifier.fillMaxWidth().padding(vertical = DEFAULT_PADDING), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (availableRelays.isEmpty()) {
SectionView {
SectionItemView(padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Text(
generalGetString(MR.strings.no_available_relays),
color = MaterialTheme.colors.secondary
)
}
}
} else {
SectionView {
AddRelaysButton(
onClick = onAddRelays,
disabled = selectedRelayIds.isEmpty() || isAdding
)
}
SectionCustomFooter {
val count = selectedRelayIds.size
Text(
if (count == 0) generalGetString(MR.strings.no_relays_selected)
else String.format(generalGetString(MR.strings.num_relays_selected), count),
color = MaterialTheme.colors.secondary,
lineHeight = 18.sp,
fontSize = 14.sp
)
}
SectionDividerSpaced(maxTopPadding = true)
SectionView(generalGetString(MR.strings.select_relays).uppercase()) {
availableRelays.forEach { item ->
val selected = item.relayId in selectedRelayIds
SectionItemView(
click = { onToggleRelay(item.relayId) },
padding = PaddingValues(horizontal = DEFAULT_PADDING, vertical = 4.dp)
) {
Column(Modifier.weight(1f)) {
Text(
chatRelayDisplayName(item.relay),
maxLines = 1,
color = MaterialTheme.colors.onBackground
)
if (item.operatorName != null) {
Text(
item.operatorName,
fontSize = 12.sp,
maxLines = 1,
color = MaterialTheme.colors.secondary
)
}
}
Spacer(Modifier.width(8.dp))
Icon(
painterResource(if (selected) MR.images.ic_check_circle_filled else MR.images.ic_circle),
contentDescription = null,
tint = if (selected) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
modifier = Modifier.size(24.dp)
)
}
}
}
}
SectionBottomSpacer()
}
}
@Composable
private fun AddRelaysButton(onClick: () -> Unit, disabled: Boolean) {
SettingsActionItem(
painterResource(MR.images.ic_check),
generalGetString(MR.strings.add_relays_title),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
disabled = disabled,
)
}
private suspend fun addSelectedRelays(
groupInfo: GroupInfo,
relayIds: List<Long>,
selectedRelayIds: Set<Long>,
availableRelays: List<AvailableRelay>,
onRelayAdded: () -> Unit,
close: () -> Unit,
updateState: (Set<Long>, List<AvailableRelay>) -> Unit
) {
try {
val result = ChatController.apiAddGroupRelays(groupInfo.groupId, relayIds)
if (result == null) {
updateState(selectedRelayIds, availableRelays)
return
}
when (result) {
is ChatController.AddGroupRelaysResult.Added -> {
ChannelRelaysModel.set(groupId = result.groupInfo.groupId, groupRelays = result.groupRelays)
onRelayAdded()
close()
}
is ChatController.AddGroupRelaysResult.AddFailed -> {
val results = result.addRelayResults
val successIds = results.filter { it.relayError == null }.mapNotNull { it.relay.chatRelayId }.toSet()
var newSelectedIds = selectedRelayIds
var newAvailableRelays = availableRelays
if (successIds.isNotEmpty()) {
newSelectedIds = selectedRelayIds - successIds
newAvailableRelays = availableRelays.filter { it.relayId !in successIds }
onRelayAdded()
}
val errorLines = results.filter { it.relayError != null }
.map { "${chatRelayDisplayName(it.relay)}: ${it.relayError?.let { e -> ChatController.connErrorText(e) } ?: ""}" }
val successNames = results.filter { it.relayError == null }
.map { chatRelayDisplayName(it.relay) }
var msg = errorLines.joinToString("\n")
if (successNames.isNotEmpty()) {
msg += "\n" + String.format(generalGetString(MR.strings.relays_added_format), successNames.joinToString(", "))
}
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_adding_relays),
text = msg
)
updateState(newSelectedIds, newAvailableRelays)
}
}
} catch (e: Exception) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.error_adding_relays),
text = e.message ?: ""
)
updateState(selectedRelayIds, availableRelays)
}
}
@@ -2,6 +2,7 @@ package chat.simplex.common.views.chat.group
import SectionBottomSpacer
import SectionItemView
import SectionItemViewLongClickable
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.*
@@ -16,9 +17,11 @@ import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.chatlist.setGroupMembers
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun ChannelRelaysView(
@@ -29,16 +32,18 @@ fun ChannelRelaysView(
showMemberInfo: (GroupMember, GroupRelay?) -> Unit
) {
BackHandler(onBack = close)
var groupRelays by remember { mutableStateOf<List<GroupRelay>>(emptyList()) }
val groupRelays = ChannelRelaysModel.groupRelays
LaunchedEffect(Unit) {
setGroupMembers(rhId, groupInfo, chatModel)
if (groupInfo.isOwner) {
groupRelays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
ChannelRelaysModel.set(groupId = groupInfo.groupId, groupRelays = relays)
}
}
ChannelRelaysLayout(
rhId = rhId,
groupInfo = groupInfo,
chatModel = chatModel,
groupRelays = groupRelays,
@@ -48,13 +53,14 @@ fun ChannelRelaysView(
@Composable
private fun ChannelRelaysLayout(
rhId: Long?,
groupInfo: GroupInfo,
chatModel: ChatModel,
groupRelays: List<GroupRelay>,
showMemberInfo: (GroupMember, GroupRelay?) -> Unit
) {
val relayMembers = remember { chatModel.groupMembers }.value
.filter { it.memberRole == GroupMemberRole.Relay }
.filter { it.memberRole == GroupMemberRole.Relay && it.memberStatus != GroupMemberStatus.MemRemoved && it.memberStatus != GroupMemberStatus.MemGroupDeleted }
ColumnWithScrollBar {
AppBarTitle(generalGetString(MR.strings.channel_relays_title))
@@ -74,11 +80,21 @@ private fun ChannelRelaysLayout(
if (index > 0) {
Divider()
}
SectionItemView(
val showMenu = remember { mutableStateOf(false) }
SectionItemViewLongClickable(
click = { showMemberInfo(member, groupRelays.firstOrNull { it.groupMemberId == member.groupMemberId }) },
longClick = { showMenu.value = true },
minHeight = 54.dp,
padding = PaddingValues(horizontal = DEFAULT_PADDING)
) {
if (groupInfo.isOwner && member.canBeRemoved(groupInfo)) {
DefaultDropdownMenu(showMenu) {
ItemAction(generalGetString(MR.strings.button_remove_relay), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
removeMemberAlert(rhId, groupInfo, member)
showMenu.value = false
})
}
}
val statusText = if (groupInfo.isOwner) {
ownerRelayStatusText(member, groupRelays)
} else {
@@ -90,6 +106,32 @@ private fun ChannelRelaysLayout(
}
SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages))
}
if (groupInfo.isOwner) {
SectionView {
SectionItemView(click = {
val existingRelayIds = groupRelays.filter { it.relayStatus != RelayStatus.RsInactive }.mapNotNull { it.userChatRelay.chatRelayId }.toSet()
ModalManager.end.showModalCloseable(true) { close ->
AddGroupRelayView(
groupInfo = groupInfo,
existingRelayIds = existingRelayIds,
onRelayAdded = { withBGApi { setGroupMembers(rhId, groupInfo, chatModel) } },
close = close
)
}
}, padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Icon(
painterResource(MR.images.ic_add),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
Spacer(Modifier.width(4.dp))
Text(
generalGetString(MR.strings.add_relay_button),
color = MaterialTheme.colors.primary
)
}
}
}
SectionBottomSpacer()
}
}
@@ -239,39 +239,88 @@ fun leaveGroupDialog(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel, cl
)
}
private fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) {
val titleId = if (groupInfo.useRelays) MR.strings.button_remove_subscriber_question
else MR.strings.button_remove_member_question
val messageId = if (groupInfo.useRelays)
MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone
else if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(titleId),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
fun removeMemberAlert(rhId: Long?, groupInfo: GroupInfo, mem: GroupMember) {
if (mem.memberRole == GroupMemberRole.Relay) {
val isLastActive = groupInfo.useRelays && mem.memberCurrent && run {
val activeRelays = ChatModel.groupMembers.value.filter { it.memberRole == GroupMemberRole.Relay && it.memberCurrent }
activeRelays.size <= 1
}
val message = if (isLastActive) generalGetString(MR.strings.last_active_relay_warning)
else generalGetString(MR.strings.relay_will_be_removed_from_channel)
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_relay_question),
message,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
})
} else if (groupInfo.useRelays) {
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_subscriber_question),
generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
})
} else {
val titleId = MR.strings.button_remove_member_question
val messageId = if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(titleId),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = false)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMembers(rhId, groupInfo, listOf(mem.groupMemberId), withMessages = true)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
})
})
}
}
private fun removeMembersAlert(rhId: Long?, groupInfo: GroupInfo, memberIds: List<Long>, onSuccess: () -> Unit = {}) {
@@ -242,34 +242,86 @@ fun GroupMemberInfoView(
}
fun removeMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
val messageId = if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_member_question),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
if (member.memberRole == GroupMemberRole.Relay) {
val isLastActive = groupInfo.useRelays && run {
val activeRelays = chatModel.groupMembers.value.filter { it.memberRole == GroupMemberRole.Relay && it.memberCurrent }
activeRelays.size <= 1
}
val message = if (isLastActive) generalGetString(MR.strings.last_active_relay_warning)
else generalGetString(MR.strings.relay_will_be_removed_from_channel)
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_relay_question),
message,
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
})
} else if (groupInfo.useRelays) {
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_subscriber_question),
generalGetString(MR.strings.subscriber_will_be_removed_from_channel_cannot_be_undone),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
})
} else {
val messageId = if (groupInfo.businessChat == null)
MR.strings.member_will_be_removed_from_group_cannot_be_undone
else
MR.strings.member_will_be_removed_from_chat_cannot_be_undone
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.button_remove_member_question),
generalGetString(messageId),
buttons = {
Column {
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = false, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
removeMember(rhId, groupInfo, member, withMessages = true, chatModel, close)
}) {
Text(generalGetString(MR.strings.remove_member_delete_messages_confirmation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = Color.Red)
}
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(generalGetString(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
})
})
}
}
fun deleteMemberMessagesDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, chatModel: ChatModel, close: (() -> Unit)? = null) {
@@ -368,7 +420,7 @@ fun GroupMemberInfoLayout(
@Composable
fun ModeratorDestructiveSection() {
val canBlockForAll = member.canBlockForAll(groupInfo)
val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay
val canRemove = member.canBeRemoved(groupInfo)
if (canBlockForAll || canRemove) {
SectionDividerSpaced(maxBottomPadding = false)
SectionView {
@@ -380,10 +432,10 @@ fun GroupMemberInfoLayout(
}
}
if (canRemove) {
if (member.memberStatus == GroupMemberStatus.MemRemoved || member.memberStatus == GroupMemberStatus.MemLeft) {
if (member.memberStatus != GroupMemberStatus.MemRemoved && (member.memberStatus != GroupMemberStatus.MemLeft || member.memberRole == GroupMemberRole.Relay)) {
RemoveMemberButton(groupInfo.useRelays, member.memberRole == GroupMemberRole.Relay, removeMember)
} else if (member.memberRole != GroupMemberRole.Relay) {
DeleteMemberMessagesButton(deleteMemberMessages)
} else {
RemoveMemberButton(groupInfo.useRelays, removeMember)
}
}
}
@@ -753,8 +805,10 @@ fun UnblockForAllButton(onClick: () -> Unit) {
}
@Composable
fun RemoveMemberButton(useRelays: Boolean = false, onClick: () -> Unit) {
val label = if (useRelays) MR.strings.button_remove_subscriber else MR.strings.button_remove_member
fun RemoveMemberButton(useRelays: Boolean = false, isRelay: Boolean = false, onClick: () -> Unit) {
val label = if (isRelay) MR.strings.button_remove_relay
else if (useRelays) MR.strings.button_remove_subscriber
else MR.strings.button_remove_member
SettingsActionItem(
painterResource(MR.images.ic_delete),
stringResource(label),
@@ -588,7 +588,7 @@ fun relayDisplayName(relay: GroupRelay): String {
return "relay ${relay.groupRelayId}"
}
private fun chatRelayDisplayName(relay: UserChatRelay): String {
fun chatRelayDisplayName(relay: UserChatRelay): String {
if (relay.displayName.isNotEmpty()) return relay.displayName
return relay.address
}
@@ -597,7 +597,7 @@ private fun chatRelayDisplayName(relay: UserChatRelay): String {
fun RelayStatusIndicator(status: RelayStatus, connFailed: Boolean = false, memberStatus: GroupMemberStatus? = null) {
val removed = memberStatus in listOf(GroupMemberStatus.MemLeft, GroupMemberStatus.MemRemoved, GroupMemberStatus.MemGroupDeleted)
val color = if (connFailed || removed) Color.Red else if (status == RelayStatus.RsActive) Color.Green else WarningYellow
val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else status.text
val text = if (connFailed) generalGetString(MR.strings.relay_status_failed) else if (memberStatus == GroupMemberStatus.MemLeft) generalGetString(MR.strings.relay_conn_status_removed_by_operator) else if (removed) generalGetString(MR.strings.relay_conn_status_removed) else status.text
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
@@ -2986,6 +2986,7 @@
<string name="relay_conn_status_deleted">deleted</string>
<string name="relay_conn_status_failed">failed</string>
<string name="relay_conn_status_removed_by_operator">removed by operator</string>
<string name="relay_conn_status_removed">removed</string>
<string name="relay_status_new">new</string>
<string name="relay_status_invited">invited</string>
<string name="relay_status_accepted">accepted</string>
@@ -3007,7 +3008,8 @@
<string name="relay_bar_connected_with_failures">%1$d/%2$d relays connected, %3$d failed</string>
<string name="relay_bar_connected_with_removed">%1$d/%2$d relays connected, %3$d removed</string>
<string name="relay_bar_connected">%1$d/%2$d relays connected</string>
<string name="relay_bar_owner_no_delivery">Adding relays will be supported later.</string>
<string name="relay_bar_no_relays">No relays</string>
<string name="relay_bar_owner_no_delivery">Add relays to restore message delivery.</string>
<string name="relay_bar_subscriber_waiting">Waiting for channel owner to add relays.</string>
<!-- GroupMemberInfoView.kt channel-related -->
@@ -3022,6 +3024,10 @@
<string name="relay_section_footer_owner">Subscribers use relay link to connect to the channel.\nRelay address was used to set up this relay for the channel.</string>
<string name="relay_section_footer_subscriber">You connected to the channel via this relay link.</string>
<string name="button_remove_subscriber">Remove subscriber</string>
<string name="button_remove_relay">Remove relay</string>
<string name="button_remove_relay_question">Remove relay?</string>
<string name="relay_will_be_removed_from_channel">Relay will be removed from channel - this cannot be undone!</string>
<string name="last_active_relay_warning">This is the last active relay. Removing it will prevent message delivery to subscribers.</string>
<string name="block_subscriber_for_all_question">Block subscriber for all?</string>
<!-- AddChannelView.kt -->
@@ -3041,6 +3047,15 @@
<string name="your_profile_shared_with_channel_relays">Your profile %1$s will be shared with channel relays and subscribers.\nRelays can access channel messages.</string>
<string name="configure_relays">Configure relays</string>
<string name="relay_status_failed">failed</string>
<string name="add_button">Add</string>
<string name="add_relay_button">Add relay</string>
<string name="add_relays_title">Add relays</string>
<string name="no_available_relays">No available relays</string>
<string name="error_adding_relays">Error adding relays</string>
<string name="relays_added_format">Relays added: %1$s.</string>
<string name="select_relays">Select relays</string>
<string name="no_relays_selected">No relays selected</string>
<string name="num_relays_selected">%d relay(s) selected</string>
<string name="relay_connection_failed">Relay connection failed</string>
<string name="not_all_relays_connected">Not all relays connected</string>
<string name="wait_verb">Wait</string>
+46
View File
@@ -32,6 +32,7 @@ This file is generated automatically.
- [APINewGroup](#apinewgroup)
- [APINewPublicGroup](#apinewpublicgroup)
- [APIGetGroupRelays](#apigetgrouprelays)
- [APIAddGroupRelays](#apiaddgrouprelays)
- [APIUpdateGroupProfile](#apiupdategroupprofile)
[Group link commands](#group-link-commands)
@@ -1034,6 +1035,51 @@ ChatCmdError: Command error (only used in WebSockets API).
---
### APIAddGroupRelays
Add relays to group.
*Network usage*: interactive.
**Parameters**:
- groupId: int64
- relayIds: [int64]
**Syntax**:
```
/_add relays #<groupId> <relayIds[0]>[,<relayIds[1]>...]
```
```javascript
'/_add relays #' + groupId + ' ' + relayIds.join(',') // JavaScript
```
```python
'/_add relays #' + str(groupId) + ' ' + ','.join(map(str, relayIds)) # Python
```
**Responses**:
GroupRelaysAdded: Group relays added.
- type: "groupRelaysAdded"
- user: [User](./TYPES.md#user)
- groupInfo: [GroupInfo](./TYPES.md#groupinfo)
- groupLink: [GroupLink](./TYPES.md#grouplink)
- groupRelays: [[GroupRelay](./TYPES.md#grouprelay)]
GroupRelaysAddFailed: Group relays add failed.
- type: "groupRelaysAddFailed"
- user: [User](./TYPES.md#user)
- addRelayResults: [[AddRelayResult](./TYPES.md#addrelayresult)]
ChatCmdError: Command error (only used in WebSockets API).
- type: "chatCmdError"
- chatError: [ChatError](./TYPES.md#chaterror)
---
### APIUpdateGroupProfile
Update group profile.
+1
View File
@@ -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")
]
),
+2
View File
@@ -71,6 +71,8 @@ chatResponsesDocsData =
("CRPublicGroupCreated", ""),
("CRPublicGroupCreationFailed", ""),
("CRGroupRelays", ""),
("CRGroupRelaysAdded", ""),
("CRGroupRelaysAddFailed", ""),
("CRGroupMembers", ""),
("CRGroupUpdated", ""),
("CRGroupsList", "Groups"),
@@ -372,6 +372,21 @@ export namespace APIGetGroupRelays {
}
}
// Add relays to group.
// Network usage: interactive.
export interface APIAddGroupRelays {
groupId: number // int64
relayIds: number[] // int64, non-empty
}
export namespace APIAddGroupRelays {
export type Response = CR.GroupRelaysAdded | CR.GroupRelaysAddFailed | CR.ChatCmdError
export function cmdString(self: APIAddGroupRelays): string {
return '/_add relays #' + self.groupId + ' ' + self.relayIds.join(',')
}
}
// Update group profile.
// Network usage: background.
export interface APIUpdateGroupProfile {
@@ -30,6 +30,8 @@ export type ChatResponse =
| CR.PublicGroupCreated
| CR.PublicGroupCreationFailed
| CR.GroupRelays
| CR.GroupRelaysAdded
| CR.GroupRelaysAddFailed
| CR.GroupMembers
| CR.GroupUpdated
| CR.GroupsList
@@ -85,6 +87,8 @@ export namespace CR {
| "publicGroupCreated"
| "publicGroupCreationFailed"
| "groupRelays"
| "groupRelaysAdded"
| "groupRelaysAddFailed"
| "groupMembers"
| "groupUpdated"
| "groupsList"
@@ -275,6 +279,20 @@ export namespace CR {
groupRelays: T.GroupRelay[]
}
export interface GroupRelaysAdded extends Interface {
type: "groupRelaysAdded"
user: T.User
groupInfo: T.GroupInfo
groupLink: T.GroupLink
groupRelays: T.GroupRelay[]
}
export interface GroupRelaysAddFailed extends Interface {
type: "groupRelaysAddFailed"
user: T.User
addRelayResults: T.AddRelayResult[]
}
export interface GroupMembers extends Interface {
type: "groupMembers"
user: T.User
+415
View File
@@ -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 |
+2
View File
@@ -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:
+3
View File
@@ -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 = "",
+6
View File
@@ -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]}
+111 -25
View File
@@ -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)),
+13 -3
View File
@@ -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' <-
+49 -23
View File
@@ -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"
+27 -2
View File
@@ -94,6 +94,9 @@ module Simplex.Chat.Store.Groups
setGroupInProgressDone,
createRelayRequestGroup,
updateRelayOwnStatusFromTo,
updateRelayOwnStatus_,
getRelayServedGroups,
getRelayInactiveGroups,
createNewContactMemberAsync,
createJoiningMember,
getMemberJoinRequest,
@@ -188,7 +191,7 @@ import Data.Maybe (catMaybes, fromMaybe, isJust, isNothing)
import Data.Ord (Down (..))
import Data.Text (Text)
import qualified Data.Text as T
import Data.Time.Clock (UTCTime (..), getCurrentTime)
import Data.Time.Clock (NominalDiffTime, UTCTime (..), addUTCTime, getCurrentTime)
import Data.Text.Encoding (encodeUtf8)
import Simplex.Chat.Messages
import Simplex.Chat.Operators
@@ -1585,7 +1588,29 @@ updateRelayOwnStatusFromTo db gInfo@GroupInfo {groupId} fromStatus toStatus = do
updateRelayOwnStatus_ :: DB.Connection -> GroupInfo -> RelayStatus -> IO ()
updateRelayOwnStatus_ db GroupInfo {groupId} relayStatus = do
currentTs <- getCurrentTime
DB.execute db "UPDATE groups SET relay_own_status = ?, updated_at = ? WHERE group_id = ?" (relayStatus, currentTs, groupId)
let inactiveAt_ = if relayStatus == RSInactive then Just currentTs else Nothing
DB.execute db "UPDATE groups SET relay_own_status = ?, relay_inactive_at = ?, updated_at = ? WHERE group_id = ?" (relayStatus, inactiveAt_, currentTs, groupId)
getRelayServedGroups :: DB.Connection -> VersionRangeChat -> User -> IO [GroupInfo]
getRelayServedGroups db vr User {userId, userContactId} = do
map (toGroupInfo vr userContactId [])
<$> DB.query
db
( groupInfoQuery
<> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status IN (?, ?)"
)
(userId, userContactId, RSAccepted, RSActive)
getRelayInactiveGroups :: DB.Connection -> VersionRangeChat -> User -> NominalDiffTime -> IO [GroupInfo]
getRelayInactiveGroups db vr User {userId, userContactId} ttl = do
cutoffTs <- addUTCTime (- ttl) <$> getCurrentTime
map (toGroupInfo vr userContactId [])
<$> DB.query
db
( groupInfoQuery
<> " WHERE g.user_id = ? AND mu.contact_id = ? AND g.relay_own_status = ? AND g.relay_inactive_at IS NOT NULL AND g.relay_inactive_at <= ?"
)
(userId, userContactId, RSInactive, cutoffTs)
createNewContactMemberAsync :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> Contact -> GroupMemberRole -> (CommandId, ConnId) -> VersionChat -> VersionRangeChat -> SubscriptionMode -> ExceptT StoreError IO ()
createNewContactMemberAsync db gVar user@User {userId, userContactId} GroupInfo {groupId, membership} Contact {contactId, localDisplayName, profile} memberRole (cmdId, agentConnId) chatV peerChatVRange subMode =
@@ -29,6 +29,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link
import Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays
import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed
import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries
import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -57,7 +58,8 @@ schemaMigrations =
("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link),
("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays),
("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed),
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries)
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries),
("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,19 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260507_relay_inactive_at :: Text
m20260507_relay_inactive_at =
[r|
ALTER TABLE groups ADD COLUMN relay_inactive_at TIMESTAMPTZ;
|]
down_m20260507_relay_inactive_at :: Text
down_m20260507_relay_inactive_at =
[r|
ALTER TABLE groups DROP COLUMN relay_inactive_at;
|]
@@ -962,7 +962,8 @@ CREATE TABLE test_chat_schema.groups (
public_member_count bigint,
relay_request_retries bigint DEFAULT 0 NOT NULL,
relay_request_delay bigint DEFAULT 0 NOT NULL,
relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 01:00:00+01'::timestamp with time zone NOT NULL
relay_request_execute_at timestamp with time zone DEFAULT '1970-01-01 04:00:00+04'::timestamp with time zone NOT NULL,
relay_inactive_at timestamp with time zone
);
+3 -1
View File
@@ -152,6 +152,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link
import Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays
import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed
import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries
import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -303,7 +304,8 @@ schemaMigrations =
("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link),
("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays),
("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed),
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries)
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries),
("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260507_relay_inactive_at :: Query
m20260507_relay_inactive_at =
[sql|
ALTER TABLE groups ADD COLUMN relay_inactive_at TEXT;
|]
down_m20260507_relay_inactive_at :: Query
down_m20260507_relay_inactive_at =
[sql|
ALTER TABLE groups DROP COLUMN relay_inactive_at;
|]
@@ -273,6 +273,16 @@ Query:
Plan:
SEARCH connections USING PRIMARY KEY (conn_id=?)
Query:
SELECT user_id FROM users u
WHERE u.deleted = ?
AND NOT EXISTS (SELECT c.conn_id FROM connections c WHERE c.user_id = u.user_id)
Plan:
SCAN u
CORRELATED SCALAR SUBQUERY 1
SEARCH c USING COVERING INDEX idx_connections_user (user_id=?)
Query:
SELECT user_id FROM users u
WHERE u.user_id = ?
@@ -525,6 +535,21 @@ Query:
Plan:
SEARCH conn_confirmations USING COVERING INDEX idx_conn_confirmations_conn_id (conn_id=?)
Query:
DELETE FROM encrypted_rcv_message_hashes
WHERE encrypted_rcv_message_hash_id IN (
SELECT encrypted_rcv_message_hash_id
FROM encrypted_rcv_message_hashes
WHERE created_at < ?
ORDER BY created_at ASC
LIMIT ?
)
Plan:
SEARCH encrypted_rcv_message_hashes USING INTEGER PRIMARY KEY (rowid=?)
LIST SUBQUERY 1
SEARCH encrypted_rcv_message_hashes USING COVERING INDEX idx_encrypted_rcv_message_hashes_created_at (created_at<?)
Query:
INSERT INTO connections
(user_id, conn_id, conn_mode, smp_agent_version, enable_ntfs, pq_support, duplex_handshake) VALUES (?,?,?,?,?,?,?)
@@ -1085,6 +1110,14 @@ Query: SELECT conn_id FROM connections WHERE deleted = 0
Plan:
SCAN connections
Query: SELECT conn_id FROM connections WHERE deleted = ?
Plan:
SCAN connections
Query: SELECT conn_id FROM connections WHERE deleted_at_wait_delivery IS NOT NULL
Plan:
SCAN connections
Query: SELECT conn_id FROM connections WHERE user_id = ?
Plan:
SEARCH connections USING COVERING INDEX idx_connections_user (user_id=?)
@@ -6808,6 +6808,10 @@ Query: SELECT last_insert_rowid()
Plan:
SCAN CONSTANT ROW
Query: SELECT local_display_name FROM group_members
Plan:
SCAN group_members USING COVERING INDEX idx_group_members_user_id_local_display_name
Query: SELECT max(active_order) FROM users
Plan:
SEARCH users
@@ -176,7 +176,8 @@ CREATE TABLE groups(
public_member_count INTEGER,
relay_request_retries INTEGER NOT NULL DEFAULT 0,
relay_request_delay INTEGER NOT NULL DEFAULT 0,
relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00', -- received
relay_request_execute_at TEXT NOT NULL DEFAULT '1970-01-01 00:00:00',
relay_inactive_at TEXT, -- received
FOREIGN KEY(user_id, local_display_name)
REFERENCES display_names(user_id, local_display_name)
ON DELETE CASCADE
+10 -4
View File
@@ -182,6 +182,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte
CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView
CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results
CRGroupRelays u g relays -> ttyUser u $ viewGroupRelays g relays
CRGroupRelaysAdded u g _groupLink relays -> ttyUser u $ viewGroupRelays g relays
CRGroupRelaysAddFailed u results -> ttyUser u $ viewGroupRelaysAddFailed results
CRGroupMembers u g -> ttyUser u $ viewGroupMembers g
CRMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms
-- CRGroupConversationsArchived u _g _conversations -> ttyUser u []
@@ -1239,14 +1241,18 @@ viewGroupCreated g testView =
where
relaysInstruction = "wait for selected relay(s) to join, then you can invite members via group link"
viewPublicGroupCreationFailed :: [AddRelayResult] -> [StyledString]
viewPublicGroupCreationFailed results =
["channel not created, results:"]
<> map showRelayResult results
viewRelayResults :: StyledString -> [AddRelayResult] -> [StyledString]
viewRelayResults header results = [header] <> map showRelayResult results
where
showRelayResult (AddRelayResult UserChatRelay {chatRelayId = DBEntityId i} err_) =
" relay " <> sShow i <> ": " <> maybe "ok" (plain . tshow) err_
viewPublicGroupCreationFailed :: [AddRelayResult] -> [StyledString]
viewPublicGroupCreationFailed = viewRelayResults "channel not created, results:"
viewGroupRelaysAddFailed :: [AddRelayResult] -> [StyledString]
viewGroupRelaysAddFailed = viewRelayResults "relays not added, results:"
viewCannotResendInvitation :: GroupInfo -> ContactName -> [StyledString]
viewCannotResendInvitation g c =
[ ttyContact c <> " is already invited to group " <> ttyGroup' g,
+237
View File
@@ -269,6 +269,9 @@ chatGroupTests = do
it "subscriber should update profile in channel (signed)" testChannelSubscriberProfileUpdate
it "should report relay results when one relay deleted its address" testChannelCreateDeletedRelay
it "should deliver support scope messages via relay" testChannelSupportScope
it "should add relay to existing channel" testChannelAddRelay
it "should remove relay from channel" testChannelRemoveRelay
it "should remove left relay from channel" testChannelRemoveLeftRelay
describe "channel message operations" $ do
it "should update channel message" testChannelMessageUpdate
it "should delete channel message" testChannelMessageDelete
@@ -9686,6 +9689,240 @@ testChannelSubscriberProfileUpdate ps =
dan `hasContactProfiles` ["alice", "bob", "kate", "dave"]
eve `hasContactProfiles` ["alice", "bob", "kate", "dave", "eve"]
testChannelAddRelay :: HasCallStack => TestParams -> IO ()
testChannelAddRelay ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "dan" danProfile $ \dan ->
withNewTestChat ps "eve" eveProfile $ \eve -> do
-- create channel with 1 relay (bob)
(shortLink, fullLink) <- prepareChannel1Relay "team" alice bob
-- subscriber joins through bob (the only relay at this point)
memberJoinChannel "team" [bob] [alice] shortLink fullLink dan
-- configure cath as a second relay
cath ##> "/ad"
(cathSLink, _cLink) <- getContactLinks cath True
alice ##> ("/relays name=cath " <> cathSLink)
alice <## "ok"
-- can't add same relay twice
alice ##> "/_add relays #1 1"
alice <## "bad chat command: some relays are already in the group"
-- add cath relay to existing channel
alice ##> "/_add relays #1 2"
alice <## "#team: group relays:"
alice <## " - relay id 1: active"
alice <## " - relay id 2: invited"
-- wait for cath to join as relay (async)
concurrentlyN_
[ do
alice <## "#team: group link relays updated, current relays:"
alice
<### [ " - relay id 1: active",
" - relay id 2: active"
]
alice <## "group link:"
void $ getTermLine alice,
cath <## "#team: you joined the group as relay"
]
threadDelay 100000
-- existing subscriber discovers and connects to new relay
dan ##> "/_get group link data #1"
dan <## "group ID: 1"
void $ getTermLine dan -- subscribers: N
concurrentlyN_
[ do
dan <## "#team: joining the group (connecting to relay cath)..."
dan <## "#team: you joined the group (connected to relay cath)",
do
cath <## "dan (Daniel): accepting request to join group #team..."
cath <## "#team: dan joined the group"
]
threadDelay 100000
-- new subscriber joins through both relays
memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink eve
-- verify delivery through both relays
alice #> "#team hello"
[bob, cath] *<# "#team> hello"
[dan, eve] *<# "#team> hello [>>]"
testChannelRemoveRelay :: HasCallStack => TestParams -> IO ()
testChannelRemoveRelay ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "dan" danProfile $ \dan -> do
(shortLink, fullLink) <- prepareChannel2Relays "team" alice bob cath
memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan
-- verify delivery works
alice #> "#team hello"
[bob, cath] *<# "#team> hello"
dan <# "#team> hello [>>]"
-- remove relay bob
threadDelay 100000
alice ##> "/rm #team bob"
alice <## "#team: you removed bob from the group (signed)"
concurrentlyN_
[ do
bob <## "#team: alice removed you from the group (signed)"
bob <## "use /d #team to delete the group",
-- cath doesn't have bob in member list (relays aren't introduced to each other),
-- so x.grp.mem.del arrives with unknown member ID — cath still forwards it (Left branch in xGrpMemDel)
cath <## "error: x.grp.mem.del with unknown member ID",
dan <## "#team: alice removed bob from the group (signed)"
]
-- verify delivery still works via remaining relay (cath)
threadDelay 100000
alice #> "#team still working"
cath <# "#team> still working"
dan <# "#team> still working [>>]"
-- remove last relay cath
threadDelay 100000
alice ##> "/rm #team cath"
alice <## "#team: you removed cath from the group (signed)"
concurrentlyN_
[ do
cath <## "#team: alice removed you from the group (signed)"
cath <## "use /d #team to delete the group",
dan <## "#team: alice removed cath from the group (signed)"
]
-- verify delivery stops — no relays to forward
threadDelay 100000
alice #> "#team no relays"
(dan </)
-- bob's and cath's member records should be deleted on alice's and dan's sides
threadDelay 100000
aliceMembers <- withCCTransaction alice $ \db ->
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
aliceMembers `shouldMatchList` [Only "alice", Only "dan"]
danMembers <- withCCTransaction dan $ \db ->
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
danMembers `shouldMatchList` [Only "dan", Only "alice"]
-- re-add bob as relay
alice ##> "/_add relays #1 1"
alice <## "#team: group relays:"
alice .<##. (" - relay id", ": invited")
-- wait for bob to rejoin as relay (bob gets LDN "team_1" since old group record exists)
concurrentlyN_
[ do
alice <## "#team: group link relays updated, current relays:"
alice .<##. (" - relay id", ": active")
alice <## "group link:"
void $ getTermLine alice,
bob <## "#team_1: you joined the group as relay"
]
threadDelay 100000
-- subscriber discovers and connects to new relay
dan ##> "/_get group link data #1"
dan <## "group ID: 1"
void $ getTermLine dan -- subscribers: N
concurrentlyN_
[ do
dan <## "#team: joining the group (connecting to relay bob)..."
dan <## "#team: you joined the group (connected to relay bob)",
do
bob <## "dan_1 (Daniel): accepting request to join group #team_1..."
bob <## "#team_1: dan_1 joined the group"
]
threadDelay 100000
-- verify delivery works again through re-added relay
alice #> "#team relays restored"
bob <# "#team_1> relays restored"
dan <# "#team> relays restored [>>]"
testChannelRemoveLeftRelay :: HasCallStack => TestParams -> IO ()
testChannelRemoveLeftRelay ps =
withNewTestChat ps "alice" aliceProfile $ \alice ->
withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob ->
withNewTestChatOpts ps relayTestOpts "cath" cathProfile $ \cath ->
withNewTestChat ps "dan" danProfile $ \dan -> do
(shortLink, fullLink) <- prepareChannel2Relays "team" alice bob cath
memberJoinChannel "team" [bob, cath] [alice] shortLink fullLink dan
-- verify delivery works
alice #> "#team hello"
[bob, cath] *<# "#team> hello"
dan <# "#team> hello [>>]"
-- bob leaves
threadDelay 100000
bob ##> "/l team"
concurrentlyN_
[ do
bob <## "#team: you left the group"
bob <## "use /d #team to delete the group",
alice <## "#team: bob left the group (signed)",
dan <## "#team: bob left the group (signed)"
]
-- alice removes left bob
threadDelay 100000
alice ##> "/rm #team bob"
alice <## "#team: you removed bob from the group (signed)"
concurrentlyN_
[ cath <## "error: x.grp.mem.del with unknown member ID",
dan <## "#team: alice removed bob from the group (signed)"
]
-- bob's member record should be deleted on alice's and dan's sides
threadDelay 100000
aliceMembers <- withCCTransaction alice $ \db ->
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
aliceMembers `shouldMatchList` [Only "alice", Only "cath", Only "dan"]
danMembers <- withCCTransaction dan $ \db ->
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
danMembers `shouldMatchList` [Only "dan", Only "alice", Only "cath"]
-- cath leaves
threadDelay 100000
cath ##> "/l team"
concurrentlyN_
[ do
cath <## "#team: you left the group"
cath <## "use /d #team to delete the group",
alice <## "#team: cath left the group (signed)",
dan <## "#team: cath left the group (signed)"
]
-- alice removes left cath - dan doesn't receive (no relay to forward)
threadDelay 100000
alice ##> "/rm #team cath"
alice <## "#team: you removed cath from the group (signed)"
-- dan syncs with link - should clean up cath's stale record
threadDelay 100000
dan ##> "/_get group link data #1"
dan <## "group ID: 1"
void $ getTermLine dan -- subscribers: N
-- cath's member record should be cleaned up on dan's side after sync
threadDelay 100000
danMembers2 <- withCCTransaction dan $ \db ->
DB.query_ db "SELECT local_display_name FROM group_members" :: IO [Only T.Text]
danMembers2 `shouldMatchList` [Only "dan", Only "alice"]
testChannelCreateDeletedRelay :: HasCallStack => TestParams -> IO ()
testChannelCreateDeletedRelay ps =
withNewTestChat ps "alice" aliceProfile $ \alice -> do