summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/AppState.swift2
-rw-r--r--Hutch/Views/Home/HomeView.swift33
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift51
-rw-r--r--Hutch/Views/Home/RecentActivityStore.swift10
-rw-r--r--Hutch/Views/Repositories/ReadmeView.swift4
5 files changed, 89 insertions, 11 deletions
diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift
index ad31c5c..a526afd 100644
--- a/Hutch/App/AppState.swift
+++ b/Hutch/App/AppState.swift
@@ -623,7 +623,7 @@ final class AppState {
defaults: accountDefaults,
accountID: activeAccountID
)
- await viewModel.loadDashboard()
+ await viewModel.loadDashboard(awaitInboxUnread: true)
}
private func clearWebData() async {
diff --git a/Hutch/Views/Home/HomeView.swift b/Hutch/Views/Home/HomeView.swift
index f0ec9ff..0e3e214 100644
--- a/Hutch/Views/Home/HomeView.swift
+++ b/Hutch/Views/Home/HomeView.swift
@@ -5,6 +5,8 @@ struct HomeView: View {
@Environment(\.scenePhase) private var scenePhase
@AppStorage(AppStorageKeys.homeFailedBuildLookbackDays, store: .standard)
private var failedBuildLookbackDays = HomeViewModel.defaultFailedBuildLookbackDays
+ @AppStorage(AppStorageKeys.swipeActionsEnabled, store: .standard)
+ private var swipeActionsEnabled = true
@State private var viewModel: HomeViewModel?
@State private var recentItems: [RecentActivityEntry] = []
@State private var isOpeningRecentItem = false
@@ -118,7 +120,7 @@ struct HomeView: View {
@ViewBuilder
private var recentSection: some View {
if !recentItems.isEmpty {
- Section("Recent") {
+ Section {
ForEach(recentItems.prefix(3)) { item in
Button {
openRecentItem(item)
@@ -128,12 +130,41 @@ struct HomeView: View {
.buttonStyle(.plain)
.disabled(isOpeningRecentItem)
.listRowSeparator(.hidden)
+ .swipeActions(edge: .trailing, allowsFullSwipe: true) {
+ if swipeActionsEnabled {
+ Button(role: .destructive) {
+ removeRecentItem(item)
+ } label: {
+ Label("Remove", systemImage: "trash")
+ }
+ }
+ }
}
.themedRow()
+ } header: {
+ HStack {
+ Text("Recent")
+ Spacer()
+ Button("Clear") {
+ clearRecentActivity()
+ }
+ .font(.caption)
+ .textCase(nil)
+ }
}
}
}
+ private func removeRecentItem(_ item: RecentActivityEntry) {
+ RecentActivityStore.remove(id: item.id, defaults: appState.accountDefaults)
+ loadRecentActivity()
+ }
+
+ private func clearRecentActivity() {
+ RecentActivityStore.clear(defaults: appState.accountDefaults)
+ loadRecentActivity()
+ }
+
private func buildsSection(_ viewModel: HomeViewModel) -> some View {
Section("Builds") {
Button {
diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift
index 7779bea..23b2d80 100644
--- a/Hutch/Views/Home/HomeViewModel.swift
+++ b/Hutch/Views/Home/HomeViewModel.swift
@@ -199,6 +199,7 @@ final class HomeViewModel {
private let projectService: ProjectService
private let ticketFetchConcurrencyLimit = 6
private let inboxUnreadConcurrencyLimit = 4
+ private var inboxRefreshTask: Task<Void, Never>?
private var currentUserKey: String {
currentUser.canonicalName
@@ -348,7 +349,19 @@ final class HomeViewModel {
/// `forceRefresh` bypasses the cache. Without it, a pull to refresh returns
/// whatever is already cached and only schedules a background fetch, so new
/// mail cannot show up on the first pull.
- func loadDashboard(forceRefresh: Bool = false) async {
+ ///
+ /// The inbox unread count is deliberately kept off this method's critical
+ /// path: it paginates every thread of every subscribed list and costs
+ /// ~25s, which — because `.refreshable` binds the spinner to this method
+ /// returning — would hold the pull-to-refresh spinner for the whole time.
+ /// Instead it refreshes in a detached background task and updates the badge
+ /// when it lands, so the spinner returns as soon as the fast branches settle.
+ ///
+ /// Callers that build a persisted snapshot from a throwaway view model —
+ /// the widget / App Intents needs-attention refresh — cannot let the inbox
+ /// fetch outlive them, so they pass `awaitInboxUnread: true` to fold it back
+ /// onto the critical path and get an accurate count before this returns.
+ func loadDashboard(forceRefresh: Bool = false, awaitInboxUnread: Bool = false) async {
isLoadingProjects = true
isLoadingAssignedTickets = true
isLoadingRecentBuilds = true
@@ -362,7 +375,6 @@ final class HomeViewModel {
async let projectsTask = loadProjects(forceRefresh: forceRefresh)
async let jobsTask = loadRecentJobs(forceRefresh: forceRefresh)
async let assignedTicketsTask = loadAssignedTickets(forceRefresh: forceRefresh)
- async let inboxUnreadTask = loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
async let systemStatusTask = loadSystemStatusSnapshot(forceRefresh: forceRefresh)
// Resolve system status first so the Home title-bar status badge can
@@ -413,13 +425,40 @@ final class HomeViewModel {
}
isLoadingAssignedTickets = false
- let inboxUnreadSnapshot = await inboxUnreadTask
- unreadInboxThreadCount = inboxUnreadSnapshot?.unreadCount
- unreadInboxThreads = inboxUnreadSnapshot?.threads ?? []
- hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
lastRefreshed = Date()
persistNeedsAttentionSnapshot()
persistSystemStatusWidgetSnapshot()
+
+ if awaitInboxUnread {
+ inboxRefreshTask?.cancel()
+ inboxRefreshTask = nil
+ applyInboxUnreadSnapshot(await loadInboxUnreadSnapshot(forceRefresh: forceRefresh))
+ } else {
+ refreshInboxUnreadInBackground(forceRefresh: forceRefresh)
+ }
+ }
+
+ /// Refreshes the inbox unread badge without blocking `loadDashboard`.
+ ///
+ /// Any in-flight refresh is cancelled so overlapping pulls don't stack
+ /// ~25s fetches. On cancellation the snapshot loader returns `nil`; the
+ /// early `Task.isCancelled` check then leaves the existing badge untouched
+ /// rather than wiping it, so a superseding refresh owns the final value.
+ private func refreshInboxUnreadInBackground(forceRefresh: Bool) {
+ inboxRefreshTask?.cancel()
+ inboxRefreshTask = Task { [weak self] in
+ guard let self else { return }
+ let snapshot = await self.loadInboxUnreadSnapshot(forceRefresh: forceRefresh)
+ guard !Task.isCancelled else { return }
+ self.applyInboxUnreadSnapshot(snapshot)
+ }
+ }
+
+ private func applyInboxUnreadSnapshot(_ snapshot: HomeInboxUnreadSnapshot?) {
+ unreadInboxThreadCount = snapshot?.unreadCount
+ unreadInboxThreads = snapshot?.threads ?? []
+ hasUnreadInboxThreads = (unreadInboxThreadCount ?? 0) > 0
+ persistNeedsAttentionSnapshot()
}
/// Returns true if sufficient time has elapsed since the last dashboard refresh.
diff --git a/Hutch/Views/Home/RecentActivityStore.swift b/Hutch/Views/Home/RecentActivityStore.swift
index 464a2c1..6bc5720 100644
--- a/Hutch/Views/Home/RecentActivityStore.swift
+++ b/Hutch/Views/Home/RecentActivityStore.swift
@@ -129,6 +129,16 @@ enum RecentActivityStore {
)
}
+ static func remove(id: String, defaults: UserDefaults) {
+ var entries = load(defaults: defaults)
+ entries.removeAll { $0.id == id }
+ save(entries, defaults: defaults)
+ }
+
+ static func clear(defaults: UserDefaults) {
+ defaults.removeObject(forKey: AppStorageKeys.recentActivity)
+ }
+
private static func record(_ entry: RecentActivityEntry, defaults: UserDefaults) {
var entries = load(defaults: defaults)
entries.removeAll { $0.id == entry.id }
diff --git a/Hutch/Views/Repositories/ReadmeView.swift b/Hutch/Views/Repositories/ReadmeView.swift
index 69d105b..b8b9622 100644
--- a/Hutch/Views/Repositories/ReadmeView.swift
+++ b/Hutch/Views/Repositories/ReadmeView.swift
@@ -1809,9 +1809,7 @@ private struct HTMLWebViewRepresentable: UIViewRepresentable {
vertical-align: middle;
}
.task-list-item {
- display: inline-flex;
- align-items: center;
- gap: 0.1rem;
+ display: inline;
}
a { color: \(linkColor); }
table { border-collapse: collapse; width: 100%; }