chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app comments, reviews, reacts, merges, queues, closes and stars
Only what this pull adds. The iOS app signs in, and reads as the account signed in landed as eae6913, so its changes do not show here again.
ios/Knotless/API/Client.swift+18−0
| @@ -36,6 +36,24 @@ actor Client { | ||
return try await send(request) | ||
} | ||
| ||
/// A write, as the account signed in. What it answers is left, as a screen | ||
/// that writes reads what it shows again. | ||
func call(_ method: String, _ input: [String: String]) async throws { | ||
var request = URLRequest(url: Self.site.appendingPathComponent("xrpc/dev.knotless." + method)) | ||
request.httpMethod = "POST" | ||
request.timeoutInterval = 60 | ||
request.setValue("application/json", forHTTPHeaderField: "Content-Type") | ||
request.httpBody = try JSONSerialization.data(withJSONObject: input) | ||
authorize(&request, nil) | ||
let (data, response) = try await session.data(for: request) | ||
let status = (response as? HTTPURLResponse)?.statusCode ?? 0 | ||
guard (200..<300).contains(status) else { | ||
let failure = try? decoder.decode(Failure.self, from: data) | ||
let text = failure == nil ? String(data: data, encoding: .utf8) : nil | ||
throw APIError(status: status, name: failure?.error, message: failure?.message ?? text) | ||
} | ||
} | ||
| ||
/// Trades the code a login came back with for the session's token. | ||
func trade(code: String, verifier: String) async throws -> String { | ||
struct Answer: Decodable, Sendable { let token: String } | ||
ios/Knotless/API/Models.swift+48−0
| @@ -42,6 +42,12 @@ struct RepoDetail: Decodable { | ||
let openIssues: Int | ||
var starCount: Int? | ||
var `private`: Bool? | ||
var viewer: Viewing? | ||
| ||
struct Viewing: Decodable { | ||
/// The asker's star of it, if they starred it. | ||
var star: String? | ||
} | ||
} | ||
| ||
struct ItemLabel: Decodable, Hashable { | ||
| @@ -162,6 +168,48 @@ struct PullThread: Decodable { | ||
var reviews: [Review]? | ||
var checks: [Check]? | ||
var changes: Changes? | ||
var reactions: [ReactionCount]? | ||
var viewer: ThreadViewer? | ||
var queue: QueueInfo? | ||
} | ||
| ||
struct ReactionCount: Decodable, Hashable { | ||
let content: String | ||
var count: Int | ||
/// The asker's own reaction of this content, if they reacted so. | ||
var reaction: String? | ||
} | ||
| ||
struct ThreadViewer: Decodable { | ||
var setState: Bool? | ||
} | ||
| ||
/// Where a pull stands in the merge queue, if it was queued or ejected. | ||
struct QueueInfo: Decodable, Hashable { | ||
let state: String | ||
/// Where it stands, in the server's words. | ||
let said: String | ||
var reason: String? | ||
} | ||
| ||
/// Whether a pull can merge, and what the asker may do about it. | ||
struct MergeStatus: Decodable { | ||
let state: String | ||
var checks: String? | ||
var queueOnly: Bool? | ||
var queueBlocked: String? | ||
var reviews: String? | ||
var reviewsMissing: [Note]? | ||
var may: May? | ||
| ||
struct Note: Decodable { | ||
let text: String | ||
} | ||
| ||
struct May: Decodable { | ||
var merge: Bool? | ||
var queue: Bool? | ||
} | ||
} | ||
| ||
struct DiffAnswer: Decodable { | ||
ios/Knotless/Assets.xcassets/oct-person.imageset/Contents.jsonnew+1−0
| @@ -0,0 +1 @@ | ||
{"images":[{"filename":"person.svg","idiom":"universal"}],"info":{"author":"xcode","version":1},"properties":{"preserves-vector-representation":true,"template-rendering-intent":"template"}} | ||
ios/Knotless/Assets.xcassets/oct-person.imageset/person.svgnew+1−0
| @@ -0,0 +1 @@ | ||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M10.561 8.073a6.005 6.005 0 0 1 3.432 5.142.75.75 0 1 1-1.498.07 4.5 4.5 0 0 0-8.99 0 .75.75 0 0 1-1.498-.07 6.004 6.004 0 0 1 3.431-5.142 3.999 3.999 0 1 1 5.123 0ZM10.5 5a2.5 2.5 0 1 0-5 0 2.5 2.5 0 0 0 5 0Z"/></svg> | ||
| \ No newline at end of file | ||
ios/Knotless/Views/InboxView.swift+5−0
| @@ -93,6 +93,11 @@ struct InboxView: View { | ||
items = answer.notifications | ||
cursor = answer.cursor | ||
phase = .ready | ||
// Seen, once all of them were on offer. The dots stay until the next | ||
// look, so someone can see what was new this time. | ||
if filter == nil, SessionStore.token != nil, let newest = items.first?.createdAt { | ||
try? await Client.shared.call("updateSeen", ["seenAt": newest]) | ||
} | ||
} catch let error as APIError where error.needsSignIn { | ||
phase = .signedOut | ||
} catch is CancellationError { | ||
ios/Knotless/Views/RepoView.swift+40−1
| @@ -26,7 +26,7 @@ struct RepoView: View { | ||
Text(d.name).font(.title.bold()) | ||
if let about = d.description, !about.isEmpty { Text(about) } | ||
HStack(spacing: 14) { | ||
SwiftUI.Label("\(d.starCount ?? 0) stars", systemImage: "star") | ||
StarButton(repo: repo, starred: d.viewer?.star != nil, count: d.starCount ?? 0) | ||
if d.private == true { SwiftUI.Label("Private", systemImage: "lock") } | ||
} | ||
.labelStyle(TightLabel()) | ||
| @@ -188,3 +188,42 @@ struct ItemRow: View { | ||
.padding(.vertical, 2) | ||
} | ||
} | ||
| ||
/// A repository's star count, which someone signed in can tap to star it. | ||
struct StarButton: View { | ||
@Environment(Account.self) private var account | ||
let repo: RepoRef | ||
@State var starred: Bool | ||
@State var count: Int | ||
@State private var failure: String? | ||
@State private var busy = false | ||
| ||
var body: some View { | ||
Button { | ||
Task { await toggle() } | ||
} label: { | ||
SwiftUI.Label(count == 1 ? "1 star" : "\(count) stars", systemImage: starred ? "star.fill" : "star") | ||
.foregroundStyle(starred ? Color.attention : .secondary) | ||
} | ||
.buttonStyle(.borderless) | ||
.disabled(!account.signedIn || busy) | ||
.accessibilityValue(starred ? "Starred" : "Not starred") | ||
.alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { | ||
Button("OK", role: .cancel) {} | ||
} message: { | ||
Text(failure ?? "") | ||
} | ||
} | ||
| ||
private func toggle() async { | ||
busy = true | ||
defer { busy = false } | ||
do { | ||
try await Client.shared.call(starred ? "unstarRepo" : "starRepo", ["repo": repo.uri]) | ||
count += starred ? -1 : 1 | ||
starred.toggle() | ||
} catch { | ||
failure = error.localizedDescription | ||
} | ||
} | ||
} | ||
ios/Knotless/Views/ThreadView.swift+344−79
423 lines changed: not loaded until asked for.
ios/Knotless/Views/Writing.swiftnew+349−0
| @@ -0,0 +1,349 @@ | ||
import SwiftUI | ||
| ||
/// Writes a comment, or on a pull, a review. | ||
struct Composer: View { | ||
enum Kind: String, CaseIterable, Identifiable { | ||
case comment = "Comment" | ||
case approve = "Approve" | ||
case changes = "Ask for changes" | ||
var id: String { rawValue } | ||
} | ||
| ||
let repo: RepoRef | ||
let uri: String | ||
let isPull: Bool | ||
let sent: () -> Void | ||
| ||
@Environment(\.dismiss) private var dismiss | ||
@State private var text = "" | ||
@State private var kind = Kind.comment | ||
@State private var sending = false | ||
@State private var failure: String? | ||
@FocusState private var focused: Bool | ||
| ||
private var empty: Bool { text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } | ||
| ||
var body: some View { | ||
NavigationStack { | ||
VStack(spacing: 12) { | ||
if isPull { | ||
Picker("Kind", selection: $kind) { | ||
ForEach(Kind.allCases) { Text($0.rawValue).tag($0) } | ||
} | ||
.pickerStyle(.segmented) | ||
} | ||
TextEditor(text: $text) | ||
.focused($focused) | ||
.accessibilityLabel("Your comment") | ||
.overlay(alignment: .topLeading) { | ||
if empty { | ||
Text(kind == .approve ? "Say why, if you like" : "Write in Markdown") | ||
.foregroundStyle(.tertiary) | ||
.padding(.top, 8) | ||
.padding(.leading, 5) | ||
.allowsHitTesting(false) | ||
} | ||
} | ||
if let failure { | ||
Text(failure).font(.footnote).foregroundStyle(Color.danger) | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
} | ||
} | ||
.padding() | ||
.navigationTitle(isPull ? "Comment or review" : "Comment") | ||
.navigationBarTitleDisplayMode(.inline) | ||
.toolbar { | ||
ToolbarItem(placement: .cancellationAction) { | ||
Button("Cancel") { dismiss() } | ||
} | ||
ToolbarItem(placement: .confirmationAction) { | ||
Button("Send") { Task { await send() } } | ||
.disabled(sending || (empty && kind != .approve)) | ||
} | ||
} | ||
.onAppear { focused = true } | ||
.interactiveDismissDisabled(!empty) | ||
} | ||
} | ||
| ||
private func send() async { | ||
sending = true | ||
failure = nil | ||
defer { sending = false } | ||
var input = ["repo": repo.uri, "subject": uri, "body": text] | ||
switch kind { | ||
case .comment: break | ||
case .approve: input["verdict"] = "approve" | ||
case .changes: input["verdict"] = "changes" | ||
} | ||
do { | ||
try await Client.shared.call("createComment", input) | ||
sent() | ||
dismiss() | ||
} catch { | ||
failure = error.localizedDescription | ||
} | ||
} | ||
} | ||
| ||
/// How people reacted, and adding or taking back one's own. | ||
struct ReactionBar: View { | ||
private static let all = [ | ||
("+1", "👍"), ("-1", "👎"), ("laugh", "😄"), ("hooray", "🎉"), | ||
("confused", "😕"), ("heart", "❤️"), ("rocket", "🚀"), ("eyes", "👀"), | ||
] | ||
| ||
@Environment(Account.self) private var account | ||
let repo: RepoRef | ||
let subject: String | ||
let reactions: [ReactionCount] | ||
let changed: () -> Void | ||
@State private var failure: String? | ||
/// What a touch made of them, until the server's answer replaces it. | ||
@State private var touched: [ReactionCount]? | ||
| ||
private var shown: [ReactionCount] { touched ?? reactions } | ||
| ||
var body: some View { | ||
ScrollView(.horizontal, showsIndicators: false) { | ||
HStack(spacing: 6) { | ||
if account.signedIn { | ||
Menu { | ||
ForEach(Self.all, id: \.0) { content, emoji in | ||
Button(emoji) { toggle(content, mine: mine(content)) } | ||
} | ||
} label: { | ||
Image(systemName: "face.smiling") | ||
.padding(.horizontal, 8) | ||
.padding(.vertical, 5) | ||
.background(Color(.secondarySystemBackground), in: Capsule()) | ||
} | ||
.accessibilityLabel("Add a reaction") | ||
} | ||
ForEach(shown.filter { $0.count > 0 }, id: \.content) { reaction in | ||
Button { | ||
toggle(reaction.content, mine: reaction.reaction != nil) | ||
} label: { | ||
Text("\(emoji(reaction.content)) \(reaction.count)") | ||
.font(.subheadline) | ||
.foregroundStyle(.primary) | ||
.padding(.horizontal, 8) | ||
.padding(.vertical, 4) | ||
.background(reaction.reaction != nil ? Color.accentSoft : Color(.secondarySystemBackground), in: Capsule()) | ||
} | ||
.buttonStyle(.borderless) | ||
.disabled(!account.signedIn) | ||
} | ||
} | ||
} | ||
.onChange(of: reactions) { touched = nil } | ||
.alert("That didn't work", isPresented: Binding(get: { failure != nil }, set: { if !$0 { failure = nil } })) { | ||
Button("OK", role: .cancel) {} | ||
} message: { | ||
Text(failure ?? "") | ||
} | ||
} | ||
| ||
private func emoji(_ content: String) -> String { Self.all.first { $0.0 == content }?.1 ?? content } | ||
private func mine(_ content: String) -> Bool { shown.first { $0.content == content }?.reaction != nil } | ||
| ||
private func toggle(_ content: String, mine: Bool) { | ||
var next = shown | ||
if let at = next.firstIndex(where: { $0.content == content }) { | ||
next[at].count += mine ? -1 : 1 | ||
next[at].reaction = mine ? nil : "mine" | ||
} else { | ||
next.append(ReactionCount(content: content, count: 1, reaction: "mine")) | ||
} | ||
touched = next | ||
Task { | ||
do { | ||
try await Client.shared.call(mine ? "removeReaction" : "addReaction", | ||
["repo": repo.uri, "subject": subject, "content": content]) | ||
changed() | ||
} catch { | ||
touched = nil | ||
failure = error.localizedDescription | ||
} | ||
} | ||
} | ||
} | ||
| ||
/// What can happen to an open pull now, asked of the server rather than worked | ||
/// out here, with the buttons for what the asker may do. | ||
struct MergeSection: View { | ||
let repo: RepoRef | ||
let pull: String | ||
let base: String | ||
let queue: QueueInfo? | ||
/// Moved when the thread is read again, as whether it can merge may be different. | ||
let refresh: Int | ||
let changed: () -> Void | ||
| ||
@State private var status: MergeStatus? | ||
@State private var loadFailed = false | ||
@State private var confirming = false | ||
/// The write under way, until the thread read after it shows the change. | ||
@State private var doing: String? | ||
@State private var failure: String? | ||
| ||
private var busy: Bool { doing != nil } | ||
private var queued: Bool { queue?.state == "queued" } | ||
| ||
var body: some View { | ||
VStack(alignment: .leading, spacing: 12) { | ||
if let queue, queued { | ||
said(title: queue.said, icon: "clock", color: .attention, lines: []) | ||
BigButton(title: doing == "leaveQueue" ? "Leaving…" : "Leave the queue", role: .destructive, disabled: busy) { act("leaveQueue") } | ||
} else if let status { | ||
// One the queue turned away can be queued again, once fixed. | ||
if let queue { | ||
said(title: queue.reason ?? queue.said, icon: "xmark.circle.fill", color: .danger, lines: []) | ||
} | ||
summary(status) | ||
let merges = canMerge(status) | ||
if merges { | ||
BigButton(title: doing == "mergePull" ? "Merging…" : "Merge pull", main: true, disabled: busy) { confirming = true } | ||
.confirmationDialog("Merge this pull?", isPresented: $confirming, titleVisibility: .visible) { | ||
Button("Merge pull") { act("mergePull") } | ||
} | ||
} | ||
if canQueue(status) { | ||
BigButton(title: doing == "queuePull" ? "Adding…" : "Add to merge queue", main: !merges, disabled: busy) { act("queuePull") } | ||
} | ||
} else if loadFailed { | ||
Text("Can't say whether this can merge.").foregroundStyle(.secondary) | ||
} else { | ||
ProgressView().frame(maxWidth: .infinity) | ||
} | ||
if let failure { | ||
Text(failure).font(.footnote).foregroundStyle(Color.danger).padding(.leading, 36) | ||
} | ||
} | ||
.padding(16) | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
.onChange(of: queue) { doing = nil } | ||
.task(id: "\(queued) \(refresh)") { | ||
if queued { return } | ||
do { | ||
status = try await Client.shared.query("getMergeStatus", ["repo": repo.uri, "pull": pull]) | ||
} catch is CancellationError { | ||
} catch { | ||
loadFailed = true | ||
} | ||
} | ||
} | ||
| ||
private func canQueue(_ s: MergeStatus) -> Bool { | ||
s.may?.queue == true && s.queueBlocked == nil && s.state != "conflicting" && s.state != "draft" | ||
&& s.checks != "failed" && s.reviews != "blocked" | ||
} | ||
| ||
private func canMerge(_ s: MergeStatus) -> Bool { | ||
s.may?.merge == true && s.queueOnly != true && s.state == "clean" | ||
} | ||
| ||
/// Where it stands in the app's own words. The answer's `reason` is written | ||
/// for the command line, with commands to run, so it isn't shown. | ||
private func summary(_ s: MergeStatus) -> some View { | ||
let it = standing(s) | ||
return said(title: it.title, icon: it.icon, color: it.color, lines: it.lines) | ||
} | ||
| ||
private func said(title: String, icon: String, color: Color, lines: [String]) -> some View { | ||
HStack(alignment: .top, spacing: 12) { | ||
Image(systemName: icon).foregroundStyle(color).frame(width: 24) | ||
VStack(alignment: .leading, spacing: 4) { | ||
Text(title) | ||
ForEach(lines, id: \.self) { line in | ||
Text(line).font(.subheadline).foregroundStyle(.secondary) | ||
} | ||
} | ||
} | ||
} | ||
| ||
private func standing(_ s: MergeStatus) -> (title: String, icon: String, color: Color, lines: [String]) { | ||
let queues = s.may?.queue == true && s.queueBlocked == nil | ||
var after: [String] = [] | ||
if s.queueBlocked != nil { after.append("The merge queue is off until the owner delegates writes to this server.") } | ||
if s.may?.merge != true { after.append("Only the owner and collaborators merge it.") } | ||
| ||
if s.state == "draft" { | ||
return ("Still a draft", "pencil.circle", .neutral, ["It can't be merged or queued until it's marked ready for review."]) | ||
} | ||
if s.state == "conflicting" { | ||
return ("Conflicts with \(base)", "exclamationmark.triangle.fill", .danger, ["Merge \(base) into its branch, resolve the files that conflict, then push."]) | ||
} | ||
if s.reviews == "blocked" { | ||
let missing = (s.reviewsMissing ?? []).map { $0.text.prefix(1).uppercased() + $0.text.dropFirst() } | ||
return ("Review required", "xmark.circle.fill", .danger, missing + after) | ||
} | ||
switch s.checks { | ||
case "failed": | ||
return ("Checks failed", "xmark.circle.fill", .danger, ["It can land once they pass. Fix what failed and push."] + after) | ||
case "running": | ||
let line = queues ? "It can land once they pass. Queued now, it lands then." : "It can land once they pass." | ||
return ("Checks are running", "clock", .attention, [line] + after) | ||
case "pending": | ||
let line = queues | ||
? "The merge queue checks it against \(base) as it is now, then lands it." | ||
: "Its checks must pass against \(base) as it is now first." | ||
return ("Not checked yet", "clock", .attention, [line] + after) | ||
default: | ||
break | ||
} | ||
if s.queueOnly == true { | ||
return ("Ready for the merge queue", "checkmark.circle.fill", .success, ["\(base) takes pulls only through the merge queue."] + after) | ||
} | ||
if s.state == "clean" { | ||
return ("Ready to merge", "checkmark.circle.fill", .success, after) | ||
} | ||
return ("Not ready to merge", "clock", .attention, after) | ||
} | ||
| ||
private func act(_ method: String) { | ||
doing = method | ||
failure = nil | ||
Task { | ||
do { | ||
try await Client.shared.call(method, ["repo": repo.uri, "pull": pull]) | ||
changed() | ||
// The change usually shows sooner, and ends this: see onChange. | ||
try? await Task.sleep(for: .seconds(3)) | ||
} catch { | ||
failure = error.localizedDescription | ||
} | ||
doing = nil | ||
} | ||
} | ||
} | ||
| ||
/// A row's button, as wide as its words, indented under the icon beside them. | ||
/// The one marked `main` is what lands the pull next. | ||
private struct BigButton: View { | ||
let title: String | ||
var role: ButtonRole? | ||
var main = false | ||
let disabled: Bool | ||
let action: () -> Void | ||
| ||
var body: some View { | ||
Group { | ||
if main { | ||
button.buttonStyle(.borderedProminent).tint(Color.success) | ||
} else { | ||
button.buttonStyle(.bordered) | ||
} | ||
} | ||
.controlSize(.large) | ||
.buttonBorderShape(.roundedRectangle(radius: 14)) | ||
.disabled(disabled) | ||
.padding(.leading, 36) | ||
} | ||
| ||
private var button: some View { | ||
Button(role: role, action: action) { | ||
Text(title).font(.body.weight(.medium)).frame(maxWidth: .infinity) | ||
} | ||
} | ||
} | ||
ios/KnotlessUITests/FlowTests.swift+14−0
| @@ -31,6 +31,20 @@ final class FlowTests: XCTestCase { | ||
XCTAssertFalse(app.staticTexts["This didn't load"].waitForExistence(timeout: 2), "A screen failed to load") | ||
} | ||
| ||
func testAThreadsHeaderOpensOnlyFromItsLinks() { | ||
app.terminate() | ||
let did = "did:plc:bcgltzqazw5tb6k2g3ttenbj" | ||
app.launchEnvironment["KNOTLESS_ROUTE"] = "thread|\(did)|chadtmiller.com|stack-test|at://\(did)/dev.knotless.pull/3mzkcgac7w4is" | ||
app.launch() | ||
let title = app.staticTexts["Pull c1"].firstMatch | ||
XCTAssertTrue(title.waitForExistence(timeout: 30)) | ||
app.staticTexts["Fails"].tap() | ||
sleep(2) | ||
XCTAssertTrue(title.isHittable, "Touching the description left the pull") | ||
app.buttons["thread-author"].tap() | ||
see("followers") | ||
} | ||
| ||
func testInboxOpensAPullAndItsFiles() { | ||
tab("Inbox") | ||
let row = app.cells.firstMatch | ||
ios/run.sh+1−1
| @@ -18,7 +18,7 @@ xcrun simctl install "$device" build/Build/Products/Debug-iphonesimulator/Knotle | ||
if [ -n "${KNOTLESS_ACTOR:-}" ] && [ ! -f "$tokens" -o -n "$(find "$tokens" -mmin +50 2>/dev/null)" ]; then | ||
umask 077 | ||
{ printf '{'; sep='' | ||
for lxm in getViewer getActorRepos getActorStars getProfile listNotifications getThread getPullDiff getRepo getTree getFile listItems; do | ||
for lxm in getViewer getActorRepos getActorStars getProfile listNotifications getThread getMergeStatus getPullDiff getRepo getTree getFile listItems; do | ||
t=$(goat account service-auth --aud did:web:knotless.dev --lxm "dev.knotless.$lxm" --duration-sec 3600) | ||
printf '%s"dev.knotless.%s":"%s"' "$sep" "$lxm" "$t"; sep=, | ||
done | ||