389 lines
14 KiB
Swift
389 lines
14 KiB
Swift
// 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<String> {
|
|
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<Trailing: View>: 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<Header: View, Rows: View>: 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)
|
|
}
|