This commit is contained in:
wt committed 2026-07-30 20:07:51 +07:00
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()
}
}