~ 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 lists followers and following, and a follow updates the counts

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:ios/follows
ios/Knotless/API/Account.swift+3−0
@@ -61,6 +61,9 @@ final class Account {
private(set) var person: Person?
private(set) var checking = true
var failure: String?
/// Moved by following or unfollowing, so every profile is read again: the
/// one followed, for its followers, and your own, for who you follow.
var follows = 0
private var web: ASWebAuthenticationSession?
private let anchor = BrowserAnchor()
ios/Knotless/API/Models.swift+6−0
@@ -7,6 +7,12 @@ struct Person: Decodable, Hashable, Identifiable {
var id: String { did }
}
/// A page of accounts: who follows someone, or whom they follow.
struct ActorPage: Decodable, Sendable {
let actors: [Person]
var cursor: String?
}
/// A repository as the feed and notifications name it.
struct RepoRef: Decodable, Hashable {
let did: String
ios/Knotless/Views/ProfileView.swift+42−2
@@ -25,6 +25,7 @@ struct OwnProfileView: View {
}
struct ProfileScreen: View {
@Environment(Account.self) private var account
let actor: String
private struct Answer: Sendable { let profile: Profile; let repos: [Repo] }
@@ -34,7 +35,7 @@ struct ProfileScreen: View {
async let profile: Profile = Client.shared.query("getProfile", ["actor": actor])
async let repos: RepoList = Client.shared.query("getActorRepos", ["actor": actor])
return try await Answer(profile: profile, repos: repos.repos)
}) { answer in
}, refresh: account.follows) { answer in
ProfilePage(profile: answer.profile, repos: answer.repos)
}
.navigationTitle(actor.hasPrefix("did:") ? "Profile" : actor)
@@ -64,7 +65,9 @@ private struct ProfilePage: View {
if let link = p.links?.first { LinkLine(link: link) }
HStack(spacing: Space.s1_5) {
Oct(name: "person").foregroundStyle(.secondary)
Text("\(Text("\(p.followersCount ?? 0)").fontWeight(.semibold)) \(Text((p.followersCount ?? 0) == 1 ? "follower" : "followers").foregroundStyle(.secondary)) · \(Text("\(p.followsCount ?? 0)").fontWeight(.semibold)) \(Text("following").foregroundStyle(.secondary))")
count(.followers(p.handle), p.followersCount ?? 0, (p.followersCount ?? 0) == 1 ? "follower" : "followers")
Text("·").foregroundStyle(.secondary)
count(.following(p.handle), p.followsCount ?? 0, "following")
}
.font(.subheadline)
if account.signedIn, account.person?.did != p.did {
@@ -115,9 +118,45 @@ private struct ProfilePage: View {
}
.background(Color(.systemGroupedBackground))
}
private func count(_ route: Route, _ number: Int, _ word: String) -> some View {
NavigationLink(value: route) {
Text("\(Text("\(number)").fontWeight(.semibold)) \(Text(word).foregroundStyle(.secondary))")
}
.buttonStyle(.plain)
}
}
/// Who follows an account, or whom it follows.
struct FollowsView: View {
let actor: String
let followers: Bool
var body: some View {
PagedList<Person, ActorRow>(empty: followers ? "No followers yet." : "Not following anyone yet.", fetch: { [actor, followers] cursor in
let answer: ActorPage = try await Client.shared.query(followers ? "getFollowers" : "getFollows", ["actor": actor, "cursor": cursor])
return .init(items: answer.actors, cursor: answer.cursor)
}) { ActorRow(person: $0) }
.navigationTitle(followers ? "Followers" : "Following")
.navigationBarTitleDisplayMode(.inline)
}
}
private struct ActorRow: View {
let person: Person
var body: some View {
NavigationLink(value: Route.profile(person.handle)) {
HStack(spacing: Space.s3) {
Avatar(url: person.avatar, name: person.handle, did: person.did, size: Size.Avatar.medium)
Text(person.handle).lineLimit(1)
}
}
}
}
private struct FollowButton: View {
@Environment(Account.self) private var account
let did: String
@State var following: Bool
@State private var busy = false
@@ -148,6 +187,7 @@ private struct FollowButton: View {
do {
try await Client.shared.send(Writes.follow(did: did, undo: following))
following.toggle()
account.follows += 1
} catch {
failure = error.localizedDescription
}
ios/Knotless/App.swift+6−0
@@ -27,6 +27,8 @@ enum Route: Hashable {
case mine(kind: String)
case starred(String)
case organizations(String)
case followers(String)
case following(String)
case search
case searchCode(String)
case searchRepositories(String)
@@ -53,6 +55,8 @@ extension View {
case .repositories(let actor): RepositoriesView(actor: actor)
case .mine(let kind): MyItemsView(kind: kind)
case .starred(let actor): StarredView(actor: actor)
case .followers(let actor): FollowsView(actor: actor, followers: true)
case .following(let actor): FollowsView(actor: actor, followers: false)
case .organizations(let actor): OrganizationsView(actor: actor)
case .search: SearchView()
case .searchCode(let query): CodeResultsView(query: query)
@@ -105,6 +109,8 @@ enum DevRoute {
if p.count == 2, p[0] == "mine" { return (.home, [.mine(kind: p[1])]) }
if p.count == 2, p[0] == "starred" { return (.home, [.starred(p[1])]) }
if p.count == 2, p[0] == "organizations" { return (.home, [.organizations(p[1])]) }
if p.count == 2, p[0] == "followers" { return (.home, [.followers(p[1])]) }
if p.count == 2, p[0] == "following" { return (.home, [.following(p[1])]) }
if p.count == 2, p[0] == "search-code" { return (.home, [.searchCode(p[1])]) }
if p.count == 2, p[0] == "search-repositories" { return (.home, [.searchRepositories(p[1])]) }
if p.count == 2, p[0] == "search-people" { return (.home, [.searchPeople(p[1])]) }
ios/look-answers.json+2−0
@@ -1,6 +1,8 @@
{
"getViewer": {"login": true, "actor": {"did": "did:plc:abe24xewga4qdbzjcxmuzdt3", "handle": "alice.test"}},
"getActorStars": {"repos": []},
"getFollowers": {"actors": [{"did": "did:plc:abe24xewga4qdbzjcxmuzdt3", "handle": "alice.test"}]},
"getFollows": {"actors": [{"did": "did:plc:abe24xewga4qdbzjcxmuzdt3", "handle": "alice.test"}]},
"listActorItems": {"items": [{"item": {"uri": "at://did:plc:abe24xewga4qdbzjcxmuzdt3/dev.knotless.issue/3mz1", "kind": "issue", "title": "Fails on files that are not UTF-8", "state": "open", "author": {"did": "did:plc:abe24xewga4qdbzjcxmuzdt3", "handle": "alice.test"}, "createdAt": "2026-10-20T09:00:00Z", "comments": 2}, "repo": {"did": "did:plc:abe24xewga4qdbzjcxmuzdt3", "handle": "alice.test", "name": "tally"}}, {"item": {"uri": "at://did:plc:abe24xewga4qdbzjcxmuzdt3/dev.knotless.pull/3mz2", "kind": "pull", "title": "Print counts as JSON with --json", "state": "open", "author": {"did": "did:plc:abe24xewga4qdbzjcxmuzdt3", "handle": "alice.test"}, "createdAt": "2026-10-20T09:00:00Z", "comments": 0}, "repo": {"did": "did:plc:abe24xewga4qdbzjcxmuzdt3", "handle": "alice.test", "name": "tally"}}]},
"getMergeStatus": {
"state": "clean",
ios/look.mjs+3−1
@@ -135,6 +135,8 @@ const screens = {
'my-pulls': 'mine|pull',
starred: 'starred|alice.test',
organizations: 'organizations|alice.test',
followers: 'followers|alice.test',
following: 'following|alice.test',
search: 'search',
'search-code': 'search-code|count',
'search-repositories': 'search-repositories|count',
@@ -189,7 +191,7 @@ if (Lexicons) {
const dir = join(root, 'lexicons/dev/knotless')
const docs = readdirSync(dir, { recursive: true }).filter((f) => f.endsWith('.json')).map((f) => JSON.parse(readFileSync(join(dir, f), 'utf8')))
const lexicons = new Lexicons(docs)
const held = new Set(['getViewer', 'getActorStars', 'getMergeStatus', 'listNotifications', 'getPullDiff', ...sets.map((s) => s.method)])
const held = new Set(['getViewer', 'getActorStars', 'getFollowers', 'getFollows', 'getMergeStatus', 'listNotifications', 'getPullDiff', ...sets.map((s) => s.method)])
for (const answer of answers) {
if (!held.has(answer.method) || answer.status !== 200) continue
try {