Commit 4ce872b9f4
Verified · cmc
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 | ||