chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
The iOS app lists followers and following, and a follow updates the counts
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 { | ||