diff --git a/Friendly/Sources/AddFriendService.swift b/Friendly/Sources/AddFriendService.swift new file mode 100644 index 0000000..c425669 --- /dev/null +++ b/Friendly/Sources/AddFriendService.swift @@ -0,0 +1,129 @@ +import Foundation + +@MainActor +final class AddFriendService { + private let storage: Storage = .shared + private let networkClient: NetworkClient = .meetacy + private let userDefaults: UserDefaults = .standard + private let reconciliationUserIdKey = "friends.add.reconciliationUserId" + private var isAddingFriend: Bool = false + + func add(_ command: AddFriendCommand) async throws(AddError) { + guard !isAddingFriend else { throw .alreadyProcessing } + isAddingFriend = true + defer { isAddingFriend = false } + + let authorization: Authorization + do { + authorization = try storage.loadAuthorization() + } catch { + throw .retryable + } + + markForReconciliation(authorization: authorization) + + do { + try await networkClient.friendsAdd( + authorization: authorization, + token: command.token, + id: command.id, + ) + } catch let error { + switch error { + case .expiredToken: + clearReconciliation() + throw .invalidInvite + case .serverError(let statusCode): + if (400..<500).contains(statusCode) { + clearReconciliation() + throw .invalidInvite + } + throw .retryable + case .unauthorized: + clearReconciliation() + throw .retryable + case .ioError: + throw .retryable + } + } + + do { + try storage.addFriend() + clearReconciliation() + } catch { + // The server operation succeeded. Reconcile the local gate on next launch. + } + } + + func hasPendingReconciliation( + authorization: Authorization, + ) -> Bool { + guard let userId = userDefaults.string( + forKey: reconciliationUserIdKey, + ) else { + return false + } + + guard userId == String(authorization.id.int64) else { + clearReconciliation() + return false + } + return true + } + + func reconcile() async throws(ReconciliationError) -> Bool { + let authorization: Authorization + do { + authorization = try storage.loadAuthorization() + } catch { + throw .retryable + } + + let network: NetworkDetails + do { + network = try await networkClient.networkDetails( + authorization: authorization, + ) + } catch { + throw .retryable + } + + guard !network.friends.isEmpty else { + clearReconciliation() + return false + } + + do { + try storage.addFriend() + clearReconciliation() + } catch { + // Keep the marker and retry persistence on the next launch. + } + return true + } + + func clearReconciliation() { + userDefaults.removeObject(forKey: reconciliationUserIdKey) + } + + private func markForReconciliation( + authorization: Authorization, + ) { + userDefaults.set( + String(authorization.id.int64), + forKey: reconciliationUserIdKey, + ) + } + + enum AddError: Swift.Error { + case alreadyProcessing + case invalidInvite + case retryable + } + + enum ReconciliationError: Swift.Error { + case retryable + } + + static let shared = AddFriendService() +} diff --git a/Friendly/Sources/ContentView.swift b/Friendly/Sources/ContentView.swift index a9c2540..da19299 100644 --- a/Friendly/Sources/ContentView.swift +++ b/Friendly/Sources/ContentView.swift @@ -5,18 +5,24 @@ struct ContentView: View { var body: some View { destinationView - .animation(.easeInOut(duration: 0.3), value: viewModel.destination) - .transition(.opacity) - .onAppear { - viewModel.appear() - } - .onOpenURL { url in - guard let deeplink = Deeplink.of(url: url) else { return } - switch deeplink { + .animation(.easeInOut(duration: 0.3), value: viewModel.destination) + .transition(.opacity) + .onAppear { + viewModel.appear() + } + .onOpenURL { url in + guard let deeplink = Deeplink.parseOf(url: url) else { + viewModel.onInvalidFriendLink() + return + } + switch deeplink { case let .addFriend(id, token): viewModel.onAddFriend(id: id, token: token) + } + } + .alert(item: $viewModel.friendLinkAlert) { alert in + friendLinkAlert(alert) } - } } private var destinationView: some View { @@ -32,7 +38,7 @@ struct ContentView: View { case .main: MainView( routeToSignUp: viewModel.routeToSignUp, - addFriend: $viewModel.addFriend, + route: $viewModel.mainRoute, ) case .qrAddFriend: ScanToUseAppView( @@ -41,6 +47,46 @@ struct ContentView: View { onSuccess: viewModel.onAddFriendWithQr, ) } + + if viewModel.isProcessingFriendAccess { + Color(uiColor: .systemBackground) + .ignoresSafeArea() + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } + + private func friendLinkAlert( + _ alert: ContentViewModel.FriendLinkAlert, + ) -> Alert { + switch alert { + case .authenticationRequired, .alreadyProcessing, .invalidInvite: + Alert( + title: Text(alert.title), + message: Text(alert.message), + dismissButton: .default(Text(.buttonBaseClose)), + ) + case .retryInvite: + Alert( + title: Text(alert.title), + message: Text(alert.message), + primaryButton: .default(Text(.friendLinkRetryButton)) { + viewModel.retryFriendLink() + }, + secondaryButton: .cancel(Text(.friendLinkCancelButton)) { + viewModel.cancelFriendLink() + }, + ) + case .retryReconciliation: + Alert( + title: Text(alert.title), + message: Text(alert.message), + primaryButton: .default(Text(.friendLinkRetryButton)) { + viewModel.retryFriendAccessReconciliation() + }, + secondaryButton: .cancel(Text(.friendLinkCancelButton)), + ) } } } diff --git a/Friendly/Sources/ContentViewModel.swift b/Friendly/Sources/ContentViewModel.swift index 87853f7..75506fa 100644 --- a/Friendly/Sources/ContentViewModel.swift +++ b/Friendly/Sources/ContentViewModel.swift @@ -1,18 +1,30 @@ import SwiftUI +@MainActor @Observable class ContentViewModel { private(set) var destination: Destination = .empty private let storage: Storage = .shared + private let addFriendService: AddFriendService = .shared + private var activeInvite: ActiveInvite? - var addFriend: AddFriendCommand? = nil + private(set) var isProcessingFriendAccess: Bool = false + var friendLinkAlert: FriendLinkAlert? + var mainRoute: MainRoute? func appear() { + guard !isProcessingFriendAccess else { return } + do { if try storage.hasAuthorization() { - destination = try storage.getHasFriend() - ? .main - : .qrAddFriend + let authorization = try storage.loadAuthorization() + if addFriendService.hasPendingReconciliation( + authorization: authorization, + ) { + reconcileFriendAccess() + } else { + routeUsingStoredFriendState() + } } else { destination = .signUp } @@ -27,21 +39,151 @@ class ContentViewModel { } func onAddFriendWithQr() { - try? storage.addFriend() + mainRoute = .feed destination = .main } func onEmailLogin() { try? storage.addFriend() + addFriendService.clearReconciliation() + mainRoute = .feed destination = .main } func routeToSignUp() { + activeInvite = nil + mainRoute = nil + addFriendService.clearReconciliation() appear() } func onAddFriend(id: UserId, token: FriendToken) { - addFriend = AddFriendCommand(id: id, token: token) + guard !isProcessingFriendAccess else { + friendLinkAlert = .alreadyProcessing + return + } + guard activeInvite == nil else { + friendLinkAlert = .retryInvite + return + } + + let command = AddFriendCommand(id: id, token: token) + + do { + guard try storage.hasAuthorization() else { + destination = .signUp + friendLinkAlert = .authenticationRequired + return + } + + let hasFriend = try storage.getHasFriend() + destination = hasFriend || destination == .main + ? .main + : .qrAddFriend + activeInvite = ActiveInvite(command: command) + addActiveInvite() + } catch { + storage.clearAuthorization() + destination = .signUp + friendLinkAlert = .authenticationRequired + } + } + + func onInvalidFriendLink() { + guard !isProcessingFriendAccess else { + friendLinkAlert = .alreadyProcessing + return + } + guard activeInvite == nil else { + friendLinkAlert = .retryInvite + return + } + friendLinkAlert = .invalidInvite + } + + func retryFriendLink() { + guard activeInvite != nil else { return } + addActiveInvite() + } + + func cancelFriendLink() { + activeInvite = nil + } + + func retryFriendAccessReconciliation() { + reconcileFriendAccess() + } + + private func addActiveInvite() { + guard let activeInvite else { return } + isProcessingFriendAccess = true + friendLinkAlert = nil + + Task { [weak self] in + guard let self else { return } + + do { + try await addFriendService.add(activeInvite.command) + self.activeInvite = nil + isProcessingFriendAccess = false + friendLinkAlert = nil + mainRoute = .feed + destination = .main + } catch let error as AddFriendService.AddError { + isProcessingFriendAccess = false + switch error { + case .alreadyProcessing: + self.activeInvite = nil + friendLinkAlert = .alreadyProcessing + case .invalidInvite: + self.activeInvite = nil + friendLinkAlert = .invalidInvite + case .retryable: + friendLinkAlert = .retryInvite + } + } + } + } + + private func reconcileFriendAccess() { + guard !isProcessingFriendAccess else { return } + isProcessingFriendAccess = true + friendLinkAlert = nil + + Task { [weak self] in + guard let self else { return } + + do { + let hasFriend = try await addFriendService.reconcile() + isProcessingFriendAccess = false + friendLinkAlert = nil + if hasFriend { + mainRoute = .feed + destination = .main + } else { + destination = .qrAddFriend + } + } catch { + isProcessingFriendAccess = false + routeUsingStoredFriendState() + friendLinkAlert = .retryReconciliation + } + } + } + + private func routeUsingStoredFriendState() { + do { + destination = try storage.getHasFriend() + ? .main + : .qrAddFriend + } catch { + storage.clearAuthorization() + destination = .signUp + } + } + + private struct ActiveInvite { + let command: AddFriendCommand } enum Destination: Hashable { @@ -50,4 +192,48 @@ class ContentViewModel { case main case qrAddFriend } + + enum MainRoute: Hashable { + case feed + } + + enum FriendLinkAlert: Int, Identifiable { + case authenticationRequired + case alreadyProcessing + case invalidInvite + case retryInvite + case retryReconciliation + + var id: Int { rawValue } + + var title: LocalizedStringResource { + switch self { + case .authenticationRequired: + .friendLinkAuthRequiredTitle + case .alreadyProcessing: + .friendLinkProcessingTitle + case .invalidInvite: + .friendLinkInvalidTitle + case .retryInvite: + .friendLinkRetryTitle + case .retryReconciliation: + .friendGateRetryTitle + } + } + + var message: LocalizedStringResource { + switch self { + case .authenticationRequired: + .friendLinkAuthRequiredMessage + case .alreadyProcessing: + .friendLinkProcessingMessage + case .invalidInvite: + .friendLinkInvalidMessage + case .retryInvite: + .friendLinkRetryMessage + case .retryReconciliation: + .friendGateRetryMessage + } + } + } } diff --git a/Friendly/Sources/Deeplink.swift b/Friendly/Sources/Deeplink.swift index 8d9dd8e..cddf7b8 100644 --- a/Friendly/Sources/Deeplink.swift +++ b/Friendly/Sources/Deeplink.swift @@ -2,21 +2,6 @@ import Foundation enum Deeplink { case addFriend(id: UserId, token: FriendToken) - - static let addFriend: Regex<(Substring, Substring, Substring)> = - try! Regex("friendly://add/(.*)/(.*)") - - static func of(url: URL) -> Deeplink? { - if let match = try! addFriend.wholeMatch(in: url.absoluteString) { - let rawId = String(match.output.1) - let rawToken = String(match.output.2) - guard let idInt64 = Int64(rawId) else { return nil } - let id = UserId(idInt64) - guard let token = try? FriendToken(rawToken) else { return nil } - return .addFriend(id: id, token: token) - } - return nil - } } extension Deeplink { diff --git a/Friendly/Sources/Localizable.xcstrings b/Friendly/Sources/Localizable.xcstrings index 761ffae..88a735f 100644 --- a/Friendly/Sources/Localizable.xcstrings +++ b/Friendly/Sources/Localizable.xcstrings @@ -138,6 +138,7 @@ } }, "button_base_close" : { + "extractionState" : "manual", "localizations" : { "en" : { "stringUnit" : { @@ -440,6 +441,210 @@ } } }, + "friend_gate_retry_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "We couldn't verify whether a friend was added. Check your internet connection and try again." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось проверить, был ли добавлен друг. Проверьте подключение к интернету и повторите попытку." + } + } + } + }, + "friend_gate_retry_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't verify access" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось проверить доступ" + } + } + } + }, + "friend_link_auth_required_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finish registration or sign in to an existing account. Then open your friend's invitation link again to add them." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Завершите регистрацию или войдите в существующий аккаунт. Затем снова откройте ссылку-приглашение, чтобы добавить друга." + } + } + } + }, + "friend_link_auth_required_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Registration or sign-in required" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Требуется регистрация или вход" + } + } + } + }, + "friend_link_cancel_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancel" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Отмена" + } + } + } + }, + "friend_link_invalid_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "This invitation link is invalid or has expired. Ask your friend to send a new one." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ссылка-приглашение недействительна или устарела. Попросите друга отправить новую." + } + } + } + }, + "friend_link_invalid_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invalid invitation link" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Недействительная ссылка" + } + } + } + }, + "friend_link_processing_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wait for the current link to finish processing. To open another link, open it again after the current operation is complete." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Дождитесь завершения обработки текущей ссылки. Чтобы открыть другую ссылку, перейдите по ней ещё раз после завершения." + } + } + } + }, + "friend_link_processing_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A link is already being processed" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ссылка уже обрабатывается" + } + } + } + }, + "friend_link_retry_button" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try Again" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Повторить" + } + } + } + }, + "friend_link_retry_message" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Check your internet connection and try again. If you cancel, you'll need to open the invitation link again." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Проверьте подключение к интернету и повторите попытку. Если отменить, для добавления друга потребуется снова открыть ссылку." + } + } + } + }, + "friend_link_retry_title" : { + "extractionState" : "manual", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Couldn't add friend" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Не удалось добавить друга" + } + } + } + }, "feed_empty" : { "extractionState" : "manual", "localizations" : { diff --git a/Friendly/Sources/MainView.swift b/Friendly/Sources/MainView.swift index 2352108..b8c1cac 100644 --- a/Friendly/Sources/MainView.swift +++ b/Friendly/Sources/MainView.swift @@ -2,14 +2,14 @@ import SwiftUI struct MainView: View { @State private var viewModel: MainViewModel - @Binding private var addFriend: AddFriendCommand? + @Binding private var route: ContentViewModel.MainRoute? init( routeToSignUp: @escaping () -> Void, - addFriend: Binding, + route: Binding, ) { viewModel = MainViewModel(routeToSignUp: routeToSignUp) - _addFriend = addFriend + _route = route } var body: some View { @@ -32,7 +32,6 @@ struct MainView: View { RouterView { router in NetworkView( router: router, - addFriend: $viewModel.addFriend, ) } } @@ -53,11 +52,13 @@ struct MainView: View { } } } - .onChange(of: addFriend == nil, initial: true) { - guard let addFriend = addFriend else { return } - viewModel.command(addFriend: addFriend) - self.addFriend = nil + .onChange(of: route, initial: true) { + guard let route else { return } + switch route { + case .feed: + viewModel.showFeed() + } + self.route = nil } } } - diff --git a/Friendly/Sources/MainViewModel.swift b/Friendly/Sources/MainViewModel.swift index 4e09faf..a2e9d7c 100644 --- a/Friendly/Sources/MainViewModel.swift +++ b/Friendly/Sources/MainViewModel.swift @@ -3,16 +3,14 @@ import SwiftUI @Observable class MainViewModel { var selectedItem: Tab = .feed - var addFriend: AddFriendCommand? = nil let routeToSignUp: () -> Void init(routeToSignUp: @escaping () -> Void) { self.routeToSignUp = routeToSignUp } - func command(addFriend: AddFriendCommand) { - self.selectedItem = .network - self.addFriend = addFriend + func showFeed() { + selectedItem = .feed } enum Tab: Hashable { diff --git a/Friendly/Sources/NetworkClient.swift b/Friendly/Sources/NetworkClient.swift index f3eb812..460e93c 100644 --- a/Friendly/Sources/NetworkClient.swift +++ b/Friendly/Sources/NetworkClient.swift @@ -420,8 +420,9 @@ class NetworkClient { enum FriendsAddError: Error { case ioError(Error) - case serverError + case serverError(statusCode: Int) case unauthorized + case expiredToken } func friendsAdd( @@ -434,19 +435,27 @@ class NetworkClient { token: token.string, userId: id.int64, ) - let _ = try await transport.authorized( + let response = try await transport.authorized( path: "friends/add", method: .post, body: body, type: FriendsAddResponseBody.self, authorization: authorization, ) - } catch { + if response.type == "FriendTokenExpired" { + throw FriendsAddError.expiredToken + } + } catch let error as FriendsAddError { + throw error + } catch let error as Transport.AuthorizedError { switch error { case .ioError(let error): throw .ioError(error) - case .serverError: throw .serverError + case .serverError(let statusCode, _): + throw .serverError(statusCode: statusCode) case .unauthorized: throw .unauthorized } + } catch { + throw .serverError(statusCode: 0) } } @@ -455,7 +464,9 @@ class NetworkClient { let userId: Int64 } - private struct FriendsAddResponseBody: Decodable {} + private struct FriendsAddResponseBody: Decodable { + let type: String + } enum FriendsDeclineError: Error { case ioError(Error) @@ -575,7 +586,7 @@ class NetworkClient { static let meetacy = NetworkClient( baseUrl: URL(string: "https://api.getfriend.ly")!, landingUrl: URL( - string: "https://friendly-social.github.io/landing/#/", + string: "https://getfriend.ly/#/", )!, ) } diff --git a/Friendly/Sources/NetworkQRCodeViewModel.swift b/Friendly/Sources/NetworkQRCodeViewModel.swift index 65ab4a0..6c9a3e0 100644 --- a/Friendly/Sources/NetworkQRCodeViewModel.swift +++ b/Friendly/Sources/NetworkQRCodeViewModel.swift @@ -63,4 +63,3 @@ class NetworkQRCodeViewModel { let url: URL } } - diff --git a/Friendly/Sources/NetworkView.swift b/Friendly/Sources/NetworkView.swift index d5867ff..cd81c4a 100644 --- a/Friendly/Sources/NetworkView.swift +++ b/Friendly/Sources/NetworkView.swift @@ -2,14 +2,11 @@ import SwiftUI struct NetworkView: View { @State private var viewModel: NetworkViewModel - @Binding private var addFriend: AddFriendCommand? init( router: Router, - addFriend: Binding, ) { self.viewModel = NetworkViewModel(router: router) - _addFriend = addFriend } var body: some View { @@ -51,9 +48,11 @@ struct NetworkView: View { .sheet( isPresented: $viewModel.shouldFindQRCode, ) { - ScanToUseAppView(isBlocked: false) { viewModel.shouldFindQRCode = false } + ScanToUseAppView(isBlocked: false) { + viewModel.shouldFindQRCode = false + } } - .onAppear { viewModel.appear() } + .task { await viewModel.reload() } .navigationDestination( for: NetworkViewModel.ProfileDestination.self, ) { destination in @@ -67,11 +66,6 @@ struct NetworkView: View { ) } .refreshable { await viewModel.reload() } - .onChange(of: addFriend == nil, initial: true) { - guard let addFriend = addFriend else { return } - viewModel.command(addFriend: addFriend) - self.addFriend = nil - } } } diff --git a/Friendly/Sources/NetworkViewModel.swift b/Friendly/Sources/NetworkViewModel.swift index 13a16ad..8f568a4 100644 --- a/Friendly/Sources/NetworkViewModel.swift +++ b/Friendly/Sources/NetworkViewModel.swift @@ -7,6 +7,8 @@ class NetworkViewModel { private let networkClient: NetworkClient = .meetacy var state: State = .loading + private var reloadGeneration: Int = 0 + var shouldShowQRCode: Bool = false { didSet { if !shouldShowQRCode { @@ -40,21 +42,26 @@ class NetworkViewModel { shouldFindQRCode = true } - func appear() { - Task { - await reload() - } - } - func reload() async { + reloadGeneration &+= 1 + let generation = reloadGeneration + do { let authorization = try storage.loadAuthorization() let network = try await networkClient.networkDetails( authorization: authorization, ) + guard !Task.isCancelled, + generation == reloadGeneration else { + return + } let friends = mapUsers(network.friends) state = .success(friends) } catch { + guard !Task.isCancelled, + generation == reloadGeneration else { + return + } state = .ioError } } @@ -82,23 +89,6 @@ class NetworkViewModel { } } - func command(addFriend: AddFriendCommand) { - let id = addFriend.id - let token = addFriend.token - Task { - shouldShowQRCode = false - guard let authorization = try? storage.loadAuthorization() else { - return - } - guard let _ = try? await networkClient.friendsAdd( - authorization: authorization, - token: token, - id: id, - ) else { return } - await reload() - } - } - enum State { case loading case ioError diff --git a/Friendly/Sources/ScannerQr/ScanViewModel.swift b/Friendly/Sources/ScannerQr/ScanViewModel.swift index fae013a..3786652 100644 --- a/Friendly/Sources/ScannerQr/ScanViewModel.swift +++ b/Friendly/Sources/ScannerQr/ScanViewModel.swift @@ -15,8 +15,7 @@ final class ScanToUseAppViewModel: ObservableObject { case loading } - private let storage: Storage = .shared - private let networkClient: NetworkClient = .meetacy + private let addFriendService: AddFriendService = .shared @Published var state: State = .idle @Published var isScannerPresented = false @@ -52,18 +51,9 @@ final class ScanToUseAppViewModel: ObservableObject { errorMessage = nil isErrorAlertPresented = false - let id = friend.id - let token = friend.token Task { - guard let authorization = try? storage.loadAuthorization() else { - isErrorAlertPresented = true - return - } - guard let _ = try? await networkClient.friendsAdd( - authorization: authorization, - token: token, - id: id, - ) else { + guard let _ = try? await addFriendService.add(friend) else { + state = .idle isErrorAlertPresented = true return }