summaryrefslogtreecommitdiff
path: root/Hutch/Views/Lists/MailingListListView.swift
blob: 09fa2a0e0a76677f9b296ea4879c61951051468b (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
import SwiftUI

@Observable
@MainActor
final class MailingListListViewModel {
    private(set) var mailingLists: [InboxMailingListReference] = []
    private(set) var isLoading = false
    private(set) var isPerformingAction = false
    var error: String?
    var searchText = ""

    private let client: SRHTClient

    private static let subscriptionsQuery = """
    query mailingLists($cursor: Cursor) {
        subscriptions(cursor: $cursor) {
            results {
                ... on MailingListSubscription {
                    list {
                        id
                        rid
                        name
                        owner { canonicalName }
                    }
                }
            }
            cursor
        }
    }
    """

    private static let unsubscribeMutation = """
    mutation mailingListUnsubscribe($listID: Int!) {
        subscription: mailingListUnsubscribe(listID: $listID) { id }
    }
    """

    init(client: SRHTClient) {
        self.client = client
    }

    /// Unsubscribes from a list and drops it from the list on success. This view
    /// is built from the subscriptions query, so a successful unsubscribe means
    /// the row no longer belongs here.
    func unsubscribe(from mailingList: InboxMailingListReference) async {
        guard !isPerformingAction else { return }
        isPerformingAction = true
        error = nil
        defer { isPerformingAction = false }

        let previousLists = mailingLists
        mailingLists.removeAll { $0.rid == mailingList.rid }

        do {
            struct Response: Decodable, Sendable {
                // mailingListUnsubscribe is nullable: sr.ht returns null when there
                // was no subscription to remove, which is still a success.
                let subscription: SubscriptionPayload?
            }

            struct SubscriptionPayload: Decodable, Sendable {
                let id: Int
            }

            _ = try await client.execute(
                service: .lists,
                query: Self.unsubscribeMutation,
                variables: ["listID": mailingList.id],
                responseType: Response.self
            )
        } catch {
            mailingLists = previousLists
            self.error = "Couldn't unsubscribe from \(mailingList.name). \(error.userFacingMessage)"
        }
    }

    var filteredMailingLists: [InboxMailingListReference] {
        let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        guard !q.isEmpty else { return mailingLists }
        return mailingLists.filter {
            $0.name.lowercased().contains(q) ||
            $0.owner.canonicalName.lowercased().contains(q)
        }
    }

    func loadMailingLists() async {
        guard !isLoading else { return }
        isLoading = true
        error = nil
        defer { isLoading = false }

        do {
            mailingLists = try await fetchMailingLists()
        } catch {
            self.error = "Failed to load mailing lists"
        }
    }

    private func fetchMailingLists() async throws -> [InboxMailingListReference] {
        struct Response: Decodable, Sendable {
            let subscriptions: Page
        }

        struct Page: Decodable, Sendable {
            let results: [Subscription]
            let cursor: String?
        }

        struct Subscription: Decodable, Sendable {
            let list: InboxMailingListReference?
        }

        var results: [InboxMailingListReference] = []
        var cursor: String?

        while true {
            var variables: [String: any Sendable] = [:]
            if let cursor {
                variables["cursor"] = cursor
            }

            let response = try await client.execute(
                service: .lists,
                query: Self.subscriptionsQuery,
                variables: variables.isEmpty ? nil : variables,
                responseType: Response.self
            )

            results.append(contentsOf: response.subscriptions.results.compactMap(\.list))
            guard let nextCursor = response.subscriptions.cursor else {
                break
            }
            cursor = nextCursor
        }

        var seen = Set<String>()
        return results
            .filter { seen.insert($0.rid).inserted }
            .sorted {
                if $0.owner.canonicalName == $1.owner.canonicalName {
                    return $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
                }
                return $0.owner.canonicalName.localizedCaseInsensitiveCompare($1.owner.canonicalName) == .orderedAscending
            }
    }
}

struct MailingListListView: View {
    @Environment(AppState.self) private var appState
    @State private var viewModel: MailingListListViewModel?
    @State private var pendingUnsubscribe: InboxMailingListReference?

    var body: some View {
        Group {
            if let viewModel {
                content(viewModel)
            } else {
                SRHTLoadingStateView(message: "Loading mailing lists…")
            }
        }
        .navigationTitle("Mailing Lists")
        .task {
            if viewModel == nil {
                let vm = MailingListListViewModel(client: appState.client)
                viewModel = vm
                await vm.loadMailingLists()
            }
        }
    }

    @ViewBuilder
    private func content(_ viewModel: MailingListListViewModel) -> some View {
        @Bindable var vm = viewModel

        List {
            ForEach(viewModel.filteredMailingLists, id: \.rid) { mailingList in
                NavigationLink(value: MoreRoute.mailingList(mailingList)) {
                    VStack(alignment: .leading, spacing: 4) {
                        Text(mailingList.name)
                            .font(.subheadline.weight(.medium))
                        Text(mailingList.owner.canonicalName)
                            .font(.caption)
                            .foregroundStyle(.secondary)
                    }
                    .padding(.vertical, 2)
                }
                .swipeActions(edge: .trailing) {
                    Button {
                        pendingUnsubscribe = mailingList
                    } label: {
                        SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
                    }
                    .tint(.orange)
                }
            }
            .themedRow()
        }
        .themedList()
        .listStyle(.plain)
        .searchable(
            text: $vm.searchText,
            placement: .navigationBarDrawer(displayMode: .always),
            prompt: "Search lists"
        )
        .confirmationDialog(
            pendingUnsubscribe.map { "Unsubscribe from \($0.name)?" } ?? "",
            isPresented: .init(
                get: { pendingUnsubscribe != nil },
                set: { if !$0 { pendingUnsubscribe = nil } }
            ),
            titleVisibility: .visible,
            presenting: pendingUnsubscribe
        ) { mailingList in
            Button("Unsubscribe", role: .destructive) {
                Task { await viewModel.unsubscribe(from: mailingList) }
            }
            Button("Cancel", role: .cancel) { pendingUnsubscribe = nil }
        } message: { _ in
            Text("You will stop receiving email from this list. Hutch cannot resubscribe you — you would need to do that from the list's page on the web.")
        }
        .overlay {
            if viewModel.isLoading, viewModel.mailingLists.isEmpty {
                SRHTLoadingStateView(message: "Loading mailing lists…")
            } else if let error = viewModel.error, viewModel.mailingLists.isEmpty {
                SRHTErrorStateView(
                    title: "Couldn't Load Mailing Lists",
                    message: error,
                    retryAction: { await viewModel.loadMailingLists() }
                )
            } else if !viewModel.mailingLists.isEmpty, viewModel.filteredMailingLists.isEmpty {
                ContentUnavailableView.search(text: viewModel.searchText)
            } else if viewModel.mailingLists.isEmpty {
                ContentUnavailableView(
                    "No Mailing Lists",
                    systemImage: "list.bullet.rectangle",
                    description: Text("Your subscribed mailing lists will appear here.")
                )
            }
        }
        .srhtErrorBanner(error: $vm.error)
        .refreshable {
            await viewModel.loadMailingLists()
        }
    }
}