johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
import SwiftUI
@main
struct KnotlessApp: App {
@UIApplicationDelegateAdaptor(PushDelegate.self) private var pushDelegate
@Environment(\.scenePhase) private var phase
@State private var account = Account()
@AppStorage(Appearance.key) private var appearance = Appearance.account
@State private var update = AppUpdate()
private let accountTheme: AccountTheme
init() {
// Before anything reads them.
DevAccount.freshen()
accountTheme = AccountTheme.shared
}
private var theme: ThemeChoice? { DevAccount.theme ?? appearance.choice(account: accountTheme.choice, themes: accountTheme.themes) }
var body: some Scene {
// Before anything is drawn, so the first colours are the theme's.
let _ = Themes.set(theme)
WindowGroup {
Group {
if update.required {
UpdateScreen(reason: update.reason)
} else {
RootView()
}
}
.tint(.accent)
.foregroundStyle(Color.fg)
.environment(account)
.environment(\.themeGeneration, Themes.generation)
.preferredColorScheme(theme?.scheme ?? DevAccount.scheme)
// Lists show the theme's page rather than iOS's grey.
.scrollContentBackground(theme == nil ? .automatic : .hidden)
.task { await account.restore() }
.task(id: phase) {
guard phase == .active else { return }
await update.check()
await account.refresh()
}
.task(id: "\(phase == .active) \(account.person?.did ?? "")") {
guard phase == .active else { return }
if account.person == nil {
if !account.checking { accountTheme.hold(nil) }
} else if let did = account.person?.did {
await accountTheme.load(as: did)
}
}
}
}
}
enum Route: Hashable {
case repo(RepoRef)
case items(RepoRef, kind: String)
case thread(RepoRef, uri: String)
case files(RepoRef, uri: String)
case commits(RepoRef, uri: String)
case tree(RepoRef, ref: String?, path: String)
case file(RepoRef, ref: String?, path: String)
case profile(String)
case history(RepoRef, ref: String?)
case commit(RepoRef, sha: String)
case releases(RepoRef)
case release(Release)
case runs(RepoRef)
/// One workflow's runs, or all of them.
case workflowRuns(RepoRef, workflow: String?)
case run(RepoRef, uri: String)
/// A run's step's log, or all of its log past its steps.
case step(RepoRef, uri: String, index: Int)
case repositories(String)
case mine(kind: String)
case starred(String)
case organizations(String)
case followers(String)
case following(String)
case search
case searchCode(String)
case searchRepositories(String)
case searchPeople(String)
case blocks
case settings
case appearance
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 {
// The theme's page behind every screen, which iOS's own draws where there's none.
background(Color.page).navigationDestination(for: Route.self) { route in
Group {
switch route {
case .repo(let repo): RepoView(repo: repo)
case .items(let repo, let kind): ItemsView(repo: repo, kind: kind)
case .thread(let repo, let uri): ThreadView(repo: repo, uri: uri)
case .files(let repo, let uri): FilesView(repo: repo, uri: uri)
case .commits(let repo, let uri): CommitsView(repo: repo, uri: uri)
case .tree(let repo, let ref, let path): TreeView(repo: repo, ref: ref, path: path)
case .file(let repo, let ref, let path): FileView(repo: repo, ref: ref, path: path)
case .profile(let actor): ProfileScreen(actor: actor)
case .history(let repo, let ref): HistoryView(repo: repo, ref: ref)
case .commit(let repo, let sha): CommitView(repo: repo, sha: sha)
case .releases(let repo): ReleasesView(repo: repo)
case .release(let release): ReleaseView(release: release)
case .runs(let repo): ChecksView(repo: repo)
case .workflowRuns(let repo, let workflow): WorkflowRunsView(repo: repo, workflow: workflow)
case .run(let repo, let uri): RunView(repo: repo, uri: uri)
case .step(let repo, let uri, let index): StepLogView(repo: repo, uri: uri, index: index)
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)
case .searchRepositories(let query): RepositoryResultsView(query: query)
case .searchPeople(let query): PeopleResultsView(query: query)
case .blocks: BlocksView()
case .settings: SettingsView()
case .appearance: AppearanceView()
case .notificationSettings: NotificationSettingsView()
}
}
.background(Color.page)
}
}
}
struct RootView: View {
enum Page: String { case home, inbox, explore, profile }
@State private var page: Page
@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
init() {
let start = DevRoute.parse(DevAccount.route)
_page = State(initialValue: start.page)
_homePath = State(initialValue: start.path)
}
var body: some View {
// Above the tabs rather than a safe area inset, which a TabView's
// screens draw under, hiding their toolbars.
VStack(spacing: 0) {
if account.signedIn, account.reauth { ReauthNote() }
tabs
}
.task(id: account.person?.did) {
if account.person != nil { await pushes.start() }
}
// Signed out from a screen of the account's, as Settings: nothing of it stays open.
.onChange(of: account.person?.did) { _, did in
guard did == nil else { return }
homePath = []
inboxPath = []
explorePath = []
profilePath = []
}
.task(id: pushes.opened) {
guard let target = pushes.opened else { return }
pushes.opened = nil
page = .inbox
inboxPath = target.route.map { [$0] } ?? []
}
}
private var tabs: some 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(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) })
}
}
}
}
/// Opens a screen at launch, for `ios/run.sh`: `inbox`, or
/// `thread|<did>|<handle>|<name>|<uri>`, `files|…`, `repo|<did>|<handle>|<name>`, `tree|…|<path>`.
enum DevRoute {
static func parse(_ text: String?) -> (page: RootView.Page, path: [Route]) {
guard let text else { return (.home, []) }
if let page = RootView.Page(rawValue: text) { return (page, []) }
let p = text.split(separator: "|").map(String.init)
if p == ["search"] { return (.home, [.search]) }
if p.count == 2, p[0] == "repositories" { return (.home, [.repositories(p[1])]) }
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 == ["blocks"] { return (.home, [.blocks]) }
if p == ["settings"] { return (.home, [.settings]) }
if p == ["appearance"] { return (.home, [.settings, .appearance]) }
if p == ["notification-settings"] { return (.home, [.settings, .notificationSettings]) }
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])]) }
if p.count == 2, p[0] == "user" { return (.home, [.profile(p[1])]) }
guard p.count >= 4 else { return (.home, []) }
let repo = RepoRef(did: p[1], handle: p[2], name: p[3])
switch p[0] {
case "repo": return (.home, [.repo(repo)])
case "history": return (.home, [.history(repo, ref: nil)])
case "commit" where p.count == 5: return (.home, [.commit(repo, sha: p[4])])
case "releases": return (.home, [.releases(repo)])
case "runs": return (.home, [.runs(repo)])
case "workflow-runs" where p.count == 5: return (.home, [.runs(repo), .workflowRuns(repo, workflow: p[4])])
case "run" where p.count == 5: return (.home, [.runs(repo), .run(repo, uri: p[4])])
case "step" where p.count == 6: return (.home, [.runs(repo), .run(repo, uri: p[4]), .step(repo, uri: p[4], index: Int(p[5]) ?? 0)])
case "tree" where p.count == 5: return (.home, [.tree(repo, ref: nil, path: p[4])])
case "thread" where p.count == 5: return (.home, [.thread(repo, uri: p[4])])
case "files" where p.count == 5: return (.home, [.files(repo, uri: p[4])])
case "commits" where p.count == 5: return (.home, [.commits(repo, uri: p[4])])
case "items" where p.count == 5: return (.home, [.items(repo, kind: p[4])])
case "file" where p.count == 5: return (.home, [.file(repo, ref: nil, path: p[4])])
default: return (.home, [])
}
}
}