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 {
5252 let bodyFormat: String?
5353 let createdAt: Date
5454 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?
5558
5659 enum CodingKeys: String, CodingKey {
57 case author, body, reactions
60 case author, body, reactions, kind
5861 case commentID = "id"
5962 case bodyFormat = "body_format"
6063 case createdAt = "created_at"
6164 }
6265
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
6370 var id: String { author + createdAt.timeIntervalSince1970.description + body }
6471 }
6572}
gitbay/Issues/Reaction.swift +11 −4
@@ -19,11 +19,14 @@ nonisolated struct Reaction: Sendable, Hashable, Identifiable {
1919
2020 var id: String { name }
2121
22 /// What VoiceOver says; "+1" would be read as "plus one".
23 let spoken: String
24
2225 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"),
2730 ]
2831
2932 /// A name a newer server added draws as its name rather than vanishing.
@@ -31,6 +34,10 @@ nonisolated struct Reaction: Sendable, Hashable, Identifiable {
3134 all.first { $0.name == name }?.emoji ?? name
3235 }
3336
37 static func spoken(for name: String) -> String {
38 all.first { $0.name == name }?.spoken ?? name
39 }
40
3441 /// Picking a reaction the account already gave takes it back, the way
3542 /// the web's toggle does.
3643 static func remove(_ name: String, given: [ReactionCount]?) -> Bool {
gitbay/MRs/MRModels.swift +8 −1
@@ -249,14 +249,21 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
249249 let bodyFormat: String?
250250 let createdAt: Date
251251 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?
252255
253256 enum CodingKeys: String, CodingKey {
254 case author, body, reactions
257 case author, body, reactions, kind
255258 case commentID = "id"
256259 case bodyFormat = "body_format"
257260 case createdAt = "created_at"
258261 }
259262
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
260267 var id: String { author + createdAt.timeIntervalSince1970.description + body }
261268 }
262269
gitbay/Views/Issues/IssueView.swift +1 −1
@@ -173,7 +173,7 @@ struct IssueView: View {
173173 }
174174 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
175175 .font(.gbSans(.subheadline))
176 if let id = comment.commentID {
176 if let id = comment.commentID, !comment.isSystem {
177177 ReactionBar(reactions: comment.reactions ?? [], disabled: model.working,
178178 idPrefix: "issue-comment-\(id)-reaction") { name in
179179 Task { await model.react(name, comment: id) }
gitbay/Views/MRs/MRView.swift +1 −1
@@ -539,7 +539,7 @@ struct MRView: View {
539539 }
540540 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
541541 .font(.gbSans(.subheadline))
542 if let id = comment.commentID {
542 if let id = comment.commentID, !comment.isSystem {
543543 ReactionBar(reactions: comment.reactions ?? [], disabled: model.working,
544544 idPrefix: "mr-comment-\(id)-reaction") { name in
545545 Task { await model.react(name, comment: id) }
gitbay/Views/Shared/ReactionBar.swift +3 −1
@@ -50,6 +50,8 @@ struct ReactionBar: View {
5050 Text("\(reaction.emoji) \(reaction.name)")
5151 }
5252 }
53 .accessibilityLabel(Reaction.spoken(for: reaction.name))
54 .accessibilityAddTraits(Reaction.remove(reaction.name, given: reactions) ? .isSelected : [])
5355 }
5456 } label: {
5557 Image(systemName: "face.smiling")
@@ -70,6 +72,6 @@ struct ReactionBar: View {
7072
7173 private func label(for given: ReactionCount) -> String {
7274 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" : "")"
7476 }
7577}
gitbayTests/ReactionTests.swift +8 −1
@@ -29,7 +29,9 @@ private let mrShowJSON = """
2929 {"protocol_version":1,"data":{"number":3,"title":"t","state":"open","author":"krz",\
3030 "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z",\
3131 "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"}]},\
3335 "exit_code":0}
3436 """
3537
@@ -42,6 +44,7 @@ struct ReactionModelTests {
4244 #expect(Reaction.emoji(for: "hooray") == "🎉")
4345 // An unknown name from a newer server still draws.
4446 #expect(Reaction.emoji(for: "sparkles") == "sparkles")
47 #expect(Reaction.spoken(for: "+1") == "thumbs up")
4548 }
4649
4750 @Test func argvReactsToTheItemOrAComment() {
@@ -123,6 +126,10 @@ struct MRReactionTests {
123126 #expect(mr.reactions == [ReactionCount(reaction: "rocket", count: 1, me: true)])
124127 #expect(mr.comments?.first?.commentID == 9)
125128 #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)
126133
127134 await model.react("eyes", comment: 9)
128135