chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app's sign-in suggests handles as they're typed
ios/Knotless/API/Account.swift+73−6
| @@ -149,25 +149,44 @@ private final class BrowserAnchor: NSObject, ASWebAuthenticationPresentationCont | ||
} | ||
} | ||
| ||
/// The tilde over a handle to sign in with, which goes to the browser. | ||
/// An account the typeahead service matched. | ||
struct FoundActor: Decodable, Identifiable, Sendable { | ||
let did: String | ||
let handle: String | ||
var displayName: String? | ||
var avatar: String? | ||
var id: String { did } | ||
} | ||
| ||
/// The tilde over a handle to sign in with, which goes to the browser. Whoever | ||
/// matches what's typed takes the tilde's place, right above the field, as the | ||
/// keyboard covers what's under it. | ||
struct SignInForm: View { | ||
@Environment(Account.self) private var account | ||
@State private var handle = "" | ||
@State private var busy = false | ||
@State private var found: [FoundActor] = [] | ||
/// The handle just picked, which needs no more suggestions. | ||
@State private var picked: String? | ||
@FocusState private var typing: Bool | ||
var why: String | ||
| ||
private var name: String { handle.trimmingCharacters(in: .whitespaces) } | ||
private var ready: Bool { !name.isEmpty && !busy } | ||
private var suggesting: Bool { typing && !busy && !found.isEmpty && handle != picked } | ||
| ||
var body: some View { | ||
VStack(spacing: Space.s3) { | ||
Spacer(minLength: Space.s4) | ||
Image("mark").renderingMode(.template).resizable().scaledToFit() | ||
.frame(width: 112) | ||
.foregroundStyle(.primary) | ||
.accessibilityLabel("knotless") | ||
Spacer(minLength: Space.s4) | ||
if suggesting { | ||
suggestions | ||
} else { | ||
Image("mark").renderingMode(.template).resizable().scaledToFit() | ||
.frame(width: 112) | ||
.foregroundStyle(.primary) | ||
.accessibilityLabel("knotless") | ||
Spacer(minLength: Space.s4) | ||
} | ||
| ||
TextField("alice.bsky.social", text: $handle) | ||
.textContentType(.username) | ||
| @@ -207,10 +226,12 @@ struct SignInForm: View { | ||
} | ||
.font(.footnote) | ||
.multilineTextAlignment(.center) | ||
.fixedSize(horizontal: false, vertical: true) | ||
| ||
Text(Self.terms) | ||
.font(.footnote) | ||
.multilineTextAlignment(.center) | ||
.fixedSize(horizontal: false, vertical: true) | ||
.tint(Color.accent) | ||
.padding(.top, Space.s5) | ||
.padding(.bottom, Space.s4) | ||
| @@ -219,6 +240,45 @@ struct SignInForm: View { | ||
.frame(maxWidth: .infinity, maxHeight: .infinity) | ||
.contentShape(Rectangle()) | ||
.onTapGesture { typing = false } | ||
// Asked a moment after typing stops; one overtaken by more typing is let go. | ||
.task(id: handle) { | ||
guard handle != picked else { return } | ||
try? await Task.sleep(for: .milliseconds(150)) | ||
guard !Task.isCancelled else { return } | ||
let actors = await Client.shared.findActors(handle) | ||
if !Task.isCancelled { found = actors } | ||
} | ||
} | ||
| ||
private var suggestions: some View { | ||
ScrollView { | ||
VStack(spacing: 0) { | ||
ForEach(found) { actor in | ||
Button { pick(actor) } label: { | ||
HStack(spacing: Space.s3) { | ||
Avatar(url: actor.avatar, name: actor.displayName ?? actor.handle, did: actor.did, size: Size.Avatar.regular) | ||
VStack(alignment: .leading, spacing: 0) { | ||
if let name = actor.displayName, !name.isEmpty { | ||
Text(name).font(.subheadline.weight(.semibold)).lineLimit(1) | ||
} | ||
Text("@\(actor.handle)").font(.footnote).foregroundStyle(.secondary).lineLimit(1) | ||
} | ||
Spacer(minLength: 0) | ||
} | ||
.padding(.horizontal, Space.s4) | ||
.frame(minHeight: Size.row) | ||
.contentShape(Rectangle()) | ||
} | ||
.buttonStyle(.plain) | ||
} | ||
} | ||
} | ||
.scrollBounceBehavior(.basedOnSize) | ||
.frame(maxHeight: Size.row * CGFloat(found.count)) | ||
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: Radius.card)) | ||
.clipShape(RoundedRectangle(cornerRadius: Radius.card)) | ||
// It takes the room it asks for before the space above it does. | ||
.layoutPriority(1) | ||
} | ||
| ||
private static let terms: AttributedString = { | ||
| @@ -228,6 +288,13 @@ struct SignInForm: View { | ||
return (try? AttributedString(markdown: line)) ?? AttributedString(line) | ||
}() | ||
| ||
/// Fills it in and goes on, as the button would. | ||
private func pick(_ actor: FoundActor) { | ||
picked = actor.handle | ||
handle = actor.handle | ||
go() | ||
} | ||
| ||
private func go() { | ||
guard ready else { return } | ||
typing = false | ||
ios/Knotless/API/Client.swift+29−0
| @@ -29,6 +29,35 @@ actor Client { | ||
return URLSession(configuration: config) | ||
}() | ||
| ||
/// Who on the Atmosphere matches what's typed of a handle or name, best first, | ||
/// from the typeahead service the pages ask: none for under two letters, or | ||
/// when it can't say. It's told nothing but the text. | ||
func findActors(_ typed: String) async -> [FoundActor] { | ||
let text = typed.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: "^@", with: "", options: .regularExpression) | ||
guard text.count >= 2 else { return [] } | ||
struct Found: Decodable { var actors: [FoundActor]? } | ||
let data: Data | ||
#if DEBUG | ||
if let recorded = DevAccount.recording { | ||
guard let answer = try? recorded.answer("searchActors", ["q": text]) else { return [] } | ||
return (try? decoder.decode(Found.self, from: answer))?.actors ?? [] | ||
} | ||
#endif | ||
var parts = URLComponents(string: "https://typeahead.waow.tech/xrpc/tech.waow.typeahead.searchActors")! | ||
parts.queryItems = [URLQueryItem(name: "q", value: text), URLQueryItem(name: "limit", value: "6")] | ||
var request = URLRequest(url: parts.url!) | ||
request.timeoutInterval = 10 | ||
do { | ||
let (body, response) = try await session.data(for: request) | ||
guard (response as? HTTPURLResponse)?.statusCode == 200 else { return [] } | ||
data = body | ||
} catch { | ||
return [] | ||
} | ||
let actors = (try? decoder.decode(Found.self, from: data))?.actors ?? [] | ||
return actors.filter { !$0.handle.isEmpty && $0.handle != "handle.invalid" } | ||
} | ||
| ||
func query<T: Decodable & Sendable>(_ method: String, _ params: [String: String?] = [:]) async throws -> T { | ||
let nsid = "dev.knotless." + method | ||
#if DEBUG | ||
ios/look-answers.json+9−1
| @@ -33,5 +33,13 @@ | ||
"createdAt": "2026-10-18T09:00:00Z" | ||
} | ||
] | ||
} | ||
}, | ||
"searchActors": { "actors": [ | ||
{ "did": "did:plc:alice", "handle": "alice.test", "displayName": "Alice" }, | ||
{ "did": "did:plc:alicia", "handle": "alicia.bsky.social", "displayName": "Alicia Keane" }, | ||
{ "did": "did:plc:ali", "handle": "ali.example.com" }, | ||
{ "did": "did:plc:alistair", "handle": "alistair.test", "displayName": "Alistair" }, | ||
{ "did": "did:plc:alina", "handle": "alina.test", "displayName": "Alina" }, | ||
{ "did": "did:plc:alba", "handle": "alba.test" } | ||
] } | ||
} | ||