Commit 664ba82d6a

664ba82d6a4d850d26be921d04dfefdfe9624daa

parent: 198f6fc441

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 04:49 UTC

Reactions on issues, merge requests and comments

The item and each comment show the reactions issue show and mr show
carry, with the account's own tinted, and a menu of the server's
vocabulary. Picking a reaction the account already gave takes it back,
as the web's toggle does.

Closes #22

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 +7 −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,13 +45,17 @@ 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]?
51 55
52 enum CodingKeys: String, CodingKey { 56 enum CodingKeys: String, CodingKey {
53 case author, body 57 case author, body, reactions
58 case commentID = "id"
54 case bodyFormat = "body_format" 59 case bodyFormat = "body_format"
55 case createdAt = "created_at" 60 case createdAt = "created_at"
56 } 61 }
gitbay/Issues/Reaction.swift added +49
@@ -0,0 +1,49 @@
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 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: "👀"),
27 ]
28
29 /// A name a newer server added draws as its name rather than vanishing.
30 static func emoji(for name: String) -> String {
31 all.first { $0.name == name }?.emoji ?? name
32 }
33
34 /// Picking a reaction the account already gave takes it back, the way
35 /// the web's toggle does.
36 static func remove(_ name: String, given: [ReactionCount]?) -> Bool {
37 given?.contains { $0.reaction == name && $0.me } ?? false
38 }
39
40 /// `issue react` / `mr react <owner/name> <n> [--comment <id>] <reaction> [--remove]`.
41 static func argv(noun: String, repo: String, number: Int64, comment: Int64?,
42 name: String, remove: Bool) -> [String] {
43 var argv = [noun, "react", repo, String(number)]
44 if let comment { argv += ["--comment", String(comment)] }
45 argv.append(name)
46 if remove { argv.append("--remove") }
47 return argv
48 }
49}
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 +8 −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,13 +242,17 @@ 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]?
247 252
248 enum CodingKeys: String, CodingKey { 253 enum CodingKeys: String, CodingKey {
249 case author, body 254 case author, body, reactions
255 case commentID = "id"
250 case bodyFormat = "body_format" 256 case bodyFormat = "body_format"
251 case createdAt = "created_at" 257 case createdAt = "created_at"
252 } 258 }
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 {
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 {
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 +75
@@ -0,0 +1,75 @@
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 }
54 } label: {
55 Image(systemName: "face.smiling")
56 .foregroundStyle(.secondary)
57 .frame(minWidth: 44, minHeight: 44)
58 .contentShape(Rectangle())
59 }
60 .menuStyle(.borderlessButton)
61 // A menu in a list row takes the accent; the icon is a quiet
62 // affordance beside the chips, not a link.
63 .tint(.secondary)
64 .disabled(disabled)
65 .accessibilityLabel("Add reaction")
66 .accessibilityIdentifier("\(idPrefix)-add")
67 Spacer(minLength: 0)
68 }
69 }
70
71 private func label(for given: ReactionCount) -> String {
72 let count = given.count == 1 ? "1 reaction" : "\(given.count) reactions"
73 return "\(given.reaction), \(count)\(given.me ? ", including yours" : "")"
74 }
75}
gitbayTests/ReactionTests.swift added +132
@@ -0,0 +1,132 @@
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 "exit_code":0}
34 """
35
36private let ok = #"{"protocol_version":1,"data":{"reaction":"+1"},"exit_code":0}"#
37
38struct ReactionModelTests {
39
40 @Test func theVocabularyIsTheServersInItsOrder() {
41 #expect(Reaction.all.map(\.name) == ["+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes"])
42 #expect(Reaction.emoji(for: "hooray") == "🎉")
43 // An unknown name from a newer server still draws.
44 #expect(Reaction.emoji(for: "sparkles") == "sparkles")
45 }
46
47 @Test func argvReactsToTheItemOrAComment() {
48 #expect(Reaction.argv(noun: "issue", repo: "krz/gitbay", number: 11, comment: nil, name: "+1", remove: false)
49 == ["issue", "react", "krz/gitbay", "11", "+1"])
50 #expect(Reaction.argv(noun: "mr", repo: "krz/gitbay", number: 3, comment: 9, name: "eyes", remove: true)
51 == ["mr", "react", "krz/gitbay", "3", "--comment", "9", "eyes", "--remove"])
52 }
53
54 /// Every name the server knows is offered, and the ones already given
55 /// are offered too: picking one of those takes it back.
56 @Test func tappingAReactionYouGaveRemovesIt() {
57 let given = [ReactionCount(reaction: "+1", count: 2, me: true), ReactionCount(reaction: "eyes", count: 1, me: false)]
58 #expect(Reaction.remove("+1", given: given))
59 #expect(!Reaction.remove("eyes", given: given))
60 #expect(!Reaction.remove("heart", given: given))
61 }
62}
63
64@MainActor
65struct IssueReactionTests {
66
67 @Test func issueShowDecodesReactionsOnTheIssueAndItsComments() async throws {
68 let (client, stub) = try makeClient()
69 stub.enqueue(.init(status: 200, json: issueShowJSON))
70 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
71
72 await model.load()
73
74 let issue = try #require(model.state.value)
75 #expect(issue.reactions == [ReactionCount(reaction: "+1", count: 2, me: true),
76 ReactionCount(reaction: "eyes", count: 1, me: false)])
77 #expect(issue.comments?.first?.commentID == 7)
78 #expect(issue.comments?.first?.reactions?.first?.reaction == "heart")
79 }
80
81 @Test func reactingToACommentSendsItsIDThenReloads() async throws {
82 let (client, stub) = try makeClient()
83 stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show"))
84 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
85 stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show"))
86 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
87 await model.load()
88
89 await model.react("heart", comment: 7)
90
91 let post = try #require(stub.seen.first { $0.method == "POST" })
92 #expect(try argvOf(post) == ["issue", "react", "krz/gitbay", "11", "--comment", "7", "heart"])
93 #expect(stub.seen.filter { $0.url.absoluteString.contains("argv=show") }.count == 2)
94 }
95
96 @Test func reactingWithOneYouGaveTakesItBack() async throws {
97 let (client, stub) = try makeClient()
98 stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show"))
99 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
100 stub.enqueue(.init(status: 200, json: issueShowJSON, match: "argv=show"))
101 let model = IssueDetailViewModel(client: client, repoPath: "krz/gitbay", number: 11)
102 await model.load()
103
104 await model.react("+1", comment: nil)
105
106 let post = try #require(stub.seen.first { $0.method == "POST" })
107 #expect(try argvOf(post) == ["issue", "react", "krz/gitbay", "11", "+1", "--remove"])
108 }
109}
110
111@MainActor
112struct MRReactionTests {
113
114 @Test func mrShowDecodesReactionsAndReactingSendsTheNoun() async throws {
115 let (client, stub) = try makeClient()
116 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
117 stub.enqueue(.init(status: 200, json: ok, match: "cmd"))
118 stub.enqueue(.init(status: 200, json: mrShowJSON, match: "argv=show"))
119 let model = MRDetailViewModel(client: client, repoPath: "krz/gitbay", number: 3)
120 await model.load()
121
122 let mr = try #require(model.state.value)
123 #expect(mr.reactions == [ReactionCount(reaction: "rocket", count: 1, me: true)])
124 #expect(mr.comments?.first?.commentID == 9)
125 #expect(mr.comments?.first?.reactions == nil)
126
127 await model.react("eyes", comment: 9)
128
129 let post = try #require(stub.seen.first { $0.method == "POST" })
130 #expect(try argvOf(post) == ["mr", "react", "krz/gitbay", "3", "--comment", "9", "eyes"])
131 }
132}