diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 023dc1926c..5e85417326 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -379,6 +379,7 @@ final class ChatModel: ObservableObject { @Published var chatSubStatus: SubscriptionStatus? @Published var openAroundItemId: ChatItem.ID? = nil @Published var chatToTop: String? + @Published var creatingChannelId: String? @Published var groupMembers: [GMember] = [] @Published var groupMembersIndexes: Dictionary = [:] // groupMemberId to index in groupMembers list @Published var membersLoaded = false @@ -1241,13 +1242,19 @@ final class ChatModel: ObservableObject { updateGroup(groupInfo) return false } - // update current chat - if chatId == groupInfo.id { + // update current chat or channel being created + if chatId == groupInfo.id || creatingChannelId == groupInfo.id { if let i = groupMembersIndexes[member.groupMemberId] { + let connStatusChanged = self.groupMembers[i].wrapped.activeConn?.connStatus != member.activeConn?.connStatus withAnimation(.default) { self.groupMembers[i].wrapped = member self.groupMembers[i].created = Date.now } + // Updating wrapped on a reference-type GMember doesn't mutate the groupMembers array, + // so ChatModel.objectWillChange doesn't fire automatically — notify views explicitly. + if connStatusChanged { + objectWillChange.send() + } return false } else { withAnimation { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index af9b4673c0..271e50c8d7 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -386,9 +386,10 @@ struct ComposeView: View { if gInfo.membership.memberRole == .owner { let relays = channelRelaysModel.groupId == gInfo.groupId ? channelRelaysModel.groupRelays : [] - let activeCount = relays.filter { $0.relayStatus == .rsActive }.count + let failedCount = relays.filter { relayMemberConnFailed($0) != nil }.count + let activeCount = relays.filter { $0.relayStatus == .rsActive && relayMemberConnFailed($0) == nil }.count if !relays.isEmpty && activeCount < relays.count { - ownerChannelRelayBar(relays: relays, activeCount: activeCount) + ownerChannelRelayBar(relays: relays, activeCount: activeCount, failedCount: failedCount) } } else { let hostnames = (chatModel.channelRelayHostnames[gInfo.groupId] ?? []).sorted() @@ -398,6 +399,8 @@ struct ComposeView: View { let showProgress = !gInfo.nextConnectPrepared || composeState.inProgress let connectedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .ready }.count let deletedCount = relayMembers.filter { $0.wrapped.activeConn?.connStatus == .deleted }.count + let failedCount = relayMembers.filter { $0.wrapped.activeConn?.connFailedErr != nil }.count + let errorCount = deletedCount + failedCount let resolvedCount = connectedCount + deletedCount let total = relayMembers.count > 0 ? relayMembers.count : hostnames.count if total > 0, !showProgress || resolvedCount < total { @@ -405,7 +408,7 @@ struct ComposeView: View { hostnames: hostnames, relayMembers: relayMembers, connectedCount: connectedCount, - deletedCount: deletedCount, + errorCount: errorCount, total: total, showProgress: showProgress ) @@ -719,22 +722,35 @@ struct ComposeView: View { } } - private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int) -> some View { + private func ownerChannelRelayBar(relays: [GroupRelay], activeCount: Int, failedCount: Int) -> some View { let total = relays.count let sorted = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } return VStack(spacing: 0) { relayBarHeader { - if activeCount < total { + if activeCount + failedCount < total { RelayProgressIndicator(active: activeCount, total: total) } - Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total)) + if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel relay bar progress with errors"), activeCount, total, failedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel relay bar progress"), activeCount, total)) + } } if relayListExpanded { ForEach(sorted) { relay in - relayBarDetailRow { - Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary) - Spacer() - relayStatusIndicator(relay.relayStatus) + let failedErr = relayMemberConnFailed(relay) + if let err = failedErr { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + ownerRelayDetailRow(relay, connFailed: true) + } + .buttonStyle(.plain) + } else { + ownerRelayDetailRow(relay, connFailed: false) } } } @@ -743,25 +759,32 @@ struct ComposeView: View { .animation(nil, value: relayListExpanded) } + private func ownerRelayDetailRow(_ relay: GroupRelay, connFailed: Bool) -> some View { + relayBarDetailRow { + Text(relayDisplayName(relay)).foregroundColor(theme.colors.secondary) + Spacer() + relayStatusIndicator(relay.relayStatus, connFailed: connFailed) + } + } + private func subscriberChannelRelayBar( hostnames: [String], relayMembers: [GMember], connectedCount: Int, - deletedCount: Int, + errorCount: Int, total: Int, showProgress: Bool ) -> some View { VStack(spacing: 0) { relayBarHeader { - let activeTotal = total - deletedCount - if showProgress && connectedCount < activeTotal { - RelayProgressIndicator(active: connectedCount, total: activeTotal) + if showProgress && connectedCount + errorCount < total { + RelayProgressIndicator(active: connectedCount, total: total) } if showProgress { - if deletedCount > 0 { - Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d deleted", comment: "channel subscriber relay bar progress with deleted"), connectedCount, activeTotal, deletedCount)) + if errorCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected, %d errors", comment: "channel subscriber relay bar progress with errors"), connectedCount, total, errorCount)) } else { - Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, activeTotal)) + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays connected", comment: "channel subscriber relay bar progress"), connectedCount, total)) } } else { Text(String.localizedStringWithFormat(NSLocalizedString("%d relays", comment: "channel relay bar"), total)) @@ -780,16 +803,19 @@ struct ComposeView: View { ForEach(relayMembers) { member in let m = member.wrapped let host = m.relayLink.map { hostFromRelayLink($0) } - relayBarDetailRow { - Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), host ?? m.chatViewName)) - .foregroundColor(theme.colors.secondary) - Spacer() - let status = relayConnStatus(m) - Circle() - .fill(status.color) - .frame(width: 8, height: 8) - Text(status.text) - .foregroundColor(theme.colors.secondary) + let failedErr = m.activeConn?.connFailedErr + if let err = failedErr { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + subscriberRelayDetailRow(m, host: host, connFailed: true) + } + .buttonStyle(.plain) + } else { + subscriberRelayDetailRow(m, host: host, connFailed: false) } } } @@ -799,6 +825,24 @@ struct ComposeView: View { .animation(nil, value: relayListExpanded) } + private func subscriberRelayDetailRow(_ m: GroupMember, host: String?, connFailed: Bool) -> some View { + relayBarDetailRow { + Text(String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), host ?? m.chatViewName)) + .foregroundColor(theme.colors.secondary) + Spacer() + let status = relayConnStatus(m) + Circle() + .fill(status.color) + .frame(width: 8, height: 8) + Text(status.text) + .foregroundColor(theme.colors.secondary) + if connFailed { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.accentColor) + } + } + } + private func relayBarHeader(@ViewBuilder content: () -> Content) -> some View { Button { withAnimation(nil) { relayListExpanded.toggle() } @@ -830,6 +874,11 @@ struct ComposeView: View { .padding(.vertical, 2) } + private func relayMemberConnFailed(_ relay: GroupRelay) -> String? { + chatModel.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })? + .wrapped.activeConn?.connFailedErr + } + private func connectButtonView(_ label: LocalizedStringKey, icon: String, connect: @escaping () -> Void) -> some View { Button(action: connect) { ZStack(alignment: .trailing) { diff --git a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift index 2ed55d1f28..1a4e384e24 100644 --- a/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift +++ b/apps/ios/Shared/Views/Chat/Group/ChannelRelaysView.swift @@ -17,21 +17,20 @@ struct ChannelRelaysView: View { @State private var groupRelays: [GroupRelay] = [] var body: some View { - let isOwner = groupInfo.isOwner List { - relaysList(showRelayStatus: isOwner) + relaysList() } .onAppear { Task { await chatModel.loadGroupMembers(groupInfo) - if isOwner { + if groupInfo.isOwner { groupRelays = await apiGetGroupRelays(groupInfo.groupId) } } } } - @ViewBuilder private func relaysList(showRelayStatus: Bool) -> some View { + @ViewBuilder private func relaysList() -> some View { let relayMembers = chatModel.groupMembers.filter { $0.wrapped.memberRole == .relay } if relayMembers.isEmpty { Section { @@ -51,7 +50,10 @@ struct ChannelRelaysView: View { ) .navigationBarHidden(false) } label: { - relayMemberRow(member.wrapped, relayStatus: showRelayStatus ? relayStatusForMember(member.wrapped) : nil) + let statusText = groupInfo.isOwner + ? ownerRelayStatusText(member.wrapped) + : subscriberRelayStatusText(member.wrapped) + relayMemberRow(member.wrapped, statusText: statusText) } } } footer: { @@ -60,28 +62,7 @@ struct ChannelRelaysView: View { } } - private func relayStatusForMember(_ member: GroupMember) -> RelayStatus? { - groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus - } - - private func relayMemberRow(_ member: GroupMember, relayStatus: RelayStatus?) -> some View { - HStack { - MemberProfileImage(member, size: 38) - .padding(.trailing, 2) - VStack(alignment: .leading) { - Text(member.chatViewName) - .foregroundColor(theme.colors.onBackground) - .lineLimit(1) - Text(relayStatus?.text ?? relayConnStatusText(member)) - .lineLimit(1) - .font(.caption) - .foregroundColor(theme.colors.secondary) - } - Spacer() - } - } - - private func relayConnStatusText(_ member: GroupMember) -> LocalizedStringKey { + private func subscriberRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { if member.activeConn?.connDisabled ?? false { "disabled" } else if member.activeConn?.connInactive ?? false { @@ -90,13 +71,43 @@ struct ChannelRelaysView: View { relayConnStatus(member).text } } -} + private func ownerRelayStatusText(_ member: GroupMember) -> LocalizedStringKey { + if case .failed = member.activeConn?.connStatus { + "failed" + } else if member.activeConn?.connDisabled ?? false { + "disabled" + } else if member.activeConn?.connInactive ?? false { + "inactive" + } else { + groupRelays.first(where: { $0.groupMemberId == member.groupMemberId })?.relayStatus.text + ?? relayConnStatus(member).text + } + } + + private func relayMemberRow(_ member: GroupMember, statusText: LocalizedStringKey) -> some View { + HStack { + MemberProfileImage(member, size: 38) + .padding(.trailing, 2) + VStack(alignment: .leading) { + Text(member.chatViewName) + .foregroundColor(theme.colors.onBackground) + .lineLimit(1) + Text(statusText) + .lineLimit(1) + .font(.caption) + .foregroundColor(theme.colors.secondary) + } + Spacer() + } + } +} func relayConnStatus(_ member: GroupMember) -> (text: LocalizedStringKey, color: Color) { switch member.activeConn?.connStatus { case .ready: ("connected", .green) case .deleted: ("deleted", .red) + case .failed: ("failed", .red) default: ("connecting", .yellow) } } diff --git a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift index ce9779b578..442f547b9c 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupMemberInfoView.swift @@ -236,7 +236,14 @@ struct GroupMemberInfoView: View { if let connFailedErr = member.activeConn?.connFailedErr { Section { - infoRow("Connection failed", connFailedErr) + Text(connFailedErr) + .foregroundColor(theme.colors.secondary) + } header: { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.red) + Text("Connection failed") + } } } diff --git a/apps/ios/Shared/Views/NewChat/AddChannelView.swift b/apps/ios/Shared/Views/NewChat/AddChannelView.swift index 15be6aa969..4402d0a5ed 100644 --- a/apps/ios/Shared/Views/NewChat/AddChannelView.swift +++ b/apps/ios/Shared/Views/NewChat/AddChannelView.swift @@ -181,6 +181,7 @@ struct AddChannelView: View { } await MainActor.run { m.updateGroup(gInfo) + m.creatingChannelId = gInfo.id groupInfo = gInfo groupLink = gLink groupRelays = gRelays.sorted { relayDisplayName($0) < relayDisplayName($1) } @@ -218,7 +219,8 @@ struct AddChannelView: View { // MARK: - Step 2: Progress private func progressStepView(_ gInfo: GroupInfo) -> some View { - let activeCount = groupRelays.filter { $0.relayStatus == .rsActive }.count + let failedCount = groupRelays.filter { relayConnFailed($0) != nil }.count + let activeCount = groupRelays.filter { $0.relayStatus == .rsActive && relayConnFailed($0) == nil }.count let total = groupRelays.count return List { Group { @@ -238,10 +240,14 @@ struct AddChannelView: View { withAnimation { relayListExpanded.toggle() } } label: { HStack(spacing: 8) { - if activeCount < total { + if activeCount + failedCount < total { RelayProgressIndicator(active: activeCount, total: total) } - Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel creation progress"), activeCount, total)) + if failedCount > 0 { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active, %d failed", comment: "channel creation progress with errors"), activeCount, total, failedCount)) + } else { + Text(String.localizedStringWithFormat(NSLocalizedString("%d/%d relays active", comment: "channel creation progress"), activeCount, total)) + } Spacer() Image(systemName: relayListExpanded ? "chevron.up" : "chevron.down") .foregroundColor(theme.colors.secondary) @@ -251,10 +257,19 @@ struct AddChannelView: View { if relayListExpanded { ForEach(groupRelays) { relay in - HStack { - Text(relayDisplayName(relay)) - Spacer() - relayStatusIndicator(relay.relayStatus) + let failed = relayConnFailed(relay) + if let err = failed { + Button { + showAlert( + NSLocalizedString("Relay connection failed", comment: "alert title"), + message: err + ) + } label: { + relayRow(relay, connFailed: true) + } + .buttonStyle(.plain) + } else { + relayRow(relay, connFailed: false) } } } @@ -266,13 +281,21 @@ struct AddChannelView: View { if activeCount >= total { showLinkStep = true } else if activeCount > 0 { + let actions: [UIAlertAction] = if activeCount + failedCount < total { + [ + UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in } + ] + } else { + [ + UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, + cancelAlertAction + ] + } showAlert( NSLocalizedString("Not all relays connected", comment: "alert title"), message: String.localizedStringWithFormat(NSLocalizedString("Channel will start working with %d of %d relays. Proceed?", comment: "alert message"), activeCount, total), - actions: {[ - UIAlertAction(title: NSLocalizedString("Proceed", comment: "alert action"), style: .default) { _ in showLinkStep = true }, - UIAlertAction(title: NSLocalizedString("Wait", comment: "alert action"), style: .cancel) { _ in } - ]} + actions: { actions } ) } } @@ -289,13 +312,26 @@ struct AddChannelView: View { .onChange(of: channelRelaysModel.groupRelays) { relays in guard channelRelaysModel.groupId == gInfo.groupId else { return } groupRelays = relays.sorted { relayDisplayName($0) < relayDisplayName($1) } - if relays.allSatisfy({ $0.relayStatus == .rsActive }) { + if relays.allSatisfy({ $0.relayStatus == .rsActive && relayConnFailed($0) == nil }) { showLinkStep = true channelRelaysModel.reset() } } } + private func relayConnFailed(_ relay: GroupRelay) -> String? { + m.groupMembers.first(where: { $0.wrapped.groupMemberId == relay.groupMemberId })? + .wrapped.activeConn?.connFailedErr + } + + private func relayRow(_ relay: GroupRelay, connFailed: Bool) -> some View { + HStack { + Text(relayDisplayName(relay)) + Spacer() + relayStatusIndicator(relay.relayStatus, connFailed: connFailed) + } + } + // MARK: - Step 3: Link private func linkStepView(_ gInfo: GroupInfo) -> some View { @@ -307,6 +343,7 @@ struct AddChannelView: View { creatingGroup: true, isChannel: true ) { + m.creatingChannelId = nil DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { dismissAllSheets(animated: true) { ItemsModel.shared.loadOpenChat(gInfo.id) @@ -317,6 +354,7 @@ struct AddChannelView: View { } private func cancelChannelCreation(_ gInfo: GroupInfo) { + m.creatingChannelId = nil channelRelaysModel.reset() dismissAllSheets(animated: true) Task { @@ -358,14 +396,21 @@ func relayDisplayName(_ relay: GroupRelay) -> String { return "relay \(relay.groupRelayId)" } -func relayStatusIndicator(_ status: RelayStatus) -> some View { - HStack(spacing: 4) { +func relayStatusIndicator(_ status: RelayStatus, connFailed: Bool = false) -> some View { + let color: Color = connFailed ? .red : (status == .rsActive ? .green : .orange) + let text: LocalizedStringKey = connFailed ? "failed" : status.text + return HStack(spacing: 4) { Circle() - .fill(status == .rsActive ? .green : status == .rsNew ? .red : .orange) + .fill(color) .frame(width: 8, height: 8) - Text(status.text) + Text(text) .font(.caption) .foregroundStyle(.secondary) + if connFailed { + Image(systemName: "exclamationmark.circle") + .foregroundColor(.accentColor) + .font(.caption) + } } } diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index b9cd242ef0..86c7694902 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2571,10 +2571,10 @@ public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable { extension RelayStatus { public var text: LocalizedStringKey { switch self { - case .rsNew: "New" - case .rsInvited: "Invited" - case .rsAccepted: "Accepted" - case .rsActive: "Active" + case .rsNew: "new" + case .rsInvited: "invited" + case .rsAccepted: "accepted" + case .rsActive: "active" } } } diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 451ef5d825..d13003dce8 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -728,7 +728,7 @@ updatePreparedUserAndHostMembers' db vr user - gInfo@GroupInfo {groupId, groupProfile = gp, businessChat} + gInfo@GroupInfo {groupId, membership, groupProfile = gp, businessChat} hostMember fromMember fromMemberProfile @@ -737,7 +737,9 @@ updatePreparedUserAndHostMembers' business membershipStatus = do currentTs <- liftIO getCurrentTime - liftIO $ updateUserMember currentTs + -- For channels, don't regress membership status if already connected via another relay + unless (memberStatus membership == GSMemConnected) $ + liftIO $ updateUserMember currentTs hostMember' <- updateHostMember currentTs when (gp /= groupProfile) $ void $ updateGroupProfile db user gInfo groupProfile @@ -747,8 +749,7 @@ updatePreparedUserAndHostMembers' pure (gInfo', hostMember') where updateUserMember currentTs = do - let GroupInfo {membership} = gInfo - MemberIdRole memberId memberRole = invitedMember + let MemberIdRole memberId memberRole = invitedMember DB.execute db [sql|