// All threads, opened from the last row of the feed: a search field over the hosts, then a host's // projects sorted by title, then a project's threads with settled ones included. Hosts and projects // come from `GET /v1/projects`, threads from `GET /v1/projects/:hostId/:projectId/threads`, and // search results from `GET /v1/search`. Each screen loads when it appears; nothing polls. A thread or // a search hit opens the usual thread screen. import SwiftUI /// A search field, then every host, offline ones dimmed. Each host opens its projects. struct HostsView: View { @Environment(AppModel.self) private var model /// Snapshots render the rows without their List, which ImageRenderer leaves blank. var scrolls = true var body: some View { BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) { SearchField() .feedRow() } rows: { if let hosts = model.projects?.hosts { ForEach(hosts) { host in BrowseRow( title: host.label, subtitle: host.status.problem ?? (host.projects.count == 1 ? "1 project" : "\(host.projects.count) projects"), route: .hostProjects(hostId: host.id)) .opacity(host.status == .online ? 1 : 0.5) .feedRow() } } } .navigationTitle("All threads") } } /// One host's projects, sorted by title. Each opens its threads. struct ProjectsView: View { @Environment(AppModel.self) private var model let hostId: String var scrolls = true var body: some View { let host = model.projects?.hosts.first { $0.id == hostId } BrowseList(scrolls: scrolls, error: model.projectsError, loaded: model.projects != nil, reload: model.loadProjects) { if let host { let projects = host.projects.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending } ForEach(projects) { project in BrowseRow(title: project.title, route: .projectThreads(hostId: host.id, projectId: project.id)) .feedRow() } if projects.isEmpty { BrowseNote(text: "No projects.") } } else if model.projects != nil { BrowseNote(text: "This host is gone.") } } .navigationTitle(host?.label ?? "Projects") } } /// One project's threads, newest first: a status dot, the title, dimmed once finished or settled, /// and the age. struct ProjectThreadsView: View { @Environment(AppModel.self) private var model let hostId: String let projectId: String @State private var rows: [ThreadRow]? @State private var error: String? private let scrolls: Bool /// `rows` starts the screen loaded, for snapshots. init(hostId: String, projectId: String, rows: [ThreadRow]? = nil, scrolls: Bool = true) { self.hostId = hostId self.projectId = projectId self.scrolls = scrolls _rows = State(initialValue: rows) } var body: some View { let title = model.projects?.hosts.first { $0.id == hostId }?.projects.first { $0.id == projectId }?.title BrowseList(scrolls: scrolls, error: error, loaded: rows != nil, reload: load) { if let rows { ForEach(rows) { row in ThreadLine(row: row) { Text(Format.elapsed(since: row.updatedAt)) .foregroundStyle(Theme.secondaryText) } .feedRow() } if rows.isEmpty { BrowseNote(text: "No threads yet.") } } } .navigationTitle(title ?? "Threads") .task { // The title comes from the projects list, which a wristcode:// link may skip. if model.projects == nil { await model.loadProjects() } } } private func load() async { do { rows = try await model.api.projectThreads(hostId: hostId, projectId: projectId).items error = nil } catch { if let message = model.message(for: error) { self.error = message } } } } /// Threads on every host matching `query`: each hit's host and project, its title, and the words /// around the match when it was inside a message. Says which hosts were left out. struct SearchView: View { @Environment(AppModel.self) private var model let query: String @State private var response: SearchResponse? @State private var error: String? private let scrolls: Bool /// `response` starts the screen loaded, for snapshots. init(query: String, response: SearchResponse? = nil, scrolls: Bool = true) { self.query = query self.scrolls = scrolls _response = State(initialValue: response) } var body: some View { BrowseList(scrolls: scrolls, error: error, loaded: response != nil, reload: load) { if let response { ForEach(response.hits) { hit in SearchHitCard(hit: hit, query: query) .feedRow() } if response.hits.isEmpty { Text("No threads match.") .font(.system(size: 13)) .foregroundStyle(Theme.secondaryText) .frame(maxWidth: .infinity) .padding(.top, 16) .feedRow() } if let note = SearchResponse.unsearchedNote(response.unsearched, offline: offlineHosts) { Text(note) .font(.system(size: 11)) .foregroundStyle(Theme.secondaryText) .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(.horizontal, 8) .feedRow() } } } .navigationTitle("\u{201C}\(query)\u{201D}") } /// Labels of hosts the feed or All threads last saw offline. private var offlineHosts: Set { let feed = model.feed?.hosts.filter { $0.status != .online }.map(\.label) ?? [] let browsed = model.projects?.hosts.filter { $0.status != .online }.map(\.label) ?? [] return Set(feed + browsed) } private func load() async { do { response = try await model.api.search(query) error = nil } catch { if let message = model.message(for: error) { self.error = message } } } } /// The field at the top of All threads. Tapping it opens dictation or the keyboard, as any watchOS /// text field does, and submitting opens the results. private struct SearchField: View { @Environment(AppModel.self) private var model var body: some View { TextFieldLink(prompt: Text("Search threads")) { HStack(spacing: 7) { Image(systemName: "magnifyingglass") .foregroundStyle(Theme.secondaryText) Text("Search") .foregroundStyle(Theme.secondaryText) Spacer(minLength: 4) Image(systemName: "mic.fill") .foregroundStyle(Theme.work) } .font(.system(size: 14, weight: .semibold)) .padding(.vertical, 9) .padding(.horizontal, 12) .background(Theme.control.opacity(0.7), in: RoundedRectangle(cornerRadius: 18, style: .continuous)) .contentShape(Rectangle()) } onSubmit: { text in let query = text.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { return } model.path.append(.search(query: query)) } .buttonStyle(.plain) .accessibilityLabel("Search threads") } } /// A search hit: "host · project", the title, dimmed once finished or settled, and the snippet with /// the matched words picked out. Opens the thread. private struct SearchHitCard: View { @Environment(AppModel.self) private var model var hit: SearchHit var query: String var body: some View { Button { model.path.append(hit.thread.route) } label: { Card(edge: Theme.card) { Text("\(hit.hostLabel) · \(hit.projectTitle)") .font(.system(size: 11, weight: .semibold)) .foregroundStyle(Theme.secondaryText) .lineLimit(1) Text(hit.thread.title) .font(.system(size: 14, weight: .semibold)) .foregroundStyle(hit.thread.isActive ? Color.white : Theme.secondaryText) .lineLimit(2) .padding(.top, 1) if let snippet = hit.snippet { Text(highlighted(snippet)) .font(.system(size: 12)) .foregroundStyle(Theme.secondaryText) .lineLimit(3) .padding(.top, 3) } } .contentShape(Rectangle()) } .buttonStyle(.plain) } /// The snippet with each match of the query bold and white. private func highlighted(_ snippet: String) -> AttributedString { var text = AttributedString(snippet) var searchFrom = text.startIndex while let range = text[searchFrom...].range(of: query, options: .caseInsensitive) { text[range].inlinePresentationIntent = .stronglyEmphasized text[range].foregroundColor = .white searchFrom = range.upperBound } return text } } /// A thread on one line: status dot, title, and `trailing` on the right. Opens the thread. struct ThreadLine: View { @Environment(AppModel.self) private var model var row: ThreadRow @ViewBuilder var trailing: Trailing var body: some View { Button { model.path.append(row.route) } label: { Card(edge: Theme.card) { HStack(spacing: 7) { Circle() .fill(row.color) .frame(width: 7, height: 7) Text(row.title) .font(.system(size: 13, weight: .semibold)) .foregroundStyle(row.isActive ? Color.white : Theme.secondaryText) .lineLimit(1) Spacer(minLength: 4) trailing .font(.system(size: 11, weight: .semibold)) .lineLimit(1) } } .contentShape(Rectangle()) } .buttonStyle(.plain) } } /// The List each browse screen scrolls, with Loading, an error and Try again. Loads on appear. /// `header` shows above the rows even while they load. Once rows show, a failed reload only adds a /// dim line under them. private struct BrowseList: View { var scrolls: Bool var error: String? var loaded: Bool var reload: () async -> Void @ViewBuilder var header: Header @ViewBuilder var rows: Rows var body: some View { if scrolls { List { content } .listStyle(.plain) .refreshable { await reload() } .task { await reload() } } else { VStack(spacing: 6) { content } .padding(.horizontal, 4) } } @ViewBuilder private var content: some View { header if loaded { rows } if !loaded || error != nil { LoadStatus(loaded: loaded, error: error, retry: reload) .feedRow() } } } extension BrowseList where Header == EmptyView { init(scrolls: Bool, error: String?, loaded: Bool, reload: @escaping () async -> Void, @ViewBuilder rows: () -> Rows) { self.init(scrolls: scrolls, error: error, loaded: loaded, reload: reload, header: { EmptyView() }, rows: rows) } } /// A plain card with a title, an optional line under it, and a chevron. private struct BrowseRow: View { @Environment(AppModel.self) private var model var title: String var subtitle: String? var route: Route var body: some View { Button { model.path.append(route) } label: { Card(edge: Theme.done) { HStack { VStack(alignment: .leading, spacing: 1) { Text(title) .font(.system(size: 14, weight: .semibold)) .lineLimit(2) if let subtitle { Text(subtitle) .font(.system(size: 11)) .foregroundStyle(Theme.secondaryText) .lineLimit(1) } } Spacer(minLength: 4) Image(systemName: "chevron.right") .font(.system(size: 11, weight: .bold)) .foregroundStyle(Theme.secondaryText) } } .contentShape(Rectangle()) } .buttonStyle(.plain) } } private struct BrowseNote: View { var text: String var body: some View { Text(text) .font(.system(size: 12)) .foregroundStyle(Theme.secondaryText) .padding(.horizontal, 8) .feedRow() } } #Preview("Hosts") { NavigationStack { HostsView() } .environment(AppModel.preview()) .fontDesign(.rounded) } #Preview("Search") { NavigationStack { SearchView(query: "containerfile") } .environment(AppModel.preview()) .fontDesign(.rounded) } #Preview("Threads") { NavigationStack { ProjectThreadsView(hostId: "mac-mini", projectId: "p-wc-mac") } .environment(AppModel.preview()) .fontDesign(.rounded) }