summaryrefslogtreecommitdiff
path: root/Hutch
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch')
-rw-r--r--Hutch/App/RootView.swift3
-rw-r--r--Hutch/Networking/SRHTClient.swift10
-rw-r--r--Hutch/Views/Lookup/LookupView.swift387
-rw-r--r--Hutch/Views/Lookup/UserProfileView.swift52
-rw-r--r--Hutch/Views/More/MoreView.swift4
-rw-r--r--Hutch/Views/Projects/ProjectMailingListView.swift18
-rw-r--r--Hutch/Views/Repositories/HgRepositoryDetailView.swift20
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift20
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift16
-rw-r--r--Hutch/Views/Tickets/TicketListView.swift9
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
+ }
}
}
}