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 { /// 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: DS.Space.sm8) { summaryRow if isExpanded { recentRows } } .padding(.horizontal, DS.Space.md12) .padding(.vertical, DS.Space.sm8) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: DS.Radius.md12)) .overlay( RoundedRectangle(cornerRadius: DS.Radius.md12) .strokeBorder(DS.Palette.hairline, lineWidth: DS.Stroke.hairline) ) .accessibilityElement(children: .contain) } private var summaryRow: some View { HStack(spacing: DS.Space.sm8) { Image(systemName: "clock.arrow.circlepath") .foregroundStyle(DS.Palette.accent) Text(Self.summary(for: digest)) .font(DS.Typography.callout.weight(.medium)) .foregroundStyle(DS.Palette.textPrimary) .lineLimit(1) Spacer(minLength: DS.Space.sm8) if !isExpanded { Button(Copy.expandTitle, systemImage: "chevron.down", action: onExpand) .labelStyle(.iconOnly) } Button(Copy.dismissTitle, systemImage: "xmark", action: onDismiss) .labelStyle(.iconOnly) } .buttonStyle(.borderless) .tint(DS.Palette.accent) .font(DS.Typography.callout) } /// 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: DS.Space.xs4) { ForEach( Array(digest.recent.suffix(Metrics.maxRecentRows).enumerated()), id: \.offset ) { _, event in recentRow(event) } } } private func recentRow(_ event: TimelineEvent) -> some View { HStack(spacing: DS.Space.sm8) { Text(Self.time(of: event)) .font(DS.Typography.metaMono) .foregroundStyle(DS.Palette.textSecondary) Text(event.label) .font(DS.Typography.caption) .foregroundStyle(DS.Palette.textPrimary) .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) } }