mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
Keep normal multi-screen prompts inline and bound oversized messages to a preview. Add a screen-owned paged reader with exact full-text copy and preserved reading state, plus threshold, Unicode, layout, and presentation tests.
112 lines
4.2 KiB
Swift
112 lines
4.2 KiB
Swift
import HapiProtocol
|
|
import HapiUI
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
private struct OpenMessageKey: EnvironmentKey {
|
|
static let defaultValue: ((UserTextBlock) -> Void)? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var openChatMessage: ((UserTextBlock) -> Void)? {
|
|
get { self[OpenMessageKey.self] }
|
|
set { self[OpenMessageKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
private struct MessageSelection: Identifiable {
|
|
let id: String
|
|
let text: String
|
|
}
|
|
|
|
/// A screen owns the sheet, never a recycled bubble. Retain the original
|
|
/// text as a read-only snapshot even if the message window trims that row.
|
|
struct MessagePresentationHost: ViewModifier {
|
|
let model: ChatModel
|
|
let owner: String
|
|
@State private var selection: MessageSelection?
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.environment(\.openChatMessage, { block in
|
|
model.beginContentInspection()
|
|
model.retainSurface("message:\(owner)")
|
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
selection = MessageSelection(id: block.id, text: block.text)
|
|
})
|
|
.sheet(item: $selection, onDismiss: {
|
|
model.releaseSurface("message:\(owner)")
|
|
}) { message in
|
|
MessageDetailView(text: message.text).id(message.id)
|
|
}
|
|
.onChange(of: model.toolInspection.invalidation) {
|
|
selection = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MessageDetailView: View {
|
|
@State private var pager: MessageTextPager
|
|
@Environment(\.dismiss) private var dismiss
|
|
@Environment(\.hapiPasteboard) private var pasteboard
|
|
@Environment(\.hapiTypography) private var typography
|
|
|
|
init(text: String) {
|
|
_pager = State(initialValue: MessageTextPager(source: text))
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
// Plain text, like the user bubble. No markdown parsing,
|
|
// highlighting or single infinitely tall Text layout.
|
|
Text(verbatim: pager.page.text)
|
|
.font(typography.bodyFont)
|
|
.lineSpacing(typography.bodyLineSpacing)
|
|
.textSelection(.enabled)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.hapiReadingColumn()
|
|
.padding(.vertical, 16)
|
|
.accessibilityIdentifier("message-detail-text")
|
|
}
|
|
.id(pager.number)
|
|
.navigationTitle("Full message")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .cancellationAction) {
|
|
Button("Close", systemImage: "xmark") { dismiss() }
|
|
}
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button("Copy full content", systemImage: "doc.on.doc") { pasteboard.copy(pager.source) }
|
|
.accessibilityIdentifier("message-copy-full")
|
|
}
|
|
}
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
HStack {
|
|
Button { pager.previous() } label: {
|
|
Label("Previous part", systemImage: "chevron.left")
|
|
.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44)
|
|
}
|
|
.disabled(!pager.hasPrevious)
|
|
.accessibilityIdentifier("message-previous")
|
|
Spacer()
|
|
Text(String(format: String(localized: "Part %lld"), Int64(pager.number)))
|
|
.font(.footnote.monospacedDigit())
|
|
Spacer()
|
|
Button { pager.next() } label: {
|
|
Label("Next part", systemImage: "chevron.right")
|
|
.labelStyle(.iconOnly).frame(minWidth: 44, minHeight: 44)
|
|
}
|
|
.disabled(!pager.hasNext)
|
|
.accessibilityIdentifier("message-next")
|
|
}
|
|
.hapiReadingColumn()
|
|
.background(.bar)
|
|
}
|
|
}
|
|
.hapiTypography()
|
|
.presentationDetents([.large])
|
|
.presentationDragIndicator(.visible)
|
|
}
|
|
}
|