diff options
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/App/RootView.swift | 3 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTClient.swift | 10 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/LookupView.swift | 387 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileView.swift | 52 | ||||
| -rw-r--r-- | Hutch/Views/More/MoreView.swift | 4 | ||||
| -rw-r--r-- | Hutch/Views/Projects/ProjectMailingListView.swift | 18 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/HgRepositoryDetailView.swift | 20 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailView.swift | 20 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 16 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListView.swift | 9 |
10 files changed, 527 insertions, 12 deletions
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift index f0bde89..a71b247 100644 --- a/Hutch/App/RootView.swift +++ b/Hutch/App/RootView.swift @@ -263,6 +263,7 @@ enum MoreDestination: Hashable { } enum MoreRoute: Hashable { + case lookup case lists case pastes case settings @@ -275,6 +276,8 @@ private struct MoreNavigationRoot: View { MoreView() .navigationDestination(for: MoreRoute.self) { route in switch route { + case .lookup: + LookupView() case .lists: MailingListListView() case .pastes: diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index ddbd97f..1b8b9b3 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -98,6 +98,11 @@ final class SRHTClient: Sendable { } } + if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), + let errors = errorEnvelope.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + // Decode GraphQL response envelope let graphQLResponse: GraphQLResponse<T> do { @@ -248,6 +253,11 @@ final class SRHTClient: Sendable { } } + if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), + let errors = errorEnvelope.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + let graphQLResponse: GraphQLResponse<T> do { graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) diff --git a/Hutch/Views/Lookup/LookupView.swift b/Hutch/Views/Lookup/LookupView.swift new file mode 100644 index 0000000..9f42d9e --- /dev/null +++ b/Hutch/Views/Lookup/LookupView.swift @@ -0,0 +1,387 @@ +import SwiftUI + +enum LookupType: String, CaseIterable, Identifiable { + case user = "User" + case gitRepo = "Git Repo" + case hgRepo = "Hg Repo" + case mailingList = "Mailing List" + case tracker = "Tracker" + case buildJob = "Build Job" + + var id: String { rawValue } + + var placeholder: String { + switch self { + case .user: + "~username" + case .gitRepo, .hgRepo: + "~username/repo-name" + case .mailingList: + "~username/list-name" + case .tracker: + "~username/tracker-name" + case .buildJob: + "Job ID (e.g. 123456)" + } + } + + var inputLabel: String { + switch self { + case .user: + "Username" + case .gitRepo, .hgRepo: + "Repository" + case .mailingList: + "Mailing List" + case .tracker: + "Tracker" + case .buildJob: + "Build Job ID" + } + } +} + +enum LookupResult: Identifiable { + case user(User) + case repository(RepositorySummary) + case mailingList(InboxMailingListReference) + case tracker(TrackerSummary) + case buildJob(Int) + + var id: String { + switch self { + case .user(let user): + "user:\(user.id)" + case .repository(let repository): + "repo:\(repository.id)" + case .mailingList(let mailingList): + "list:\(mailingList.id)" + case .tracker(let tracker): + "tracker:\(tracker.id)" + case .buildJob(let jobId): + "job:\(jobId)" + } + } +} + +@Observable +@MainActor +final class LookupViewModel { + var selectedType: LookupType = .user + var inputText: String = "" + private(set) var result: LookupResult? + private(set) var isLooking = false + var error: String? + + private let client: SRHTClient + private let appState: AppState + + var resultBinding: Binding<LookupResult?> { + Binding( + get: { self.result }, + set: { newValue in + if newValue == nil { + self.result = nil + } + } + ) + } + + init(client: SRHTClient, appState: AppState) { + self.client = client + self.appState = appState + } + + func lookup() async { + result = nil + error = nil + isLooking = true + defer { isLooking = false } + + do { + switch selectedType { + case .user: + result = try await lookupUser() + case .gitRepo: + result = try await lookupRepository(service: .git) + case .hgRepo: + result = try await lookupRepository(service: .hg) + case .mailingList: + result = try await lookupMailingList() + case .tracker: + result = try await lookupTracker() + case .buildJob: + result = try await lookupBuildJob() + } + } catch LookupError.invalidInput { + return + } catch { + self.error = error.userFacingMessage + } + } + + private func parseOwnerAndName() -> (owner: String, name: String)? { + let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + let parts = normalized.split(separator: "/", omittingEmptySubsequences: false) + + guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { + error = "Enter a value in the format ~username/name." + return nil + } + + return (String(parts[0]), String(parts[1])) + } + + private func parseUsername() -> String? { + let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + + guard !normalized.isEmpty else { + error = "Enter a username." + return nil + } + + return normalized + } + + private func parseBuildJobId() -> Int? { + let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) + + guard let jobId = Int(trimmed) else { + error = "Enter a numeric build job ID." + return nil + } + + return jobId + } + + private func lookupUser() async throws -> LookupResult { + guard let username = parseUsername() else { throw LookupError.invalidInput } + + struct Response: Decodable, Sendable { + let user: User + } + + let query = """ + query userLookup($username: String!) { + user: userByName(username: $username) { + id username canonicalName email avatar + } + } + """ + + let result = try await client.execute( + service: .meta, + query: query, + variables: ["username": username], + responseType: Response.self + ) + + return .user(result.user) + } + + private func lookupRepository(service: SRHTService) async throws -> LookupResult { + guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } + + let repository = try await appState.resolveRepository(owner: owner, name: name, service: service) + let resolvedRepository = RepositorySummary( + id: repository.id, + rid: repository.rid, + service: service, + name: repository.name, + description: repository.description, + visibility: repository.visibility, + updated: repository.updated, + owner: repository.owner, + head: repository.head + ) + + return .repository(resolvedRepository) + } + + private func lookupMailingList() async throws -> LookupResult { + guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } + + struct Response: Decodable, Sendable { + let user: UserWithList + } + + struct UserWithList: Decodable, Sendable { + let mailingList: InboxMailingListReference + } + + let query = """ + query mailingListLookup($owner: String!, $name: String!) { + user(username: $owner) { + mailingList: list(name: $name) { + id rid name owner { canonicalName } + } + } + } + """ + + let result = try await client.execute( + service: .lists, + query: query, + variables: ["owner": owner, "name": name], + responseType: Response.self + ) + + return .mailingList(result.user.mailingList) + } + + private func lookupTracker() async throws -> LookupResult { + guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } + let tracker = try await appState.resolveTracker(owner: owner, name: name) + return .tracker(tracker) + } + + private func lookupBuildJob() async throws -> LookupResult { + guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput } + + struct Response: Decodable, Sendable { + let job: JobIdOnly + } + + struct JobIdOnly: Decodable, Sendable { + let id: Int + } + + let query = """ + query buildLookup($id: Int!) { + job(id: $id) { id } + } + """ + + _ = try await client.execute( + service: .builds, + query: query, + variables: ["id": jobId], + responseType: Response.self + ) + + return .buildJob(jobId) + } + + private enum LookupError: Error { + case invalidInput + } +} + +struct LookupView: View { + @Environment(AppState.self) private var appState + @State private var viewModel: LookupViewModel? + + var body: some View { + Group { + if let viewModel { + content(viewModel) + } else { + SRHTLoadingStateView(message: "Preparing lookup…") + } + } + .navigationTitle("Look Up") + .task { + if viewModel == nil { + viewModel = LookupViewModel(client: appState.client, appState: appState) + } + } + } + + @ViewBuilder + private func content(_ viewModel: LookupViewModel) -> some View { + @Bindable var vm = viewModel + + Form { + Section { + Picker("Type", selection: $vm.selectedType) { + ForEach(LookupType.allCases) { type in + Text(type.rawValue).tag(type) + } + } + .pickerStyle(.menu) + + TextField( + vm.selectedType.inputLabel, + text: $vm.inputText, + prompt: Text(vm.selectedType.placeholder) + ) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .submitLabel(.search) + .onSubmit { + Task { await vm.lookup() } + } + } + + Section { + HStack { + Button("Look Up") { + Task { await vm.lookup() } + } + .disabled(vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.isLooking) + + if vm.isLooking { + Spacer() + ProgressView() + } + } + } + } + .formStyle(.grouped) + .srhtErrorBanner(error: $vm.error) + .sheet(item: vm.resultBinding) { result in + NavigationStack { + lookupDestination(result) + } + .navigationDestination(for: MoreRoute.self) { route in + switch route { + case .lookup: + LookupView() + case .lists: + MailingListListView() + case .pastes: + PasteListView() + case .settings: + SettingsView() + case .mailingList(let mailingList): + MailingListDetailView(mailingList: mailingList) + case .thread(let thread): + ThreadDetailView( + thread: thread, + onViewed: { + InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) + }, + onMarkRead: { + InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) + }, + onMarkUnread: { + InboxReadStateStore.markUnread(for: thread.id) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) + } + ) + } + } + .environment(appState) + } + } + + @ViewBuilder + private func lookupDestination(_ result: LookupResult) -> some View { + switch result { + case .user(let user): + UserProfileView(user: user) + case .repository(let repository): + RepositoryDetailView(repository: repository) + case .mailingList(let mailingList): + MailingListDetailView(mailingList: mailingList) + case .tracker(let tracker): + TicketListView(tracker: tracker) + case .buildJob(let jobId): + BuildDetailView(jobId: jobId) + } + } +} diff --git a/Hutch/Views/Lookup/UserProfileView.swift b/Hutch/Views/Lookup/UserProfileView.swift new file mode 100644 index 0000000..3fcbb2f --- /dev/null +++ b/Hutch/Views/Lookup/UserProfileView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct UserProfileView: View { + let user: User + + @Environment(AppState.self) private var appState + + var body: some View { + List { + if let avatarURL = user.avatar.flatMap(URL.init(string:)) { + Section { + HStack { + Spacer() + AsyncImage(url: avatarURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFill() + case .failure, .empty: + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundStyle(.secondary) + .padding(20) + @unknown default: + EmptyView() + } + } + .frame(width: 96, height: 96) + .clipShape(Circle()) + .overlay { + Circle() + .stroke(Color.secondary.opacity(0.2), lineWidth: 1) + } + Spacer() + } + .listRowBackground(Color.clear) + } + } + + Section { + LabeledContent("Username", value: user.username) + LabeledContent("Canonical Name", value: user.canonicalName) + LabeledContent("Email", value: user.email) + } + } + .listStyle(.insetGrouped) + .navigationTitle(user.canonicalName) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/Hutch/Views/More/MoreView.swift b/Hutch/Views/More/MoreView.swift index 001a9d6..9c5cf92 100644 --- a/Hutch/Views/More/MoreView.swift +++ b/Hutch/Views/More/MoreView.swift @@ -14,6 +14,10 @@ struct MoreView: View { var body: some View { List { Section { + NavigationLink(value: MoreRoute.lookup) { + Label("Look Up", systemImage: "magnifyingglass") + } + NavigationLink(value: MoreRoute.lists) { Label("Mailing Lists", systemImage: "list.bullet.rectangle") } diff --git a/Hutch/Views/Projects/ProjectMailingListView.swift b/Hutch/Views/Projects/ProjectMailingListView.swift index 39645be..832c889 100644 --- a/Hutch/Views/Projects/ProjectMailingListView.swift +++ b/Hutch/Views/Projects/ProjectMailingListView.swift @@ -257,7 +257,23 @@ struct MailingListDetailView: View { List { ForEach(viewModel.filteredThreads) { thread in - NavigationLink(value: MoreRoute.thread(thread)) { + NavigationLink { + ThreadDetailView( + thread: thread, + onViewed: { + InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) + }, + onMarkRead: { + InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) + }, + onMarkUnread: { + InboxReadStateStore.markUnread(for: thread.id) + NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) + } + ) + } label: { InboxThreadRow(thread: thread) } .swipeActions(edge: .trailing, allowsFullSwipe: true) { diff --git a/Hutch/Views/Repositories/HgRepositoryDetailView.swift b/Hutch/Views/Repositories/HgRepositoryDetailView.swift index 6779165..575ce81 100644 --- a/Hutch/Views/Repositories/HgRepositoryDetailView.swift +++ b/Hutch/Views/Repositories/HgRepositoryDetailView.swift @@ -14,6 +14,11 @@ struct HgRepositoryDetailView: View { @State private var isShowingRepositoryDetails = false @State private var showBrowseRefPicker = false + private var canManageRepository: Bool { + guard let currentUser = appState.currentUser else { return false } + return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) + } + private var shareURL: URL? { guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } return SRHTWebURL.file( @@ -51,10 +56,12 @@ struct HgRepositoryDetailView: View { Image(systemName: "square.and.arrow.up") } - Button { - showSettings = true - } label: { - Image(systemName: "gear") + if canManageRepository { + Button { + showSettings = true + } label: { + Image(systemName: "gear") + } } } } @@ -85,6 +92,11 @@ struct HgRepositoryDetailView: View { } } + private func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + } + @ViewBuilder private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View { VStack(spacing: 0) { diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift index 9044e56..39ef912 100644 --- a/Hutch/Views/Repositories/RepositoryDetailView.swift +++ b/Hutch/Views/Repositories/RepositoryDetailView.swift @@ -11,6 +11,11 @@ struct RepositoryDetailView: View { @State private var showSettings = false @State private var displayName: String + private var canManageRepository: Bool { + guard let currentUser = appState.currentUser else { return false } + return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) + } + init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) { self.repository = repository self.onDeleted = onDeleted @@ -36,10 +41,12 @@ struct RepositoryDetailView: View { Image(systemName: "square.and.arrow.up") } - Button { - showSettings = true - } label: { - Image(systemName: "gear") + if canManageRepository { + Button { + showSettings = true + } label: { + Image(systemName: "gear") + } } } } @@ -104,4 +111,9 @@ struct RepositoryDetailView: View { set: { viewModel.error = $0 } )) } + + private func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + } } diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index 6ed5df0..22ebaa9 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -20,6 +20,11 @@ struct TicketDetailView: View { // Comment composer mode @State private var commentMode: CommentMode = .write + private var isOwnedByCurrentUser: Bool { + guard let currentUser = appState.currentUser else { return false } + return normalizedUsername(currentUser.username) == normalizedUsername(ownerUsername) + } + private enum CommentMode: String, CaseIterable { case write = "Write" case preview = "Preview" @@ -41,7 +46,7 @@ struct TicketDetailView: View { Image(systemName: "square.and.arrow.up") } - if let viewModel, viewModel.ticket != nil { + if let viewModel, viewModel.ticket != nil, isOwnedByCurrentUser { actionsMenu(viewModel) } } @@ -195,7 +200,9 @@ struct TicketDetailView: View { Spacer(minLength: 12) - assignToMeButton(ticket: ticket, viewModel: viewModel) + if isOwnedByCurrentUser { + assignToMeButton(ticket: ticket, viewModel: viewModel) + } } HStack(spacing: 8) { @@ -334,6 +341,11 @@ struct TicketDetailView: View { } .padding() } + + private func normalizedUsername(_ value: String) -> String { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed + } } // MARK: - Self-Sizing Markdown Web View diff --git a/Hutch/Views/Tickets/TicketListView.swift b/Hutch/Views/Tickets/TicketListView.swift index baed21c..a1cea6f 100644 --- a/Hutch/Views/Tickets/TicketListView.swift +++ b/Hutch/Views/Tickets/TicketListView.swift @@ -18,6 +18,11 @@ struct TicketListView: View { @State private var showTrackerLabels = false @State private var showDeleteTrackerConfirmation = false + private var isOwnedByCurrentUser: Bool { + guard let currentUser = appState.currentUser else { return false } + return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName) + } + init( tracker: TrackerSummary, onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in }, @@ -58,7 +63,9 @@ struct TicketListView: View { } .accessibilityLabel("Create ticket") - trackerActionsMenu + if isOwnedByCurrentUser { + trackerActionsMenu + } } } } |
