Models: - Convert Conversation and ChatMessage to SwiftData @Model classes with a one-to-many relationship and cascade delete. - Rename ChatMessage.id to clientID and drop Conversation.id in favor of jid (the string id would clash with PersistentIdentifier). - Keep MessageReaction as a Codable value stored in an array attribute. Persistence: - Add ArchiveStore, a per-account ModelContainer/ModelContext that replaces the JSON ChatArchive with load/save/erase. - Add ArchiveMetadataRecord for the durable MAM checkpoints, cursor, locally deleted message IDs and roster contact JIDs. - Add LegacyArchiveImporter to import the old JSON snapshot once and delete the file afterwards, preserving existing history. AppModel: - Replace ChatArchive with ArchiveStore and persist via context.save(); insert new models and delete the replaced object on upsert merges so SwiftData never keeps an orphaned duplicate. Tests: - Replace ChatArchiveTests with ArchiveStoreTests (store round-trip and legacy import) and drop the now-obsolete Codable round-trip assertions.
348 lines
12 KiB
Swift
348 lines
12 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
|
|
#if os(iOS)
|
|
import UIKit
|
|
#elseif os(macOS)
|
|
import AppKit
|
|
#endif
|
|
|
|
/// iMessage-style focus mode for one source message and its reply thread.
|
|
@MainActor
|
|
struct ReplyThreadOverlay: View {
|
|
@ObservedObject var model: AppModel
|
|
let rootMessage: ChatMessage
|
|
let replies: [ChatMessage]
|
|
let selectedReplyID: String
|
|
let onDismiss: () -> Void
|
|
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
@State private var isVisible = false
|
|
|
|
var body: some View {
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .topTrailing) {
|
|
Rectangle()
|
|
.fill(.ultraThinMaterial)
|
|
.ignoresSafeArea()
|
|
|
|
Color.black
|
|
.opacity(colorScheme == .dark ? 0.72 : 0.62)
|
|
.ignoresSafeArea()
|
|
.contentShape(Rectangle())
|
|
.onTapGesture(perform: onDismiss)
|
|
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
VStack(spacing: 14) {
|
|
Text(
|
|
rootMessage.timestamp,
|
|
format: .dateTime
|
|
.weekday(.abbreviated)
|
|
.month(.abbreviated)
|
|
.day()
|
|
.hour()
|
|
.minute()
|
|
)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.white.opacity(0.56))
|
|
.padding(.bottom, 2)
|
|
|
|
threadContent(
|
|
maximumBubbleWidth: min(520, geometry.size.width * 0.76)
|
|
)
|
|
}
|
|
.frame(maxWidth: min(760, geometry.size.width))
|
|
.padding(.horizontal, 18)
|
|
.padding(.top, max(58, geometry.safeAreaInsets.top + 30))
|
|
.padding(.bottom, max(34, geometry.safeAreaInsets.bottom + 20))
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
.onAppear {
|
|
isVisible = true
|
|
DispatchQueue.main.async {
|
|
withAnimation(.easeOut(duration: 0.28)) {
|
|
proxy.scrollTo(selectedReplyID, anchor: .center)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Button(action: onDismiss) {
|
|
Image(systemName: "xmark")
|
|
.font(.system(size: 13, weight: .bold))
|
|
.foregroundStyle(.white.opacity(0.82))
|
|
.frame(width: 32, height: 32)
|
|
.background(.white.opacity(0.12), in: Circle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.keyboardShortcut(.cancelAction)
|
|
.accessibilityLabel("Закрыть ветку ответов")
|
|
.padding(.top, max(12, geometry.safeAreaInsets.top + 6))
|
|
.padding(.trailing, 14)
|
|
}
|
|
.opacity(isVisible ? 1 : 0)
|
|
.scaleEffect(isVisible ? 1 : 0.985)
|
|
.animation(.easeOut(duration: 0.22), value: isVisible)
|
|
}
|
|
.transition(.opacity.combined(with: .scale(scale: 0.985)))
|
|
}
|
|
|
|
private func threadContent(maximumBubbleWidth: CGFloat) -> some View {
|
|
let showsBracket = replies.count > 1
|
|
let bracketOnLeadingEdge = rootMessage.direction == .outgoing
|
|
|
|
return VStack(spacing: 9) {
|
|
ReplyThreadMessageRow(
|
|
model: model,
|
|
message: rootMessage,
|
|
maximumBubbleWidth: maximumBubbleWidth,
|
|
isSelected: false,
|
|
showsDeliveryStatus: false
|
|
)
|
|
.id(rootMessage.id)
|
|
|
|
if showsBracket {
|
|
replyCountRow
|
|
}
|
|
|
|
ForEach(replies) { reply in
|
|
ReplyThreadMessageRow(
|
|
model: model,
|
|
message: reply,
|
|
maximumBubbleWidth: maximumBubbleWidth,
|
|
isSelected: reply.clientID == selectedReplyID,
|
|
showsDeliveryStatus: reply.clientID == selectedReplyID
|
|
)
|
|
.id(reply.clientID)
|
|
}
|
|
}
|
|
.padding(.leading, showsBracket && bracketOnLeadingEdge ? 43 : 0)
|
|
.padding(.trailing, showsBracket && !bracketOnLeadingEdge ? 43 : 0)
|
|
.overlay(
|
|
alignment: bracketOnLeadingEdge ? .leading : .trailing
|
|
) {
|
|
if showsBracket {
|
|
ReplyThreadBracket(opensToTrailing: bracketOnLeadingEdge)
|
|
.stroke(
|
|
Color.white.opacity(0.25),
|
|
style: StrokeStyle(lineWidth: 3, lineCap: .round)
|
|
)
|
|
.frame(width: 38)
|
|
.padding(.vertical, 28)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var replyCountRow: some View {
|
|
HStack {
|
|
if rootMessage.direction == .outgoing { Spacer(minLength: 40) }
|
|
Text(replyCountText)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(Color(red: 0.16, green: 0.59, blue: 1))
|
|
.padding(.horizontal, 13)
|
|
if rootMessage.direction == .incoming { Spacer(minLength: 40) }
|
|
}
|
|
}
|
|
|
|
private var replyCountText: String {
|
|
let value = replies.count
|
|
let mod10 = value % 10
|
|
let mod100 = value % 100
|
|
let noun: String
|
|
if mod10 == 1, mod100 != 11 {
|
|
noun = "ответ"
|
|
} else if (2...4).contains(mod10), !(12...14).contains(mod100) {
|
|
noun = "ответа"
|
|
} else {
|
|
noun = "ответов"
|
|
}
|
|
return "\(value) \(noun)"
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private struct ReplyThreadMessageRow: View {
|
|
@ObservedObject var model: AppModel
|
|
let message: ChatMessage
|
|
let maximumBubbleWidth: CGFloat
|
|
let isSelected: Bool
|
|
let showsDeliveryStatus: Bool
|
|
|
|
var body: some View {
|
|
VStack(
|
|
alignment: message.direction == .outgoing ? .trailing : .leading,
|
|
spacing: 4
|
|
) {
|
|
if message.isGroupMessage, message.direction == .incoming {
|
|
Text(message.senderDisplayName ?? model.displayName(for: message.senderJID))
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.white.opacity(0.62))
|
|
.padding(.horizontal, 12)
|
|
}
|
|
|
|
HStack(alignment: .bottom) {
|
|
if message.direction == .outgoing { Spacer(minLength: 44) }
|
|
focusedBubble
|
|
.frame(maxWidth: maximumBubbleWidth, alignment: message.direction == .outgoing ? .trailing : .leading)
|
|
.shadow(
|
|
color: isSelected ? Color.blue.opacity(0.5) : Color.clear,
|
|
radius: isSelected ? 22 : 0,
|
|
y: isSelected ? 8 : 0
|
|
)
|
|
.scaleEffect(isSelected ? 1.018 : 1)
|
|
if message.direction == .incoming { Spacer(minLength: 44) }
|
|
}
|
|
|
|
if showsDeliveryStatus, message.direction == .outgoing {
|
|
Text(deliveryText)
|
|
.font(.caption.weight(.semibold))
|
|
.foregroundStyle(.white.opacity(0.54))
|
|
.padding(.trailing, 13)
|
|
}
|
|
}
|
|
.animation(.spring(response: 0.34, dampingFraction: 0.78), value: isSelected)
|
|
.task(id: message.remoteAttachmentURL) {
|
|
if isVisualMedia {
|
|
await model.prepareMediaPreview(message)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var focusedBubble: some View {
|
|
if isVisualMedia {
|
|
visualMediaBubble
|
|
} else {
|
|
HStack(spacing: 10) {
|
|
if let icon = nonVisualMediaIcon {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 18, weight: .semibold))
|
|
}
|
|
Text(displayText)
|
|
.font(.body)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.leading, message.direction == .incoming ? 17 : 14)
|
|
.padding(.trailing, message.direction == .outgoing ? 17 : 14)
|
|
.padding(.vertical, 11)
|
|
.foregroundStyle(.white)
|
|
.background(focusedBubbleBackground)
|
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
}
|
|
}
|
|
|
|
private var visualMediaBubble: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ZStack {
|
|
Color.white.opacity(0.08)
|
|
mediaThumbnail
|
|
if message.kind != .photo {
|
|
Image(systemName: "play.fill")
|
|
.font(.system(size: 24, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.frame(width: 52, height: 52)
|
|
.background(.black.opacity(0.42), in: Circle())
|
|
}
|
|
}
|
|
.frame(width: min(maximumBubbleWidth, 430), height: 190)
|
|
.clipped()
|
|
|
|
Text(message.previewText)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 15)
|
|
.padding(.vertical, 11)
|
|
}
|
|
.background(focusedBubbleBackground)
|
|
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var mediaThumbnail: some View {
|
|
if let data = model.mediaThumbnail(for: message) {
|
|
#if os(iOS)
|
|
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
|
Image(uiImage: image)
|
|
.resizable()
|
|
.scaledToFill()
|
|
} else {
|
|
mediaPlaceholder
|
|
}
|
|
#elseif os(macOS)
|
|
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
|
Image(nsImage: image)
|
|
.resizable()
|
|
.scaledToFill()
|
|
} else {
|
|
mediaPlaceholder
|
|
}
|
|
#endif
|
|
} else {
|
|
mediaPlaceholder
|
|
}
|
|
}
|
|
|
|
private var mediaPlaceholder: some View {
|
|
Image(systemName: message.kind == .photo ? "photo.fill" : "video.fill")
|
|
.font(.system(size: 42, weight: .medium))
|
|
.foregroundStyle(.white.opacity(0.62))
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var focusedBubbleBackground: some View {
|
|
if message.direction == .outgoing {
|
|
LinearGradient(
|
|
colors: [
|
|
Color(red: 0.12, green: 0.51, blue: 0.96),
|
|
Color(red: 0.10, green: 0.63, blue: 1)
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
} else {
|
|
LinearGradient(
|
|
colors: [Color.white.opacity(0.34), Color.white.opacity(0.23)],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
)
|
|
}
|
|
}
|
|
|
|
private var isVisualMedia: Bool {
|
|
!message.isRetracted
|
|
&& (message.kind == .photo || message.kind == .video || message.kind == .videoNote)
|
|
}
|
|
|
|
private var nonVisualMediaIcon: String? {
|
|
switch message.kind {
|
|
case .attachment: return "doc.fill"
|
|
case .audio: return "music.note"
|
|
case .voice: return "waveform"
|
|
case .location: return "location.fill"
|
|
case .text, .system, .photo, .video, .videoNote: return nil
|
|
}
|
|
}
|
|
|
|
private var displayText: String {
|
|
message.isRetracted ? "Сообщение удалено" : message.body
|
|
}
|
|
|
|
private var deliveryText: String {
|
|
let state: String
|
|
switch message.delivery {
|
|
case .sending: state = "Отправка"
|
|
case .sent: state = "Отправлено"
|
|
case .delivered: state = "Доставлено"
|
|
case .failed: state = "Не отправлено"
|
|
}
|
|
let value = Calendar.current.isDateInToday(message.timestamp)
|
|
? message.timestamp.formatted(date: .omitted, time: .shortened)
|
|
: message.timestamp.formatted(date: .numeric, time: .omitted)
|
|
return "\(state) \(value)"
|
|
}
|
|
}
|