T-iOS-11: TerminalScreen/KeyBar/TerminalViewModel + KeyByteMap (byte-for-byte keybar.ts, arrows excluded from UIKeyCommand to preserve DECCKM) T-iOS-12: PairingScreen/VM — confirm-before-network (zero-call assertions), §5.4 four-tier warnings, Host construction per contract ruling T-iOS-13: SessionListScreen/VM — Tunables-paced polling with leak-free teardown, optimistic kill+rollback, pending via overlay (LiveSessionInfo has no pending field) T-iOS-14: GateBanner/PlanGateSheet/AwayDigestView/GateViewModel — three-way mapping from SessionCore Affordance single source, tap-epoch guard, per-epoch haptics T-iOS-16: IntegrationTests vs real Node server (10 tests: origin guards, mirror, kill-close vs exit-frame differential, 16MiB+ESC/C0 replay) + ios.yml own-sources coverage gate (red-once demoed) T-iOS-17: ios/README.md ntfy chapter (read-only verification, file:line cites) Verified: 224 unit + 10 integration tests green; 5/5 semantic spot-checks; zero Owns violations
112 lines
4.2 KiB
Swift
112 lines
4.2 KiB
Swift
import SessionCore
|
|
import SwiftUI
|
|
import WireProtocol
|
|
|
|
/// T-iOS-14 · "What happened while I was away" banner rendered above the
|
|
/// terminal after a reconnect. Render-only: `GateViewModel` owns the state —
|
|
/// an ALL-ZERO digest never reaches this view (the VM keeps `digest` nil),
|
|
/// the collapsed row auto-fades after `Tunables.digestFadeDelay`, and manual
|
|
/// expansion (which cancels the fade) reveals the recent entries.
|
|
struct AwayDigestView: View {
|
|
let digest: AwayDigest
|
|
let isExpanded: Bool
|
|
let onExpand: () -> Void
|
|
let onDismiss: () -> Void
|
|
|
|
private enum Metrics {
|
|
static let spacing: CGFloat = 8
|
|
static let rowSpacing: CGFloat = 4
|
|
static let horizontalPadding: CGFloat = 14
|
|
static let verticalPadding: CGFloat = 10
|
|
static let cornerRadius: CGFloat = 12
|
|
/// Recent entries shown when expanded (newest kept — the engine's
|
|
/// digest is uncapped, the banner must not be).
|
|
static let maxRecentRows = 12
|
|
}
|
|
|
|
private enum Copy {
|
|
static let prefix = "离开期间:"
|
|
static let separator = " · "
|
|
static let expandTitle = "展开明细"
|
|
static let dismissTitle = "关闭摘要"
|
|
}
|
|
|
|
/// Summary line: non-zero parts only, joined web-statusline style. A
|
|
/// non-empty digest whose counted signals are all zero (e.g. only `user`
|
|
/// events) falls back to a plain activity count so the row is never blank.
|
|
static func summary(for digest: AwayDigest) -> String {
|
|
let parts = [
|
|
digest.toolRuns > 0 ? "工具调用 \(digest.toolRuns) 次" : nil,
|
|
digest.waitingCount > 0 ? "等待审批 \(digest.waitingCount) 次" : nil,
|
|
digest.sawDone ? "已完成" : nil,
|
|
digest.sawStuck ? "曾卡住" : nil,
|
|
].compactMap { $0 }
|
|
guard !parts.isEmpty else {
|
|
return "\(Copy.prefix)活动 \(digest.recent.count) 条"
|
|
}
|
|
return Copy.prefix + parts.joined(separator: Copy.separator)
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: Metrics.spacing) {
|
|
summaryRow
|
|
if isExpanded {
|
|
recentRows
|
|
}
|
|
}
|
|
.padding(.horizontal, Metrics.horizontalPadding)
|
|
.padding(.vertical, Metrics.verticalPadding)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: Metrics.cornerRadius))
|
|
.accessibilityElement(children: .contain)
|
|
}
|
|
|
|
private var summaryRow: some View {
|
|
HStack(spacing: Metrics.spacing) {
|
|
Image(systemName: "clock.arrow.circlepath")
|
|
.foregroundStyle(.secondary)
|
|
Text(Self.summary(for: digest))
|
|
.font(.footnote.weight(.medium))
|
|
.lineLimit(1)
|
|
Spacer(minLength: Metrics.spacing)
|
|
if !isExpanded {
|
|
Button(Copy.expandTitle, systemImage: "chevron.down", action: onExpand)
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
Button(Copy.dismissTitle, systemImage: "xmark", action: onDismiss)
|
|
.labelStyle(.iconOnly)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.font(.footnote)
|
|
}
|
|
|
|
/// Newest `maxRecentRows` entries, oldest→newest. Server text (`label`) is
|
|
/// untrusted display input — rendered as plain Text only.
|
|
private var recentRows: some View {
|
|
VStack(alignment: .leading, spacing: Metrics.rowSpacing) {
|
|
ForEach(
|
|
Array(digest.recent.suffix(Metrics.maxRecentRows).enumerated()),
|
|
id: \.offset
|
|
) { _, event in
|
|
recentRow(event)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func recentRow(_ event: TimelineEvent) -> some View {
|
|
HStack(spacing: Metrics.spacing) {
|
|
Text(Self.time(of: event))
|
|
.font(.caption2.monospacedDigit())
|
|
.foregroundStyle(.secondary)
|
|
Text(event.label)
|
|
.font(.caption)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
|
|
private static func time(of event: TimelineEvent) -> String {
|
|
let millisecondsPerSecond = 1_000.0
|
|
let date = Date(timeIntervalSince1970: Double(event.at) / millisecondsPerSecond)
|
|
return date.formatted(date: .omitted, time: .shortened)
|
|
}
|
|
}
|