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 let bodyFormat: String? 52 let bodyFormat: String?
53 let createdAt: Date 53 let createdAt: Date
54 let reactions: [ReactionCount]? 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 enum CodingKeys: String, CodingKey { 59 enum CodingKeys: String, CodingKey {
57 case author, body, reactions 60 case author, body, reactions, kind
58 case commentID = "id" 61 case commentID = "id"
59 case bodyFormat = "body_format" 62 case bodyFormat = "body_format"
60 case createdAt = "created_at" 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 var id: String { author + createdAt.timeIntervalSince1970.description + body } 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 var id: String { name } 20 var id: String { name }
21 21
22 /// What VoiceOver says; "+1" would be read as "plus one".
23 let spoken: String
24
22 static let all: [Reaction] = [ 25 static let all: [Reaction] = [
23 .init(name: "+1", emoji: "👍"), .init(name: "-1", emoji: "👎"), 26 .init(name: "+1", emoji: "👍", spoken: "thumbs up"), .init(name: "-1", emoji: "👎", spoken: "thumbs down"),
24 .init(name: "laugh", emoji: "😄"), .init(name: "hooray", emoji: "🎉"), 27 .init(name: "laugh", emoji: "😄", spoken: "laugh"), .init(name: "hooray", emoji: "🎉", spoken: "hooray"),
25 .init(name: "confused", emoji: "😕"), .init(name: "heart", emoji: "❤️"), 28 .init(name: "confused", emoji: "😕", spoken: "confused"), .init(name: "heart", emoji: "❤️", spoken: "heart"),
26 .init(name: "rocket", emoji: "🚀"), .init(name: "eyes", emoji: "👀"), 29 .init(name: "rocket", emoji: "🚀", spoken: "rocket"), .init(name: "eyes", emoji: "👀", spoken: "eyes"),
27 ] 30 ]
28 31
29 /// A name a newer server added draws as its name rather than vanishing. 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 all.first { $0.name == name }?.emoji ?? name 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 /// Picking a reaction the account already gave takes it back, the way 41 /// Picking a reaction the account already gave takes it back, the way
35 /// the web's toggle does. 42 /// the web's toggle does.
36 static func remove(_ name: String, given: [ReactionCount]?) -> Bool { 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 let bodyFormat: String? 249 let bodyFormat: String?
250 let createdAt: Date 250 let createdAt: Date
251 let reactions: [ReactionCount]? 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 enum CodingKeys: String, CodingKey { 256 enum CodingKeys: String, CodingKey {
254 case author, body, reactions 257 case author, body, reactions, kind
255 case commentID = "id" 258 case commentID = "id"
256 case bodyFormat = "body_format" 259 case bodyFormat = "body_format"
257 case createdAt = "created_at" 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 var id: String { author + createdAt.timeIntervalSince1970.description + body } 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 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) 174 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
175 .font(.gbSans(.subheadline)) 175 .font(.gbSans(.subheadline))
176 if let id = comment.commentID { 176 if let id = comment.commentID, !comment.isSystem {
177 ReactionBar(reactions: comment.reactions ?? [], disabled: model.working, 177 ReactionBar(reactions: comment.reactions ?? [], disabled: model.working,
178 idPrefix: "issue-comment-\(id)-reaction") { name in 178 idPrefix: "issue-comment-\(id)-reaction") { name in
179 Task { await model.react(name, comment: id) } 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 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) 540 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
541 .font(.gbSans(.subheadline)) 541 .font(.gbSans(.subheadline))
542 if let id = comment.commentID { 542 if let id = comment.commentID, !comment.isSystem {
543 ReactionBar(reactions: comment.reactions ?? [], disabled: model.working, 543 ReactionBar(reactions: comment.reactions ?? [], disabled: model.working,
544 idPrefix: "mr-comment-\(id)-reaction") { name in 544 idPrefix: "mr-comment-\(id)-reaction") { name in
545 Task { await model.react(name, comment: id) } 545 Task { await model.react(name, comment: id) }
gitbay/Views/Shared/ReactionBar.swift +3 −1
@@ -50,6 +50,8 @@ struct ReactionBar: View {
50 Text("\(reaction.emoji) \(reaction.name)") 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 } label: { 56 } label: {
55 Image(systemName: "face.smiling") 57 Image(systemName: "face.smiling")
@@ -70,6 +72,6 @@ struct ReactionBar: View {
70 72
71 private func label(for given: ReactionCount) -> String { 73 private func label(for given: ReactionCount) -> String {
72 let count = given.count == 1 ? "1 reaction" : "\(given.count) reactions" 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 {"protocol_version":1,"data":{"number":3,"title":"t","state":"open","author":"krz",\ 29 {"protocol_version":1,"data":{"number":3,"title":"t","state":"open","author":"krz",\
30 "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z",\ 30 "source":"feat","target_ref":"main","head_sha":"abc","created_at":"2026-08-20T10:00:00.000Z",\
31 "reactions":[{"reaction":"rocket","count":1,"me":true}],\ 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 "exit_code":0} 35 "exit_code":0}
34 """ 36 """
35 37
@@ -42,6 +44,7 @@ struct ReactionModelTests {
42 #expect(Reaction.emoji(for: "hooray") == "🎉") 44 #expect(Reaction.emoji(for: "hooray") == "🎉")
43 // An unknown name from a newer server still draws. 45 // An unknown name from a newer server still draws.
44 #expect(Reaction.emoji(for: "sparkles") == "sparkles") 46 #expect(Reaction.emoji(for: "sparkles") == "sparkles")
47 #expect(Reaction.spoken(for: "+1") == "thumbs up")
45 } 48 }
46 49
47 @Test func argvReactsToTheItemOrAComment() { 50 @Test func argvReactsToTheItemOrAComment() {
@@ -123,6 +126,10 @@ struct MRReactionTests {
123 #expect(mr.reactions == [ReactionCount(reaction: "rocket", count: 1, me: true)]) 126 #expect(mr.reactions == [ReactionCount(reaction: "rocket", count: 1, me: true)])
124 #expect(mr.comments?.first?.commentID == 9) 127 #expect(mr.comments?.first?.commentID == 9)
125 #expect(mr.comments?.first?.reactions == nil) 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 await model.react("eyes", comment: 9) 134 await model.react("eyes", comment: 9)
128 135