chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app links the pulls and issues Markdown names, as the pages do
ios/Knotless/Support/Markdown.swift+24−1
| @@ -11,6 +11,18 @@ struct MarkdownView: View { | ||
/// Its author's, to tick a task, which is by its number in `source`. Without | ||
/// it the boxes only say how each stands. | ||
var tick: ((Int, Bool) -> Void)? | ||
/// The pulls and issues it names, read from `html`. | ||
private let references: MarkdownReferences | ||
| ||
@Environment(\.openRoute) private var openRoute | ||
| ||
init(source: String, html: String? = nil, place: MarkdownPlace? = nil, tick: ((Int, Bool) -> Void)? = nil) { | ||
self.source = source | ||
self.html = html | ||
self.place = place | ||
self.tick = tick | ||
references = html.map { MarkdownReferences(html: $0, source: source) } ?? MarkdownReferences() | ||
} | ||
| ||
enum Block: Hashable { | ||
case heading(Int, String) | ||
| @@ -32,6 +44,17 @@ struct MarkdownView: View { | ||
} | ||
} | ||
.frame(maxWidth: .infinity, alignment: .leading) | ||
.environment(\.openURL, OpenURLAction(handler: open)) | ||
} | ||
| ||
/// A pull or issue of its repository opens here, as the app's own screen. | ||
private func open(_ url: URL) -> OpenURLAction.Result { | ||
guard let repo = place?.repo, let uri = MarkdownReferences.thread(url, in: repo) else { | ||
return url.scheme == MarkdownReferences.scheme ? .discarded : .systemAction | ||
} | ||
guard let openRoute else { return .systemAction } | ||
openRoute(.thread(repo, uri: uri)) | ||
return .handled | ||
} | ||
| ||
@ViewBuilder private func view(_ block: Block, _ colours: [String: String]) -> some View { | ||
| @@ -220,7 +243,7 @@ struct MarkdownView: View { | ||
| ||
private func inline(_ text: String) -> Text { | ||
let options = AttributedString.MarkdownParsingOptions(interpretedSyntax: .inlineOnlyPreservingWhitespace) | ||
if var marked = try? AttributedString(markdown: text, options: options) { | ||
if var marked = try? AttributedString(markdown: references.linked(text), options: options) { | ||
for run in marked.runs where run.inlinePresentationIntent?.contains(.code) == true { | ||
marked[run.range].font = .callout.monospaced() | ||
marked[run.range].backgroundColor = Color(.secondarySystemBackground) | ||
ios/Knotless/Support/MarkdownFences.swift+1−1
| @@ -14,7 +14,7 @@ enum MarkdownFences { | ||
return out | ||
} | ||
| ||
private static func unescaped(_ text: String) -> String { | ||
static func unescaped(_ text: String) -> String { | ||
// `&` last, so that an escaped `<` stays one. | ||
text.replacing("<", with: "<").replacing(">", with: ">") | ||
.replacing(""", with: "\"").replacing("'", with: "'").replacing("'", with: "'") | ||
ios/Knotless/Support/MarkdownReferences.swiftnew+85−0
| @@ -0,0 +1,85 @@ | ||
import Foundation | ||
| ||
/// The pulls and issues a Markdown text names by their `at://` URIs, as the | ||
/// server links them: read from the HTML it made of the text, where each one | ||
/// of the repository's is a link to its page, a bare one showing its title. | ||
/// One the server didn't link, as another repository's, stays as written. | ||
struct MarkdownReferences: Hashable, Sendable { | ||
/// The title of each the server linked, by its URI. | ||
var titles: [String: String] = [:] | ||
/// Each the server linked, a bare one or a link's target. | ||
var known: Set<String> = [] | ||
| ||
/// What a link to a pull or issue the app opens itself points at. | ||
static let scheme = "knotless-thread" | ||
| ||
init() {} | ||
| ||
init(html: String, source: String) { | ||
for found in html.matches(of: #/<a href="/[^/"]+/[^/"]+/(issue|pull)/(did:[^/"]+)/([^/"]+)">(.*?)</a>/#.dotMatchesNewlines()) { | ||
let uri = "at://\(found.output.2)/dev.knotless.\(found.output.1)/\(found.output.3)" | ||
known.insert(uri) | ||
let title = MarkdownFences.unescaped(String(found.output.4).replacing(#/<[^>]*>/#, with: "")) | ||
// Where the text links the URI itself, those are its own words, not the title. | ||
if titles[uri] == nil, !source.contains("[\(title)](\(uri)") { titles[uri] = title } | ||
} | ||
} | ||
| ||
/// `text` with each `at://` URI the server linked made a link the app opens: | ||
/// a bare one showing its title, as the pages show it. Not in code. | ||
func linked(_ text: String) -> String { | ||
guard !known.isEmpty, text.contains("at://") else { return text } | ||
var out = "" | ||
var done = text.startIndex | ||
var from = text.startIndex | ||
while let found = text[from...].range(of: "at://") { | ||
from = found.upperBound | ||
let at = found.lowerBound | ||
let before = text[..<at] | ||
if let last = before.last, last.isLetter || last.isNumber { continue } | ||
if before.filter({ $0 == "`" }).count % 2 == 1 { continue } | ||
let rest = text[at...] | ||
var end = rest.firstIndex { $0.isWhitespace || "<>()[]\"'`".contains($0) } ?? rest.endIndex | ||
while end > at, ".,;:!?".contains(text[text.index(before: end)]) { end = text.index(before: end) } | ||
let uri = String(text[at..<end]) | ||
guard known.contains(uri) else { continue } | ||
if before.hasSuffix("](") { | ||
out += text[done..<at] + Self.target(uri) | ||
} else if let title = titles[uri] { | ||
out += text[done..<at] + "[\(Self.escaped(title))](\(Self.target(uri)))" | ||
} else { | ||
continue | ||
} | ||
done = end | ||
from = end | ||
} | ||
return out + text[done...] | ||
} | ||
| ||
/// The pull or issue of `repo` a link opens, if it's one: by the link this | ||
/// makes of its URI, or its page on the site. | ||
static func thread(_ url: URL, in repo: RepoRef) -> String? { | ||
if url.scheme == scheme { | ||
return url.absoluteString.dropFirst(scheme.count + 1).removingPercentEncoding | ||
} | ||
guard url.scheme == "https", url.host() == URL(string: MarkdownPlace.site)?.host() else { return nil } | ||
let p = url.path(percentEncoded: false).split(separator: "/").map(String.init) | ||
guard p.count == 5, [repo.handle, repo.did].contains(p[0]), p[1] == repo.name, | ||
["issue", "pull"].contains(p[2]), p[3].hasPrefix("did:") | ||
else { return nil } | ||
return "at://\(p[3])/dev.knotless.\(p[2])/\(p[4])" | ||
} | ||
| ||
private static func target(_ uri: String) -> String { | ||
"\(scheme):" + (uri.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? uri) | ||
} | ||
| ||
private static func escaped(_ title: String) -> String { | ||
var out = "" | ||
for c in title { | ||
if "\\`*_[]<>!~".contains(c) { out.append("\\") } | ||
out.append(c) | ||
} | ||
return out | ||
} | ||
} | ||
ios/Knotless/App.swift+19−1
| @@ -64,6 +64,18 @@ enum Route: Hashable { | ||
case notificationSettings | ||
} | ||
| ||
/// Opens a screen in the stack it's asked from, for what can't be a | ||
/// `NavigationLink`, as a link in some text. | ||
struct OpenRoute { | ||
let open: @MainActor (Route) -> Void | ||
| ||
@MainActor func callAsFunction(_ route: Route) { open(route) } | ||
} | ||
| ||
extension EnvironmentValues { | ||
@Entry var openRoute: OpenRoute? | ||
} | ||
| ||
extension View { | ||
func routes() -> some View { | ||
navigationDestination(for: Route.self) { route in | ||
| @@ -109,6 +121,7 @@ struct RootView: View { | ||
@State private var homePath: [Route] | ||
@State private var inboxPath: [Route] = [] | ||
@State private var profilePath: [Route] = [] | ||
@State private var explorePath: [Route] = [] | ||
@Environment(Account.self) private var account | ||
private let pushes = Pushes.shared | ||
| ||
| @@ -133,6 +146,7 @@ struct RootView: View { | ||
guard did == nil else { return } | ||
homePath = [] | ||
inboxPath = [] | ||
explorePath = [] | ||
profilePath = [] | ||
} | ||
.task(id: pushes.opened) { | ||
| @@ -147,15 +161,19 @@ struct RootView: View { | ||
TabView(selection: $page) { | ||
Tab("Home", image: "oct-home-fill", value: Page.home) { | ||
NavigationStack(path: $homePath) { HomeView().routes() } | ||
.environment(\.openRoute, OpenRoute { homePath.append($0) }) | ||
} | ||
Tab("Inbox", image: "oct-inbox-fill", value: Page.inbox) { | ||
NavigationStack(path: $inboxPath) { InboxView().routes() } | ||
.environment(\.openRoute, OpenRoute { inboxPath.append($0) }) | ||
} | ||
Tab("Explore", image: "oct-telescope-fill", value: Page.explore) { | ||
NavigationStack { ExploreView().routes() } | ||
NavigationStack(path: $explorePath) { ExploreView().routes() } | ||
.environment(\.openRoute, OpenRoute { explorePath.append($0) }) | ||
} | ||
Tab("Profile", image: "oct-person-fill", value: Page.profile) { | ||
NavigationStack(path: $profilePath) { OwnProfileView().routes() } | ||
.environment(\.openRoute, OpenRoute { profilePath.append($0) }) | ||
} | ||
} | ||
} | ||
ios/KnotlessUITests/LookTests.swift+5−1
| @@ -66,7 +66,11 @@ final class LookTests: XCTestCase { | ||
} | ||
| ||
private func take(_ step: Step, in app: XCUIApplication) { | ||
if let label = step.tap { | ||
if let label = step.tap, label.hasPrefix("@") { | ||
let xy = label.dropFirst().split(separator: ",").compactMap { Double($0) } | ||
app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: xy[0], dy: xy[1])).tap() | ||
sleep(2) | ||
} else if let label = step.tap { | ||
let match = NSPredicate(format: "label == %@ OR identifier == %@", label, label) | ||
let found = app.descendants(matching: .any).matching(match).firstMatch | ||
for _ in 0..<8 where !(found.exists && found.isHittable) { app.swipeUp() } | ||
ios/look.mjs+3−1
| @@ -16,7 +16,9 @@ | ||
// the file's JSON, and null takes it out | ||
// --signed-out as no one; signed in as alice.test otherwise | ||
// --tap <label> tap what has that label or accessibility | ||
// identifier, scrolling to it, in turn | ||
// identifier, scrolling to it, in turn; or | ||
// @x,y, that point on the screen, as for a | ||
// link in some text, which has no label | ||
// --type <text> type into the first text box, in turn | ||
// --swipe <up|down|left|right> swipe, in turn | ||
// --top only the screen as it opens, not all of it | ||