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}