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 {
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 +14 −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,17 +45,28 @@ 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]?
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?
5158
5259 enum CodingKeys: String, CodingKey {
53 case author, body
60 case author, body, reactions, kind
61 case commentID = "id"
5462 case bodyFormat = "body_format"
5563 case createdAt = "created_at"
5664 }
5765
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
5870 var id: String { author + createdAt.timeIntervalSince1970.description + body }
5971 }
6072}
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 {
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 +15 −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,17 +242,28 @@ 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]?
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?
247255
248256 enum CodingKeys: String, CodingKey {
249 case author, body
257 case author, body, reactions, kind
258 case commentID = "id"
250259 case bodyFormat = "body_format"
251260 case createdAt = "created_at"
252261 }
253262
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
254267 var id: String { author + createdAt.timeIntervalSince1970.description + body }
255268 }
256269
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, !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 }
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, !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 }
538548 }
539549 .padding(.vertical, 2)
540550 }
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}