0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -0,0 +1,307 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
struct AudioMessagePlayer: View {
|
||||
@ObservedObject var model: AppModel
|
||||
@ObservedObject private var playback: MediaPlaybackCoordinator
|
||||
let message: ChatMessage
|
||||
let onFallbackTap: () -> Void
|
||||
|
||||
init(
|
||||
model: AppModel,
|
||||
message: ChatMessage,
|
||||
onFallbackTap: @escaping () -> Void
|
||||
) {
|
||||
self.model = model
|
||||
_playback = ObservedObject(wrappedValue: model.audioPlayback)
|
||||
self.message = message
|
||||
self.onFallbackTap = onFallbackTap
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
if message.kind == .voice {
|
||||
voicePlayer
|
||||
} else {
|
||||
musicPlayer
|
||||
}
|
||||
}
|
||||
.frame(width: 252, alignment: .leading)
|
||||
.task(id: message.remoteAttachmentURL) {
|
||||
await model.prepareMediaPreview(message)
|
||||
}
|
||||
.accessibilityElement(children: .contain)
|
||||
}
|
||||
|
||||
private var voicePlayer: some View {
|
||||
HStack(spacing: 10) {
|
||||
playButton
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
WaveformScrubber(
|
||||
samples: model.audioWaveform(for: message),
|
||||
progress: progress,
|
||||
activeColor: accent,
|
||||
inactiveColor: secondaryAccent,
|
||||
onSeek: seek
|
||||
)
|
||||
.frame(height: 30)
|
||||
|
||||
HStack(spacing: 7) {
|
||||
Text(voiceTimeText)
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(secondaryText)
|
||||
Spacer()
|
||||
Button(action: playback.cycleVoiceRate) {
|
||||
Text(rateText)
|
||||
.font(.caption2.monospacedDigit().weight(.bold))
|
||||
.foregroundStyle(accent)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(accent.opacity(0.14), in: Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Скорость воспроизведения \(rateText)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var musicPlayer: some View {
|
||||
HStack(spacing: 11) {
|
||||
playButton
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(message.localFilename ?? "Аудиофайл")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(primaryText)
|
||||
.lineLimit(1)
|
||||
|
||||
MediaProgressScrubber(
|
||||
progress: progress,
|
||||
activeColor: accent,
|
||||
inactiveColor: secondaryAccent,
|
||||
onSeek: seek
|
||||
)
|
||||
.frame(height: 12)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Text(audioTimeText)
|
||||
if let byteCount = message.byteCount {
|
||||
Text("·")
|
||||
Text(ByteCountFormatter.string(
|
||||
fromByteCount: Int64(byteCount),
|
||||
countStyle: .file
|
||||
))
|
||||
}
|
||||
}
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(secondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var playButton: some View {
|
||||
Button(action: togglePlayback) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(accent.opacity(message.direction == .outgoing ? 0.2 : 0.14))
|
||||
if model.isMediaPreviewLoading(message) && localURL == nil {
|
||||
ProgressView()
|
||||
.tint(accent)
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
.foregroundStyle(accent)
|
||||
.offset(x: isPlaying ? 0 : 1)
|
||||
}
|
||||
}
|
||||
.frame(width: 44, height: 44)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(isPlaying ? "Пауза" : "Воспроизвести")
|
||||
}
|
||||
|
||||
private var localURL: URL? {
|
||||
model.mediaPreviewURL(for: message)
|
||||
}
|
||||
|
||||
private var isCurrent: Bool {
|
||||
playback.currentMessageID == message.id
|
||||
}
|
||||
|
||||
private var isPlaying: Bool {
|
||||
isCurrent && playback.isPlaying
|
||||
}
|
||||
|
||||
private var totalDuration: TimeInterval {
|
||||
let value = isCurrent && playback.duration > 0
|
||||
? playback.duration
|
||||
: (message.duration ?? 0)
|
||||
return max(0, value)
|
||||
}
|
||||
|
||||
private var displayedTime: TimeInterval {
|
||||
isCurrent ? playback.currentTime : 0
|
||||
}
|
||||
|
||||
private var progress: Double {
|
||||
guard isCurrent, totalDuration > 0 else { return 0 }
|
||||
return max(0, min(1, playback.currentTime / totalDuration))
|
||||
}
|
||||
|
||||
private var voiceTimeText: String {
|
||||
if isCurrent, playback.currentTime > 0 {
|
||||
return "\(MediaTimeFormatter.string(displayedTime)) / \(MediaTimeFormatter.string(totalDuration))"
|
||||
}
|
||||
return MediaTimeFormatter.string(totalDuration)
|
||||
}
|
||||
|
||||
private var audioTimeText: String {
|
||||
"\(MediaTimeFormatter.string(displayedTime)) / \(MediaTimeFormatter.string(totalDuration))"
|
||||
}
|
||||
|
||||
private var rateText: String {
|
||||
if playback.voiceRate < 1.25 { return "1×" }
|
||||
if playback.voiceRate < 1.75 { return "1.5×" }
|
||||
return "2×"
|
||||
}
|
||||
|
||||
private var accent: Color {
|
||||
message.direction == .outgoing ? .white : .accentColor
|
||||
}
|
||||
|
||||
private var secondaryAccent: Color {
|
||||
message.direction == .outgoing ? .white.opacity(0.30) : Color.secondary.opacity(0.24)
|
||||
}
|
||||
|
||||
private var primaryText: Color {
|
||||
message.direction == .outgoing ? .white : .primary
|
||||
}
|
||||
|
||||
private var secondaryText: Color {
|
||||
message.direction == .outgoing ? .white.opacity(0.78) : .secondary
|
||||
}
|
||||
|
||||
private func togglePlayback() {
|
||||
guard let localURL else {
|
||||
Task {
|
||||
await model.prepareMediaPreview(message)
|
||||
guard let downloadedURL = model.mediaPreviewURL(for: message) else {
|
||||
onFallbackTap()
|
||||
return
|
||||
}
|
||||
playback.toggle(
|
||||
messageID: message.id,
|
||||
url: downloadedURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
playback.toggle(
|
||||
messageID: message.id,
|
||||
url: localURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice
|
||||
)
|
||||
}
|
||||
|
||||
private func seek(_ fraction: Double) {
|
||||
guard let localURL else { return }
|
||||
playback.seek(
|
||||
messageID: message.id,
|
||||
url: localURL,
|
||||
durationHint: message.duration,
|
||||
isVoice: message.kind == .voice,
|
||||
fraction: fraction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private struct WaveformScrubber: View {
|
||||
let samples: [Float]
|
||||
let progress: Double
|
||||
let activeColor: Color
|
||||
let inactiveColor: Color
|
||||
let onSeek: (Double) -> Void
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
HStack(alignment: .center, spacing: 2) {
|
||||
ForEach(Array(samples.enumerated()), id: \.offset) { index, sample in
|
||||
Capsule()
|
||||
.fill(sampleProgress(index) <= progress ? activeColor : inactiveColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: max(3, geometry.size.height * CGFloat(sample)))
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
onSeek(fraction(at: value.location.x, width: geometry.size.width))
|
||||
}
|
||||
)
|
||||
}
|
||||
.accessibilityLabel("Позиция голосового сообщения")
|
||||
.accessibilityValue("\(Int(progress * 100)) процентов")
|
||||
}
|
||||
|
||||
private func sampleProgress(_ index: Int) -> Double {
|
||||
guard samples.count > 1 else { return 0 }
|
||||
return Double(index) / Double(samples.count - 1)
|
||||
}
|
||||
}
|
||||
|
||||
private struct MediaProgressScrubber: View {
|
||||
let progress: Double
|
||||
let activeColor: Color
|
||||
let inactiveColor: Color
|
||||
let onSeek: (Double) -> Void
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(inactiveColor).frame(height: 3)
|
||||
Capsule()
|
||||
.fill(activeColor)
|
||||
.frame(width: geometry.size.width * CGFloat(progress), height: 3)
|
||||
Circle()
|
||||
.fill(activeColor)
|
||||
.frame(width: 9, height: 9)
|
||||
.offset(x: max(0, geometry.size.width * CGFloat(progress) - 4.5))
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { value in
|
||||
onSeek(fraction(at: value.location.x, width: geometry.size.width))
|
||||
}
|
||||
)
|
||||
}
|
||||
.accessibilityLabel("Позиция аудиофайла")
|
||||
.accessibilityValue("\(Int(progress * 100)) процентов")
|
||||
}
|
||||
}
|
||||
|
||||
private func fraction(at x: CGFloat, width: CGFloat) -> Double {
|
||||
guard width > 0 else { return 0 }
|
||||
return max(0, min(1, Double(x / width)))
|
||||
}
|
||||
|
||||
enum MediaTimeFormatter {
|
||||
static func string(_ duration: TimeInterval) -> String {
|
||||
guard duration.isFinite else { return "0:00" }
|
||||
let seconds = max(0, Int(duration.rounded(.down)))
|
||||
if seconds >= 3_600 {
|
||||
return String(format: "%d:%02d:%02d", seconds / 3_600, (seconds / 60) % 60, seconds % 60)
|
||||
}
|
||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
struct AvatarView: View {
|
||||
let conversation: Conversation
|
||||
var imageData: Data?
|
||||
var size: CGFloat = 50
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
avatarContent
|
||||
.frame(width: size, height: size)
|
||||
.clipShape(Circle())
|
||||
|
||||
if isActive {
|
||||
Circle()
|
||||
.fill(.green)
|
||||
.frame(width: size * 0.25, height: size * 0.25)
|
||||
.overlay(Circle().stroke(.background, lineWidth: 2.5))
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(accessibilityText)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var avatarContent: some View {
|
||||
#if os(iOS)
|
||||
if let imageData, let image = UIImage(data: imageData) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let imageData, let image = NSImage(data: imageData) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var fallback: some View {
|
||||
Circle()
|
||||
.fill(
|
||||
LinearGradient(
|
||||
colors: [baseColor, baseColor.opacity(0.68)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
)
|
||||
.overlay {
|
||||
if conversation.isGroup {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.system(size: size * 0.31, weight: .semibold))
|
||||
.foregroundStyle(.white)
|
||||
} else {
|
||||
Text(conversation.initials)
|
||||
.font(.system(size: size * 0.35, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var isActive: Bool {
|
||||
conversation.isGroup ? conversation.isGroupJoined : conversation.isOnline
|
||||
}
|
||||
|
||||
private var accessibilityText: String {
|
||||
if conversation.isGroup {
|
||||
return "\(conversation.displayName), \(conversation.isGroupJoined ? "подключено" : "не подключено")"
|
||||
}
|
||||
return "\(conversation.displayName), \(conversation.isOnline ? "в сети" : "не в сети")"
|
||||
}
|
||||
|
||||
private var baseColor: Color {
|
||||
Color(
|
||||
hue: Double(conversation.colorSeed % 360) / 360,
|
||||
saturation: 0.68,
|
||||
brightness: 0.88
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Short faded curve under the compact source bubble in a collapsed reply.
|
||||
struct ReplyThreadCurve: Shape {
|
||||
let sourceDirection: ChatMessage.Direction
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
var path = Path()
|
||||
if sourceDirection == .incoming {
|
||||
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: rect.maxX, y: rect.maxY),
|
||||
control1: CGPoint(x: rect.minX + 5, y: rect.maxY * 0.78),
|
||||
control2: CGPoint(x: rect.midX, y: rect.maxY)
|
||||
)
|
||||
} else {
|
||||
path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: rect.minX, y: rect.maxY),
|
||||
control1: CGPoint(x: rect.maxX - 5, y: rect.maxY * 0.78),
|
||||
control2: CGPoint(x: rect.midX, y: rect.maxY)
|
||||
)
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
/// Bracket displayed beside a source message and several replies in focus mode.
|
||||
struct ReplyThreadBracket: Shape {
|
||||
let opensToTrailing: Bool
|
||||
|
||||
func path(in rect: CGRect) -> Path {
|
||||
let edgeX = opensToTrailing ? rect.maxX : rect.minX
|
||||
let spineX = opensToTrailing ? rect.minX : rect.maxX
|
||||
let radius = min(26, rect.width)
|
||||
var path = Path()
|
||||
|
||||
path.move(to: CGPoint(x: edgeX, y: rect.minY))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: spineX, y: rect.minY + radius),
|
||||
control1: CGPoint(x: spineX, y: rect.minY),
|
||||
control2: CGPoint(x: spineX, y: rect.minY + radius * 0.45)
|
||||
)
|
||||
path.addLine(to: CGPoint(x: spineX, y: rect.maxY - radius))
|
||||
path.addCurve(
|
||||
to: CGPoint(x: edgeX, y: rect.maxY),
|
||||
control1: CGPoint(x: spineX, y: rect.maxY - radius * 0.45),
|
||||
control2: CGPoint(x: spineX, y: rect.maxY)
|
||||
)
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectionBanner: View {
|
||||
@ObservedObject var model: AppModel
|
||||
|
||||
var body: some View {
|
||||
switch model.connectionStatus {
|
||||
case .connected:
|
||||
if model.isArchiveSyncing || !model.isOMEMOReady {
|
||||
banner(
|
||||
icon: model.isArchiveSyncing ? "arrow.triangle.2.circlepath" : "lock.rotation",
|
||||
text: model.isArchiveSyncing ? "Синхронизация истории…" : "Публикация ключей OMEMO…",
|
||||
color: .blue,
|
||||
progress: true
|
||||
)
|
||||
}
|
||||
case .connecting:
|
||||
banner(icon: "antenna.radiowaves.left.and.right", text: "Подключение…", color: .orange, progress: true)
|
||||
case .disconnected(let reason):
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "wifi.exclamationmark")
|
||||
Text(reason ?? "Нет соединения")
|
||||
.lineLimit(1)
|
||||
Spacer()
|
||||
Button("Повторить") { Task { await model.reconnect() } }
|
||||
.buttonStyle(.borderless)
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(Color.red.opacity(0.88))
|
||||
}
|
||||
}
|
||||
|
||||
private func banner(icon: String, text: String, color: Color, progress: Bool) -> some View {
|
||||
HStack(spacing: 8) {
|
||||
if progress {
|
||||
ProgressView().controlSize(.mini).tint(.white)
|
||||
} else {
|
||||
Image(systemName: icon)
|
||||
}
|
||||
Text(text)
|
||||
Spacer()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 8)
|
||||
.background(color.opacity(0.88))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct ConversationRow: View {
|
||||
let conversation: Conversation
|
||||
let imageData: Data?
|
||||
let isEncrypted: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
AvatarView(conversation: conversation, imageData: imageData)
|
||||
|
||||
VStack(alignment: .leading, spacing: 5) {
|
||||
HStack {
|
||||
Text(conversation.displayName)
|
||||
.font(.headline)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
if conversation.lastActivity != .distantPast {
|
||||
Text(conversation.lastActivity, format: .dateTime.hour().minute())
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(spacing: 7) {
|
||||
if conversation.isGroup {
|
||||
Image(systemName: "person.3.fill")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
} else if !conversation.lastMessage.isEmpty {
|
||||
Image(systemName: isEncrypted ? "lock.fill" : "lock.open")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(isEncrypted ? Color.secondary : Color.orange)
|
||||
}
|
||||
Text(conversation.lastMessage.isEmpty ? conversation.jid : conversation.lastMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
Spacer(minLength: 8)
|
||||
if conversation.isGroup, conversation.occupantCount > 0 {
|
||||
Text("\(conversation.occupantCount)")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
if conversation.unreadCount > 0 {
|
||||
Text("\(min(conversation.unreadCount, 99))")
|
||||
.font(.caption2.bold())
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(.tint, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
struct InlineVideoPlayer: UIViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeUIView(context: Context) -> PlayerView {
|
||||
let view = PlayerView()
|
||||
view.playerLayer.player = player
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: PlayerView, context: Context) {
|
||||
uiView.playerLayer.player = player
|
||||
}
|
||||
|
||||
final class PlayerView: UIView {
|
||||
override class var layerClass: AnyClass { AVPlayerLayer.self }
|
||||
|
||||
var playerLayer: AVPlayerLayer {
|
||||
layer as! AVPlayerLayer
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
playerLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
|
||||
struct InlineVideoPlayer: NSViewRepresentable {
|
||||
let player: AVPlayer
|
||||
|
||||
func makeNSView(context: Context) -> PlayerView {
|
||||
let view = PlayerView()
|
||||
view.playerLayer.player = player
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ nsView: PlayerView, context: Context) {
|
||||
nsView.playerLayer.player = player
|
||||
}
|
||||
|
||||
final class PlayerView: NSView {
|
||||
let playerLayer = AVPlayerLayer()
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
wantsLayer = true
|
||||
layer = playerLayer
|
||||
playerLayer.videoGravity = .resizeAspectFill
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
nil
|
||||
}
|
||||
|
||||
override func layout() {
|
||||
super.layout()
|
||||
playerLayer.frame = bounds
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,63 @@
|
||||
import MapKit
|
||||
import SwiftUI
|
||||
|
||||
struct LocationMessagePreview: View {
|
||||
let location: GeoLocation
|
||||
|
||||
var body: some View {
|
||||
Button(action: openInMaps) {
|
||||
ZStack(alignment: .bottomLeading) {
|
||||
Map(initialPosition: cameraPosition, interactionModes: []) {
|
||||
Marker("Геопозиция", coordinate: coordinate)
|
||||
.tint(.red)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "location.fill")
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("Геопозиция")
|
||||
.font(.subheadline.weight(.semibold))
|
||||
Text("Открыть в Картах")
|
||||
.font(.caption)
|
||||
.opacity(0.82)
|
||||
}
|
||||
}
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 11)
|
||||
.padding(.vertical, 8)
|
||||
.background(.black.opacity(0.58), in: RoundedRectangle(cornerRadius: 11))
|
||||
.padding(9)
|
||||
}
|
||||
.frame(width: 252, height: 170)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.stroke(.primary.opacity(0.1), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Геопозиция")
|
||||
.accessibilityHint("Открывает точку в Картах")
|
||||
}
|
||||
|
||||
private var coordinate: CLLocationCoordinate2D {
|
||||
CLLocationCoordinate2D(latitude: location.latitude, longitude: location.longitude)
|
||||
}
|
||||
|
||||
private var cameraPosition: MapCameraPosition {
|
||||
.region(
|
||||
MKCoordinateRegion(
|
||||
center: coordinate,
|
||||
latitudinalMeters: 1_500,
|
||||
longitudinalMeters: 1_500
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private func openInMaps() {
|
||||
let item = MKMapItem(placemark: MKPlacemark(coordinate: coordinate))
|
||||
item.name = "Геопозиция из Luma"
|
||||
item.openInMaps()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
import AVFoundation
|
||||
import AVKit
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
private typealias MediaViewerPlatformImage = UIImage
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
private typealias MediaViewerPlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
enum MediaViewerDismissGesturePolicy {
|
||||
private static let verticalDominance: CGFloat = 1.15
|
||||
|
||||
static func isVertical(_ translation: CGSize) -> Bool {
|
||||
let verticalDistance = abs(translation.height)
|
||||
let horizontalDistance = abs(translation.width)
|
||||
return verticalDistance >= 8 && verticalDistance > horizontalDistance * verticalDominance
|
||||
}
|
||||
|
||||
static func shouldDismiss(
|
||||
translation: CGSize,
|
||||
predictedEndTranslation: CGSize,
|
||||
containerHeight: CGFloat
|
||||
) -> Bool {
|
||||
guard isVertical(translation) else { return false }
|
||||
|
||||
let distanceThreshold = min(140, max(88, containerHeight * 0.14))
|
||||
if abs(translation.height) >= distanceThreshold {
|
||||
return true
|
||||
}
|
||||
|
||||
return isVertical(predictedEndTranslation)
|
||||
&& abs(predictedEndTranslation.height) >= distanceThreshold * 1.35
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
struct MediaViewer: View {
|
||||
let item: MediaViewerItem
|
||||
let onClose: () -> Void
|
||||
|
||||
@State private var dragOffset: CGFloat = 0
|
||||
@State private var isTrackingDismissGesture = false
|
||||
@State private var isCompletingDismissal = false
|
||||
@State private var isPhotoZoomed = false
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color.black
|
||||
.opacity(backgroundOpacity(in: geometry.size.height))
|
||||
.ignoresSafeArea()
|
||||
|
||||
ZStack {
|
||||
viewerContent
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 12)
|
||||
.padding(.bottom, 8)
|
||||
.background(
|
||||
LinearGradient(
|
||||
colors: [.black.opacity(0.72), .clear],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.offset(y: dragOffset)
|
||||
.scaleEffect(contentScale(in: geometry.size.height))
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.simultaneousGesture(
|
||||
verticalDismissGesture(in: geometry.size),
|
||||
including: .all
|
||||
)
|
||||
.onChange(of: isPhotoZoomed) { _, isZoomed in
|
||||
guard isZoomed else { return }
|
||||
resetDismissGesture(animated: true)
|
||||
}
|
||||
.accessibilityAction(.escape) {
|
||||
closeImmediately()
|
||||
}
|
||||
}
|
||||
#if os(macOS)
|
||||
.onExitCommand {
|
||||
closeImmediately()
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var canTrackDismissGesture: Bool {
|
||||
!isPhotoZoomed && !isCompletingDismissal
|
||||
}
|
||||
|
||||
private func verticalDismissGesture(in size: CGSize) -> some Gesture {
|
||||
DragGesture(minimumDistance: 8, coordinateSpace: .global)
|
||||
.onChanged { value in
|
||||
guard canTrackDismissGesture else { return }
|
||||
|
||||
if !isTrackingDismissGesture {
|
||||
guard MediaViewerDismissGesturePolicy.isVertical(value.translation) else { return }
|
||||
isTrackingDismissGesture = true
|
||||
}
|
||||
|
||||
dragOffset = value.translation.height
|
||||
}
|
||||
.onEnded { value in
|
||||
guard isTrackingDismissGesture else { return }
|
||||
isTrackingDismissGesture = false
|
||||
|
||||
guard canTrackDismissGesture else {
|
||||
resetDismissGesture(animated: true)
|
||||
return
|
||||
}
|
||||
|
||||
if MediaViewerDismissGesturePolicy.shouldDismiss(
|
||||
translation: value.translation,
|
||||
predictedEndTranslation: value.predictedEndTranslation,
|
||||
containerHeight: size.height
|
||||
) {
|
||||
completeDismissal(
|
||||
direction: dismissalDirection(for: value),
|
||||
containerHeight: size.height
|
||||
)
|
||||
} else {
|
||||
resetDismissGesture(animated: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissalDirection(for value: DragGesture.Value) -> CGFloat {
|
||||
let projected = value.predictedEndTranslation.height
|
||||
let distance = abs(projected) > abs(value.translation.height)
|
||||
? projected
|
||||
: value.translation.height
|
||||
return distance < 0 ? -1 : 1
|
||||
}
|
||||
|
||||
private func completeDismissal(direction: CGFloat, containerHeight: CGFloat) {
|
||||
guard !isCompletingDismissal else { return }
|
||||
isCompletingDismissal = true
|
||||
|
||||
withAnimation(.easeOut(duration: 0.18)) {
|
||||
dragOffset = direction * max(700, containerHeight * 1.15)
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 150_000_000)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
private func resetDismissGesture(animated: Bool) {
|
||||
isTrackingDismissGesture = false
|
||||
if animated {
|
||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.84)) {
|
||||
dragOffset = 0
|
||||
}
|
||||
} else {
|
||||
dragOffset = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func closeImmediately() {
|
||||
guard !isCompletingDismissal else { return }
|
||||
isCompletingDismissal = true
|
||||
onClose()
|
||||
}
|
||||
|
||||
private func dismissalProgress(in containerHeight: CGFloat) -> CGFloat {
|
||||
min(1, abs(dragOffset) / max(1, containerHeight * 0.62))
|
||||
}
|
||||
|
||||
private func backgroundOpacity(in containerHeight: CGFloat) -> Double {
|
||||
Double(1 - dismissalProgress(in: containerHeight) * 0.72)
|
||||
}
|
||||
|
||||
private func contentScale(in containerHeight: CGFloat) -> CGFloat {
|
||||
1 - dismissalProgress(in: containerHeight) * 0.045
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var viewerContent: some View {
|
||||
switch item.kind {
|
||||
case .photo:
|
||||
ZoomablePhotoView(url: item.url, isZoomed: $isPhotoZoomed)
|
||||
case .video:
|
||||
FullWindowVideoPlayer(url: item.url)
|
||||
default:
|
||||
ContentUnavailableView(
|
||||
"Не удалось открыть медиа",
|
||||
systemImage: "exclamationmark.triangle"
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct FullWindowVideoPlayer: View {
|
||||
let url: URL
|
||||
@State private var player: AVPlayer?
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VideoPlayer(player: player)
|
||||
.task(id: url) {
|
||||
player?.pause()
|
||||
let nextPlayer = AVPlayer(url: url)
|
||||
player = nextPlayer
|
||||
nextPlayer.play()
|
||||
}
|
||||
.onDisappear {
|
||||
player?.pause()
|
||||
player = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private struct ZoomablePhotoView: View {
|
||||
let url: URL
|
||||
@Binding var isZoomed: Bool
|
||||
|
||||
@State private var imageValue: MediaViewerPlatformImage?
|
||||
@State private var hasFinishedLoading = false
|
||||
@State private var scale: CGFloat = 1
|
||||
@State private var offset: CGSize = .zero
|
||||
@GestureState private var gestureScale: CGFloat = 1
|
||||
@GestureState private var gestureOffset: CGSize = .zero
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
ZStack {
|
||||
Color.black
|
||||
|
||||
if let imageValue {
|
||||
image(imageValue)
|
||||
.scaledToFit()
|
||||
.frame(width: geometry.size.width, height: geometry.size.height)
|
||||
.scaleEffect(effectiveScale)
|
||||
.offset(effectiveOffset)
|
||||
.gesture(dragGesture)
|
||||
.simultaneousGesture(magnifyGesture)
|
||||
.onTapGesture(count: 2, perform: toggleZoom)
|
||||
} else if hasFinishedLoading {
|
||||
invalidImage
|
||||
} else {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.controlSize(.large)
|
||||
}
|
||||
}
|
||||
.clipped()
|
||||
}
|
||||
.onChange(of: effectiveScale) { _, value in
|
||||
isZoomed = value > 1.01
|
||||
}
|
||||
.onDisappear {
|
||||
isZoomed = false
|
||||
}
|
||||
.task(id: url) {
|
||||
imageValue = nil
|
||||
hasFinishedLoading = false
|
||||
let data = await Task.detached(priority: .userInitiated) {
|
||||
try? Data(contentsOf: url, options: [.mappedIfSafe])
|
||||
}.value
|
||||
guard !Task.isCancelled else { return }
|
||||
imageValue = data.flatMap { MediaViewerPlatformImage(data: $0) }
|
||||
hasFinishedLoading = true
|
||||
}
|
||||
}
|
||||
|
||||
private var effectiveScale: CGFloat {
|
||||
min(5, max(1, scale * gestureScale))
|
||||
}
|
||||
|
||||
private var effectiveOffset: CGSize {
|
||||
guard effectiveScale > 1 else { return .zero }
|
||||
return CGSize(
|
||||
width: offset.width + gestureOffset.width,
|
||||
height: offset.height + gestureOffset.height
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func image(_ value: MediaViewerPlatformImage) -> some View {
|
||||
#if os(iOS)
|
||||
Image(uiImage: value)
|
||||
.resizable()
|
||||
#elseif os(macOS)
|
||||
Image(nsImage: value)
|
||||
.resizable()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var invalidImage: some View {
|
||||
ContentUnavailableView(
|
||||
"Фото повреждено",
|
||||
systemImage: "photo.badge.exclamationmark"
|
||||
)
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
|
||||
private var dragGesture: some Gesture {
|
||||
DragGesture()
|
||||
.updating($gestureOffset) { value, state, _ in
|
||||
guard effectiveScale > 1 else { return }
|
||||
state = value.translation
|
||||
}
|
||||
.onEnded { value in
|
||||
guard effectiveScale > 1 else {
|
||||
offset = .zero
|
||||
return
|
||||
}
|
||||
offset.width += value.translation.width
|
||||
offset.height += value.translation.height
|
||||
}
|
||||
}
|
||||
|
||||
private var magnifyGesture: some Gesture {
|
||||
MagnifyGesture()
|
||||
.updating($gestureScale) { value, state, _ in
|
||||
state = value.magnification
|
||||
}
|
||||
.onEnded { value in
|
||||
scale = min(5, max(1, scale * value.magnification))
|
||||
if scale == 1 {
|
||||
offset = .zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func toggleZoom() {
|
||||
withAnimation(.spring(response: 0.28, dampingFraction: 0.82)) {
|
||||
if scale > 1 {
|
||||
scale = 1
|
||||
offset = .zero
|
||||
} else {
|
||||
scale = 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,737 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct MessageBubble: View {
|
||||
let model: AppModel
|
||||
let message: ChatMessage
|
||||
let isSelectionMode: Bool
|
||||
let isSelected: Bool
|
||||
let onAttachmentTap: () -> Void
|
||||
let onEdit: () -> Void
|
||||
let onReply: () -> Void
|
||||
let onForward: () -> Void
|
||||
let onRetry: () -> Void
|
||||
let onToggleSelection: () -> Void
|
||||
let onBeginSelection: () -> Void
|
||||
let onRetract: () -> Void
|
||||
let onDelete: () -> Void
|
||||
let onReplyTap: (String) -> Void
|
||||
let onReact: (String) -> Void
|
||||
|
||||
@State private var replySwipeOffset: CGFloat = 0
|
||||
@State private var replySwipeArmed = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
replySwipeIndicator
|
||||
messageContainer
|
||||
.offset(x: replySwipeOffset)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(isSelected ? Color.accentColor.opacity(0.1) : Color.clear)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard isSelectionMode else { return }
|
||||
onToggleSelection()
|
||||
}
|
||||
.padding(
|
||||
.vertical,
|
||||
message.kind == .photo || message.kind == .video ? 7 : 2
|
||||
)
|
||||
.simultaneousGesture(
|
||||
replySwipeGesture,
|
||||
including: !isSelectionMode && message.canBeRepliedTo ? .gesture : .none
|
||||
)
|
||||
.onChange(of: isSelectionMode) { _, selecting in
|
||||
guard selecting else { return }
|
||||
replySwipeOffset = 0
|
||||
replySwipeArmed = false
|
||||
}
|
||||
.contextMenu {
|
||||
if !isSelectionMode {
|
||||
if message.canBeReactedTo {
|
||||
Menu {
|
||||
ForEach(MessageReactionPolicy.quickChoices, id: \.self) { emoji in
|
||||
Button(emoji) { onReact(emoji) }
|
||||
}
|
||||
} label: {
|
||||
Label("Реакция", systemImage: "face.smiling")
|
||||
}
|
||||
}
|
||||
if message.canBeRepliedTo {
|
||||
Button(action: onReply) {
|
||||
Label("Ответить", systemImage: "arrowshape.turn.up.left")
|
||||
}
|
||||
}
|
||||
if message.canBeForwarded {
|
||||
Button(action: onForward) {
|
||||
Label("Переслать", systemImage: "arrowshape.turn.up.right")
|
||||
}
|
||||
}
|
||||
if model.canRetryMediaMessage(message) {
|
||||
Button(action: onRetry) {
|
||||
Label("Повторить отправку", systemImage: "arrow.clockwise")
|
||||
}
|
||||
}
|
||||
if message.canBeEdited {
|
||||
Button(action: onEdit) {
|
||||
Label("Редактировать", systemImage: "pencil")
|
||||
}
|
||||
}
|
||||
Button(action: onBeginSelection) {
|
||||
Label("Выбрать", systemImage: "checkmark.circle")
|
||||
}
|
||||
Divider()
|
||||
Button(role: .destructive, action: onDelete) {
|
||||
Label("Удалить у меня", systemImage: "trash")
|
||||
}
|
||||
if message.canBeRetracted {
|
||||
Button(role: .destructive, action: onRetract) {
|
||||
Label("Удалить у всех", systemImage: "trash.slash")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel(Text(selectionAccessibilityLabel))
|
||||
.accessibilityAction(named: Text(isSelected ? "Убрать из выбранных" : "Выбрать")) {
|
||||
if isSelectionMode {
|
||||
onToggleSelection()
|
||||
} else {
|
||||
onBeginSelection()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var messageContainer: some View {
|
||||
HStack(spacing: 2) {
|
||||
if isSelectionMode {
|
||||
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
||||
.font(.system(size: 23, weight: .semibold))
|
||||
.foregroundStyle(isSelected ? Color.accentColor : Color.secondary)
|
||||
.frame(width: 34)
|
||||
.padding(.leading, 7)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
|
||||
VStack(spacing: 0) {
|
||||
if hasReplyReference {
|
||||
compactReplyReference
|
||||
}
|
||||
messageRow
|
||||
}
|
||||
.allowsHitTesting(!isSelectionMode)
|
||||
}
|
||||
}
|
||||
|
||||
private var replySwipeIndicator: some View {
|
||||
HStack {
|
||||
if replySwipeOffset >= 0 {
|
||||
replySwipeIcon
|
||||
Spacer()
|
||||
} else {
|
||||
Spacer()
|
||||
replySwipeIcon
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.allowsHitTesting(false)
|
||||
.opacity(replySwipeOffset == 0 ? 0 : 1)
|
||||
}
|
||||
|
||||
private var replySwipeIcon: some View {
|
||||
Image(systemName: replySwipeArmed
|
||||
? "arrowshape.turn.up.left.fill"
|
||||
: "arrowshape.turn.up.left")
|
||||
.font(.system(size: 19, weight: .semibold))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.scaleEffect(
|
||||
CGFloat(0.8) + CGFloat(0.2) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
||||
)
|
||||
.opacity(Double(
|
||||
CGFloat(0.35) + CGFloat(0.65) * MessageReplySwipePolicy.progress(for: replySwipeOffset)
|
||||
))
|
||||
}
|
||||
|
||||
private var replySwipeGesture: some Gesture {
|
||||
DragGesture(minimumDistance: 20)
|
||||
.onChanged { value in
|
||||
let offset = MessageReplySwipePolicy.offset(for: value.translation)
|
||||
guard offset != 0 || replySwipeOffset != 0 else {
|
||||
// A vertical timeline pan must not publish per-cell state
|
||||
// changes or compete with UIScrollView's own recognizer.
|
||||
return
|
||||
}
|
||||
replySwipeOffset = offset
|
||||
let armed = MessageReplySwipePolicy.shouldReply(
|
||||
translation: value.translation,
|
||||
predictedEndTranslation: value.translation
|
||||
)
|
||||
if armed, !replySwipeArmed {
|
||||
replySwipeFeedback()
|
||||
}
|
||||
replySwipeArmed = armed
|
||||
}
|
||||
.onEnded { value in
|
||||
let shouldReply = MessageReplySwipePolicy.shouldReply(
|
||||
translation: value.translation,
|
||||
predictedEndTranslation: value.predictedEndTranslation
|
||||
)
|
||||
if replySwipeOffset != 0 || replySwipeArmed {
|
||||
withAnimation(.spring(response: 0.24, dampingFraction: 0.82)) {
|
||||
replySwipeOffset = 0
|
||||
replySwipeArmed = false
|
||||
}
|
||||
}
|
||||
if shouldReply {
|
||||
onReply()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func replySwipeFeedback() {
|
||||
#if os(iOS)
|
||||
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
||||
#endif
|
||||
}
|
||||
|
||||
private var selectionAccessibilityLabel: String {
|
||||
guard isSelectionMode else { return message.previewText }
|
||||
return "\(message.previewText), \(isSelected ? "выбрано" : "не выбрано")"
|
||||
}
|
||||
|
||||
private var messageRow: some View {
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
if message.direction == .outgoing { Spacer(minLength: sideSpacerMinimum) }
|
||||
|
||||
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(senderColor)
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
messageContent
|
||||
|
||||
if !reactionSummaries.isEmpty {
|
||||
reactionStrip
|
||||
}
|
||||
|
||||
if hasReplyReference {
|
||||
replyWarningIndicators
|
||||
} else {
|
||||
standardMetadata
|
||||
}
|
||||
}
|
||||
|
||||
if message.direction == .incoming { Spacer(minLength: sideSpacerMinimum) }
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard let targetID = message.replyToID,
|
||||
message.kind == .text || message.kind == .system else { return }
|
||||
onReplyTap(targetID)
|
||||
}
|
||||
}
|
||||
|
||||
private var sideSpacerMinimum: CGFloat {
|
||||
message.kind.isMedia ? 16 : 54
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var replyWarningIndicators: some View {
|
||||
if message.security != .omemo || message.delivery == .failed {
|
||||
HStack(spacing: 4) {
|
||||
if message.security != .omemo {
|
||||
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
|
||||
.foregroundStyle(message.security == .plaintext ? .orange : .red)
|
||||
}
|
||||
if message.delivery == .failed {
|
||||
Image(systemName: "exclamationmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private var standardMetadata: some View {
|
||||
HStack(spacing: 4) {
|
||||
if message.callHistory == nil, message.security != .omemo {
|
||||
Image(systemName: message.security == .plaintext ? "lock.open" : "exclamationmark.shield")
|
||||
.foregroundStyle(message.security == .plaintext ? .orange : .red)
|
||||
}
|
||||
if message.editedAt != nil {
|
||||
Text("изм.")
|
||||
}
|
||||
Text(message.timestamp, format: .dateTime.hour().minute())
|
||||
if message.direction == .outgoing, message.callHistory == nil {
|
||||
Image(systemName: deliveryIcon)
|
||||
.foregroundStyle(message.delivery == .failed ? .red : .secondary)
|
||||
}
|
||||
}
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var messageContent: some View {
|
||||
if message.isRetracted {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "trash.slash")
|
||||
Text("Сообщение удалено")
|
||||
.italic()
|
||||
}
|
||||
.font(.subheadline)
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 9)
|
||||
.foregroundStyle(message.direction == .outgoing ? Color.white.opacity(0.86) : Color.secondary)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
} else {
|
||||
regularMessageContent
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var regularMessageContent: some View {
|
||||
switch message.kind {
|
||||
case .videoNote:
|
||||
VideoNotePreview(
|
||||
model: model,
|
||||
message: message,
|
||||
onFallbackTap: onAttachmentTap
|
||||
)
|
||||
case .video:
|
||||
VideoAttachmentPreview(
|
||||
model: model,
|
||||
message: message,
|
||||
onFallbackTap: onAttachmentTap
|
||||
)
|
||||
case .audio, .voice:
|
||||
AudioMessagePlayer(
|
||||
model: model,
|
||||
message: message,
|
||||
onFallbackTap: onAttachmentTap
|
||||
)
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 10)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
case .photo:
|
||||
PhotoAttachmentPreview(
|
||||
model: model,
|
||||
message: message,
|
||||
onFallbackTap: onAttachmentTap
|
||||
)
|
||||
case .location:
|
||||
if let location = GeoLocation(uri: message.body) {
|
||||
LocationMessagePreview(location: location)
|
||||
} else {
|
||||
Text(message.body)
|
||||
.lumaTextSelection()
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 9)
|
||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
}
|
||||
case .attachment:
|
||||
Button(action: onAttachmentTap) {
|
||||
mediaContent
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 9)
|
||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
case .text, .system:
|
||||
if let callHistory = message.callHistory {
|
||||
callHistoryContent(callHistory)
|
||||
} else {
|
||||
Text(message.body)
|
||||
.lumaTextSelection()
|
||||
.padding(.horizontal, 13)
|
||||
.padding(.vertical, 9)
|
||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func callHistoryContent(_ history: CallHistoryMetadata) -> some View {
|
||||
HStack(spacing: 12) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(callAccentColor(for: history).opacity(
|
||||
message.direction == .outgoing ? 0.28 : 0.14
|
||||
))
|
||||
Image(systemName: history.isVideo ? "video.fill" : "phone.fill")
|
||||
.font(.system(size: 17, weight: .semibold))
|
||||
Image(systemName: message.direction == .outgoing ? "arrow.up.right" : "arrow.down.left")
|
||||
.font(.system(size: 8, weight: .heavy))
|
||||
.padding(3)
|
||||
.background(Circle().fill(callDirectionBadgeBackground))
|
||||
.offset(x: 14, y: 14)
|
||||
}
|
||||
.frame(width: 42, height: 42)
|
||||
.foregroundStyle(callIconForeground(for: history))
|
||||
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(message.callTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
if let subtitle = message.callSubtitle {
|
||||
Text(subtitle)
|
||||
.font(.caption)
|
||||
.opacity(0.78)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.frame(maxWidth: 300, alignment: .leading)
|
||||
.foregroundStyle(message.direction == .outgoing ? Color.white : Color.primary)
|
||||
.background(bubbleBackground)
|
||||
.clipShape(messageBubbleShape)
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityLabel(Text(message.callTitle))
|
||||
}
|
||||
|
||||
private func callAccentColor(for history: CallHistoryMetadata) -> Color {
|
||||
switch history.outcome {
|
||||
case .completed:
|
||||
return .green
|
||||
case .declined, .missed, .unanswered, .failed:
|
||||
return .red
|
||||
case .cancelled, .answeredElsewhere:
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private func callIconForeground(for history: CallHistoryMetadata) -> Color {
|
||||
message.direction == .outgoing ? .white : callAccentColor(for: history)
|
||||
}
|
||||
|
||||
private var callDirectionBadgeBackground: Color {
|
||||
message.direction == .outgoing
|
||||
? Color(red: 0.13, green: 0.57, blue: 0.94)
|
||||
: Color.secondary.opacity(0.16)
|
||||
}
|
||||
|
||||
private var reactionSummaries: [MessageReactionSummary] {
|
||||
message.reactionSummaries(ownJID: model.account?.normalizedJID)
|
||||
}
|
||||
|
||||
private var reactionStrip: some View {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(reactionSummaries) { summary in
|
||||
Button {
|
||||
onReact(summary.emoji)
|
||||
} label: {
|
||||
HStack(spacing: 3) {
|
||||
Text(summary.emoji)
|
||||
Text(String(summary.count))
|
||||
.font(.caption2.weight(.semibold))
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 4)
|
||||
.background(
|
||||
summary.includesOwnReaction
|
||||
? Color.accentColor.opacity(0.2)
|
||||
: Color.secondary.opacity(0.12),
|
||||
in: Capsule()
|
||||
)
|
||||
.overlay {
|
||||
Capsule().stroke(
|
||||
summary.includesOwnReaction
|
||||
? Color.accentColor.opacity(0.55)
|
||||
: Color.secondary.opacity(0.16),
|
||||
lineWidth: 0.8
|
||||
)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("\(summary.emoji), реакций: \(summary.count)")
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 4)
|
||||
}
|
||||
|
||||
private var hasReplyReference: Bool {
|
||||
message.replyToID != nil || message.replyPreview != nil
|
||||
}
|
||||
|
||||
private var compactReplyReference: some View {
|
||||
Group {
|
||||
if let targetID = message.replyToID {
|
||||
Button {
|
||||
onReplyTap(targetID)
|
||||
} label: {
|
||||
compactReplyLayout
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Открыть ветку ответа")
|
||||
} else {
|
||||
compactReplyLayout
|
||||
}
|
||||
}
|
||||
.opacity(0.56)
|
||||
.padding(.bottom, -2)
|
||||
.task(id: replyTarget?.remoteAttachmentURL) {
|
||||
if let replyTarget,
|
||||
replyTarget.kind == .photo
|
||||
|| replyTarget.kind == .video
|
||||
|| replyTarget.kind == .videoNote {
|
||||
await model.prepareMediaPreview(replyTarget)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var compactReplyLayout: some View {
|
||||
HStack {
|
||||
if compactTargetDirection == .outgoing {
|
||||
Spacer(minLength: 58)
|
||||
}
|
||||
|
||||
VStack(
|
||||
alignment: compactTargetDirection == .outgoing ? .trailing : .leading,
|
||||
spacing: -1
|
||||
) {
|
||||
compactSourceBubble
|
||||
ReplyThreadCurve(sourceDirection: compactTargetDirection)
|
||||
.stroke(
|
||||
compactReplyColor.opacity(0.72),
|
||||
style: StrokeStyle(lineWidth: 2.4, lineCap: .round)
|
||||
)
|
||||
.frame(width: 58, height: 34)
|
||||
.offset(x: compactTargetDirection == .outgoing ? -18 : 18)
|
||||
}
|
||||
|
||||
if compactTargetDirection == .incoming {
|
||||
Spacer(minLength: 58)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 18)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
|
||||
private var compactSourceBubble: some View {
|
||||
HStack(spacing: 7) {
|
||||
compactReplyThumbnail
|
||||
Text(replyText)
|
||||
.font(.callout.weight(.medium))
|
||||
.foregroundStyle(compactReplyColor)
|
||||
.lineLimit(1)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
.padding(.leading, compactTargetDirection == .incoming ? 12 : 10)
|
||||
.padding(.trailing, compactTargetDirection == .outgoing ? 12 : 10)
|
||||
.padding(.vertical, 7)
|
||||
.frame(maxWidth: 230, minHeight: 34, alignment: .leading)
|
||||
.background(Color.primary.opacity(0.018))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 17, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 17, style: .continuous)
|
||||
.stroke(compactReplyColor.opacity(0.75), lineWidth: 1.25)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var compactReplyThumbnail: some View {
|
||||
if let target = replyTarget, target.kind != .text, target.kind != .system {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.fill(Color.primary.opacity(0.08))
|
||||
replyThumbnailImage(for: target)
|
||||
}
|
||||
.frame(width: 27, height: 27)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func replyThumbnailImage(for target: ChatMessage) -> some View {
|
||||
if let data = model.mediaThumbnail(for: target) {
|
||||
#if os(iOS)
|
||||
if let image = ChatMediaImageCache.image(for: target, data: data) {
|
||||
Image(uiImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
Image(systemName: replyMediaIcon(for: target.kind))
|
||||
.foregroundStyle(compactReplyColor)
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let image = ChatMediaImageCache.image(for: target, data: data) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
Image(systemName: replyMediaIcon(for: target.kind))
|
||||
.foregroundStyle(compactReplyColor)
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
Image(systemName: replyMediaIcon(for: target.kind))
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(compactReplyColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var replyTarget: ChatMessage? {
|
||||
message.replyToID.flatMap { model.message(withID: $0, in: message.conversationID) }
|
||||
}
|
||||
|
||||
private var compactTargetDirection: ChatMessage.Direction {
|
||||
if let direction = replyTarget?.direction { return direction }
|
||||
return message.direction == .outgoing ? .incoming : .outgoing
|
||||
}
|
||||
|
||||
private var compactReplyColor: Color {
|
||||
compactTargetDirection == .outgoing
|
||||
? Color(red: 0.18, green: 0.59, blue: 0.98)
|
||||
: Color.secondary
|
||||
}
|
||||
|
||||
private var replyText: String {
|
||||
replyTarget?.quotePreview ?? message.replyPreview ?? "Исходное сообщение недоступно"
|
||||
}
|
||||
|
||||
private var mediaContent: some View {
|
||||
HStack(spacing: 11) {
|
||||
ZStack {
|
||||
Circle()
|
||||
.fill(.white.opacity(message.direction == .outgoing ? 0.22 : 0.12))
|
||||
.frame(width: message.kind == .videoNote ? 58 : 38, height: message.kind == .videoNote ? 58 : 38)
|
||||
Image(systemName: mediaIcon)
|
||||
.font(.system(size: message.kind == .videoNote ? 24 : 17))
|
||||
}
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(mediaTitle)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.lineLimit(2)
|
||||
Text(mediaSubtitle)
|
||||
.font(.caption)
|
||||
.opacity(0.8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaTitle: String {
|
||||
switch message.kind {
|
||||
case .photo: return "Фото"
|
||||
case .video: return "Видео"
|
||||
case .audio: return message.localFilename ?? "Аудио"
|
||||
case .voice: return "Голосовое сообщение"
|
||||
case .videoNote: return "Видеосообщение"
|
||||
case .attachment: return message.localFilename ?? message.body
|
||||
case .location: return "Геопозиция"
|
||||
case .text, .system: return message.body
|
||||
}
|
||||
}
|
||||
|
||||
private var mediaSubtitle: String {
|
||||
if message.remoteAttachmentURL == nil {
|
||||
return "Подготовка…"
|
||||
}
|
||||
var parts: [String] = []
|
||||
if let duration = message.duration {
|
||||
let seconds = max(0, Int(duration.rounded()))
|
||||
parts.append(String(format: "%d:%02d", seconds / 60, seconds % 60))
|
||||
}
|
||||
if let byteCount = message.byteCount {
|
||||
parts.append(ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file))
|
||||
}
|
||||
return parts.isEmpty ? "Нажмите для просмотра" : parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var mediaIcon: String {
|
||||
switch message.kind {
|
||||
case .attachment: return "doc.fill"
|
||||
case .photo: return "photo.fill"
|
||||
case .video: return "play.rectangle.fill"
|
||||
case .audio: return "music.note"
|
||||
case .voice: return "waveform"
|
||||
case .videoNote: return "video.circle.fill"
|
||||
case .location: return "location.fill"
|
||||
case .text, .system: return "doc.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private func replyMediaIcon(for kind: ChatMessage.Kind) -> String {
|
||||
switch kind {
|
||||
case .photo: return "photo.fill"
|
||||
case .video: return "play.fill"
|
||||
case .audio: return "music.note"
|
||||
case .voice: return "waveform"
|
||||
case .videoNote: return "video.circle.fill"
|
||||
case .attachment: return "doc.fill"
|
||||
case .location: return "location.fill"
|
||||
case .text, .system: return "quote.bubble.fill"
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var bubbleBackground: some View {
|
||||
if message.direction == .outgoing {
|
||||
LinearGradient(
|
||||
colors: [Color(red: 0.13, green: 0.51, blue: 0.94), Color(red: 0.12, green: 0.63, blue: 0.94)],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
} else {
|
||||
Color.secondary.opacity(0.13)
|
||||
}
|
||||
}
|
||||
|
||||
private var messageBubbleShape: RoundedRectangle {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
}
|
||||
|
||||
private var deliveryIcon: String {
|
||||
switch message.delivery {
|
||||
case .sending: return "clock"
|
||||
case .sent: return "checkmark"
|
||||
case .delivered: return "checkmark.circle.fill"
|
||||
case .failed: return "exclamationmark.circle.fill"
|
||||
}
|
||||
}
|
||||
|
||||
private var senderColor: Color {
|
||||
color(for: message.senderJID)
|
||||
}
|
||||
|
||||
private func color(for jid: String) -> Color {
|
||||
let seed = jid.utf8.reduce(UInt64(2_166_136_261)) { value, byte in
|
||||
(value ^ UInt64(byte)) &* 16_777_619
|
||||
}
|
||||
return Color(
|
||||
hue: Double(seed % 360) / 360,
|
||||
saturation: 0.72,
|
||||
brightness: 0.78
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func lumaTextSelection() -> some View {
|
||||
#if os(macOS)
|
||||
textSelection(.enabled)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct PhotoAttachmentPreview: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let message: ChatMessage
|
||||
let onFallbackTap: () -> Void
|
||||
|
||||
private let islandWidth: CGFloat = 268
|
||||
|
||||
var body: some View {
|
||||
Button(action: openViewer) {
|
||||
ZStack {
|
||||
Color.secondary.opacity(0.12)
|
||||
thumbnail
|
||||
.padding(6)
|
||||
|
||||
if model.isMediaPreviewLoading(message) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.controlSize(.large)
|
||||
.padding(15)
|
||||
.background(.black.opacity(0.34), 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(sizeText)
|
||||
.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("Фото, \(sizeText)")
|
||||
.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: "photo.fill")
|
||||
.font(.system(size: 42, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var sizeText: String {
|
||||
guard let byteCount = message.byteCount else { return "Фото" }
|
||||
return ByteCountFormatter.string(fromByteCount: Int64(byteCount), countStyle: .file)
|
||||
}
|
||||
|
||||
private var islandHeight: CGFloat {
|
||||
let contentWidth = islandWidth - 12
|
||||
let naturalHeight = contentWidth / thumbnailAspectRatio + 12
|
||||
return min(max(naturalHeight, 164), 328)
|
||||
}
|
||||
|
||||
private var thumbnailAspectRatio: CGFloat {
|
||||
guard let data = model.mediaThumbnail(for: message) else { return 4 / 3 }
|
||||
#if os(iOS)
|
||||
guard let image = ChatMediaImageCache.image(for: message, data: data),
|
||||
image.size.height > 0 else { return 4 / 3 }
|
||||
return max(0.45, 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 4 / 3 }
|
||||
return max(0.45, min(2.2, image.size.width / image.size.height))
|
||||
#endif
|
||||
}
|
||||
|
||||
private func openViewer() {
|
||||
Task {
|
||||
await model.presentMediaViewer(message)
|
||||
if model.mediaViewerItem?.id != message.id,
|
||||
message.remoteAttachmentURL == nil {
|
||||
onFallbackTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import SwiftUI
|
||||
import WebRTC
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
|
||||
struct RTCVideoRendererView: UIViewRepresentable {
|
||||
let track: RTCVideoTrack?
|
||||
var mirrored = false
|
||||
var contentMode: UIView.ContentMode = .scaleAspectFill
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeUIView(context: Context) -> RTCMTLVideoView {
|
||||
let view = RTCMTLVideoView(frame: .zero)
|
||||
view.videoContentMode = contentMode
|
||||
view.backgroundColor = .black
|
||||
context.coordinator.update(track: track, renderer: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ view: RTCMTLVideoView, context: Context) {
|
||||
view.videoContentMode = contentMode
|
||||
view.transform = mirrored
|
||||
? CGAffineTransform(scaleX: -1, y: 1)
|
||||
: .identity
|
||||
context.coordinator.update(track: track, renderer: view)
|
||||
}
|
||||
|
||||
static func dismantleUIView(_ view: RTCMTLVideoView, coordinator: Coordinator) {
|
||||
coordinator.detach(renderer: view)
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
private weak var track: RTCVideoTrack?
|
||||
|
||||
func update(track newTrack: RTCVideoTrack?, renderer: RTCMTLVideoView) {
|
||||
guard track !== newTrack else { return }
|
||||
track?.remove(renderer)
|
||||
track = newTrack
|
||||
newTrack?.add(renderer)
|
||||
}
|
||||
|
||||
func detach(renderer: RTCMTLVideoView) {
|
||||
track?.remove(renderer)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
|
||||
struct RTCVideoRendererView: NSViewRepresentable {
|
||||
let track: RTCVideoTrack?
|
||||
var mirrored = false
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> RTCMTLNSVideoView {
|
||||
let view = RTCMTLNSVideoView(frame: .zero)
|
||||
view.wantsLayer = true
|
||||
view.layer?.backgroundColor = NSColor.black.cgColor
|
||||
context.coordinator.update(track: track, renderer: view)
|
||||
return view
|
||||
}
|
||||
|
||||
func updateNSView(_ view: RTCMTLNSVideoView, context: Context) {
|
||||
view.layer?.setAffineTransform(
|
||||
mirrored ? CGAffineTransform(scaleX: -1, y: 1) : .identity
|
||||
)
|
||||
context.coordinator.update(track: track, renderer: view)
|
||||
}
|
||||
|
||||
static func dismantleNSView(_ view: RTCMTLNSVideoView, coordinator: Coordinator) {
|
||||
coordinator.detach(renderer: view)
|
||||
}
|
||||
|
||||
final class Coordinator {
|
||||
private weak var track: RTCVideoTrack?
|
||||
|
||||
func update(track newTrack: RTCVideoTrack?, renderer: RTCMTLNSVideoView) {
|
||||
guard track !== newTrack else { return }
|
||||
track?.remove(renderer)
|
||||
track = newTrack
|
||||
newTrack?.add(renderer)
|
||||
}
|
||||
|
||||
func detach(renderer: RTCMTLNSVideoView) {
|
||||
track?.remove(renderer)
|
||||
track = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,347 @@
|
||||
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.id == selectedReplyID,
|
||||
showsDeliveryStatus: reply.id == selectedReplyID
|
||||
)
|
||||
.id(reply.id)
|
||||
}
|
||||
}
|
||||
.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)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
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.id,
|
||||
message.remoteAttachmentURL == nil {
|
||||
onFallbackTap()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import AVFoundation
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
struct VideoNotePreview: View {
|
||||
@ObservedObject var model: AppModel
|
||||
let message: ChatMessage
|
||||
let onFallbackTap: () -> Void
|
||||
|
||||
@State private var player: AVPlayer?
|
||||
@State private var isPlaying = false
|
||||
|
||||
private var diameter: CGFloat { isPlaying ? 176 : 144 }
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
LinearGradient(
|
||||
colors: [
|
||||
Color(red: 0.08, green: 0.30, blue: 0.56),
|
||||
Color(red: 0.07, green: 0.64, blue: 0.83)
|
||||
],
|
||||
startPoint: .topLeading,
|
||||
endPoint: .bottomTrailing
|
||||
)
|
||||
|
||||
thumbnail
|
||||
|
||||
if let player {
|
||||
InlineVideoPlayer(player: player)
|
||||
}
|
||||
|
||||
if previewURL == nil, model.isMediaPreviewLoading(message) {
|
||||
ProgressView()
|
||||
.tint(.white)
|
||||
.controlSize(.large)
|
||||
} else if !isPlaying {
|
||||
Image(systemName: "play.fill")
|
||||
.font(.system(size: 24, weight: .bold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(17)
|
||||
.background(.black.opacity(0.34), in: Circle())
|
||||
}
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(durationText)
|
||||
.font(.caption2.monospacedDigit().weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 3)
|
||||
.background(.black.opacity(0.48), in: Capsule())
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
}
|
||||
.frame(width: diameter, height: diameter)
|
||||
.animation(.spring(response: 0.32, dampingFraction: 0.82), value: isPlaying)
|
||||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(.white.opacity(0.18), lineWidth: 1))
|
||||
.contentShape(Circle())
|
||||
.onTapGesture(perform: togglePlayback)
|
||||
.task(id: message.remoteAttachmentURL) {
|
||||
await model.prepareMediaPreview(message)
|
||||
configurePlayerIfNeeded()
|
||||
}
|
||||
.onChange(of: previewURL) { _, _ in
|
||||
configurePlayerIfNeeded()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .AVPlayerItemDidPlayToEndTime)) { notification in
|
||||
guard let item = notification.object as? AVPlayerItem,
|
||||
item === player?.currentItem else { return }
|
||||
player?.seek(to: .zero)
|
||||
isPlaying = false
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .lumaExclusiveMediaPlayback)) { notification in
|
||||
guard let activeID = notification.object as? String,
|
||||
activeID != message.id else { return }
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
.onDisappear {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
.accessibilityLabel("Видеосообщение, \(durationText)")
|
||||
.accessibilityHint("Нажмите для воспроизведения или паузы")
|
||||
}
|
||||
|
||||
private var previewURL: URL? {
|
||||
model.mediaPreviewURL(for: message)
|
||||
}
|
||||
|
||||
@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()
|
||||
.scaledToFill()
|
||||
}
|
||||
#elseif os(macOS)
|
||||
if let image = ChatMediaImageCache.image(for: message, data: data) {
|
||||
Image(nsImage: image)
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
}
|
||||
#endif
|
||||
} else {
|
||||
Image(systemName: "video.fill")
|
||||
.font(.system(size: 34, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.72))
|
||||
}
|
||||
}
|
||||
|
||||
private var durationText: String {
|
||||
let seconds = max(0, Int((message.duration ?? 0).rounded()))
|
||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
|
||||
private func configurePlayerIfNeeded() {
|
||||
guard player == nil, let previewURL else { return }
|
||||
let player = AVPlayer(url: previewURL)
|
||||
player.actionAtItemEnd = .pause
|
||||
self.player = player
|
||||
}
|
||||
|
||||
private func togglePlayback() {
|
||||
guard previewURL != nil else {
|
||||
onFallbackTap()
|
||||
return
|
||||
}
|
||||
configurePlayerIfNeeded()
|
||||
guard let player else { return }
|
||||
if isPlaying {
|
||||
player.pause()
|
||||
} else {
|
||||
model.audioPlayback.stop()
|
||||
NotificationCenter.default.post(
|
||||
name: .lumaExclusiveMediaPlayback,
|
||||
object: message.id
|
||||
)
|
||||
player.play()
|
||||
}
|
||||
isPlaying.toggle()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user