ios: relay failure indication (#6665)

This commit is contained in:
spaced4ndy authored and GitHub committed 2026-03-09 15:48:22 +00:00
1 parent 7ac901587a
commit a4f3e21490
7 files changed
+202 -82

No files matched your search

+9 -2
View File
@@ -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<Int64, Int> = [:] // 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 {
@@ -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<Content: View>(@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) {
@@ -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)
}
}
@@ -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")
}
}
}
@@ -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)
}
}
}
+4 -4
View File
@@ -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"
}
}
}
+5 -4
View File
@@ -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|