summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Hutch.xcodeproj/project.pbxproj24
-rw-r--r--Hutch/App/RootView.swift48
-rw-r--r--Hutch/Models/Meta.swift10
-rw-r--r--Hutch/Networking/SRHTClient.swift37
-rw-r--r--Hutch/Views/Lists/MailingListListView.swift368
-rw-r--r--Hutch/Views/More/ProfileView.swift56
-rw-r--r--Hutch/Views/Pastes/PasteListView.swift3
-rw-r--r--Hutch/Views/Projects/ProjectDetailView.swift11
-rw-r--r--Hutch/Views/Repositories/ArtifactsView.swift154
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailView.swift2
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift151
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift5
-rw-r--r--Hutch/Views/Repositories/RepositorySettingsViewModel.swift5
-rw-r--r--Hutch/Views/Settings/SettingsViewModel.swift42
-rw-r--r--Hutch/Views/Tickets/TrackerListView.swift3
-rw-r--r--Hutch/Views/Tickets/TrackerManagementView.swift6
-rw-r--r--README.md4
-rw-r--r--ROADMAP.md53
-rw-r--r--SCOPE.md36
19 files changed, 944 insertions, 74 deletions
diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj
index 8f71248..32da475 100644
--- a/Hutch.xcodeproj/project.pbxproj
+++ b/Hutch.xcodeproj/project.pbxproj
@@ -597,7 +597,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 89;
+ CURRENT_PROJECT_VERSION = 90;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -614,7 +614,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.7.0;
+ MARKETING_VERSION = 3.8.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 89;
+ CURRENT_PROJECT_VERSION = 90;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -651,7 +651,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 3.7.0;
+ MARKETING_VERSION = 3.8.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -714,7 +714,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 89;
+ CURRENT_PROJECT_VERSION = 90;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -724,7 +724,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.7.0;
+ MARKETING_VERSION = 3.8.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -743,7 +743,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 89;
+ CURRENT_PROJECT_VERSION = 90;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -753,7 +753,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.7.0;
+ MARKETING_VERSION = 3.8.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -772,7 +772,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 89;
+ CURRENT_PROJECT_VERSION = 90;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -782,7 +782,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.7.0;
+ MARKETING_VERSION = 3.8.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
@@ -801,7 +801,7 @@
APPLICATION_EXTENSION_API_ONLY = YES;
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 89;
+ CURRENT_PROJECT_VERSION = 90;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
GENERATE_INFOPLIST_FILE = NO;
INFOPLIST_FILE = HutchSafariExtension/Info.plist;
@@ -811,7 +811,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
- MARKETING_VERSION = 3.7.0;
+ MARKETING_VERSION = 3.8.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchSafariExtension;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
diff --git a/Hutch/App/RootView.swift b/Hutch/App/RootView.swift
index 1ae4651..10720ad 100644
--- a/Hutch/App/RootView.swift
+++ b/Hutch/App/RootView.swift
@@ -291,39 +291,45 @@ struct RootView: View {
}
}
+ /// Replaces the target tab's path in one assignment.
+ ///
+ /// Resetting the path and appending to it afterwards races when the target tab
+ /// is already the one on screen: the reset starts an animated pop of the view
+ /// the user is standing on, and the appends land mid-animation, leaving a blank
+ /// screen. That is why opening a mailing list from a pinned project on Home
+ /// worked while the same tap under More → Projects did not — one changes tabs
+ /// and the other does not.
+ ///
+ /// Building the whole path first and assigning once gives SwiftUI a single
+ /// diff, with nothing to race.
private func handleTabNavigation(_ target: AppState.TabNavigationTarget) {
switch target {
case .repository(let repository):
- repoPath = NavigationPath()
+ var path = NavigationPath()
+ path.append(repository)
+ repoPath = path
appState.selectedTab = .repositories
- Task {
- await settleNavigationTransition()
- repoPath.append(repository)
- }
case .tracker(let tracker):
- ticketsPath = NavigationPath()
+ var path = NavigationPath()
+ path.append(tracker)
+ ticketsPath = path
appState.selectedTab = .tickets
- Task {
- await settleNavigationTransition()
- ticketsPath.append(tracker)
- }
case .mailingList(let mailingList):
- morePath = NavigationPath()
+ // .lists first so back lands on Mailing Lists rather than dead-ending.
+ var path = NavigationPath()
+ path.append(MoreRoute.lists)
+ path.append(MoreRoute.mailingList(mailingList))
+ morePath = path
appState.selectedTab = .more
- Task {
- await settleNavigationTransition()
- morePath.append(MoreRoute.lists)
- morePath.append(MoreRoute.mailingList(mailingList))
- }
+
case .systemStatus:
- morePath = NavigationPath()
+ var path = NavigationPath()
+ path.append(MoreRoute.systemStatus)
+ morePath = path
appState.selectedTab = .more
- Task {
- await settleNavigationTransition()
- morePath.append(MoreRoute.systemStatus)
- }
+
case .builds:
buildsPath = NavigationPath()
appState.selectedTab = .builds
diff --git a/Hutch/Models/Meta.swift b/Hutch/Models/Meta.swift
index 91bd7e3..f52fc76 100644
--- a/Hutch/Models/Meta.swift
+++ b/Hutch/Models/Meta.swift
@@ -69,3 +69,13 @@ struct PersonalAccessToken: Codable, Sendable, Identifiable {
let comment: String?
let grants: String?
}
+
+/// One entry in meta.sr.ht's audit log: a security-relevant action on the
+/// account, with the address it came from.
+struct AuditLogEntry: Codable, Sendable, Identifiable {
+ let id: Int
+ let created: Date
+ let ipAddress: String
+ let eventType: String
+ let details: String?
+}
diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift
index 94531f4..8aaa4aa 100644
--- a/Hutch/Networking/SRHTClient.swift
+++ b/Hutch/Networking/SRHTClient.swift
@@ -276,6 +276,43 @@ final class SRHTClient: Sendable {
// MARK: - Plain-text fetch
+ /// Fetch the bytes at a URL using the same authorization header.
+ ///
+ /// sr.ht serves some resources from the API origin rather than the web one —
+ /// `Artifact.url` is `https://git.sr.ht/query/artifact/<checksum>/<filename>`
+ /// — and those return an auth error to anything without a bearer token. They
+ /// cannot be handed to a browser; they have to be fetched here.
+ func fetchData(url: URL) async throws -> Data {
+ guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
+ throw SRHTError.unauthorized
+ }
+ guard Self.isTrustedAuthenticatedTextURL(url) else {
+ throw SRHTError.invalidAuthenticatedURL(url)
+ }
+
+ var request = URLRequest(url: url)
+ request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+
+ let (data, response): (Data, URLResponse)
+ do {
+ (data, response) = try await session.data(for: request)
+ } catch {
+ throw SRHTError.networkError(error)
+ }
+
+ if let http = response as? HTTPURLResponse {
+ if http.statusCode == 401 {
+ throw SRHTError.unauthorized
+ }
+ if !(200...299).contains(http.statusCode) {
+ throw SRHTError.httpError(http.statusCode)
+ }
+ }
+
+ return data
+ }
+
/// Fetch the contents of a URL as plain text, using the same authorization header.
/// Used for build logs and other non-GraphQL resources.
func fetchText(url: URL) async throws -> String {
diff --git a/Hutch/Views/Lists/MailingListListView.swift b/Hutch/Views/Lists/MailingListListView.swift
index 09fa2a0..1b159bf 100644
--- a/Hutch/Views/Lists/MailingListListView.swift
+++ b/Hutch/Views/Lists/MailingListListView.swift
@@ -1,5 +1,9 @@
import SwiftUI
+private struct ListIDPayload: Decodable, Sendable {
+ let id: Int
+}
+
@Observable
@MainActor
final class MailingListListViewModel {
@@ -35,10 +39,176 @@ final class MailingListListViewModel {
}
"""
+ private static let createMailingListMutation = """
+ mutation createMailingList($name: String!, $description: String, $visibility: Visibility!) {
+ createMailingList(name: $name, description: $description, visibility: $visibility) {
+ id
+ rid
+ name
+ owner { canonicalName }
+ }
+ }
+ """
+
+ /// InboxMailingListReference carries only id/rid/name/owner, so the settings
+ /// sheet has to read the current values before it can offer to change them —
+ /// otherwise saving would blank the description and reset visibility.
+ private static let listSettingsQuery = """
+ query listSettings($rid: ID!) {
+ list(rid: $rid) {
+ description
+ visibility
+ }
+ }
+ """
+
+ private static let updateMailingListMutation = """
+ mutation updateMailingList($id: Int!, $input: MailingListInput!) {
+ updateMailingList(id: $id, input: $input) { id }
+ }
+ """
+
+ private static let deleteMailingListMutation = """
+ mutation deleteMailingList($id: Int!) {
+ deleteMailingList(id: $id) { id }
+ }
+ """
+
init(client: SRHTClient) {
self.client = client
}
+ /// Creates a list. sr.ht subscribes the owner automatically, so a reload is
+ /// enough to surface it — this view is built from the subscriptions query.
+ @discardableResult
+ func createMailingList(name: String, description: String, visibility: Visibility) async -> Bool {
+ guard !isPerformingAction else { return false }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+
+ do {
+ struct Response: Decodable, Sendable {
+ let createMailingList: InboxMailingListReference
+ }
+
+ _ = try await client.execute(
+ service: .lists,
+ query: Self.createMailingListMutation,
+ variables: [
+ "name": trimmedName,
+ "description": trimmedDescription.isEmpty ? nil as String? as Any : trimmedDescription,
+ "visibility": visibility.rawValue
+ ],
+ responseType: Response.self
+ )
+ await loadMailingLists()
+ return true
+ } catch {
+ self.error = "Couldn't create \(trimmedName). \(error.userFacingMessage)"
+ return false
+ }
+ }
+
+ /// Reads a list's current description and visibility, so the settings sheet
+ /// can seed itself rather than overwrite with blanks.
+ func listSettings(rid: String) async -> (description: String, visibility: Visibility)? {
+ struct Response: Decodable, Sendable {
+ let list: ListSettingsPayload?
+ }
+
+ struct ListSettingsPayload: Decodable, Sendable {
+ let description: String?
+ let visibility: Visibility
+ }
+
+ do {
+ let response = try await client.execute(
+ service: .lists,
+ query: Self.listSettingsQuery,
+ variables: ["rid": rid],
+ responseType: Response.self
+ )
+ guard let list = response.list else { return nil }
+ return (list.description ?? "", list.visibility)
+ } catch {
+ self.error = "Couldn't load the list's settings. \(error.userFacingMessage)"
+ return nil
+ }
+ }
+
+ /// Edits a list's description and visibility.
+ ///
+ /// `MailingListInput` also carries `permitMime` / `rejectMime`; those are left
+ /// alone rather than sent as empty, which would clear the list's filters.
+ @discardableResult
+ func updateMailingList(id: Int, description: String, visibility: Visibility) async -> Bool {
+ guard !isPerformingAction else { return false }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ let trimmedDescription = description.trimmingCharacters(in: .whitespacesAndNewlines)
+ var input: [String: any Sendable] = ["visibility": visibility.rawValue]
+ if trimmedDescription.isEmpty {
+ // A nil subscript assignment would drop the key and leave the old
+ // description in place instead of clearing it.
+ input.updateValue(Optional<String>.none as any Sendable, forKey: "description")
+ } else {
+ input["description"] = trimmedDescription
+ }
+
+ do {
+ struct Response: Decodable, Sendable {
+ let updateMailingList: ListIDPayload?
+ }
+
+ _ = try await client.execute(
+ service: .lists,
+ query: Self.updateMailingListMutation,
+ variables: ["id": id, "input": input],
+ responseType: Response.self
+ )
+ await loadMailingLists()
+ return true
+ } catch {
+ self.error = "Couldn't update the list. \(error.userFacingMessage)"
+ return false
+ }
+ }
+
+ @discardableResult
+ func deleteMailingList(_ mailingList: InboxMailingListReference) async -> Bool {
+ guard !isPerformingAction else { return false }
+ isPerformingAction = true
+ error = nil
+ defer { isPerformingAction = false }
+
+ let previousLists = mailingLists
+ mailingLists.removeAll { $0.rid == mailingList.rid }
+
+ do {
+ struct Response: Decodable, Sendable {
+ let deleteMailingList: ListIDPayload?
+ }
+
+ _ = try await client.execute(
+ service: .lists,
+ query: Self.deleteMailingListMutation,
+ variables: ["id": mailingList.id],
+ responseType: Response.self
+ )
+ return true
+ } catch {
+ mailingLists = previousLists
+ self.error = "Couldn't delete \(mailingList.name). \(error.userFacingMessage)"
+ return false
+ }
+ }
+
/// Unsubscribes from a list and drops it from the list on success. This view
/// is built from the subscriptions query, so a successful unsubscribe means
/// the row no longer belongs here.
@@ -149,6 +319,19 @@ struct MailingListListView: View {
@Environment(AppState.self) private var appState
@State private var viewModel: MailingListListViewModel?
@State private var pendingUnsubscribe: InboxMailingListReference?
+ @State private var pendingDeletion: InboxMailingListReference?
+ @State private var editingList: InboxMailingListReference?
+ @State private var showCreateSheet = false
+
+ /// The subscriptions query returns lists the user follows, which is not the
+ /// same as lists they own — only the owner may edit or delete one.
+ private func isOwned(_ mailingList: InboxMailingListReference) -> Bool {
+ guard let currentUser = appState.currentUser else { return false }
+ let owner = mailingList.owner.canonicalName.hasPrefix("~")
+ ? String(mailingList.owner.canonicalName.dropFirst())
+ : mailingList.owner.canonicalName
+ return owner.caseInsensitiveCompare(currentUser.username) == .orderedSame
+ }
var body: some View {
Group {
@@ -184,13 +367,32 @@ struct MailingListListView: View {
}
.padding(.vertical, 2)
}
- .swipeActions(edge: .trailing) {
- Button {
- pendingUnsubscribe = mailingList
- } label: {
- SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
+ // allowsFullSwipe: false, as in PasteListView. A destructive
+ // action left to full-swipe animates the row out on the gesture,
+ // before the confirmation is answered, so it flickers back when
+ // the data has not actually changed.
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ if isOwned(mailingList) {
+ Button {
+ pendingDeletion = mailingList
+ } label: {
+ SwiftUI.Label("Delete", systemImage: "trash")
+ }
+ .tint(.red)
+ Button {
+ editingList = mailingList
+ } label: {
+ SwiftUI.Label("Settings", systemImage: "gear")
+ }
+ .tint(.gray)
+ } else {
+ Button {
+ pendingUnsubscribe = mailingList
+ } label: {
+ SwiftUI.Label("Unsubscribe", systemImage: "bell.slash")
+ }
+ .tint(.orange)
}
- .tint(.orange)
}
}
.themedRow()
@@ -218,6 +420,46 @@ struct MailingListListView: View {
} message: { _ in
Text("You will stop receiving email from this list. Hutch cannot resubscribe you — you would need to do that from the list's page on the web.")
}
+ .toolbar {
+ ToolbarItem(placement: .topBarTrailing) {
+ Button {
+ showCreateSheet = true
+ } label: {
+ SwiftUI.Label("New List", systemImage: "plus")
+ }
+ .disabled(viewModel.isPerformingAction)
+ }
+ }
+ .sheet(isPresented: $showCreateSheet) {
+ MailingListEditSheet(mode: .create, isPresented: $showCreateSheet) { name, description, visibility in
+ await viewModel.createMailingList(name: name, description: description, visibility: visibility)
+ }
+ }
+ .sheet(item: $editingList) { mailingList in
+ MailingListEditSheet(
+ mode: .edit(mailingList.name),
+ isPresented: .init(get: { true }, set: { if !$0 { editingList = nil } }),
+ loadInitialValues: { await viewModel.listSettings(rid: mailingList.rid) }
+ ) { _, description, visibility in
+ await viewModel.updateMailingList(id: mailingList.id, description: description, visibility: visibility)
+ }
+ }
+ .confirmationDialog(
+ pendingDeletion.map { "Delete \($0.name)?" } ?? "",
+ isPresented: .init(
+ get: { pendingDeletion != nil },
+ set: { if !$0 { pendingDeletion = nil } }
+ ),
+ titleVisibility: .visible,
+ presenting: pendingDeletion
+ ) { mailingList in
+ Button("Delete List", role: .destructive) {
+ Task { await viewModel.deleteMailingList(mailingList) }
+ }
+ Button("Cancel", role: .cancel) { pendingDeletion = nil }
+ } message: { _ in
+ Text("This permanently deletes the list and its entire archive, for everyone. This cannot be undone.")
+ }
.overlay {
if viewModel.isLoading, viewModel.mailingLists.isEmpty {
SRHTLoadingStateView(message: "Loading mailing lists…")
@@ -243,3 +485,117 @@ struct MailingListListView: View {
}
}
}
+
+// MARK: - Edit Sheet
+
+/// Create and settings share a sheet: sr.ht takes name only at creation, and
+/// description plus visibility in both cases.
+private struct MailingListEditSheet: View {
+ enum Mode {
+ case create
+ case edit(String)
+
+ var title: String {
+ switch self {
+ case .create: "New Mailing List"
+ case .edit(let name): name
+ }
+ }
+
+ var isCreate: Bool {
+ if case .create = self { return true }
+ return false
+ }
+ }
+
+ let mode: Mode
+ @Binding var isPresented: Bool
+ /// Seeds the sheet with the list's current values. Editing without this would
+ /// save blanks over whatever is already there.
+ var loadInitialValues: (() async -> (description: String, visibility: Visibility)?)?
+ let onSubmit: (String, String, Visibility) async -> Bool
+
+ @State private var name = ""
+ @State private var description = ""
+ @State private var visibility: Visibility = .publicVisibility
+ @State private var isSubmitting = false
+ @State private var isLoadingInitialValues = false
+ @State private var hasLoadedInitialValues = false
+
+ private var trimmedName: String {
+ name.trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+
+ private var canSubmit: Bool {
+ guard !isSubmitting, !isLoadingInitialValues else { return false }
+ if mode.isCreate { return !trimmedName.isEmpty }
+ // Never offer to save values we have not read back yet.
+ return hasLoadedInitialValues
+ }
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ if mode.isCreate {
+ Section("Name") {
+ TextField("list-name", text: $name)
+ .textInputAutocapitalization(.never)
+ .autocorrectionDisabled()
+ .themedRow()
+ }
+ }
+
+ Section("Description") {
+ TextField("Description", text: $description, axis: .vertical)
+ .lineLimit(2...6)
+ .themedRow()
+ }
+
+ Section("Visibility") {
+ Picker("Visibility", selection: $visibility) {
+ Text("Public").tag(Visibility.publicVisibility)
+ Text("Unlisted").tag(Visibility.unlisted)
+ Text("Private").tag(Visibility.privateVisibility)
+ }
+ .pickerStyle(.inline)
+ .labelsHidden()
+ .themedRow()
+ }
+ }
+ .themedList()
+ .navigationTitle(mode.title)
+ .navigationBarTitleDisplayMode(.inline)
+ .task {
+ guard let loadInitialValues, !hasLoadedInitialValues else { return }
+ isLoadingInitialValues = true
+ if let current = await loadInitialValues() {
+ description = current.description
+ visibility = current.visibility
+ hasLoadedInitialValues = true
+ }
+ isLoadingInitialValues = false
+ }
+ .toolbar {
+ ToolbarItem(placement: .cancellationAction) {
+ Button("Cancel") { isPresented = false }
+ }
+ ToolbarItem(placement: .confirmationAction) {
+ Button(mode.isCreate ? "Create" : "Save") {
+ Task {
+ isSubmitting = true
+ let ok = await onSubmit(trimmedName, description, visibility)
+ isSubmitting = false
+ if ok { isPresented = false }
+ }
+ }
+ .disabled(!canSubmit)
+ }
+ }
+ .overlay {
+ if isSubmitting || isLoadingInitialValues {
+ ProgressView()
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/More/ProfileView.swift b/Hutch/Views/More/ProfileView.swift
index 45f7d06..fc1ed2f 100644
--- a/Hutch/Views/More/ProfileView.swift
+++ b/Hutch/Views/More/ProfileView.swift
@@ -84,6 +84,7 @@ struct ProfileView: View {
sshKeysSection(viewModel)
pgpKeysSection(viewModel)
patSection(viewModel)
+ auditLogSection(viewModel)
}
}
.themedList()
@@ -432,6 +433,61 @@ struct ProfileView: View {
}
}
}
+
+ /// Loaded on demand, like the tokens above — an audit log is something you go
+ /// looking for, not something worth a request on every profile view.
+ @ViewBuilder
+ private func auditLogSection(_ viewModel: SettingsViewModel) -> some View {
+ Section {
+ if viewModel.isLoadingAuditLog {
+ HStack {
+ Spacer()
+ ProgressView()
+ Spacer()
+ }
+ .themedRow()
+ } else if let error = viewModel.auditLogError {
+ Text(error)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .themedRow()
+ } else if viewModel.auditLog.isEmpty {
+ Button("Load Audit Log") {
+ Task { await viewModel.loadAuditLog() }
+ }
+ .themedRow()
+ } else {
+ ForEach(viewModel.auditLog) { entry in
+ VStack(alignment: .leading, spacing: 4) {
+ Text(entry.eventType)
+ .font(.subheadline)
+
+ if let details = entry.details, !details.isEmpty {
+ Text(details)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ .lineLimit(3)
+ }
+
+ HStack(spacing: 12) {
+ Text(entry.created.relativeDescription)
+ Text(entry.ipAddress)
+ .monospaced()
+ }
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ .accessibilityElement(children: .combine)
+ .accessibilityLabel("\(entry.eventType), \(entry.created.relativeDescription), from \(entry.ipAddress)")
+ }
+ .themedRow()
+ }
+ } header: {
+ Text("Audit Log")
+ } footer: {
+ Text("Recent security-relevant activity on your account, newest first. The full log lives on meta.sr.ht.")
+ }
+ }
}
private struct ProfileBioView: View {
diff --git a/Hutch/Views/Pastes/PasteListView.swift b/Hutch/Views/Pastes/PasteListView.swift
index b325153..b2c7838 100644
--- a/Hutch/Views/Pastes/PasteListView.swift
+++ b/Hutch/Views/Pastes/PasteListView.swift
@@ -90,11 +90,12 @@ struct PasteListView: View {
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
if swipeActionsEnabled {
- Button(role: .destructive) {
+ Button {
pasteToDelete = paste
} label: {
Label("Delete", systemImage: "trash")
}
+ .tint(.red)
}
}
.task {
diff --git a/Hutch/Views/Projects/ProjectDetailView.swift b/Hutch/Views/Projects/ProjectDetailView.swift
index b5c6cdb..c30ec88 100644
--- a/Hutch/Views/Projects/ProjectDetailView.swift
+++ b/Hutch/Views/Projects/ProjectDetailView.swift
@@ -196,9 +196,14 @@ struct ProjectDetailView: View {
if !displayedProject.mailingLists.isEmpty {
Section("Mailing Lists") {
ForEach(displayedProject.mailingLists) { mailingList in
- Button {
- appState.openMailingList(mailingList.inboxReference)
- dismiss()
+ // Pushed here rather than routed through AppState. Projects
+ // already lives in the More tab, so asking for a tab
+ // navigation made the path rebuild itself while dismiss()
+ // popped this view out from under it, leaving a blank screen.
+ // Sources and trackers still route, because they genuinely
+ // land in other tabs.
+ NavigationLink {
+ MailingListDetailView(mailingList: mailingList.inboxReference)
} label: {
ProjectResourceRow(
title: mailingList.displayName,
diff --git a/Hutch/Views/Repositories/ArtifactsView.swift b/Hutch/Views/Repositories/ArtifactsView.swift
index b8caf4c..69da0d6 100644
--- a/Hutch/Views/Repositories/ArtifactsView.swift
+++ b/Hutch/Views/Repositories/ArtifactsView.swift
@@ -1,24 +1,146 @@
import SwiftUI
+import UniformTypeIdentifiers
struct ArtifactsView: View {
let viewModel: RepositoryDetailViewModel
- @Environment(\.openURL) private var openURL
+ /// Passed in rather than recomputed: RepositoryDetailView already owns this
+ /// check and gates its other management surfaces on it.
+ var canManage: Bool = false
+
+ @State private var uploadTargetRef: String?
+ @State private var isImporting = false
+ @State private var pendingDeletion: ArtifactInfo?
+ @State private var downloadedFile: DownloadedArtifact?
+
+ private var isOwnedByCurrentUser: Bool { canManage }
+
+ /// A menu rather than a confirmation dialog: this view already presents one
+ /// for delete, and two .confirmationDialog modifiers on the same view leave
+ /// one of them silently dead. A menu also puts the tags one tap away.
+ @ViewBuilder
+ private var uploadMenu: some View {
+ Menu {
+ if viewModel.tags.isEmpty {
+ Text("This repository has no tags")
+ } else {
+ ForEach(viewModel.tags.prefix(12), id: \.name) { tag in
+ Button(RepositorySummary.displayBranchName(for: tag.name)) {
+ uploadTargetRef = tag.name
+ isImporting = true
+ }
+ }
+ }
+ } label: {
+ SwiftUI.Label("Upload Artifact…", systemImage: "square.and.arrow.up")
+ }
+ // Deliberately not disabled when there are no tags. The explanation for
+ // that state lives inside the menu, and disabling the control makes the
+ // explanation unreachable — the tap just dies with no reason given.
+ .disabled(viewModel.isMutatingArtifact)
+ }
var body: some View {
- List {
+ @Bindable var vm = viewModel
+
+ return List {
+ // In the list rather than the toolbar: this view is a segment inside
+ // RepositoryDetailView's tab switch, not its own navigation
+ // destination, and a toolbar declared from there does not reliably
+ // reach the navigation bar. It also has to be reachable when there are
+ // no artifacts at all, which is the state a new tag is in.
+ if isOwnedByCurrentUser {
+ uploadMenu
+ .themedRow()
+ }
+
ForEach(viewModel.referenceArtifacts) { refArtifacts in
- Section(refArtifacts.name) {
+ Section {
ForEach(refArtifacts.artifacts) { artifact in
ArtifactRow(artifact: artifact) {
- openURL(artifact.url)
+ Task {
+ // Artifact.url is on the API origin and 401s
+ // without a bearer token, so it cannot be handed
+ // to a browser. Fetch it and share the file.
+ if let fileURL = await viewModel.downloadArtifact(artifact) {
+ downloadedFile = DownloadedArtifact(url: fileURL)
+ }
+ }
+ }
+ // See MailingListListView: a full-swipe destructive
+ // action animates the row out before the confirmation.
+ .swipeActions(edge: .trailing, allowsFullSwipe: false) {
+ if isOwnedByCurrentUser {
+ Button {
+ pendingDeletion = artifact
+ } label: {
+ SwiftUI.Label("Delete", systemImage: "trash")
+ }
+ .tint(.red)
+ }
}
}
.themedRow()
+ } header: {
+ HStack {
+ Text(refArtifacts.name)
+ if isOwnedByCurrentUser {
+ Spacer()
+ // Upload targets a specific tag, so the control belongs
+ // on the tag rather than in the toolbar.
+ Button {
+ uploadTargetRef = refArtifacts.name
+ isImporting = true
+ } label: {
+ SwiftUI.Label("Upload", systemImage: "plus.circle")
+ .font(.caption)
+ }
+ .disabled(viewModel.isMutatingArtifact)
+ }
+ }
}
}
}
+ // isImporting drives presentation; uploadTargetRef carries the tag. They
+ // have to be separate: a binding derived from uploadTargetRef clears it on
+ // dismissal, and dismissal happens before the completion runs — so the
+ // completion read nil and returned without uploading anything.
+ .fileImporter(
+ isPresented: $isImporting,
+ allowedContentTypes: [.data]
+ ) { result in
+ let revspec = uploadTargetRef
+ uploadTargetRef = nil
+ guard let revspec, case .success(let fileURL) = result else { return }
+ Task { await viewModel.uploadArtifact(revspec: revspec, fileURL: fileURL) }
+ }
+ .confirmationDialog(
+ pendingDeletion.map { "Delete \($0.filename)?" } ?? "",
+ isPresented: .init(
+ get: { pendingDeletion != nil },
+ set: { if !$0 { pendingDeletion = nil } }
+ ),
+ titleVisibility: .visible,
+ presenting: pendingDeletion
+ ) { artifact in
+ Button("Delete Artifact", role: .destructive) {
+ Task { await viewModel.deleteArtifact(id: artifact.id) }
+ }
+ Button("Cancel", role: .cancel) { pendingDeletion = nil }
+ } message: { _ in
+ Text("This permanently removes the artifact from the tag. This cannot be undone.")
+ }
.themedList()
.listStyle(.insetGrouped)
+ .srhtErrorBanner(error: $vm.error)
+ .sheet(item: $downloadedFile) { download in
+ FileContentShareSheet(activityItems: [download.url])
+ }
+ .task {
+ // Tags drive the picker above and are not otherwise needed by this tab.
+ if isOwnedByCurrentUser, viewModel.tags.isEmpty {
+ await viewModel.loadReferences()
+ }
+ }
.overlay {
if viewModel.isLoadingArtifacts, viewModel.referenceArtifacts.isEmpty {
SRHTLoadingStateView(message: "Loading artifacts…")
@@ -29,11 +151,18 @@ struct ArtifactsView: View {
retryAction: { await viewModel.loadArtifacts() }
)
} else if viewModel.referenceArtifacts.isEmpty {
- ContentUnavailableView(
- "No Artifacts",
- systemImage: "archivebox",
- description: Text("This repository has no release artifacts.")
- )
+ // The overlay covers the whole list, so the upload row above is
+ // hidden underneath it — and a repository with no artifacts is
+ // exactly the one that needs uploading. Offer it here too.
+ ContentUnavailableView {
+ SwiftUI.Label("No Artifacts", systemImage: "archivebox")
+ } description: {
+ Text("This repository has no release artifacts.")
+ } actions: {
+ if isOwnedByCurrentUser {
+ uploadMenu
+ }
+ }
}
}
.task {
@@ -47,6 +176,13 @@ struct ArtifactsView: View {
}
}
+/// Wraps the downloaded file for `.sheet(item:)`. URL is not Identifiable, and
+/// conforming a stdlib type retroactively is worse than a four-line struct.
+private struct DownloadedArtifact: Identifiable {
+ let id = UUID()
+ let url: URL
+}
+
private struct ArtifactRow: View {
let artifact: ArtifactInfo
let onDownload: () -> Void
diff --git a/Hutch/Views/Repositories/RepositoryDetailView.swift b/Hutch/Views/Repositories/RepositoryDetailView.swift
index 6e7343f..8f466ba 100644
--- a/Hutch/Views/Repositories/RepositoryDetailView.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailView.swift
@@ -121,7 +121,7 @@ struct RepositoryDetailView: View {
case .refs:
ReferencesListView(viewModel: viewModel)
case .artifacts:
- ArtifactsView(viewModel: viewModel)
+ ArtifactsView(viewModel: viewModel, canManage: canManageRepository)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
index 4839437..9b6b942 100644
--- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift
@@ -75,6 +75,20 @@ private struct PathObject: Decodable, Sendable {
let text: String?
}
+private struct UploadArtifactResponse: Decodable, Sendable {
+ let uploadArtifact: ArtifactInfo
+}
+
+private struct DeleteArtifactResponse: Decodable, Sendable {
+ /// Nullable in the schema: sr.ht returns null when there was no artifact to
+ /// remove, which is still a success from the caller's point of view.
+ let deleteArtifact: ArtifactIDPayload?
+}
+
+private struct ArtifactIDPayload: Decodable, Sendable {
+ let id: Int
+}
+
private struct ArtifactsResponse: Decodable, Sendable {
let repository: ArtifactsRepository?
}
@@ -144,6 +158,7 @@ final class RepositoryDetailViewModel {
private(set) var referenceArtifacts: [ReferenceWithArtifacts] = []
private(set) var isLoadingArtifacts = false
+ private(set) var isMutatingArtifact = false
// MARK: - Error
@@ -457,6 +472,26 @@ final class RepositoryDetailViewModel {
// MARK: - Artifacts
+ /// `file` is a top-level Upload variable here, unlike meta's avatar upload
+ /// where it is nested inside an input object.
+ private static let uploadArtifactMutation = """
+ mutation uploadArtifact($repoId: Int!, $revspec: String!, $file: Upload!) {
+ uploadArtifact(repoId: $repoId, revspec: $revspec, file: $file) {
+ id
+ filename
+ checksum
+ size
+ url
+ }
+ }
+ """
+
+ private static let deleteArtifactMutation = """
+ mutation deleteArtifact($id: Int!) {
+ deleteArtifact(id: $id) { id }
+ }
+ """
+
private static let artifactsQuery = """
query artifacts($rid: ID!) {
repository(rid: $rid) {
@@ -480,6 +515,122 @@ final class RepositoryDetailViewModel {
}
"""
+ /// Attaches a file to the tag named by `revspec`.
+ ///
+ /// sr.ht requires the filename to be unique among the repository's artifacts,
+ /// and rejects a duplicate rather than replacing it, so the error is surfaced
+ /// as-is rather than being retried.
+ @discardableResult
+ func uploadArtifact(revspec: String, fileURL: URL) async -> Bool {
+ guard !isMutatingArtifact else { return false }
+ isMutatingArtifact = true
+ error = nil
+ defer { isMutatingArtifact = false }
+
+ let needsScopedAccess = fileURL.startAccessingSecurityScopedResource()
+ defer {
+ if needsScopedAccess {
+ fileURL.stopAccessingSecurityScopedResource()
+ }
+ }
+
+ let fileData: Data
+ do {
+ fileData = try Data(contentsOf: fileURL)
+ } catch {
+ self.error = "Couldn't read \(fileURL.lastPathComponent)."
+ return false
+ }
+
+ // sr.ht streams the upload into S3, which rejects a zero-part multipart
+ // completion with "MalformedXML" — an error that says nothing about the
+ // actual problem. Catch it here where we can name it.
+ guard !fileData.isEmpty else {
+ self.error = "\(fileURL.lastPathComponent) is empty. SourceHut rejects zero-byte artifacts."
+ return false
+ }
+
+ do {
+ _ = try await client.executeMultipart(
+ service: service,
+ query: Self.uploadArtifactMutation,
+ variables: [
+ "repoId": repository.id,
+ "revspec": revspec,
+ "file": nil as String? as Any
+ ],
+ file: MultipartUploadFile(
+ variablePath: "file",
+ fileData: fileData,
+ fileName: fileURL.lastPathComponent,
+ mimeType: Self.mimeType(for: fileURL)
+ ),
+ responseType: UploadArtifactResponse.self
+ )
+ await reloadArtifacts()
+ return true
+ } catch {
+ self.error = "Couldn't upload \(fileURL.lastPathComponent). \(error.userFacingMessage)"
+ return false
+ }
+ }
+
+ /// Downloads an artifact and returns a local file URL to share.
+ ///
+ /// `Artifact.url` points at the API origin, not the web one, and returns an
+ /// auth error to anything without a bearer token — so it cannot be opened in
+ /// a browser. Fetch it here and hand the user the file instead.
+ func downloadArtifact(_ artifact: ArtifactInfo) async -> URL? {
+ guard !isMutatingArtifact else { return nil }
+ isMutatingArtifact = true
+ error = nil
+ defer { isMutatingArtifact = false }
+
+ do {
+ let data = try await client.fetchData(url: artifact.url)
+ let destination = FileManager.default.temporaryDirectory
+ .appendingPathComponent(artifact.filename)
+ try data.write(to: destination, options: .atomic)
+ return destination
+ } catch {
+ self.error = "Couldn't download \(artifact.filename). \(error.userFacingMessage)"
+ return nil
+ }
+ }
+
+ @discardableResult
+ func deleteArtifact(id: Int) async -> Bool {
+ guard !isMutatingArtifact else { return false }
+ isMutatingArtifact = true
+ error = nil
+ defer { isMutatingArtifact = false }
+
+ do {
+ _ = try await client.execute(
+ service: service,
+ query: Self.deleteArtifactMutation,
+ variables: ["id": id],
+ responseType: DeleteArtifactResponse.self
+ )
+ await reloadArtifacts()
+ return true
+ } catch {
+ self.error = "Couldn't delete the artifact. \(error.userFacingMessage)"
+ return false
+ }
+ }
+
+ private func reloadArtifacts() async {
+ isLoadingArtifacts = false
+ await loadArtifacts()
+ }
+
+ /// Artifacts are release tarballs and signatures rather than media, so a
+ /// generic binary type is honest more often than guessing from the extension.
+ private nonisolated static func mimeType(for url: URL) -> String {
+ "application/octet-stream"
+ }
+
func loadArtifacts() async {
guard !isLoadingArtifacts else { return }
isLoadingArtifacts = true
diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift
index 695abf8..b3669e6 100644
--- a/Hutch/Views/Repositories/RepositoryListViewModel.swift
+++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift
@@ -189,7 +189,10 @@ final class RepositoryListViewModel {
filteredResults = []
}
} else {
- let repositories = try await fetchAllRepositories(useCache: true)
+ // forceRefresh used to reach only the build statuses, so a pull to
+ // refresh re-served the cached list and a deleted repository stayed
+ // on screen.
+ let repositories = try await fetchAllRepositories(useCache: !forceRefresh)
updateSearchIndex(with: repositories)
filteredResults = repositories
}
diff --git a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
index dd8d7a1..0b31125 100644
--- a/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
+++ b/Hutch/Views/Repositories/RepositorySettingsViewModel.swift
@@ -231,6 +231,11 @@ final class RepositorySettingsViewModel {
variables: ["id": repositoryId],
responseType: DeleteRepositoryResponse.self
)
+ // The list and Home are both served from cache, so without this the
+ // repository lingers on screen after it no longer exists. Creation
+ // already does this; deletion never did.
+ await client.invalidateCache(prefix: APICacheKeys.prefix(service.rawValue, "repositories"))
+ await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
didDelete = true
} catch {
self.error = error.userFacingMessage
diff --git a/Hutch/Views/Settings/SettingsViewModel.swift b/Hutch/Views/Settings/SettingsViewModel.swift
index edfaad4..8536e60 100644
--- a/Hutch/Views/Settings/SettingsViewModel.swift
+++ b/Hutch/Views/Settings/SettingsViewModel.swift
@@ -43,6 +43,15 @@ private struct PATListResponse: Decodable, Sendable {
let personalAccessTokens: [PersonalAccessToken]
}
+private struct AuditLogResponse: Decodable, Sendable {
+ let auditLog: AuditLogPage
+}
+
+private struct AuditLogPage: Decodable, Sendable {
+ let results: [AuditLogEntry]
+ let cursor: String?
+}
+
// MARK: - View Model
@Observable
@@ -53,6 +62,11 @@ final class SettingsViewModel {
private(set) var sshKeys: [SSHKey] = []
private(set) var pgpKeys: [PGPKey] = []
private(set) var personalAccessTokens: [PersonalAccessToken] = []
+ private(set) var auditLog: [AuditLogEntry] = []
+ private(set) var isLoadingAuditLog = false
+ /// Kept apart from `error` so a failed audit fetch cannot bury a profile
+ /// save failure, and vice versa.
+ var auditLogError: String?
private(set) var isLoading = false
private(set) var isLoadingPATs = false
@@ -139,6 +153,12 @@ final class SettingsViewModel {
}
"""
+ private static let auditLogQuery = """
+ query auditLog {
+ auditLog { results { id created ipAddress eventType details } }
+ }
+ """
+
private static let personalAccessTokensQuery = """
query personalAccessTokens {
personalAccessTokens { id issued expires comment grants }
@@ -393,4 +413,26 @@ final class SettingsViewModel {
isLoadingPATs = false
}
+ // MARK: - Audit Log
+
+ /// Loads the most recent audit entries.
+ ///
+ /// Deliberately one page: this is a glanceable "has anything happened to my
+ /// account" surface, not an archive. The full log is on meta.sr.ht.
+ func loadAuditLog() async {
+ guard !isLoadingAuditLog else { return }
+ isLoadingAuditLog = true
+ defer { isLoadingAuditLog = false }
+
+ do {
+ let result = try await client.execute(
+ service: .meta,
+ query: Self.auditLogQuery,
+ responseType: AuditLogResponse.self
+ )
+ auditLog = result.auditLog.results
+ } catch {
+ auditLogError = error.userFacingMessage
+ }
+ }
}
diff --git a/Hutch/Views/Tickets/TrackerListView.swift b/Hutch/Views/Tickets/TrackerListView.swift
index 5deb513..cb4a59c 100644
--- a/Hutch/Views/Tickets/TrackerListView.swift
+++ b/Hutch/Views/Tickets/TrackerListView.swift
@@ -145,11 +145,12 @@ struct TrackerListView: View {
TrackerRowView(tracker: tracker)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
- Button(role: .destructive) {
+ Button {
pendingDeletion = tracker
} label: {
Label("Delete", systemImage: "trash")
}
+ .tint(.red)
Button {
editingTracker = tracker
diff --git a/Hutch/Views/Tickets/TrackerManagementView.swift b/Hutch/Views/Tickets/TrackerManagementView.swift
index fad1ae8..73b2a08 100644
--- a/Hutch/Views/Tickets/TrackerManagementView.swift
+++ b/Hutch/Views/Tickets/TrackerManagementView.swift
@@ -751,11 +751,12 @@ struct TrackerACLManagementSheet: View {
TrackerPermissionSummary(permissions: entry.permissions)
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
- Button(role: .destructive) {
+ Button {
pendingDeletion = entry
} label: {
Label("Delete", systemImage: "trash")
}
+ .tint(.red)
Button {
editingACL = entry
@@ -1132,11 +1133,12 @@ struct TrackerLabelManagementSheet: View {
}
.tint(.blue)
- Button(role: .destructive) {
+ Button {
pendingDeletion = label
} label: {
Label("Delete", systemImage: "trash")
}
+ .tint(.red)
}
}
.themedRow()
diff --git a/README.md b/README.md
index fd553a8..34c7d03 100644
--- a/README.md
+++ b/README.md
@@ -14,13 +14,15 @@ The app currently includes:
- Home dashboard with assigned tickets, recent builds, and projects
- Repository browsing for Git and Mercurial repositories
- Repository details including README, references, commits, diffs, files, artifacts, and settings
+- Artifact upload and deletion on release tags
- Tracker and ticket browsing, ticket detail views, and tracker creation
- Ticket editing and deletion, with subscriptions for tickets and trackers
- Build job browsing, build detail views, and build submission
- Inbox and mailing list reading flows
- Patchset review: cover letters, per-patch diffs, checks, version chains, and status changes
+- Mailing list creation, settings, and deletion
- Paste browsing, creation, and detail views
-- Profile and account settings, including SSH keys, PGP keys, and personal access token management
+- Profile and account settings, including SSH keys, PGP keys, personal access token management, and the audit log
- Email preferences for todo.sr.ht and lists.sr.ht
- Deep links for repositories, tickets, and build jobs
diff --git a/ROADMAP.md b/ROADMAP.md
index f08981d..2436877 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -131,22 +131,43 @@ GraphQL mutation. Treat that boundary as explicit rather than half-building it.
## Phase 3: Polish and reach
-- **Localization.** The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS =
- YES` but ships no string catalog, so every user-facing string is hardcoded
- English.
-- **Accessibility.** Labels and hints appear in only 16 of roughly 130 view
- files.
-- `uploadArtifact` / `deleteArtifact` — artifacts are read-only today.
-- Webhook management. Zero calls to any `create*Webhook` across every service.
- Push notifications are out of scope because they need a relay server (see
- [SCOPE.md](SCOPE.md)), but webhook management is client-side only and is a
- prerequisite if that relay ever ships.
-- `auditLog` (meta.sr.ht) — unused security surface.
-- Build groups (`createGroup`, `startGroup`) and secret management
- (`shareSecret`, the `secrets` query). Today `secrets` is only a submit toggle.
-- Mailing list creation and settings (`createMailingList`, `updateMailingList`,
- `deleteMailingList`).
-- `events` feed (todo.sr.ht) and `archiveMessage` (lists.sr.ht).
+Unlike Phases 1 and 2, this is not one shippable thing. It is several, and they
+are sized very differently — measure before committing to one.
+
+### API features — done (v3.8.0)
+
+- ~~`uploadArtifact` / `deleteArtifact`~~ — artifacts were read-only.
+- ~~`auditLog` (meta.sr.ht)~~ — surfaced under the tokens in Profile.
+- ~~Mailing list creation and settings~~ (`createMailingList`,
+ `updateMailingList`, `deleteMailingList`).
+
+Three of the six planned. The other three did not survive contact:
+
+- `archiveMessage` is `@internal` and inaccessible.
+- The `events` feed was built, then removed: todo.sr.ht's root `events` resolver
+ joins `event.participant_id` against `participant.user_id`, which are
+ different id spaces, so it returns an empty list for everyone. See
+ [SCOPE.md](SCOPE.md).
+- Webhook management, `shareSecret`, and build groups are reachable but declined
+ on judgement — see [SCOPE.md](SCOPE.md) for the reasoning, so they do not get
+ re-proposed.
+
+### Localization
+
+The project sets `LOCALIZATION_PREFERS_STRING_CATALOGS = YES` but ships no
+string catalog, so every user-facing string is hardcoded English. Roughly 634
+literals: 239 `Text(`, 150 `Label(`, 117 `Button(`, 77 `Section(`, 51
+`navigationTitle(`.
+
+Worth knowing before starting: a catalog containing only English changes nothing
+for users until translations exist. It is groundwork, and it is the largest diff
+in the roadmap — it touches nearly every view, with the regression risk that
+implies.
+
+### Accessibility
+
+Labels and hints appear in 17 of 89 view files. Mechanical and low-risk, but it
+cannot be verified from a build — it needs VoiceOver driven on a device.
### Swift 6 language mode
diff --git a/SCOPE.md b/SCOPE.md
index b4f668f..407f292 100644
--- a/SCOPE.md
+++ b/SCOPE.md
@@ -7,3 +7,39 @@
- Explore / search (hub.sr.ht) (no public discovery API)
- Pronouns on profile (not in GraphQL schema)
- Revoke personal access tokens (`@internal` in schema, inaccessible)
+- Archive a message to a list (`archiveMessage` is `@internal`, inaccessible)
+- Ticket activity feed (todo.sr.ht's root `events` query is broken upstream and
+ returns an empty list for every user). `event.participant_id` references
+ `participant(id)`, but the resolver joins it against `participant.user_id`:
+
+ ```sql
+ FROM event ev
+ JOIN participant p ON p.user_id = ev.participant_id -- id space vs user id space
+ WHERE p.user_id = <viewer>
+ ```
+
+ The rows exist — the writer inserts `participant.ID` for the submitter and for
+ every subscriber — but that join cannot find them. `Ticket.events` is
+ unaffected because it filters on `ev.ticket_id`, which is why ticket timelines
+ work. Nothing a client can do fixes this; revisit only if sr.ht changes the
+ resolver.
+- Subscribe to a mailing list (`mailingListSubscribe` exists, but `MailingList`
+ has no `subscription` field and sr.ht has no discovery API, so there is no way
+ to find a list you are not already subscribed to — see hub.sr.ht above)
+- Submitting patches (a `git send-email` flow, not a GraphQL mutation; Hutch
+ reviews patchsets but cannot send them)
+
+## Declined rather than blocked
+
+These are reachable in the API. They are left out on judgement, not capability.
+
+- **Webhook management** (24 fields across five services). A webhook needs an
+ HTTPS endpoint you control to receive POSTs. Without the relay above, this
+ only serves someone already running their own endpoint, and that person is not
+ managing it from a phone. Reconsider if `hutch-notify` ever ships.
+- **`shareSecret`.** Shares a build secret — an SSH key or PAT — with another
+ user. A mistap grants someone else a credential, and nothing in the app can
+ take it back. That belongs on the web behind a full-size confirmation. The
+ read-only `secrets` list would be fine on its own.
+- **Build groups** (`createGroup`, `startGroup`). Multi-job pipelines are
+ authored in `.build.yml`, not composed on a phone.