Commit 55f362e4c3
Unsigned
Layout: unified · split
Hutch.xcodeproj/project.pbxproj +8 −8
| @@ -539,7 +539,7 @@ | ||
| 539 | 539 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 540 | 540 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 541 | 541 | CODE_SIGN_STYLE = Automatic; |
| 542 | CURRENT_PROJECT_VERSION = 18; | |
| 542 | CURRENT_PROJECT_VERSION = 19; | |
| 543 | 543 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 544 | 544 | ENABLE_PREVIEWS = YES; |
| 545 | 545 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -556,7 +556,7 @@ | ||
| 556 | 556 | "$(inherited)", |
| 557 | 557 | "@executable_path/Frameworks", |
| 558 | 558 | ); |
| 559 | MARKETING_VERSION = 2.7.1; | |
| 559 | MARKETING_VERSION = 2.8.0; | |
| 560 | 560 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 561 | 561 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 562 | 562 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -576,7 +576,7 @@ | ||
| 576 | 576 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 577 | 577 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 578 | 578 | CODE_SIGN_STYLE = Automatic; |
| 579 | CURRENT_PROJECT_VERSION = 18; | |
| 579 | CURRENT_PROJECT_VERSION = 19; | |
| 580 | 580 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 581 | 581 | ENABLE_PREVIEWS = YES; |
| 582 | 582 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -593,7 +593,7 @@ | ||
| 593 | 593 | "$(inherited)", |
| 594 | 594 | "@executable_path/Frameworks", |
| 595 | 595 | ); |
| 596 | MARKETING_VERSION = 2.7.1; | |
| 596 | MARKETING_VERSION = 2.8.0; | |
| 597 | 597 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 598 | 598 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 599 | 599 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -656,7 +656,7 @@ | ||
| 656 | 656 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 657 | 657 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 658 | 658 | CODE_SIGN_STYLE = Automatic; |
| 659 | CURRENT_PROJECT_VERSION = 18; | |
| 659 | CURRENT_PROJECT_VERSION = 19; | |
| 660 | 660 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 661 | 661 | GENERATE_INFOPLIST_FILE = NO; |
| 662 | 662 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -666,7 +666,7 @@ | ||
| 666 | 666 | "@executable_path/Frameworks", |
| 667 | 667 | "@executable_path/../../Frameworks", |
| 668 | 668 | ); |
| 669 | MARKETING_VERSION = 2.7.1; | |
| 669 | MARKETING_VERSION = 2.8.0; | |
| 670 | 670 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 671 | 671 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 672 | 672 | SKIP_INSTALL = YES; |
| @@ -685,7 +685,7 @@ | ||
| 685 | 685 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 686 | 686 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 687 | 687 | CODE_SIGN_STYLE = Automatic; |
| 688 | CURRENT_PROJECT_VERSION = 18; | |
| 688 | CURRENT_PROJECT_VERSION = 19; | |
| 689 | 689 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 690 | 690 | GENERATE_INFOPLIST_FILE = NO; |
| 691 | 691 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -695,7 +695,7 @@ | ||
| 695 | 695 | "@executable_path/Frameworks", |
| 696 | 696 | "@executable_path/../../Frameworks", |
| 697 | 697 | ); |
| 698 | MARKETING_VERSION = 2.7.1; | |
| 698 | MARKETING_VERSION = 2.8.0; | |
| 699 | 699 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 700 | 700 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 701 | 701 | SKIP_INSTALL = YES; |
Hutch/App/RootView.swift +3
| @@ -263,6 +263,7 @@ enum MoreDestination: Hashable { | ||
| 263 | 263 | } |
| 264 | 264 | |
| 265 | 265 | enum MoreRoute: Hashable { |
| 266 | case lookup | |
| 266 | 267 | case lists |
| 267 | 268 | case pastes |
| 268 | 269 | case settings |
| @@ -275,6 +276,8 @@ private struct MoreNavigationRoot: View { | ||
| 275 | 276 | MoreView() |
| 276 | 277 | .navigationDestination(for: MoreRoute.self) { route in |
| 277 | 278 | switch route { |
| 279 | case .lookup: | |
| 280 | LookupView() | |
| 278 | 281 | case .lists: |
| 279 | 282 | MailingListListView() |
| 280 | 283 | case .pastes: |
Hutch/Networking/SRHTClient.swift +10
| @@ -98,6 +98,11 @@ final class SRHTClient: Sendable { | ||
| 98 | 98 | } |
| 99 | 99 | } |
| 100 | 100 | |
| 101 | if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), | |
| 102 | let errors = errorEnvelope.errors, !errors.isEmpty { | |
| 103 | throw SRHTError.graphQLErrors(errors) | |
| 104 | } | |
| 105 | ||
| 101 | 106 | // Decode GraphQL response envelope |
| 102 | 107 | let graphQLResponse: GraphQLResponse<T> |
| 103 | 108 | do { |
| @@ -248,6 +253,11 @@ final class SRHTClient: Sendable { | ||
| 248 | 253 | } |
| 249 | 254 | } |
| 250 | 255 | |
| 256 | if let errorEnvelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), | |
| 257 | let errors = errorEnvelope.errors, !errors.isEmpty { | |
| 258 | throw SRHTError.graphQLErrors(errors) | |
| 259 | } | |
| 260 | ||
| 251 | 261 | let graphQLResponse: GraphQLResponse<T> |
| 252 | 262 | do { |
| 253 | 263 | graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) |
Hutch/Views/Lookup/LookupView.swift added +387
| @@ -0,0 +1,387 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | enum LookupType: String, CaseIterable, Identifiable { | |
| 4 | case user = "User" | |
| 5 | case gitRepo = "Git Repo" | |
| 6 | case hgRepo = "Hg Repo" | |
| 7 | case mailingList = "Mailing List" | |
| 8 | case tracker = "Tracker" | |
| 9 | case buildJob = "Build Job" | |
| 10 | ||
| 11 | var id: String { rawValue } | |
| 12 | ||
| 13 | var placeholder: String { | |
| 14 | switch self { | |
| 15 | case .user: | |
| 16 | "~username" | |
| 17 | case .gitRepo, .hgRepo: | |
| 18 | "~username/repo-name" | |
| 19 | case .mailingList: | |
| 20 | "~username/list-name" | |
| 21 | case .tracker: | |
| 22 | "~username/tracker-name" | |
| 23 | case .buildJob: | |
| 24 | "Job ID (e.g. 123456)" | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | var inputLabel: String { | |
| 29 | switch self { | |
| 30 | case .user: | |
| 31 | "Username" | |
| 32 | case .gitRepo, .hgRepo: | |
| 33 | "Repository" | |
| 34 | case .mailingList: | |
| 35 | "Mailing List" | |
| 36 | case .tracker: | |
| 37 | "Tracker" | |
| 38 | case .buildJob: | |
| 39 | "Build Job ID" | |
| 40 | } | |
| 41 | } | |
| 42 | } | |
| 43 | ||
| 44 | enum LookupResult: Identifiable { | |
| 45 | case user(User) | |
| 46 | case repository(RepositorySummary) | |
| 47 | case mailingList(InboxMailingListReference) | |
| 48 | case tracker(TrackerSummary) | |
| 49 | case buildJob(Int) | |
| 50 | ||
| 51 | var id: String { | |
| 52 | switch self { | |
| 53 | case .user(let user): | |
| 54 | "user:\(user.id)" | |
| 55 | case .repository(let repository): | |
| 56 | "repo:\(repository.id)" | |
| 57 | case .mailingList(let mailingList): | |
| 58 | "list:\(mailingList.id)" | |
| 59 | case .tracker(let tracker): | |
| 60 | "tracker:\(tracker.id)" | |
| 61 | case .buildJob(let jobId): | |
| 62 | "job:\(jobId)" | |
| 63 | } | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | @Observable | |
| 68 | @MainActor | |
| 69 | final class LookupViewModel { | |
| 70 | var selectedType: LookupType = .user | |
| 71 | var inputText: String = "" | |
| 72 | private(set) var result: LookupResult? | |
| 73 | private(set) var isLooking = false | |
| 74 | var error: String? | |
| 75 | ||
| 76 | private let client: SRHTClient | |
| 77 | private let appState: AppState | |
| 78 | ||
| 79 | var resultBinding: Binding<LookupResult?> { | |
| 80 | Binding( | |
| 81 | get: { self.result }, | |
| 82 | set: { newValue in | |
| 83 | if newValue == nil { | |
| 84 | self.result = nil | |
| 85 | } | |
| 86 | } | |
| 87 | ) | |
| 88 | } | |
| 89 | ||
| 90 | init(client: SRHTClient, appState: AppState) { | |
| 91 | self.client = client | |
| 92 | self.appState = appState | |
| 93 | } | |
| 94 | ||
| 95 | func lookup() async { | |
| 96 | result = nil | |
| 97 | error = nil | |
| 98 | isLooking = true | |
| 99 | defer { isLooking = false } | |
| 100 | ||
| 101 | do { | |
| 102 | switch selectedType { | |
| 103 | case .user: | |
| 104 | result = try await lookupUser() | |
| 105 | case .gitRepo: | |
| 106 | result = try await lookupRepository(service: .git) | |
| 107 | case .hgRepo: | |
| 108 | result = try await lookupRepository(service: .hg) | |
| 109 | case .mailingList: | |
| 110 | result = try await lookupMailingList() | |
| 111 | case .tracker: | |
| 112 | result = try await lookupTracker() | |
| 113 | case .buildJob: | |
| 114 | result = try await lookupBuildJob() | |
| 115 | } | |
| 116 | } catch LookupError.invalidInput { | |
| 117 | return | |
| 118 | } catch { | |
| 119 | self.error = error.userFacingMessage | |
| 120 | } | |
| 121 | } | |
| 122 | ||
| 123 | private func parseOwnerAndName() -> (owner: String, name: String)? { | |
| 124 | let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 125 | let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | |
| 126 | let parts = normalized.split(separator: "/", omittingEmptySubsequences: false) | |
| 127 | ||
| 128 | guard parts.count == 2, !parts[0].isEmpty, !parts[1].isEmpty else { | |
| 129 | error = "Enter a value in the format ~username/name." | |
| 130 | return nil | |
| 131 | } | |
| 132 | ||
| 133 | return (String(parts[0]), String(parts[1])) | |
| 134 | } | |
| 135 | ||
| 136 | private func parseUsername() -> String? { | |
| 137 | let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 138 | let normalized = trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | |
| 139 | ||
| 140 | guard !normalized.isEmpty else { | |
| 141 | error = "Enter a username." | |
| 142 | return nil | |
| 143 | } | |
| 144 | ||
| 145 | return normalized | |
| 146 | } | |
| 147 | ||
| 148 | private func parseBuildJobId() -> Int? { | |
| 149 | let trimmed = inputText.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 150 | ||
| 151 | guard let jobId = Int(trimmed) else { | |
| 152 | error = "Enter a numeric build job ID." | |
| 153 | return nil | |
| 154 | } | |
| 155 | ||
| 156 | return jobId | |
| 157 | } | |
| 158 | ||
| 159 | private func lookupUser() async throws -> LookupResult { | |
| 160 | guard let username = parseUsername() else { throw LookupError.invalidInput } | |
| 161 | ||
| 162 | struct Response: Decodable, Sendable { | |
| 163 | let user: User | |
| 164 | } | |
| 165 | ||
| 166 | let query = """ | |
| 167 | query userLookup($username: String!) { | |
| 168 | user: userByName(username: $username) { | |
| 169 | id username canonicalName email avatar | |
| 170 | } | |
| 171 | } | |
| 172 | """ | |
| 173 | ||
| 174 | let result = try await client.execute( | |
| 175 | service: .meta, | |
| 176 | query: query, | |
| 177 | variables: ["username": username], | |
| 178 | responseType: Response.self | |
| 179 | ) | |
| 180 | ||
| 181 | return .user(result.user) | |
| 182 | } | |
| 183 | ||
| 184 | private func lookupRepository(service: SRHTService) async throws -> LookupResult { | |
| 185 | guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } | |
| 186 | ||
| 187 | let repository = try await appState.resolveRepository(owner: owner, name: name, service: service) | |
| 188 | let resolvedRepository = RepositorySummary( | |
| 189 | id: repository.id, | |
| 190 | rid: repository.rid, | |
| 191 | service: service, | |
| 192 | name: repository.name, | |
| 193 | description: repository.description, | |
| 194 | visibility: repository.visibility, | |
| 195 | updated: repository.updated, | |
| 196 | owner: repository.owner, | |
| 197 | head: repository.head | |
| 198 | ) | |
| 199 | ||
| 200 | return .repository(resolvedRepository) | |
| 201 | } | |
| 202 | ||
| 203 | private func lookupMailingList() async throws -> LookupResult { | |
| 204 | guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } | |
| 205 | ||
| 206 | struct Response: Decodable, Sendable { | |
| 207 | let user: UserWithList | |
| 208 | } | |
| 209 | ||
| 210 | struct UserWithList: Decodable, Sendable { | |
| 211 | let mailingList: InboxMailingListReference | |
| 212 | } | |
| 213 | ||
| 214 | let query = """ | |
| 215 | query mailingListLookup($owner: String!, $name: String!) { | |
| 216 | user(username: $owner) { | |
| 217 | mailingList: list(name: $name) { | |
| 218 | id rid name owner { canonicalName } | |
| 219 | } | |
| 220 | } | |
| 221 | } | |
| 222 | """ | |
| 223 | ||
| 224 | let result = try await client.execute( | |
| 225 | service: .lists, | |
| 226 | query: query, | |
| 227 | variables: ["owner": owner, "name": name], | |
| 228 | responseType: Response.self | |
| 229 | ) | |
| 230 | ||
| 231 | return .mailingList(result.user.mailingList) | |
| 232 | } | |
| 233 | ||
| 234 | private func lookupTracker() async throws -> LookupResult { | |
| 235 | guard let (owner, name) = parseOwnerAndName() else { throw LookupError.invalidInput } | |
| 236 | let tracker = try await appState.resolveTracker(owner: owner, name: name) | |
| 237 | return .tracker(tracker) | |
| 238 | } | |
| 239 | ||
| 240 | private func lookupBuildJob() async throws -> LookupResult { | |
| 241 | guard let jobId = parseBuildJobId() else { throw LookupError.invalidInput } | |
| 242 | ||
| 243 | struct Response: Decodable, Sendable { | |
| 244 | let job: JobIdOnly | |
| 245 | } | |
| 246 | ||
| 247 | struct JobIdOnly: Decodable, Sendable { | |
| 248 | let id: Int | |
| 249 | } | |
| 250 | ||
| 251 | let query = """ | |
| 252 | query buildLookup($id: Int!) { | |
| 253 | job(id: $id) { id } | |
| 254 | } | |
| 255 | """ | |
| 256 | ||
| 257 | _ = try await client.execute( | |
| 258 | service: .builds, | |
| 259 | query: query, | |
| 260 | variables: ["id": jobId], | |
| 261 | responseType: Response.self | |
| 262 | ) | |
| 263 | ||
| 264 | return .buildJob(jobId) | |
| 265 | } | |
| 266 | ||
| 267 | private enum LookupError: Error { | |
| 268 | case invalidInput | |
| 269 | } | |
| 270 | } | |
| 271 | ||
| 272 | struct LookupView: View { | |
| 273 | @Environment(AppState.self) private var appState | |
| 274 | @State private var viewModel: LookupViewModel? | |
| 275 | ||
| 276 | var body: some View { | |
| 277 | Group { | |
| 278 | if let viewModel { | |
| 279 | content(viewModel) | |
| 280 | } else { | |
| 281 | SRHTLoadingStateView(message: "Preparing lookup…") | |
| 282 | } | |
| 283 | } | |
| 284 | .navigationTitle("Look Up") | |
| 285 | .task { | |
| 286 | if viewModel == nil { | |
| 287 | viewModel = LookupViewModel(client: appState.client, appState: appState) | |
| 288 | } | |
| 289 | } | |
| 290 | } | |
| 291 | ||
| 292 | @ViewBuilder | |
| 293 | private func content(_ viewModel: LookupViewModel) -> some View { | |
| 294 | @Bindable var vm = viewModel | |
| 295 | ||
| 296 | Form { | |
| 297 | Section { | |
| 298 | Picker("Type", selection: $vm.selectedType) { | |
| 299 | ForEach(LookupType.allCases) { type in | |
| 300 | Text(type.rawValue).tag(type) | |
| 301 | } | |
| 302 | } | |
| 303 | .pickerStyle(.menu) | |
| 304 | ||
| 305 | TextField( | |
| 306 | vm.selectedType.inputLabel, | |
| 307 | text: $vm.inputText, | |
| 308 | prompt: Text(vm.selectedType.placeholder) | |
| 309 | ) | |
| 310 | .textInputAutocapitalization(.never) | |
| 311 | .autocorrectionDisabled() | |
| 312 | .submitLabel(.search) | |
| 313 | .onSubmit { | |
| 314 | Task { await vm.lookup() } | |
| 315 | } | |
| 316 | } | |
| 317 | ||
| 318 | Section { | |
| 319 | HStack { | |
| 320 | Button("Look Up") { | |
| 321 | Task { await vm.lookup() } | |
| 322 | } | |
| 323 | .disabled(vm.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || vm.isLooking) | |
| 324 | ||
| 325 | if vm.isLooking { | |
| 326 | Spacer() | |
| 327 | ProgressView() | |
| 328 | } | |
| 329 | } | |
| 330 | } | |
| 331 | } | |
| 332 | .formStyle(.grouped) | |
| 333 | .srhtErrorBanner(error: $vm.error) | |
| 334 | .sheet(item: vm.resultBinding) { result in | |
| 335 | NavigationStack { | |
| 336 | lookupDestination(result) | |
| 337 | } | |
| 338 | .navigationDestination(for: MoreRoute.self) { route in | |
| 339 | switch route { | |
| 340 | case .lookup: | |
| 341 | LookupView() | |
| 342 | case .lists: | |
| 343 | MailingListListView() | |
| 344 | case .pastes: | |
| 345 | PasteListView() | |
| 346 | case .settings: | |
| 347 | SettingsView() | |
| 348 | case .mailingList(let mailingList): | |
| 349 | MailingListDetailView(mailingList: mailingList) | |
| 350 | case .thread(let thread): | |
| 351 | ThreadDetailView( | |
| 352 | thread: thread, | |
| 353 | onViewed: { | |
| 354 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | |
| 355 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | |
| 356 | }, | |
| 357 | onMarkRead: { | |
| 358 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | |
| 359 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | |
| 360 | }, | |
| 361 | onMarkUnread: { | |
| 362 | InboxReadStateStore.markUnread(for: thread.id) | |
| 363 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | |
| 364 | } | |
| 365 | ) | |
| 366 | } | |
| 367 | } | |
| 368 | .environment(appState) | |
| 369 | } | |
| 370 | } | |
| 371 | ||
| 372 | @ViewBuilder | |
| 373 | private func lookupDestination(_ result: LookupResult) -> some View { | |
| 374 | switch result { | |
| 375 | case .user(let user): | |
| 376 | UserProfileView(user: user) | |
| 377 | case .repository(let repository): | |
| 378 | RepositoryDetailView(repository: repository) | |
| 379 | case .mailingList(let mailingList): | |
| 380 | MailingListDetailView(mailingList: mailingList) | |
| 381 | case .tracker(let tracker): | |
| 382 | TicketListView(tracker: tracker) | |
| 383 | case .buildJob(let jobId): | |
| 384 | BuildDetailView(jobId: jobId) | |
| 385 | } | |
| 386 | } | |
| 387 | } | |
Hutch/Views/Lookup/UserProfileView.swift added +52
| @@ -0,0 +1,52 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct UserProfileView: View { | |
| 4 | let user: User | |
| 5 | ||
| 6 | @Environment(AppState.self) private var appState | |
| 7 | ||
| 8 | var body: some View { | |
| 9 | List { | |
| 10 | if let avatarURL = user.avatar.flatMap(URL.init(string:)) { | |
| 11 | Section { | |
| 12 | HStack { | |
| 13 | Spacer() | |
| 14 | AsyncImage(url: avatarURL) { phase in | |
| 15 | switch phase { | |
| 16 | case .success(let image): | |
| 17 | image | |
| 18 | .resizable() | |
| 19 | .scaledToFill() | |
| 20 | case .failure, .empty: | |
| 21 | Image(systemName: "person.crop.circle.fill") | |
| 22 | .resizable() | |
| 23 | .scaledToFit() | |
| 24 | .foregroundStyle(.secondary) | |
| 25 | .padding(20) | |
| 26 | @unknown default: | |
| 27 | EmptyView() | |
| 28 | } | |
| 29 | } | |
| 30 | .frame(width: 96, height: 96) | |
| 31 | .clipShape(Circle()) | |
| 32 | .overlay { | |
| 33 | Circle() | |
| 34 | .stroke(Color.secondary.opacity(0.2), lineWidth: 1) | |
| 35 | } | |
| 36 | Spacer() | |
| 37 | } | |
| 38 | .listRowBackground(Color.clear) | |
| 39 | } | |
| 40 | } | |
| 41 | ||
| 42 | Section { | |
| 43 | LabeledContent("Username", value: user.username) | |
| 44 | LabeledContent("Canonical Name", value: user.canonicalName) | |
| 45 | LabeledContent("Email", value: user.email) | |
| 46 | } | |
| 47 | } | |
| 48 | .listStyle(.insetGrouped) | |
| 49 | .navigationTitle(user.canonicalName) | |
| 50 | .navigationBarTitleDisplayMode(.inline) | |
| 51 | } | |
| 52 | } | |
Hutch/Views/More/MoreView.swift +4
| @@ -14,6 +14,10 @@ struct MoreView: View { | ||
| 14 | 14 | var body: some View { |
| 15 | 15 | List { |
| 16 | 16 | Section { |
| 17 | NavigationLink(value: MoreRoute.lookup) { | |
| 18 | Label("Look Up", systemImage: "magnifyingglass") | |
| 19 | } | |
| 20 | ||
| 17 | 21 | NavigationLink(value: MoreRoute.lists) { |
| 18 | 22 | Label("Mailing Lists", systemImage: "list.bullet.rectangle") |
| 19 | 23 | } |
Hutch/Views/Projects/ProjectMailingListView.swift +17 −1
| @@ -257,7 +257,23 @@ struct MailingListDetailView: View { | ||
| 257 | 257 | |
| 258 | 258 | List { |
| 259 | 259 | ForEach(viewModel.filteredThreads) { thread in |
| 260 | NavigationLink(value: MoreRoute.thread(thread)) { | |
| 260 | NavigationLink { | |
| 261 | ThreadDetailView( | |
| 262 | thread: thread, | |
| 263 | onViewed: { | |
| 264 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | |
| 265 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | |
| 266 | }, | |
| 267 | onMarkRead: { | |
| 268 | InboxReadStateStore.markViewed(max(Date(), thread.lastActivityAt), for: thread.id) | |
| 269 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: -1) | |
| 270 | }, | |
| 271 | onMarkUnread: { | |
| 272 | InboxReadStateStore.markUnread(for: thread.id) | |
| 273 | NeedsAttentionSnapshotStore.adjustUnreadInboxThreads(by: 1) | |
| 274 | } | |
| 275 | ) | |
| 276 | } label: { | |
| 261 | 277 | InboxThreadRow(thread: thread) |
| 262 | 278 | } |
| 263 | 279 | .swipeActions(edge: .trailing, allowsFullSwipe: true) { |
Hutch/Views/Repositories/HgRepositoryDetailView.swift +16 −4
| @@ -14,6 +14,11 @@ struct HgRepositoryDetailView: View { | ||
| 14 | 14 | @State private var isShowingRepositoryDetails = false |
| 15 | 15 | @State private var showBrowseRefPicker = false |
| 16 | 16 | |
| 17 | private var canManageRepository: Bool { | |
| 18 | guard let currentUser = appState.currentUser else { return false } | |
| 19 | return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) | |
| 20 | } | |
| 21 | ||
| 17 | 22 | private var shareURL: URL? { |
| 18 | 23 | guard let viewModel, let selectedFilePath = viewModel.selectedFilePath else { return nil } |
| 19 | 24 | return SRHTWebURL.file( |
| @@ -51,10 +56,12 @@ struct HgRepositoryDetailView: View { | ||
| 51 | 56 | Image(systemName: "square.and.arrow.up") |
| 52 | 57 | } |
| 53 | 58 | |
| 54 | Button { | |
| 55 | showSettings = true | |
| 56 | } label: { | |
| 57 | Image(systemName: "gear") | |
| 59 | if canManageRepository { | |
| 60 | Button { | |
| 61 | showSettings = true | |
| 62 | } label: { | |
| 63 | Image(systemName: "gear") | |
| 64 | } | |
| 58 | 65 | } |
| 59 | 66 | } |
| 60 | 67 | } |
| @@ -85,6 +92,11 @@ struct HgRepositoryDetailView: View { | ||
| 85 | 92 | } |
| 86 | 93 | } |
| 87 | 94 | |
| 95 | private func normalizedUsername(_ value: String) -> String { | |
| 96 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 97 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | |
| 98 | } | |
| 99 | ||
| 88 | 100 | @ViewBuilder |
| 89 | 101 | private func content(_ viewModel: HgRepositoryDetailViewModel) -> some View { |
| 90 | 102 | VStack(spacing: 0) { |
Hutch/Views/Repositories/RepositoryDetailView.swift +16 −4
| @@ -11,6 +11,11 @@ struct RepositoryDetailView: View { | ||
| 11 | 11 | @State private var showSettings = false |
| 12 | 12 | @State private var displayName: String |
| 13 | 13 | |
| 14 | private var canManageRepository: Bool { | |
| 15 | guard let currentUser = appState.currentUser else { return false } | |
| 16 | return normalizedUsername(currentUser.username) == normalizedUsername(repository.owner.canonicalName) | |
| 17 | } | |
| 18 | ||
| 14 | 19 | init(repository: RepositorySummary, onDeleted: (() -> Void)? = nil) { |
| 15 | 20 | self.repository = repository |
| 16 | 21 | self.onDeleted = onDeleted |
| @@ -36,10 +41,12 @@ struct RepositoryDetailView: View { | ||
| 36 | 41 | Image(systemName: "square.and.arrow.up") |
| 37 | 42 | } |
| 38 | 43 | |
| 39 | Button { | |
| 40 | showSettings = true | |
| 41 | } label: { | |
| 42 | Image(systemName: "gear") | |
| 44 | if canManageRepository { | |
| 45 | Button { | |
| 46 | showSettings = true | |
| 47 | } label: { | |
| 48 | Image(systemName: "gear") | |
| 49 | } | |
| 43 | 50 | } |
| 44 | 51 | } |
| 45 | 52 | } |
| @@ -104,4 +111,9 @@ struct RepositoryDetailView: View { | ||
| 104 | 111 | set: { viewModel.error = $0 } |
| 105 | 112 | )) |
| 106 | 113 | } |
| 114 | ||
| 115 | private func normalizedUsername(_ value: String) -> String { | |
| 116 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 117 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | |
| 118 | } | |
| 107 | 119 | } |
Hutch/Views/Tickets/TicketDetailView.swift +14 −2
| @@ -20,6 +20,11 @@ struct TicketDetailView: View { | ||
| 20 | 20 | // Comment composer mode |
| 21 | 21 | @State private var commentMode: CommentMode = .write |
| 22 | 22 | |
| 23 | private var isOwnedByCurrentUser: Bool { | |
| 24 | guard let currentUser = appState.currentUser else { return false } | |
| 25 | return normalizedUsername(currentUser.username) == normalizedUsername(ownerUsername) | |
| 26 | } | |
| 27 | ||
| 23 | 28 | private enum CommentMode: String, CaseIterable { |
| 24 | 29 | case write = "Write" |
| 25 | 30 | case preview = "Preview" |
| @@ -41,7 +46,7 @@ struct TicketDetailView: View { | ||
| 41 | 46 | Image(systemName: "square.and.arrow.up") |
| 42 | 47 | } |
| 43 | 48 | |
| 44 | if let viewModel, viewModel.ticket != nil { | |
| 49 | if let viewModel, viewModel.ticket != nil, isOwnedByCurrentUser { | |
| 45 | 50 | actionsMenu(viewModel) |
| 46 | 51 | } |
| 47 | 52 | } |
| @@ -195,7 +200,9 @@ struct TicketDetailView: View { | ||
| 195 | 200 | |
| 196 | 201 | Spacer(minLength: 12) |
| 197 | 202 | |
| 198 | assignToMeButton(ticket: ticket, viewModel: viewModel) | |
| 203 | if isOwnedByCurrentUser { | |
| 204 | assignToMeButton(ticket: ticket, viewModel: viewModel) | |
| 205 | } | |
| 199 | 206 | } |
| 200 | 207 | |
| 201 | 208 | HStack(spacing: 8) { |
| @@ -334,6 +341,11 @@ struct TicketDetailView: View { | ||
| 334 | 341 | } |
| 335 | 342 | .padding() |
| 336 | 343 | } |
| 344 | ||
| 345 | private func normalizedUsername(_ value: String) -> String { | |
| 346 | let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 347 | return trimmed.hasPrefix("~") ? String(trimmed.dropFirst()) : trimmed | |
| 348 | } | |
| 337 | 349 | } |
| 338 | 350 | |
| 339 | 351 | // MARK: - Self-Sizing Markdown Web View |
Hutch/Views/Tickets/TicketListView.swift +8 −1
| @@ -18,6 +18,11 @@ struct TicketListView: View { | ||
| 18 | 18 | @State private var showTrackerLabels = false |
| 19 | 19 | @State private var showDeleteTrackerConfirmation = false |
| 20 | 20 | |
| 21 | private var isOwnedByCurrentUser: Bool { | |
| 22 | guard let currentUser = appState.currentUser else { return false } | |
| 23 | return normalizedUsername(currentUser.username) == normalizedUsername(tracker.owner.canonicalName) | |
| 24 | } | |
| 25 | ||
| 21 | 26 | init( |
| 22 | 27 | tracker: TrackerSummary, |
| 23 | 28 | onTrackerUpdated: @escaping (TrackerSummary) -> Void = { _ in }, |
| @@ -58,7 +63,9 @@ struct TicketListView: View { | ||
| 58 | 63 | } |
| 59 | 64 | .accessibilityLabel("Create ticket") |
| 60 | 65 | |
| 61 | trackerActionsMenu | |
| 66 | if isOwnedByCurrentUser { | |
| 67 | trackerActionsMenu | |
| 68 | } | |
| 62 | 69 | } |
| 63 | 70 | } |
| 64 | 71 | } |