krz/hutch

an ios client for sourcehut

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

a83893afa086f0050c825144fa818024db4df056

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-02T04:25:54Z

feat: add Look Up screen and restrict management actions to owners

Implements: https://todo.sr.ht/~ccleberg/Hutch/4
 Hutch.xcodeproj/project.pbxproj                    |  16 +-
 Hutch/App/RootView.swift                           |   3 +
 Hutch/Networking/SRHTClient.swift                  |  10 +
 Hutch/Views/Lookup/LookupView.swift                | 387 +++++++++++++++++++++
 Hutch/Views/Lookup/UserProfileView.swift           |  52 +++
 Hutch/Views/More/MoreView.swift                    |   4 +
 Hutch/Views/Projects/ProjectMailingListView.swift  |  18 +-
 .../Repositories/HgRepositoryDetailView.swift      |  20 +-
 .../Views/Repositories/RepositoryDetailView.swift  |  20 +-
 Hutch/Views/Tickets/TicketDetailView.swift         |  16 +-
 Hutch/Views/Tickets/TicketListView.swift           |   9 +-
 11 files changed, 535 insertions(+), 20 deletions(-)

diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 36eed28..71bbc39 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -539,7 +539,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 18;
+				CURRENT_PROJECT_VERSION = 19;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -556,7 +556,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.7.1;
+				MARKETING_VERSION = 2.8.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -576,7 +576,7 @@
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 18;
+				CURRENT_PROJECT_VERSION = 19;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -593,7 +593,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 2.7.1;
+				MARKETING_VERSION = 2.8.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -656,7 +656,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 18;
+				CURRENT_PROJECT_VERSION = 19;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -666,7 +666,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.7.1;
+				MARKETING_VERSION = 2.8.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
@@ -685,7 +685,7 @@
 				APPLICATION_EXTENSION_API_ONLY = YES;
 				CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 18;
+				CURRENT_PROJECT_VERSION = 19;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				GENERATE_INFOPLIST_FILE = NO;
 				INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -695,7 +695,7 @@
 					"@executable_path/Frameworks",
 					"@executable_path/../../Frameworks",
 				);
-				MARKETING_VERSION = 2.7.1;
+				MARKETING_VERSION = 2.8.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				SKIP_INSTALL = YES;
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
+                    }
                 }
             }
         }