~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

The iOS app comments, reviews, reacts, merges, queues, closes and stars

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:feature/ios-writes

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/Shared.swift+13−2
@@ -33,10 +33,14 @@ struct Avatar: View {
/// Loads what a screen shows, and says when it can't.
struct Loading<Value: Sendable, Content: View>: View {
let load: @Sendable () async throws -> Value
/// Moved to read it again. What's on screen stays until the answer comes,
/// and stays if none does.
var refresh = 0
@ViewBuilder let content: (Value) -> Content
@State private var value: Value?
@State private var failure: String?
@State private var loaded: Int?
var body: some View {
Group {
@@ -54,12 +58,19 @@ struct Loading<Value: Sendable, Content: View>: View {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.task { if value == nil { await run() } }
.task(id: refresh) { if value == nil || loaded != refresh { await run() } }
}
private func run() async {
failure = nil
do { value = try await load() } catch { failure = error.localizedDescription }
do {
value = try await load()
loaded = refresh
} catch is CancellationError {
} catch let error as URLError where error.code == .cancelled {
} catch {
if value == nil { 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