Reactions on issues, merge requests and comments !155

merged merged by cmc on 2026-10-14 05:28 UTC · krz/gitbay-ios:reactions into main

9 files changed, +345 −8

Layout: unified · split

gitbay/Issues/IssueDetailViewModel.swift +10
@@ -73,6 +73,16 @@ final class IssueDetailViewModel {
73 await perform(argv, stdin: text) 73 await perform(argv, stdin: text)
74 } 74 }
75 75
76 /// React to the issue, or to one of its comments. A reaction the
77 /// account already gave is taken back.
78 func react(_ name: String, comment: Int64?) async {
79 let given = comment == nil
80 ? state.value?.reactions
81 : state.value?.comments?.first { $0.commentID == comment }?.reactions
82 await perform(Reaction.argv(noun: "issue", repo: repoPath, number: number, comment: comment,
83 name: name, remove: Reaction.remove(name, given: given)))
84 }
85
76 func close() async { 86 func close() async {
77 await perform(["issue", "close"] + ref) 87 await perform(["issue", "close"] + ref)
78 } 88 }
gitbay/Issues/IssueModels.swift +14 −2
@@ -33,10 +33,11 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable {
33 let body: String? 33 let body: String?
34 let bodyFormat: String? 34 let bodyFormat: String?
35 let createdAt: Date 35 let createdAt: Date
36 let reactions: [ReactionCount]?
36 let comments: [Comment]? 37 let comments: [Comment]?
37 38
38 enum CodingKeys: String, CodingKey { 39 enum CodingKeys: String, CodingKey {
39 case number, title, state, author, milestone, labels, assignees, body, comments 40 case number, title, state, author, milestone, labels, assignees, body, reactions, comments
40 case bodyFormat = "body_format" 41 case bodyFormat = "body_format"
41 case createdAt = "created_at" 42 case createdAt = "created_at"
42 } 43 }
@@ -44,17 +45,28 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable {
44 var isOpen: Bool { state == "open" } 45 var isOpen: Bool { state == "open" }
45 46
46 nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable { 47 nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable {
48 /// What `--comment` names. Absent from a server older than v1.39.0.
49 let commentID: Int64?
47 let author: String 50 let author: String
48 let body: String 51 let body: String
49 let bodyFormat: String? 52 let bodyFormat: String?
50 let createdAt: Date 53 let createdAt: Date
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?
51 58
52 enum CodingKeys: String, CodingKey { 59 enum CodingKeys: String, CodingKey {
53 case author, body 60 case author, body, reactions, kind
61 case commentID = "id"
54 case bodyFormat = "body_format" 62 case bodyFormat = "body_format"
55 case createdAt = "created_at" 63 case createdAt = "created_at"
56 } 64 }
57 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
58 var id: String { author + createdAt.timeIntervalSince1970.description + body } 70 var id: String { author + createdAt.timeIntervalSince1970.description + body }
59 } 71 }
60} 72}
gitbay/Issues/Reaction.swift added +56
@@ -0,0 +1,56 @@
1import Foundation
2
3/// One reaction on an issue, a merge request, or one of their comments,
4/// as `issue show` and `mr show` carry it.
5nonisolated struct ReactionCount: Decodable, Sendable, Hashable, Identifiable {
6 let reaction: String
7 let count: Int
8 /// The signed-in account gave this one.
9 let me: Bool
10
11 var id: String { reaction }
12}
13
14/// The reaction vocabulary, named as the server stores it. The order is
15/// the server's display order, so the picker reads the way the web's does.
16nonisolated struct Reaction: Sendable, Hashable, Identifiable {
17 let name: String
18 let emoji: String
19
20 var id: String { name }
21
22 /// What VoiceOver says; "+1" would be read as "plus one".
23 let spoken: String
24
25 static let all: [Reaction] = [
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"),
30 ]
31
32 /// A name a newer server added draws as its name rather than vanishing.
33 static func emoji(for name: String) -> String {
34 all.first { $0.name == name }?.emoji ?? name
35 }
36
37 static func spoken(for name: String) -> String {
38 all.first { $0.name == name }?.spoken ?? name
39 }
40
41 /// Picking a reaction the account already gave takes it back, the way
42 /// the web's toggle does.
43 static func remove(_ name: String, given: [ReactionCount]?) -> Bool {
44 given?.contains { $0.reaction == name && $0.me } ?? false
45 }
46
47 /// `issue react` / `mr react <owner/name> <n> [--comment <id>] <reaction> [--remove]`.
48 static func argv(noun: String, repo: String, number: Int64, comment: Int64?,
49 name: String, remove: Bool) -> [String] {
50 var argv = [noun, "react", repo, String(number)]
51 if let comment { argv += ["--comment", String(comment)] }
52 argv.append(name)
53 if remove { argv.append("--remove") }
54 return argv
55 }
56}
gitbay/MRs/MRDetailViewModel.swift +10
@@ -114,6 +114,16 @@ final class MRDetailViewModel {
114 await perform(argv, stdin: text) 114 await perform(argv, stdin: text)
115 } 115 }
116 116
117 /// React to the merge request, or to one of its comments. A reaction the
118 /// account already gave is taken back.
119 func react(_ name: String, comment: Int64?) async {
120 let given = comment == nil
121 ? state.value?.reactions
122 : state.value?.comments?.first { $0.commentID == comment }?.reactions
123 await perform(Reaction.argv(noun: "mr", repo: repoPath, number: number, comment: comment,
124 name: name, remove: Reaction.remove(name, given: given)))
125 }
126
117 func merge(strategy: String? = nil) async { 127 func merge(strategy: String? = nil) async {
118 var argv = ["mr", "merge"] + ref 128 var argv = ["mr", "merge"] + ref
119 if let strategy { argv.append(contentsOf: ["--strategy", strategy]) } 129 if let strategy { argv.append(contentsOf: ["--strategy", strategy]) }
gitbay/MRs/MRModels.swift +15 −2
@@ -99,6 +99,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
99 let checksCombined: String? 99 let checksCombined: String?
100 let unresolvedThreads: Int? 100 let unresolvedThreads: Int?
101 let commits: [MRCommit]? 101 let commits: [MRCommit]?
102 let reactions: [ReactionCount]?
102 let comments: [MRComment]? 103 let comments: [MRComment]?
103 let reviews: [Review]? 104 let reviews: [Review]?
104 /// Open but not asking: does not merge, does not appear in a review queue. 105 /// Open but not asking: does not merge, does not appear in a review queue.
@@ -111,7 +112,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
111 let gates: Gates? 112 let gates: Gates?
112 113
113 enum CodingKeys: String, CodingKey { 114 enum CodingKeys: String, CodingKey {
114 case number, title, state, author, source, body, milestone, labels, checks, commits, comments, reviews, draft, stacked, gates 115 case number, title, state, author, source, body, milestone, labels, checks, commits, reactions, comments, reviews, draft, stacked, gates
115 case bodyFormat = "body_format" 116 case bodyFormat = "body_format"
116 case targetRef = "target_ref" 117 case targetRef = "target_ref"
117 case headSHA = "head_sha" 118 case headSHA = "head_sha"
@@ -150,6 +151,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
150 checksCombined = try container.decodeIfPresent(String.self, forKey: .checksCombined) 151 checksCombined = try container.decodeIfPresent(String.self, forKey: .checksCombined)
151 unresolvedThreads = try container.decodeIfPresent(Int.self, forKey: .unresolvedThreads) 152 unresolvedThreads = try container.decodeIfPresent(Int.self, forKey: .unresolvedThreads)
152 commits = try container.decodeIfPresent([MRCommit].self, forKey: .commits) 153 commits = try container.decodeIfPresent([MRCommit].self, forKey: .commits)
154 reactions = try container.decodeIfPresent([ReactionCount].self, forKey: .reactions)
153 comments = try container.decodeIfPresent([MRComment].self, forKey: .comments) 155 comments = try container.decodeIfPresent([MRComment].self, forKey: .comments)
154 reviews = try container.decodeIfPresent([Review].self, forKey: .reviews) 156 reviews = try container.decodeIfPresent([Review].self, forKey: .reviews)
155 draft = try container.decodeIfPresent(Bool.self, forKey: .draft) ?? false 157 draft = try container.decodeIfPresent(Bool.self, forKey: .draft) ?? false
@@ -240,17 +242,28 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
240 } 242 }
241 243
242 nonisolated struct MRComment: Decodable, Sendable, Hashable, Identifiable { 244 nonisolated struct MRComment: Decodable, Sendable, Hashable, Identifiable {
245 /// What `--comment` names. Absent from a server older than v1.39.0.
246 let commentID: Int64?
243 let author: String 247 let author: String
244 let body: String 248 let body: String
245 let bodyFormat: String? 249 let bodyFormat: String?
246 let createdAt: Date 250 let createdAt: Date
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?
247 255
248 enum CodingKeys: String, CodingKey { 256 enum CodingKeys: String, CodingKey {
249 case author, body 257 case author, body, reactions, kind
258 case commentID = "id"
250 case bodyFormat = "body_format" 259 case bodyFormat = "body_format"
251 case createdAt = "created_at" 260 case createdAt = "created_at"
252 } 261 }
253 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
254 var id: String { author + createdAt.timeIntervalSince1970.description + body } 267 var id: String { author + createdAt.timeIntervalSince1970.description + body }
255 } 268 }
256 269
gitbay/Views/Issues/IssueView.swift +12 −2
@@ -29,11 +29,15 @@ struct IssueView: View {
29 } 29 }
30 } 30 }
31 31
32 if let body = issue.body, !body.isEmpty { 32 Section {
33 Section { 33 if let body = issue.body, !body.isEmpty {
34 FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath) 34 FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath)
35 .padding(.vertical, 4) 35 .padding(.vertical, 4)
36 } 36 }
37 ReactionBar(reactions: issue.reactions ?? [], disabled: model.working,
38 idPrefix: "issue-reaction") { name in
39 Task { await model.react(name, comment: nil) }
40 }
37 } 41 }
38 42
39 triageSection(issue) 43 triageSection(issue)
@@ -169,6 +173,12 @@ struct IssueView: View {
169 } 173 }
170 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) 174 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
171 .font(.gbSans(.subheadline)) 175 .font(.gbSans(.subheadline))
176 if let id = comment.commentID, !comment.isSystem {
177 ReactionBar(reactions: comment.reactions ?? [], disabled: model.working,
178 idPrefix: "issue-comment-\(id)-reaction") { name in
179 Task { await model.react(name, comment: id) }
180 }
181 }
172 } 182 }
173 .padding(.vertical, 2) 183 .padding(.vertical, 2)
174 } 184 }
gitbay/Views/MRs/MRView.swift +12 −2
@@ -35,11 +35,15 @@ struct MRView: View {
35 } 35 }
36 } 36 }
37 37
38 if let body = mr.body, !body.isEmpty { 38 Section {
39 Section { 39 if let body = mr.body, !body.isEmpty {
40 FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath) 40 FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath)
41 .padding(.vertical, 4) 41 .padding(.vertical, 4)
42 } 42 }
43 ReactionBar(reactions: mr.reactions ?? [], disabled: model.working,
44 idPrefix: "mr-reaction") { name in
45 Task { await model.react(name, comment: nil) }
46 }
43 } 47 }
44 48
45 if let gates = mr.gates, mr.isOpen { 49 if let gates = mr.gates, mr.isOpen {
@@ -535,6 +539,12 @@ struct MRView: View {
535 } 539 }
536 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath) 540 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
537 .font(.gbSans(.subheadline)) 541 .font(.gbSans(.subheadline))
542 if let id = comment.commentID, !comment.isSystem {
543 ReactionBar(reactions: comment.reactions ?? [], disabled: model.working,
544 idPrefix: "mr-comment-\(id)-reaction") { name in
545 Task { await model.react(name, comment: id) }
546 }
547 }
538 } 548 }
539 .padding(.vertical, 2) 549 .padding(.vertical, 2)
540 } 550 }
gitbay/Views/Shared/ReactionBar.swift added +77
@@ -0,0 +1,77 @@
1import SwiftUI
2
3/// The reactions on an issue, a merge request or a comment: one chip per
4/// reaction given, tinted when it is the account's own, and a menu of the
5/// whole vocabulary. Tapping a chip toggles the account's reaction; the
6/// command decides, and the screen reloads from what it says.
7struct ReactionBar: View {
8
9 let reactions: [ReactionCount]
10 let disabled: Bool
11 /// Names the bar's controls: `<idPrefix>-<reaction>` and `<idPrefix>-add`.
12 let idPrefix: String
13 let react: (String) -> Void
14
15 var body: some View {
16 HStack(spacing: 6) {
17 ForEach(reactions) { given in
18 Button {
19 react(given.reaction)
20 } label: {
21 HStack(spacing: 3) {
22 Text(Reaction.emoji(for: given.reaction))
23 Text("\(given.count)")
24 .monospacedDigit()
25 .foregroundStyle(given.me ? Color.gbAccent : .secondary)
26 }
27 .font(.gbSans(.caption))
28 .padding(.horizontal, 7)
29 .padding(.vertical, 3)
30 .background((given.me ? Color.gbAccent : .secondary).opacity(0.07), in: gbChipShape)
31 .overlay(gbChipShape.stroke((given.me ? Color.gbAccent : .secondary).opacity(0.35), lineWidth: 1))
32 .frame(minHeight: 44)
33 .contentShape(Rectangle())
34 }
35 .buttonStyle(.borderless)
36 .disabled(disabled)
37 .accessibilityLabel(label(for: given))
38 .accessibilityIdentifier("\(idPrefix)-\(given.reaction)")
39 }
40 Menu {
41 ForEach(Reaction.all) { reaction in
42 Button {
43 react(reaction.name)
44 } label: {
45 // The account's own reaction carries the menu's checkmark;
46 // picking it again takes it back.
47 if Reaction.remove(reaction.name, given: reactions) {
48 Label("\(reaction.emoji) \(reaction.name)", systemImage: "checkmark")
49 } else {
50 Text("\(reaction.emoji) \(reaction.name)")
51 }
52 }
53 .accessibilityLabel(Reaction.spoken(for: reaction.name))
54 .accessibilityAddTraits(Reaction.remove(reaction.name, given: reactions) ? .isSelected : [])
55 }
56 } label: {
57 Image(systemName: "face.smiling")
58 .foregroundStyle(.secondary)
59 .frame(minWidth: 44, minHeight: 44)
60 .contentShape(Rectangle())
61 }
62 .menuStyle(.borderlessButton)
63 // A menu in a list row takes the accent; the icon is a quiet
64 // affordance beside the chips, not a link.
65 .tint(.secondary)
66 .disabled(disabled)
67 .accessibilityLabel("Add reaction")
68 .accessibilityIdentifier("\(idPrefix)-add")
69 Spacer(minLength: 0)
70 }
71 }
72
73 private func label(for given: ReactionCount) -> String {
74 let count = given.count == 1 ? "1 reaction" : "\(given.count) reactions"
75 return "\(Reaction.spoken(for: given.reaction)), \(count)\(given.me ? ", including yours" : "")"
76 }
77}
gitbayTests/ReactionTests.swift added +139
@@ -0,0 +1,139 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private func argvOf(_ seen: StubProtocol.Seen) throws -> [String] {
16 let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any])
17 return try #require(body["argv"] as? [String])
18}
19
20private let issueShowJSON = """
21 {"protocol_version":1,"data":{"number":11,"title":"iOS app","state":"open",\
22 "author":"krz","body":"Build it.","created_at":"2026-08-20T10:00:00.000Z",\
23 "reactions":[{"reaction":"+1","count":2,"me":true},{"reaction":"eyes","count":1,"me":false}],\
24 "comments":[{"id":7,"author":"cmc","body":"started","created_at":"2026-08-22T09:00:00.000Z",\
25 "reactions":[{"reaction":"heart","count":1,"me":false}]}]},"exit_code":0}
26 """
27
28private let mrShowJSON = """
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",\
31 "reactions":[{"reaction":"rocket","count":1,"me":true}],\
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"}]},\
35 "exit_code":0}
36 """
37
38private let ok = #"{"protocol_version":1,"data":{"reaction":"+1"},"exit_code":0}"#
39
40struct ReactionModelTests {
41
42 @Test func theVocabularyIsTheServersInItsOrder() {
43 #expect(Reaction.all.map(\.name) == ["+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes"])
44 #expect(Reaction.emoji(for: "hooray") == "🎉")
45 // An unknown name from a newer server still draws.
46 #expect(Reaction.emoji(for: "sparkles") == "sparkles")
47 #expect(Reaction.spoken(for: "+1") == "thumbs up")
48 }
49
50 @Test func argvReactsToTheItemOrAComment() {
51 #expect(Reaction.argv(noun: "issue", repo: "krz/gitbay", number: 11, comment: nil, name: "+1", remove: false)
52 == ["issue", "react", "krz/gitbay", "11", "+1"])
53 #expect(Reaction.argv(noun: "mr", repo: "krz/gitbay", number: 3, comment: 9, name: "eyes", remove: true)
54 == ["mr", "react", "krz/gitbay", "3", "--comment", "9", "eyes", "--remove"])
55 }
56
57 /// Every name the server knows is offered, and the ones already given
58 /// are offered too: picking one of those takes it back.
59 @Test func tappingAReactionYouGaveRemovesIt() {
60 let given = [ReactionCount(reaction: "+1", count: 2, me: true), ReactionCount(reaction: "eyes", count: 1, me: false)]
61 #expect(Reaction.remove("+1", given: given))
62 #expect(!Reaction.remove("eyes", given: given))
63 #expect(!Reaction.remove("heart", given: given))
64 }
65}
66
67@MainActor
68struct IssueReactionTests {
69
70 @Test func issueShowDecodesReactionsOnTheIssueAndItsComments() async throws {
71 let (client, stub) = try makeClient()
72 stub.enqueue(.init(status: 200, json: issueShowJSON))
73 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
74
75 await model.load()
76
77 let issue = try #require(model.state.value)
78 #expect(issue.reactions == [ReactionCount(reaction: "+1", count: 2, me: true),
79 ReactionCount(reaction: "eyes", count: 1, me: false)])
80 #expect(issue.comments?.first?.commentID == 7)
81 #expect(issue.comments?.first?.reactions?.first?.reaction == "heart")
82 }
83
84 @Test func reactingToACommentSendsItsIDThenReloads() async throws {
85 let (client, stub) = try makeClient()
86 stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show"))
87 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
88 stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show"))
89 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
90 await model.load()
91
92 await model.react("heart", comment: 7)
93
94 let post = try #require(stub.seen.first { $0.method == "POST" })
95 #expect(try argvOf(post) == ["issue", "react", "krz/gitbay", "11", "--comment", "7", "heart"])
96 #expect(stub.seen.filter { $0.url.absoluteString.contains("argv=show") }.count == 2)
97 }
98
99 @Test func reactingWithOneYouGaveTakesItBack() async throws {
100 let (client, stub) = try makeClient()
101 stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show"))
102 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
103 stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show"))
104 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
105 await model.load()
106
107 await model.react("+1", comment: nil)
108
109 let post = try #require(stub.seen.first { $0.method == "POST" })
110 #expect(try argvOf(post) == ["issue", "react", "krz/gitbay", "11", "+1", "--remove"])
111 }
112}
113
114@MainActor
115struct MRReactionTests {
116
117 @Test func mrShowDecodesReactionsAndReactingSendsTheNoun() async throws {
118 let (client, stub) = try makeClient()
119 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
120 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
121 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
122 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 3)
123 await model.load()
124
125 let mr = try #require(model.state.value)
126 #expect(mr.reactions == [ReactionCount(reaction: "rocket", count: 1, me: true)])
127 #expect(mr.comments?.first?.commentID == 9)
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)
133
134 await model.react("eyes", comment: 9)
135
136 let post = try #require(stub.seen.first { $0.method == "POST" })
137 #expect(try argvOf(post) == ["mr", "react", "krz/gitbay", "3", "--comment", "9", "eyes"])
138 }
139}