0.10.6
This commit is contained in:
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 = "Не удалось отправить. Файлы сохранены здесь — проверьте соединение и повторите попытку."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("Выберите чат или начните новый защищённый диалог.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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() } }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user