Skip to content

Commit 62e3749

Browse files
authored
fix: connection error handling - WPB-19984 (#4230)
1 parent cf7af88 commit 62e3749

File tree

17 files changed

+224
-43
lines changed

17 files changed

+224
-43
lines changed

WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Accessibility.strings

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@
3636
"conversation.wireCells.files.pendingCells.message" = "Your documents and media files will be available here once we've uploaded all files and folders.";
3737
"conversation.wireCells.allFiles.noData.message" = "You'll find all files and folders shared across your conversations here.";
3838
"conversation.wireCells.files.error.title" = "Can't load files";
39-
"conversation.wireCells.files.error.message" = "Your files and folders could not be loaded. Please reload.";
40-
"conversation.wireCells.files.error.reload" = "Reload";
39+
"conversation.wireCells.files.error.message" = "Something went wrong while loading. Please try again.";
40+
"conversation.wireCells.files.error.retry" = "Retry";
41+
"conversation.wireCells.files.error.noConnection.title" = "Unable to load files";
42+
"conversation.wireCells.files.error.noConnection.message" = "Your list of files could not be loaded. Please check your Internet connection and try again.";
43+
"conversation.wireCells.files.error.noConnection.retry" = "Try again";
4144
"conversation.wireCells.files.moreActionsButton" = "More";
4245
"conversation.wireCells.recycleBin.noData.message" = "You'll find all deleted files and folders here.";
4346
"conversation.wireCells.files.moveToFolder.move" = "Move here";

WireMessaging/Sources/WireMessagingUI/Resources/Localization/en.lproj/Localizable.strings

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"general.create" = "Create";
3131
"general.cancel" = "Cancel";
3232
"general.done" = "Done";
33+
"general.noInternet.title" = "No Internet";
3334

3435
// MARK: - Channel Access level
3536
"channel_access_level.public" = "Public";
@@ -140,8 +141,11 @@
140141
"conversation.wireCells.files.pendingCells.title" = "Files are loading";
141142
"conversation.wireCells.files.pendingCells.message" = "Your documents and media files will be available here once we've uploaded all files and folders.";
142143
"conversation.wireCells.files.error.title" = "Can't load files";
143-
"conversation.wireCells.files.error.message" = "Your files and folders could not be loaded. Please reload.";
144-
"conversation.wireCells.files.error.reload" = "Reload";
144+
"conversation.wireCells.files.error.message" = "Something went wrong while loading. Please try again.";
145+
"conversation.wireCells.files.error.retry" = "Retry";
146+
"conversation.wireCells.files.error.noConnection.title" = "Unable to load files";
147+
"conversation.wireCells.files.error.noConnection.message" = "Your list of files could not be loaded. Please check your Internet connection and try again.";
148+
"conversation.wireCells.files.error.noConnection.retry" = "Try again";
145149
"conversation.wireCells.files.search.title" = "Search files";
146150
"conversation.wireCells.files.list.createFolder" = "Create folder";
147151
"conversation.wireCells.files.list.createFile" = "Create file";
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// Wire
3+
// Copyright (C) 2026 Wire Swiss GmbH
4+
//
5+
// This program is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU General Public License as published by
7+
// the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// This program is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU General Public License
16+
// along with this program. If not, see http://www.gnu.org/licenses/.
17+
//
18+
19+
@preconcurrency import Combine
20+
import Network
21+
22+
final class NetworkMonitor: Sendable {
23+
24+
enum NetworkStatus {
25+
case connected
26+
case disconnected
27+
}
28+
29+
static let shared = NetworkMonitor()
30+
31+
private let monitor = NWPathMonitor()
32+
private let queue = DispatchQueue(label: "NetworkMonitorQueue")
33+
private let subject = CurrentValueSubject<NetworkStatus, Never>(.disconnected)
34+
35+
var statusPublisher: AnyPublisher<NetworkStatus, Never> {
36+
subject.eraseToAnyPublisher()
37+
}
38+
39+
var currentStatus: NetworkStatus {
40+
subject.value
41+
}
42+
43+
private init() {
44+
monitor.pathUpdateHandler = { [weak self] path in
45+
let status: NetworkStatus =
46+
path.status == .satisfied ? .connected : .disconnected
47+
48+
self?.subject.send(status)
49+
}
50+
51+
monitor.start(queue: queue)
52+
}
53+
}

WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesBrowserView.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,22 @@ package struct FilesBrowserView: FilesViewProtocol {
4949
ProgressView()
5050
.progressViewStyle(.circular)
5151
case .received, .pending:
52-
filesList
53-
case .error:
54-
FilesInfoView(info: .error, onReload: {
52+
VStack {
53+
if viewModel.connectionState == .offline {
54+
Spacer()
55+
offlineBar.id(UUID())
56+
Spacer()
57+
}
58+
59+
filesList
60+
}
61+
case let .error(isConnectionError):
62+
FilesInfoView(info: .error(isConnectionError: isConnectionError), onRetry: {
5563
reloadTask()
5664
})
5765
}
5866
}
67+
.animation(.easeInOut(duration: 0.25), value: viewModel.connectionState)
5968
.quickLookPreview($viewModel.viewingURL) // TODO: [WPB-19395] Temporary implementation
6069
.navigationTitle(Strings.AllFiles.navigationTitle)
6170
.navigationBarTitleDisplayMode(.inline)

WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesInfoView.swift

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ struct FilesInfoView: View {
2828
enum Info: Equatable {
2929
case preparingFiles
3030
case noFilesFound(scope: Scope)
31-
case error
31+
case error(isConnectionError: Bool)
3232

3333
enum Scope: Equatable {
3434
case allConversations
@@ -73,8 +73,12 @@ struct FilesInfoView: View {
7373
Strings.Files.NoData.title : Strings.AllFiles.NoData.title,
7474
textForScope(scope)
7575
)
76-
case .error:
77-
(Strings.Files.Error.title, Strings.Files.Error.message)
76+
case let .error(isConnectionError):
77+
if isConnectionError {
78+
(Strings.Files.Error.NoConnection.title, Strings.Files.Error.NoConnection.message)
79+
} else {
80+
(Strings.Files.Error.title, Strings.Files.Error.message)
81+
}
7882
}
7983
}
8084

@@ -87,8 +91,12 @@ struct FilesInfoView: View {
8791
Accessibility.Files.NoData.title,
8892
accessibilityTextForScope(scope)
8993
)
90-
case .error:
91-
(Accessibility.Files.Error.title, Accessibility.Files.Error.message)
94+
case let .error(isConnectionError):
95+
if isConnectionError {
96+
(Accessibility.Files.Error.NoConnection.title, Strings.Files.Error.NoConnection.message)
97+
} else {
98+
(Accessibility.Files.Error.title, Strings.Files.Error.message)
99+
}
92100
}
93101
}
94102

@@ -108,7 +116,7 @@ struct FilesInfoView: View {
108116
}
109117

110118
let info: Info
111-
var onReload: (() -> Void)?
119+
var onRetry: (() -> Void)?
112120

113121
var body: some View {
114122
VStack(spacing: 25) {
@@ -133,7 +141,7 @@ struct FilesInfoView: View {
133141
case let .noFilesFound(scope):
134142
learnMoreLink(scope: scope)
135143
case .error:
136-
reloadButton
144+
retryButton
137145
default:
138146
EmptyView()
139147
}
@@ -160,11 +168,12 @@ struct FilesInfoView: View {
160168
}
161169
}
162170

163-
private var reloadButton: some View {
171+
private var retryButton: some View {
164172
Button {
165-
onReload?()
173+
onRetry?()
166174
} label: {
167-
Text(Strings.Files.Error.reload)
175+
Text(info == .error(isConnectionError: true) ? Strings.Files.Error.NoConnection.retry : Strings.Files.Error
176+
.retry)
168177
.padding()
169178
.font(.subheadline.weight(.semibold))
170179
.foregroundColor(SemanticColors.Label.textDefault.color)
@@ -178,8 +187,8 @@ struct FilesInfoView: View {
178187

179188
)
180189
}
181-
.accessibilityLabel(Strings.Files.Error.reload)
182-
.accessibilityIdentifier("filesBrowser.reloadButton")
190+
.accessibilityLabel(Strings.Files.Error.retry)
191+
.accessibilityIdentifier("filesBrowser.retryButton")
183192
}
184193

185194
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// Wire
3+
// Copyright (C) 2026 Wire Swiss GmbH
4+
//
5+
// This program is free software: you can redistribute it and/or modify
6+
// it under the terms of the GNU General Public License as published by
7+
// the Free Software Foundation, either version 3 of the License, or
8+
// (at your option) any later version.
9+
//
10+
// This program is distributed in the hope that it will be useful,
11+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
// GNU General Public License for more details.
14+
//
15+
// You should have received a copy of the GNU General Public License
16+
// along with this program. If not, see http://www.gnu.org/licenses/.
17+
//
18+
19+
import SwiftUI
20+
21+
struct FilesOfflineBarView: View {
22+
var body: some View {
23+
Group {
24+
Text(
25+
L10n.Localizable.General.NoInternet.title.uppercased()
26+
)
27+
.font(for: .subline2)
28+
.foregroundColor(.white)
29+
}
30+
.frame(maxWidth: .infinity)
31+
.frame(height: 25)
32+
.background(Color(
33+
red: 254.0 / 255.0,
34+
green: 191.0 / 255.0,
35+
blue: 2.0 / 255.0,
36+
opacity: 1
37+
))
38+
.cornerRadius(6)
39+
.padding(.horizontal, 16)
40+
}
41+
}
42+
43+
#Preview {
44+
FilesOfflineBarView()
45+
}

WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesView.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,22 @@ package struct FilesView: FilesViewProtocol {
5757
ProgressView()
5858
.progressViewStyle(.circular)
5959
case .received, .pending:
60-
filesList
61-
case .error:
62-
FilesInfoView(info: .error, onReload: {
60+
VStack {
61+
if viewModel.connectionState == .offline {
62+
Spacer()
63+
offlineBar.id(UUID())
64+
Spacer()
65+
}
66+
67+
filesList
68+
}
69+
case let .error(isConnectionError):
70+
FilesInfoView(info: .error(isConnectionError: isConnectionError), onRetry: {
6371
reloadTask()
6472
})
6573
}
6674
}
75+
.animation(.easeInOut(duration: 0.25), value: viewModel.connectionState)
6776
.quickLookPreview($viewModel.viewingURL) // TODO: [WPB-19395] Temporary implementation
6877
.navigationTitle(viewModel.navigationTitle)
6978
.navigationBarTitleDisplayMode(.inline)

WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewModel.swift

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ package final class FilesViewModel: ObservableObject {
131131
case loading
132132
case received(items: [FilesViewItem])
133133
case pending // drive is not ready yet
134-
case error
134+
case error(isConnectionError: Bool)
135135

136136
var items: [FilesViewItem] {
137137
switch self {
@@ -226,7 +226,12 @@ package final class FilesViewModel: ObservableObject {
226226
var filterWithTags: [String] = []
227227
let title: String?
228228
var showSearchBar: Bool {
229-
state != .error && state != .pending
229+
switch state {
230+
case .loading, .received:
231+
true
232+
case .pending, .error:
233+
false
234+
}
230235
}
231236

232237
var navigationTitle: String {
@@ -246,6 +251,11 @@ package final class FilesViewModel: ObservableObject {
246251
loadMoreTask != nil
247252
}
248253

254+
enum ConnectionState {
255+
case offline
256+
case online
257+
}
258+
249259
@Published var hasMore = true
250260
@Published private var loadMoreTask: LoadItemsTask?
251261
@Published var searchText = ""
@@ -257,6 +267,7 @@ package final class FilesViewModel: ObservableObject {
257267
@Published var fileRenameView: FileRenameView?
258268
@Published var isEditing: FilesViewItem?
259269
@Published var templates: [WireDriveFileTemplate] = []
270+
@Published var connectionState: ConnectionState = .online
260271

261272
package init(
262273
useCases: UseCases,
@@ -288,6 +299,7 @@ package final class FilesViewModel: ObservableObject {
288299
self.accentColorProvider = accentColorProvider
289300

290301
bindSearch()
302+
bindNetworkConnection()
291303
fetchTemplates()
292304
}
293305

@@ -318,6 +330,24 @@ package final class FilesViewModel: ObservableObject {
318330
}
319331
}
320332

333+
private func bindNetworkConnection() {
334+
NetworkMonitor.shared.statusPublisher
335+
.removeDuplicates()
336+
.receive(on: DispatchQueue.main)
337+
.sink { [weak self] status in
338+
guard let self, !state.items.isEmpty else { return }
339+
340+
switch status {
341+
case .connected:
342+
guard connectionState == .offline else { return }
343+
connectionState = .online
344+
case .disconnected:
345+
connectionState = .offline
346+
}
347+
}
348+
.store(in: &subscriptions)
349+
}
350+
321351
private func bindSearch() {
322352
$searchText
323353
.removeDuplicates()
@@ -583,14 +613,18 @@ package final class FilesViewModel: ObservableObject {
583613
} catch is CancellationError {
584614
return // developer-driven error, discard
585615
} catch {
616+
let urlError = (error as? URLError)?.code
617+
let isNoInternetError = urlError == .notConnectedToInternet || urlError == .networkConnectionLost
618+
586619
if state.items.isEmpty {
587-
state = .error
620+
state = .error(isConnectionError: isNoInternetError)
588621
} else {
589-
let urlError = (error as? URLError)?.code
590-
let isNoInternetError = urlError == .notConnectedToInternet || urlError == .networkConnectionLost
591-
alert = isNoInternetError ? .noInternet : .unknownError
622+
if isNoInternetError {
623+
// no-op, offline bar is dynamically shown/hidden on top of the list (see `bindNetworkConnection()`)
624+
} else {
625+
alert = .unknownError
626+
}
592627
}
593-
594628
hasMore = state.items.isEmpty ? true : hasMore
595629
}
596630
loadMoreTask = nil

WireMessaging/Sources/WireMessagingUI/WireDrive/Components/Files/FilesViewProtocol.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,3 +116,17 @@ extension FilesViewProtocol {
116116
Task { await viewModel.loadMoreIfNeeded(index: lastRowIndex) }
117117
}
118118
}
119+
120+
// MARK: - Offline bar
121+
122+
extension FilesViewProtocol {
123+
124+
var offlineBar: some View {
125+
FilesOfflineBarView()
126+
.transition(
127+
.move(edge: .top)
128+
.combined(with: .opacity)
129+
)
130+
}
131+
132+
}

0 commit comments

Comments
 (0)