Watch: snooze finished threads, name the files a file approval would edit, Settle on finished notifications, an instant launch from the saved feed, plain Loading states, and no more cancelled errors
Gateway / check (push) Successful in 9s
Gateway / image (push) Successful in 22s

Gateway: an approval push for a file change says which files it would edit
This commit is contained in:
gib committed 2026-11-02 14:45:34 -05:00
1 parent 4dc4e57dac
commit edc4b88990
35 files changed
+1162 -144

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
// Snoozing a finished thread hides it from Recent until a time picked from four fixed choices, and
// the gateway brings it back on its own. A Recent row's swipe and the thread screen's Snooze button
// open the same picker. When any thread is snoozed, a dim violet row under Recent counts them and
// opens the Snoozed list, where each card says when it wakes and swipes to Unsnooze.
import SwiftUI
/// One of the picker's times, worked out in the watch's time zone.
struct SnoozeChoice: Hashable, Identifiable {
var title: String
var until: Date
var id: String { title }
/// In 1 hour, This evening at 6 PM (left out from 5 PM on), Tomorrow at 9 AM, and Next week,
/// which is next Monday at 9 AM.
static func choices(now: Date = .now, calendar: Calendar = .current) -> [SnoozeChoice] {
let today = calendar.startOfDay(for: now)
func at(_ hour: Int, daysAfter days: Int) -> Date? {
calendar.date(byAdding: .day, value: days, to: today)
.flatMap { calendar.date(bySettingHour: hour, minute: 0, second: 0, of: $0) }
}
var choices = [SnoozeChoice(title: "In 1 hour", until: now.addingTimeInterval(3600))]
if calendar.component(.hour, from: now) < 17, let evening = at(18, daysAfter: 0) {
choices.append(SnoozeChoice(title: "This evening · 6 PM", until: evening))
}
if let tomorrow = at(9, daysAfter: 1) {
choices.append(SnoozeChoice(title: "Tomorrow · 9 AM", until: tomorrow))
}
// Days until the next Monday, a full week when today is Monday. Weekday 2 is Monday.
let days = (9 - calendar.component(.weekday, from: now)) % 7
if let monday = at(9, daysAfter: days == 0 ? 7 : days) {
choices.append(SnoozeChoice(title: "Next week", until: monday))
}
return choices
}
}
extension Format {
/// When a snoozed thread comes back: "Wakes at 6 PM" today, "Wakes tomorrow 9 AM", "Wakes Monday
/// 9 AM" within the week, and "Wakes Nov 30, 9 AM" past that.
static func wakes(at date: Date, now: Date = .now, calendar: Calendar = .current) -> String {
let style = Date.FormatStyle(locale: Locale(identifier: "en_US_POSIX"), calendar: calendar, timeZone: calendar.timeZone)
// "6:00 PM" becomes "6 PM". Some formatters put a narrow space before PM.
let time = date.formatted(style.hour(.defaultDigits(amPM: .abbreviated)).minute())
.replacingOccurrences(of: ":00", with: "")
.replacingOccurrences(of: "\u{202F}", with: " ")
let days = calendar.dateComponents([.day], from: calendar.startOfDay(for: now), to: calendar.startOfDay(for: date)).day ?? 0
switch days {
case ...0: return "Wakes at \(time)"
case 1: return "Wakes tomorrow \(time)"
case 2 ..< 7:
return "Wakes \(date.formatted(style.weekday(.wide))) \(time)"
default:
let day = date.formatted(style.month(.abbreviated).day())
return "Wakes \(day), \(time)"
}
}
}
/// "Snooze until" and the four choices. Picking one snoozes the thread; on success the picker closes,
/// and on failure it stays and says why.
struct SnoozePicker: View {
@Environment(AppModel.self) private var model
var item: FeedItem
/// Fixed for snapshots. Live, the choices are worked out when the picker opens.
var now: Date = .now
/// Snapshots render the choices without their ScrollView, which ImageRenderer leaves blank.
var scrolls = true
@State private var sending = false
@State private var error: String?
var body: some View {
if scrolls {
ScrollView { choices }
.navigationTitle("Snooze until")
} else {
choices
}
}
private var choices: some View {
VStack(spacing: 6) {
ForEach(SnoozeChoice.choices(now: now)) { choice in
Button(choice.title) { snooze(until: choice.until) }
.buttonStyle(PillButtonStyle(fill: Theme.card, foreground: .white))
}
if let error {
Text(error)
.font(.system(size: 11))
.foregroundStyle(Theme.secondaryText)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8)
}
}
.disabled(sending)
.opacity(sending ? 0.5 : 1)
.padding(.horizontal, 4)
}
private func snooze(until: Date) {
sending = true
error = nil
Task {
do {
try await model.snooze(item, until: until)
} catch {
self.error = model.message(for: error)
}
sending = false
}
}
}
/// The dim violet row under Recent, "☾ 2 snoozed", opening the Snoozed list.
struct SnoozedRow: View {
@Environment(AppModel.self) private var model
var count: Int
var body: some View {
Button { model.path.append(.snoozed) } label: {
Card(edge: Theme.snooze) {
HStack(spacing: 5) {
Image(systemName: "moon.fill")
.font(.system(size: 11, weight: .bold))
Text("\(count) snoozed")
.font(.system(size: 13, weight: .semibold))
Spacer(minLength: 4)
Image(systemName: "chevron.right")
.font(.system(size: 11, weight: .bold))
}
.foregroundStyle(Theme.snooze)
}
.opacity(0.85)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityLabel("\(count) snoozed threads")
}
}
/// Every snoozed thread, the soonest to wake first. It loads `GET /v1/snoozed` when it appears;
/// nothing polls.
struct SnoozedView: 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 {
Group {
if scrolls {
List { rows }
.listStyle(.plain)
.refreshable { await model.loadSnoozed() }
.task { await model.loadSnoozed() }
} else {
VStack(spacing: 6) { rows }
.padding(.horizontal, 4)
}
}
.navigationTitle("Snoozed")
}
@ViewBuilder private var rows: some View {
if let items = model.snoozed {
ForEach(items) { item in
SnoozedCard(item: item).feedRow()
}
if items.isEmpty {
Text("Nothing snoozed.")
.font(.system(size: 13))
.foregroundStyle(Theme.secondaryText)
.padding(.horizontal, 8)
.feedRow()
}
}
if model.snoozed == nil || model.snoozedError != nil {
LoadStatus(loaded: model.snoozed != nil, error: model.snoozedError, retry: model.loadSnoozed)
.feedRow()
}
}
}
/// A snoozed thread: when it wakes in violet, its title, and where it runs. Opens the thread, and a
/// left swipe unsnoozes it.
private struct SnoozedCard: View {
@Environment(AppModel.self) private var model
var item: FeedItem
var body: some View {
Button { model.path.append(item.route) } label: {
Card(edge: Theme.snooze) {
if let until = item.snoozedUntil {
Text(Format.wakes(at: until))
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(Theme.snooze)
.lineLimit(1)
}
Text(item.title)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(Theme.secondaryText)
.lineLimit(2)
.padding(.top, 1)
Text("\(item.hostLabel) · \(item.projectTitle)")
.font(.system(size: 11))
.foregroundStyle(Theme.done)
.lineLimit(1)
.padding(.top, 2)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.swipeActions(edge: .trailing) {
Button { Task { await model.unsnooze(item) } } label: {
Label("Unsnooze", systemImage: "sun.max.fill")
}
.tint(Theme.snooze)
}
}
}
#Preview("Snoozed") {
NavigationStack {
SnoozedView()
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}
#Preview("Snooze until") {
NavigationStack {
SnoozePicker(item: DemoFixtures.previewRecent()[0])
}
.environment(AppModel.preview())
.fontDesign(.rounded)
}