Files
hapi/ios/Hapi/Features/Chat/MessageDetailView.swift
T
weishu 7995334399 fix(ios): prevent long user messages from blocking chat
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.
2026-09-11 17:14:21 +08:00

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)
}
}