~ 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's sign-in suggests handles as they're typed

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:ios/sign-in-typeahead
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" }
] }
}