krz/hutch

an ios client for sourcehut

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

v3.1.7: Hutch/Views/Home/HomePinStore.swift · raw

  1import Foundation
  2
  3enum HomePinKind: String, Codable, Sendable {
  4    case project
  5    case repository
  6    case tracker
  7    case mailingList
  8    case user
  9}
 10
 11struct HomePinRecord: Codable, Hashable, Identifiable, Sendable {
 12    let kind: HomePinKind
 13    let value: String
 14    let title: String
 15    let subtitle: String
 16    let ownerUsername: String?
 17    let service: SRHTService?
 18
 19    var id: String {
 20        switch kind {
 21        case .project:
 22            return "\(kind.rawValue):\(value)"
 23        case .repository:
 24            return "\(kind.rawValue):\(service?.rawValue ?? "git"):\(ownerUsername ?? "")/\(value)"
 25        case .tracker, .mailingList:
 26            return "\(kind.rawValue):\(ownerUsername ?? "")/\(value)"
 27        case .user:
 28            return "\(kind.rawValue):\(ownerUsername ?? value)"
 29        }
 30    }
 31
 32    static func project(_ project: Project) -> HomePinRecord {
 33        HomePinRecord(
 34            kind: .project,
 35            value: project.id,
 36            title: project.displayName,
 37            subtitle: "Project",
 38            ownerUsername: nil,
 39            service: nil
 40        )
 41    }
 42
 43    static func repository(_ repository: RepositorySummary) -> HomePinRecord {
 44        HomePinRecord(
 45            kind: .repository,
 46            value: repository.name,
 47            title: repository.name,
 48            subtitle: repository.service == .hg ? "Mercurial Repo" : "Git Repo",
 49            ownerUsername: repository.owner.canonicalName.srhtUsername,
 50            service: repository.service
 51        )
 52    }
 53
 54    static func tracker(_ tracker: TrackerSummary) -> HomePinRecord {
 55        HomePinRecord(
 56            kind: .tracker,
 57            value: tracker.name,
 58            title: tracker.name,
 59            subtitle: "Tracker",
 60            ownerUsername: tracker.owner.canonicalName.srhtUsername,
 61            service: nil
 62        )
 63    }
 64
 65    static func mailingList(_ mailingList: InboxMailingListReference) -> HomePinRecord {
 66        HomePinRecord(
 67            kind: .mailingList,
 68            value: mailingList.rid,
 69            title: mailingList.name,
 70            subtitle: "Mailing List",
 71            ownerUsername: mailingList.owner.canonicalName.srhtUsername,
 72            service: nil
 73        )
 74    }
 75
 76    static func user(_ user: User) -> HomePinRecord {
 77        HomePinRecord(
 78            kind: .user,
 79            value: user.username,
 80            title: user.canonicalName,
 81            subtitle: "User",
 82            ownerUsername: user.username,
 83            service: nil
 84        )
 85    }
 86}
 87
 88enum HomePinStore {
 89    static func loadPins(
 90        for userKey: String,
 91        defaults: UserDefaults = .standard
 92    ) -> [HomePinRecord] {
 93        let normalizedUserKey = normalizedUserKey(userKey)
 94        guard !normalizedUserKey.isEmpty else { return [] }
 95
 96        let storedPins = loadAll(defaults: defaults)
 97        if let storedPinsForUser = storedPins[normalizedUserKey] {
 98            return normalizedPins(storedPinsForUser)
 99        }
100
101        let legacyProjectIDs = ProjectPinStore.loadLegacyPinnedProjectIDs(for: normalizedUserKey, defaults: defaults)
102        guard !legacyProjectIDs.isEmpty else { return [] }
103
104        let migratedPins = legacyProjectIDs.map {
105            HomePinRecord(
106                kind: .project,
107                value: $0,
108                title: "Pinned Project",
109                subtitle: "Project",
110                ownerUsername: nil,
111                service: nil
112            )
113        }
114        savePins(migratedPins, for: normalizedUserKey, defaults: defaults)
115        return migratedPins
116    }
117
118    static func isPinned(
119        _ pin: HomePinRecord,
120        for userKey: String,
121        defaults: UserDefaults = .standard
122    ) -> Bool {
123        loadPins(for: userKey, defaults: defaults).contains(pin)
124    }
125
126    static func togglePin(
127        _ pin: HomePinRecord,
128        for userKey: String,
129        defaults: UserDefaults = .standard
130    ) {
131        let normalizedUserKey = normalizedUserKey(userKey)
132        guard !normalizedUserKey.isEmpty else { return }
133
134        var pinsByUser = loadAll(defaults: defaults)
135        var pins = normalizedPins(pinsByUser[normalizedUserKey] ?? loadPins(for: normalizedUserKey, defaults: defaults))
136
137        if let index = pins.firstIndex(of: pin) {
138            pins.remove(at: index)
139        } else {
140            pins.append(pin)
141        }
142
143        pinsByUser[normalizedUserKey] = pins
144        saveAll(pinsByUser, defaults: defaults)
145    }
146
147    static func pinnedProjectIDs(
148        for userKey: String,
149        defaults: UserDefaults = .standard
150    ) -> [String] {
151        loadPins(for: userKey, defaults: defaults)
152            .filter { $0.kind == .project }
153            .map(\.value)
154    }
155
156    private static func loadAll(defaults: UserDefaults) -> [String: [HomePinRecord]] {
157        guard let data = defaults.data(forKey: AppStorageKeys.pinnedHomeItems) else {
158            return [:]
159        }
160
161        let decoded = (try? JSONDecoder().decode([String: [HomePinRecord]].self, from: data)) ?? [:]
162        return decoded.reduce(into: [String: [HomePinRecord]]()) { result, entry in
163            let key = normalizedUserKey(entry.key)
164            guard !key.isEmpty else { return }
165            let pins = normalizedPins(entry.value)
166            if !pins.isEmpty {
167                result[key] = pins
168            }
169        }
170    }
171
172    private static func savePins(_ pins: [HomePinRecord], for userKey: String, defaults: UserDefaults) {
173        var allPins = loadAll(defaults: defaults)
174        allPins[userKey] = normalizedPins(pins)
175        saveAll(allPins, defaults: defaults)
176    }
177
178    private static func saveAll(_ pinsByUser: [String: [HomePinRecord]], defaults: UserDefaults) {
179        guard let data = try? JSONEncoder().encode(pinsByUser) else { return }
180        defaults.set(data, forKey: AppStorageKeys.pinnedHomeItems)
181    }
182
183    private static func normalizedPins(_ pins: [HomePinRecord]) -> [HomePinRecord] {
184        var seen = Set<String>()
185        return pins.compactMap { pin in
186            let title = pin.title.trimmingCharacters(in: .whitespacesAndNewlines)
187            let subtitle = pin.subtitle.trimmingCharacters(in: .whitespacesAndNewlines)
188            let value = pin.value.trimmingCharacters(in: .whitespacesAndNewlines)
189            guard !title.isEmpty, !subtitle.isEmpty, !value.isEmpty else { return nil }
190
191            let normalized = HomePinRecord(
192                kind: pin.kind,
193                value: value,
194                title: title,
195                subtitle: subtitle,
196                ownerUsername: pin.ownerUsername?.trimmingCharacters(in: .whitespacesAndNewlines),
197                service: pin.service
198            )
199            guard seen.insert(normalized.id).inserted else { return nil }
200            return normalized
201        }
202    }
203
204    private static func normalizedUserKey(_ userKey: String) -> String {
205        userKey.trimmingCharacters(in: .whitespacesAndNewlines)
206    }
207}