krz/hutch

an ios client for sourcehut

clone: git clone https://gitbay.org/krz/hutch.git

v2.8.2: Hutch/Views/Lookup/LookupView.swift · raw

  1import SwiftUI
  2import os
  3
  4private let lookupLogger = Logger(subsystem: "net.cleberg.Hutch", category: "Lookup")
  5
  6enum LookupType: String, CaseIterable, Identifiable {
  7    case user = "User"
  8    case gitRepo = "Git Repo"
  9    case hgRepo = "Hg Repo"
 10    case mailingList = "Mailing List"
 11    case tracker = "Tracker"
 12    case buildJob = "Build Job"
 13
 14    var id: String { rawValue }
 15
 16    var placeholder: String {
 17        switch self {
 18        case .user:
 19            "~username"
 20        case .gitRepo, .hgRepo:
 21            "~username/repo-name"
 22        case .mailingList:
 23            "~username/list-name"
 24        case .tracker:
 25            "~username/tracker-name"
 26        case .buildJob:
 27            "Job ID (e.g. 123456)"
 28        }
 29    }
 30
 31    var inputLabel: String {
 32        switch self {
 33        case .user:
 34            "Username"
 35        case .gitRepo, .hgRepo:
 36            "Repository"
 37        case .mailingList:
 38            "Mailing List"
 39        case .tracker:
 40            "Tracker"
 41        case .buildJob:
 42            "Build Job ID"
 43        }
 44    }
 45}
 46
 47enum LookupResult: Identifiable {
 48    case user(User)
 49    case repository(RepositorySummary)
 50    case mailingList(InboxMailingListReference)
 51    case tracker(TrackerSummary)
 52    case buildJob(Int)
 53
 54    var id: String {
 55        switch self {
 56        case .user(let user):
 57            "user:\(user.id)"
 58        case .repository(let repository):
 59            "repo:\(repository.id)"
 60        case .mailingList(let mailingList):
 61            "list:\(mailingList.id)"
 62        case .tracker(let tracker):
 63            "tracker:\(tracker.id)"
 64        case .buildJob(let jobId):
 65            "job:\(jobId)"
 66        }
 67    }
 68}
 69
 70@Observable
 71@MainActor
 72final class LookupViewModel {
 73    var selectedType: LookupType = .user
 74    var inputText: String = ""
 75    private(set) var result: LookupResult?
 76    private(set) var isLooking = false
 77    var error: String?
 78
 79    private let client: SRHTClient
 80    private let appState: AppState
 81
 82    var resultBinding: Binding<LookupResult?> {
 83        Binding(
 84            get: { self.result },
 85            set: { newValue in
 86                if newValue == nil {
 87                    self.result = nil
 88                }
 89            }
 90        )
 91    }
 92
 93    init(client: SRHTClient, appState: AppState) {
 94        self.client = client
 95        self.appState = appState
 96    }
 97
 98    func lookup() async {
 99        result = nil
100        error = nil
101        isLooking = true
102        defer { isLooking = false }
103
104        do {
105            switch selectedType {
106            case .user:
107                result = try await lookupUser()
108            case .gitRepo:
109                result = try await lookupRepository(service: .git)
110            case .hgRepo:
111                result = try await lookupRepository(service: .hg)
112            case .mailingList:
113                result = try await lookupMailingList()
114            case .tracker:
115                result = try await lookupTracker()
116            case .buildJob:
117                result = try await lookupBuildJob()
118            }
119        } catch LookupError.invalidInput {
120            return
121        } catch {
122            self.error = error.userFacingMessage
123        }
124    }
125
126    private func parseOwnerAndName() -> (owner: String, name: String)? {
127        let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
128        let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
129        let parts = normalized.split(separator: "/", omittingEmptySubsequences: false)
130
131        guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else {
132            error = "Enter a value in the format ~username/name."
133            return nil
134        }
135
136        return (String(parts[0]), String(parts[1]))
137    }
138
139    private func parseUsername() -> String? {
140        let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
141        let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed
142
143        guard !normalized.isEmpty else {
144            error = "Enter a username."
145            return nil
146        }
147
148        return normalized
149    }
150
151    private func parseBuildJobId() -> Int? {
152        let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
153
154        guard let jobId = Int(trimmed) else {
155            error = "Enter a numeric build job ID."
156            return nil
157        }
158
159        return jobId
160    }
161
162    private func lookupUser() async throws -> LookupResult {
163        guard let username = parseUsername() else { throw LookupError.invalidInput }
164
165        struct Response: Decodable, Sendable {
166            let user: User
167        }
168
169        let query = """
170        query userLookup($username: String!) {
171            user: userByName(username: $username) {
172                id
173                created
174                updated
175                canonicalName
176                username
177                email
178                url
179                location
180                bio
181                avatar
182                pronouns
183                userType
184            }
185        }
186        """
187
188        lookupLogger.debug("Looking up user profile for username: \(username, privacy: .public)")
189
190        let result: Response
191        do {
192            result = try await client.execute(
193                service: .meta,
194                query: query,
195                variables: ["username": username],
196                responseType: Response.self
197            )
198        } catch {
199            lookupLogger.error(
200                """
201                User lookup failed
202                username: \(username, privacy: .public)
203                query:
204                \(query, privacy: .public)
205                error:
206                \(String(describing: error), privacy: .public)
207                """
208            )
209            throw error
210        }
211
212        lookupLogger.debug("User lookup succeeded for username: \(username, privacy: .public)")
213
214        return .user(result.user)
215    }
216
217    private func lookupRepository(service: SRHTService) async throws -> LookupResult {
218        guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
219
220        let repository = try await appState.resolveRepository(owner: owner, name: name, service: service)
221        let resolvedRepository = RepositorySummary(
222            id: repository.id,
223            rid: repository.rid,
224            service: service,
225            name: repository.name,
226            description: repository.description,
227            visibility: repository.visibility,
228            updated: repository.updated,
229            owner: repository.owner,
230            head: repository.head
231        )
232
233        return .repository(resolvedRepository)
234    }
235
236    private func lookupMailingList() async throws -> LookupResult {
237        guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
238
239        struct Response: Decodable, Sendable {
240            let user: UserWithList
241        }
242
243        struct UserWithList: Decodable, Sendable {
244            let mailingList: InboxMailingListReference
245        }
246
247        let query = """
248        query mailingListLookup($owner: String!, $name: String!) {
249            user(username: $owner) {
250                mailingList: list(name: $name) {
251                    id rid name owner { canonicalName }
252                }
253            }
254        }
255        """
256
257        let result = try await client.execute(
258            service: .lists,
259            query: query,
260            variables: ["owner": owner, "name": name],
261            responseType: Response.self
262        )
263
264        return .mailingList(result.user.mailingList)
265    }
266
267    private func lookupTracker() async throws -> LookupResult {
268        guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput }
269        let tracker = try await appState.resolveTracker(owner: owner, name: name)
270        return .tracker(tracker)
271    }
272
273    private func lookupBuildJob() async throws -> LookupResult {
274        guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput }
275
276        struct Response: Decodable, Sendable {
277            let job: JobIdOnly
278        }
279
280        struct JobIdOnly: Decodable, Sendable {
281            let id: Int
282        }
283
284        let query = """
285        query buildLookup($id: Int!) {
286            job(id: $id) { id }
287        }
288        """
289
290        _ = try await client.execute(
291            service: .builds,
292            query: query,
293            variables: ["id": jobId],
294            responseType: Response.self
295        )
296
297        return .buildJob(jobId)
298    }
299
300    private enum LookupError: Error {
301        case invalidInput
302    }
303}
304
305struct LookupView: View {
306    @Environment(AppState.self) private var appState
307    @State private var viewModel: LookupViewModel?
308
309    var body: some View {
310        Group {
311            if let viewModel {
312                content(viewModel)
313            } else {
314                SRHTLoadingStateView(message: "Preparing lookup…")
315            }
316        }
317        .navigationTitle("Look Up")
318        .task {
319            if viewModel == nil {
320                viewModel = LookupViewModel(client: appState.client, appState: appState)
321            }
322        }
323    }
324
325    @ViewBuilder
326    private func content(_ viewModel: LookupViewModel) -> some View {
327        @Bindable var vm = viewModel
328
329        Form {
330            Section {
331                Picker("Type", selection: $vm.selectedType) {
332                    ForEach(LookupType.allCases) { type in
333                        Text(type.rawValue).tag(type)
334                    }
335                }
336                .pickerStyle(.menu)
337
338                TextField(
339                    vm.selectedType.inputLabel,
340                    text: $vm.inputText,
341                    prompt: Text(vm.selectedType.placeholder)
342                )
343                .textInputAutocapitalization(.never)
344                .autocorrectionDisabled()
345                .submitLabel(.search)
346                .onSubmit {
347                    Task { await vm.lookup() }
348                }
349            }
350
351            Section {
352                HStack {
353                    Button("Look Up") {
354                        Task { await vm.lookup() }
355                    }
356                    .disabled(vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.isLooking)
357
358                    if vm.isLooking {
359                        Spacer()
360                        ProgressView()
361                    }
362                }
363            }
364        }
365        .formStyle(.grouped)
366        .srhtErrorBanner(error: $vm.error)
367        .sheet(item: vm.resultBinding) { result in
368            NavigationStack {
369                lookupDestination(result)
370            }
371            .navigationDestination(for: MoreRoute.self) { route in
372                switch route {
373                case .lookup:
374                    LookupView()
375                case .lists:
376                    MailingListListView()
377                case .pastes:
378                    PasteListView()
379                case .settings:
380                    SettingsView()
381                case .mailingList(let mailingList):
382                    MailingListDetailView(mailingList: mailingList)
383                case .thread(let thread):
384                    ThreadDetailView(
385                        thread: thread,
386                        onViewed: {
387                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
388                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
389                        },
390                        onMarkRead: {
391                            InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id)
392                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1)
393                        },
394                        onMarkUnread: {
395                            InboxReadStateStore.markUnread(for: thread.id)
396                            NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1)
397                        }
398                    )
399                }
400            }
401            .environment(appState)
402        }
403    }
404
405    @ViewBuilder
406    private func lookupDestination(_ result: LookupResult) -> some View {
407        switch result {
408        case .user(let user):
409            UserProfileView(user: user)
410        case .repository(let repository):
411            RepositoryDetailView(repository: repository)
412        case .mailingList(let mailingList):
413            MailingListDetailView(mailingList: mailingList)
414        case .tracker(let tracker):
415            TicketListView(tracker: tracker)
416        case .buildJob(let jobId):
417            BuildDetailView(jobId: jobId)
418        }
419    }
420}