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,293 @@
import Foundation
import SwiftUI
#if os(iOS)
import UIKit
private typealias DraftThumbnailImage = UIImage
#elseif os(macOS)
import AppKit
private typealias DraftThumbnailImage = NSImage
#endif
@MainActor
struct AttachmentPreviewView: View {
@ObservedObject var model: AppModel
let conversation: Conversation
@Binding var drafts: [AttachmentDraft]
@Environment(\.dismiss) private var dismiss
@State private var caption = ""
@State private var isSending = false
@State private var sendStatusMessage: String?
@State private var previewItem: MediaViewerItem?
@State private var thumbnailImages: [UUID: DraftThumbnailImage] = [:]
private let columns = [GridItem(.adaptive(minimum: 128, maximum: 190), spacing: 18)]
var body: some View {
NavigationStack {
VStack(spacing: 0) {
ScrollView {
LazyVGrid(columns: columns, spacing: 22) {
ForEach(drafts) { draft in
draftCard(draft)
}
}
.padding(16)
}
Divider()
VStack(spacing: 10) {
TextField("Добавить подпись…", text: $caption, axis: .vertical)
.lineLimit(1...4)
.textFieldStyle(.plain)
.padding(.horizontal, 13)
.padding(.vertical, 10)
.background(Color.secondary.opacity(0.1), in: RoundedRectangle(cornerRadius: 16))
.disabled(isSending)
if let sendStatusMessage {
Label(sendStatusMessage, systemImage: "arrow.clockwise.circle.fill")
.font(.caption)
.foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
}
HStack {
Text(summary)
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Button {
send()
} label: {
Label(isSending ? "Отправка…" : "Отправить", systemImage: "arrow.up.circle.fill")
.fontWeight(.semibold)
}
.buttonStyle(.borderedProminent)
.disabled(drafts.isEmpty || isSending)
}
}
.padding(14)
.background(.ultraThinMaterial)
}
.navigationTitle("Перед отправкой")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { cancel() }
.disabled(isSending)
}
}
}
#if os(macOS)
.frame(minWidth: 560, minHeight: 560)
#endif
.interactiveDismissDisabled(isSending || previewItem != nil)
.task(id: drafts.map(\.id)) {
await cacheDraftThumbnails()
}
.overlay {
if let previewItem {
MediaViewer(item: previewItem) {
self.previewItem = nil
}
.zIndex(100)
}
}
.onDisappear {
guard !isSending, !drafts.isEmpty else { return }
model.discardAttachmentDrafts(drafts)
drafts = []
}
}
private func draftCard(_ draft: AttachmentDraft) -> some View {
VStack(alignment: .leading, spacing: 7) {
ZStack(alignment: .topTrailing) {
Button {
presentPreview(for: draft)
} label: {
ZStack {
Color.secondary.opacity(0.1)
thumbnail(for: draft)
.padding(6)
if draft.kind == .video {
Image(systemName: "play.fill")
.font(.title3.weight(.bold))
.foregroundStyle(.white)
.padding(11)
.background(.black.opacity(0.45), in: Circle())
}
}
.frame(maxWidth: .infinity)
.aspectRatio(1, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 15, style: .continuous)
.stroke(Color.secondary.opacity(0.13), lineWidth: 1)
}
}
.buttonStyle(.plain)
.disabled(!canPreview(draft) || isSending)
.accessibilityLabel("Предпросмотр \(draft.filename)")
Button {
remove(draft)
} label: {
Image(systemName: "xmark")
.font(.caption.bold())
.foregroundStyle(.white)
.frame(width: 25, height: 25)
.background(.black.opacity(0.62), in: Circle())
}
.buttonStyle(.plain)
.padding(7)
.accessibilityLabel("Убрать \(draft.filename)")
}
Text(draft.filename)
.font(.caption.weight(.semibold))
.lineLimit(1)
Text(detail(for: draft))
.font(.caption2)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.padding(9)
.background(.background, in: RoundedRectangle(cornerRadius: 19, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 19, style: .continuous)
.stroke(Color.secondary.opacity(0.15), lineWidth: 1)
}
.shadow(color: .black.opacity(0.045), radius: 5, y: 2)
}
@ViewBuilder
private func thumbnail(for draft: AttachmentDraft) -> some View {
#if os(iOS)
if let image = thumbnailImages[draft.id] {
Image(uiImage: image)
.resizable()
.scaledToFit()
} else {
filePlaceholder(for: draft)
}
#elseif os(macOS)
if let image = thumbnailImages[draft.id] {
Image(nsImage: image)
.resizable()
.scaledToFit()
} else {
filePlaceholder(for: draft)
}
#endif
}
private func filePlaceholder(for draft: AttachmentDraft) -> some View {
ZStack {
LinearGradient(
colors: [Color.accentColor.opacity(0.18), Color.secondary.opacity(0.08)],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
Image(systemName: icon(for: draft.kind))
.font(.system(size: 38, weight: .medium))
.foregroundStyle(.tint)
}
}
private var summary: String {
let bytes = drafts.reduce(0) { $0 + $1.byteCount }
return "\(drafts.count) · \(ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file))"
}
private func detail(for draft: AttachmentDraft) -> String {
var values = [ByteCountFormatter.string(fromByteCount: Int64(draft.byteCount), countStyle: .file)]
if let duration = draft.duration {
let seconds = max(0, Int(duration.rounded()))
values.insert(String(format: "%d:%02d", seconds / 60, seconds % 60), at: 0)
}
return values.joined(separator: " · ")
}
private func icon(for kind: ChatMessage.Kind) -> String {
switch kind {
case .photo: return "photo.fill"
case .video: return "video.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 "doc"
}
}
private func remove(_ draft: AttachmentDraft) {
model.discardAttachmentDrafts([draft])
drafts.removeAll { $0.id == draft.id }
thumbnailImages.removeValue(forKey: draft.id)
if drafts.isEmpty { dismiss() }
}
private func cacheDraftThumbnails() async {
let retainedIDs = Set(drafts.map(\.id))
thumbnailImages = thumbnailImages.filter { retainedIDs.contains($0.key) }
for draft in drafts where thumbnailImages[draft.id] == nil {
guard let data = draft.thumbnailData,
let image = DraftThumbnailImage(data: data) else { continue }
thumbnailImages[draft.id] = image
await Task.yield()
}
}
private func canPreview(_ draft: AttachmentDraft) -> Bool {
draft.kind == .photo || draft.kind == .video
}
private func presentPreview(for draft: AttachmentDraft) {
guard canPreview(draft), FileManager.default.fileExists(atPath: draft.url.path) else { return }
previewItem = MediaViewerItem(
id: draft.id.uuidString,
url: draft.url,
kind: draft.kind,
title: draft.filename
)
}
private func cancel() {
model.discardAttachmentDrafts(drafts)
drafts = []
dismiss()
}
private func send() {
guard !isSending, !drafts.isEmpty else { return }
let outgoingDrafts = drafts
sendStatusMessage = nil
isSending = true
Task {
let result = await model.sendAttachmentBatch(
outgoingDrafts,
caption: caption,
to: conversation.jid
)
isSending = false
if result.failedDrafts.isEmpty {
drafts = []
dismiss()
} else {
drafts = result.failedDrafts
if result.sentCount > 0 {
sendStatusMessage = "Отправлено: \(result.sentCount). Не удалось: \(result.failedDrafts.count). Нажмите «Отправить», чтобы повторить оставшиеся."
} else {
sendStatusMessage = "Не удалось отправить. Файлы сохранены здесь — проверьте соединение и повторите попытку."
}
}
}
}
}
+243
View File
@@ -0,0 +1,243 @@
import Foundation
import SwiftUI
import WebRTC
@MainActor
struct CallView: View {
@ObservedObject var model: AppModel
let call: CallSnapshot
private var conversation: Conversation {
model.conversations.first(where: { $0.id == call.peerJID.lowercased() })
?? Conversation(jid: call.peerJID, displayName: model.displayName(for: call.peerJID))
}
private var remoteTrack: RTCVideoTrack? {
model.remoteCallVideoTrack(for: call.id)
}
private var localTrack: RTCVideoTrack? {
model.localCallVideoTrack(for: call.id)
}
var body: some View {
ZStack {
background
VStack(spacing: 24) {
callHeader
.padding(.top, 22)
Spacer(minLength: 12)
if remoteTrack == nil {
contactIdentity
}
Spacer(minLength: 20)
controls
.padding(.bottom, 28)
}
.padding(.horizontal, 24)
if call.isVideoCall, let localTrack, call.isCameraEnabled {
RTCVideoRendererView(track: localTrack, mirrored: true)
.frame(width: 118, height: 168)
.clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(.white.opacity(0.24), lineWidth: 1)
}
.shadow(color: .black.opacity(0.38), radius: 16, y: 8)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
.padding(.top, 92)
.padding(.trailing, 18)
}
}
.foregroundStyle(.white)
.accessibilityElement(children: .contain)
}
@ViewBuilder
private var background: some View {
if call.isVideoCall, let remoteTrack {
RTCVideoRendererView(track: remoteTrack)
.ignoresSafeArea()
.overlay {
LinearGradient(
colors: [
.black.opacity(0.48),
.clear,
.black.opacity(0.62)
],
startPoint: .top,
endPoint: .bottom
)
}
} else {
LinearGradient(
colors: [Color(red: 0.11, green: 0.17, blue: 0.24), .black],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.overlay {
Circle()
.fill(Color.accentColor.opacity(0.24))
.frame(width: 440, height: 440)
.blur(radius: 90)
.offset(x: 170, y: -260)
}
.ignoresSafeArea()
}
}
private var callHeader: some View {
VStack(spacing: 5) {
Text(conversation.displayName)
.font(.headline)
.lineLimit(1)
TimelineView(.periodic(from: .now, by: 1)) { context in
Text(statusText(at: context.date))
.font(.subheadline.monospacedDigit())
.foregroundStyle(.white.opacity(0.72))
}
}
.frame(maxWidth: .infinity)
}
private var contactIdentity: some View {
VStack(spacing: 18) {
AvatarView(
conversation: conversation,
imageData: model.avatarData(for: call.peerJID),
size: 132
)
.overlay {
Circle().stroke(.white.opacity(0.14), lineWidth: 1)
}
.shadow(color: .black.opacity(0.32), radius: 28, y: 14)
Text(conversation.displayName)
.font(.system(size: 30, weight: .semibold, design: .rounded))
.multilineTextAlignment(.center)
.lineLimit(2)
Text(call.isVideoCall ? "Видеозвонок Luma" : "Аудиозвонок Luma")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.66))
}
}
@ViewBuilder
private var controls: some View {
if call.isIncomingRinging {
HStack(spacing: 52) {
CallControlButton(
title: "Отклонить",
systemImage: "phone.down.fill",
color: .red,
action: model.rejectCall
)
CallControlButton(
title: "Ответить",
systemImage: call.isVideoCall ? "video.fill" : "phone.fill",
color: .green
) {
Task { await model.answerCall() }
}
}
} else {
VStack(spacing: 22) {
HStack(spacing: call.isVideoCall ? 10 : 24) {
CallControlButton(
title: call.isMuted ? "Включить" : "Микрофон",
systemImage: call.isMuted ? "mic.slash.fill" : "mic.fill",
color: call.isMuted ? .white.opacity(0.34) : .white.opacity(0.18)
) {
model.setCallMuted(!call.isMuted)
}
#if os(iOS)
CallControlButton(
title: "Динамик",
systemImage: call.isSpeakerEnabled ? "speaker.wave.3.fill" : "speaker.fill",
color: call.isSpeakerEnabled ? .white.opacity(0.34) : .white.opacity(0.18)
) {
model.setCallSpeakerEnabled(!call.isSpeakerEnabled)
}
#endif
if call.isVideoCall {
CallControlButton(
title: call.isCameraEnabled ? "Камера" : "Включить",
systemImage: call.isCameraEnabled ? "video.fill" : "video.slash.fill",
color: call.isCameraEnabled ? .white.opacity(0.18) : .white.opacity(0.34)
) {
model.setCallCameraEnabled(!call.isCameraEnabled)
}
CallControlButton(
title: "Повернуть",
systemImage: "camera.rotate.fill",
color: .white.opacity(0.18),
action: model.switchCallCamera
)
}
}
CallControlButton(
title: "Завершить",
systemImage: "phone.down.fill",
color: .red,
action: model.endCall
)
}
}
}
private func statusText(at date: Date) -> String {
switch call.phase {
case .ringing:
return call.direction == .incoming ? "Входящий звонок" : "Вызов…"
case .connecting:
return "Соединение…"
case .connected:
guard let connectedAt = call.connectedAt else { return "Соединено" }
let total = max(0, Int(date.timeIntervalSince(connectedAt)))
let hours = total / 3_600
let minutes = (total % 3_600) / 60
let seconds = total % 60
return hours > 0
? String(format: "%d:%02d:%02d", hours, minutes, seconds)
: String(format: "%02d:%02d", minutes, seconds)
}
}
}
private struct CallControlButton: View {
let title: String
let systemImage: String
let color: Color
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 58, height: 58)
.overlay {
Image(systemName: systemImage)
.font(.system(size: 24, weight: .semibold))
}
Text(title)
.font(.caption)
.lineLimit(1)
}
.frame(minWidth: 58)
}
.buttonStyle(.plain)
.foregroundStyle(.white)
.accessibilityLabel(title)
}
}
File diff suppressed because it is too large. Load diff
@@ -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()
}
}
@@ -0,0 +1,149 @@
import SwiftUI
struct EncryptionDevicesView: View {
@ObservedObject var model: AppModel
let conversation: Conversation
@Environment(\.dismiss) private var dismiss
@State private var devices: [OMEMODevice] = []
var body: some View {
NavigationStack {
List {
Section {
Label(
statusTitle,
systemImage: statusIcon
)
.foregroundStyle(statusColor)
} footer: {
Text("Luma использует trust-on-first-use. Сверьте отпечаток по другому каналу и отметьте устройство как проверенное.")
}
if devices.isEmpty {
Section("Устройства контакта") {
ContentUnavailableView(
"Ключи ещё не получены",
systemImage: "key.horizontal",
description: Text("Они появятся после первой попытки отправки или получения OMEMO-сообщения.")
)
}
} else {
Section("Устройства контакта") {
ForEach(devices) { device in
DeviceRow(device: device) {
model.setDeviceVerified(
device.trust != .verified,
jid: device.jid,
deviceID: device.deviceID
)
reload()
}
}
}
}
if let fingerprint = model.ownFingerprint {
Section("Это устройство") {
Text(fingerprint.chunked(every: 8).joined(separator: " "))
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
}
}
.navigationTitle("Шифрование")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
}
}
.onAppear(perform: reload)
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 500)
#endif
}
private func reload() {
devices = model.devices(for: conversation.jid).filter { !$0.isOwn }
}
private var statusTitle: String {
guard model.encryptionEnabled(for: conversation.jid) else { return "OMEMO выключено для этого чата" }
return model.isOMEMOReady ? "OMEMO активно" : "OMEMO подготавливается"
}
private var statusIcon: String {
guard model.encryptionEnabled(for: conversation.jid) else { return "lock.open" }
return model.isOMEMOReady ? "lock.shield.fill" : "lock.rotation"
}
private var statusColor: Color {
model.encryptionEnabled(for: conversation.jid) && model.isOMEMOReady ? .green : .orange
}
}
private struct DeviceRow: View {
let device: OMEMODevice
let toggleVerification: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Label("Устройство \(device.deviceID)", systemImage: device.isActive ? "iphone" : "iphone.slash")
.font(.headline)
Spacer()
Button(action: toggleVerification) {
Label(trustTitle, systemImage: trustIcon)
}
.buttonStyle(.bordered)
.tint(trustColor)
}
Text(device.formattedFingerprint)
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
.padding(.vertical, 5)
}
private var trustTitle: String {
switch device.trust {
case .verified: return "Проверено"
case .trusted: return "Доверено"
case .undecided: return "Проверить"
case .compromised: return "Скомпрометировано"
}
}
private var trustIcon: String {
switch device.trust {
case .verified: return "checkmark.seal.fill"
case .trusted: return "checkmark.shield"
case .undecided: return "questionmark.diamond"
case .compromised: return "exclamationmark.triangle.fill"
}
}
private var trustColor: Color {
switch device.trust {
case .verified: return .green
case .trusted: return .blue
case .undecided: return .orange
case .compromised: return .red
}
}
}
private extension String {
func chunked(every size: Int) -> [String] {
guard size > 0 else { return [self] }
var result: [String] = []
var cursor = startIndex
while cursor < endIndex {
let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
result.append(String(self[cursor..<end]))
cursor = end
}
return result
}
}
+137
View File
@@ -0,0 +1,137 @@
import SwiftUI
@MainActor
struct ForwardMessageView: View {
@ObservedObject var model: AppModel
let messages: [ChatMessage]
let onComplete: () -> Void
@Environment(\.dismiss) private var dismiss
@State private var destinationJID = ""
@State private var isForwarding = false
init(
model: AppModel,
messages: [ChatMessage],
onComplete: @escaping () -> Void = {}
) {
_model = ObservedObject(wrappedValue: model)
self.messages = messages.filter(\.canBeForwarded)
self.onComplete = onComplete
}
var body: some View {
NavigationStack {
List {
Section(messages.count == 1 ? "Пересылаемое сообщение" : "Пересылаемые сообщения") {
Label(forwardingSummary, systemImage: "arrowshape.turn.up.right.fill")
.fontWeight(.semibold)
ForEach(Array(messages.prefix(3))) { message in
Text(message.previewText)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
if messages.count > 3 {
Text("И ещё \(messages.count - 3)…")
.font(.caption)
.foregroundStyle(.secondary)
}
}
if !model.conversations.isEmpty {
Section("Чаты") {
ForEach(model.conversations) { conversation in
Button {
forward(to: conversation.jid)
} label: {
HStack(spacing: 11) {
AvatarView(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid),
size: 34
)
VStack(alignment: .leading, spacing: 2) {
Text(conversation.displayName)
.foregroundStyle(.primary)
Text(conversation.jid)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "paperplane.fill")
.foregroundStyle(.tint)
}
}
.buttonStyle(.plain)
.disabled(isForwarding)
}
}
}
Section("Другой адрес") {
TextField("user@example.org", text: $destinationJID)
.disableAutocorrection(true)
Button {
forward(to: destinationJID)
} label: {
Label("Переслать", systemImage: "paperplane.fill")
}
.disabled(
isForwarding
|| destinationJID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
)
}
}
.navigationTitle(messages.count == 1 ? "Переслать" : "Переслать сообщения")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
}
.overlay {
if isForwarding {
ZStack {
Color.black.opacity(0.08).ignoresSafeArea()
ProgressView("Пересылка…")
.padding(18)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
}
}
}
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
}
private func forward(to jid: String) {
guard !isForwarding, !messages.isEmpty else { return }
isForwarding = true
Task {
let succeeded = await model.forwardMessages(messages, to: jid)
isForwarding = false
if succeeded {
onComplete()
dismiss()
}
}
}
private var forwardingSummary: String {
let count = messages.count
let remainder100 = count % 100
let remainder10 = count % 10
let word: String
if (11...14).contains(remainder100) {
word = "сообщений"
} else if remainder10 == 1 {
word = "сообщение"
} else if (2...4).contains(remainder10) {
word = "сообщения"
} else {
word = "сообщений"
}
return "\(count) \(word)"
}
}
+121
View File
@@ -0,0 +1,121 @@
import Foundation
import SwiftUI
@MainActor
struct GroupInfoView: View {
@ObservedObject var model: AppModel
let conversation: Conversation
@Environment(\.dismiss) private var dismiss
@State private var nickname: String
@State private var invitees = ""
@State private var isJoining = false
@State private var isInviting = false
init(model: AppModel, conversation: Conversation) {
self.model = model
self.conversation = conversation
_nickname = State(initialValue: conversation.groupNickname ?? model.suggestedGroupNickname)
}
var body: some View {
NavigationStack {
Form {
Section("Групповой чат") {
LabeledContent("Комната", value: conversation.jid)
LabeledContent("Состояние", value: liveConversation.isGroupJoined ? "Подключено" : "Не подключено")
LabeledContent("Участников", value: "\(liveConversation.occupantCount)")
TextField("Ваш псевдоним", text: $nickname)
.disabled(liveConversation.isGroupJoined)
}
if !liveConversation.isGroupJoined {
Section {
Button {
join()
} label: {
Label(liveConversation.invitedBy == nil ? "Войти в комнату" : "Принять приглашение", systemImage: "person.2.fill")
}
.disabled(nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isJoining)
} footer: {
if let inviter = liveConversation.invitedBy {
Text("Приглашение от \(inviter)")
}
}
} else {
Section("Пригласить участников") {
TextField("user@example.org, ещё@example.org", text: $invitees, axis: .vertical)
.lineLimit(2...4)
Button {
invite()
} label: {
if isInviting {
ProgressView()
} else {
Label("Отправить приглашения", systemImage: "person.badge.plus")
}
}
.disabled(parsedInvitees.isEmpty || isInviting)
}
Section {
Button(role: .destructive) {
model.leaveGroup(jid: conversation.jid)
dismiss()
} label: {
Label("Покинуть комнату", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
Section {
Label(
model.encryptionEnabled(for: conversation.jid)
? "Для комнаты включено OMEMO. Нужны неанонимный MUC и OMEMO-устройства у всех участников."
: "OMEMO для этой комнаты выключено: сообщения передаются открытым текстом внутри TLS.",
systemImage: model.encryptionEnabled(for: conversation.jid) ? "lock.fill" : "lock.open"
)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle(conversation.displayName)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
}
}
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 500)
#endif
}
private var liveConversation: Conversation {
model.conversations.first(where: { $0.id == conversation.id }) ?? conversation
}
private var parsedInvitees: [String] {
invitees.components(separatedBy: CharacterSet(charactersIn: ",;\n \t"))
.filter { !$0.isEmpty }
}
private func join() {
isJoining = true
Task {
await model.joinGroup(jid: conversation.jid, nickname: nickname)
isJoining = false
}
}
private func invite() {
let outgoing = parsedInvitees
guard !outgoing.isEmpty else { return }
isInviting = true
Task {
await model.inviteMembers(outgoing, to: conversation.jid)
invitees = ""
isInviting = false
}
}
}
+148
View File
@@ -0,0 +1,148 @@
import CoreLocation
import Combine
import Foundation
import MapKit
import SwiftUI
@MainActor
struct LocationPickerView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var locationProvider = LocationProvider()
@State private var cameraPosition: MapCameraPosition = .automatic
@State private var selectedCoordinate: CLLocationCoordinate2D?
let onSend: (GeoLocation) -> Void
var body: some View {
NavigationStack {
ZStack {
Map(position: $cameraPosition) {
UserAnnotation()
}
.mapStyle(.standard(elevation: .realistic))
.onMapCameraChange(frequency: .onEnd) { context in
selectedCoordinate = context.region.center
}
Image(systemName: "mappin.circle.fill")
.font(.system(size: 42, weight: .semibold))
.symbolRenderingMode(.palette)
.foregroundStyle(.white, Color.accentColor)
.shadow(color: .black.opacity(0.28), radius: 4, y: 2)
.offset(y: -20)
.allowsHitTesting(false)
VStack {
if let message = locationProvider.errorMessage {
Text(message)
.font(.caption)
.multilineTextAlignment(.center)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.regularMaterial, in: Capsule())
.padding()
}
Spacer()
HStack {
Spacer()
Button(action: centerOnCurrentLocation) {
Group {
if locationProvider.isLocating {
ProgressView()
} else {
Image(systemName: "location.fill")
}
}
.frame(width: 44, height: 44)
.background(.regularMaterial, in: Circle())
.shadow(color: .black.opacity(0.16), radius: 5, y: 2)
}
.buttonStyle(.plain)
.accessibilityLabel("Показать мою геопозицию")
}
.padding(.horizontal, 16)
.padding(.bottom, 10)
}
}
.safeAreaInset(edge: .bottom) {
VStack(spacing: 10) {
Text(coordinateText)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
Button(action: sendSelection) {
Label("Отправить эту геопозицию", systemImage: "location.fill")
.font(.headline)
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(.vertical, 12)
.background(Color.accentColor, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.plain)
.disabled(selectedCoordinate == nil)
}
.padding(16)
.background(.regularMaterial)
}
.navigationTitle("Геопозиция")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
}
}
.onAppear {
locationProvider.requestCurrentLocation()
}
.onReceive(locationProvider.$location.compactMap { $0 }) { location in
selectedCoordinate = location.coordinate
cameraPosition = .region(
MKCoordinateRegion(
center: location.coordinate,
latitudinalMeters: 1_200,
longitudinalMeters: 1_200
)
)
}
}
private var coordinateText: String {
guard let selectedCoordinate else { return "Переместите карту, чтобы выбрать точку" }
return String(
format: "%.6f, %.6f",
locale: Locale(identifier: "en_US_POSIX"),
selectedCoordinate.latitude,
selectedCoordinate.longitude
)
}
private func centerOnCurrentLocation() {
if let location = locationProvider.location {
selectedCoordinate = location.coordinate
cameraPosition = .region(
MKCoordinateRegion(
center: location.coordinate,
latitudinalMeters: 1_200,
longitudinalMeters: 1_200
)
)
} else {
locationProvider.requestCurrentLocation()
}
}
private func sendSelection() {
guard let selectedCoordinate,
let location = GeoLocation(
latitude: selectedCoordinate.latitude,
longitude: selectedCoordinate.longitude
) else { return }
onSend(location)
dismiss()
}
}
+149
View File
@@ -0,0 +1,149 @@
import SwiftUI
struct LoginView: View {
@ObservedObject var model: AppModel
@State private var jid = ""
@State private var password = ""
@State private var displayName = ""
@State private var resource = "Luma"
@State private var showAdvanced = false
@State private var manualHost = ""
@State private var manualPort = ""
@State private var directTLS = false
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.08, green: 0.38, blue: 0.78),
Color(red: 0.12, green: 0.62, blue: 0.96),
Color(red: 0.44, green: 0.84, blue: 0.96)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
Circle()
.fill(.white.opacity(0.12))
.frame(width: 420, height: 420)
.offset(x: -210, y: -260)
ScrollView {
VStack(spacing: 28) {
brand
loginCard
}
.frame(maxWidth: 460)
.padding(.horizontal, 24)
.padding(.vertical, 48)
.frame(maxWidth: .infinity)
}
}
}
private var brand: some View {
VStack(spacing: 14) {
ZStack {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.white)
.frame(width: 88, height: 88)
.shadow(color: .black.opacity(0.16), radius: 22, y: 10)
Image(systemName: "bubble.left.and.bubble.right.fill")
.font(.system(size: 39, weight: .semibold))
.foregroundStyle(
LinearGradient(
colors: [.blue, .cyan],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
}
Text("Luma")
.font(.system(size: 42, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Приватный XMPP без привязки к одному серверу")
.font(.subheadline)
.foregroundStyle(.white.opacity(0.86))
.multilineTextAlignment(.center)
}
}
private var loginCard: some View {
VStack(spacing: 18) {
VStack(alignment: .leading, spacing: 7) {
Text("XMPP-аккаунт")
.font(.headline)
TextField("you@example.org", text: $jid)
.textFieldStyle(.roundedBorder)
.disableAutocorrection(true)
}
VStack(alignment: .leading, spacing: 7) {
Text("Пароль")
.font(.headline)
SecureField("Пароль", text: $password)
.textFieldStyle(.roundedBorder)
}
DisclosureGroup("Дополнительные настройки", isExpanded: $showAdvanced) {
VStack(spacing: 14) {
TextField("Отображаемое имя", text: $displayName)
.textFieldStyle(.roundedBorder)
TextField("Ресурс устройства", text: $resource)
.textFieldStyle(.roundedBorder)
TextField("Сервер вручную (необязательно)", text: $manualHost)
.textFieldStyle(.roundedBorder)
TextField(directTLS ? "Порт, обычно 5223" : "Порт, обычно 5222", text: $manualPort)
.textFieldStyle(.roundedBorder)
Toggle("Direct TLS", isOn: $directTLS)
Text("Без ручного адреса Luma использует DNS SRV и автоматически выбирает STARTTLS/direct TLS.")
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.top, 14)
}
Button {
Task { await signIn() }
} label: {
HStack {
if model.isAuthenticating {
ProgressView().controlSize(.small)
}
Text(model.isAuthenticating ? "Подключение…" : "Войти")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.frame(height: 34)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.disabled(model.isAuthenticating)
Label("Сообщения отправляются с OMEMO. Пароль хранится только в Keychain.", systemImage: "lock.shield.fill")
.font(.caption)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(26)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
.shadow(color: .black.opacity(0.14), radius: 24, y: 12)
}
private func signIn() async {
let port = Int(manualPort.trimmingCharacters(in: .whitespacesAndNewlines))
let account = AccountConfiguration(
jid: jid,
displayName: displayName,
resource: resource,
manualHost: manualHost,
manualPort: port,
usesDirectTLS: directTLS
)
await model.signIn(account: account, password: password)
}
}
+268
View File
@@ -0,0 +1,268 @@
import Foundation
import SwiftUI
struct MainChatView: View {
private enum SidebarMode: String, CaseIterable {
case chats
case contacts
var title: String {
switch self {
case .chats: return "Чаты"
case .contacts: return "Контакты"
}
}
}
@ObservedObject var model: AppModel
@State private var searchText = ""
@State private var sidebarMode: SidebarMode = .chats
@State private var showingNewChat = false
@State private var showingNewGroup = false
@State private var showingSettings = false
private var filteredConversations: [Conversation] {
guard !searchText.isEmpty else { return model.conversations }
return model.conversations.filter(matchesSearch)
}
private var filteredRosterContacts: [Conversation] {
model.conversations
.filter { !$0.isGroup && model.rosterContactJIDs.contains($0.id) }
.filter { searchText.isEmpty || matchesSearch($0) }
.sorted(by: contactSort)
}
private var filteredGroupContacts: [Conversation] {
model.conversations
.filter { $0.isGroup }
.filter { searchText.isEmpty || matchesSearch($0) }
.sorted(by: contactSort)
}
private var hasStoredContacts: Bool {
!model.rosterContactJIDs.isEmpty || model.conversations.contains { $0.isGroup }
}
var body: some View {
NavigationSplitView {
VStack(spacing: 0) {
ConnectionBanner(model: model)
Picker("Раздел", selection: $sidebarMode) {
Label("Чаты", systemImage: "bubble.left.and.bubble.right.fill")
.tag(SidebarMode.chats)
Label("Контакты", systemImage: "person.2.fill")
.tag(SidebarMode.contacts)
}
.pickerStyle(.segmented)
.padding(.horizontal, 12)
.padding(.vertical, 8)
switch sidebarMode {
case .chats:
chatList
case .contacts:
contactsList
}
}
.navigationTitle(sidebarMode.title)
.searchable(
text: $searchText,
prompt: sidebarMode == .chats ? "Поиск чатов" : "Поиск контактов"
)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
Button { showingSettings = true } label: {
Image(systemName: "gearshape")
}
Button { showingNewGroup = true } label: {
Image(systemName: "person.3.fill")
}
.accessibilityLabel("Новый групповой чат")
.help("Новый групповой чат")
Button { showingNewChat = true } label: {
Image(systemName: "square.and.pencil")
}
.accessibilityLabel("Новый личный чат")
.help("Новый личный чат")
}
}
} detail: {
if let conversation = model.selectedConversation {
ChatView(model: model, conversation: conversation)
.id(conversation.id)
} else {
WelcomeDetailView()
}
}
.navigationSplitViewStyle(.balanced)
.onChange(of: model.selectedConversationID) { _, id in
guard let id else { return }
model.selectConversation(id: id)
}
.sheet(isPresented: $showingNewChat) {
NewChatView(model: model)
}
.sheet(isPresented: $showingNewGroup) {
NewGroupView(model: model)
}
.sheet(isPresented: $showingSettings) {
SettingsView(model: model)
}
}
private var chatList: some View {
List(selection: $model.selectedConversationID) {
ForEach(filteredConversations) { conversation in
ConversationRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid),
isEncrypted: model.encryptionEnabled(for: conversation.jid)
)
.tag(conversation.id)
.listRowInsets(EdgeInsets(top: 6, leading: 12, bottom: 6, trailing: 12))
}
}
.listStyle(.plain)
.overlay {
if filteredConversations.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет чатов" : "Ничего не найдено",
systemImage: searchText.isEmpty ? "bubble.left" : "magnifyingglass",
description: Text(
searchText.isEmpty
? "Создайте личный или групповой чат."
: "Попробуйте другой JID или имя."
)
)
}
}
}
private var contactsList: some View {
List(selection: $model.selectedConversationID) {
if !filteredRosterContacts.isEmpty {
Section {
ForEach(filteredRosterContacts) { conversation in
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
.tag(conversation.id)
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
}
} header: {
Label("Люди из roster", systemImage: "person.2.fill")
}
}
if !filteredGroupContacts.isEmpty {
Section {
ForEach(filteredGroupContacts) { conversation in
ContactRow(
conversation: conversation,
imageData: model.avatarData(for: conversation.jid)
)
.tag(conversation.id)
.listRowInsets(EdgeInsets(top: 7, leading: 12, bottom: 7, trailing: 12))
}
} header: {
Label("Групповые чаты", systemImage: "person.3.fill")
}
}
}
.listStyle(.plain)
.overlay {
if filteredRosterContacts.isEmpty && filteredGroupContacts.isEmpty {
ContentUnavailableView(
searchText.isEmpty ? "Нет контактов" : "Ничего не найдено",
systemImage: searchText.isEmpty ? "person.2" : "magnifyingglass",
description: Text(contactListDescription)
)
}
}
}
private var contactListDescription: String {
if !searchText.isEmpty {
return "Попробуйте другой JID или имя."
}
if !hasStoredContacts, model.connectionStatus != .connected {
return "Подключитесь к серверу, чтобы загрузить roster Prosody и групповые комнаты."
}
return "Добавьте человека в roster или создайте групповой чат."
}
private func matchesSearch(_ conversation: Conversation) -> Bool {
conversation.displayName.localizedCaseInsensitiveContains(searchText)
|| conversation.jid.localizedCaseInsensitiveContains(searchText)
}
private func contactSort(_ lhs: Conversation, _ rhs: Conversation) -> Bool {
lhs.displayName.localizedCaseInsensitiveCompare(rhs.displayName) == .orderedAscending
}
}
private struct ContactRow: View {
let conversation: Conversation
let imageData: Data?
var body: some View {
HStack(spacing: 12) {
AvatarView(conversation: conversation, imageData: imageData, size: 42)
VStack(alignment: .leading, spacing: 3) {
HStack(spacing: 6) {
Text(conversation.displayName)
.font(.headline)
.lineLimit(1)
if !conversation.isGroup, conversation.isOnline {
Circle()
.fill(Color.green)
.frame(width: 7, height: 7)
.accessibilityLabel("В сети")
}
}
if conversation.isGroup {
HStack(spacing: 4) {
Image(systemName: "person.3.fill")
Text(groupSubtitle)
}
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text(conversation.jid)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.caption.bold())
.foregroundStyle(.tertiary)
}
.contentShape(Rectangle())
}
private var groupSubtitle: String {
guard conversation.occupantCount > 0 else { return conversation.jid }
return "\(conversation.occupantCount) участн. • \(conversation.jid)"
}
}
private struct WelcomeDetailView: View {
var body: some View {
ZStack {
Color.secondary.opacity(0.04).ignoresSafeArea()
ContentUnavailableView {
Label("Luma", systemImage: "bubble.left.and.bubble.right.fill")
} description: {
Text("Выберите чат или начните новый защищённый диалог.")
}
}
}
}
+47
View File
@@ -0,0 +1,47 @@
import SwiftUI
struct NewChatView: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var jid = ""
@State private var name = ""
@State private var addToRoster = true
var body: some View {
NavigationStack {
Form {
Section("Контакт") {
TextField("user@example.org", text: $jid)
.disableAutocorrection(true)
TextField("Имя (необязательно)", text: $name)
}
Section {
Toggle("Добавить в roster", isOn: $addToRoster)
} footer: {
Text("Начать чат можно с любым полным JID. Для шифрования у контакта должно быть опубликовано хотя бы одно OMEMO-устройство.")
}
}
.navigationTitle("Новый чат")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Начать") {
model.openConversation(
jid: jid,
name: name.isEmpty ? nil : name,
addToRoster: addToRoster
)
if model.errorMessage == nil { dismiss() }
}
.fontWeight(.semibold)
.disabled(jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
}
#if os(macOS)
.frame(minWidth: 380, minHeight: 300)
#endif
}
}
+94
View File
@@ -0,0 +1,94 @@
import Foundation
import SwiftUI
@MainActor
struct NewGroupView: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var roomJID = ""
@State private var name = ""
@State private var nickname = ""
@State private var invitees = ""
@State private var isJoining = false
var body: some View {
NavigationStack {
Form {
GroupBox {
VStack(alignment: .leading, spacing: 12) {
TextField("room@conference.example.org", text: $roomJID)
.disableAutocorrection(true)
TextField("Название (необязательно)", text: $name)
TextField("Ваш псевдоним", text: $nickname)
}
} label: {
Text("Комната")
.font(.headline)
}
GroupBox {
VStack(alignment: .leading, spacing: 8) {
TextField("alice@example.org, bob@example.org", text: $invitees, axis: .vertical)
.lineLimit(2...5)
Text("Укажите JID через запятую или с новой строки. Если комнаты ещё нет, Prosody создаст её с настройками по умолчанию.")
.font(.footnote)
.foregroundStyle(.secondary)
}
} label: {
Text("Пригласить")
.font(.headline)
}
Label(
"Новая комната настраивается как неанонимная и доступная участникам, чтобы поддерживать групповое OMEMO. Режим шифрования можно изменить в меню замка внутри чата.",
systemImage: "lock.shield.fill"
)
.font(.footnote)
.foregroundStyle(.secondary)
}
.navigationTitle("Новая группа")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") { dismiss() }
.disabled(isJoining)
}
ToolbarItem(placement: .confirmationAction) {
Button("Создать / войти") { join() }
.fontWeight(.semibold)
.disabled(!canJoin || isJoining)
}
}
.onAppear {
if nickname.isEmpty { nickname = model.suggestedGroupNickname }
}
}
#if os(macOS)
.frame(minWidth: 480, minHeight: 470)
#endif
}
private var canJoin: Bool {
!roomJID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
private func join() {
isJoining = true
Task {
await model.createOrJoinGroup(
roomJID: roomJID,
name: name.isEmpty ? nil : name,
nickname: nickname,
invitees: parsedInvitees
)
isJoining = false
if model.errorMessage == nil { dismiss() }
}
}
private var parsedInvitees: [String] {
invitees.components(separatedBy: CharacterSet(charactersIn: ",;\n \t"))
.filter { !$0.isEmpty }
}
}
+46
View File
@@ -0,0 +1,46 @@
import QuickLook
import SwiftUI
struct RootView: View {
@ObservedObject var model: AppModel
var body: some View {
ZStack {
Group {
if model.account == nil {
LoginView(model: model)
} else {
MainChatView(model: model)
}
}
if let item = model.mediaViewerItem {
MediaViewer(item: item, onClose: model.closeMediaViewer)
.transition(.opacity.combined(with: .scale(scale: 0.98)))
.zIndex(100)
}
if let call = model.activeCall {
CallView(model: model, call: call)
.transition(.opacity.combined(with: .scale(scale: 1.015)))
.zIndex(200)
}
}
.animation(.easeInOut(duration: 0.22), value: model.account?.id)
.animation(.easeInOut(duration: 0.2), value: model.mediaViewerItem?.id)
.animation(.easeInOut(duration: 0.2), value: model.activeCall?.id)
.alert("Luma", isPresented: errorBinding) {
Button("OK") { model.clearError() }
} message: {
Text(model.errorMessage ?? "Неизвестная ошибка")
}
.quickLookPreview($model.previewURL)
}
private var errorBinding: Binding<Bool> {
Binding(
get: { model.errorMessage != nil },
set: { visible in if !visible { model.clearError() } }
)
}
}
+194
View File
@@ -0,0 +1,194 @@
import PhotosUI
import SwiftUI
struct SettingsView: View {
@ObservedObject var model: AppModel
@Environment(\.dismiss) private var dismiss
@State private var showingSignOutConfirmation = false
@State private var forgetHistory = false
@State private var avatarItem: PhotosPickerItem?
var body: some View {
NavigationStack {
Form {
if let account = model.account {
Section("Профиль") {
HStack(spacing: 14) {
AvatarView(
conversation: Conversation(
jid: account.normalizedJID,
displayName: account.displayName.isEmpty ? nil : account.displayName
),
imageData: model.avatarData(for: account.normalizedJID),
size: 64
)
VStack(alignment: .leading, spacing: 6) {
Text(account.displayName.isEmpty ? account.normalizedJID : account.displayName)
.font(.headline)
PhotosPicker(selection: $avatarItem, matching: .images) {
Label("Обновить аватар", systemImage: "photo.badge.plus")
}
.disabled(model.isUpdatingAvatar)
}
Spacer()
if model.isUpdatingAvatar {
ProgressView()
}
}
}
Section("Аккаунт") {
LabeledContent("JID", value: account.normalizedJID)
LabeledContent("Ресурс", value: account.effectiveResource)
if let host = account.manualHost {
LabeledContent("Сервер", value: "\(host):\(account.manualPort ?? (account.usesDirectTLS ? 5223 : 5222))")
} else {
LabeledContent("Обнаружение", value: "DNS SRV")
}
}
}
Section("Соединение") {
HStack {
Label(connectionTitle, systemImage: connectionIcon)
.foregroundStyle(connectionColor)
Spacer()
if model.connectionStatus != .connected {
Button("Подключить") { Task { await model.reconnect() } }
}
}
LabeledContent("История MAM", value: model.isArchiveSyncing ? "Синхронизация" : "Готово")
}
Section("Шифрование") {
Toggle("OMEMO по умолчанию", isOn: Binding(
get: { model.globalEncryptionEnabled },
set: { model.setGlobalEncryptionEnabled($0) }
))
Text("Для отдельного чата режим можно изменить через значок замка в его панели.")
.font(.caption)
.foregroundStyle(.secondary)
if !model.globalEncryptionEnabled {
Label("Новые сообщения без отдельного правила отправляются открытым текстом.", systemImage: "exclamationmark.triangle.fill")
.font(.caption)
.foregroundStyle(.orange)
}
LabeledContent("Состояние", value: model.isOMEMOReady ? "Активно" : "Подготовка")
if let fingerprint = model.ownFingerprint {
VStack(alignment: .leading, spacing: 7) {
Text("Отпечаток этого устройства")
.font(.subheadline)
Text(fingerprint.chunked(every: 8).joined(separator: " "))
.font(.system(.caption, design: .monospaced))
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
}
}
Section("Уведомления") {
Label("Локальные уведомления включаются после входа", systemImage: "bell.badge")
Text("Для доставки при выгруженном iOS-приложении требуется APNs и XEP-0357 push-шлюз приложения. Обычный XMPP-сокет не может постоянно работать в фоне.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Приватность чата") {
Toggle("Показывать статус набора", isOn: Binding(
get: { model.typingIndicatorsEnabled },
set: { model.setTypingIndicatorsEnabled($0) }
))
Text("Когда включено, Luma показывает «печатает…» и отправляет собеседникам стандартные XMPP-состояния набора.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section {
Toggle("Удалить локальную историю при выходе", isOn: $forgetHistory)
Button(role: .destructive) {
showingSignOutConfirmation = true
} label: {
Label("Выйти из аккаунта", systemImage: "rectangle.portrait.and.arrow.right")
}
}
}
.navigationTitle("Настройки")
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Готово") { dismiss() }
}
}
.confirmationDialog(
"Выйти из Luma?",
isPresented: $showingSignOutConfirmation,
titleVisibility: .visible
) {
Button("Выйти", role: .destructive) {
Task {
await model.signOut(forgetHistory: forgetHistory)
dismiss()
}
}
Button("Отмена", role: .cancel) {}
} message: {
Text(forgetHistory
? "Пароль и локальная история будут удалены. Серверная история MAM не изменится."
: "Пароль будет удалён из Keychain. Серверная история MAM не изменится.")
}
.onChange(of: avatarItem) { _, item in
guard let item else { return }
Task {
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
await model.updateOwnAvatar(from: data)
avatarItem = nil
} catch {
model.errorMessage = error.localizedDescription
}
}
}
}
#if os(macOS)
.frame(minWidth: 500, minHeight: 560)
#endif
}
private var connectionTitle: String {
switch model.connectionStatus {
case .connected: return "Подключено"
case .connecting: return "Подключение"
case .disconnected: return "Отключено"
}
}
private var connectionIcon: String {
switch model.connectionStatus {
case .connected: return "checkmark.circle.fill"
case .connecting: return "arrow.triangle.2.circlepath"
case .disconnected: return "xmark.circle.fill"
}
}
private var connectionColor: Color {
switch model.connectionStatus {
case .connected: return .green
case .connecting: return .orange
case .disconnected: return .red
}
}
}
private extension String {
func chunked(every size: Int) -> [String] {
guard size > 0 else { return [self] }
var result: [String] = []
var cursor = startIndex
while cursor < endIndex {
let end = index(cursor, offsetBy: size, limitedBy: endIndex) ?? endIndex
result.append(String(self[cursor..<end]))
cursor = end
}
return result
}
}
@@ -0,0 +1,159 @@
import Foundation
struct CapturedCameraMedia: Sendable {
let url: URL
let kind: ChatMessage.Kind
}
#if os(iOS)
import SwiftUI
import UniformTypeIdentifiers
import UIKit
@MainActor
struct SystemPhotoCameraView: UIViewControllerRepresentable {
let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
let onCancel: () -> Void
static var isAvailable: Bool {
UIImagePickerController.isSourceTypeAvailable(.camera)
}
func makeCoordinator() -> Coordinator {
Coordinator(onMedia: onMedia, onCancel: onCancel)
}
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.delegate = context.coordinator
picker.sourceType = .camera
picker.mediaTypes = [UTType.image.identifier, UTType.movie.identifier]
picker.cameraCaptureMode = .photo
picker.videoQuality = .typeHigh
picker.videoMaximumDuration = 180
picker.allowsEditing = false
picker.modalPresentationStyle = .fullScreen
return picker
}
func updateUIViewController(
_ uiViewController: UIImagePickerController,
context: Context
) {}
final class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
private let onMedia: (Result<CapturedCameraMedia, Error>) -> Void
private let onCancel: () -> Void
private var completed = false
init(
onMedia: @escaping (Result<CapturedCameraMedia, Error>) -> Void,
onCancel: @escaping () -> Void
) {
self.onMedia = onMedia
self.onCancel = onCancel
}
func imagePickerController(
_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]
) {
guard !completed else { return }
completed = true
let mediaType = (info[.mediaType] as? String).flatMap { UTType($0) }
if mediaType?.conforms(to: .movie) == true,
let sourceURL = info[.mediaURL] as? URL {
prepareMovie(from: sourceURL)
return
}
guard let image = info[.originalImage] as? UIImage else {
onMedia(.failure(SystemCameraError.missingMedia))
return
}
preparePhoto(image)
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
guard !completed else { return }
completed = true
onCancel()
}
private func preparePhoto(_ image: UIImage) {
let completion = onMedia
DispatchQueue.global(qos: .userInitiated).async {
let result: Result<CapturedCameraMedia, Error>
do {
let url = try Self.destinationURL(extension: "jpg")
guard let data = image.jpegData(compressionQuality: 0.9),
!data.isEmpty else {
throw SystemCameraError.encodingFailed
}
try data.write(to: url, options: [.atomic])
result = .success(CapturedCameraMedia(url: url, kind: .photo))
} catch {
result = .failure(error)
}
DispatchQueue.main.async { completion(result) }
}
}
private func prepareMovie(from sourceURL: URL) {
let completion = onMedia
DispatchQueue.global(qos: .userInitiated).async {
let result: Result<CapturedCameraMedia, Error>
do {
let fileExtension = sourceURL.pathExtension.isEmpty
? "mov"
: sourceURL.pathExtension
let destination = try Self.destinationURL(extension: fileExtension)
try FileManager.default.copyItem(at: sourceURL, to: destination)
let values = try destination.resourceValues(
forKeys: [.fileSizeKey, .isRegularFileKey]
)
guard values.isRegularFile == true,
(values.fileSize ?? 0) > 0 else {
try? FileManager.default.removeItem(at: destination)
throw SystemCameraError.emptyMovie
}
result = .success(CapturedCameraMedia(url: destination, kind: .video))
} catch {
result = .failure(error)
}
DispatchQueue.main.async { completion(result) }
}
}
private static func destinationURL(extension fileExtension: String) throws -> URL {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("LumaCameraMedia", isDirectory: true)
try FileManager.default.createDirectory(
at: directory,
withIntermediateDirectories: true
)
return directory.appendingPathComponent(
"camera-\(UUID().uuidString).\(fileExtension)"
)
}
}
}
private enum SystemCameraError: LocalizedError {
case missingMedia
case encodingFailed
case emptyMovie
var errorDescription: String? {
switch self {
case .missingMedia:
return "Камера не вернула фото или видео."
case .encodingFailed:
return "Не удалось подготовить фотографию к отправке."
case .emptyMovie:
return "Камера вернула пустой видеофайл. Повторите запись."
}
}
}
#endif
@@ -0,0 +1,192 @@
import AVFoundation
import SwiftUI
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
@MainActor
struct VideoNoteCaptureView: View {
@Environment(\.dismiss) private var dismiss
@StateObject private var recorder = VideoNoteRecorder()
@State private var errorMessage: String?
@State private var isFinalizing = false
let onComplete: (VideoNoteRecorder.Recording) -> Void
var body: some View {
NavigationStack {
VStack(spacing: 22) {
ZStack {
Circle().fill(Color.black)
CameraPreview(session: recorder.session)
.clipShape(Circle())
if !recorder.isPrepared {
ProgressView("Подготовка камеры…")
.tint(.white)
.foregroundStyle(.white)
}
}
.frame(maxWidth: 420)
.aspectRatio(1, contentMode: .fit)
.overlay(Circle().stroke(.white.opacity(0.16), lineWidth: 1))
Text(recordingStatus)
.font(.system(.title3, design: .monospaced).weight(.semibold))
.foregroundStyle(recorder.isRecording ? .red : .secondary)
Button {
guard recorder.isRecording, !isFinalizing else { return }
isFinalizing = true
Task { @MainActor in
await recorder.stop(
afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration
)
}
} label: {
ZStack {
Circle()
.stroke(Color.red, lineWidth: 4)
.frame(width: 76, height: 76)
if isFinalizing {
ProgressView()
.controlSize(.regular)
.tint(.red)
} else {
RoundedRectangle(cornerRadius: 7)
.fill(Color.red)
.frame(width: 32, height: 32)
}
}
}
.buttonStyle(.plain)
.disabled(!recorder.isRecording || isFinalizing)
.accessibilityLabel(isFinalizing ? "Видео завершается" : "Завершить запись")
Text("Запись началась. Нажмите красную кнопку, чтобы завершить и отправить.")
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
.padding(24)
.navigationTitle("Видеосообщение")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Отмена") {
recorder.cancel()
dismiss()
}
}
}
}
.task {
recorder.onFinished = { result in
switch result {
case .success(let recording):
onComplete(recording)
dismiss()
case .failure(let error):
isFinalizing = false
errorMessage = error.localizedDescription
}
}
do {
try await recorder.prepare()
guard !Task.isCancelled else { return }
try recorder.start()
} catch {
errorMessage = error.localizedDescription
}
}
.onDisappear {
if recorder.isRecording {
recorder.cancel()
} else {
recorder.shutdown()
}
}
.alert("Luma", isPresented: Binding(
get: { errorMessage != nil },
set: { if !$0 { errorMessage = nil } }
)) {
Button("OK") { errorMessage = nil }
} message: {
Text(errorMessage ?? "Неизвестная ошибка")
}
#if os(macOS)
.frame(minWidth: 540, minHeight: 650)
#endif
}
private func formatted(_ duration: TimeInterval) -> String {
let seconds = max(0, Int(duration.rounded(.down)))
return String(format: "%d:%02d", seconds / 60, seconds % 60)
}
private var recordingStatus: String {
if isFinalizing { return "Завершение…" }
if recorder.isRecording { return formatted(recorder.elapsed) }
return "Подготовка камеры…"
}
}
#if os(iOS)
struct CameraPreview: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
view.previewLayer.session = session
view.previewLayer.videoGravity = .resizeAspectFill
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {
uiView.previewLayer.session = session
}
final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer {
layer as! AVCaptureVideoPreviewLayer
}
}
}
#elseif os(macOS)
struct CameraPreview: NSViewRepresentable {
let session: AVCaptureSession
func makeNSView(context: Context) -> PreviewView {
let view = PreviewView()
view.previewLayer.session = session
view.previewLayer.videoGravity = .resizeAspectFill
return view
}
func updateNSView(_ nsView: PreviewView, context: Context) {
nsView.previewLayer.session = session
}
final class PreviewView: NSView {
let previewLayer = AVCaptureVideoPreviewLayer()
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
wantsLayer = true
layer = previewLayer
}
required init?(coder: NSCoder) {
return nil
}
override func layout() {
super.layout()
previewLayer.frame = bounds
}
}
}
#endif