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 {
7373 await perform(argv, stdin: text)
7474 }
7575
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
7686 func close() async {
7787 await perform(["issue", "close"] + ref)
7888 }
gitbay/Issues/IssueModels.swift +7 −2
@@ -33,10 +33,11 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable {
3333 let body: String?
3434 let bodyFormat: String?
3535 let createdAt: Date
36 let reactions: [ReactionCount]?
3637 let comments: [Comment]?
3738
3839 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
4041 case bodyFormat = "body_format"
4142 case createdAt = "created_at"
4243 }
@@ -44,13 +45,17 @@ nonisolated struct IssueDetail: Decodable, Sendable, Hashable {
4445 var isOpen: Bool { state == "open" }
4546
4647 nonisolated struct Comment: Decodable, Sendable, Hashable, Identifiable {
48 /// What `--comment` names. Absent from a server older than v1.39.0.
49 let commentID: Int64?
4750 let author: String
4851 let body: String
4952 let bodyFormat: String?
5053 let createdAt: Date
54 let reactions: [ReactionCount]?
5155
5256 enum CodingKeys: String, CodingKey {
53 case author, body
57 case author, body, reactions
58 case commentID = "id"
5459 case bodyFormat = "body_format"
5560 case createdAt = "created_at"
5661 }
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 {
114114 await perform(argv, stdin: text)
115115 }
116116
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
117127 func merge(strategy: String? = nil) async {
118128 var argv = ["mr", "merge"] + ref
119129 if let strategy { argv.append(contentsOf: ["--strategy", strategy]) }
gitbay/MRs/MRModels.swift +8 −2
@@ -99,6 +99,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
9999 let checksCombined: String?
100100 let unresolvedThreads: Int?
101101 let commits: [MRCommit]?
102 let reactions: [ReactionCount]?
102103 let comments: [MRComment]?
103104 let reviews: [Review]?
104105 /// Open but not asking: does not merge, does not appear in a review queue.
@@ -111,7 +112,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
111112 let gates: Gates?
112113
113114 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
115116 case bodyFormat = "body_format"
116117 case targetRef = "target_ref"
117118 case headSHA = "head_sha"
@@ -150,6 +151,7 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
150151 checksCombined = try container.decodeIfPresent(String.self, forKey: .checksCombined)
151152 unresolvedThreads = try container.decodeIfPresent(Int.self, forKey: .unresolvedThreads)
152153 commits = try container.decodeIfPresent([MRCommit].self, forKey: .commits)
154 reactions = try container.decodeIfPresent([ReactionCount].self, forKey: .reactions)
153155 comments = try container.decodeIfPresent([MRComment].self, forKey: .comments)
154156 reviews = try container.decodeIfPresent([Review].self, forKey: .reviews)
155157 draft = try container.decodeIfPresent(Bool.self, forKey: .draft) ?? false
@@ -240,13 +242,17 @@ nonisolated struct MRDetail: Decodable, Sendable, Hashable {
240242 }
241243
242244 nonisolated struct MRComment: Decodable, Sendable, Hashable, Identifiable {
245 /// What `--comment` names. Absent from a server older than v1.39.0.
246 let commentID: Int64?
243247 let author: String
244248 let body: String
245249 let bodyFormat: String?
246250 let createdAt: Date
251 let reactions: [ReactionCount]?
247252
248253 enum CodingKeys: String, CodingKey {
249 case author, body
254 case author, body, reactions
255 case commentID = "id"
250256 case bodyFormat = "body_format"
251257 case createdAt = "created_at"
252258 }
gitbay/Views/Issues/IssueView.swift +12 −2
@@ -29,11 +29,15 @@ struct IssueView: View {
2929 }
3030 }
3131
32 if let body = issue.body, !body.isEmpty {
33 Section {
32 Section {
33 if let body = issue.body, !body.isEmpty {
3434 FormattedBodyView(text: body, format: issue.bodyFormat, repo: model.repoPath)
3535 .padding(.vertical, 4)
3636 }
37 ReactionBar(reactions: issue.reactions ?? [], disabled: model.working,
38 idPrefix: "issue-reaction") { name in
39 Task { await model.react(name, comment: nil) }
40 }
3741 }
3842
3943 triageSection(issue)
@@ -169,6 +173,12 @@ struct IssueView: View {
169173 }
170174 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
171175 .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 }
172182 }
173183 .padding(.vertical, 2)
174184 }
gitbay/Views/MRs/MRView.swift +12 −2
@@ -35,11 +35,15 @@ struct MRView: View {
3535 }
3636 }
3737
38 if let body = mr.body, !body.isEmpty {
39 Section {
38 Section {
39 if let body = mr.body, !body.isEmpty {
4040 FormattedBodyView(text: body, format: mr.bodyFormat, repo: model.repoPath)
4141 .padding(.vertical, 4)
4242 }
43 ReactionBar(reactions: mr.reactions ?? [], disabled: model.working,
44 idPrefix: "mr-reaction") { name in
45 Task { await model.react(name, comment: nil) }
46 }
4347 }
4448
4549 if let gates = mr.gates, mr.isOpen {
@@ -535,6 +539,12 @@ struct MRView: View {
535539 }
536540 FormattedBodyView(text: comment.body, format: comment.bodyFormat, repo: model.repoPath)
537541 .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 }
538548 }
539549 .padding(.vertical, 2)
540550 }
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}