// // AdminBlocklistView.swift // FTStore Admin // // Tweak names this server will not take. Mirrors the panel on /admin/settings. // // A rule is a name plus a scope, and an empty scope means everywhere — which // is the common case, so it is what happens when the field is left alone // rather than something to switch on. // // The note is not decoration. Matching folds case and punctuation and looks // anywhere in the name, which is broad on purpose; months later the only way // to tell a deliberate rule from an over-eager one is to have written down // why. // // Saved on its own, immediately, like everything else in this app — there is // no Save button here either. // import SwiftUI struct AdminBlocklistView: View { @EnvironmentObject private var session: AdminSession @StateObject private var _settings = AdminLoader() @State private var _entries: [AdminBlockedTweak] = [] @State private var _busy = false @State private var _banner: String? @State private var _adding = false @State private var _newName = "" @State private var _newSource = "" @State private var _newNote = "" var body: some View { List { if let banner = _banner { Section { Text(banner).font(.footnote).foregroundStyle(.secondary) } } Section { if _settings.isLoading && !_settings.hasLoaded { AdminLoadingRow() } else if let error = _settings.errorMessage, !_settings.hasLoaded { AdminErrorRow(message: error) { Task { await _reload() } } } else if _entries.isEmpty { AdminEmptyRow( icon: "hand.raised", title: "Nothing is blocked", detail: "Everything a source publishes is considered." ) } else { ForEach(_entries) { entry in _row(entry) .swipeActions(edge: .trailing) { Button(role: .destructive) { _remove(entry) } label: { Label("Remove", systemImage: "trash") } } } } } header: { Text("Blocked") } footer: { Text("Builds whose name matches are never taken. Checked on the file name before downloading and on the app's real name after, so a build named nothing like its contents is still caught. Case and punctuation are ignored and the match is anywhere in the name.") } } .navigationTitle("Blocked Tweaks") .navigationBarTitleDisplayMode(.inline) .refreshable { await _reload() } .task { await _reload() } .disabled(_busy) .toolbar { ToolbarItem(placement: .topBarTrailing) { if _busy { ProgressView() } else { Button { _newName = "" _newSource = "" _newNote = "" _adding = true } label: { Image(systemName: "plus") } } } } .alert("Block a name", isPresented: $_adding) { TextField("Tweak or app name", text: $_newName) .textInputAutocapitalization(.never) .autocorrectionDisabled() TextField("Source, or empty for everywhere", text: $_newSource) .textInputAutocapitalization(.never) .autocorrectionDisabled() TextField("Why (optional)", text: $_newNote) Button("Block") { _add() } Button("Cancel", role: .cancel) {} } message: { Text("Leave the source empty to block the name everywhere. Write @channel or github:owner/repo to block it from one source only.") } } @ViewBuilder private func _row(_ entry: AdminBlockedTweak) -> some View { VStack(alignment: .leading, spacing: 4) { HStack(spacing: 6) { Text(entry.name) .font(.subheadline.weight(.medium)) .lineLimit(1) // The scope, always stated. A rule whose reach is not on screen // is one somebody will misread as global. Label { Text(entry.sourceId ?? .localized("everywhere")) } icon: { Image(systemName: entry.sourceId == nil ? "globe" : "antenna.radiowaves.left.and.right") } .font(.caption2) .padding(.horizontal, 6) .padding(.vertical, 2) .background(Color(uiColor: .tertiarySystemFill), in: Capsule()) .foregroundStyle(.secondary) } if let note = entry.note, !note.isEmpty { Text(note) .font(.caption) .foregroundStyle(.secondary) } } .padding(.vertical, 2) } // MARK: Actions private func _reload() async { await _settings.load { try await session.get("/api/settings", as: AdminSettingsResponse.self) } if let list = _settings.value?.data.blockedTweaks { _entries = list } } private func _add() { let name = _newName.trimmingCharacters(in: .whitespaces) guard !name.isEmpty else { return } let entry = AdminBlockedTweak( name: name, sourceId: _newSource.trimmingCharacters(in: .whitespaces).nilIfEmpty, note: _newNote.trimmingCharacters(in: .whitespaces).nilIfEmpty ) _save(_entries + [entry], .localized("\"%@\" is blocked.", name)) } private func _remove(_ entry: AdminBlockedTweak) { _save(_entries.filter { $0.id != entry.id }, .localized("Rule removed.")) } private func _save(_ next: [AdminBlockedTweak], _ fallback: String) { _busy = true Task { @MainActor in do { let ack = try await session.act( .put, "/api/settings", body: AdminRequest.Blocklist(blocked_tweaks: next) ) _banner = ack.message ?? fallback } catch { _banner = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } _busy = false await _reload() } } } private extension String { /// An empty scope means "everywhere", and the server reads that as null. var nilIfEmpty: String? { isEmpty ? nil : self } }