diff --git a/IceCubesApp/App/IceCubesApp.swift b/IceCubesApp/App/IceCubesApp.swift index 0e2d1b75..3223d4ce 100644 --- a/IceCubesApp/App/IceCubesApp.swift +++ b/IceCubesApp/App/IceCubesApp.swift @@ -140,9 +140,7 @@ struct IceCubesApp: App { } } selectedTab = newTab - if userPreferences.hapticTabSelectionEnabled { - HapticManager.shared.selectionChanged() - } + HapticManager.shared.fireHaptic(of: .tabSelection) })) { ForEach(availableTabs) { tab in tab.makeContentView(popToRootTab: $popToRootTab) diff --git a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift index e7b3daa2..b64cd2f7 100644 --- a/IceCubesApp/App/Tabs/Settings/SettingsTab.swift +++ b/IceCubesApp/App/Tabs/Settings/SettingsTab.swift @@ -102,9 +102,11 @@ struct SettingsTabs: View { NavigationLink(destination: DisplaySettingsView()) { Label("settings.general.display", systemImage: "paintpalette") } - NavigationLink(destination: HapticSettingsView()) { - Label("settings.general.haptic", systemImage: "waveform.path") - } + if HapticManager.shared.supportsHaptics { + NavigationLink(destination: HapticSettingsView()) { + Label("settings.general.haptic", systemImage: "waveform.path") + } + } NavigationLink(destination: remoteLocalTimelinesView) { Label("settings.general.remote-timelines", systemImage: "dot.radiowaves.right") } diff --git a/IceCubesApp/Resources/Localization/ko.lproj/Localizable.strings b/IceCubesApp/Resources/Localization/ko.lproj/Localizable.strings index 27671c08..8cb1dfb4 100644 --- a/IceCubesApp/Resources/Localization/ko.lproj/Localizable.strings +++ b/IceCubesApp/Resources/Localization/ko.lproj/Localizable.strings @@ -24,14 +24,14 @@ "enum.avatar-position.top" = "본문 위"; "enum.avatar-shape.circle" = "원"; "enum.avatar-shape.rounded" = "둥근 사각형"; -"enum.durations.infinite" = "infinite"; -"enum.durations.fiveMinutes" = "5 minutes"; -"enum.durations.thirtyMinutes" = "30 minutes"; -"enum.durations.oneHour" = "1 hour"; -"enum.durations.sixHours" = "6 hours"; -"enum.durations.oneDay" = "1 day"; -"enum.durations.threeDays" = "3 days"; -"enum.durations.sevenDays" = "7 days"; +"enum.durations.infinite" = "해제할 때까지"; +"enum.durations.fiveMinutes" = "5분"; +"enum.durations.thirtyMinutes" = "30분"; +"enum.durations.oneHour" = "1시간"; +"enum.durations.sixHours" = "6시간"; +"enum.durations.oneDay" = "1일"; +"enum.durations.threeDays" = "3일"; +"enum.durations.sevenDays" = "7일"; "enum.status-actions-display.all" = "모두 표시"; "enum.status-actions-display.no-buttons" = "표시하지 않음"; "enum.status-actions-display.only-buttons" = "버튼만 표시"; @@ -193,7 +193,7 @@ "account.action.unblock" = "차단 해제"; "account.action.mute" = "뮤트"; "account.action.unmute" = "뮤트 해제"; -"account.action.share" = "Share this account"; +"account.action.share" = "공유"; "account.boosted-by" = "부스트한 사용자"; "account.detail.about" = "정보"; "account.detail.familiar-followers" = "내가 아는 팔로워"; @@ -315,7 +315,7 @@ "timeline.local" = "로컬"; "timeline.n-recent-from-n-participants %lld %lld" = "%lld개의 최근 글 (%lld명의 사용자가 이야기 중)"; "timeline.trending" = "뜨고 있는"; -"timeline.add.url" = "Instance URL"; +"timeline.add.url" = "인스턴스 URL"; // MARK: Package: Status "status.action.translate" = "번역"; @@ -340,8 +340,8 @@ "status.action.unfavorite" = "좋아요 취소"; "status.action.unpin" = "고정 해제"; "status.action.view-in-browser" = "브라우저에서 보기"; -"status.card.share" = "Share this link"; -"status.card.copy" = "Copy this link"; +"status.card.share" = "링크 공유"; +"status.card.copy" = "링크 복사"; "status.draft.delete" = "삭제"; "status.draft.save" = "임시 보관함에 저장"; "status.editor.ai-prompt.correct" = "맞게 고치기"; diff --git a/IceCubesApp/Resources/Localization/nl.lproj/Localizable.strings b/IceCubesApp/Resources/Localization/nl.lproj/Localizable.strings index 88e70a04..e17ecdf7 100644 --- a/IceCubesApp/Resources/Localization/nl.lproj/Localizable.strings +++ b/IceCubesApp/Resources/Localization/nl.lproj/Localizable.strings @@ -24,14 +24,14 @@ "enum.avatar-position.top" = "Boven"; "enum.avatar-shape.circle" = "Cirkel"; "enum.avatar-shape.rounded" = "Afgerond"; -"enum.durations.infinite" = "infinite"; -"enum.durations.fiveMinutes" = "5 minutes"; -"enum.durations.thirtyMinutes" = "30 minutes"; -"enum.durations.oneHour" = "1 hour"; -"enum.durations.sixHours" = "6 hours"; -"enum.durations.oneDay" = "1 day"; -"enum.durations.threeDays" = "3 days"; -"enum.durations.sevenDays" = "7 days"; +"enum.durations.infinite" = "Onbepaald"; +"enum.durations.fiveMinutes" = "5 minuten"; +"enum.durations.thirtyMinutes" = "30 minuten"; +"enum.durations.oneHour" = "1 uur"; +"enum.durations.sixHours" = "6 uur"; +"enum.durations.oneDay" = "1 dag"; +"enum.durations.threeDays" = "3 dagen"; +"enum.durations.sevenDays" = "7 dagen"; "enum.status-actions-display.all" = "Met tekst"; "enum.status-actions-display.no-buttons" = "Geen knoppen"; "enum.status-actions-display.only-buttons" = "Zonder tekst"; @@ -189,7 +189,7 @@ "account.action.unblock" = "Deblokkeer"; "account.action.mute" = "Dempen"; "account.action.unmute" = "Dempen opheffen"; -"account.action.share" = "Share this account"; +"account.action.share" = "Deel dit account"; "account.boosted-by" = "Geboost door"; "account.detail.about" = "Over"; "account.detail.familiar-followers" = "Ook gevolgd door"; @@ -332,8 +332,8 @@ "status.action.unfavorite" = "Verwijder favoriet"; "status.action.unpin" = "Maak los"; "status.action.view-in-browser" = "Open in browser"; -"status.card.share" = "Share this link"; -"status.card.copy" = "Copy this link"; +"status.card.share" = "Deel deze link"; +"status.card.copy" = "Kopieer deze link"; "status.draft.delete" = "Verwijder concept"; "status.draft.save" = "Bewaar concept"; "status.editor.ai-prompt.correct" = "Corrigeer tekst"; diff --git a/IceCubesApp/Resources/Localization/pl.lproj/Localizable.strings b/IceCubesApp/Resources/Localization/pl.lproj/Localizable.strings index 80811739..3d38798e 100644 --- a/IceCubesApp/Resources/Localization/pl.lproj/Localizable.strings +++ b/IceCubesApp/Resources/Localization/pl.lproj/Localizable.strings @@ -145,7 +145,7 @@ "settings.display.font.system" = "Systemowa"; "settings.display.font.custom" = "Własna"; "settings.display.font.scaling-%@" = "Skalowanie czcionki: %@"; -"settings.about.built-with" = "Ice Cubes is built with the following Open Source software:"; +"settings.about.built-with" = "Ice Cubes zbudowano z wykorzystaniem następującego oprogramowania Open Source:"; "settings.about.title" = "Ice Cubes"; "settings.account.cached-posts-%@" = "Liczba postów w buforze: %@"; "settings.account.action.delete-cache" = "Wyczyść bufor"; diff --git a/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift b/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift index d519a495..28af48bd 100644 --- a/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift +++ b/Packages/AppAccount/Sources/AppAccount/AppAccountsSelectorView.swift @@ -38,9 +38,7 @@ public struct AppAccountsSelectorView: View { } } .onTapGesture { - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.impact() - } + HapticManager.shared.fireHaptic(of: .buttonPress) } .onAppear { refreshAccounts() @@ -80,9 +78,7 @@ public struct AppAccountsSelectorView: View { appAccounts.currentAccount = viewModel.appAccount } - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.impact() - } + HapticManager.shared.fireHaptic(of: .buttonPress) } label: { HStack { if viewModel.account?.id == currentAccount.account?.id { @@ -96,9 +92,7 @@ public struct AppAccountsSelectorView: View { if accountCreationEnabled { Divider() Button { - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.impact() - } + HapticManager.shared.fireHaptic(of: .buttonPress) routerPath.presentedSheet = .addAccount } label: { Label("app-account.button.add", systemImage: "person.badge.plus") @@ -108,9 +102,7 @@ public struct AppAccountsSelectorView: View { if UIDevice.current.userInterfaceIdiom == .phone { Divider() Button { - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.impact() - } + HapticManager.shared.fireHaptic(of: .buttonPress) routerPath.presentedSheet = .settings } label: { Label("tab.settings", systemImage: "gear") diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/FollowRequestButtons.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/FollowRequestButtons.swift index c8d75abc..6de2e53c 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/FollowRequestButtons.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/FollowRequestButtons.swift @@ -35,7 +35,7 @@ public struct FollowRequestButtons: View { } } .buttonStyle(.bordered) - .disabled(currentAccount.isUpdating) + .disabled(currentAccount.updatingFollowRequestAccountIds.contains(account.id)) .padding(.top, 4) } } diff --git a/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift b/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift index 74e4693c..8913e248 100644 --- a/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift +++ b/Packages/DesignSystem/Sources/DesignSystem/Views/StatusEditorToolbarItem.swift @@ -8,9 +8,7 @@ public extension View { ToolbarItem(placement: .navigationBarTrailing) { Button { routerPath.presentedSheet = .newStatusEditor(visibility: visibility) - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.impact() - } + HapticManager.shared.fireHaptic(of: .buttonPress) } label: { Image(systemName: "square.and.pencil") } @@ -31,9 +29,7 @@ public struct StatusEditorToolbarItem: ToolbarContent { ToolbarItem(placement: .navigationBarTrailing) { Button { routerPath.presentedSheet = .newStatusEditor(visibility: visibility) - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.impact() - } + HapticManager.shared.fireHaptic(of: .buttonPress) } label: { Image(systemName: "square.and.pencil") } diff --git a/Packages/Env/Sources/Env/CurrentAccount.swift b/Packages/Env/Sources/Env/CurrentAccount.swift index d6abceea..e354e1fe 100644 --- a/Packages/Env/Sources/Env/CurrentAccount.swift +++ b/Packages/Env/Sources/Env/CurrentAccount.swift @@ -9,6 +9,7 @@ public class CurrentAccount: ObservableObject { @Published public private(set) var tags: [Tag] = [] @Published public private(set) var followRequests: [Account] = [] @Published public private(set) var isUpdating: Bool = false + @Published public private(set) var updatingFollowRequestAccountIds = Set() @Published public private(set) var isLoadingAccount: Bool = false private var client: Client? @@ -122,9 +123,9 @@ public class CurrentAccount: ObservableObject { public func acceptFollowerRequest(id: String) async { guard let client else { return } do { - isUpdating = true + updatingFollowRequestAccountIds.insert(id) defer { - isUpdating = false + updatingFollowRequestAccountIds.remove(id) } _ = try await client.post(endpoint: FollowRequests.accept(id: id)) await fetchFollowerRequests() @@ -134,9 +135,9 @@ public class CurrentAccount: ObservableObject { public func rejectFollowerRequest(id: String) async { guard let client else { return } do { - isUpdating = true + updatingFollowRequestAccountIds.insert(id) defer { - isUpdating = false + updatingFollowRequestAccountIds.remove(id) } _ = try await client.post(endpoint: FollowRequests.reject(id: id)) await fetchFollowerRequests() diff --git a/Packages/Env/Sources/Env/HapticManager.swift b/Packages/Env/Sources/Env/HapticManager.swift index 2accc426..84fb23d9 100644 --- a/Packages/Env/Sources/Env/HapticManager.swift +++ b/Packages/Env/Sources/Env/HapticManager.swift @@ -1,30 +1,57 @@ +import CoreHaptics import UIKit public class HapticManager { public static let shared: HapticManager = .init() + public enum HapticType { + case buttonPress + case dataRefresh(intensity: CGFloat) + case notification(_ type: UINotificationFeedbackGenerator.FeedbackType) + case tabSelection + case timeline + } + private let selectionGenerator = UISelectionFeedbackGenerator() private let impactGenerator = UIImpactFeedbackGenerator(style: .heavy) private let notificationGenerator = UINotificationFeedbackGenerator() + private let userPreferences = UserPreferences.shared + private init() { selectionGenerator.prepare() impactGenerator.prepare() } - public func selectionChanged() { - selectionGenerator.selectionChanged() + @MainActor + public func fireHaptic(of type: HapticType) { + guard supportsHaptics else { return } + + switch type { + case .buttonPress: + if userPreferences.hapticButtonPressEnabled { + impactGenerator.impactOccurred() + } + case let .dataRefresh(intensity): + if userPreferences.hapticTimelineEnabled { + impactGenerator.impactOccurred(intensity: intensity) + } + case let .notification(type): + if userPreferences.hapticButtonPressEnabled { + notificationGenerator.notificationOccurred(type) + } + case .tabSelection: + if userPreferences.hapticTabSelectionEnabled { + selectionGenerator.selectionChanged() + } + case .timeline: + if userPreferences.hapticTimelineEnabled { + selectionGenerator.selectionChanged() + } + } } - public func impact() { - impactGenerator.impactOccurred() - } - - public func impact(intensity: CGFloat) { - impactGenerator.impactOccurred(intensity: intensity) - } - - public func notification(type: UINotificationFeedbackGenerator.FeedbackType) { - notificationGenerator.notificationOccurred(type) + public var supportsHaptics: Bool { + CHHapticEngine.capabilitiesForHardware().supportsHaptics } } diff --git a/Packages/Notifications/Sources/Notifications/NotificationsListView.swift b/Packages/Notifications/Sources/Notifications/NotificationsListView.swift index c54d0f28..0f9efc00 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationsListView.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationsListView.swift @@ -52,6 +52,7 @@ public struct NotificationsListView: View { .background(theme.primaryBackgroundColor) .task { viewModel.client = client + viewModel.currentAccount = account if let lockedType { viewModel.selectedType = lockedType } diff --git a/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift b/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift index ac9f3436..47f5b548 100644 --- a/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift +++ b/Packages/Notifications/Sources/Notifications/NotificationsViewModel.swift @@ -1,3 +1,4 @@ +import Env import Foundation import Models import Network @@ -27,6 +28,7 @@ class NotificationsViewModel: ObservableObject { } } } + var currentAccount: CurrentAccount? @Published var state: State = .loading @Published var selectedType: Models.Notification.NotificationType? { @@ -52,7 +54,7 @@ class NotificationsViewModel: ObservableObject { private var consolidatedNotifications: [ConsolidatedNotification] = [] func fetchNotifications() async { - guard let client else { return } + guard let client, let currentAccount else { return } do { var nextPageState: State.PagingState = .hasNextPage if consolidatedNotifications.isEmpty { @@ -77,6 +79,9 @@ class NotificationsViewModel: ObservableObject { at: 0 ) } + + await currentAccount.fetchFollowerRequests() + withAnimation { state = .display(notifications: consolidatedNotifications, nextPageState: consolidatedNotifications.isEmpty ? .none : nextPageState) @@ -96,6 +101,7 @@ class NotificationsViewModel: ObservableObject { maxId: lastId, types: queryTypes)) consolidatedNotifications.append(contentsOf: newNotifications.consolidated(selectedType: selectedType)) + await currentAccount?.fetchFollowerRequests() state = .display(notifications: consolidatedNotifications, nextPageState: newNotifications.count < 15 ? .none : .hasNextPage) } catch { state = .error(error: error) @@ -136,6 +142,10 @@ class NotificationsViewModel: ObservableObject { ) } + if event.notification.supportedType == .follow_request, let currentAccount { + await currentAccount.fetchFollowerRequests() + } + withAnimation { state = .display(notifications: consolidatedNotifications, nextPageState: .hasNextPage) } diff --git a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift index 099cb083..dc1f168c 100644 --- a/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift +++ b/Packages/Status/Sources/Status/Editor/StatusEditorViewModel.swift @@ -174,9 +174,7 @@ public class StatusEditorViewModel: ObservableObject { case let .edit(status): postStatus = try await client.put(endpoint: Statuses.editStatus(id: status.id, json: data)) } - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.notification(type: .success) - } + HapticManager.shared.fireHaptic(of: .notification(.success)) if hasExplicitlySelectedLanguage, let selectedLanguage { preferences?.markLanguageAsSelected(isoCode: selectedLanguage) } @@ -188,9 +186,7 @@ public class StatusEditorViewModel: ObservableObject { showPostingErrorAlert = true } isPosting = false - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.notification(type: .error) - } + HapticManager.shared.fireHaptic(of: .notification(.error)) return nil } } diff --git a/Packages/Status/Sources/Status/Row/StatusActionsView.swift b/Packages/Status/Sources/Status/Row/StatusActionsView.swift index 5d65452a..95a414d9 100644 --- a/Packages/Status/Sources/Status/Row/StatusActionsView.swift +++ b/Packages/Status/Sources/Status/Row/StatusActionsView.swift @@ -179,9 +179,7 @@ struct StatusActionsView: View { private func handleAction(action: Actions) { Task { - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.notification(type: .success) - } + HapticManager.shared.fireHaptic(of: .notification(.success)) switch action { case .respond: routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.status) diff --git a/Packages/Status/Sources/Status/Row/StatusRowView.swift b/Packages/Status/Sources/Status/Row/StatusRowView.swift index a55b852d..cbb9ed8b 100644 --- a/Packages/Status/Sources/Status/Row/StatusRowView.swift +++ b/Packages/Status/Sources/Status/Row/StatusRowView.swift @@ -425,9 +425,7 @@ public struct StatusRowView: View { private var trailinSwipeActions: some View { Button { Task { - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.notification(type: .success) - } + HapticManager.shared.fireHaptic(of: .notification(.success)) if viewModel.isFavorited { await viewModel.unFavorite() } else { @@ -440,9 +438,7 @@ public struct StatusRowView: View { .tint(.yellow) Button { Task { - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.notification(type: .success) - } + HapticManager.shared.fireHaptic(of: .notification(.success)) if viewModel.isReblogged { await viewModel.unReblog() } else { @@ -458,9 +454,7 @@ public struct StatusRowView: View { @ViewBuilder private var leadingSwipeActions: some View { Button { - if UserPreferences.shared.hapticButtonPressEnabled { - HapticManager.shared.notification(type: .success) - } + HapticManager.shared.fireHaptic(of: .notification(.success)) routerPath.presentedSheet = .replyToStatusEditor(status: viewModel.status) } label: { Image(systemName: "arrowshape.turn.up.left") diff --git a/Packages/Timeline/Sources/Timeline/PendingStatusesObserver.swift b/Packages/Timeline/Sources/Timeline/PendingStatusesObserver.swift index bb7517dd..1a681a31 100644 --- a/Packages/Timeline/Sources/Timeline/PendingStatusesObserver.swift +++ b/Packages/Timeline/Sources/Timeline/PendingStatusesObserver.swift @@ -19,9 +19,7 @@ class PendingStatusesObserver: ObservableObject { func removeStatus(status: Status) { if !disableUpdate, let index = pendingStatuses.firstIndex(of: status.id) { pendingStatuses.removeSubrange(index ... (pendingStatuses.count - 1)) - if UserPreferences.shared.hapticTimelineEnabled { - HapticManager.shared.selectionChanged() - } + HapticManager.shared.fireHaptic(of: .timeline) } } diff --git a/Packages/Timeline/Sources/Timeline/TimelineView.swift b/Packages/Timeline/Sources/Timeline/TimelineView.swift index 03beeba6..c6acf91c 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineView.swift @@ -112,13 +112,9 @@ public struct TimelineView: View { viewModel.isTimelineVisible = false } .refreshable { - if UserPreferences.shared.hapticTimelineEnabled { - HapticManager.shared.impact(intensity: 0.3) - } + HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.3)) await viewModel.fetchStatuses() - if UserPreferences.shared.hapticTimelineEnabled { - HapticManager.shared.impact(intensity: 0.7) - } + HapticManager.shared.fireHaptic(of: .dataRefresh(intensity: 0.7)) } .onChange(of: watcher.latestEvent?.id) { _ in if let latestEvent = watcher.latestEvent {