Files
Luma/Sources/Shared/UI/Components/VideoAttachmentPreview.swift
T
wt cbb820a86e
iOS CI / Build and Test SwiftUI App (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build Unsigned iOS IPA (push) Canceled after 0s
Build Unsigned iOS and macOS Apps / Build macOS ZIP (push) Canceled after 0s
migrate persistence from JSON to SwiftData
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.
2026-08-18 22:58:18 +07:00

152 lines
5.3 KiB
Swift

import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
struct VideoAttachmentPreview: View {
@ObservedObject var model: AppModel
let message: ChatMessage
let onFallbackTap: () -> Void
private let islandWidth: CGFloat = 268
var body: some View {
Button(action: openViewer) {
ZStack {
LinearGradient(
colors: [
Color(red: 0.06, green: 0.17, blue: 0.29),
Color(red: 0.09, green: 0.43, blue: 0.64)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
thumbnail
.padding(6)
if model.isMediaPreviewLoading(message) {
ProgressView()
.tint(.white)
.controlSize(.large)
} else {
Image(systemName: "play.fill")
.font(.system(size: 22, weight: .bold))
.foregroundStyle(.white)
.padding(15)
.background(.black.opacity(0.38), in: Circle())
}
VStack {
HStack {
Spacer()
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.caption.weight(.bold))
.foregroundStyle(.white)
.padding(7)
.background(.black.opacity(0.42), in: Circle())
}
Spacer()
HStack(spacing: 6) {
if let filename = message.localFilename {
Text(filename)
.lineLimit(1)
}
Spacer(minLength: 6)
Text(metadataText)
.monospacedDigit()
}
.font(.caption2.weight(.semibold))
.foregroundStyle(.white)
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(.black.opacity(0.48), in: Capsule())
}
.padding(9)
}
.frame(width: islandWidth, height: islandHeight)
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.stroke(Color.secondary.opacity(0.18), lineWidth: 1)
)
.contentShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
}
.buttonStyle(.plain)
.padding(.vertical, 5)
.task(id: message.remoteAttachmentURL) {
await model.prepareMediaPreview(message)
}
.accessibilityElement(children: .ignore)
.accessibilityLabel("Видео, \(metadataText)")
.accessibilityHint("Открывает видео на весь экран")
}
@ViewBuilder
private var thumbnail: 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()
.scaledToFit()
}
#elseif os(macOS)
if let image = ChatMediaImageCache.image(for: message, data: data) {
Image(nsImage: image)
.resizable()
.scaledToFit()
}
#endif
} else {
Image(systemName: "film.fill")
.font(.system(size: 38, weight: .medium))
.foregroundStyle(.white.opacity(0.65))
}
}
private var metadataText: String {
var parts: [String] = []
if let duration = message.duration {
parts.append(MediaTimeFormatter.string(duration))
}
if let byteCount = message.byteCount {
parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file))
}
return parts.isEmpty ? "Видео" : parts.joined(separator: " · ")
}
private var islandHeight: CGFloat {
let contentWidth = islandWidth - 12
let naturalHeight = contentWidth / thumbnailAspectRatio + 12
return min(max(naturalHeight, 164), 250)
}
private var thumbnailAspectRatio: CGFloat {
guard let data = model.mediaThumbnail(for: message) else { return 16 / 9 }
#if os(iOS)
guard let image = ChatMediaImageCache.image(for: message, data: data),
image.size.height > 0 else { return 16 / 9 }
return max(0.7, min(2.2, image.size.width / image.size.height))
#elseif os(macOS)
guard let image = ChatMediaImageCache.image(for: message, data: data),
image.size.height > 0 else { return 16 / 9 }
return max(0.7, min(2.2, image.size.width / image.size.height))
#endif
}
private func openViewer() {
Task {
await model.presentMediaViewer(message)
if model.mediaViewerItem?.id != message.clientID,
message.remoteAttachmentURL == nil {
onFallbackTap()
}
}
}
}