Commit 4ce872b9f4
4ce872b9f4e7fb92680c6c0f71eaec0d8f75e82d
parent: 664ba82d6a
Verified · cmc
cmc <hello@cleberg.net> · 2026-10-14 05:15 UTC
Reactions: none on system lines; spoken names
A line the server posted takes no reaction, so it offers none: kind
marks it (krz/gitbay#332), and an older server's system author does.
VoiceOver reads thumbs up rather than plus one, and the menu marks the
account's own reaction as selected.
Ref #22
Layout: unified · split
gitbay/Issues/IssueModels.swift
+8 −1
| @@ -52,14 +52,21 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable { |
| 52 | 52 | let bodyFormat: String? |
| 53 | 53 | let createdAt: Date |
| 54 | 54 | let reactions: [ReactionCount]? |
| 55 | /// "system" for a line the server posted (a retarget, a queued |
| 56 | /// merge), which takes no reactions (krz/gitbay#332). |
| 57 | let kind: String? |
| 55 | 58 | |
| 56 | 59 | enum CodingKeys: String, CodingKey { |
| 57 | | case author, body, reactions |
| 60 | case author, body, reactions, kind |
| 58 | 61 | case commentID = "id" |
| 59 | 62 | case bodyFormat = "body_format" |
| 60 | 63 | case createdAt = "created_at" |
| 61 | 64 | } |
| 62 | 65 | |
| 66 | /// A server without `kind` names the author "system" on those lines; |
| 67 | /// an account can carry that name too, so `kind` wins when present. |
| 68 | var isSystem: Bool { kind.map { $0 == "system" } ?? (author == "system") } |
| 69 | |
| 63 | 70 | var id: String { author + createdAt.timeIntervalSince1970.description + body } |
| 64 | 71 | } |
| 65 | 72 | } |
gitbay/Issues/Reaction.swift
+11 −4
| @@ -19,11 +19,14 @@ nonisolated struct Reaction: Sendable, Hashable, Identifiable { |
| 19 | 19 | |
| 20 | 20 | var id: String { name } |
| 21 | 21 | |
| 22 | /// What VoiceOver says; "+1" would be read as "plus one". |
| 23 | let spoken: String |
| 24 | |
| 22 | 25 | static let all: [Reaction] = [ |
| 23 | | .init(name: "+1", emoji: "👍"), .init(name: "-1", emoji: "👎"), |
| 24 | | .init(name: "laugh", emoji: "😄"), .init(name: "hooray", emoji: "🎉"), |
| 25 | | .init(name: "confused", emoji: "😕"), .init(name: "heart", emoji: "❤️"), |
| 26 | | .init(name: "rocket", emoji: "🚀"), .init(name: "eyes", emoji: "👀"), |
| 26 | .init(name: "+1", emoji: "👍", spoken: "thumbs up"), .init(name: "-1", emoji: "👎", spoken: "thumbs down"), |
| 27 | .init(name: "laugh", emoji: "😄", spoken: "laugh"), .init(name: "hooray", emoji: "🎉", spoken: "hooray"), |
| 28 | .init(name: "confused", emoji: "😕", spoken: "confused"), .init(name: "heart", emoji: "❤️", spoken: "heart"), |
| 29 | .init(name: "rocket", emoji: "🚀", spoken: "rocket"), .init(name: "eyes", emoji: "👀", spoken: "eyes"), |
| 27 | 30 | ] |
| 28 | 31 | |
| 29 | 32 | /// A name a newer server added draws as its name rather than vanishing. |
| @@ -31,6 +34,10 @@ nonisolated struct Reaction: Sendable, Hashable, Identifiable { |
| 31 | 34 | all.first { $0.name == name }?.emoji ?? name |
| 32 | 35 | } |
| 33 | 36 | |
| 37 | static func spoken(for name: String) -> String { |
| 38 | all.first { $0.name == name }?.spoken ?? name |
| 39 | } |
| 40 | |
| 34 | 41 | /// Picking a reaction the account already gave takes it back, the way |
| 35 | 42 | /// the web's toggle does. |
| 36 | 43 | static func remove(_ name: String, given: [ReactionCount]?) -> Bool { |
gitbay/MRs/MRModels.swift
+8 −1
| @@ -249,14 +249,21 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable { |
| 249 | 249 | let bodyFormat: String? |
| 250 | 250 | let createdAt: Date |
| 251 | 251 | let reactions: [ReactionCount]? |
| 252 | /// "system" for a line the server posted (a retarget, a queued |
| 253 | /// merge), which takes no reactions (krz/gitbay#332). |
| 254 | let kind: String? |
| 252 | 255 | |
| 253 | 256 | enum CodingKeys: String, CodingKey { |
| 254 | | case author, body, reactions |
| 257 | case author, body, reactions, kind |
| 255 | 258 | case commentID = "id" |
| 256 | 259 | case bodyFormat = "body_format" |
| 257 | 260 | case createdAt = "created_at" |
| 258 | 261 | } |
| 259 | 262 | |
| 263 | /// A server without `kind` names the author "system" on those lines; |
| 264 | /// an account can carry that name too, so `kind` wins when present. |
| 265 | var isSystem: Bool { kind.map { $0 == "system" } ?? (author == "system") } |
| 266 | |
| 260 | 267 | var id: String { author + createdAt.timeIntervalSince1970.description + body } |
| 261 | 268 | } |
| 262 | 269 | |
gitbay/Views/Issues/IssueView.swift
+1 −1
| @@ -173,7 +173,7 @@ struct IssueView: View { |
| 173 | 173 | } |
| 174 | 174 | FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) |
| 175 | 175 | .font(.gbSans(.subheadline)) |
| 176 | | if let id = comment.commentID { |
| 176 | if let id = comment.commentID, !comment.isSystem { |
| 177 | 177 | ReactionBar(reactions: comment.reactions ?? [], disabled: model.working, |
| 178 | 178 | idPrefix: "issue-comment-\(id)-reaction") { name in |
| 179 | 179 | Task { await model.react(name, comment: id) } |
gitbay/Views/MRs/MRView.swift
+1 −1
| @@ -539,7 +539,7 @@ struct MRView: View { |
| 539 | 539 | } |
| 540 | 540 | FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) |
| 541 | 541 | .font(.gbSans(.subheadline)) |
| 542 | | if let id = comment.commentID { |
| 542 | if let id = comment.commentID, !comment.isSystem { |
| 543 | 543 | ReactionBar(reactions: comment.reactions ?? [], disabled: model.working, |
| 544 | 544 | idPrefix: "mr-comment-\(id)-reaction") { name in |
| 545 | 545 | Task { await model.react(name, comment: id) } |
gitbay/Views/Shared/ReactionBar.swift
+3 −1
| @@ -50,6 +50,8 @@ struct ReactionBar: View { |
| 50 | 50 | Text("\(reaction.emoji) \(reaction.name)") |
| 51 | 51 | } |
| 52 | 52 | } |
| 53 | .accessibilityLabel(Reaction.spoken(for: reaction.name)) |
| 54 | .accessibilityAddTraits(Reaction.remove(reaction.name, given: reactions) ? .isSelected : []) |
| 53 | 55 | } |
| 54 | 56 | } label: { |
| 55 | 57 | Image(systemName: "face.smiling") |
| @@ -70,6 +72,6 @@ struct ReactionBar: View { |
| 70 | 72 | |
| 71 | 73 | private func label(for given: ReactionCount) -> String { |
| 72 | 74 | let count = given.count == 1 ? "1 reaction" : "\(given.count) reactions" |
| 73 | | return "\(given.reaction), \(count)\(given.me ? ", including yours" : "")" |
| 75 | return "\(Reaction.spoken(for: given.reaction)), \(count)\(given.me ? ", including yours" : "")" |
| 74 | 76 | } |
| 75 | 77 | } |
gitbayTests/ReactionTests.swift
+8 −1
| @@ -29,7 +29,9 @@ private let mrShowJSON = """ |
| 29 | 29 | {"protocol_version":1,"data":{"number":3,"title":"t","state":"open","author":"krz",\ |
| 30 | 30 | "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z",\ |
| 31 | 31 | "reactions":[{"reaction":"rocket","count":1,"me":true}],\ |
| 32 | | "comments":[{"id":9,"author":"cmc","body":"lgtm","created_at":"2026-08-22T09:00:00.000Z"}]},\ |
| 32 | "comments":[{"id":9,"author":"cmc","body":"lgtm","created_at":"2026-08-22T09:00:00.000Z"},\ |
| 33 | {"id":10,"author":"cmc","body":"queued the merge","kind":"system","created_at":"2026-08-22T09:01:00.000Z"},\ |
| 34 | {"id":11,"author":"system","body":"retargeted","created_at":"2026-08-22T09:02:00.000Z"}]},\ |
| 33 | 35 | "exit_code":0} |
| 34 | 36 | """ |
| 35 | 37 | |
| @@ -42,6 +44,7 @@ struct ReactionModelTests { |
| 42 | 44 | #expect(Reaction.emoji(for: "hooray") == "🎉") |
| 43 | 45 | // An unknown name from a newer server still draws. |
| 44 | 46 | #expect(Reaction.emoji(for: "sparkles") == "sparkles") |
| 47 | #expect(Reaction.spoken(for: "+1") == "thumbs up") |
| 45 | 48 | } |
| 46 | 49 | |
| 47 | 50 | @Test func argvReactsToTheItemOrAComment() { |
| @@ -123,6 +126,10 @@ struct MRReactionTests { |
| 123 | 126 | #expect(mr.reactions == [ReactionCount(reaction: "rocket", count: 1, me: true)]) |
| 124 | 127 | #expect(mr.comments?.first?.commentID == 9) |
| 125 | 128 | #expect(mr.comments?.first?.reactions == nil) |
| 129 | #expect(mr.comments?.first?.isSystem == false) |
| 130 | #expect(mr.comments?[1].isSystem == true) |
| 131 | // An older server sends no kind; its system lines are authored "system". |
| 132 | #expect(mr.comments?.last?.isSystem == true) |
| 126 | 133 | |
| 127 | 134 | await model.react("eyes", comment: 9) |
| 128 | 135 | |